* fix(library): order album detail tracks by disc then track number
Multi-disc album detail ordered tracks by track_number before disc_number, so
Play-All queued them interleaved (D1T1, D2T1, D1T2, D2T2, ...) instead of disc 1
in full, then disc 2. Order by (disc_number, track_number), matching the existing
convention in repos/track.rs. Adds a regression test.
* docs(changelog): note multi-disc album play order fix (#1332)
* fix(library): treat missing disc number as disc 1 in album ordering
Follow-up review. Album track ordering sorted NULL disc numbers last, but the
album UI renders a missing disc as disc 1 (discNumber ?? 1), so an untagged
disc-1 track was shown under Disc 1 while Play-All started with an explicit
disc 2. Order both album-track loaders — the scoped merge query and the direct
find_by_album query — by COALESCE(disc_number, 1), with id as the final
tie-break so equal disc/track metadata is deterministic across both paths.
Extends the regression test with a NULL-disc track and a duplicate disc/track
pair.
* fix(library): make album track order total across servers, cover direct loader
Re-review follow-up. The scoped album loader's `id` tie-break is only
server-local (the track primary key is (server_id, id)), so two cross-server
tracks surviving a merged album could tie on disc/track/id and be returned in an
arbitrary order. Add `server_id ASC` as the final key in both album-track loaders
so the Play-All order is total. Strengthen the direct find_by_album test to cover
the missing-disc (COALESCE) and duplicate-position (id) cases, and add a
multi-server regression asserting the tie resolves deterministically by server_id.
* fix(library): group artist detail releases by type again
The multi-scope artist detail builds albums from tracks and keeps the album raw_json NULL, so the DTO lost the releaseTypes the network getArtist used to provide and the artist page collapsed its Albums / Singles / EPs / Live / Compilations grouping into one flat list. Derive each album release type set from a representative track raw_json.tags.releasetype (Navidrome MusicBrainz RELEASETYPE tag), order preserved, so the existing frontend grouping works again.
* docs(changelog): note artist detail release-type grouping fix (#1329)
* fix(library): also read top-level OpenSubsonic release types
* fix(library): validate artist release-type candidates before precedence
Re-review follow-up. The release-type lookup treated any non-null JSON value
as a candidate before validating it, which had three effects:
- an empty or malformed top-level OpenSubsonic `releaseTypes` (the S2 ingest
copies empty album arrays verbatim) suppressed a valid nested
`tags.releasetype`, leaving the album ungrouped;
- the representative-track subquery stopped at the first merely non-null value
with no ordering, so an unusable track could hide a valid one on the same
album, non-deterministically;
- only "non-empty array" was checked, so non-string members (e.g. `["EP",null]`)
reached ArtistDetail.tsx, where `r.toLowerCase()` throws during render.
Select only a non-empty array of strings, validated per representation before
precedence and before `LIMIT 1`, with a deterministic order; the top-level API
field stays preferred when itself usable, else it falls back to the nested one.
The row now reuses the shared `map_album_list_row` + `album_row_to_dto` mapper
and attaches the validated array, removing the duplicate DTO construction path.
Adds regression coverage for empty top-level, hidden-valid-track, and
non-string members.
* fix(library): guard release-type lookup against malformed track raw_json
Follow-up review finding. `usable_release_types_expr` called SQLite JSON1
functions (json_type/json_each/json_array_length/json_extract) directly on
track.raw_json, which is unconstrained text the library tolerates as invalid
(LibraryTrackDto::from_row maps invalid JSON to Value::Null). Inside the
per-album correlated lookup, one malformed row raised `malformed JSON` and
aborted the whole artist-detail query before a later valid track could win.
Wrap candidate evaluation in a lazy `CASE WHEN json_valid(raw_json)` guard so a
malformed row contributes no release types. Adds a regression test with a
malformed earlier track and a valid later track on one album — it fails with
`malformed JSON` without the guard.
* fix(library): show canonical artist name on artist detail header
Multi-scope artist detail derived the header name from MAX(t.artist) over the artist tracks, so one track carrying a per-track featured-guest credit renamed the whole artist. Read the canonical name from the artist row per (server_id, id) instead, matching the browse list; fall back to the track-derived name when no artist row exists.
* docs(changelog): note artist detail canonical-name fix (#1328)
The chronological New/Latest loaders used the shared 4s request timeout, so a correct-but-slow local read was discarded and the rails stayed empty until a manual toggle. Give them a dedicated generous timeout, decouple the Hero/loading state from them so they patch in on arrival, and re-run the feed when the local index master flag flips.
The sidebar unread-badge check ran loadLocalNewReleases with includeGenreCounts=true, paying a ~3s genre aggregation it never reads on every mount, scope change and poll. Those runs serialize on the single mainstage read connection and delayed the Home New/Latest rails by ~15s. Skip genre counts for the badge and debounce burst refreshes into one trailing run.
capture_invalidated_rank_partitions joined track without a deleted = 0 predicate, so the partial idx_track_album / idx_track_artist indexes were unusable and the rebuild fell back to a full primary-key scan that held the write connection and froze the GUI on large multi-server libraries. Add deleted = 0 to the three CROSS JOIN track clauses so the partial indexes apply.
* fix(api): align native client UA with the WebView to collapse the duplicate Navidrome session
* docs(changelog): duplicate Navidrome session fix (#1322)
* perf(themes): gate inactive injected theme styles out of style matching
Inactive installed themes' <style> elements get media="not all", so the
browser skips their rules during style recalculation entirely; only the
active theme and the scheduler's day/night slots stay live. With many
installed themes every recalc (hover, playing-state flips) walked every
theme's rules. Switching only flips the media attribute in the same
effects flush that sets data-theme - no re-inject, no flash.
* docs(changelog): add 1315 theme style-matching entry
The watcher now ships the sibling manifest.json's name/author/version/
description/mode with each push, so watched themes keep their real
identity instead of dev placeholders and the registry update badge
stays quiet. Freshly seeded themes are marked dev: session-only,
excluded from persistence and from the update check, so a theme-watch
session leaves no trace in the user's installed themes. Both windows
subscribe again since dev themes cannot travel over the cross-window
storage sync; a rehydrate merge keeps them in memory.
Accept a repo root, themes/ dir, or a single theme folder besides a bare
theme.css: every themes/*/theme.css is polled (mtime-gated) and theme
folders added while running are picked up live. A startup sweep installs
each theme without stealing the active selection; a save still applies
live. A theme-watch:ready handshake re-sends loaded contents after
dev-server reloads, dev pushes preserve a store-installed copy's
metadata and grid position, and the watcher only runs in the main
window.
* refactor(playlist): extract resolvePlaylistTracks shared helper
Move the offline- and active-library-scope-aware playlist track resolution out of the Playlists overview Play handler into a reusable helper, so the overview and the playlist context menu share one resolution path instead of diverging.
* feat(playlist): add play and queue actions to the playlist card context menu
Right-clicking a playlist card now offers Play next and Add to queue alongside Play now, matching the album card. All three resolve tracks through resolvePlaylistTracks (offline- and active-library aware), so Play now becomes consistent with the queue actions and works offline. Drops the now-unused playPlaylistById.
* docs(changelog): add playlist card queue actions entry (#1307)
* fix(library): sort albums by the artist the row actually shows (#1217)
The album browse ordered by MAX(t.artist) -- the raw track artist -- while the
row mappers derive the displayed artist with pick_album_group_artist, which
prefers the album artist. On an album with featured guests the two differ
("Alpha feat. Zulu" vs "Alpha"), so the album sorted under a name the user never
sees and fell out of its artist's year run, sometimes landing behind an entirely
different artist.
Order by the same rule the row displays, via a shared SQL expression
(sql_display_artist_from) that both query shapes feed their own columns into:
aggregates for the grouped album browse, projected columns for the multi-library
dedup path.
That dedup path used to build its ORDER BY by string-replacing MAX(t.x) out of
the grouped SQL, which only held while every sort key was a bare aggregate and
would have silently mangled the new expression -- it now builds directly from
its own columns.
Regression test uses a featured-guest album and a second artist that sorts
between the two spellings; it fails against the old expression (album lands
last) and passes with the fix.
* docs(changelog): add entry for PR #1292
* fix(library): bind the album sort to the album aggregates in the scoped browse
The scoped browse (scope_merge) feeds the same ORDER BY into three query shapes.
Two of them GROUP BY t.album_id and selected the sort columns unaliased, so
`artist` / `album_artist` in the ORDER BY resolved to bare table columns -- taken
from an arbitrary row of the group -- rather than to the MAX() aggregates the row
mapper reads. The featured-guest defect (#1217) therefore survived on that path:
sorting could still key on a track credit ("Alpha feat. Zulu") instead of the
album artist.
Alias the sort columns so the ORDER BY binds to the aggregates. Adds a scoped
regression test that fails against the previous expression (featured album lands
behind a different artist) and passes now.
* fix(library): give the scoped GROUP BY browse its own album sort key
Review F1. The scoped browse ran `GROUP BY t.album_id` but received the
dedup shape's ORDER BY, whose display-artist key is a CASE over bare
`artist` / `album_artist`.
SQLite substitutes a result alias into ORDER BY only when the whole term
is a plain identifier: `ORDER BY artist COLLATE NOCASE` does bind to
`MAX(t.artist) AS artist`, but the same name inside a CASE resolves
against `track` instead — and a bare column in a grouped query is read
from an arbitrary row of the group. Aliasing the select list, the first
attempt at this, therefore never fixed the CASE form: an album whose
tracks carry `album_artist` unevenly sorted under whichever row SQLite
happened to pick. Verified both halves against SQLite directly.
Split the parameter: the two `GROUP BY t.album_id` branches now take a
grouped key (aggregates inside the CASE), and only the dedup subquery,
which really does project plain columns, takes the deduped one. The
genre and scope-list callers pass plain-identifier keys, which alias-
resolve correctly in either shape, so they hand the same string to both.
Tests (F2, F3):
- Deterministic guard on the grouped key — no bare column may survive in
it. The behavioural tests can pass by luck when the arbitrary row is a
favourable one; this one cannot.
- Multi-track album with a sparse `album_artist`, the shape the previous
single-track tests could not reach.
- SQL/Rust parity for the aggregate form of the display-artist rule,
over multi-row groups, guarding drift from `pick_album_group_artist`.
* fix(settings): revalidate the registry behind the theme credits
Credits read the theme registry through the plain TTL cache, so a copy
up to 12 hours old was served without ever touching the network. That is
fine for browsing the store, but Credits attributes work to a person: an
author whose handle is corrected upstream stayed mis-credited until the
cache aged out, and Credits has no refresh control of its own.
Add `revalidateRegistry` — stale-while-revalidate. The cached copy paints
immediately (still offline-safe), a forced fetch runs in the background,
and the list updates only when the registry actually changed.
* docs(changelog): note theme credits revalidation fix (#1302)
Modal dialogs now carry an accessible name: the dialog is linked to its title via aria-labelledby, with a per-instance id so several open dialogs cannot collide. Adds a Modal test suite covering the accessible-name contract, id uniqueness, and close behaviour.
* fix(ui): reserve shadow room in album rails so themes need no overflow hack
A horizontal album rail scrolls, so it clips — `overflow-x: auto` makes
`overflow-y` compute to `auto` as well — and an outer card shadow gets
cut off at the edges. The default theme never hit this (rail cards use
inset shadows only), but a theme with a real drop shadow did, and the
only way out was overriding `overflow` on `.album-grid`. That removes
the scroll container the nav arrows drive, so the arrows go dead.
Reserve the room inside the clip box instead: pad the rail and cancel
the padding with a matching negative margin, so the content box stays
exactly where it was. The amount is a `--rail-shadow-room` token, so a
theme with a larger shadow or glow raises that instead of touching
`overflow`.
* docs(changelog): note album rail shadow room fix (#1300)
* feat(discord): allow the server cover source in the store again
DiscordCoverSource gains 'server' back. The rehydrate migration that
forced any persisted 'server' preference to 'none' is dropped — that
coercion only made sense while the source didn't exist.
* feat(discord): resolve server covers via credential-free album-info URLs
Add a resolver that fetches cover art for the Discord 'server' source
through the standard Subsonic getAlbumInfo2 endpoint, never through an
authenticated getCoverArt URL (that was the leak fixed in #1246).
- sanitizeDiscordCoverUrl rejects anything unfit to publish: non-https,
embedded userinfo, auth-shaped query params (u/t/s/apiKey/jwt/token/...),
LAN/loopback hosts
- resolveServerCoverForDiscord takes only an album id and a share-base
string, never a server profile or credentials, and session-caches
results (including negative ones) per album
- rewrites a LAN-scoped album-info response to the profile's public
share address, keeping path + query, so the app being connected over
LAN doesn't hide an otherwise-public /share/img/<jwt> link
- discordPresence.ts wires the 'server' branch in with a staleness
guard so a slow resolve can't overwrite a newer track's presence
* feat(discord): add the server option back to the cover-source picker
Three-way segmented control again: none / server / apple. The cover
description now discloses that server covers reveal the server's
public address to anyone viewing the presence (no credentials).
Restores discordCoverServer in all 14 locales — the Italian locale
already carried an unused copy of the key from before it existed at
removal time, now wired up instead of left dead.
* fix(discord): reject non-publishable image URLs before they reach Discord
is_publishable_image_url is a backstop applied to every artwork_url
(cover_art_url param and the iTunes result alike) right before it can
become a large_image asset: https only, no userinfo, no auth-shaped
query params. This is the layer a future frontend refactor can't
silently bypass — the failure mode that turned the original
credential-free server-cover design (#462) into the leak fixed in
#1246 was exactly that kind of regression, on the frontend only.
* docs(changelog): note Discord server cover source restoration (#1299)
* fix(discord): close the LAN-host gap in the Rust publish backstop
is_publishable_image_url checked scheme/userinfo/credential params but
not host locality, despite its doc comment claiming parity with the TS
sanitizer's isLanUrl check — a self-review found the gap. Reuses
psysonic-core::log_sanitize's existing is_lan_host (now pub) instead
of a second hand-written LAN-detection copy.
* fix(discord): don't silently resurrect a pre-#1246 'server' preference
The rehydrate migration that forced any persisted 'server' cover
source to 'none' was dropped in the initial revival commit, which
meant a user skipping straight from a pre-#1246 build to this one
would have gotten it silently reactivated without ever seeing the new
opt-in disclosure. Restored as a one-time, sentinel-gated migration:
it still catches that stale value exactly once, but never coerces a
deliberate post-revival choice back.
* fix(discord): harden the server-cover sync against races and cross-server tracks
- Staleness guard on the async resolve now also rechecks
discordRichPresence/discordCoverSource, not just the track id, so a
slow resolve can't revive presence after it was disabled or the
cover source was switched away from 'server' while in flight.
- Skip server-cover resolution (fall back to the app icon) when the
playing track isn't from the active server — getAlbumInfo2 always
queries the active server, so a mixed-server queue could otherwise
ask the wrong server for an album id.
- The change-detection gate now also reacts to the active server
profile's share-base changing (e.g. adding a public address), not
only to track/play-state/cover-source/template changes.
- Server profile/shareBase is now computed once per sync and reused,
instead of a second useAuthStore.getState() call inside the branch.
* fix(discord): preserve reverse-proxy paths and TTL the server-cover cache
- rewriteOriginToShareBase only swapped protocol/hostname/port, so a
server reachable behind a reverse-proxy subpath (shareBase carrying
a path prefix) lost that prefix and produced a 404ing URL. The
prefix is now preserved.
- serverCoverCache entries (including negative ones) no longer live
forever — a 1h TTL, matching the existing Rust iTunes-artwork cache,
means a transient resolve failure doesn't hide an album's cover for
the rest of the session.
* fix(discord-banner): give the icon an explicit size so it does not blow up on Windows
The icon carried a viewBox but no width/height, and the .discord-banner-icon
class it uses had no CSS rule at all -- so the SVG had no intrinsic size. WebKit
happened to render it small; Chromium (WebView2, i.e. Windows) fell back to the
300x150 default replaced-element size, so the icon swallowed the bar and pushed
the message out of the row.
Size it explicitly on the element (correct even before the stylesheet applies)
and add the missing rule with flex-shrink so it cannot be squeezed either.
* docs(changelog): add entry for PR #1289
* docs(changelog): place the #1289 entry in PR order
* feat(player-bar): persistent shuffle mode
Shuffle reorders the queue itself -- the tracks after the current one -- and
remembers the order it came from, so switching it off restores that order. The
flag and the remembered order are persisted together: shuffle survives a
restart, so the order it can be undone to has to survive with it.
Keeping the list untouched and only *playing* in a hidden random order was
rejected: "what plays next" is derived from list order in four places (manual
next, gapless successor, chain preload, crossfade/AutoDJ plan), the engine is
handed the next track ~30s ahead with no way to take it back, and the server
play-queue and Orbit guests only ever see the list order -- a hidden permutation
would be invisible to them and lost on any other client.
The toggle lives with the other queue mutations (it pushes an undo snapshot and
syncs to the server like they do); the pure helpers stay in their own module,
free of store imports, so no new import cycle is introduced.
Restoring is id-based and total: duplicate track ids each keep their copy, rows
enqueued while shuffle was on go to the end (they had no original position), and
no row is ever lost or duplicated.
* i18n(player): shuffle on/off state and player bar item label in all 14 locales
* docs(changelog): add entry and credit for PR #1288
* feat(player-bar): configurable stop button, album line and reorderable actions
Extends the existing player-bar layout store instead of adding a second one.
- 'stop' becomes a layout item, so the stop button can be hidden. It lives in a
'transport' zone: visibility only, no reordering -- the play button is a
centred special case and shuffling controls around it would fight the adaptive
small-window layout. Hiding it leaves no dead end, since the primary button
already acts as stop while previewing.
- Track info gains an optional album line under the artist, off by default and
suppressed for radio and previews, which have no album.
- The right-hand action buttons are drag-reorderable via the shared
useListReorderDnd primitive. Moves resolve by stable id against the full list,
so the zone filter that decides which rows render cannot desync the reorder.
- The section is no longer gated behind Advanced.
Rehydrate keeps older stored layouts working: items added later ('stop') are
appended with their default instead of silently disappearing.
* i18n(settings): player bar constructor strings in all 14 locales
* docs(changelog): add entry and credit for PR #1287
* fix(music-network): surface the underlying cause of a connect NETWORK error
NETWORK is the transport catch-all: a DNS failure, a TLS handshake broken by
a proxy or AV, a timeout and an unrecognised provider API error all collapse
into it. The connect form rendered only the translated string, so the cause
never reached the user or a bug report -- the same wire, transport and
token-poll strategy work for one provider and fail for another with no way to
tell why (#1283).
Add errorDetail(), which carries the transport message for NETWORK only
(capped), and append it to the message the connect form shows.
* docs(changelog): add entry for PR #1285
* fix(music-network): make the connect error line selectable so it can be copied
* fix(servers): probe gated servers over native reqwest so custom headers connect
Adding a server behind a header gate (Cloudflare Access, Pangolin service
tokens) failed at the connect step (#1216). The connect probe ran in the
WebView (axios); a non-safelisted header such as Authorization / CF-Access-*
makes the browser send a CORS preflight OPTIONS first, and that preflight
carries no token — so the gate rejects it and the real request never leaves.
Streaming and covers already worked because they go through Rust (reqwest),
which never preflights.
Route the header-bearing connect probe through a new Tauri command
`probe_server_connection` that runs the Subsonic ping over native reqwest with
the per-server header context (endpoints + apply rule), reusing the same
resolver as the data plane. Header-less servers keep the lightweight WebView
path unchanged.
- Rust: add `probe_server_connection` (+ `ServerProbeResult`) in app_api/network,
register in collect_commands!/generate_handler!, regenerate specta bindings.
- Frontend: `pingWithCredentialsForProfile` calls the command when the profile
has custom headers; add `serverHttpContextWireForProbe` helper.
- Tests: SubsonicClient sends the gate header on ping via http_context (and
misses the gated matcher without it); frontend probe routing + WebView
fallback; result mapping unit tests.
* docs(changelog): note gated-server connect fix (#1272)
* fix(servers): keep add form open and surface the reason when connect fails
The add-server flow closed the form the instant you pressed Add and, on
failure, left only a small status dot — so a bad password, a rejected gate
header, or an unreachable host all looked the same ("nothing happened").
- The connect probe now returns a short failure reason (the server's own
error message, an HTTP status, or a transport error) via ServerProbeResult
and the axios fallback; no secrets or header values are included.
- ServersTab keeps the Add form open until the connect test succeeds and
shows the reason in a toast on failure (edit flow surfaces it too).
- AddServerForm validates the server address and username before probing,
with clear per-field messages instead of a silent no-op.
- New i18n keys (serverUrlRequired, serverUsernameRequired,
serverConnectFailedReason) added across all shipped locales.
* fix(servers): route all WebView Subsonic REST through native proxy for gated servers
Once a header-gated server (Cloudflare Access, Pangolin, …) connected, every
view that still fetched over axios in the WebView came up empty — Main stage,
New Releases, Random Albums, Statistics, search, genres — because a
non-safelisted gate header makes the WebView send a CORS preflight the gate
rejects. Only the Rust-routed paths (streaming, covers, ping) worked.
- Add a generic `subsonic_proxy_request` Tauri command backed by
`SubsonicClient::send_raw`: it runs the request natively (no preflight),
applies the per-server gate header via ServerHttpContext, and returns the
untouched JSON body for the WebView to parse as it does an axios response.
Supports GET and form-POST (OpenSubsonic formPost) with a clamped timeout.
- `api`, `apiWithCredentials`, `apiPostFormWithCredentials` (and thus
`apiForServer`/`apiPostFormForServer`) now switch to the native proxy exactly
when a request would carry gate headers; header-less servers keep the
lightweight WebView axios path unchanged.
- Tests: wiremock coverage for `send_raw` (GET gate header + form-POST body);
frontend routing tests that gated `api()` calls hit the proxy and header-less
ones stay on axios; updated the OpenSubsonic extensions test for the new path.
* fix(servers): apply gate header to media paths via URL fallback
Streaming, prefetch and artist-info fetches returned 403 on a gated
server: apply_for_http_url resolved custom headers strictly by
server_ref and attached nothing when the caller's ref (e.g. the audio
engine's playback server id) didn't match the registry key. Add a
resolve_context helper that falls back to matching the request URL
against the server's registered endpoints — the same fallback covers
and Navidrome browse already relied on — and route the audio engine,
apply_for_http_url and apply_optional_registry_headers through it.
Endpoint matching only hits a configured gated server and still honours
the apply-to LAN/public rule, so non-gated servers stay untouched.
Also pool the native proxy's reqwest clients by timeout bucket so the
extra gated-server browse traffic reuses keep-alive connections instead
of opening a fresh pool per request (which surfaced as spurious
timeouts / 499s on fast endpoints).
* fix(servers): register gate headers before probe/bind so native paths work
Root cause of the persistent 403s behind a header gate: the native
ServerHttpRegistry was populated only at the end of bindIndexedServer,
after ensureConnectUrlResolved + the bind session. When that probe was
slow, hung, or reported the server briefly offline, the sync never ran,
leaving the registry empty — so every Rust-initiated request (stream,
cover, prefetch, fanart getArtistInfo2, Navidrome /auth/login) resolved
no header and the gate returned 403. The inline-context proxy path kept
working, which is why browse succeeded while media/covers failed.
Register the header context up front: before the reachability probe in
bindIndexedServer, and authoritatively for all servers at the start of
bootstrapAllIndexedServers (once React has mounted and IPC is ready).
Add concise sync/sync_all diagnostics (endpoint URLs + header count,
never values) so gated-server registration is observable in logs.
* fix(servers): retry gated-server covers that 403'd during header-registry gap
Covers fetched while the native gate-header registry was momentarily empty
(e.g. a dev restart before the header sync landed) got a 403, which the cover
fetcher classified as a permanent "cover missing" and wrote a 30-minute
`.fetch-failed` marker for — so on a gated server most artwork stayed blank
long after the gate started answering 200.
- Treat a gate-style 401/403 (plus 408/425/429) on cover art as a transient,
retryable hiccup rather than a permanent miss, so the short retry loop can
ride out a brief registry gap. Genuine 404/410/400 stay permanent.
- On (re)bind of a gated server, once its header is registered, clear that
server's stale `.fetch-failed` markers and kick a backfill pass so the
covers re-download — same rationale as the URL-change retry.
* refactor(servers): funnel gate-header application through one helper
Collapse the remaining bespoke header-application variants onto the single
`apply_optional_registry_headers` / `resolve_context` entry point so gated-server
header logic lives in exactly one place:
- cover_cache fetch and Navidrome `/auth/login` used older
`apply_for_http_url` / `get_for_server_url` + `apply_server_headers_for_http_url`
combos; both now call the shared helper.
- The audio engine's `apply_playback_request_headers` delegates to it instead of
re-implementing the resolve+apply.
- Drop the now-unused `ServerHttpRegistry::apply_for_http_url` /
`apply_for_base_url` methods.
Behaviour is unchanged: a non-gated server (no registry match) leaves every
request untouched, exactly as before. This is purely removing duplicate ways to
do the same thing so new native paths have one obvious hook.
* chore(servers): drop server-http registry debug logging
Remove the `[server-http] sync/sync_all` eprintln diagnostics added while
tracing the header-registry timing bug. They printed to stderr on every sync
(startup + each persist-rehydrate) and echoed endpoint URLs; the behaviour is
verified and covered by tests now, so the noise is no longer warranted. The
dev-gated `[connect-probe]`/`[subsonic-proxy]` failure logs stay.
* docs(changelog): point gated-server entry at PR #1273
* fix(servers): await gate-header sync before probe/bind
bindIndexedServer registered the per-server gate headers with a fire-and-forget
`void syncServerHttpContextForProfile(...)`, so a direct bind (add / enable a
single server) could run the native reachability probe and bind session while
the header IPC was still in flight — the registry was empty and native paths
403'd behind the gate, the exact race this branch fixes. Await the sync before
probing/binding; the stale-cover retry stays off the critical path.
* fix(servers): reclaim LAN from a sticky public endpoint
For a dual-address profile, the first endpoint that answered after launch stuck
for the whole session: a public sticky endpoint was always tried first and kept
answering, so the LAN-first order was never re-run and the connection never
upgraded back to LAN. pickReachableBaseUrl now makes a short, no-retry, bounded
quick-probe of the higher-priority LAN endpoint before honouring a public sticky
entry, so a laptop returning to the LAN upgrades on the next reachability tick
while staying remote costs only one bounded probe (never the full retry cushion).
* fix(servers): refresh LAN/public badge on active-server switch; credit #1273
The connection badge read the endpoint kind from the last probe and never
re-probed when the active server changed, so switching between a LAN-only and a
public server left the badge stuck on the old server's classification until the
120-s tick. useConnectionStatus now resets the kind and re-probes when
activeServerId changes (mount still deferred to the polling effect). Also adds
the settings credits line for the gated-server work (PR #1273).
* fix(servers): LAN reclaim uses the probe's own timeout, not a 3s race
The reclaim quick-probe raced pingWithCredentialsForProfile against a fixed 3s
timer, but the underlying probe (native reqwest for gated servers, axios
otherwise) runs on a 15s timeout — so a LAN endpoint answering between 3s and
15s was wrongly treated as unreachable and the session stayed pinned to the
public sticky endpoint. Reclaim now does a single, no-retry probe on the LAN
endpoint using the normal ping timeout: a slow-but-reachable LAN still upgrades,
while a dead one still costs only one attempt (not the full retry cushion)
before falling through to the sticky sequence.
* fix(lyrics): read SYNCEDLYRICS from the concrete Vorbis comment block
Embedded lyrics were never found on a FLAC/Ogg file whose only lyrics field is
`SYNCEDLYRICS`. The lookup went through lofty's generic tag via
`ItemKey::from_key(tag_type, "SYNCEDLYRICS")`, which returns `None` because the
key is not one lofty knows — and the generic tag drops unknown Vorbis comments
entirely on conversion, so the value was not reachable there at all. The branch
had been dead since it was written; files fell through to the `LYRICS` field or
reported no lyrics.
Read the comment block off the concrete file type (FLAC, Vorbis, Opus, Speex)
instead, keeping the documented priority: SYNCEDLYRICS before plain LYRICS.
Tests cover the reader end to end against generated files: SYNCEDLYRICS and
LYRICS returned verbatim (Enhanced LRC word stamps included), SYNCEDLYRICS
winning over a plain fallback, USLT returned verbatim, and SYLT rebuilt as
line-level LRC — which is why that source can never carry word timing.
* docs(changelog): Vorbis SYNCEDLYRICS fix (#1267)
Pins the path from a local file's LRC tag to the word lines the pane renders:
Enhanced LRC yields word lines with clean text, plain LRC yields none so the
pane falls back to line sync. Verified to fail when the hook stops forwarding
word lines from the embedded source.
Rust 1.97 flags the `match spill_path { Some(p) => …, None => return None }`
as a `?` in disguise, which fails `cargo clippy -- -D warnings` on CI for every
Rust pull request. Same control flow, written the way the lint asks for.
* fix(lyrics): strip Enhanced LRC word markers from displayed text
The LRC parser only matched the leading line stamp and passed the rest of the
line through as text, so Enhanced LRC inline markers rendered literally:
`<00:12.34>Hello` instead of `Hello`. Offline and hot-cached tracks read their
embedded tags through this parser without the server ever seeing them, so a
track with embedded Enhanced LRC showed the markers in the pane.
Parse the markers instead of ignoring them: the text is now marker-free, and
where a line carries them they drive the same word-by-word highlighting the
server's songLyrics v2 cues do. A line without markers becomes one full-line
word, so word mode never drops a line.
Move the parser out of the LRCLIB client into `utils/lrc`, since embedded tags
and Netease feed it too, and move `LrcLine` next to the other lyrics types so
the parser can produce word lines without an import cycle.
* docs(changelog): Enhanced LRC marker fix (#1266)
* feat(lyrics): read word-level timing from OpenSubsonic songLyrics v2
Navidrome 0.63 advertises songLyrics v2, which adds word/syllable cues
behind a new `enhanced` flag on getLyricsBySongId. The lyrics pane already
renders word-level highlighting for the lyricsplus provider, so the server
source now feeds the same renderer.
- Request `enhanced` only where the capability catalog says the server
speaks v2 (Navidrome 0.63+), since no spec forces a v1 server to ignore
an unknown parameter.
- `enhanced=true` also returns translation and pronunciation layers, so the
response is filtered to the main layer before display.
- Map cue lines to word lines: a missing cue `end` is all-or-nothing per
line and resolves from the next cue, then the line end; multi-voice lines
share an index and keep the main agent; a line without cues stays a single
full-line word so no line is dropped.
- Bump the lyrics IndexedDB cache so line-only entries cached under the
90-day TTL cannot suppress word timing.
- Move the shared lyrics value types out of the hook, which also drops the
useLyrics/lyricsPersistentCache import cycle.
* i18n(settings): add the server word-sync hint
One line explaining what word-by-word lyrics need from the server.
* feat(settings): surface the server word-sync requirement in Lyrics Sources
Word-by-word lyrics need a recent server and lyrics that actually carry word
timing, which is not obvious from the source list alone.
Rebuild the block on the settings sub-card primitives while adding the hint:
the section description moves into the SettingsSubSection slot, the source
list and its ordering hints become a SettingsSubCard/SettingsField, and the
hint uses the field's note slot instead of a hand-rolled div borrowing an
unrelated class.
* docs(changelog): server word-level lyrics (#1265)
Adds the changelog entry, the contributor credit, and a What's New highlight
for the 1.50.0 line.
On Windows/MSVC the test executables for `psysonic-analysis`, `psysonic-audio`
and the top `psysonic` crate link the wry/tao windowing runtime (via the tauri
dependency) and statically import `TaskDialogIndirect` from comctl32. That symbol
only exists in Common-Controls v6; System32 comctl32.dll is v5.82 and lacks it,
so an unmanifested test exe aborts at startup with STATUS_ENTRYPOINT_NOT_FOUND
(0xC0000139) before any test runs. The app binary avoids this through the
manifest tauri_build embeds.
Declare the Common-Controls v6 dependency on the test binaries via build-script
link args, gated to windows-msvc. The app binary's manifest is unchanged
(byte-identical: tauri_build already embeds the same dependency, the linker
dedupes). Non-Windows/CI builds are unaffected.
* feat(fullscreenPlayer): add Prism style — full-bleed backdrop, right lyrics panel, glass control bar
Third fullscreen player style (Settings → Appearance). Reuses the existing
pipeline: full-bleed artist backdrop (fanart/artistBackdrop), FsLyricsApple in a
floating right-side glass panel, player-store transport, cover-derived accent.
New: the bottom bar layout (transport · time elapsed/−remaining · now-playing
pill with integrated scrubbable progress · volume/queue/lyrics-toggle/minimize)
and fullscreen-player-prism.css. Wired into the style toggle + AppShell routing
+ picker + 13 locales.
* fix(fullscreenPlayer): Prism controls stay visible; transport/utils float bare (no 3-box split)
* fix(fullscreenPlayer): Prism bottom bar is one continuous glass bar (not three separate elements)
* fix(fullscreenPlayer): Prism control row is a centred middle band; only the now-playing element is boxed
* fix(fullscreenPlayer): Prism bar has both an outer glass strip and a darker nested now-playing box
* fix(fullscreenPlayer): Prism outer bar is half-width and centred
* feat(fullscreenPlayer): Prism lyrics — progressive blur on upcoming lines + accent-tinted active line
* style(fullscreenPlayer): centre the title/album/artist text in the Prism now-playing pill
* refactor(fullscreenPlayer): share seek/volume/backdrop/time across FS players
The Prism player had copy-pasted the seekbar, volume, time readout and
backdrop resolution from the Static/Immersive players. Two of those copies
carried bugs: the progress input dropped FsSeekbar's touch/pointer handlers
(no scrubbing on touchscreens) and the volume toggle only recorded the
pre-mute level on the mute click (unmuting after dragging the slider to 0
restored a stale value).
Extract the shared logic into single sources of truth and rewire all three
players onto them:
- useFsArtistBackdrop — the artist-backdrop URL resolution (was duplicated
verbatim in Static, Immersive and Prism).
- useImperativeSeek — the drag/preview/commit + progress-subscription loop
with mouse, touch, pointer and keyboard handlers; FsSeekbar and Prism's
progress line now share it, so touch scrubbing works everywhere.
- useVolumeToggle — mute toggle that continuously tracks the last non-zero
volume, restoring it correctly regardless of how the level reached 0.
- FsTimeReadout gains `remaining`/`className` props and replaces Prism's
bespoke time component.
Drops a dead aria-valuetext no-op on the progress input. Adds regression
tests for the volume-toggle restore path and a Prism smoke test.
* docs(changelog): add Prism fullscreen player style (#1251)
* feat(fullscreenPlayer): recover immersive player — components, hooks, CSS, settings
Phase 1 of the Minimal/Immersive style toggle. Recover the pre-#1001 fullscreen
player from history into the feature folder and make it compile against the
current architecture:
- FullscreenPlayerImmersive + Fs{Art,Portrait,Seekbar,LyricsMenu,LyricsRail}
- hooks useFsDynamicAccent, useFsArtistPortrait
- fullscreen-player-immersive.css (core/mesh/portrait/controls/seekbar + rail +
lyrics-menu + no-compositing overrides), shared Apple-lyrics stays in
adaptive-portrait.css
- re-add settings removed in #1001: showFullscreenLyrics, fsLyricsStyle,
showFsArtistPortrait, fsPortraitDim; plus the new fullscreenPlayerStyle toggle
Not wired into the shell yet (Phase 2). The two hooks still trip the current
set-state-in-effect lint rule — both are reworked in Phase 3 (portrait rewire to
artistBackdrop + dynamic-accent perf gating), which resolves it.
* feat(fullscreenPlayer): style toggle — route Minimal/Immersive + Appearance picker (13 locales)
* feat(fullscreenPlayer): immersive artist portrait via fanart backdrop pipeline; fix hook lint
* refactor(playback): add feature barrel; route immersive imports through barrels (dep:check)
* fix(fullscreenPlayer): restore lyrics-style i18n (13 locales) + close popover on style pick
* feat(fullscreenPlayer): immersive Apple-lyrics mode shows artist image as dimmed full-screen backdrop
* fix(fullscreenPlayer): address code-review findings on the immersive player
- star: pass currentTrack.serverId to queueSongStar (multi-server correctness)
- cover: use album-keyed ref (useAlbumCoverRef) to stop per-track cover flicker
- backdrop: honour backdrops.fullscreenPlayer.enabled (toggle was ignored)
- lyrics popover: Escape now closes only the popover, not the whole player
(capture-phase listener + stopPropagation vs useFsIdleFade's bubble handler)
- settings: recover the Show-artist-photo toggle + photo-dim slider (13 locales),
shown for the immersive style — the persisted settings had no UI
- apple mode: don't mount the (CSS-hidden) portrait — the full-screen backdrop
already shows the image; avoids a duplicate 2000px load/decode per track
- dynamic accent: cache-as-source-of-truth + last-shown ref so a cache-hit album
no longer surfaces a stale accent from an earlier album
- rehydrate: clamp fsPortraitDim (0–80) so a malformed value can't yield NaN dim
- FsArt: clear the layer when a track has no cover (was leaving prior art)
- FsSeekbar: pause the progress subscription during keyboard seeking (onKeyDown)
* fix(fullscreenPlayer): stop the immersive scrim over-darkening the artist portrait in rail mode
* fix(fullscreenPlayer): dynamic cover accent — re-run extraction when the async cover src resolves
* refactor(fullscreenPlayer): dynamic accent reads the cover blob via the cover cache, not a raw fetch
* test(fullscreenPlayer): unit-test dynamic accent hook + immersive player render/control smoke
* docs(changelog): note fullscreen player styles + credits (#1249)
* fix(fullscreenPlayer): satisfy npm run lint — drop no-explicit-any casts and ref-in-render
* fix(themes): theme card what's-new shows only the latest version
* feat(themes): credit community theme authors in Settings System tab
* docs(changelog): note theme contributor credits and card what's-new (#1248)
* fix(discord): drop server cover source that leaked credentials
The "server" Discord cover source built an authenticated Subsonic
getCoverArt URL (carrying the username, auth token, and salt) and handed
it to Discord as the large image. Discord fetches external images through
its own proxy and exposes the full source URL to anyone viewing the rich
presence, leaking replayable server credentials.
- Remove the "server" cover source; keep "None" (app icon) and
"Apple Music" (iTunes, credential-free), both resolved without server auth
- Migrate persisted "server" preference to "None" on rehydrate; new default
is "None"
- Drop the now-dead frontend cover-URL builder and its test
- Move the two-option picker to the shared SettingsSegmented control
- Remove the obsolete locale key across all 13 locales
* docs(changelog): note Discord server cover credential fix (#1246)
* feat(themes): show per-theme changelog and pin updatable themes
Surface theme updates in the store: each card gains an expandable "What's
new" built from the theme's optional manifest changelog (versions listed
newest first), and installed themes with a pending update now float to the
top of the list in both sort modes so they are easy to find.
* i18n(settings): add themeStoreWhatsNew across locales
New "What's new" label for the theme-store changelog disclosure, in all 13
locales.
* docs(changelog): add theme store changelog and pinned-updates entry (#1240)
* fix(playlist): batch playlist writes past the GET URL limit (#1227)
Adding tracks failed past ~341 songs because the write path re-sent the
entire song list as createPlaylist.view?songId=<all> query params on a GET,
blowing past the server's ~8 KiB URL limit. Writes now append incrementally
via updatePlaylist.view?songIdToAdd=<batch> (and songIndexToRemove for
clears/removals) in 150-id batches, so there is no practical size cap. A
per-server in-memory membership cache removes the full getPlaylist refetch
on every dedup, fixing the "slow add on big playlists" report.
Layering detangle (keeps the new cache from adding dep-cruiser cycles):
- lib/api/subsonicPlaylists.ts is pure of the store again; cache invalidation
on write failure moved to the feature callers' catch blocks.
- membership cache extracted to the core layer (src/store/playlistMembershipStore.ts)
so offline/orbit/contextMenu/playlist read it directly instead of routing
through the @/features/playlist barrel.
- severed the offline -> playlist-barrel edge (pinnedOfflineSync name fallback
through the live playlist list was dead: nameless callers are all gated by
isSourcePinnedOffline, where offline meta already carries the name).
- confirmAddAllDuplicates moved into the playlist feature; contextMenu submenus
import the add/merge helpers via the playlist barrel, not deep paths.
dep-cruiser baseline regenerated: 742 -> 714 (net -28, all no-circular). The
churn in the baseline is path-shift of the frozen playerStore SCC (cover/
playback/orbit), not new coupling; the new playlist modules have zero violations.
* docs(changelog): record #1235 playlist URL-limit fix (changelog + credits)
* fix(playlist): seed membership cache from full list, not library-scoped view (#1235 review)
F1: runPlaylistLoad seeded the dedup membership cache from the
library-scope-filtered songs, so out-of-scope members looked "new" and
addTracksToPlaylistWithDedup/collectMergeSongIds could re-add them as
duplicates. Cache now holds the full unfiltered server list while the UI
still shows the filtered view; add a regression test.
Also documents the two accepted trade-offs flagged in review:
- F2: >batch updatePlaylist clears then appends non-atomically (URL-limit
workaround); a mid-step failure truncates server state, cache invalidation
lets the client re-read truth.
- F3: dedup read-modify-append isn't atomic across the await; rare missed
dedup on concurrent adds, self-heals on next load.
Retroactive changelog + credits for the already-merged feature-folder restructure (PR #1225). Adds a 1.50.0 Changed entry (internal restructure, no user-facing change; additional architecture credited to cucadmuh) and a contributions line under Psychotoxical. cucadmuh is intentionally not added to the settings credits list.
FE cutover for the D1 slice: libraryGetCatalogYearBounds and
libraryGetGenreAlbumCounts now call the generated commands.* bindings instead of
a hand-written invoke<T>('cmd', ...), unwrapping the specta Result union (rethrow
on error — behavior-preserving). The duplicated hand DTOs collapse into named
aliases of the generated CatalogYearBoundsDto / GenreAlbumCountDto, so the shape
lives in one place (the contract) and consumers stay unchanged.
Verified the guard end to end: a serde-rename of the Rust field (contract change,
Rust still compiles) regenerated bindings and made tsc fail at the FE consumer;
reverted. cargo + tsc now enforce this slice's contract at compile time.
The serverCapabilities cluster (catalog, context, resolve, storeView, types) is
lib-level infra: it imports only @/lib/server and is consumed by both lib and
feature code, so a feature home would invert the two lib consumers. Move it to
src/lib/serverCapabilities and repoint consumers.
Pure move + import updates, no behavior change. Baseline gains one lib->store
entry (storeView reads authStore, only now caught under lib/).
The five residual src/hooks/ hooks (useConnectionStatus, useCardGridMetrics,
useTracklistColumns, useNavidromeAdminRole, useAnalysisPerfListener) are all
domain-agnostic — none imports @/features — so they join the other shared hooks
in src/lib/hooks/ (with their two colocated tests). Consumers repointed;
relative-up imports in the moved files become the @/ alias. src/hooks/ is removed.
Pure move + import updates, no behavior change. The layering baseline gains five
lib->store entries (the three hooks that read authStore/devOfflineBrowseStore are
only now caught under lib/) — same documented debt class as the api move.
The legacy src/components/ dir held eight shared modals. Audited each: the five
generic primitives (Modal, ConfirmModal, GlobalConfirmModal, ExportPickerModal,
ThemeMigrationNotice) move to src/ui (the shared-primitive home). The three that
import @/features/* are feature-owned, so they move into their feature instead of
polluting ui with core->feature edges: SongInfoModal -> features/playback,
LicenseTextModal -> features/settings, PasteClipboardHandler -> features/share.
Pure move + import updates, no behavior change. The layering baseline is unchanged
(every relocated import lands in a legal direction: feature->ui/store/lib, or
cross-feature only via the barrel). src/components/ is now removed.
The seven remaining src/api/ modules (analysis, azuracast, bandsintown,
coverCache, migration, network, runtimeLogs) were the last IPC-layer files
outside src/lib/api/, where the rest of the FE IPC clients already live. Move
them (+ the colocated coverCache test) into lib/api and repoint every consumer;
relative-up imports in the moved files become the depth-independent @/ alias.
Pure move + import updates — no behavior change. The layering baseline is
regenerated in the same commit: the moved files' pre-existing store/cover edges
are only now caught under lib/, and the cover<->coverCache cycles are the same
edges re-keyed to the new path (E4 shrinks the allowlist later).
First vertical slice of the tauri-specta rollout: annotate two psysonic-library
browse-metadata commands (library_get_catalog_year_bounds,
library_get_genre_album_counts) with #[specta::specta] and derive specta::Type on
their DTOs (CatalogYearBoundsDto, GenreAlbumCountDto), then add them to
collect_commands!. bindings.ts now carries their typed signatures + DTO shapes
(serde camelCase carried through). Non-breaking: generate_handler! stays the live
handler and nothing imports the bindings yet.
Both DTOs are i64-free (i32/u32/String), so no BigInt handling is needed here —
that convention is decided when the first i64 DTO is annotated. Generated
src/generated is excluded from eslint (its runtime helper uses `any`); tsc still
type-checks bindings.ts.
Stand up the FE<->BE contract pipeline end to end with near-zero surface: a
tauri_specta::Builder collects one proven command (greet) and exports typed TS
bindings to src/generated/bindings.ts. The existing generate_handler! stays the
live invoke handler — no FE change yet.
Safety: the export runs only under #[cfg(debug_assertions)] (debug launch) and a
headless test, never in a release build, so a specta RC break can never block a
release cargo build; the committed bindings.ts is plain TypeScript for tsc. The
snapshot is committed (gitignore exception) so CI diffs catch contract drift.
Pinned deps: specta / tauri-specta =2.0.0-rc.25, specta-typescript =0.0.12 (the
latest mutually-consistent set; rc.21 + specta-typescript 0.0.9 no longer resolve).
Grow collect_commands! crate-by-crate next, starting with psysonic-library.
After a server URL edit, rewriteFrontendStoreKeysForRemap must repoint the old
index key to the new one across offline album keys, local-playback entries, and
the player queue (queueServerId + per-item refs), leaving unrelated keys
untouched. A same-key remap is a no-op.
Cover switchActiveServer end to end with the heavy deps mocked and the real orbit
+ auth stores driving assertions: unreachable aborts; host tears down via
endOrbitSession, guest via leaveOrbitSession, both reset the role; the old
server's queue is flushed, the active server rebinds, and a queue handoff is
marked. Closes the switchActiveServer QA.
Route a multi-track enqueue through the real enqueue action and the orbitRuntime
bulkGuard seam: over-threshold + accept commits the tracks, reject commits none,
and a single track bypasses the guard entirely.
Exercise the real hasLocalPlaybackUrl + subsonicNetworkGuard: a track with local
psysonic-local:// bytes (any tier) skips the reachability probe even when the
server is unreachable, while a non-local track stays gated by reachability.
Closes the local-bytes network-skip QA (review residual #1).
Register the real offlineMediaResolve into the mediaResolver seam and drive its
offline-vs-network routing through the actual decision inputs: offline-browse
active + local browse enabled reaches the local-bytes path, otherwise the
network path. Asserts which data source the seam call reaches. Closes the
offline-aware media resolution QA (review residual #2).
Guard shape drift when an old persisted blob rehydrates. Cover the
analysisStrategyStore v0 to v1 migrate (preserves strategy, clamps parallelism,
adds the per-server maps) plus a v1 passthrough, and add cold-start rehydrate
round-trips for localPlaybackStore (entries) and offlineStore (albums).
Pin the contract of the three core-feature seams the refactor rests on: before
registration each delegator returns its documented default (mediaResolver
network-only, orbit neutral snapshot + allow, bridge null / no-op); after
registerX(fake) it forwards to the registered impl with the given args and
propagates its result. Underpins the boot smoke and the scenario suite.
hasLocalPlaybackUrl mirrors resolvePlaybackUrl's local-source branch so the
network guard can skip the reachability probe for local tracks; its only other
test mocks it away. Cover it directly against real stores: per-tier hits
(library / favorite-auto / ephemeral), the no-bytes false case, index-key vs raw
profile-id resolution, and an equivalence guard against resolvePlaybackUrl's
local branch that locks the bit-identical claim.
Import every lazy(() => import('@/features/*/pages/*')) page up front and assert
a default-exported component, so a broken specifier fails at CI instead of only
when the route is first navigated to. A drift guard reads the real route sources
and fails if the loader table and the app fall out of sync, so a newly added
route cannot silently skip coverage.
Guard the boot-order invariant the three core-feature seams rely on: importing
the real app entry module runs the registration side effects (playback bridge
via MainApp, media resolver via the offline barrel, orbit runtime via AppShell's
orbit barrel), and the smoke asserts each seam is the registered implementation,
not its neutral default. Drops any of those imports later, and the matching
assertion fails at CI instead of silently at runtime.
Each seam gains a small behavior-free isRegistered() introspection so the guard
reads registration directly rather than coupling to store internals.
Encode the feature-folder layering contract as a CI gate: lib is the floor,
store/ui must not import features/app, cross-feature only via the barrel, no
cycles. dependency-cruiser resolves the @/ alias via tsconfig; tests excluded.
Land as a ratchet: the 738 current violations are seeded into
.dependency-cruiser-known-violations.json and ignored via --ignore-known, so
HEAD is green and any NEW violation fails CI. Regenerate the baseline as the
drain removes an exception; the count is the tracked residual debt.
Wired via `npm run dep:check` into a new dependency-cruiser job in eslint.yml
and the ci-ok required-check aggregate.
The 945-LOC lib/api/library.ts (cucadmuh's deliberate 'one thin file' library_*
command surface) exceeded the ~500 LOC ceiling. Split by its own PR-5 section
comments into library/{dto,reads,sync,stats,events}.ts + a private library/internal.ts
(the shared serverId↔indexKey helpers). lib/api/library.ts is now a barrel that
re-exports all of them, so @/lib/api/library stays the single import point — every
consumer (incl. the Settings LibraryTab) and all 11 vi.mock('@/lib/api/library')
test factories are unchanged. Pure reorganization, no logic change; tests pass
unmodified. Largest module is now dto.ts (466, pure types).
a11y-branch hold lifted. Pure share machinery (no @/features runtime imports) →
lib/share: shareLink, shareSearch, shareServerOriginLabel, copyEntityShareLink
(+tests). The two orchestrators that runtime-import offline/playback/orbit →
new features/share: applySharePaste, enqueueShareSearchPayload (+test); doc-only
barrel, consumers use deep paths. No low-layer consumes the orchestrators, so no
seam is needed — pure move, tests pass unmodified. Drains utils/share.
Only import lines changed in the PasteClipboardHandler consumer (logic untouched).
a11y-branch hold lifted (Frank: modals get re-refactored separately, no rebase to
protect). Both were only blocked by their modal consumers:
- licensesData (pure licenses.json lazy-loader) → features/settings/utils; main
consumer is the settings LicensesPanel. JSON dynamic import absolutized to
@/data/licenses.json.
- userMgmtHelpers (pure formatLastSeen, lib/format only) → lib/format; multi-feature
consumer set, same class as playlistDetailHelpers.
Drains utils/componentHelpers (dir removed). Only import lines changed in the
LicenseTextModal/SongInfoModal consumers (logic untouched); tests pass unmodified.
uploadArtistImage lived in lib/api/subsonicPlaylists but is artist-domain and
consumed only by features/artist/runArtistDetailActions. Relocated to
lib/api/subsonicArtists (its rightful API module); consumer repointed. Also fixes
a stale doc comment in features/randomMix/index.ts (mixRatingFilter now lives in
features/playback). No behavior change; tests pass unmodified.
cardGridLayout's only store dep was three grid-column constants it imported from
authStoreDefaults. Flipped ownership: the constants now live in lib/util/cardGridLayout
(pure layout config) and authStoreDefaults re-exports them (store→lib), so the auth
settings clamp/default and every existing consumer are unchanged. cardGridLayout is now
store-free and homes in lib/util alongside the other pure helpers — consumed cleanly by
ui/VirtualCardGrid + cover/layoutSizes. Resolves the documented store-free-lib block for
the card-grid layout helper. Pure move + constant relocation; tests pass unmodified.
The connection-reachability + Subsonic network-guard helpers were pinned low by
four lib/api consumers (subsonicLibrary/Playlists/Ratings/Scrobble) yet ran two
lower-layer→feature inversions. Both removed without a registry:
- devOfflineBrowseStore is a self-contained DEV-only toggle with zero offline-
feature coupling — it was only colocated there. Relocated features/offline/store
→ store/ (global). The @/features/offline barrel re-exports it so feature/UI
consumers are unchanged; the three lower-layer readers (subsonicNetworkGuard,
activeServerReachability, useConnectionStatus) now import it from @/store directly.
- subsonicNetworkGuard's only other feature dep was playback's resolvePlaybackUrl,
used solely for the psysonic-local:// skip check. Added hasLocalPlaybackUrl to the
existing M4 substrate store/localPlaybackResolve — it mirrors resolvePlaybackUrl's
local-source branch exactly (same profile resolution; the empty-serverId playback
fallback never applies in the guard), so the skip stays bit-identical.
network/ (subsonicNetworkGuard + activeServerReachability + tests) → lib/network,
now @/features-free. useConnectionStatus is now iron-rule-clean and stays in hooks/
(cross-cutting). Test mocks retargeted to the new seam modules.
Behavior-adjacent (covered by suite; default paths identical): the local-bytes skip
helper — flag for offline-playback runtime QA alongside the M4 media-resolver seam.
Two lower-layer→feature inversions removed:
- Detail-route predicates (isAlbumDetailPath/isArtistDetailPath/isComposerDetailPath)
were defined in features/album's browse store but are pure URL checks. Extracted
to lib/navigation/detailRoutePaths; the browse store re-exports them so the
@/features/album barrel surface is unchanged. This frees albumDetailNavigation of
its only @/features import, so it moves utils/navigation → lib/navigation (drains
utils/navigation). Also fixes the M5 isArtistDetailPath misplacement.
- mixRatingFilter's sole feature dep is playback's userRatingOverrides and its only
binding consumer is playback's buildInfiniteQueueCandidates, so it belongs in the
playback feature: utils/mix → features/playback/utils (drains utils/mix). The
earlier handoff note that lib/api/subsonicStarRating consumes it was stale — that
is only a code comment, verified no runtime import.
Pure moves; tests pass unmodified. The share cluster + switchActiveServer stay in
utils/ — they are imported by the a11y-HELD PasteClipboardHandler, so relocating
them would rewrite a do-not-touch file.
Per-file routing of the 9-file utils/componentHelpers grab-bag, each driven by
its verified consumer set:
- contextMenu{Actions,Helpers} → features/contextMenu/utils (sole consumer)
- queuePanelHelpers → features/queue/utils (sole consumer)
- nowPlayingHelpers → features/nowPlaying/utils; isRealArtistImage split out to
cover/ (consumed by cover/artistHero, a lower layer)
- appUpdaterHelpers(+test) + listReorder(+test) → lib/util (config/ + lib/hooks
consume them; pure)
- playlistDetailHelpers → lib/format (consumed by 3 features; pure)
- appShellHelpers → app/ (app-shell consumers only)
- userMgmtHelpers left in place: consumed by the a11y-HELD SongInfoModal, so a
move would rewrite a do-not-touch file (same block as licensesData).
Pure moves; tests pass unmodified.
Both one-time boot migrations are consumed only by app/MainApp; advancedModeMigration runtime-imports @/features/artist + @/features/playlist, so it is app-level orchestration (an inversion if kept in utils/ infra). Pure move.
The frontend moves left the subsystem docs pointing at pre-campaign locations.
Update the src/CLAUDE.md key-files table + prose and the test/README.md examples
to current homes: playerStore/playAlbum -> features/playback, Track model +
songToTrack -> lib/media, sidebar -> features/sidebar, QueuePanel ->
features/queue, CachedImage/TooltipPortal/CustomSelect -> ui/, toast -> utils/ui,
subsonic/i18n -> lib, App.tsx split into app/{MainApp,AppShell,RequireAuth}.
Docs only; no code touched.
songToTrack (the canonical Subsonic-song -> Track mapper) and the pure
server-scope helpers (activeServerProfileId, stampTrackServerId/s,
isMultiServerQueue, profileIdFromQueueRef) operate only on the lib/media model +
authStore + server-key utils -- no playback-store read. Move them to lib/media so
the ~58 app-wide consumers (cover, context menus, sharing, lucky-mix, library
browse, pages, hooks) depend on lib, not on the playback feature.
trackServerScope is split: the store-reading queue helpers (queueItemRefAt,
filterQueueRefs*, activeServerQueueTrackIds) stay in
features/playback/utils/playback/trackServerScope and build on the pure lib half.
With Track/QueueItemRef (prior commit), songToTrack and shuffleArray now in lib,
the media-domain model is fully out of features/playback. The remaining
playerStoreTypes imports are PlayerState only (the store shape). tsc 0, lint 0,
full suite 2353/2353, build OK.
Track is the app's normalized song model and QueueItemRef its thin queue
identity -- consumed app-wide (queue, cover, context menus, sharing, lucky-mix,
library browse), not a playback-internal detail. They lived in
features/playback/store/playerStoreTypes, forcing ~115 core/feature files to
reach into the playback feature for the central media type.
Move the two interfaces to lib/media/trackTypes; playerStoreTypes keeps
PlayerState and now imports them from lib. 115 importers repointed (mixed
'PlayerState, Track' lines split so PlayerState stays). Pure type move (erased at
runtime). tsc 0, lint 0.
utils/library is the local-index query/browse/sync engine -- feature-free shared
infra consumed by the album, search, genre, artist and offline features. With
its last feature edges removed (queue resolver, shuffleArray, albumBrowseCatalogChunk,
genreBrowsePlayback all relocated earlier this branch), the whole directory is
pure infra and belongs under lib/.
Whole-directory move (58 files, 76 consumers rewritten) via resolver-based
rewrite: cross-cluster relatives absolutized to @/, intra-cluster siblings kept
relative. utils/library is gone; lib/library imports zero @/features. Three
stale doc comments in the album/search/artist barrels repointed.
tsc 0, lint 0, full suite 2353/2353, production build OK.
genreBrowsePlayback is a "play X" queue builder -- it turns a genre seed into a
Track[] for the player, the genre analogue of playArtistShuffled which already
lives in features/playback/utils/playback. Consumed only by the 3 genre pages,
by no utils/library sibling, so the whole-file move is clean (no split). Its
Track/songToTrack edges become intra-feature; its remaining utils/library
siblings (advancedSearchLocal, albumBrowseSort, genreCatalogCountsCache,
genreAlbumBrowse, libraryReady) are plain infra deps.
utils/library now has ZERO @/features importers (source AND tests) -- the bulk
is ready to relocate to lib/library. tsc 0, lint 0, genre suite green.
shuffleArray is a pure generic Fisher-Yates over T[], misfiled in
features/playback. Move to lib/util (next to dedupeById): fixes the existing
core->feature edges (utils/componentHelpers/*) and drops one of
genreBrowsePlayback's playback deps.
albumBrowseCatalogChunk is the feature-layer orchestrator that picks the offline
branch on top of the pure lib catalog loaders; its sole consumer lives in
features/album and its offline dep is a legal feature->feature edge there. Its
.test.ts stays in utils/library -- that test exercises fetchLocalAlbumCatalogChunk
from albumBrowseLoad (misnamed), which is not moving.
utils/library now has a single remaining feature importer: genreBrowsePlayback
(songToTrack + type Track), blocked on the Track domain model living in
features/playback. tsc 0, lint 0, targeted suites green.
The queue-resolver family (queueTrackResolver, queueRestore, queueItemRef,
queueTrackView) runtime-imports usePlayerStore / playerStoreTypes — it is the
playback engine's thin-state queue subsystem, not shared library infra. It sat
in utils/library only because it queries the local index. Co-locate into
features/playback/store so those edges become intra-feature; its remaining deps
(advancedSearchLocal, libraryReady, serverLookup, serverIndexKey, authStore) are
plain core/infra (feature -> infra, no inversion).
Removes 4 of the 6 store/utils -> feature runtime inversions that blocked the
utils/library -> lib move. 8 files moved, 46 consumers rewritten. tsc 0, lint 0,
targeted suites green.
Relocate the playback/queue/transport/audio-output engine out of the type-first
store/ + utils/playback/ + utils/audio/ dirs into a cohesive src/features/playback/,
structure-preserving:
store/<x> -> features/playback/store/<x>
store/audioListenerSetup/<x> -> features/playback/store/audioListenerSetup/<x>
utils/playback/<x> -> features/playback/utils/playback/<x>
utils/audio/<x> -> features/playback/utils/audio/<x>
184 files moved (107 source + 77 tests), 365 consumers rewritten. Pure move — no
behavior change, no state-split (the playerStore state-split stays a separate M5
question). Enabled by this session's decouple seams (artist/offline/orbit/auth →
core registries), so the engine carries no inbound core->feature inversion: store/
now holds only the 50 cross-cutting global stores (auth family, the seams, library
index, UI/settings stores).
KEPT OUT of the move (would re-create global->engine edges): the 3 pure config
helpers utils/audio/{loudnessPreAnalysisSlider,hiResCrossfadeResample} +
utils/playback/autodjOverlapCap (authStore + settings UI read them — they stay in
utils/). Ambiguous view-state stores (eqStore, queueToolbarStore,
playerBarLayoutStore) stay global (no engine imports).
Consumers use DEEP paths (@/features/playback/...), no barrel — matches the lib/
approach and avoids barrel-mock-collapse across the 140 usePlayerStore consumers.
Two tolerated type-only core->feature edges remain (localPlaybackStore->QueueItemRef,
localPlaybackMigration->HotCacheEntry, both erased).
tsc 0, lint 0, full suite 319/2353 green, iron-rule clean (no runtime store->feature
import). Behavior-touching only via the prerequisite bridge seam (already QA-flagged);
the move itself is pure.
The last core→engine inversions blocking the playback-core move live in the
authStore settings/profile family: authServerProfileActions reads
usePlayerStore.queueServerId + calls clearQueueServerForPlayback (on server
delete), and authAudioSettingsActions calls
usePlayerStore.updateReplayGainForCurrentTrack from ~8 ReplayGain/normalization
setters. Both are re-exported through the authStore barrel, so a global store
would depend on the (soon-to-move) engine.
Add core seam store/playbackEngineBridge.ts: a registry exposing getQueueServerId
/ clearQueueServerForPlayback / updateReplayGainForCurrentTrack with no-op/null
defaults. The engine registers its impls via store/playbackEngineBridgeRegister.ts
(side-effect-imported by MainApp at boot). authStore actions now call the neutral
delegators instead of importing the engine.
Default is safe: the only callers are user-triggered settings/profile actions that
fire long after boot (engine already registered); at boot there's no current track
or queue binding, so the no-op would be correct anyway. authStore.servers.test
imports the register module so removeServer's queue-clear runs through the real
engine wiring.
Prepares the playback-core move (Engine → features/playback): with this + keeping
the 3 config-helper utils in utils/ + tolerating 2 type-only edges, the engine has
no inbound core→feature inversions left. tsc 0, lint 0, suite 319/2353 green.
Behavior-touching (server-delete queue clear, settings→gain refresh) → Frank QA.
Add core seam store/orbitRuntime.ts: a registry (registerOrbitRuntime) exposing a
neutral session snapshot {role,phase,state} + an async bulkGuard, plus pure
derivations mirrored from the feature (isInOrbitSession, isOrbitPlaybackSyncActive,
estimateLivePosition). Default (unregistered) = neutral snapshot + bulkGuard allow
— bit-identical to today's no-session behavior, and a session can only start via
the topbar which loads the @/features/orbit barrel (→ registers) first.
features/orbit/utils/orbitBulkGuard registers the runtime at module init
(store-backed getSnapshot + the existing confirm-modal orbitBulkGuard as the gate).
The orbit feature keeps its own copies of the pure helpers for UI (incl. the
arg-form isOrbitPlaybackSyncActive(role,phase) used by two settings/player-bar
components), so nothing UI-facing changes.
Repointed the 9 audio-core sites (playbackRateStore, previewStore,
playbackReportSession, nextAction, resumeAction, playTrackAction,
queueMutationActions, playAlbum) from @/features/orbit to @/store/orbitRuntime;
state reads (useOrbitStore.getState().role/.state) become orbitSnapshot().
Migrated 6 audio-core test mocks to @/store/orbitRuntime via importOriginal-spread
(keeps registerOrbitRuntime callable). enqueueShareSearchPayload stays on the orbit
barrel (share util, not audio core) — its test mock unchanged.
Decouple Step 3 — last seam. The audio ENGINE is now free of @/features/* runtime
imports (only type-only edges + the non-engine composerBrowseSessionStore browse
store remain). Unblocks the playback-core move + utils/library→lib.
tsc 0, lint 0, full suite 319/2353 green.
NEEDS Frank's live-session QA before relying on it (host+guest bulk-replace modal,
guest catch-up, rate/preview/scrobble suppression).
Add core seam store/mediaResolver.ts: a registry (registerMediaResolver) with
delegating resolveAlbum/resolveArtist/resolvePlaylist + the pure auth wrappers
resolveMediaServerId/resolveAlbumForActiveServer/resolveAlbumForServer and the
ResolvedAlbum type. Default (unregistered) = network-only safety net (no
library-index/offline branch — those stay in the feature).
features/offline/utils/offlineMediaResolve keeps the offline-aware policy impls
and registers them at module init (registerMediaResolver). The call runs at boot:
AppShell eagerly imports the @/features/offline barrel, whose export * evaluates
offlineMediaResolve. The 3 thin wrappers + ResolvedAlbum move to the core seam and
are re-exported from offlineMediaResolve so the offline barrel still surfaces them
for the ~25 UI consumers (unchanged).
Repointed the 5 audio-core call sites (fetchTracksForSource, playAlbum,
playArtistShuffled, playByOpaqueId, luckyMixHelpers) from @/features/offline to
@/store/mediaResolver. Migrated playAlbum.test's mock to the new module via
importOriginal-spread (keeps registerMediaResolver callable when the offline
barrel loads transitively — partial-mock-collapse otherwise).
Decouple Step 2b. The audio core's only remaining @/features/offline reference is
now the type-only localPlaybackMigration import (erased). Runtime offline edge gone;
orbit seam (Step 3) remains.
tsc 0, lint 0, full suite 319/2353 green.
Pull findLocalPlaybackUrl/findLocalPlaybackEntry/hasLocalPersistentPlaybackBytes
+ the entry/index-key membership helpers (entryBelongsToServer,
indexKeyBelongsToServer, findFavoriteAutoEntry, hasLocal{Library,FavoriteAuto}Bytes)
out of features/offline/utils/offlineLibraryHelpers into a new core module
store/localPlaybackResolve.ts. The substrate depends only on authStore +
localPlaybackStore + serverIndexKey utils (no useOfflineStore), so the audio
core can resolve on-disk bytes without inverting into @/features/offline.
Repointed the audio-core consumers (crossfadePreload, playbackUrlRouting,
resolvePlaybackUrl, playTrackAction, promoteStreamCache, hotCachePrefetch,
hotCacheStore) + offline-internal callers + favorites hook; moved the two
barrel vi.mocks onto the new leaf module. offlineLibraryHelpers re-imports the
two primitives it still needs internally.
Decouple Step 2a. Removes the local-bytes offline edge from the audio core;
the resolve* media-resolution family (Step 2b) + orbit seam (Step 3) remain.
tsc 0, lint 0, full suite 319/2353 green.
First step of decoupling the audio core from feature imports. coerceOpenArtist
Refs is a pure Subsonic-response normalizer (one-object-vs-array quirk) that was
mis-membered in features/artist but consumed by the audio core (songToTrack,
trackArtistRefs) and others. Relocate it beside its SubsonicOpenArtistRef type in
lib/api/; drop the artist-barrel re-export; repoint the 4 barrel consumers.
Removes the @/features/artist runtime edge from the audio core (utils/playback)
entirely. tsc 0, lint 0/0, suite 319/2353 green.
(composerBrowseSessionStore's ALL_SENTINEL import from @/features/artist remains
— that's a non-audio global browse store, separate from this decouple.)
The shortcut contract (shortcutActions.ts + shortcutTypes.ts) is NOT a
pure-frontend file: src-tauri/src/cli/parse.rs include_str!s
'/../src/config/shortcutActions.ts' at compile time, so moving it to
lib/shortcuts/ (slice 7ad19671) broke the Rust build (couldn't read the file ->
dev build won't start). Revert the contract back to src/config/ — it has a
build-time backend path contract and belongs where Rust anchors it, not in lib/.
No Rust file touched (fix = restore the frontend path the backend expects).
cargo check green (13s), tsc 0, lint 0/0, frontend suite 319/2353 green.
Lesson: grep src-tauri for include_str!/include_bytes! '../src/...' before
moving ANY frontend file — Rust can pin a TS file by path.
Extract the queue UI (QueuePanel + queuePanel/* components + useQueue* hooks)
into features/queue/ with a barrel. This is the chosen playback/queue boundary:
queue = the QueuePanel UI that CONSUMES the playback store; queue STATE and the
audio engine stay in store/ (playback-core, moved separately). Verified
one-way: no playback-core file imports the queue UI back (only doc comments
mention it).
Excluded as NOT queue-UI: useIdlePlayQueuePull (AppShell) and
usePlayQueueSyncLedState (ConnectionIndicator) are queue-SYNC orchestration,
not panel UI — left in hooks/ to move with playback.
Pure move. One test fix: QueuePanel.test.tsx reads its subject via a hardcoded
readFileSync('src/components/QueuePanel.tsx') path (architecture-pin grep for
forbidden HTML5 DnD) — repointed to the new location (the move tool can't
rewrite non-import string paths). tsc 0, lint 0/0, suite 319/2353 green.
Continue M4 lib/ de-flattening with domain-agnostic infra:
- lib/hooks/: 9 pure generic hooks (useDebouncedValue, useIsMobile,
useLongPressAction, useRangeSelection, useResizeClientHeight,
useWindowVisibility, useSystemPrefersDark, useVirtualizerScrollMargin,
useRemeasureGridVirtualizer) — all PURE (only react/zustand/tanstack deps).
- lib/dnd/DragDropContext.tsx: the generic mouse-event DnD engine (WebKitGTK
HTML5-DnD workaround, ~24 importers, self-contained) — empties src/contexts/.
- lib/shortcuts/: shortcutActions + shortcutTypes (the action-id + binding
contract; registry/dispatch/bindings stay app-level and import the contract,
app->lib direction).
useWindowFullscreenState deliberately NOT moved — kept in hooks/ as app-shell
per the handoff iron-rule list (overrides its pure-helper appearance).
Pure move via deep @/lib/* specifiers. tsc 0, lint 0/0, full suite 319 files /
2353 tests green.
Move domain-agnostic, feature-free helpers out of the flat utils/ and store/
roots into src/lib/ (plan M4, §3 lib/ layer):
- lib/format/: formatBytes, formatClockTime, formatDuration, formatHumanDuration,
relativeTime (pure format/date/byte/clock helpers)
- lib/i18n.ts: i18next bootstrap (global app infra)
- lib/util/: sanitizeHtml, platform, dedupeById, safeStorage (pure helpers +
the zustand storage adapter)
playbackScheduleFormat stays in utils/format (runtime usePlayerStore dep =
audio-core coupling, not generic). formatClockTime keeps a type-only
@/store/authStoreTypes import (erased, no runtime inversion — accepted per the
deviceSync precedent).
Pure move via deep @/lib/* specifiers (no barrel → no mock-collapse surface).
tsc 0, lint 0/0, full suite 319 files / 2353 tests green.
Tooling note: lib_move.py regex extended to also rewrite side-effect imports
(import './i18n') and vi.importActual paths — both were silent gaps.
Move the 5 subsonic api modules that M3 had co-located into features
(subsonicAlbumInfo/Artists/Playlists/Radio/Statistics) into src/lib/api/, the
feature-free infra layer, and drop their feature-barrel re-exports. Consumers
now import these protocol calls directly from @/lib/api/subsonic*; the feature
barrels export only domain UI/hooks/state.
This is the consistent api placement (plan §10.3, revised: ALL subsonic protocol
clients are feature-free infra, not per-feature) and it kills the documented
api-induced feature<->feature cycles: offline->artist/playlist (getArtist/
getPlaylist*), album->artist (getArtistInfo), deviceSync/nowPlaying/orbit->*.
Remaining artist<->album refs are UI-only (OpenArtistRefInline/coerceOpenArtist
Refs) and offline->playlist is store-level (usePlaylistStore) — both deeper,
tracked for M5, not api placement.
Pure move: import specifiers + vi.mock/spy targets repointed; no behaviour
change. tsc 0, lint 0/0, full suite 319 files / 2353 tests green.
Tooling note: barrel-imported and dynamic-import api symbols were split out of
@/features/* to @/lib/api/* (tsc-driven); 12 vi.mock barrels retargeted to the
deep lib module (mock-collapse sweep), AlbumHeader's UI mock left untouched.
Move the Subsonic + Navidrome protocol clients and the library IPC facade
(subsonic*, navidrome*, library.ts + tests) from src/api/ into src/lib/api/,
the feature-free infra layer (plan M4, decision §10.3: shared client core → lib/).
Pure move: deep-path import specifiers @/api/* -> @/lib/api/* across ~150
consumers; no behaviour change. Domain REST (lyrics/events) and cover/analysis
infra stay in src/api/ pending their own M4 placement.
tsc 0, lint 0/0, full suite 319 files / 2353 tests green.
The album barrel reaches genreBrowsePlayback.test's transitive graph through the
artist↔album edge (songToTrack → @/features/artist → @/features/album →
useGenreAlbumBrowse), which needs GENRE_ALBUM_FIRST_PAGE from the partially
mocked ./genreAlbumBrowse. Spread importOriginal so it stays real.
The move tool rewrote single-module mocks (e.g. vi.mock('@/utils/orbitBulkGuard'))
into feature-BARREL mocks (vi.mock('@/features/orbit', ...)). A partial barrel
factory shadows every other barrel export to undefined — and src/test/helpers/
storeReset.ts reads useOrbitStore.getState() at module load, so 6 test files
(playerStore.misc/events/queue/playbackActions, ContextMenu, QueuePanel) crashed
at collection (0 tests run, mis-read as flaky teardown). Retarget each collapsed
mock to its deep submodule so the barrel re-exports the stub while siblings
(useOrbitStore, offlineActionPolicy, usePlaylistStore) stay real.
The offline move collapsed a single-module mock into a `@/features/offline`
barrel mock, which shadowed the real `useOfflineBrowseContext` the test relied
on. Mock the reload-token submodule directly so the barrel re-exports the stub
while the sibling context hook stays live.
The M0 pilot mis-membered the waveform data pipeline into features/waveform,
creating a core->feature inversion: ~12 audio-core files in store/ imported
refreshWaveformForTrack / fetchWaveformBins / bumpWaveformRefreshGen from the
feature barrel. These are playback coordination (write waveformBins into
playerStore on track change, last-write-wins gen guard), not seekbar rendering.
Move waveformRefresh + waveformRefreshGen (+ tests) to src/store/ and
waveformParse (+ test) to src/utils/waveform/ (beside waveformSilence). The
feature now holds only the seekbar UI (WaveformSeek/SeekbarPreview + their
hooks/render utils); its barrel exports UI only. Core consumers import the
pipeline directly from @/store/*. Restores the no-core->feature-inversion rule.
Theme infra (fixedThemes, theme utils), music-network presetIcon, audio-device
probe, library-index sync, and the auth*SettingsActions slices stay in the
core/global layer (consumed via @/ alias) to avoid inverting core->feature.
* feat(settings): add "Square Corners" appearance toggle
A Display toggle in Settings → Appearance → Visual Options that overrides
the active theme to render cards and cover art with square (non-rounded)
corners, for a sharper, boxy look.
Covered surfaces: grid cards (album/playlist/artist/song/because) and
their covers, detail-header heros (album/playlist/tracks), the Now
Playing / Radio view, the fullscreen player, the cover lightbox, the
queue panel cover, and the mini player.
Persisted in themeStore and driven by an `html[data-square-corners]`
attribute set in App.tsx — applied in both the main and mini-player
webviews (the mini player rehydrates the theme store on the shared
`storage` event). Strings added for all 12 locales.
* docs(changelog): add Square Corners to 1.50.0 CHANGELOG and What's New
Set the 1.49.0 release date, fix Added PR order for Polish (#1185),
refresh WHATS_NEW highlights for missing user-facing fixes, and credit
ImAsra for WinGet release automation (#1077).
Extract the drag-to-reorder boilerplate the sidebar, artist-layout, lyrics,
queue-toolbar and servers customizers each hand-rolled into a single
`useListReorderDnd` hook plus an id-based `applyListReorderById`, and migrate
all five onto it. Reorders resolve by stable item id, never positional index,
so a render filter can never share an index space with the reorder and desync
it (the class behind #1164). No user-facing change.
- New: useListReorderDnd, applyListReorderById (+ tests), ReorderGripHandle.
- applySidebarReorderById now builds on the shared core; section/conserved
guards stay sidebar-specific.
- Queue/mini-queue/playlist reorder stay backend-index based (out of scope).
* fix(sidebar): reorder nav items by id instead of positional index
The Settings sidebar customizer rendered library rows with one filter and
reordered them with another, so a hidden/gated item (luckyMix when AudioMuse
is unavailable) made the rendered list shorter than the reorder list and drag
indices landed one slot off — rows jumped back on drop. Replace the index-based
reorder with an id-based primitive shared by the customizer and the in-sidebar
long-press DnD, so a render filter can no longer desync the move. Unknown,
cross-section, conserved, or no-op drops are rejected.
* docs(changelog): note sidebar reorder fix (#1206)
* fix(cover): build cover tiers from the full-resolution source
Derive the larger tiers from the decoded download instead of re-reading the just-written smaller tier (resize never upscales, so 512/800 were stored at the small resolution). Resolve the full-res 2000 tier exactly so it is actually downloaded and cached. Bump the cache layout stamp to drop already-poisoned tiers.
* fix(cover): open the cover lightbox at full resolution
Require the exact requested tier for the full-res helper so a smaller warmed tier no longer pins the lightbox to a downscaled image. Race the 2000 fetch against a 500ms opening window, show the 800 tier meanwhile, and persist 2000 for the next open. Adds an opening animation (reduced-motion aware) and tests.
* docs(changelog): note full-resolution cover fix (#1205)
* fix(cover): keep full-res peek exact in peek_batch and the grid seeder
Review follow-up: ensure-path peek alone left a hole — cover_cache_peek_batch still laddered a 2000 request down to a smaller tier, and the in-memory grid seeder wrote that smaller path under the 2000 key, so Hero/fullscreen/lightbox surfaces (which peek 2000 before ensure) still showed a downscaled cover. Share one exact-2000 rule (peek_plain_cover_tier) across ensure and peek_batch, and never seed the full-res key from a smaller tier file.
* fix(queue): persist timeline play history across queue replace (#1096)
Add a session-scoped play-history buffer (with play_session cold bootstrap)
and timeline UI that shows history + current + upcoming without mutating
the canonical queue or Subsonic sync.
* fix(queue): pin timeline current to top and replay history in-place
Timeline scroll matches queue mode (current at top). History clicks insert
after the playing track instead of replacing the queue, and replayed tracks
stay visible in the history strip.
* docs: add CHANGELOG and credits for timeline play history (PR #1204)
* fix(queue): resolve cross-server cover art for timeline history
Include album/cover ids in play_session bootstrap rows, prefetch history
refs through the queue resolver per server, and resolve before replay so
Now Playing artwork works for inactive-server tracks.
* fix(now-playing): stop playbackReport on cross-server track switch
Send stopped to the previous server's playbackReport session when the
playback server changes (queue click, history replay, etc.) so Who is
listening clears on the server that was showing the prior track.
* fix(queue): close timeline history review gaps for PR #1204
Defer play_session bootstrap until the library index is ready with retry
while timeline mode is active; resolve history and queue rows by serverId
+ trackId for mixed-server queues; add tests for bootstrap defer and ref lookup.
* chore(queue): remove dead timeline scroll guard in QueueList
Timeline scroll is handled in the virtual-rows effect; the legacy branch
is queue/playlist only.
* fix(queue): address PR review nits for timeline play history
Use authoritative row.ref.serverId for history clicks before resolver fill;
simplify empty bootstrap seed; tighten completion types; unify recent_plays SQL.
* fix(queue): immutable session history append for useSyncExternalStore
Replace in-place push with a fresh array so getSnapshot returns a new
reference and React re-renders on live appends without a playerStore update.
* feat(titlebar): theme-colored window bar on macOS
macOS showed the grey native title bar that ignores the app theme
(#1198). Enable titleBarStyle: Overlay so the webview reaches the top
edge and the native traffic lights float over the existing in-page
title bar, which already paints with the active theme.
Reuses the Linux title-bar grid: the bar is shown on macOS too (hidden
in native fullscreen), without the custom window buttons since the
traffic lights stay native. The GTK resize grips are now Linux-only.
Renamed the stylesheet to drop the misleading linux-only name.
* docs(changelog): macOS themed title bar
* fix(titlebar): crisp macOS bar text — drop transform layer and shadow
* fix(titlebar): drop now-playing text from the macOS bar
Replace the Interface Scale zoom slider and its tick row with the shared
SettingsSegmented control — one button per preset (80–150%), matching the
Lyrics, Queue and Track-transitions sections. Drops the slider/tick
alignment issue entirely. Legacy off-preset values snap to the nearest
preset so one button is always active. Removes the now-dead slider CSS.
* fix(hero): use the album cover as the mainstage backdrop's last fallback
The mainstage hero inherited the artist-detail fallback chain, ending on
the Navidrome artist image. The hero frames an album, so its last-resort
backdrop should be the album's own cover (the layer shown when the
feature is off), not the artist image. Resolve it scope-true from the
album cover ref already in hand. Artist-detail keeps the artist cover.
* fix(hero): default backdrop order to fanart, Navidrome, then banner
Lead both heroes with the artist fanart and fall back to the wide banner
last, instead of trying the banner first. The fullscreen player keeps
fanart then Navidrome (it has no banner). Affects defaults / reset only;
saved per-surface orders are untouched and remain user-reorderable.
Extract a reusable SettingsSegmented helper for the settings-segmented
pill picker. Track transitions adopts it unchanged (mode picker + AutoDJ
overlap cap). Lyrics scroll style and Queue display mode move from
stacks of mutually-exclusive toggles to the segmented picker, with the
active option's description shown below.
* refactor(cover): extract shared pickArtistBackdrop priority helper
* feat(hero): show the album artist's fanart as the mainstage hero backdrop
* feat(settings): configurable per-surface artist backdrop sources
Each artist-backdrop surface (mainstage hero, artist-detail header, fullscreen player) gets its own enable toggle + an ordered, individually-toggleable source list, configured under External Artwork Scraper on the Integrations tab (shown when the scraper is on). Reorder via the shared useDragSource/psy-drop drag infra plus keyboard-accessible up/down buttons; each source has its own on/off.
The shared chooser pickArtistBackdrop is generalised to resolveBackdrop + backdropFromConfig (ordered candidate list with the same pending/miss/centred-framing semantics), so all three surfaces resolve identically. themeStore persist bumped to v2; defaults reproduce today's order, so there is no visible change without user action.
Gating decoupled: the three surfaces are gated solely by their own per-surface flag, not by enableCoverArtBackground (which stays scoped to album/playlist-header cover blur). Reorder maths extracted to a pure, unit-tested module. i18n en + de (other locales TODO before PR).
Tests: resolver (10) + reorder (7).
* feat(cover): make the ensure queue surface-aware for artist backdrops
coverEnsureQueued now threads optional CoverEnsureOpts through to the Rust ensure and weaves the external surface into the in-flight key, so the fanart and banner surfaces of one artist no longer collide on one download chain. External surfaces also bypass the disk-src memory short-circuit (their {tier}-{surface}.webp never seeds those caches, and the canonical cover must not read as a hit). New thin ensureArtistBackdropQueued wrapper. Backward-compatible: plain covers append nothing to the key; the 5 queue tests stay green.
* fix(cover): reset the artist external-image hook synchronously on artist change
The hook reset src in an effect (one render late), so for the render between an artist change and that effect a consumer read the *previous* artist's resolved image. The mainstage hero then froze (and cached into per-album memory) a neighbouring slide's banner. Reset synchronously via the React adjust-state-on-prop-change pattern. Also removes brief stale-image flashes on the artist-detail header and fullscreen player.
* feat(hero): prefetch artist backdrops and show-ready-now / upgrade-on-re-entry
warmHomeMainstageCovers now prefetches each hero slide's artist backdrop (banner/fanart) at static slide-index priorities (idx1=high, idx0=low, rest=middle; no reprioritise on navigation), then predecodes every slide already on disk. useHeroBackdrop shows the best source ready at entry (Navidrome on a cold first visit, the prefetched/cached external one on re-entry) and freezes that source choice for the visit so nothing swaps mid-dwell; the url is derived live from the frozen choice, with a per-album disk memory for re-entry. HeroBg now crossfades only after the image bytes load (onLoad/complete gate + onError + fallback). Inert when the scraper is off. Tests: per-album memory (5).
* docs(changelog): configurable artist backdrops + mainstage hero (PR #1193)
CHANGELOG Added entry, settingsCredits line, and the What's New Artist-artwork highlight extended to cover the mainstage hero backdrop and per-place source config.
* docs(changelog): fold mainstage hero + per-place backdrops into the fanart entry (PR #1193)
Merge the configurable-backdrops changes into the existing 'Artist artwork from fanart.tv' block (now PR #1137 and #1193) instead of a separate entry.
* fix(hero): revert HeroBg load-gate that blanked the app (Maximum update depth)
The byte-load gate I added drove the crossfade reveal from an inline img ref + onLoad that re-fires on every render and scheduled a setTimeout each call; frequent re-renders (playback, marquee) stacked nested updates until React threw 'Maximum update depth exceeded' — and with no ErrorBoundary the whole window blanked. Reverted HeroBg to the proven timer-based crossfade. The hero only ever receives ready/predecoded urls, so the gate was cosmetic.
* fix(hero): gate the HeroBg crossfade on image load to stop the slide flicker
The bare 20 ms reveal faded a layer in before its bytes were ready (notably the Navidrome raw url), so a slide change flickered. Now an Image() preloader in the [url] effect reveals the layer on load (or a cached complete check), with a fallback. Everything is scheduled once per url — no per-render <img> ref/onLoad — so unlike the reverted gate it can't stack nested updates.
* feat(i18n): translate the backdrop-source settings into the remaining 10 locales
Adds the per-surface backdrop config strings (es, fr, nl, zh, nb, ru, ro, ja, hu, pl) and extends externalArtworkDesc to mention the mainstage hero where the key exists (ja has none → falls back to en).
* feat(app): add a top-level error boundary so a render error no longer blanks the app
Until now any thrown error during render took the whole window down with no recovery (issue #382). A class ErrorBoundary around the authenticated shell catches it and shows a recoverable fallback (Try again / Reload app) while playback keeps going. Hook-free + English-only with literal CSS var() fallbacks so it renders even when i18n/theme/state is what broke.
* docs(changelog): error boundary prevents the app blanking on a render error (PR #1194)
* feat(sort-dropdown): support a right-aligned popover via align prop
* feat(playlist): sort playlist tracks by date added
* docs(changelog): playlist sort by date added (#1191)
* docs(credits): playlist sort by date added (#1191)
* feat(album): show all genres in album details, linkable to genre pages
The album header listed only a single genre. It now renders every genre
from the album's OpenSubsonic genres[] (falling back to splitting the
legacy genre string), each linking to its genre page via the existing
genre route.
* docs(changelog): multiple genres in album details (#1186)
* refactor(genres): extract genreColor into a shared util
* feat(album): union album and track genres behind a +N cursor menu
* feat(album): read album detail from the library index when ready
* docs(changelog): expand the album genres entry
* feat(album): genre pill row with keyboard-navigable popover
* test(offline): cover index-first resolveAlbum path with mocked libraryIsReady
* refactor(styles): move album-detail genre styles out of cover-lightbox.css
* feat(settings): add SettingsSubCard primitives for sub-section controls
Reusable wrappers (SettingsSubCard / SettingsField / SettingsRow /
SettingsValue / SettingsCallout) that encapsulate the settings-norm-* sub-card
styles. Per-mode and detail controls inside a settings section should use these
instead of hand-rolling the box with inline padding or one-off classes — the
lack of a shared primitive is what let recent additions drift off the pattern.
* refactor(settings): move the Audio tab onto the SettingsSubCard primitives
Normalization, Track transitions and Hi-Res now build their sub-cards from the
shared primitives instead of raw settings-norm-* markup. Track Previews, which
rendered its sub-options bare with inline styles and dividers, now uses the same
sub-card so the whole Audio tab is consistent. Behaviour and styling unchanged;
Track Previews' start/duration become labelled slider rows like Normalization.
* refactor(settings): move the remaining tabs onto SettingsSubCard
Apply the shared sub-card primitives across the rest of Settings so every tab
matches the Audio-tab reference look:
- Appearance: grid columns, window buttons, UI scale, font, seekbar
- Library: random-mix blacklist, rating-filter detail
- Input: in-app + global keybind lists
- Integrations: Discord templates, external-artwork BYOK
- System: language, Linux Wayland text render, clock format, logging
- Storage: media dir, hot-cache detail, downloads folder
- Backup: mode picker + per-mode export/import
Detail/per-mode controls now sit in the bordered sub-card; toggle groups stay on
SettingsToggle. Behaviour unchanged.
* refactor(settings): box the data-heavy and shared sections in SettingsSubCard
Bring the previously-skipped 'borderline' sections onto the sub-card too:
- Playback speed: wrap the settings-mode controls in a sub-card; the compact
player-popup path is left untouched (shares the component via the compact flag)
- Theme scheduler: mode picker + theme/time selects move into the sub-card
- Cover-cache + analytics strategy: the per-server tables sit in a sub-card
Behaviour unchanged; the table layouts and the playback-rate-* internals stay as
they are, only the surrounding sub-card is added for visual consistency.
* fix(settings): box the AutoDJ and Hi-Res sub-options like Normalization
The AutoDJ overlap-cap and the Hi-Res blend-rate options sat bare in their
sections instead of in the bordered sub-card the Normalization block uses for
its per-mode controls. Wrap both (and the crossfade-seconds slider, for
consistency within Track transitions) in the shared settings-norm-block card,
and drop the redundant SettingsGroup wrapper from the Hi-Res block — the Audio
tab already wraps that section in a group, so the block added a second border.
* docs(changelog): audio sub-section design fix; move recent entries to the end of Fixed
The #1172 and #1174 entries were prepended to the Fixed section instead of
appended; move them to the end where new entries belong, and add the #1175
audio settings entry.
* fix(playlist): play the playlist from the context menu Play Now action
The Playlists-page context menu 'Play Now' only navigated to the playlist
detail page instead of starting playback. It now loads the playlist's songs
and plays them via the shared playPlaylistAll action (same path as the detail
page 'Play All' button), with a guard against load failures.
* fix(ui): close the context menu on window resize
The context menu is absolutely positioned at fixed coordinates, so resizing
the window left it stranded and drifting off-screen. Whether a resize closed
it was inconsistent across setups (it stayed open on some Windows and Linux
environments). Always close it on resize so the behaviour is the same
everywhere.
* docs(changelog): context menu Play Now and resize fixes (#1174)
* docs(changelog): credit the reporter for the context menu fixes
* fix(cover): report a genuine miss for external artist surfaces
When external artwork is enabled, the fanart/banner ensure fell through to
the Navidrome cover and returned it as a hit on a fanart.tv miss. That made
'this artist has no banner' indistinguishable from a real hit, so the
artist-detail hero short-circuited on the ND cover and never reached the
fanart it actually had. Return hit=false on an external-surface miss instead;
the Navidrome fallback is the caller's job, since each surface has its own
chain (hero: banner->fanart->ND, fullscreen player: fanart->ND).
* fix(artist): hero background banner->fanart->Navidrome with raised focal point
The hero header background now steps through banner -> fanart -> Navidrome
artist cover, resolving the ND cover the same way the fullscreen player does.
Previously it had no Navidrome stage at all and, combined with the backend
masking a banner miss as a hit, showed the ND cover where the fanart belonged.
Also raise the focal point (background-position: center 30%) for the portrait-ish
fanart/ND images so the band's heads stay in frame on wide (2K+) viewports,
where 'cover' scales them up and overflows vertically. The wide banner strip
keeps the shared center. Done via a scoped inline style; .album-detail-bg stays
untouched for the album/playlist headers that share it.
* docs(changelog): artist header external-background fallback fix (#1172)
* fix(macos): pad dock icon to Apple icon grid
The macOS .icns artwork filled the canvas edge-to-edge, so the dock icon
rendered larger than native apps. Replace icon.icns with a build whose artwork
is scaled to an ~824px body centred on a 1024px transparent canvas (Apple's
icon grid). macOS-only asset; Windows .ico and Linux PNGs unchanged. Refs #1166.
* fix(macos): skip the icns app-icon in dev to avoid a launch crash
Tauri's dev-only macOS path sets the app icon from icon.icns via
NSImage::initWithData(...).expect(...) (RunEvent::Ready); the padded icns
makes that return nil and abort at launch — release builds are unaffected.
Add a dev config override (tauri.dev.conf.json) that drops icon.icns from
bundle.icon so the dev app icon falls back to a PNG; tauri:dev passes it via
--config. Production bundling keeps the padded icon.icns. Refs #1166.
* docs(changelog): add entry for macOS dock icon padding (PR #1169)
* chore(eslint): add eslint toolchain and configs
* fix(eslint): resolve gradual-config errors (rules-of-hooks, no-empty, prefer-const, …)
* chore(eslint): clear unused vars in config, contexts, app and test
* chore(eslint): clear unused vars in api and music-network
* chore(eslint): clear unused vars in utils
* chore(eslint): clear unused vars in store
* chore(eslint): clear unused vars in cover and hooks
* chore(eslint): clear unused vars in components
* chore(eslint): clear unused vars in pages
* chore(eslint): remove explicit any in src
* chore(eslint): align react-hooks exhaustive-deps
* chore(eslint): zero gradual config on src
* chore(eslint): strict hook rules in store and utils
* chore(eslint): strict hook rules in hooks and cover
* chore(eslint): strict hook rules in components
* chore(eslint): strict hook rules in pages and contexts
* chore(eslint): document scripts ignore in eslint config
* chore(eslint): add lint script and zero strict findings
* chore(eslint): address review round 1 (gradual 0/0, per-site disable reasons)
* chore(eslint): tighten two set-state disable comments (review round 2 LOW)
* chore(nix): sync npmDepsHash with package-lock.json
* docs(changelog): add Under the Hood entry for ESLint setup (PR #1165)
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* feat(themes): add follow-system mode to the theme scheduler
The theme scheduler can now switch the day/night theme pair by the OS
light/dark preference instead of a clock schedule. A segmented control
picks the trigger; in system mode the time inputs are hidden and the two
theme pickers read as Light/Dark.
The OS theme is read via the native Tauri window theme API (theme() +
onThemeChanged) rather than the Web prefers-color-scheme media query,
which is unreliable through WebKitGTK on Linux. Live updates land
instantly where the platform forwards them; on Linux setups that don't,
a hint notes the change applies after an app restart.
* docs(changelog): add follow-system theme mode entry (#1163)
CHANGELOG Added entry, contributor credit and What's New highlight for the
theme scheduler's new system-theme mode.
* fix(orbit): harden the guest outbox — recover lost suggestions, avoid a duplicate outbox
Two outbox robustness issues on the poll-based, non-atomic playlist
transport:
- Lost-update: a track a guest appends to its outbox can be wiped by a
concurrent host sweep-clear before the host recorded it, leaving the
suggestion lost AND stuck on "waiting on host" forever (it only clears
once it reaches the host's play queue). The playlist read-modify-write
can't be made atomic, so recover: re-send a pending suggestion the host
hasn't recorded (absent from state.queue) past a grace window — the host
dedupes by (user, trackId), so it's idempotent — and give up + toast past
a 45s window so the row stops hanging. New pendingResend planner module
with unit tests; suggest + tick share ensureTrackInOutbox.
- Duplicate outbox: a transient getPlaylists failure at join was swallowed
to [], so the existing-outbox check missed and a second outbox was
created. Distinguish "lookup failed" from "genuinely absent" and retry
once before falling back to create.
New i18n key for the give-up toast across all locales.
* fix(orbit): reject non-http(s) schemes in share invites
normalizeShareServerUrl only prepends http:// when the server string doesn't
already start with "http", so an invite carrying a scheme like httpx:// or
https-phish:// slips through unchanged. Reject anything whose parsed protocol
isn't http(s) at parse time, before the known-server match. Add round-trip
tests covering the accepted and rejected schemes.
* fix(orbit): read exit-modal role fresh to avoid a stale keydown closure
The exit modal's Enter/Escape handler binds once per open (deps [isOpen]) and
closed over the role prop, so a role change while the modal was up would act
on a stale value. Read role from the store inside the action instead, and drop
the now-unused reactive subscription.
* docs(changelog): consolidate Orbit fixes into one block, add 1159
* feat(orbit): mirror host track-transition settings into the session
Each client previously used its own local crossfade / gapless / AutoDJ
settings, so guests blended tracks differently from the host and drifted
out of sync — the exact drift the Catch-Up button exists to paper over.
Add an optional OrbitSettings.transitions (crossfade enabled/secs/trim,
AutoDJ smooth-skip, gapless), additive on the wire so older sessions
simply omit it. The host seeds its own prefs at start and refreshes them
every tick, so a mid-session change propagates. Guests snapshot their own
prefs on join, adopt the host's for the session (idempotent — no setState
churn / audio-sync re-fire when unchanged), and restore their own on
leave. Applying via authStore.setState reuses the existing authSyncListener
path to the Rust engine, so no new IPC. Without the Hot cache a guest's
AutoDJ blend degrades gracefully via the existing byte-preload fallback.
Add a bridge module with unit tests for read/apply/save/restore.
* feat(orbit): lock guest transition controls during a session
Since a guest now mirrors the host's track-transition settings (re-applied
every read tick), their own controls would visibly fight the sync. Disable
them while a guest is in a live session and explain why.
Settings -> Audio -> Track transitions greys out the mode picker, seconds
slider and smooth-skip toggle with a "controlled by the Orbit host" note;
the queue toolbar's Gapless / Crossfade / AutoDJ quick-toggles get the same
disabled state and tooltip. Host controls are untouched. New i18n key added
across all locales.
* docs(changelog): add Orbit transition sync (1158)
* fix(orbit): unify session-settings default to the canonical preset
updateOrbitSettings merged patches onto a hand-rolled
{ autoApprove: true, autoShuffle: true } fallback when a legacy session's
blob predated the settings field. That literal disagreed with
ORBIT_DEFAULT_SETTINGS (autoApprove: false), so toggling autoShuffle on
such a session silently flipped autoApprove on, landing guest suggestions
without host approval. Use ORBIT_DEFAULT_SETTINGS as the single source of
truth for the fallback. Add a behavioural test for the settings-less,
preserve-existing, and not-hosting paths.
* fix(orbit): enforce maxUsers host-side in the participant rebuild
The join gate only sees a one-tick-old state blob, so two guests can both
pass the participants.length < maxUsers check and join past the cap at
once; maxUsers was advisory. The host is the single writer of the
canonical state, so enforce the cap there: admit at most maxUsers guests
in the participant rebuild, earliest joiners winning (an established
participant is never displaced by a newcomer) with a deterministic
username tie-break for same-tick joins. Suggestions from an over-cap guest
are ignored, consistent with their absence from the participant list.
Reorders the fold so the admitted set gates the queue-additions loop. Add
tests for the cap, the no-displacement rule, and the ignored over-cap
suggestion.
* fix(orbit): serialise host state pushes to stop overlapping ticks dropping writes
pushState does several awaited round-trips (sweep -> resolve -> write) and
is fired from three sources: the mount, the 2.5 s timer, and a play/pause
subscription. With no in-flight lock these can overlap and race
last-writer-wins: a slow run that already swept and cleared an outbox can
lose its write to a faster run, dropping those suggestions from both the
outbox and the published state. The lastPushedAtRef was only ever written,
never used as a guard.
Add makeCoalescedRunner: a pure helper that serialises an async task and
coalesces any mid-flight request into a single rerun, so no trigger is
lost. The host hook wraps pushState in it; the dead ref is removed. Add a
unit test for the serialise + coalesce semantics.
* docs(changelog): add Orbit host-side session guards (1157)
* fix(orbit): stop cleanup sweep from deleting live sessions on other devices
The orphan-cleanup regex anchored the trailing `__` inside the optional
`_from_…` group, so the canonical session playlist name
(`__psyorbit_<sid>__`) never matched. It then fell into the
"unrecognised → prune" branch, which deletes unconditionally and bypasses
the orphan-TTL grace that exists precisely to protect a session running on
the user's other device. Opening Psysonic on a second device wiped the
first device's live session out from under its guests.
Move the trailing `__` outside the optional group so both the session and
outbox names match; the existing sid/TTL/ended logic then applies as
intended. Add a behavioural test covering keep-fresh / prune-stale /
prune-ended / skip-current / outbox / corrupt / foreign-owner cases.
* fix(orbit): keep host publishing when the state blob grows past budget
OrbitState.queue (the suggestion/attribution history) was append-only at
the sweep-fold step and never bounded. On a long session it grew until the
serialised blob exceeded ORBIT_STATE_MAX_BYTES; serialiseOrbitState then
threw, writeOrbitState's caller swallowed the error and retried the same
over-budget state every tick, so the host silently stopped publishing and
guests froze into a host-timeout.
Two layers:
- Cap queue at ORBIT_QUEUE_HISTORY_LIMIT in applyOutboxSnapshotsToState,
evicting oldest-by-addedAt (robust to the periodic queue shuffle). The
dropped tracks have long since played, so their attribution/dedupe entry
is dead weight.
- Add serialiseOrbitStateForWire: a budget-aware serialiser that sheds
oldest history, then the play-queue tail, on a copy before giving up.
writeOrbitState now uses it, so a transient over-budget tick degrades the
published blob instead of taking the host offline. Local store state is
untouched.
Tested: history cap eviction order, wire-trim byte budget + oldest-first
drop, play-queue fallback, and within-budget passthrough.
* fix(orbit): lock out the proactive radio top-up during a session
The ≤2-remaining radio top-up in runNext lacked the isInOrbitSession()
guard that its infinite-queue sibling has at both entry and resolution.
A guest who joined with residual radioAdded tracks (before the first
syncToHost replaces the queue) could fire it, appending up to 10 unrelated
radio tracks and trimming queue history — drifting the guest off the host's
playlist.
Add the guard at entry and re-check inside the resolution .then(), mirroring
the infinite-queue branch; the existing finally() still clears the fetching
flag. The queue-exhausted radio fallback already returns early in Orbit, so
only this mid-queue path was unguarded.
* docs(changelog): add Orbit cleanup / state-budget / radio fixes (#1155)
* docs(changelog): move Orbit fixes to bottom of Fixed section (#1155)
* fix(playlists): wrap header action buttons instead of overflowing
The Playlists header action row was a fixed flex row, so at narrow
widths (e.g. with the queue panel open) the buttons overflowed and
were clipped off-screen. Add flex-wrap so they wrap, left-aligned,
matching the Albums toolbar behaviour.
* docs(changelog): add 1.49.0 entry for Playlists header button wrap (#1153)
* feat(i18n): add Hungarian (hu) translation files
* feat(i18n): register Hungarian locale and language option
* docs(changelog): Hungarian translation entry and credit (#1149)
* fix(i18n): list Romanian in the Login language picker
* fix(eq): surface the active AutoEQ profile name in the preset picker
* i18n(settings): add AutoEQ preset group label (10 locales)
* docs(changelog): AutoEQ active-profile picker fix (#1147)
* feat(ui): add reusable Modal component
Portal + dimmed backdrop (no backdrop-filter — on WebKitGTK the blur bleeds
onto modal content on some GPU stacks), Escape/backdrop/X to close, header
(icon/title/subtitle) + scrollable body + footer slot. First consumer is the
update modal; the other hand-rolled modals migrate onto it over time.
* fix(updater): rebuild the update modal on the reusable Modal
Fixes a user report (Manjaro / AMD / X11): blurry modal text (the eq-popup
backdrop-filter bled onto the content), the version arrow sitting between the
two header rows, and the unclear Skip / Remind buttons. Now uses the no-blur
Modal shell; the header icon aligns with the title; Skip is a clear button and
Remind me later is the accent action when there is no in-app install. Drops the
now-dead update-modal shell CSS.
* docs(changelog): update notification popup fix (PR #1142)
The disclosure named fanart.tv but omitted musicbrainz.org, which the
name-to-MBID identity lookup contacts with the artist/album name when an artist
has no tag MBID; it also mis-stated what is sent. Rewrite the note across all 9
locales: names both fanart.tv (artwork source) and musicbrainz.org (identity
lookup with the artist/album name), and is accurate about on-demand/read-only
and that library tags are never changed. Satisfies fanart.tv terms ("inform
your users") and design-review §9.
The project key was injected only at our build time (CI secret / option_env!),
so AUR, Nix and any from-source build had no key baked in and the External
Artwork toggle was inert there. Commit it as a source literal (like the Last.fm
key, and as fanart.tv terms expect: the app ships a project key, users add
their own on top via BYOK). Drops the now-redundant CI wiring.
* feat(cover): add artist_artwork_lookup table + accessors
Image-scraper P0 (design-review §12): additive library-SQLite migration 013
plus get/upsert/clear-per-server accessors for the external artist-artwork
lookup (fanart.tv). Render never reads it; the on-demand cover ensure path
and the mbid_ambiguous 24h negative cache use it. server_id = serverIndexKey.
* feat(cover): add fanart.tv + getArtistInfo2 provider layer
Image-scraper P0 (§7/§19/§23): new cover_cache/external.rs — Rust-side
getArtistInfo2 tag-MBID resolution plus fanart.tv v3/music URL + first
artistbackground fetch (BYOK client_key sent in addition to the project
api_key per fanart.tv ToS, §22). Extract a shared build_subsonic_url helper
in fetch.rs (cover URL behaviour unchanged). Add a dedicated low-concurrency
fanart_http_sem so external HTTP never starves Navidrome (§26). URL builders
unit-tested; wired into ensure_inner next.
* feat(cover): wire fanart.tv external branch into ensure_inner
Image-scraper P0 (§16): on-demand artist `fanart` ensures try fanart.tv
before the Navidrome fallback. MBID resolved Rust-side via getArtistInfo2
(§23, tag MBID); on a miss it falls through WITHOUT a .fetch-failed marker so
Navidrome stays the display fallback (§28). External tiers are written as
{tier}-fanart.webp in the same entity dir (same cacheKind, §16) — 2000 + 512
(matryoshka §17); peek prefers them for the fanart surface. Dedicated
low-concurrency fanart lane (§26); .miss-fanart ~30min negative marker.
Additive IPC args (externalArtworkEnabled, surfaceKind), off by default and
gated by PSYSONIC_FANART_KEY — inert until a render surface opts in (P1).
Quality gate (§11), name->MusicBrainz (§19), and lookup-table writes are P1.
* feat(cover): fanart-first peek for the fanart surface
Image-scraper P0: for an artist `fanart` ensure, the early peek serves only
the external {tier}-fanart.webp tiers; if none exist yet it returns None so
ensure runs the external branch (fetch fanart) instead of short-circuiting on
a cached Navidrome tier. Realises "fanart prioritised" (§18) for the opt-in
surface; Navidrome stays the fallback inside the branch's miss path.
* chore(cover): dev-only artist-fanart spike helper
DEV-only window.psyFanartSpike(name) — resolves an artist by name and fires
the real cover_cache_ensure with externalArtworkEnabled+surfaceKind=fanart to
verify the P0 pipeline against a live server (with PSYSONIC_FANART_KEY set).
Not wired in production.
* feat(cover): §11 quality gate for the fanart surface
Before an external fanart fetch, check whether a Navidrome tier already on
disk is an HQ ~16:9 image (width >= 1280, aspect 1.6-2.0) and skip the fetch
if so — square artist portraits never satisfy it, so the common case still
fetches. Reads tier dimensions only (no full decode). Rust consts per the
design review. Unit-tested predicate.
* feat(cover): persist fanart resolution in artist_artwork_lookup (§12)
Wire the lookup table as both the MBID resolution cache and the negative
cache: a cached MBID skips the getArtistInfo2 round-trip; no_mbid/mbid_ambiguous
back off 24h and miss 30min from updated_at before re-querying. Writes
hit/miss/no_mbid with mbid/mbid_source/provider; transient network errors are
not cached. All store reads/writes run off the async executor via
spawn_blocking and no-op before login. Store reached via app.try_state::<LibraryRuntime>().
* feat(cover): compile-time fanart key fallback + album/name IPC args
A runtime PSYSONIC_FANART_KEY still wins (dev), else the key baked in at build
time via option_env! (release). Add additive artistName/albumTitle ensure args
as context for the §19 name->MusicBrainz fallback (inert until the render
passes them). Library backfill passes None.
* feat(settings): add External Artwork Scraper toggle under Integrations
Master toggle (themeStore, off by default per §20) in a new Integrations
subsection, alongside the other opt-in third-party categories. Contacts
fanart.tv only when enabled. i18n across all 9 locales.
* feat(cover): wire fanart background into the fullscreen player (§28)
New useArtistFanart hook resolves a fanart.tv 16:9 background via a dedicated
cover_cache_ensure (surfaceKind=fanart) — it bypasses the shared peek/disk-src
cache (the {tier}-fanart.webp surface is keyed differently) and reuses
coverDiskUrl for the asset URL. Fullscreen background priority is now
fanart -> Navidrome artist image (cover pipeline) -> album cover; the live
useFsArtistPortrait probe is deleted (§28). Additive ensure opts
(surfaceKind/artistName/albumTitle); externalArtworkEnabled is derived in
ensureArgsFromRef from the master toggle and restricted to the artist fanart
surface, so plain cover ensures are unaffected.
* feat(cover): generalize external surface to fanart + banner (§13)
surfaceKind='banner' fetches the fanart.tv musicbanner array -> {tier}-banner.webp
in the same entity dir; fanart stays the 16:9 artistbackground. The ensure
branch, peek, lookup rows (per-surface surface_kind), miss marker
(.miss-<surface>) and tier suffix are all surface-parameterised. The §11
quality gate stays fanart-only (the banner strip has its own aspect). Unit
test for the surface->fanart JSON key map.
* feat(artist): fanart banner on the artist-detail header (§13, Option B)
The artist-detail header gets an album-detail-style background layer: fanart.tv
banner (musicbanner) -> the 16:9 fanart background cropped to the strip ->
empty. Both via a shared useArtistExternalImage hook (useArtistBanner /
useArtistFanart); each fetches on demand and shares the Rust cache, so the
header and the fullscreen player warm each other's images. The header is its
own stacking context (isolation) so a z-index:-1 banner clips behind the avatar
+ meta with no content wrapper; the album-style framing (padding/radius/clip)
is applied only when a banner is shown, so the off-by-default case stays
pixel-identical.
* refactor(artist): reuse album-detail header structure for the fanart banner
The artist-detail header now uses the same album-detail-* container classes as
AlbumHeader (header/bg/overlay/content/hero) with the fanart banner as the
background; the Back button moves inside the header. A surgical
`artist-detail-bleed` cancels the artist page's .content-body padding so the
banner is full-bleed to the container edges, matching the album header exactly
instead of the earlier inset card. Reverts the experimental artist-specific bg
CSS.
* feat(cover): drop artist_artwork_lookup rows on clear-cover-cache (§12/B.4)
cover_cache_clear_server already removed the server's whole cover dir (so the
{tier}-fanart.webp / -banner.webp tiers + .miss-* markers go with it); also
clear the artist_artwork_lookup rows for that server (off-thread) so no stale
resolution state lingers. Automatic toggle-off purge deferred — turning the
toggle off already hides external artwork (render is gated), and explicit
cache-clear now cleans external state too.
* feat(cover): name->MusicBrainz album-confirmed MBID resolution (§19)
When getArtistInfo2 has no tag MBID and the ensure carries the artist name +
an album in context (fullscreen), one MusicBrainz release-search query resolves
the artist MBID: the primary artist across score>=90 releases wins, conflicting
ids -> mbid_ambiguous (24h backoff), none -> no_mbid. Sends the required
User-Agent; a single-permit musicbrainz_sem + >=1s spacing holds us under MB's
rate limit. mbid_source=musicbrainz persisted. Banner surface (no album
context) correctly skips this. Pure classify/escape helpers unit-tested.
* fix(cover): enable banner surface in ensureArgsFromRef
externalEnsureFields only set externalArtworkEnabled for surfaceKind 'fanart',
so the 'banner' surface never fired — the artist-detail header always fell back
to the fanart image instead of the fanart.tv musicbanner. Both external artist
surfaces (fanart/banner) now enable the external branch.
* feat(settings): optional BYOK personal fanart key field
Add an optional personal fanart.tv API key field to the External Artwork
Scraper block (shown when the toggle is on): a masked input, a saved/in-use
status line, and the simple note that it is sent in addition to the app key.
Persisted in themeStore and plumbed through cover_cache_ensure
(externalArtworkByok); Rust prefers the settings key, falling back to the
PSYSONIC_FANART_CLIENT_KEY dev env. i18n x9.
* fix(cover): resolve artist-page fanart image collision on navigation
The artist-detail header keyed its fanart/banner hooks on the route `id`,
which flips immediately on navigation while `artist`/`albums` refetch a beat
later. The mismatched ensure wrote the previous artist's image under the new
artist's id (e.g. Sepultura's image under Lordi's id).
- key on the loaded `artist.id`, not the route `id`, so id/name/album always
describe the same artist
- pick the §19 album context from an album that actually belongs to this
artist (`albums.find(a => a.artistId === artist.id)`), so a stale album can't
run a mismatched name→MusicBrainz query or cache a wrong `no_mbid`
- reset `src` on every input change in `useArtistExternalImage` so a previous
artist's image never lingers while the new one resolves
* fix(cover): strip trailing album qualifier before MusicBrainz lookup
Library titles like "Show No Mercy (2004 Remastered)" or "Album [Deluxe
Edition]" failed the §19 MusicBrainz release query, blocking name-confirmed
MBID resolution. `normalize_album_for_mb` strips a single trailing
parenthetical/bracketed qualifier; leading qualifiers (e.g. "(What's the
Story) Morning Glory?") are left intact. Unit-tested.
* fix(cover): don't cache no_mbid when album context is unavailable
The banner ensure could fire before the artist's albums loaded, with no album
in context. The old code cached `no_mbid` there and the 24h backoff then
blocked the later ensure that arrived *with* album context. Could-not-attempt
is not tried-and-failed: the no-album branch now returns without persisting.
* fix(cover): don't emit tier-ready for external fanart/banner surfaces
`try_external_fanart` emitted `cover:tier-ready` with the `{tier}-{surface}.webp`
path. That event is keyed by the canonical cover key (cacheKind/cacheEntityId/
tier, no surface), so the frontend `useCoverArtBridge` listener seeded the
Navidrome artist cover's disk-src cache with the external image — leaking
fanart/banner into the plain artist cover (avatar, fullscreen "navidrome-artist"
fallback) even with the scraper toggled off.
Remove the emit: the fanart/banner hooks read the path from the
`cover_cache_ensure` return value, so no event is needed. (No disk-level
overwrite — the suffixed files are never matched by `tier_exists`; this was
frontend disk-src-cache cross-contamination.)
* fix(cover): wait for the final external background before showing it, with fade-in
The fullscreen player and artist-detail header flashed several backgrounds in
sequence while the fanart resolved (upscaled album cover → Navidrome artist
image → fanart), and the artist header could show the fanart first and then
swap to the banner.
- the album cover is no longer a background source — it only feeds the
foreground thumbnail
- the external-artwork hooks return `{ src, pending }` so callers can tell
"still resolving" (hold back) from "resolved, no image" (fall back now)
- fullscreen background: scraper on → fanart, empty while it resolves, Navidrome
artist image only on a confirmed miss; scraper off → Navidrome artist image
- artist header: the banner is preferred — nothing shows while it resolves
(no fanart flash), fanart is the fallback only once the banner misses
- both backgrounds preload the chosen image and fade it in (`onLoad` plus a
`ref` `complete` check so an already-cached image, whose load event can fire
before React attaches the handler, still appears). The header fade is a
scoped inline opacity so the shared `album-detail-bg` class is untouched.
* ci(release): pass PSYSONIC_FANART_KEY into the macOS + Linux builds
* refactor(cover): extract external-artwork ensure into its own module
Pure code move: the on-demand fanart/banner fetch, the quality gate, the
surface-aware peek and the lookup-table cache move from cover_cache/mod.rs
into cover_cache/external_ensure.rs. Behaviour unchanged; mod.rs 1877 -> 1488.
* chore(cover): remove dev-only fanart spike helper
The real render wiring now exercises the external ensure branch, so the
dev-only window.psyFanartSpike helper is redundant.
* feat(cover): purge external artwork on opt-out (B3)
New cover_cache_purge_external command: when the External Artwork toggle is
turned off, drop every fetched {tier}-{provider}.webp, .miss-{provider}
marker and artist_artwork_lookup row across all configured servers, leaving
the canonical Navidrome covers intact. Opting out now removes the
third-party-sourced data instead of just hiding it (design-review §9/§12/B.4).
* docs: changelog, credits and what's new for artist fanart (PR #1137)
* refactor(settings): promote Normalization and Track transitions to own Audio categories
Pull Normalization and Track transitions out of the combined Playback
section into their own top-level SettingsSubSection categories, placed
directly under Audio Output Device. Both follow the established reusable
pattern (SettingsSubSection header + title-less SettingsGroup inside a
single-group settings-card, so the frame-collapse CSS applies).
- New TrackTransitionsBlock extracted from PlaybackBehaviorBlock; the
latter now holds only the Queue behaviour toggle (slated to move to
Personalisation).
- NormalizationBlock's SettingsGroup is now title-less; the section
header and description name it.
- Split the single audio search-index row into three (Normalization /
Track transitions / Playback) so crossfade/replaygain/lufs keywords
focus the right section.
* refactor(settings): consolidate Queue Settings under Personalisation, drop Audio Playback section
Combine Queue Display Mode and Queue Toolbar under one 'Queue Settings'
category in the Personalisation tab, and move the Queue behaviour toggle
(preservePlayNextOrder) there from Audio. The now-empty Audio Playback
section is removed.
- New 'Queue Settings' SettingsSubSection holds three titled groups:
Queue Display Mode, Queue behaviour, and (advanced-only) Queue Toolbar.
The toolbar group keeps its reset, now via a new optional 'action' slot
on SettingsGroup (+ .settings-group-title-action CSS).
- Delete PlaybackBehaviorBlock; its single toggle is inlined.
- Search index: drop the audio 'playbackTitle' row; the personalisation
queue row now points at 'queueSettingsTitle' with merged keywords.
- i18n: add settings.queueSettingsTitle to all 9 locales.
* fix(settings): align queue-toolbar separator label and restore Advanced badge
- QueueToolbarCustomizer: the separator row rendered a 1px rule where other
rows have a 16px icon, so its label sat shifted left. Reserve the full
16px icon column (1px rule centred) so the label lines up. (Preexisting.)
- SettingsGroup gains an optional 'advanced' flag rendering the Advanced
badge; badge + action now share a right-aligned title-end slot
(.settings-group-title-end), so the badge sits just left of the reset
button. Restores the Advanced indicator the Queue Toolbar lost when it
moved from a SettingsSubSection into a group.
* fix(audio): hide the output-device category on macOS instead of showing a notice
Playback is pinned to the system default on macOS, so the picker showed a
notice explaining it does nothing there. Gate the whole Audio Output Device
category out on macOS (`!IS_MACOS` in AudioTab) and drop the now-dead notice
branch + the `audioOutputDeviceMacNotice` string from all 9 locales. The
device-probe hook already short-circuits on macOS, so no work is wasted.
* refactor(settings): box the sidebar customizer groups consistently
The sidebar display toggles sat as a bare div and looked unfinished. Box
them in a SettingsGroup, with the nav-item drag list in a second group.
Render the groups directly in the sub-section content (no settings-card
wrapper) — matching the other Personalisation customizers, which use bare
SettingsGroups. Also drop the settings-card around Queue Settings for the
same reason, so every Personalisation section's boxes share one width and
inset instead of the card-wrapped ones sitting narrower/indented.
* docs(settings): changelog, what's new, and credits for the settings reorg (#1130)
Fold the Audio/Personalisation reorganization into the existing
'Settings — consistent grouped layout' changelog entry (now #1126 + #1130)
and the matching What's New highlight, and add a consolidated settings-
refactor line to the contributor credits.
* refactor(settings): extract reusable SettingsGroup component
Pull the boxed sub-section pattern (bordered panel + accent uppercase
header) introduced for the Audio tab into a reusable <SettingsGroup
title desc> component, and migrate NormalizationBlock and
PlaybackBehaviorBlock onto it. No visual change.
* feat(settings): box the System behavior section into groups
Split the System -> App Behavior card (which bundled tray toggles, Linux
rendering tweaks and the clock format) into titled SettingsGroup panels
(Tray / Linux rendering / Clock) for a clearer, consistent boxed look.
Adds the group titles across all 9 locales.
* feat(settings): box Appearance visual options; optional SettingsGroup title
Split the Appearance -> Visual options card into a Display group and a
Window group (Linux custom titlebar controls). SettingsGroup now allows an
optional/omitted title for plain boxed panels. Group titles added across
all 9 locales.
* feat(settings): box Discord cover source and templates
Group the Discord cover-source toggles and the activity templates into
separate SettingsGroup panels (templates reuse their existing title/desc),
dropping the manual indent and inline header. No new strings.
* feat(settings): box the Music Network section
Give the scrobble-master toggle, the enrichment-primary picker and the
provider list each a boxed SettingsGroup with an accent header, and switch
the add-a-service provider rows to the boxed panel style for consistent
contrast with the rest of settings.
* feat(settings): titled boxes for Integrations sections + cover source label
Wrap the Discord enable toggle, the Discord cover-source picker (now with a
"Cover art source" title and explainer), Bandsintown and Show-in-Now-Playing
in titled SettingsGroup panels so each single-item section gets the same
accent-headed boxed look. New cover-source strings across all 9 locales.
* feat(settings): box Lyrics tab sections
Wrap the lyrics-sources customizer and the sidebar-style picker in titled
SettingsGroup panels (reusing the existing section titles).
* refactor(settings): consistent content inset in SettingsGroup
Add a body wrapper with a small left inset so every boxed section indents its
controls uniformly (title stays flush at the box edge). Defined once in the
component instead of per section.
* feat(settings): box Storage tab sections incl. cover art cache
Wrap media directory, cover art cache strategy, next-track buffering and
downloads in titled SettingsGroup panels (reusing existing section titles);
align the cover-cache table's first column flush with the new content inset.
* feat(settings): box Library tab, relocate Lucky Mix, add SettingsToggle
- Add a reusable SettingsToggle component for the repeated label/desc/switch row.
- Box the Random Mix blacklist (grouping built-in keywords with the custom
filter), Ratings and the Analytics strategy section.
- Move the "Show Lucky Mix in menu" toggle out of the blacklist and up next to
split-mix navigation and now-playing-at-top in the sidebar customizer.
* feat(settings): box remaining Appearance sections
Wrap library grid, UI scale, font and seekbar style in titled SettingsGroup
panels (reusing the existing section titles).
* feat(settings): box System language, logging and backup sections
Wrap language and logging in titled SettingsGroup panels, and rework the
backup section to drop its redundant inline header (the subsection already
provides it) and box its content. About / Contributors / Licenses stay as
free display content.
* feat(settings): box remaining Audio sections
Wrap Hi-Res, equalizer, playback speed, audio output device and track
previews in titled SettingsGroup panels (reusing the existing section titles).
* feat(settings): box Input tab keybinding sections
Wrap the in-app and global shortcut lists in titled SettingsGroup panels.
* feat(settings): box the Personalisation tab
Convert every customizer (sidebar, home, artist, queue toolbar, playlist,
player bar) to plain inner containers and wrap each in a titled SettingsGroup,
so the boxed look is consistent without nested cards. Box the queue display
mode picker too.
* feat(settings): box the Themes sections above the store
Add a boxed option to the flat Themes sections and apply it to Your Themes,
Auto-Switch Theme and Import (the theme store stays unboxed). Drop the inner
settings-card from InstalledThemes, ThemeImportSection and the scheduler so
the SettingsGroup is the only frame.
* fix(settings): give the Themes boxes a card carrier and accent header
The flat Themes sections had no settings-card behind them, so the bg-app
SettingsGroup had no contrast against the page. Box each above-store section
as settings-card > titled SettingsGroup (accent header) like the rest.
* fix(settings): clean up the server library sync status line
Collapse the offline status to a single icon + phrase ("Server offline —
sync deferred") instead of a redundant "Deferred" badge alongside it, and
drop the now-unused string across all 9 locales.
* fix(queue): keep toolbar toggle buttons coloured active while hovered
The generic .queue-round-btn:hover rule outweighed .active in specificity, so
an active button kept the hover colour until the pointer left — the toggle
only looked on after moving the mouse away. Add an explicit active-hover rule
(mirroring the mini player) so active wins immediately on click.
* fix(settings): fix Lyrics triple title, drop dead Discord cover keys
- Remove LyricsSourcesCustomizer's own section header (the subsection already
titles it) and make its inner cards plain; the Lyrics boxes are now title-less.
- Drop the dead discordCoverSource/discordCoverSourceDesc i18n keys (superseded
by discordCoverTitle/Desc) and un-jam the libraryIndexServer status line in
all 9 locales.
* refactor(settings): drop redundant group titles and double frames
Single-group sections duplicated the subsection title in the group header — make
those groups title-less so the subsection header is the only title. A card whose
sole child is one group now collapses via CSS so single-group sections render a
single frame (matching the card-less Lyrics layout) instead of a card-in-group
double border. Multi-group cards keep their frame as the grouping container.
* fix(settings): keep the flat Themes cards framed when collapsing single-group cards
The single-group card collapse also flattened the Themes section cards, which —
not being inside an accordion — are themselves the contrast surface. Exclude
.themes-section descendants from the collapse.
* refactor(settings): use SettingsToggle for Appearance and System toggle rows
Replace the hand-rolled toggle-row markup in the Appearance visual options and
System tray/Linux/changelog rows with the shared SettingsToggle component, and
extend it with a searchText prop and ReactNode descriptions for the remaining
call sites.
* refactor(settings): roll SettingsToggle out to Integrations, Audio and Storage
Replace hand-rolled toggle rows in the Discord/Bandsintown/Now-Playing,
Hi-Res, hot cache and playback behaviour/rate sections with SettingsToggle.
Make its label optional (desc-only rows whose title is the group header) and
add searchText/id pass-throughs.
* refactor(settings): use SettingsToggle for the remaining standard toggle rows
Track previews master, sidebar lyrics style, YouLyPlus/static-only, queue
display mode and the sidebar split-nav/now-playing/lucky-mix rows now use the
shared component. Genuinely custom rows (drag lists, the skip-star threshold,
the AudioMuse row with its inline link, the fine-step advanced badge) stay
hand-rolled.
* fix(settings): drop duplicate titles on Hi-Res/Bandsintown/Now-Playing, un-jam locale line
These single-toggle sections kept a group title identical to their subsection
header; once the single-group card collapses the title rendered twice stacked.
Make them title-less (the subsection header is the label). Also split the
discordCoverTitle locale entry onto its own line in all 9 locales.
* feat(settings): restore section icons in boxed Themes headers
SettingsGroup gained an optional accent icon slot rendered before the
title; ThemesTab forwards the Palette/Clock/Upload icons the boxed
sections lost when they moved off the bespoke <h2> header.
* style(settings): re-indent SettingsGroup children to nesting level
Pure whitespace: children wrapped in SettingsGroup were left at their
pre-wrap indent. No content change (git diff -w is empty).
* fix(settings): clarify Native Hi-Res Playback description
The old copy led with the disabled-state behaviour ("forces 44.1 kHz"),
which read as if the toggle itself locked output to 44.1 kHz. Describe
what enabling actually does: play each track at its native sample rate
(reconfiguring the output device to match) instead of resampling to
44.1 kHz. Updated across all 9 locales.
* docs: changelog + what's-new for the settings layout refactor (#1126)
* feat(playback): add transition-mode helper
Centralise the crossfade/AutoDJ/gapless mutual exclusivity in one place
instead of the scattered setter combinations across the toolbar, mini
player and settings. AutoDJ stays encoded as crossfade + trim-silence, so
the persisted flags and the audio engine are unchanged.
* feat(queue): split AutoDJ into its own toolbar button + playlist submenu
- AutoDJ becomes a standalone toolbar button (Blend icon) next to
crossfade, driven by the shared transition-mode helper. The crossfade
right-click popover drops the mode switch and keeps only the seconds
slider.
- Save + load playlist collapse into one Playlist button opening a small
submenu, freeing up toolbar space.
- queueToolbarStore gains a position-preserving rehydrate migration
(legacy save/load -> playlist, autodj inserted after crossfade) with
unit tests.
- Toolbar customizer and all 9 queue locales updated to match.
* feat(mini-player): standalone AutoDJ button, shared transition helper
Mirror the queue toolbar: AutoDJ gets its own Blend button, the crossfade
popover keeps only the seconds slider. The mini player now drives all
three transitions through a single additive `mini:set-transition-mode`
event handled by the shared helper, replacing the per-flag mini events.
Drop the now-dead crossfade-mode CSS.
* feat(settings): segmented track-transition picker, regroup playback
Replace the crossfade toggle + inner crossfade/AutoDJ switch with a single
Off | Gapless | Crossfade | AutoDJ segmented control (mirroring the
Normalization picker above), driven by the shared transition helper — the
mutual exclusivity now reads at a glance. Crossfade keeps its seconds
slider; AutoDJ shows its content-driven explainer. The block is regrouped
under "Track transitions" and "Queue behaviour" headings. Drops the now
unused crossfade/gapless description and not-available i18n keys across all
9 locales and adds the new transition strings.
* fix(queue): open the playlist submenu inward
The playlist submenu inherited the crossfade popover's right:0 anchor and,
sitting on the left of the toolbar, opened out under the main container.
Anchor it left:0 so it stays inside the queue panel. Update the toolbar
test for the new playlist submenu (save/load moved off the toolbar).
* feat(settings): box playback sub-sections into panels
Wrap Normalization, Track transitions and Queue behaviour each in their own
bordered panel with an accent uppercase header (new reusable .settings-group
classes), so the sections read as distinct blocks instead of one wall of
text. Drops the thin divider that separated them.
* docs: changelog, credits and what's new for AutoDJ standalone
Fold the standalone-AutoDJ changes into the existing AutoDJ entry in the
changelog and the in-app What's New, and add the credit (#1124).
* feat(crossfade): add "trim silence between tracks" toggle
New persisted setting `crossfadeTrimSilence` (default off; existing
installs rehydrate off via the persist default-merge). Surfaced in
Settings -> Audio and in the crossfade popovers of the queue toolbar
and the mini-player.
Crossfade buttons now separate the two actions: left-click toggles
crossfade on/off, right-click opens the settings popover (seconds +
trim). Shared the mini popover positioning into useMiniAnchoredPopover
(now backing both volume and crossfade). Mini bridge carries
crossfadeSecs/crossfadeTrimSilence and gains mini:set-crossfade-secs /
mini:set-crossfade-trim-silence.
The actual silence-trimming playback behaviour is wired in a follow-up;
this commit only persists the user intent. i18n added across 9 locales.
* feat(crossfade): trim silence between tracks (waveform-driven)
Wire the actual silence-aware crossfade behind the (default-off)
crossfadeTrimSilence toggle. Detection is derived on the fly from the
cached 500-bin waveform + track duration — no new analysis pass or
cache fields.
- waveformSilence.ts: computeWaveformSilence(bins, duration) → lead/trail
silence + content bounds, using the peak curve, a low absolute cut and
a per-side cap. Unit-tested.
- A-tail (JS): handleAudioProgress advances the crossfade early, at
contentEnd - crossfadeSecs, when the current track ends in real
trailing silence, so the fade overlaps music. Guarded once per play
generation.
- B-head: audio_play gains an additive optional start_secs; the freshly
built source is try_seek'd past the next track's leading silence before
append, then seek_offset/samples_played are re-anchored so position is
content-relative. Non-seekable / cold sources degrade to today.
- Pre-buffer: crossfade next-track download + B-head probe moved to
crossfadePreload.ts with a fixed ~30 s budget before the track needs to
play (widened by trailing silence so the early advance keeps the
budget). Also fired right after a seek into the window so jumping near
the end still buffers in time.
Checks: tsc, vitest (store suite + new units), cargo test/clippy for
psysonic-audio.
* feat(crossfade): recommend hot cache for trim; probe B-head regardless
Add a "for reliable results, enable the Hot playback cache" note to the
trim-silence toggle description across all 9 locales — hot cache keeps
the next track on disk so it starts instantly past its lead silence.
Fix: the leading-silence probe (B-head) now runs even when hot cache is
on; only the redundant byte pre-download is gated on !hotCache. Without
this, enabling hot cache (the recommended setting) would have skipped the
probe and disabled leading-silence trimming.
* feat(crossfade): content-driven smart crossfade overlap
Smart crossfade derives the per-transition overlap purely from the
waveform envelopes — max(A outro fade, B intro rise) clamped 0.5–12s —
instead of the fixed crossfadeSecs ("work by fact"). The JS early
advance arms the computed overlap and audio_play applies it through a
new crossfade_secs_override (capping only this swap's fade); plain
loud→loud endings fall back to the engine crossfade at crossfadeSecs.
* feat(crossfade): "Crossfade | Smart crossfade" mode switch in UI
Replace the standalone "trim silence" toggle with a Crossfade / Smart
crossfade segmented control in settings and both crossfade popovers
(queue toolbar + mini-player). Classic Crossfade shows the seconds
slider; Smart crossfade is content-driven with no duration to set.
Adds smartCrossfade / smartCrossfadeDesc strings to all nine locales
and the "smart crossfade" search keyword.
* feat(crossfade): don't double-fade a track that already fades out
Decouple the outgoing track's fade-out from the incoming fade-in. When
A carries its own recorded fade-out (outroFadeA ≥ 1s and ≥ B's intro
rise), planCrossfadeTransition now sets outgoingFadeSec = 0; the engine
then skips A's TriggeredFadeOut so A keeps full gain and its recording
carries it down while B rises underneath — no more double attenuation
that made A vanish early and B blare in. Hard-cut endings still get an
engine fade over the overlap.
audio_play gains outgoing_fade_secs_override (Some(0) = ride A's own
fade), threaded via SinkSwapInputs.outgoing_fade_secs; the JS advance
arms it alongside the overlap.
* feat(crossfade): rename the smart crossfade mode to "AutoDJ"
User-facing rename of the content-driven crossfade mode from "Smart
crossfade" to "AutoDJ" across the settings segmented switch, the queue
and mini-player popovers, all nine locales, and the settings search
keywords. The underlying store flag (crossfadeTrimSilence) is unchanged.
* feat(crossfade): standard ~2s blend for hard loud→loud meetings
When AutoDJ trims a track's protective trailing silence and the loud
ending butts straight into a loud intro, neither edge fades, so the old
0.5s anti-click floor sounded like an abrupt cut. Use a standard ~2s
equal-power crossfade for that case (both edges analysed, nothing
fades); real fade-outs/buildups keep their longer content-driven span,
and the bare floor only survives when an envelope is missing.
* fix(crossfade): keep B's fade-in across the B-head start-offset seek
EqualPowerFadeIn::try_seek jumped straight to unity gain for any seek
≥100ms, which also hit the initial start-offset seek that skips the
incoming track's leading silence — so a crossfaded track with trimmed
lead silence popped in at full gain instead of fading in. Only skip the
fade-in for mid-playback seeks (sample_count > 0); a seek before any
audio has played keeps the fade-in.
* feat(crossfade): gate AutoDJ early fade on next-track readiness
The early, content-driven advance now fires only when the next track's
audio is actually available — in the engine RAM preload slot
(enginePreloadedTrackId) or local on disk (offline library, favourite-auto
or hot-cache ephemeral) — via isCrossfadeNextReady(). Analysis alone is
not enough: a cold, still-buffering stream would fade in over silence.
When B isn't ready the gen guard stays unset so it re-checks on later
ticks; if B never readies, the plain engine crossfade handles the
transition (graceful degrade) instead of a broken fade. A RAM preload
copy suffices — the full track need not be cached to disk.
* feat(crossfade): suppress engine auto-crossfade for AutoDJ + eager preload
With the hot cache off the readiness gate alone wasn't enough: the engine's
progress task autonomously fires its crossfade audio:ended ~crossfadeSecs
before the end, independently of JS, and would start a still-buffering next
track and fade over it — an audible jump.
- Engine: add autodj_suppress_autocrossfade flag (audio_set_autodj_suppress);
the progress task treats it like crossfade-off, so the early timer never
fires and audio:ended only comes from real source exhaustion / watchdog.
- JS drives the transition: set the flag when a content fade is pending
(wantEarly) and clear it for plain loud→loud / non-AutoDJ, so the normal
engine crossfade is preserved there. When the next track never readies, A
plays out and we degrade to a clean sequential start instead of a jump.
- audio_preload gains an `eager` flag; the crossfade/AutoDJ pre-buffer passes
it to skip the 8s start throttle so the RAM slot fills before the fade.
* docs(changelog): AutoDJ content-aware crossfade (PR #1122)
Add the 1.49.0 Added entry and the cucadmuh credits line for AutoDJ.
* feat(albums): "Artist → Year" sort option
Adds a third album browse sort that groups albums by artist and orders each
artist's albums chronologically (oldest first, title as a same-year tiebreak)
— the double-sort requested in #1113. The local index sorts globally via
[{artist},{year},{name}]; the server fallback fetches by artist and applies the
year ordering per page (Subsonic has no compound sort).
* i18n(albums): Artist → Year sort label (9 locales)
* docs(changelog): add Artist → Year to the album sorting entry (#1120)
* feat(playlists): playlist folder model — store + pure grouping core
Local, per-server folder layer over the server's flat playlist list (the
Subsonic API has no folder concept). Adds:
- playlistFolders.ts: shared types + pure groupPlaylistsByFolder (used by
every surface), with full unit coverage.
- playlistFolderStore.ts: persisted Zustand store (create/rename/delete/
assign/collapse), per-server scoped; deleting a folder drops its
assignments so playlists fall back to ungrouped.
UI surfaces (sidebar + Playlists page) build on this in following commits.
* i18n(playlists): folder strings across all 9 locales
Adds the playlists.folders.* namespace (folder names, move/remove, expand/
collapse, group-by-folders toggle, count plurals, and the local-only notice
explaining that Navidrome and the Subsonic API have no native folder support).
* feat(playlists): folder views, drag-to-folder, move-to-folder menu, view toggle
Surfaces the local folder layer on both the Playlists page and the sidebar:
- Page: collapsible folder sections + ungrouped remainder, each reusing
VirtualCardGrid; flat grid is kept verbatim when no folders exist or the
group view is toggled off.
- Drag-to-folder via the shared mouse-based psy-drop system (HTML5 DnD is
unusable in WebKitGTK); the whole section is the drop zone, and the
ungrouped zone appears during a drag so a playlist can always return to root.
- "Move to folder" submenu in the playlist context menu (keyboard-accessible
path; also creates folders on the fly) — stays available offline.
- Header gets a "New folder" action and a "Group by folders" view toggle
(both context-aware); a notice surfaces the local-only caveat.
- Sidebar renders the same collapsible folder groups.
- groupView preference added to the folder store.
* docs(changelog): note playlist folders (#1119)
* fix(player): player bar context menu acts on the current song, not its album
Right-clicking the current track in the player bar built an album object
from the playing track and opened the album context menu, so "Add to
playlist" added the whole album instead of the song. It now opens a
song-scoped menu for the current track. Left-click on the title still
navigates to the album.
* docs(changelog): note player bar add-to-playlist fix (#1117)
* fix(albums): order each artist's albums by title when sorting by artist
Browsing albums by artist left the albums within each artist in an
undefined order. The local-index sort only emitted the artist key; it
now appends album title as a secondary key (and artist as the tiebreak
for the by-name sort), matching the network path's per-page ordering.
* docs(changelog): note album sort-within-artist fix (#1115)
* fix(windows): restore taskbar thumbnail media buttons after deferred window show
The Prev / Play-Pause / Next buttons in the Windows taskbar thumbnail
preview stopped appearing. The taskbar code was unchanged; the regression
came from the main window now starting hidden with a deferred show. ThumbBarAddButtons was still called at setup time, before the shell had
created the window's taskbar button, so it returned S_OK but added
nothing (no error logged).
Register the shell's "TaskbarButtonCreated" message and add the buttons
from the window subclass when it fires (first show, and again after an
explorer restart), which is the documented requirement for ThumbBarAddButtons.
* docs(changelog): note Windows taskbar media buttons fix (#1112)
power_notify_win.rs imports windows::Win32::System::Power and
UI::WindowsAndMessaging, but the crate only declared Foundation/Com/Threading.
It compiled solely via workspace feature unification (the root src-tauri crate
enables them), so building or testing psysonic-audio in isolation on Windows
(cargo check/test -p psysonic-audio) failed with E0432 unresolved imports.
Declare the two features the crate actually uses so it builds standalone.
No behaviour change; workspace/release builds already had these features.
* fix(audio): make seeking work on streamed Opus/Ogg via on-demand HTTP Range
Seeking inside an Opus/Ogg track streamed over ranged HTTP was a contained
no-op (the seekbar snapped back); it only worked once the track had fully
downloaded/cached. symphonia 0.6's Ogg demuxer seeks by bisecting the byte
range (reading pages at midpoints across the whole file) and scans the last
pages during the probe, but RangedHttpSource only filled the buffer linearly
from offset 0, so any read ahead of the download front blocked until the
linear download caught up. Keeping Ogg seekable through the probe without a
real random-access source would have forced a full pre-download.
Add an on-demand random-access fetcher to RangedHttpSource: when a read
lands well ahead of the contiguous linear download (a seek, a bisection
midpoint, or the end-of-stream probe), fetch the needed range over HTTP Range
(1 MiB window) on the tokio runtime and let the read loop poll for it, instead
of blocking on the linear filler. Ranged Ogg now stays seekable through the
probe (records its byte range) so seeking works for real; the catch_unwind in
try_seek stays as a safety net.
- New OnDemand fetcher writes arbitrary ranges into the shared buffer (same
bytes the linear download would write; idempotent under the buffer mutex,
mirroring the existing MP4 moov tail-prefetch). It never touches
downloaded_to/done, so full-download completion and the track-analysis seed
are unaffected.
- On-demand only fires on a forward gap > 512 KiB, so normal sequential
read-ahead (and a slightly starved play cursor) still waits for the linear
download without spurious range requests.
- ranged-stream now passes random_access=true; preview keeps on_demand=None.
Does not touch the Tauri boundary (no invoke/event changes).
* docs(changelog): add 1.49.0 entry for streamed Opus/Ogg seeking (#1110)
Also credit the streamed-seek work in settingsCredits.
* fix(audio): require 206 for ranged Range fetches at a non-zero offset
Address PR #1110 review note: ranged_write_http_range accepted a 200 the
same as a 206. A server that ignored the Range header and replied 200 returns
the whole body from byte 0; writing that at a non-zero offset would corrupt
the buffer (affects both the on-demand seek fetcher and the MP4 moov-tail
prefetch). Accept 200 only when the request started at offset 0; otherwise
require 206.
Backport of the 1.48.1 hotfix notes onto main (which is on 1.49.0-dev).
Insert the released [1.48.1] section between [1.49.0] and [1.48.0] in
CHANGELOG.md (all eight Fixed entries with their attribution, identical to the
fix/1.48.1 branch) and the matching [1.48.1] What's New section. Application
version is intentionally left at 1.49.0-dev — only the notes are carried over.
Windows SMTC could not render our cached WebP album covers: souvlaki loads the
file and SetThumbnail/set_metadata succeed, but the lock screen and Quick
Settings media tile showed a blank cover, because the OS thumbnail decoder does
not handle WebP even with the Store WebP extension installed.
Transcode local file:// WebP covers to PNG (libwebp decode then image PNG
encode, into a single reusable temp file) before handing them to the OS media
controls, gated to Windows. macOS (ImageIO) and Linux pass through unchanged.
(cherry picked from commit 76d028127d)
The Windows system media controls (Quick Settings media tile, lock screen,
third-party media flyouts) labelled playback as "Unknown application" with no
icon, because souvlaki creates the SMTC via GetForWindow and Windows resolves
the source name from the process AppUserModelID, which was never set.
Call SetCurrentProcessExplicitAppUserModelID early in run() so the process has
an explicit identity that matches the installer shortcut's AppUserModelID;
Windows then resolves the name and icon to Psysonic.
(cherry picked from commit 4fd85f2dd4)
* fix(library): use partial indexes for §6.9 remap lookup
The delta-sync remap detection ran a single lookup with an OR across
content_hash and server_path. SQLite could not use the partial
idx_track_remap_hash / idx_track_remap_path indexes for it: a partial
index is only applied when the query's WHERE provably implies the index
predicate (… != ''), and an OR spanning two columns blocks the per-branch
index plan. The query degraded to a full track scan on every incoming
row → O(rows × catalog), causing multi-minute stalls on large libraries
(observed upsert_batch_remap exec_ms=162001 on a ~200k-track Navidrome
sync, which in turn blocked all other writers on the single write mutex).
Split it into two single-column lookups that each repeat the index
predicate so the planner picks the matching partial index (SEARCH, not
SCAN); hash is checked first, matching §6.9 strong-key priority. Adds an
EXPLAIN QUERY PLAN regression test asserting index usage.
* docs(changelog): note remap-lookup sync stall fix (PR #1105)
(cherry picked from commit bca0acbaff)
* fix(audio): fix Opus/Ogg seek crash by keeping random-access sources seekable through probe
Scrubbing the seekbar on Opus/Ogg files (then pressing Stop) crashed the whole
app. symphonia 0.6's Ogg demuxer records the physical stream's byte range only
when the source is seekable during the probe, but ProbeSeekGate hid seekability
there — so phys_byte_range_end stayed None and the first seek hit
Option::unwrap() on None on the cpal audio thread. That poisoned the engine
mutexes and aborted the process at the non-unwinding cpal FFI boundary (the
"crash on Stop" was a downstream symptom).
- Keep Ogg/Opus seekable through the probe on random-access sources (local
files, in-memory) so the demuxer computes its seek bounds and seeking works
for real. Progressive ranged-HTTP keeps the gate to avoid forcing a full
download before playback starts.
- Contain any demuxer unwind inside SizedDecoder::try_seek (catch_unwind) so a
panic on the audio thread can no longer poison engine state — covers streamed
Ogg (still gated) and any future demuxer panic.
- Thread a random_access flag through PlayInput::SeekableMedia and new_streaming.
* docs(changelog): add 1.48.1 entry for the Opus/Ogg seek crash fix
Also note the Opus seek crash fix in WHATS_NEW.md (1.48.1).
(cherry picked from commit 8bfde08199)
The macOS red close button always emitted app:force-quit, which exits
unconditionally and never checked the minimizeToTray setting — so on macOS
the window closed the app even with "Minimize to Tray" enabled. Route the
main-window close through window:close-requested on all platforms, so JS
decides hide-vs-exit from the setting (default off = unchanged quit). The
tray "Exit" item still force-quits.
Fixes#1103
(cherry picked from commit acd6f12aba)
Follow-up to the dual-address cover fix: a playback/active cover scope was
routed through getPlaybackServerId(), which returns a `string` that can be
empty or an index-key (not a profile id) for locally-cached tracks. The
`?? activeServerId` fallback never fired (empty string isn't nullish), so no
profile matched and the URL came back null — which the presence layer caches
per cover id, producing intermittent "cover shows, then doesn't".
A playback/active scope always means the active server (a cross-server track
gets an explicit `server` scope), so resolve the active profile directly.
(cherry picked from commit 8f93f30e6f)
The Discord cover URL was built via the connect endpoint, which prefers the
LAN address — but Discord fetches the image from its own servers, so a LAN
address is unreachable and the cover falls back to the app icon. This is a
dual-address regression: before a second (public) address could be added,
the only configured URL was the public one.
Build the Discord large-image URL via serverShareBaseUrl (public preferred,
like share links / Orbit invites) instead of the connect URL. Adds a test.
(cherry picked from commit 5f15784b7d)
Third defence for #1094: on a device change while paused/stopped,
reopen_output_stream stopped the old sink without bumping the engine
generation (the bump only happened on a successful internal resume). The
still-running progress task could then flip done_flag and emit a spurious
audio:ended, which the frontend turns into a restart. Bump the generation
before sink.stop() in the non-playing case so the progress task bails out;
the active-playback path keeps bumping inside try_resume_after_device_change.
(cherry picked from commit 9034882bf6)
Defence-in-depth for #1094: the device-changed/-reset handlers restarted
playback based on the UI `isPlaying` flag alone, which can be stale or
desynced at the moment of a device change. Gate the restart on the
engine-paused flag as well (`isPlaying && !getIsAudioPaused()`), so a
paused engine never auto-restarts regardless of how `isPlaying` got set;
when paused it just resets for the cold path. Adds a regression test.
(cherry picked from commit 588dd8c48d)
Toggle, Play and Pause from the OS media-control bridge (souvlaki) were
all collapsed onto the play-pause toggle event. On an audio-route change
(e.g. macOS sending an explicit Pause when headphones disconnect) this
turned the pause into a toggle, resuming paused playback on the new
output device.
Map Play and Pause to dedicated media:play / media:pause events (the
frontend handlers already exist); only a real toggle key emits
media:play-pause. Paused playback now stays paused across device changes.
Fixes#1094
(cherry picked from commit f04bfb3d35)
QueueHeader aggregated total/future duration in a useMemo keyed on queueIndex,
so every skip ran a synchronous O(n) pass over the entire queue (resolveQueueTrack
per item). On very large queues this blocked the main thread for seconds — the
UI froze on skip and on the device-switch playTrack fallback (#1072; the freeze
half of #1090). QueuePanel is always mounted, so it hit even with the queue
collapsed.
Build a cumulative-duration prefix keyed on queue/resolver-version only; the
future-tracks total is now an O(1) lookup per skip. Display output unchanged.
(cherry picked from commit 7e91a5b2a1)
* feat(themes): show theme version in the store and installed list
* feat(themes): filter the theme store by animated / static
* docs(changelog): add 1.49.0 entry for theme store version + animated filter
The rebuilt fullscreen player hardcoded Apple-style lyrics scrolling (active
line ~35% from the top) and ignored Settings -> Lyrics -> Lyrics scroll style,
which still drives the sidebar/mobile LyricsPane. FsLyricsApple now reads
sidebarLyricsStyle: 'classic' centres the active line, 'apple' keeps the 35%
anchor, matching the sidebar lyrics.
Add highlights for Live status dots, playback Semitones, Linux title bar
styles, Music Network self-hosted scrobble fix, and Navidrome radio admin
gating. Remove meta What's New page section. Re-sort CHANGELOG Fixed
entries (#1085/#1086 were at the top).
* feat(now-playing): liveness indicator dot in the listening popover
Replace the raw "Nm ago" line in the "Who is listening?" popover with a
derived presence dot (green playing / amber paused / dim idle). The presence
is computed in one tested helper that unifies the playbackReport transport
state with the legacy getNowPlaying recency, instead of formatting a raw
timestamp inline. The dot carries the localized status as an aria-label and
tooltip so it is not conveyed by colour alone.
* feat(radio): gate station create/edit/delete behind Navidrome admin role
Navidrome >= 0.62 restricts internet-radio management to admins
(GHSA-jw24-qqrj-633c); non-admin requests fail. Hide Add Station, Search
Directory, the per-card edit chip and delete button for confirmed standard
Navidrome users via a canManageNavidromeRadio() helper on the existing
useNavidromeAdminRole framework. Admins, non-Navidrome servers and transient
states stay unrestricted; playback and favourites remain available to all.
* docs(changelog): now-playing status dot + admin-gated radio (#1086)
* feat(playback): varispeed-by-semitones strategy and 2-decimal speed label
Adds a fourth playback-rate strategy "Varispeed (semitones)" — a frontend
lens over varispeed where the user dials the pitch change directly in
semitones (±12 st, 0.1 step) and speed = 2^(st/12). Engine contract is
unchanged: the store maps it to the existing "varispeed" Rust strategy via
engineStrategy(), and switching between the two varispeed lenses at the same
speed no longer restarts the track.
Also widens the speed readout to two decimals (formatSpeedLabel → toFixed(2))
so every 0.05 slider step is visible (1.05×, 1.10×, 1.15×), addressing the
feedback on issue #531 that the label looked stuck between steps.
Includes new i18n keys and updated hint across all 9 locales, plus Vitest
coverage for the new helper, label formatting, and the no-restart lens switch.
* refactor(playback): shorten Semitones strategy label, add per-strategy tooltips
Renames the fourth strategy to a concise "Semitones" (was "Varispeed
(semitones)") across all 9 locales, and lets the four strategy buttons share
the row width so they fit on one line in Settings while still wrapping in the
narrow player popover. Each strategy button now has a short hover tooltip
explaining how it is built (incl. the 2^(st/12) and 12 × log2(speed) maths).
* feat(playback): advanced fine-step precision for speed/pitch sliders
Adds an opt-in "Fine adjustment" toggle in Settings → Audio (visible only in
Advanced mode) that shrinks the playback-rate slider steps to 0.01× for speed
and 0.01 st for pitch/semitones, with the pitch readout widening to two
decimals to match. Default behaviour (0.05× / 0.1 st) is unchanged. The
preference is UI-only (persisted, not sent to the engine) and also applies to
the player-bar popover and wheel. Addresses the finer-precision request on
issue #531 without cluttering the default UX.
* docs: changelog and credits for playback speed follow-up (PR #1084)
* fix(music-network): keep API suffix for self-hosted paste-token providers
The api_key_only connect strategy persisted the raw origin from the baseUrl
field instead of the resolved API base, dropping the preset's
selfHostedApiSuffix (e.g. /apis/listenbrainz). Scrobbles and now-playing then
hit <origin>/1/submit-listens (404/405, silently unlogged) instead of
<origin>/apis/listenbrainz/1/submit-listens, so nothing was recorded.
Return the runtime-resolved ctx.baseUrl (origin + suffix) and fall back to the
field only when it is absent. Fixes Koito and both Maloja compat surfaces
(ListenBrainz and Audioscrobbler). Existing accounts must reconnect to
re-persist the corrected base.
* docs(changelog): self-hosted scrobble URL fix (#1085)
* feat(titlebar): selectable window button styles + minimize toggle
Custom title bar (Linux) gains a window-button style picker, mirroring
the seekbar style picker pattern. Six form-named styles: dots, dotsGlyph,
flat, pill, outline, glyph. All buttons now carry minimize/maximize/close
glyphs for clear, colour-blind-friendly iconography; dots reveal glyphs on
hover, dotsGlyph always shows them.
- New authStore state windowButtonStyle (default dots) + showMinimizeButton,
with rehydrate validation falling back to dots on unknown values.
- WindowButtonPreview reuses the real .titlebar-btn classes for WYSIWYG tiles.
- Picker + minimize toggle render under the Custom title bar setting, gated
on the toggle being on.
- Monochrome styles use --text-primary glyphs and stronger borders for
contrast on dark themes.
- Dev-build grey marker scoped to the real title bar so previews show true
colours.
- i18n keys in all 9 locales; setter and rehydrate tests.
* docs(changelog): window button styles (#1083)
* docs(whats-new): refresh 1.48.0 highlights for release screen
Reorder and expand user-facing copy to match CHANGELOG Added order,
add Music Network and Live, drop stale About/licenses line, replace
the vague Fixed placeholder with grouped fixes, and add a short Under
the hood section for significant non-UI work.
* docs(whats-new): order 1.48 sections by user impact
Sort Highlights, Fixed groups, and bullets within each block so the
most noticeable changes for everyday use appear first.
* docs: CHANGELOG 1.48 strict PR order; clarify WHATS_NEW sorting
Reorder Added/Changed/Fixed entries in CHANGELOG [1.48.0] by ascending PR
number. WHATS_NEW keeps impact-based order; add a maintainer note that
the two files intentionally differ.
* chore(deps): bump esbuild to 0.28.1 for GHSA-g7r4-m6w7-qqqr
Closes Dependabot alert #17 — path traversal in esbuild dev server
(--servedir) on Windows; patched in 0.28.1.
* chore(nix): sync npmDepsHash with package-lock.json
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix(installer): write logs to stderr so the download URL isn't polluted
* fix(installer): read the reinstall prompt from /dev/tty
* fix(installer): add --fail --globoff to the package download
* fix(installer): degrade gracefully when no controlling terminal is available
* feat(now-playing): adopt OpenSubsonic playbackReport for live now-playing
Drive a small playback state machine (starting → playing ↔ paused → stopped)
on the Subsonic-server channel when the server advertises the OpenSubsonic
`playbackReport` extension (Navidrome ≥ 0.62), giving `getNowPlaying` a real
transport state and an extrapolated position. Reports send `ignoreScrobble=true`
so play counts stay on the existing `scrobble.view` 50% path (no double count),
and the effective playback speed is included so the server extrapolates position
correctly with the speed feature on.
- New `playbackReportSession` FSM mirrors the existing `playListenSession`
lifecycle hooks and is wired at the same player call sites (start / gapless
switch / queue restore / resume / 15s heartbeat / pause / seek / stop / ended /
error / app quit). Servers without the extension degrade to the unchanged
legacy `scrobble.view?submission=false` presence call.
- Gated through the existing serverCapabilities framework: a new
`FEATURE_PLAYBACK_REPORT` (auto, extension-detected). The OpenSubsonic
extensions probe now stores the full advertised list once and serves both
AudioMuse `sonicSimilarity` and `playbackReport` from it, without disturbing
the legacy Instant Mix opt-in on pre-0.62 servers.
- Now Playing dropdown shows a live position bar and a paused indicator.
- reportPlayback uses the real request params (mediaId / mediaType / positionMs).
Tests: FSM transitions + gating + legacy fallback, capability resolution,
extension-list probe storage/decoupling. Full suite green; no Tauri-boundary
changes.
* feat(now-playing): glide the Live position bar between polls
Extrapolate the position of `playing` entries locally (elapsed × reported
playbackRate from the last 10 s poll) and re-render once a second, so the Live
progress bar advances smoothly instead of jumping on each refresh. Paused and
position-less entries stay frozen. A linear width transition matched to the tick
keeps the fill gliding. Only applies to clients that report a position via the
playbackReport extension.
* fix(now-playing): tighten Live timer layout and report resume immediately
Keep the progress-bar width stable without a wide empty gap before the
timer: reserve ~2ch inside the current-time span only (right-aligned), not
on the whole clock block. Report `playing` to the server as soon as
resume() runs, matching the immediate `paused` report on pause instead of
waiting for the Rust `audio:playing` event.
* docs: CHANGELOG and credits for playbackReport live now-playing (PR #1080)
Move the source-building pipeline out of play_input.rs into a focused
source_build.rs: BuildSourceArgs/PlaybackSource, the ranged-stream probe
fallback (build_playback_source_with_probe_fallback) and its private helpers
(build_source_from_play_input, wait_or_fetch_bytes_for_stream_fallback, etc.).
play_input.rs now only handles source *selection* and drops from 799 to 420
lines; helpers that became module-internal lose their pub(super) visibility.
No behavior change.
* fix(audio): release idle output stream after 60s (#1071)
Lazy-open CPAL on first playback and close the device handle after one
minute without active audio so Windows can sleep; emit output-released
for cold resume and skip post-wake reopen when idle.
* docs: CHANGELOG and credits for idle audio stream fix (PR #1073)
* fix(audio): satisfy clippy if-same-then-else in idle watcher
* fix(audio): silence rodio DeviceSink drop unless logging is debug
Gate log_on_drop(false) on runtime should_log_debug() so normal/off
logging modes avoid stderr noise from intentional idle stream release.
* feat(audio): cold-start paused restore and silent engine prepare
After getPlayQueue on startup, apply saved seek position to the UI,
prefetch the current track to hot cache, and load the engine paused via
new audio_play startPaused so playback does not audibly start before
pause. Shared engineLoadTrackAtPosition with queue-undo restore.
* fix(audio): satisfy clippy too_many_arguments on stream arm helper
Bundle spawn_legacy_stream_start_when_armed parameters into
LegacyStreamStartWhenArmed so workspace clippy passes.
* fix(audio): release output stream immediately on stop (#1071)
Stop and natural queue end call audio_stop; close the CPAL device right
away instead of waiting for the 60s idle timer. Pause keeps the grace
period for warm resume.
* fix(audio): keep waveform mounted after stop (#1071)
Stop preserves currentTrack, so its cached analysis waveform stays valid.
Stop no longer nulls waveformBins for the still-shown track and re-hydrates
them from the analysis DB, instead of dropping to flat placeholder bars.
* test(audio): cover output_stream_is_needed branches; harden audio_play arg (#1071)
- Add unit tests for the idle-keepalive decision: empty/playing/paused main
sink, preview and fading-out sinks, and radio playing/paused. Players are
built device-less via rodio's Player::new + a Zero source, so empty()/state
are exercised without an audio device.
- Make audio_play's `start_paused` an Option<bool> defaulting to false, so the
new field is strictly additive (omitting startPaused no longer fails serde).
- Drop the unused `_engine` parameter from start_stream_idle_watcher; it
resolves the engine from the AppHandle each poll.
* refactor(audio): extract sink-swap lifecycle into sink_swap module (#1071)
Move SinkSwapInputs/swap_in_new_sink and the legacy stream-arm helper
(LegacyStreamStartWhenArmed/spawn_legacy_stream_start_when_armed) out of
play_input.rs into a focused sink_swap.rs, so source selection and source
building stay separate from sink lifecycle. play_input.rs drops from 953 to
799 lines. No behavior change.
Release CSP blocked runtime <style> injection for community themes; add
explicit style-src/style-src-elem and font-src. Derive the effective theme
synchronously in useThemeScheduler so data-theme updates on the same commit.
* fix(mpris): disable WebKit media session so radio doesn't duplicate the player
Internet radio plays through an HTML <audio> element, for which WebKitGTK
auto-registers its own MPRIS player (org.webkit.*) alongside the app's
souvlaki one. On Linux desktops that list every player, now-playing then
showed twice during radio (issue #1048: one "psysonic", one "Psysonic").
Disable the WebKit media session at main-window setup (enable-media-session,
set by GObject property name since the pinned binding has no typed setter,
guarded by find_property) so souvlaki stays the single now-playing source;
radio metadata still reaches it via mpris_set_metadata. The navigator.media
Session push in useRadioMprisSync is kept as a fallback in case a WebKitGTK
version still registers the player, so issue #816 cannot regress.
* docs(changelog): MPRIS radio duplication fix (PR #1069)
* fix(fullscreen-player): drop track-number prefix from title, stop clipping descenders
The big title showed a zero-padded queue position ("11. ") before the
song name; remove it (the position still lives in the top bar). Also bump
line-height 1.05 -> 1.25 so overflow:hidden no longer crops descenders
(g, j, p, q, y) at 46px.
* feat(settings): clarify built-in Discord RP vs official Navidrome plugin
Add a bold notice atop the Discord Rich Presence block explaining it is
the built-in integration, and that users wanting the official Navidrome
Discord RP plugin should leave it off and enable "Show in Now Playing"
instead. Add a matching bold note to the Now Playing setting. New i18n
keys discordRichPresenceNotice / nowPlayingPluginNote across all 5 locales.
* fix(discord-rp): use HTTPS cover URL instead of local file:// path
Discord Rich Presence images are fetched by Discord's servers, so
large_image must be a key or an https:// URL they can reach. Since the
cover pipeline moved to an on-disk webp cache, coverArtUrlForDiscord
returned a file://.../800.webp path on cache hit, which Discord cannot
load and silently falls back to the app icon. Always hand it the
getCoverArt URL; MPRIS keeps the local file:// path unchanged.
* i18n(settings): fill discordRichPresenceNotice + nowPlayingPluginNote for es, nb, ro, ru
The two new Discord-RP notice keys had only landed in 5 of the 9 locales;
complete the set so no locale falls back to English.
* docs(changelog): fullscreen title + Discord RP fixes (PR #1068)
* feat(music-network): core domain types and wire contracts
Foundation for the Music Network framework: provider-agnostic domain
types, capability model, typed errors, and account shapes under
src/music-network/core, plus the ScrobbleWire / EnrichmentWire /
PresetManifest / AuthStrategy contracts. No runtime wiring yet.
* feat(music-network): generic audioscrobbler/listenbrainz/maloja transports
Generalize the Rust remote layer for the Music Network framework. Add
provider-agnostic transports parameterized by base_url:
- audioscrobbler_request: Audioscrobbler v2 with caller-supplied endpoint
(Last.fm, Libre.fm, Rocksky, custom GNU FM, Maloja compat share it)
- listenbrainz_request: Token-header JSON (direct + Maloja LB compat)
- maloja_request: native /apis/mlj_1 JSON
lastfm_request stays as a thin transition delegate against the fixed
host; it is removed once the framework owns all call sites. Wiremock
tests cover audioscrobbler_request with a custom base_url and API-error
mapping.
* feat(music-network): audioscrobbler wire with last.fm + libre.fm presets
Add the Audioscrobbler v2 wire, the behavioural successor to the legacy
src/api/lastfm.ts, implementing the full EnrichmentWire surface (scrobble,
now playing, love/unlove, loved sync, similar artists, track/artist stats,
top lists, recent tracks, user profile, urls).
- client.ts: transport wrapper over audioscrobbler_request, classifying
failures into MusicNetworkError without touching any store
- sign.ts: TS mirror of the api_sig base-string ordering rule (unit-tested)
- auth/tokenPoll.ts: browser token-poll connect flow as a reusable strategy
- presets/lastfm.ts, presets/librefm.ts: bundled, enrichment-capable,
token-poll presets (both endpoints verified live)
Extends WireContext with profileBase and ConnectContext with authBase so
URL builders and connect flows need no preset lookup.
* feat(music-network): listenbrainz + maloja-native wires, paste-auth presets
Add the scrobble-destination wires and presets:
- ListenBrainz wire (scrobble + now playing via playing_now), backing both
the direct api.listenbrainz.org preset and the Maloja /apis/listenbrainz
compat preset (one wire, two presets, differing only by base URL)
- Maloja native wire (/apis/mlj_1/newscrobble, scrobble only — Maloja has
no now-playing endpoint)
- Shared api_key_only paste-auth strategy (token/key/session-key paste);
the Audioscrobbler wire now dispatches token-poll vs paste by preset
- Presets: listenbrainz, maloja_listenbrainz, maloja_native, rocksky
(scrobble-only, session-key paste, bundled keys — verified live), and
custom_gnufm (token-poll, user-supplied url/key/secret)
- Contracts: ConnectContext.authStrategy, PresetManifest.selfHostedApiSuffix
maloja_compat (the {url}/apis/audioscrobbler mode) is intentionally omitted:
its protocol cannot be verified and is almost certainly the legacy handshake,
not the 2.0 web API; Maloja is covered by the native and ListenBrainz modes.
* feat(music-network): registry, orchestrator, enrichment router + runtime facade
Wire the framework together behind a single facade:
- registry: wireRegistry (WireId -> wire), presetRegistry (the 7 built-in
presets), registerBuiltinWires (one-time side-effect registration)
- CapabilityProbe: wire probe overlaid by manifest staticCapabilities as the
final authority (lets two presets on one wire diverge, e.g. Rocksky's
nowPlaying:false over the Audioscrobbler wire's optimistic yes)
- ScrobbleOrchestrator: best-effort fan-out; flips the per-account
session-error flag on AUTH_SESSION_INVALID and clears it on next success
- EnrichmentRouter: resolves the single primary to its EnrichmentWire; the
type guard rejects non-enrichment wires (Maloja/ListenBrainz)
- MusicNetworkRuntime: the only app entry point — accounts, roles, fan-out,
enrichment, urls, probe. Reads/writes state through the MusicNetworkStore
port (Phase 5 backs it with the auth store) and a RuntimeHost for side effects
- getMusicNetworkRuntime singleton + index.ts public barrel
Tests cover fan-out, master toggle, capability gating, session-error
flip/clear, primary eligibility, and enrichment routing.
* feat(music-network): auth-store state + lossless legacy migration + runtime bridge
Add the persisted Music Network state to the auth store and wire the runtime,
all additively — nothing existing breaks yet.
- authStoreTypes: musicNetworkAccounts / enrichmentPrimaryId /
scrobblingMasterEnabled + actions; legacy lastfm* fields kept until Phase 6
- authMusicNetworkActions + defaults/wiring (synchronous localStorage)
- accountPersistence: migrateLegacyLastfm (lossless — preserves session key,
username and scrobbling preference; fills bundled Last.fm key from the preset;
sets the migrated account as enrichment primary) + sanitizeAccounts
- authStoreRehydrate: one-shot migration guarded by a sentinel so a later
disconnect cannot resurrect the account from still-present legacy fields
- musicNetworkBridge: backs the MusicNetworkStore port with the auth store and
the RuntimeHost with the Tauri shell; initialized in pre-React bootstrap
nowPlayingEnabled stays a global toggle (not a lastfm* field); the Phase 6
playback call-site will gate dispatchNowPlaying on it, preserving behaviour.
* feat(music-network): route playback, enrichment and love through the runtime
Migrate every Last.fm call-site onto the Music Network runtime, preserving
behaviour:
- playback (audioEventHandlers, playTrackAction): scrobble@50% and now-playing
via dispatchScrobble/dispatchNowPlaying; loved-fetch via isTrackLoved. Now-
playing follows scrobbling (as Last.fm did), Navidrome now-playing keeps the
nowPlayingEnabled gate
- enrichment: useArtistSimilarArtists, useNowPlayingFetchers, Statistics, and
the ArtistDetail similar-artists gate now use the runtime, gated on an
enrichment primary
- love: PlayerBar, PlayerTrackInfo, all context menus, useNowPlayingStarLove and
the startup loved-sync route through setTrackLoved / toggleNetworkLove
- player store: lastfmLoved/lastfmLovedCache -> networkLoved/networkLovedCache;
lastfmActions -> networkLoveActions; loved cache storage renamed with a
lossless legacy-key fallback
- getMusicNetworkRuntimeOrNull() for best-effort callers so they no-op (not
throw) before the runtime is initialized
src/api/lastfm.ts and the Integrations UI still use the legacy path; they are
migrated and removed in the next phase.
* feat(music-network): manifest-driven Integrations UI + scrobble batch format
Replace the Last.fm Integrations card with a manifest-driven Music Network
section, and fix Audioscrobbler scrobbling to the batch/array shape.
- settings/musicNetwork/: MusicNetworkSection (master toggle, destination
cards, enrichment-primary picker, Maloja proxy warning, add-a-service list)
driven entirely off the preset registry; icon map from PresetManifest.icon
- IntegrationsTab delegates to MusicNetworkSection (Discord/Bandsintown/
Navidrome now-playing unchanged)
- i18n: musicNetwork.* across all 9 locales, incl. a per-field help hint for
Rocksky's CLI session-key flow (rocksky login)
- scrobble now uses the documented array form (artist[0]/track[0]/…); the bare
single form is only tolerated by Last.fm, Rocksky requires the indexed form
- auth-error detection keys off the response message (not the ambiguous numeric
code) so a Rocksky server-500 no longer flips the account to a reconnect state
- PresetManifest.PresetField gains an optional helpKey
Rocksky's server rejects some non-ASCII track metadata with a 500 — a Rocksky
backend bug; the client call is correct (verified).
* fix(music-network): clearer Integrations layout + scrobble batch fix
Address UI feedback on the Music Network section:
- per-account scrobble toggle moves inside its account block (was a loose
row between cards — unclear which account it belonged to)
- master toggle and the primary-service picker are now boxed blocks at the top,
not bare rows
- primary-service copy reworked: 'Primary service' + a line spelling out that
liked tracks/similar artists/stats come from it while scrobbling still goes
to all enabled services
- distinct zones separated by dividers (master/primary · connected · add)
Also folds in the verified scrobble fixes: documented array form
(artist[0]/track[0]/…) so Rocksky accepts scrobbles, and auth-error detection
by message rather than the ambiguous numeric code (a Rocksky server-500 no
longer flips the account to a reconnect state). Rocksky session-key field gains
a CLI help hint (rocksky login), all 9 locales.
* feat(music-network): indicator + remove legacy lastfm path
Phase 7b/7c — finish the cutover and delete the old Last.fm path.
- LastfmIndicator -> MusicNetworkIndicator (shows the enrichment primary's
status, click -> Integrations)
- delete src/api/lastfm.ts; remove Rust lastfm_request (remote.rs + lib.rs)
- remove legacy authStore lastfm* fields, actions and types; delete
authLastfmActions.ts; rehydrate migration reads the legacy blob via a cast
- migrate the remaining NowPlaying call-sites (NowPlaying.tsx + the now-playing
fetchers/prewarm/star-love hooks) off lastfmSessionKey/lastfmUsername onto the
enrichment primary (gate + cache key)
- type imports LastfmTrackInfo/LastfmArtistStats -> music-network TrackStats/
ArtistStats; drop 8 stale api/lastfm test mocks and the obsolete Last.fm auth
tests; update settingsTabs + src/CLAUDE.md
No lastfm imports remain outside src/music-network/; lastfm_request removed
(acceptance §12). tsc clean, 1947 frontend tests + remote rust tests green.
* test(music-network): cover scrobble shape + error classification, drop dead i18n keys
Remove the 14 unused legacy scrobble/connection i18n keys across all 9
locales (settings.lfm*/scrobble*, connection.lastfm*); the live love,
profile-link and now-playing keys stay.
Add regression tests for the parity-critical transport logic: the indexed
batch/array scrobble body, the auth-vs-network error classification
(numeric codes collide across providers), and the manifest-overrides-probe
capability merge.
* feat(music-network): provider-agnostic UI + Maloja Audioscrobbler & Koito presets
- de-hardcode the single provider name across every enrichment surface
(love labels, now-playing badge, stats title); derive it from the
enrichment primary and interpolate via {{provider}} i18n params
- surface a toast when a paste-auth connect probe fails — a static
'supported' capability flag no longer masks a runtime probe error
- add the Maloja Audioscrobbler (GNU FM) preset (the third Maloja wire
mode) and a Koito preset (ListenBrainz-compatible), both data-only
- generalise PRIVACY.md and the scrobbling help entry to the framework
- rename residual lfm* identifiers to network*; strip legacy flat
lastfm* fields from the persisted blob; neutral transport error prefix
- tests: error classification, scrobble body, capability probe, registry
* fix(music-network): validate paste-auth keys on connect + UI polish
- AudioscrobblerWire.probe now validates an api_key_only session with a
signed call and reports scrobble:'error' only on a genuine auth failure
(a scrobble-only service that rejects user.getInfo is not a bad key), so
an invalid Maloja Audioscrobbler / Rocksky key surfaces a connect toast
instead of failing silently; WireContext carries the preset authStrategy
- drop the unreachable Statistics empty-state branch and its dead
lfmNotConnected i18n key; use the useEnrichmentPrimaryLabel hook there
- drive the love-button glyph from the enrichment primary's manifest icon;
neutral Music Network section icon; remove a dead LastfmIcon import
- tests: paste-auth vs token-poll probe behaviour
* chore(music-network): rename showLastfmSimilar → showNetworkSimilar, refresh stale comments
Post-parity polish: the similar-artists toggle now sources from the generic
enrichment runtime, so rename the lingering lastfm-flavoured identifier; drop
stale 'Mirrors today's LastfmX' doc comments referencing the removed legacy
types, and generalise the scrobble-point comment.
* docs(changelog): Music Network entry (PR #1066)
Co-Authored-By: cucadmuh <49571317+cucadmuh@users.noreply.github.com>
* fix(music-network): bound request timeout on the provider transports
audioscrobbler_request / listenbrainz_request / maloja_request built a
reqwest client with no timeout, so a hung provider left scrobble / probe /
loved-sync promises unresolved. Add a shared provider_http_client() with a
15s timeout, matching the sibling fetch_* commands. Addresses review C3.
* refactor(music-network): dedupe wire transport + no-enrichment helpers
The three provider clients repeated the same invoke -> classify-error ->
MusicNetworkError boilerplate, and the three probe() bodies repeated the
"mark every enrichment capability no" loop. Extract
wires/shared/invokeTransport() (each wire keeps its own arg shape + auth
rule) and markNoEnrichment() in core/capabilities.ts. Addresses review C4.
* refactor(music-network): drop write-only malojaWireMode dead state
malojaWireMode was written on connect but never read — the wire is resolved
by wireId and the Maloja base URL by the preset's selfHostedApiSuffix.
Remove the field, the MalojaWireMode type, the malojaWireModeFor helper,
the AccountPatch entry, the PresetField union member, and the export.
Addresses review C1.
* refactor(music-network): one useEnrichmentPrimary hook, drop lastfm fallback
The enrichment-primary lookup (accounts.find by enrichmentPrimaryId) was
duplicated across two hooks and inlined in the indicator and both
context-menu builders, two of them with a hardcoded 'lastfm' icon fallback.
Add one music-network/ui/useEnrichmentPrimary() returning
{account,label,icon}|null; useEnrichmentPrimaryLabel/Icon delegate to it and
the indicator + context menus consume it directly. Icon fallback is the
neutral 'custom' glyph, never a provider (provider-agnostic, §7.3).
Addresses review C2.
---------
Co-authored-by: cucadmuh <49571317+cucadmuh@users.noreply.github.com>
* fix(favorites): reflect player-bar song star in track lists
Liking a song from the player bar / fullscreen / shortcuts wrote only to
the session `starredOverrides` map, which onStarSuccess deleted once the
server sync resolved. List views (AlbumDetail, Favorites, RandomMix,
playlists) seed their starred state from a one-shot fetch and reflect
later changes only by merging that override, so the row reverted the
instant the sync completed — the like never stuck in the list. Toggling
from a list row also updated the row's own local state, which is why that
direction already worked.
Keep the star override as the durable session source of truth (stop
deleting it on success); the in-memory Track / queue-cache patches stay.
The override is unpersisted and superseded by the next toggle, so it never
diverges from the server. Ratings keep their existing clear-on-success
behavior.
* fix(whats-new): allow plugin-fs write into AppData cache dir
mkdir for release-notes/ succeeded but write_text_file to nested
paths was denied without fs:allow-app-write-recursive — RC/stable
re-fetched whats-new.md on every launch with an empty cache folder.
* docs: CHANGELOG PR #1062 for release-notes cache write
runGenreTagsPhase entered the blocking 'inspecting' phase before awaiting
the inspect IPC, so the migration modal briefly appeared on every startup
once the one-time genre backfill was already complete. Inspect first without
a blocking phase and only switch to 'running' when work is actually needed.
* fix(library): multi-genre local index with track_genre and backfill
Restore atomic genre browse, filters, and counts via track_genre:
OpenSubsonic genres[] first with Navidrome-default split fallback, sync
write path, read-path query switches, blocking startup backfill with
progress, and v12 repair migration for DBs that recorded legacy 002–011.
TS fallback adds genreTagsFor and migration gate i18n across locales.
* fix(library): address multi-genre review — robust TS genres and scope join
genreTagsFor routes raw genres through parseItemGenres (single-object
Subsonic quirk and bare strings). Library-scoped genre browse/counts join
track for raw_json library_id fallback. Statistics keeps empty-genre bucket.
* docs: CHANGELOG and credits for multi-genre local index (PR #1059)
* docs(changelog): credit HiveMind on Discord for multi-genre report (PR #1059)
* fix(whats-new): generate release-notes bundle before dev startup
Fresh clones failed Vite import analysis because releaseNotesBundle.ts
was gitignored and dev/tauri:dev did not run prebuild. Commit a sliced
stub and hook prebuild into dev scripts.
* docs: CHANGELOG PR #1060 for dev startup bundle fix
* fix(whats-new): keep release-notes bundle gitignored
Drop the committed generated slice — it would churn on every
CHANGELOG/WHATS_NEW edit. Prebuild before dev/tauri:dev is enough.
* feat(whats-new): remote release notes with dev workspace mode
Add WHATS_NEW.md, CI whats-new.md asset upload, and client fetch/cache
with embedded fallbacks. Dev and -dev builds read the full file from the
repo for debugging; RC/stable download the release asset on first use.
* fix(whats-new): render ## headings and add changelog tab
Parse h2 sections in release-notes markdown; load changelog alongside
highlights and let users switch views on the What's New page.
* fix(whats-new): prefetch on startup and fix CI typecheck prebuild
Prefetch whats-new asset when the shell loads on RC/stable builds.
Run prebuild:release-notes before tsc and coverage jobs so the
gitignored generated bundle exists in CI.
* docs: CHANGELOG and credits for What's New remote notes (PR #1058)
* fix(whats-new): always slice embedded release notes to current line
Drop full CHANGELOG embed for -dev bundles; tauri:dev still reads live
markdown from the repo. Ignore all of src/generated/ in git.
* fix(whats-new): fetch release asset via Rust to bypass CORS
Route whats-new.md download through fetch_url_bytes; rename the
technical tab label; add fetch unit tests (PR #1058 review).
* fix(library): show album artist in album grids (#1056)
Prefer album-artist tags over track artist when building album rows from
the local index, and align grid cards with OpenSubsonic displayArtist.
* fix(library): align album artist in FTS, search, and offline paths (#1056)
Apply album-artist preference in FTS album dedupe and live search, fix
offline pin hydration order, and use albumArtistDisplayName in remaining
cheap UI/export/download call sites.
* docs(changelog): album artist grid fix for compilations (PR #1057)
* fix(library): parity guard and live-search album artist helper (#1056)
Align SQL ELSE branch with pick_album_group_artist trimming, add parity
test, and use albumArtistDisplayName in LiveSearch and MobileSearchOverlay.
* fix(scrobble): report Now Playing on playback server with local bytes
Navidrome presence and play-count scrobbles no longer skip when audio plays
from hot cache, offline pins, or favorites-auto, and reachability follows the
queue/playback server instead of the browsed active server.
* docs: changelog and credits for PR #1055
* feat(settings): redesign server cards with identity line and capability badges
Compact two-line server headers (entry name + user@host), HTTPS lock, and a
clickable version info tooltip. Navidrome ≥0.62 shows a green AudioMuse inline
badge; older Navidrome keeps the manual toggle row. Adds click-pinned tooltips
via data-tooltip-click on TooltipPortal.
* feat(settings): unify use/active slot and move delete into edit form
Merge Active badge and Use button into one rightmost action; Active uses
green styling. Reorder actions to edit, test, use/active. Remove the card
delete icon — deletion lives in the server edit form footer.
* docs: note compact server cards in CHANGELOG and credits (PR #1054)
* fix(credits): move PR #1054 server cards line to cucadmuh block
Was appended to Psychotoxical's contributions array by mistake; CHANGELOG
already credited cucadmuh (gh pr view author).
* fix(home): stop Most Played load-more from snapping the page upward
Loading more Most Played albums rebuilds the Because-You-Like anchor pool
(it is seeded from mostPlayed), and that rail's fetch effect keyed on the
pool array ref — so it re-ran on every append, swapping the row's cards and
producing a height blip that scroll anchoring turned into an upward viewport
jump. Gate the effect on poolKey (the stable top-anchor identity) instead,
matching the sibling reserve effect, so it only re-runs on a real seed change.
* docs(changelog): add #1053 Most Played load-more scroll fix
* chore(themes): drop the dead installs field from RegistryTheme
The store no longer reads install counts (the themes registry stopped
emitting them), so this optional field on RegistryTheme was unused.
* fix(themes): give dropdown border/shadow tokens a cascade default
--border-dropdown and --shadow-dropdown were set by only two themes and had
no base fallback, so dropdown/popover borders rendered without a themed value
in every other theme. Default them in the semantic cascade next to the other
menu tokens; themes that set them explicitly still override.
* fix(themes): unify input focus rings, drop the double border
Text inputs draw their own border + box-shadow focus ring, but the global
:focus-visible outline stacked a second ring outside it — a double border on
every field. Suppress the outline for text inputs centrally; the specificity
(0,1,1) beats the global ring but loses to the colour-blind-safe themes'
[data-theme] *:focus-visible (0,2,0), so those keep their stronger AAA ring on
every field by design — the header search now carries that ring on its cluster
there too. Align the few input classes that had a weaker or missing ring to the
shared border + 3px accent-dim standard.
* docs(changelog): add #1052 input/dropdown focus border fixes
Theme assets (registry.json, theme CSS, thumbnails) were served from the
jsDelivr CDN's mutable @main edge, which caches up to 12h. The registry is
purged on every themes push so updates were offered promptly — but the theme
CSS could still be served stale, so an update stored pre-update CSS under the
new version label and no further update was offered to correct it (a freshly
animated theme would install without its @keyframes).
Fetch everything from GitHub raw (permissive CORS, ~5-min server cache), which
is always current. jsDelivr is dropped entirely: our request volume does not
need a CDN, and its only real upside here (high-traffic edge serving) does not
apply, while its staleness actively caused the bug.
Rename cdnUrl -> assetUrl to reflect the source.
The install/download numbers came from jsDelivr CDN hits, which are
structurally unreliable: the stats API caps at the top 100 files, so most
themes report 0 and the figures jump from day to day. Showing them — and a
popularity bar and a most-popular sort built on them — only misled users.
Remove the stats meta box (popularity bar + download count), move the author
back under the theme name, and keep the reliable git-based last-changed date
inline. Sort is now Newest (default) or Alphabetical.
* feat(now-playing): index-first metadata resolvers (#1046)
Add nowPlayingMetadataResolve.ts: four index-first resolvers (album,
discography, top songs, song meta) that read the local library index when
it has the row and fall back to Subsonic only on index miss / off / not
ready. Reuses loadAlbumFromLibraryIndex, loadArtistFromLibraryIndex,
libraryGetTrack, libraryAdvancedSearch, trackToSong, and libraryIsReady —
the same index-first family as queueTrackResolver, not a new network path.
getSongForServer keeps its byte-style trackId guard; the index arm runs
first so an index hit avoids the guarded call. artistInfo has no index
source and stays network-only (not handled here).
* feat(now-playing): wire index-first resolvers into fetchers + prewarm (#1046)
Replace the four direct Subsonic calls in useNowPlayingFetchers and
prewarmNowPlayingFetchers (album, discography, top songs, song meta) with
the resolveNp* resolvers — index-first, network fallback. artistInfo
stays on getArtistInfoForServer (no index source). Caches, id-gating
tuples, and the subsonicFetchAllowed guard are unchanged; the top-songs
effect gains artistId in its deps (index arm filters by it).
Net: on a populated, ready index those four cards render from SQLite with
no Subsonic call; index miss/off/not-ready falls back to the network path
exactly as before.
* docs: add CHANGELOG entry for PR #1049
* fix(now-playing): widen resolveNpTopSongs artistId to optional (tsc)
The top-songs fetch isn't guard-narrowed on artistId, so callers pass
string | undefined. The resolver already handles a missing artistId (falls
straight to the network arm); widening the param type fixes the CI tsc
failure that the wiring commit introduced.
* fix(now-playing): run index reads offline + deterministic top songs (#1049 review)
Address cucadmuh's two in-PR review points:
1. Index-first reads now run whenever there's a playback server id, including
when the server is unreachable — the offline win of index-first. Split the
hook/prewarm gate: resolvers run on server-id presence; the reachability
guard moved into each resolver's network fallback arm. artistInfo (no index)
stays network-only.
2. resolveNpTopSongs derives top songs from the artist's own discography albums
(sorted by play_count) instead of an FTS-on-name query, so name collisions
can't surface the wrong tracks. getTopSongsForServer remains the fallback.
Resolver tests extended with the unreachable (index-only) cases.
The Cover art cache settings section shipped with English (and Russian)
strings only. Add the 23 keys (scope/strategy labels, Lazy/Aggressive
descriptions, per-server clear dialog) for de, fr, es, nl, nb, ro, and zh.
Placeholders and the technical progress-value string are left verbatim.
* fix(now-playing): keep metadata cards when the track plays from local cache
The Now Playing metadata fetches (album, artist info, discography, top songs)
gated on shouldAttemptSubsonicForServer(serverId, trackId). That guard returns
false when the current track's audio resolves to a psysonic-local:// URL (hot
cache / offline bytes), which is correct for playback-byte calls but wrongly
suppressed the metadata calls. On track change the prefetched next track plays
from the hot cache, so every Subsonic-backed card blanked and only the Last.fm
bio fallback and Bandsintown tour (both non-Subsonic) remained.
Drop the trackId from the four Now Playing metadata gates so metadata is fetched
whenever the server is reachable, regardless of where the audio bytes come from.
True-offline is still handled by the online / reachability checks in the guard.
The guard and all other call sites are unchanged.
* docs: add CHANGELOG entry for PR #1042
* refactor(now-playing): single Subsonic network gate owned by the fetchers hook
The metadata reachability guard (shouldAttemptSubsonicForServer) ran 2-3
times per Now Playing render/effect cycle: callers folded it into
fetchEnabled in NowPlaying.tsx and useNowPlayingPrewarm on top of the
checks already inside useNowPlayingFetchers and prewarmNowPlayingFetchers.
Callers now pass fetchEnabled as intent only ("we have a playback server
id"); the single reachability decision lives in the hook / prewarm
function. Behaviour is unchanged — only the duplicated guard calls go.
* test(now-playing): cover local-playback metadata behaviour, not only call shape
Add a guard test proving the metadata gate (no trackId) bypasses the
psysonic-local:// skip while a byte-style call stays blocked, and a hook
test that fetches album/discography/top songs with a trackId-sensitive
guard — so a reintroduced trackId at the gate fails the suite instead of
silently blanking the cards.
* feat(settings): show server software and version on server cards
Render the OpenSubsonic-reported software and version (e.g. "Navidrome
0.62.0") under each server name on Settings -> Servers. The value comes
from the existing ping identity (subsonicServerIdentityByServer), so no
extra request is made; the line is omitted when the server reports no
type (e.g. plain Subsonic without OpenSubsonic).
Extract the formatting into a shared formatServerSoftware() helper and
reuse it in the PsyLab perf-probe server section in place of its local
copy.
* docs: add CHANGELOG entry for PR #1045
* fix(library-db): name slow-write ops for macOS stall diagnosis (#1040)
Replace generic op=misc labels on production library-db write paths with
stable module.action names (sync_state.*, track.*, tombstone.*, cmd.*, …)
so SLOW write logs pinpoint the call site. Document the naming convention
on LibraryStore::with_conn.
Diagnostic step for #1040 — no behaviour change.
* docs: add CHANGELOG and credits for PR #1043
Library-db slow-write op naming diagnostic for issue #1040.
* feat(themes): sidebar notice when an installed theme has an update
Adds a dismissible sidebar pill (sibling of the What's New banner) shown
while an installed community theme has a newer version in the store. Clicking
opens Settings -> Themes; dismiss hides it until a new update changes the set.
The theme registry is now refreshed from source once per app launch instead
of only when the Theme Store tab is opened, so newly published themes and
updates surface without a manual refresh -- and feed this notice.
* docs: add CHANGELOG entry for PR #1041
* feat(themes): in-place update control on installed theme cards
Themes with a newer version in the store now show a centered update icon on
their card in Settings -> Themes; clicking it fetches and reinstalls in place.
Extracts the shared installThemeFromRegistry helper (fetch -> validate ->
install) used by both the store list and the card control, and surfaces the
full registry entry from useThemeUpdates so the card can update directly.
* fix(audio): poll only the default device when none is pinned (#996)
The device watcher ran a full output_devices() + per-device description()
CoreAudio enumeration every 3s, even with no pinned device. On some macOS
setups this contends with the audio render thread and causes a brief dropout
once per poll — a stutter whose cadence tracks the poll interval exactly.
The full enumeration is only needed to detect a pinned device disappearing.
With no pin (system default, the common case) only the current default is
needed, so the enumeration is skipped entirely in that case; the cheap
single default_output_device() query still detects default-device changes.
Confirmed with a diagnostic build: throttling the enumeration to ~60s moved
the reporter's stutter cadence from ~3s to ~60s, isolating the enumeration
as the cause.
* docs: add CHANGELOG entry for PR #1039
* refactor(themes): drive the Now Playing page from semantic tokens
Replace the hardcoded white/black colours in the Now Playing dashboard,
the info-card glass panels, the queue sidebar, track lists, tags and the
Last.fm stats with the semantic token surface (text / surface / border /
glass). Light themes now render the page legibly instead of washed-out
white-on-light; dark themes keep their look because the tokens' dark values
match the previous hardcoded ones. Cover-art content (fullscreen player,
hero) is intentionally left fixed and out of scope.
* docs: CHANGELOG for themeable Now Playing page (#1038)
* feat(server): probe AudioMuse via OpenSubsonic and add PsyLab Connections tab
Navidrome ≥0.62: detect sonicSimilarity extension for reliable plugin signal;
older servers keep the legacy getSimilarSongs probe. PsyLab gets a Connections
tab with session, endpoint, and active-server capability details.
* feat(psylab): polish Connections tab, admin role probe, and tab bar layout
Status badges and Navidrome admin/user validation in Connections; prevent
PsyLab tab row from vertically collapsing under the Logs flex layout.
* docs: add CHANGELOG and credits for PR #1032
* feat(settings): auto-enable AudioMuse on Navidrome 0.62+ with status indicator
Replace the per-server manual toggle with a probe-driven badge when
sonicSimilarity is available; pre-0.62 Navidrome keeps the legacy toggle.
* feat(server): capability framework with AudioMuse sonic routing
Add a declarative server-capability catalog (src/serverCapabilities/) that
picks a feature strategy per server generation, runs only the needed probes,
and routes API calls. AudioMuse Instant Mix now prefers the OpenSubsonic
sonicSimilarity endpoint (getSonicSimilarTracks) on Navidrome 0.62+ and
falls back to legacy getSimilarSongs.
- catalog/context/resolve: eligibility, detection, activation, call routing
- storeView: read facade over the existing per-server probe maps
- getSonicSimilarTracks API client + fetchSimilarTracksRouted router
- route Instant Mix and Lucky Mix through the resolver
- ServersTab + PsyLab Connections read the resolver (auto status vs toggle)
- tests: resolve, storeView, router
* docs: update CHANGELOG and credits for PR #1033
Renamed branch supersedes PR #1032: point changelog/credits at #1033 and
document the server-capability framework, auto-managed AudioMuse indicator,
and sonic Instant Mix routing.
* refactor(server): address PR #1033 review — idempotent probe, drop dead code
- Make scheduleInstantMixProbeForServer idempotent: skip when a definitive
result is cached; re-probe only on force (add/edit/test server), a prior
error, or a server version/type change (invalidated in setSubsonicServerIdentity).
Removes the steady-state 120 s re-probe, the present→probing→present flicker,
and the momentary legacy-fallback routing window.
- Remove now-dead identity helpers (showAudiomuseNavidromeServerSetting,
isAudiomusePluginAutoManaged, isNavidromeSonicSimilarityEligible,
resolveAudiomusePluginProbeUiStatus + type) and the superseded
probeAudiomusePluginWithCredentials; the catalog is the single source of truth.
- Drop the never-emitted 'unsupported' AudiomusePluginProbeResult variant.
- Fill audiomuseStatus* keys in all 8 non-en locales.
- Tests: probe idempotency + version-change invalidation; retarget
OpenSubsonic test to fetchOpenSubsonicExtensionsWithCredentials.
* feat(themes): install counts, downloads, popularity & sort in the Theme Store
Each registry theme now carries an install count and a last-changed date. Store
rows show them in a dedicated meta panel (author, popularity bar, total
downloads, last changed), plus a sort dropdown (most popular / newest / name),
zebra-striped rows, a numbered pager, and a note that the stats refresh daily.
Adds a shared formatRelativeTime helper (formatLastSeen now delegates to it).
* test(themes): cover sort modes, pager jump and relative-time formatting
* fix(build): gate nvidia_quirk_active to Linux
Its only caller is the Linux branch of theme_animation_risk, so on Windows and
macOS the function was dead code and tripped clippy's -D warnings.
* docs: CHANGELOG + credits for theme store download stats & sort (#1036)
Replace the raw hazard-triangle icon with a small amber motion chip
(shared AnimatedThemeBadge): an Activity glyph on a tinted rounded
surface. In the Theme Store it stays inline after the name; on installed
theme cards it moves from bottom-right to top-centre, clear of the active
indicator (top-right) and the uninstall control (top-left). Tooltip and
aria-label are unchanged.
* fix(artist): play top tracks when album list is empty on page
Top-track rows silently no-op'd when albums.length was 0 (common in
lossless artist view). Play the selected top songs immediately and only
append catalog tracks when albums are available.
* docs: note artist top tracks play fix in CHANGELOG for PR #1031
* feat(ui): add themed startup splash with deferred window show
Show a theme-aware loading splash before the Vite bundle mounts, hide the
native window until it paints, and use per-theme logo gradient colors.
* docs: note startup splash in CHANGELOG and credits for PR #1030
* docs: attribute PR #1030 startup splash to cucadmuh
* fix(library): All Albums compilation filter matches VA album artist
Local index SQL and track-grouped browse missed compilations tagged via
Various Artists on album_artist; genre-only browse also ignored combined
filters. Extend predicates on both sides and route genre+filter to advanced search.
* docs(changelog): All Albums compilation filter fix (PR #1026)
* fix(fullscreen): respect Settings clock format on wall clock
The fullscreen player corner clock always used the browser locale default
(12-hour). Route it through formatClockTime and authStore.clockFormat so
24-hour matches queue ETA and sleep-timer preview.
* docs(changelog): fullscreen clock format fix (PR #1025)
* fix(artists): per-artist links on song rails and shared OpenSubsonic refs
Song cards in Random Picks and Discover Songs showed joined artist
credits but navigated to a single artistId. Route track surfaces through
resolveTrackArtistRefs and coerce single-object Subsonic JSON payloads.
* docs(changelog): note song-rail multi-artist link fix (PR #1023)
* fix(home): sync Mainstage hero backdrop with album on fast nav
Bind hero background URL to the current slide and drop stale crossfade
layers so rapid carousel clicks no longer show the previous album's art.
* docs(changelog): note Mainstage hero backdrop sync fix (PR #1021)
* feat(themes): warn about animated themes on high-CPU setups
Show a warning icon + tooltip on animated themes (those defining
@keyframes) in the store and in Your Themes, on Linux setups where
animation is costly — the Nvidia WebKit quirk is active or compositing
is forced off. The Nvidia detection already runs once at startup; its
result is recorded (theme_animation.rs) and read via a new
theme_animation_risk command — no GPU re-probe. Display-only; never
shown off Linux. The animated flag comes from the registry (store) or
the theme CSS (Your Themes).
* docs(themes): note the animated-theme warning PR in the Theme Store entry
Debug builds only: `--theme-watch <theme.css>` polls a local theme file
and pushes it into the running app on save (a lightweight Rust watcher
thread emits a Tauri event). The frontend installs the CSS under its
[data-theme='<id>'] selector and applies it, so the existing
syncInjectedThemes effect re-injects live — no zip re-import, no reload.
Double-gated (debug_assertions + import.meta.env.DEV); never wired in
production.
* fix(themes): show store thumbnails in 16:9
Thumbnails are now 16:9 WebP. Render the store-row preview at 200x112
(16:9) so the screenshot isn't cropped in the grid; the lightbox already
shows it full size, now larger and crisper from the 1280x720 source.
* docs(themes): note the 16:9 thumbnail PR in the Theme Store entry
* feat(offline): local-bytes browse for artists and albums
Make Artists, All Albums, and artist/album detail pages work offline
from the library index limited to on-disk library and favorite-auto
tracks. Add a DEV header toggle to simulate offline browse for testing.
* feat(offline): reactive DEV offline toggle with full disconnect simulation
Subscribe nav and browse/detail hooks to useOfflineBrowseActive so UI
refreshes on toggle. DEV force-offline now blocks server probes, reports
disconnected status, and gates Subsonic like real offline for player parity.
* feat(offline): bytes-first favorites when offline browse is active
Load Favorites from local playback bytes, filter starred tracks client-side,
and restrict album-level star queries to local album ids. Drop interim perf
attempts (lean SQL, progressive load, connection singleton, prefetch UX).
* feat(offline): tracks, help, player stats; suspend library picker offline
- Offline browse for Tracks hub from local bytes; sidebar nav for tracks/help/statistics
- Statistics redirects to player-stats offline; server/Last.fm tabs skip network fetches
- Hide music-library picker offline; save filter and restore on reconnect (all libraries while disconnected)
- Unified isOfflineSidebarNavAllowed for library + system entries
* feat(offline): fork disconnect navigation by offline browse capability
When the server drops: stay on the page if nothing is browsable offline;
reload in place on offline-capable routes; otherwise redirect to All Albums
instead of the old /offline or /favorites bounce.
* feat(offline): browse cached playlists when the server is down
List and open manually pinned regular playlists from local library-tier
bytes offline, with sidebar/nav routing and read-only playlist UI.
* feat(offline): read-only artist detail and local play-all paths
Hide favorites and discography offline actions when browse is offline;
load Play All, Shuffle, and top-track continuation from local album bytes.
* feat(offline): read-only album detail and enqueue from local bytes
Hide favorites, download, and cache-offline actions on album pages when
offline browse is active. Favorites album cards enqueue via the same
resolveAlbumForServer path as play, including local playback bytes.
* chore: remove unused import in AlbumCard after enqueue refactor
* feat(offline): unify browse integration contract across the app
Add useOfflineBrowseContext, offlineMediaResolve, and offlineActionPolicy;
wire shell nav to a single capability source; migrate play/enqueue and
context-menu paths off raw getAlbum; replace readOnly with action policy
on detail surfaces. Tests updated for the media-resolve facade.
* feat(offline): close browse contract gaps and fix offline Home feed
Split offline browse modules, align favorites capability across servers,
wire action policy on context menus, migrate hooks to useOfflineBrowseContext,
and preserve stale Home feed cache when offline so the UI does not empty.
* fix(offline): block playbar stars, close audit gaps, trim dead exports
Hide star rating and favorite in PlayerBar when offline browse is active via
offlineActionPolicy playerBar surface. Wire stay-reload token into browse
hooks, migrate hooks to context.active, guard rating prefetch network calls,
and route playlist load through resolvePlaylist.
* docs: add CHANGELOG and credits for offline browse PR #1017
* fix(offline): stop DEV connection probe regression in tests
React to devForceOffline transitions only in useConnectionStatus so mount
does not double-fire check() or ignore disableBackgroundPolling. Add
pingWithCredentials to PlayerBar test mock and DEV-toggle unit tests.
* feat(themes): fresher store refresh + external-services notice
Manual refresh now fetches the registry from GitHub raw first (~5 min
cache) and falls back to the jsDelivr CDN, so a freshly merged theme shows
up without waiting on — or purging — the shared CDN @main edge (up to
12 h). Normal loads still use the CDN.
Adds a notice on the Theme Store that the catalogue and previews load from
external services (jsDelivr / GitHub), in line with the app's network
transparency. i18n ×9.
* docs(themes): note the store-refresh PR in the Theme Store entry
Now that #1015 is merged, fold PR #1016 into the still-unreleased Theme
Store changelog and credits entry.
* feat(themes): free-form community themes with a security floor
Community themes are no longer token-only. The in-app guard
(validateThemeCss) now enforces only a security floor — no network
(@import / non-data url()), no scripts (<style>/<script>/expression()/
javascript:/-moz-binding), no @property, @keyframes namespaced as <id>-,
and a 256 KB cap — and otherwise allows any selectors, structure and
animations. validateThemePackage checks the manifest plus that floor.
Themes can react to app state via same-element attributes set on the theme
root: data-playing, data-fullscreen, data-sidebar-collapsed, data-lyrics-open.
The local-import confirm dialog now notes that imported themes aren't
reviewed and are installed at the user's own risk. Removes the now-unused
bundled token whitelist.
* docs(themes): note free-form themes in the Theme Store entry
Add PR #1015 and a free-form bullet to the still-unreleased Theme Store
changelog and credits entry.
* fix(themes): route remaining UI colours through theme tokens
An audit found several surfaces still wired to the fixed Catppuccin
palette or hardcoded hex, so community themes could not recolour them:
the 5-star rating, the global search field, the gradient-text flourish,
badges and category-avatar text, and the What's New page + sidebar
banner. Rewire them to existing contract tokens — no new tokens, and the
built-in themes look identical (the values match). Community themes now
control these areas.
Also fixes device-sync rows referencing an undefined --bg-secondary
(they rendered with no background); they now use --bg-card.
* docs(themes): note the theme-coverage PR in the Theme Store entry
Add PR #1014 to the still-unreleased Theme Store changelog and credits
entry, alongside the other follow-ups.
* fix(themes): show an offline banner when the Theme Store is unavailable
When the registry can't be fetched and no catalogue is cached, the store
now shows a clear offline banner (icon, message, retry) in place of the
list, and hides the search/filter toolbar — there is nothing to browse.
The cached-catalogue fallback with its offline indicator is unchanged, so
the store still works offline when a catalogue was previously cached.
* docs(themes): note the offline-banner PR in the Theme Store entry
Add PR #1013 to the still-unreleased Theme Store changelog and credits
entry, alongside the other follow-ups.
* feat(themes): validate and extract locally imported theme packages
Add the backend + validation half of local theme import: users will be
able to load a theme packaged as a .zip (manifest.json + theme.css).
- `import_theme_zip` Tauri command unpacks only manifest.json + theme.css
from the archive, outside the webview, with an archive-size cap,
per-entry uncompressed caps, and path-traversal rejection.
- `validateThemePackage` runs the full theme-store contract in-app: the
manifest schema (field patterns copied from the repo schema), the CSS
token whitelist with all core tokens required, color-scheme matching
the declared mode, data-URI restricted to the arrow token, and a guard
against ids that collide with built-in themes. The existing
`validateThemeCss` containment guard is reused and runs again at
injection time. The contract token list is a byte-identical copy of the
themes repo's allowed-tokens.json.
Covered by validateThemePackage tests for every rejection class.
* feat(themes): add the Import a theme section to the Themes tab
A dedicated section between the theme scheduler and the Theme Store lets
users import a theme from a local .zip. After the package validates, a
confirmation dialog names the theme and its author before it is installed.
A rejected import shows a plain-language explanation aimed at end users;
the raw contract diagnostics (token names, missing fields) are tucked
into a collapsible "Technical details" block for theme authors. Fully
localised across all nine languages.
* docs(themes): changelog + credits for local theme import
Fold the local .zip import (and the store pagination / refresh-scroll
polish) into the still-unreleased Theme Store entry rather than a new
section, and extend the credits line. PRs #1011 and #1012.
The community Theme Store grew large enough that browsing it meant
scrolling through the whole catalogue. Paginate it (12 per page) with a
Prev / "Page X of Y" / Next pager that resets to page 1 on filter changes
and scrolls back to the top of the list when paging.
Also fix the refresh button resetting the scroll position. It set the
top-level loading state, which unmounted the list and collapsed the
scroll viewport so it clamped back to the top. Refreshing now keeps the
existing list mounted and only spins the refresh icon; the full-page
loading and error placeholders are reserved for the initial load.
Adds themeStorePagePrev / PageNext / PageStatus across all nine locales
and a ThemeStoreSection test covering pagination, filtering, page reset,
and the refresh-keeps-scroll behaviour.
* feat(themes): add semantic tokens for the theme-store contract (B0 P1)
Additive: define --highlight, --accent-2, --bg-deep, --bg-elevated and
--text-on-accent on the :root base as --ctp-* mappings. They resolve
per-theme automatically and nothing consumes them yet (zero behaviour
change) — groundwork for replacing direct --ctp-* use in components.
* refactor(themes): components consume semantic tokens, not --ctp-* (B0 P2)
Replace every direct --ctp-* reference in component/layout/track CSS and
TSX inline styles with the readable semantic token (--bg-app, --accent,
--highlight, --text-on-accent, …). --ctp-* now survives only as the
Catppuccin palette layer the base maps from, and as the deliberate
categorical rainbow in Composers/Genres/artistsHelpers (left untouched).
This is the readable contract surface for the community theme store.
Divergences (theme set a semantic var != its --ctp- source) are
corrections — the element now uses the theme's real semantic colour.
* feat(themes): add player-bar title/artist color tokens
New optional --player-title / --player-artist, defaulting to --text-primary / --text-secondary so nothing changes unless a theme overrides them. Lets a theme give the now-playing readout its own colour as a plain token.
* refactor(themes): token-only theme library (flatten, whitelist, one file per theme)
Turn every built-in theme into a single self-contained [data-theme] var block of semantic whitelist tokens (plus the internal --ctp-* palette layer):
- Flatten all themes: drop structural override rules, @keyframes, and global-token overrides (radius / shadow-elevation / transition / spacing / font / focus-ring). Signature player-bar readout colours are preserved via the new --player-title / --player-artist tokens.
- Normalize the var blocks to the semantic whitelist: drop the alternate token vocabulary (nav-active / scrollbar / bg-input / success / border-default / ...); rename --success -> --positive and --border-default -> --border where no whitelist equivalent was set. Migrate the few components that read those tokens to the whitelist equivalents.
- Split multi-theme files so each theme ships as its own file, making the built-in set 1:1 with the per-theme store packaging.
Kept as-is (built-in, not flattened): the two colour-blind-safe accessibility themes, plus the two curated core skins.
* chore(themes): remove seven themes retired after the token refactor
These themes leaned on heavy structural overrides and were dropped rather than flattened. Full removal each: the CSS file(s), the index.css import, the Theme type union, and the ThemePicker entry.
* feat(themes): granular tokens — track lists
Wire track rows to per-region tokens: row hover (--row-hover), the now-playing
row + indicator (--row-playing-bg / --row-playing-text), track title/artist/
number/duration text, column-header text, row dividers, and the resize-handle
active colour. Covers the desktop tracklist, the shared song-row (Tracks hub /
search), and the mobile tracklist. Drop a baked border fallback. Visual no-op.
* feat(themes): granular tokens — cards
Wire album and artist cards to per-region tokens (--card-hover-border,
--card-title, --card-subtitle, --card-placeholder-bg). Visual no-op.
* fix(themes): drop undefined/baked colour aliases
Replace the undefined --bg-surface (resolved to nothing — broken placeholder
backgrounds and filter input) with --card-placeholder-bg / --input-bg, and the
baked-hex aliases --color-error / --color-warning with --danger / --warning so
themes can actually recolour them.
* feat(themes): Spectrum demo theme + trim unused cascade tokens
Add a loud built-in demo theme that gives each region its own hue (sidebar
green, player pink, lists cyan, cards gold, menus red, controls blue) so the
per-region granularity is obvious when you switch to it. Drop two unused
cascade tokens (--sidebar-text-active, --row-active-bg) and the unused
on-media block (those media surfaces stay static by design).
* style(themes): make Spectrum demo brutally loud
Full-saturation neon per region (toxic green sidebar, magenta player, cyan
lists, acid-yellow cards, blood-red menus, electric-blue controls, purple
scrollbar) so the per-region separation is unmistakable. The earlier soft
tints were too subtle to read.
* feat(themes): name the granular demo theme Braindead
* feat(themes): granular per-region tokens — cascade layer + sidebar
Add an optional per-region token layer (semantic-cascade.css) so a theme can
recolour individual regions — sidebar hover, player controls, list rows,
menus, inputs, on-media surfaces — independently of the global tokens. Every
token defaults to its base token (or a media-safe literal), so this is a
visual no-op until a theme overrides one; it only adds control points.
Wire the sidebar region as the first consumer and drop the baked grey
fallbacks (--bg-tertiary, etc.) that no theme could reach.
* feat(themes): granular tokens — controls, menus, scrollbar
Wire inputs, buttons, sliders, the custom-select, context menus, submenus and
modals to per-region tokens, and tokenise the scrollbar. Complete B0 by
dropping the last direct --ctp-* references in the input/button/progress/
scrollbar utility CSS.
Fix three undefined-token bugs that fell back to nothing (so no theme could
reach them): --surface-2 (context-menu hover had no highlight), --bg-surface
(submenu create-input had no background), and the baked grey fallbacks in the
custom-select. Every other new token defaults to today's value — a visual
no-op that only adds override points.
* feat(themes): granular tokens — player bar
Wire the desktop player bar's transport controls, time toggle, and overflow
menu to per-region tokens (--player-control, --player-time-toggle-*, etc.).
Fix the undefined --surface-hover/--surface-active grey fallbacks on the time
toggle. Visual no-op; defaults match today's values.
* chore(themes): remove empty theme stub files
Six theme CSS files were reduced to comment-only stubs by the flatten
sweep but their files and @import lines remained. Five are empty
structural companions of now-flattened themes (morpheus, p-dvd,
aero-glass, luna-teal) and two are orphans of cut themes
(order-of-the-phoenix, pandora). Removed the files and their imports.
* feat(themes): runtime injection foundation for the theme store
Plumbing for installed community themes ahead of the in-app store UI,
nothing user-visible yet:
- installedThemesStore: persisted (localStorage) record of installed
community themes incl. their CSS text, so an active community theme is
available synchronously at startup (no flash, fully offline).
- themeInjection: reconcile <head> <style data-installed-theme> elements
with the store; lightweight defense-in-depth sanitize on top of CI.
- themeRegistry: jsDelivr registry client with a 12h localStorage cache
and stale-on-error fallback.
- App: inject installed themes before applying data-theme, in both webviews.
- themeStore: widen the Theme type to accept dynamic installed ids.
* feat(themes): dedicated Themes settings tab
Move theme selection and the day/night scheduler out of Appearance into
a new dedicated Themes tab — the future home of the community Theme Store.
Appearance keeps grid columns, visual options, UI scale, font and seekbar.
- ThemesTab: theme picker + scheduler (relocated verbatim).
- AppearanceTab: drop the two relocated sections + now-unused imports.
- Register the tab in settingsTabs (Tab union, resolveTab, search index)
and Settings (tab bar, render, label map).
- i18n: settings.tabThemes in all 9 locales.
* feat(themes): community Theme Store browse + install
Add the Theme Store section to the Themes tab:
- Fetch the jsDelivr registry (12h cache, stale-on-error fallback).
- Search by name/author/description + filter by light/dark + refresh.
- Per-row CDN thumbnail, name, author, description and actions:
Install / Apply / Update / Uninstall. Installing fetches the CSS,
persists it (localStorage) and the runtime injection applies it;
uninstalling the active theme falls back to the matching core.
- Rating slot left reserved (deferred).
- i18n: themeStore* keys in all 9 locales.
* feat(themes): slim bundle to fixed cores + flat Themes tab
Remove the 86 store palettes (incl. braindead) from the app bundle — the
CSS files, their index.css imports, the Theme union and the picker data —
leaving only the six fixed cores (Catppuccin Mocha/Latte, Kanagawa Wave,
Stark HUD, Vision Dark/Navy). Everything else installs from the store.
Themes tab is rebuilt flat (no collapsible accordions):
- "Your Themes": one card grid of the fixed cores + installed community
themes; click to apply, uninstall on community ones (active theme falls
back to the matching core). Catppuccin prefix on Mocha/Latte; a CVD-safe
pill on the colour-blind-safe Vision themes.
- Scheduler day/night options include installed themes.
- Theme Store: alphabetical order, thumbnail lightbox, and a submit hint
above the search linking to the themes repository.
- Nav order: Servers, Library, Audio, Themes, Appearance, Lyrics, …
- ThemePicker accordion removed; fixed-theme data moved to fixedThemes.ts.
- i18n for all new strings across 9 locales.
* feat(themes): reset removed-from-bundle themes to a bundled fallback
After slimming the bundle to the six fixed core themes, a profile upgraded
from an older build may have an active or scheduler theme that is now
store-only and not installed — it has no [data-theme] block and would render
as unstyled :root. Reset any theme/themeDay/themeNight that is neither bundled
nor installed to a bundled fallback: Mocha for the main + night slots, Latte
for the day slot. Runs synchronously in runPreReactBootstrap, rewriting the
persisted selection in localStorage before React mounts (no flash; Zustand
rehydrates after first paint). No auto-install and no network — the fallback
is always a bundled theme, so it works offline.
* feat(themes): floating back-to-top button on the Themes tab
The Themes tab can get long (theme grid + scheduler + full store list), so
add a floating back-to-top affordance that appears once the page is scrolled
and smooth-scrolls to the top. It is portalled into the route host and
positioned absolute against it — the main scroll viewport sets contain: paint,
which would otherwise make position: fixed resolve against the scrolling box
and drift with the content. Reusable component (scroll viewport id + threshold
props); i18n common.backToTop added in all nine locales.
* feat(themes): accessibility + state polish for the Theme Store
- Reuse the shared CoverLightbox for the thumbnail preview instead of a
second inline dialog — gains a visible close button and a focus-managed,
portalled dialog, and drops duplicated markup.
- Theme cards expose aria-pressed so assistive tech announces the selected
theme, not just the visual check.
- Transient store messages get live-region roles (loading/empty/install
failure = status, fetch error = alert).
- Thumbnails degrade gracefully when offline/missing (hide the broken-image
glyph; the thumbnail button no longer stretches with the row, so its
background can't show as letterbox bars).
* feat(themes): larger store-row thumbnails (120x75 -> 200x125)
The list previews were too small to make a theme out; bump the display size
(same 1.6 aspect). Thumbnails are now served at 720x450, so the larger
display stays crisp.
* fix(themes): bust thumbnail cache on registry change
jsDelivr serves theme thumbnails with a 7-day max-age, so when a thumbnail
is updated the webview keeps showing its cached old image (the path is
unchanged). Append the registry's generatedAt as a cache-busting query to
the thumbnail URLs (list + lightbox); it changes on every themes push, so a
registry refresh makes the webview re-fetch and reflect the current CDN
image instead of a stale one.
* docs(themes): changelog + credits for the Theme Store
Add the 1.48.0 "Themes — community Theme Store" changelog entry (PR #1009)
and the matching line in the Psychotoxical credits.
* fix(themes): address PR review (uninstall hygiene, validation, polish)
Uninstall/scheduler & validation:
- uninstallTheme() repairs every selection slot (active + day + night), not
just the manual one, and is shared by both uninstall buttons (dedup).
- Validate theme CSS at install time and skip persisting CSS that won't inject
(no more "installed/active but renders nothing" with no feedback).
- Harden the runtime validator: exactly one rule, scoped exactly to the
theme's [data-theme='<id>'] selector (no unscoped/foreign selectors), no
at-rules, url() only data:, no expression()/javascript:, size-capped.
Tokens & polish:
- Fix three dangling undefined tokens (--surface-2 x2, --bg-surface).
- Finish the warning/success token sweep (--warning / --positive, themeable).
- Apply the active theme synchronously before React mounts (no first-frame
flash) and inject installed themes up front.
- One-time, dismissible notice when the slim-bundle migration reset a theme.
- Update badge uses semver, not string inequality.
- Offline/stale indicator in the store; cross-window theme sync; drop the now
dead REMOVED_THEME_REMAP and Card.mode field.
Tests: themeInjection (validator + sync), themeRegistry (cache/force/stale/
malformed), uninstallTheme (slot repair), migration notice. i18n in all nine
locales. Full suite green (1755 tests).
* test(bootstrap): cover startup theme apply + cross-window sync
The review fixes added applyThemeAtStartup / installCrossWindowThemeSync to
bootstrap.ts (a hot-path file) without tests, dropping its coverage to 68.3%
and failing the frontend hot-path coverage gate (>=70%). Add unit tests for
both (and the no-op / malformed-storage paths); bootstrap.ts is back to ~98%.
* feat(local-playback): LP-1 media layout and download_track_local
Add library-index-backed path builder in psysonic-core and a unified
Tauri download command that writes under media/{cache|library}/ with
layout fingerprints; legacy hot/offline commands unchanged for now.
* feat(local-playback): LP-2 localPlaybackStore and media tier Rust helpers
Add unified Zustand index with legacy offline/hot-cache import, media_layout
TS mirror, and Rust commands for tier size/purge/delete/promote.
* feat(local-playback): LP-3 wire prefetch and playback to unified index
Route downloads through download_track_local, delegate hot/offline shims
to localPlaybackStore, and update resolve/promote/prefetch plus key rewrite.
* feat(local-playback): LP-4–LP-6 offline UI, invalidation, and mediaDir
Offline Library loads pinned groups via library index; sync-idle invalidates
stale paths; Settings uses a single mediaDir with cache/library tier sizes.
* feat(local-playback): migrate legacy offline files to media/library layout
Move flat psysonic-offline downloads into nested media/library paths using
library index metadata, with retry on sync-idle when tracks are not yet indexed.
* feat(local-playback): simplify offline disk migration and restore Offline Library UI
Scan psysonic-offline on disk and relocate by library track id; restore pinSource
and cover art for migrated pins; add find_live_by_id for segment/key resolution.
* feat(local-playback): disk-first offline reconcile and fast library tier discovery
Reconcile library-tier index against on-disk files using candidate track IDs
instead of scanning the full catalog. Refresh Offline Library from disk on
open and focus so deleted folders drop out of the UI. Add Rust discover/prune
helpers and wire album/server reconcile through the unified path.
* fix(offline): resolve local playback URLs across server index-key variants
Offline Library play failed when library-tier files were indexed under a
host key while playback looked up only the active profile UUID. Use
findLocalPlaybackEntry for URL resolution, pin queueServerId to the card
server, and build play queues from tracks that still have on-disk bytes.
* fix(offline): playlist cards, playback from Offline Library, and local URL routing
Show playlists with name and quad/custom cover instead of the first track's
album artist. Build play queues with library-batch fallback and offline-only
server switch. Prefer library-tier URLs in playTrack; add playback-unavailable
toast and missing trackToSong import.
* fix(cache): ephemeral disk reconcile, empty-dir prune, and Storage UI
Sweep media/cache after eviction (orphan files, stale index, empty folders).
Settings: split media folder from cover cache; in-browser image cache lives
under Cover art cache with aligned columns; clear only IndexedDB images.
* feat(offline): show library disk usage in Offline Library header
Query media/library tier size on reconcile and display it in a right-aligned
stat block beside the page title and album count.
* fix(cache): defer unindexed hot-cache eviction; drop legacy offline size cap
Reconcile ephemeral cache without deleting files from other app instances;
evict unindexed hot-cache files oldest-first only when over hotCacheMaxMb.
Remove the hidden maxCacheMb gate and offline-full banner on album pages.
* feat(offline): play-all cache card and stable Offline Library grid rows
Add a shuffle-and-play card for all on-disk library pins plus hot-cache
tracks when buffering is enabled. Fix virtual row height for offline cards
and reserve the year line so grid rows no longer overlap.
* feat(offline): queue-cache grid card limited to media/cache
Replace the full-width play-all banner with a playlist-style grid tile.
Shuffle/enqueue only ephemeral hot-cache tracks when buffering is enabled,
not offline library pins.
* chore(licenses): regenerate bundled OSS list for 1.48.0-dev
Set GPL-3.0-or-later on workspace crates and extend cargo-about accepted
licenses so generate-licenses.mjs runs; refresh src/data/licenses.json.
* feat(favorites): auto-sync starred tracks into separate media/favorites tier
Keep manual Offline Library in media/library/ and favorites offline in
favorite-auto/index + media/favorites/ so toggling sync cannot purge
user-pinned bytes; playback resolves library before favorites.
* feat(favorites): compact offline toggle with disk icon and sync semaphore
Move control to the page header (disk + switch, tooltips); show red/yellow/green
LED when enabled instead of the full-width save-offline card.
* fix(favorites): trigger offline sync on star/unstar from anywhere
Hook star/unstar API so favorites offline reconcile runs globally (songs,
albums, artists); optimistic unstar removes local bytes; drop Favorites-page-only sync.
* fix(cache): skip hot-cache prefetch when favorites or library bytes exist
Treat favorite-auto tier like offline library for prefetch, stream promote,
and same-track replay so synced favorites are not duplicated in media/cache.
* fix(favorites): reconcile offline files on merged track union only
Dedupe artist/album/song stars into one target set per track id; drop eager
unstar deletes so overlapping favorites do not remove bytes still needed.
* feat(offline): add Favorites card to Offline Library
Mirror queue-cache card for favorite-auto tier with play, enqueue, and
navigation to Favorites on card click.
* feat(offline): show library+favorites disk total with icon breakdown
Sum media/library and media/favorites in the On disk widget and open an
icon popover on hover with per-tier sizes for screen readers and sighted users.
* feat(favorites): enable offline Favorites tab when auto-save is on
Keep Favorites in the sidebar when disconnected, land on /favorites without
manual pins, and load starred rows from the local library index.
* feat(favorites): cross-server offline browse with per-server covers
When auto-save is on, Favorites merges starred items from every indexed
server and syncs each server independently. Detail links carry ?server=
for offline album/artist pages; cover art resolves disk cache by entity
serverId instead of the active server only.
* feat(playback): mixed-server queue scope and cross-server favorites sync
Per-ref server identity for playback (URL index key in queue refs, profile
UUID for API): trackServerScope, playbackServer helpers, gapless/scrobble/covers
by playing ref. Remove cross-server enqueue block; remap queueItems on URL
remigration.
Favorites: star/unstar and favorite-auto sync target the owning serverId
(not only active); queueSongStar passes server through pending sync.
* fix(offline): suppress Subsonic calls during favorites and local playback
Add reachability guards so offline favorites browse, album detail, queue
sync, scrobble, and Now Playing metadata skip network when the server is
down or the track plays from psysonic-local. Load starred albums/tracks
from the library index only (not the full artist table), refresh favorites
from index first, and pass server scope in favorites navigation.
* fix(queue): export share and playlist save for active server only
Mixed-server queues now filter queue refs by the browsed server profile
before copying a share link or saving/updating a playlist from the toolbar.
* fix(offline): complete album pins and resume interrupted downloads
Prefer full getAlbum track lists when online so partial library index
does not truncate offline pins; refresh songs before pin from album
detail. Resume incomplete persisted pins after reconcile and reconnect,
cancel in-flight work on delete, and chunk library batch fetches past
100 refs.
* fix(offline): pin queue, queued UI, and re-pin after remove
Serialize album and playlist offline pins so parallel enqueue no longer
drops in-flight work. Show an explicit queued state on album and playlist
actions with dequeue on repeat click, sidebar tooltips for long labels,
and clear stale cancel flags so Make available offline works after remove.
* fix(offline): remove Offline Library cards without full page reload
Optimistically drop the deleted card from local grid state, show the
loading spinner only on first visit, and ignore stale disk refreshes so
pin updates no longer flash the whole library view.
* fix(offline): artist discography pin state and queue handling
Detect cached/queued/downloading from persisted album pins instead of
ephemeral bulkProgress, skip already offline or in-flight albums when
enqueueing discography, and show the correct hero button after revisit.
* fix(local-playback): address LP-1 review handoff (B1, M1–M7)
Harden media path sanitization and tier containment, align Rust/TS layout
fingerprints, serialize per-track downloads, and fix favorites re-enable,
multi-server debounce, prev-track promote key, now-playing reachability,
and ephemeral prefetch soft-skip for unindexed tracks.
* fix(offline): cancel in-flight favorites downloads on unstar
Abort Rust streams with the real favorites downloadId when sync is
rescheduled or disabled, and drop completed bytes that no longer belong
in the starred set so unstar does not leave orphan files on disk.
* fix(build): resolve TypeScript errors blocking prod nix build
Align mediaLayout with LibraryTrackDto camelCase, extend analysis-sync
reasons, fix OfflineLibrary grid cover typing, and tighten vitest mocks
so `tsc && vite build` passes under the flake beforeBuildCommand.
* fix(rust): satisfy clippy too_many_arguments for CI
Bundle offline-library analysis and local path/migration helpers into
parameter structs so `cargo clippy -D warnings` passes on the branch.
* fix(settings): show correct hot-cache track count in Buffering section
Count ephemeral localPlayback rows instead of prefix-matching index keys,
which always missed host:port server segments and showed zero tracks.
* fix(media-layout): align truncation threshold on code points (M1)
Rust sanitize_and_truncate_segment now uses char count like TS so long
non-ASCII metadata does not diverge layout fingerprints; add Cyrillic
parity tests and clarify ephemeral cold-miss doc on download_track_local.
* fix(test): use numeric cachedAt in hotCacheStore count test
Align test fixture with LocalPlaybackEntry type so tsc passes in CI.
* docs: add CHANGELOG and credits for offline experience PR #1008
* feat(offline): auto-sync manually cached playlists when track list changes
Re-download new tracks and prune removed ones for playlist pins only, triggered
from updatePlaylist, playlist detail load, smart-playlist polling, and reconnect.
* docs: note cached-playlist sync in CHANGELOG and credits for PR #1008
* fix(offline): exclude smart playlists from manual offline cache and sync
Hide cache-offline for psy-smart-* playlists, block download/sync paths, and
document the distinction in CHANGELOG.
* feat(offline): auto-sync cached albums and artist discographies
Generalize pinned playlist reconcile into pinnedOfflineSync so manually
pinned albums and artist discographies re-download added tracks and prune
removed ones on reopen, reconnect, and catalog changes.
* feat(offline): split pinned sync triggers by pin kind
Album and artist pins reconcile after library index sync and reconnect;
regular playlists reconcile hourly and on in-app playlist edits only.
Remove reconcile-on-open for album, artist, and playlist detail views.
* fix(offline): address PR #1008 review (N1, tests, pin queue)
Scope playlist reconcile to the owning server via getPlaylistForServer.
Add artist discography and mixed-server playlist tests; dedupe pending
sync jobs; skip pinTasks overwrite during active downloads.
* chore(playback): remove Preload Next Track setting
The configurable next-track RAM preload duplicated hot cache and is
obsolete now that ranged streaming starts playback sooner. Gapless and
crossfade still use the internal audio_preload backup when hot cache is off.
* docs: add CHANGELOG entry for Preload Next Track removal (PR #1007)
* fix(preview): Symphonia format sniff and cluster member stream URLs
Resolve preview container hints from HTTP headers, Subsonic suffix, and
magic-byte sniff after Symphonia 0.6. Route preview streams through
clusterBrowseServerId like main playback; guard CoverArtImage when the
preview cover ref is still loading.
* fix(preview): adapt Symphonia sniff branch for main without cluster routing
Keep formatSuffix and cover-ref guards from the cluster work; use
buildStreamUrl on the active server instead of clusterBrowseServerId.
* fix(preview): use ranged HTTP so preview starts without full-file download
Open preview via RangedHttpSource when the server supports byte ranges;
fall back to buffered download otherwise. Gate in-memory probe with
ProbeSeekGate so Symphonia 0.6 does not scan the entire file before audio.
* docs: CHANGELOG and credits for preview fix PR #1006
* chore: drop PR #1006 from settings credits (minor fix)
* fix(preview): allow clippy too_many_arguments on audio_preview_play
formatSuffix pushed the Tauri command to 8 args; matches other IPC commands.
* fix(waveform): repaint on Vite HMR so theme CSS var edits show live
The seekbar caches its colours and repaints the canvas on a data-theme
MutationObserver, so a manual theme switch picks up new waveform colours.
Editing a theme's CSS variables via Vite HMR changes the stylesheet but
not data-theme, so the observer never fires and the canvas keeps the old
palette until a theme switch — annoying during theme dev.
Add a dev-only effect that drops the colour cache and repaints on every
HMR update (vite:afterUpdate). import.meta.hot is undefined in production
builds, so the effect is stripped there — no runtime cost.
* fix(waveform): guard HMR effect against partial import.meta.hot in tests
vitest's SSR env exposes a partial import.meta.hot (has .on, no .off), so
the cleanup threw and failed all WaveformSeek/PlayerBar tests. Feature-detect
both .on and .off before wiring the vite:afterUpdate listener.
* feat(queue): add Timeline display mode
A third queue display mode alongside Queue and Playlist. Timeline shows
the full queue anchored on the current track — played history above
(dimmed), upcoming below — with 'History' and 'Up next' dividers and the
current row auto-centered. It already follows shuffle order since the
queue is stored in play order. The header mode button now cycles
queue → timeline → playlist; Settings gains a third option.
* i18n(queue): Timeline mode strings (9 locales)
* docs(changelog): note Timeline queue mode (#1004)
Follow-up cleanup after the old fullscreen player was removed (#1001):
- remove the 9 now-unused settings i18n keys (fsPlayerSection,
fsShowArtistPortrait(+Desc), fsPortraitDim, fsLyricsStyle(+Rail/Apple
+Descs)) across all 9 locales.
- reduce fullscreen-player-adaptive-portrait.css to the shared lyrics
overlay styles (FsLyricsApple); drop the old player's .fs-*, the
.fslm-* lyrics menu, the unused .fsa-fade-* and dead keyframes.
* feat(player): static media-center fullscreen player (v1)
New lean fullscreen player: sharp full-bleed background (artist photo, cover
fallback), no blur / no continuous animations. Bottom-left big cover bottom-
flush with a full-width semi-transparent text bar (title with queue position,
artist, year/genre + rating stars, next-up); control row of transport · center
time · actions; full-width seekbar; live clock. Only the seekbar, time readout
and clock update at runtime, each owning its state (no per-tick re-render).
Reuses FsSeekbar/FsPlayBtn, the cover/artist hooks, idle-fade and queue
helpers. Wired in AppShell; old player kept for A/B.
* feat(fullscreen-player): true waveform seekbar instead of thin bar
Replace FsSeekbar with the real WaveformSeek (cucadmuh's idea). The taller
canvas grows the bottom cluster upward, shifting the info/control rows up.
Height clamped to 32-52px.
* feat(fullscreen-player): up-next popover + control-bar styling
- Queue button opens a semi-transparent 'Up next' popover anchored bottom
right; clicking a row jumps to that queue item.
- Larger bottom-right action buttons.
- Control row gets its own darker semi-transparent bar (touches the info bar
above) for contrast; play button matches the plain transport buttons
(no white circle, same size).
* feat(fullscreen-player): high-res (2000px) background cover
Fetch the full-screen background cover at the 2000px tier via the existing
on-demand fullRes path (same getCoverArt fetch, saved as a high-res WebP
outside the backfill pipeline) instead of the low-res 500px pipeline tier.
usePlaybackCoverArt now forwards a fullRes option to useCoverArt.
* feat(fullscreen-player): scrolling lyrics overlay + control-bar polish
- Lyrics button next to Queue toggles a centered, dark semi-transparent
scrolling-lyrics overlay (reuses FsLyricsApple).
- Control bar darkened to match the lyrics overlay (0.88).
- Close button sized down to harmonize with the clock.
* feat(fullscreen-player): make rating stars clickable
The rating stars next to the year were display-only. Wire them to
queueSongRating (same path as the player bar / context menu): click to
set, click the current value to clear, with a hover preview. Keeps the
lucide outline look to match the rest of the control bar.
* fix(fullscreen-player): stable, high-res album cover
The foreground cover was keyed per track, so Navidrome's per-track
`mf-<id>` coverArt re-triggered the distinct-disc heuristic and reloaded
the cover on every song change within the same album. Key it on albumId
(via useAlbumCoverRef) so it stays put while the album is unchanged.
It also used the low-res tier; reuse the fullRes 2000px cover already
fetched for the background so the foreground is crisp and both share a
single fetch/decode.
* refactor(fullscreen-player): remove the old player and its settings
The static rebuild is now the only fullscreen player. Delete the old
FullscreenPlayer component, its old-only parts (FsArt, FsPortrait,
FsSeekbar, FsLyricsRail, FsLyricsMenu, useFsDynamicAccent) and its test.
Remove the now-orphaned settings (showFullscreenLyrics, fsLyricsStyle,
showFsArtistPortrait, fsPortraitDim) along with the Appearance
'Fullscreen player' section and its search entry. The new player always
shows the artist photo with cover fallback and has its own lyrics toggle.
Shared building blocks used by the static player (FsLyricsApple,
FsPlayBtn, FsClock, FsTimeReadout, FsQueueModal, useFsArtistPortrait)
are kept.
* i18n(fullscreen-player): translate the new player's strings
Wire the static player's previously English-only strings through i18n
across all 9 locales: now-playing label, track position, up-next label,
queue/lyrics/shuffle controls, and the queue overlay (title, empty,
close). Reuses existing queue.title / common.close keys; adds six new
keys to the player namespace.
* docs(changelog): note fullscreen player rebuild (#1001)
* feat(audio): migrate Symphonia 0.5 -> 0.6
Port the audio + analysis pipeline to the Symphonia 0.6 API:
- bump symphonia to 0.6 and symphonia-adapter-libopus to 0.3; drop rodio's
symphonia-all feature to avoid a duplicate symphonia-core 0.5
- remove the local symphonia-format-isomp4 0.5 patch and rely on upstream 0.6
- switch codec registries to register_enabled_codecs / make_audio_decoder with
AudioCodecParameters + AudioDecoderOptions
- rework decode.rs SizedDecoder and analysis decode loops for the new
AudioDecoder trait, GenericAudioBufferRef, Time/Timestamp newtypes, and
next_packet() -> Result<Option<Packet>>
Streaming regression fixes:
- ProbeSeekGate hides seekability during probe for non-MP4 progressive streams
so Symphonia 0.6's trailing-metadata scan no longer forces a full download
before ranged FLAC/MP3/OGG playback can start
- guard the streaming probe() with a 20s timeout on a worker thread so a stalled
ranged source (e.g. right after a server switch) can no longer hang playback
start until a player restart; add probe start/done diagnostics
* docs(changelog): note Symphonia 0.6 migration and streaming fixes (#999)
Add CHANGELOG entries (Changed + Fixed) and a CONTRIBUTORS line for the
Symphonia 0.6 migration, ranged-stream start-latency fix, and probe timeout.
* test(audio): cover streaming probe path and ProbeSeekGate
Add unit tests for SizedDecoder::new_streaming (success + garbage) and
ProbeSeekGate (seekability toggle, byte_len, read/seek passthrough) to
restore decode.rs above the 70% hot-path coverage gate (67.3% -> 79.4%).
* feat(sidebar): add toggle to pin Now Playing to the top
The fixed Now Playing entry can now be pinned to the very top of the
sidebar (above the Library label) instead of sitting above the bottom
spacer. Adds a persisted `nowPlayingAtTop` setting (default off, so
existing layouts are unchanged) and a toggle in the sidebar customizer
next to the Mix-navigation split. The entry stays non-hideable and
keeps its playing indicator in both positions.
* i18n(settings): Now Playing-at-top toggle strings (9 locales)
* docs(changelog): note Now Playing top toggle (#1000)
* fix(settings): restore full border on active server card
The active server card mixed the `border` shorthand with borderTop/
borderBottom longhands in one inline style object. React clears the
unset longhand sides on mount, so the top and bottom borders fell back
to the subtle base border while only the left/right accent border
showed. Drive the drag drop-target indicator via an inset box-shadow
instead, leaving the border shorthand to apply on all four sides.
* docs(changelog): note active server card border fix (#998)
* chore(deps): bump npm patch/minor dependencies
Refresh frontend lockfile for React, Vite, Vitest, i18next, axios, Tauri
plugins, and related type packages within existing semver ranges.
* chore(deps): bump Rust patch dependencies
Update id3 to 1.17, reqwest to 0.13.4, and align root zbus with psysonic-audio 5.16.
* chore(deps): upgrade jsdom to v29
Major test-environment bump; all Vitest suites still pass and npm audit is clean.
* chore(deps): upgrade sysinfo 0.39 and zip 8
Align root sysinfo with psysonic-syncfs and migrate backup archives to zip 8.
mach2 0.6 deferred — cpal/rodio still require ^0.5.
* chore(nix): sync npmDepsHash with package-lock.json
* docs(changelog): note dependency refresh (PR #997)
* docs(changelog): move deps note to [1.48.0] section
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Add a thank-you note at the top of the 1.47.0 release notes for the
Discord community's support, quality checks, bug reports and general
collaboration, with the Discord invite link.
* fix(tray): stable tray icon id so KDE toggle stops piling up duplicates
TrayIconBuilder::new() assigns a fresh id on every rebuild. On KDE
(StatusNotifierItem) each new id registers a new item and the stale ones
linger in the hidden-icons list, so toggling the tray icon off/on stacked
up duplicate entries. Build with a constant id so every rebuild reuses the
same item.
* docs: changelog for tray icon duplicate fix (#991)
* fix(audio): cap pipewire-alsa client latency on Linux (#862)
On some Linux setups the pipewire-alsa bridge negotiates a multi-second
output buffer, so play/pause/seek/volume only take effect once it drains
(~10-20 s). cpal's buffer-size clamp is ignored by those bridges. Set
PIPEWIRE_LATENCY=256/48000 before the audio stream opens to cap the client
node latency — the reporter confirmed this makes the controls instant.
Linux-only, no-op without PipeWire, and a user-set value is left untouched.
* docs: changelog for pipewire latency fix (#990)
* chore: restore PR order in CHANGELOG [1.47.0] Fixed section
Re-sort ### blocks ascending by PR number per release-note placement rules after out-of-order Discord-fix batch inserts.
* fix(player): stable playbar clocks when showing remaining time
Pad seekbar time strings to a fixed width so ticking remaining time does not resize WaveformSeek via ResizeObserver; tighten clock-to-waveform spacing and keep the toggle icon inline.
* docs: CHANGELOG and credits for playbar remaining-time fix (PR #987)
* docs: CHANGELOG entry for playbar remaining-time waveform fix (#987)
* chore: drop settingsCredits entry for minor playbar fix (#987)
* docs: move #987 CHANGELOG entry to end of [1.47.0] Fixed section
* fix(ui): stop New badge flicker on mainstage album rail first hover
Dim cover via ::before under badges; keep play overlay transparent and
avoid visibility toggles on rail action buttons.
* docs: CHANGELOG for mainstage New badge hover fix (PR #986)
* fix(ui): keep New badge above rail cover zoom (match grid stacking)
Use contain:paint + translateZ layering like New Releases grid so scaled
cover art does not paint over the badge during hover animation.
* fix(ui): global album cover badge stacking for all rails and grids
Move img/badge/overlay z-index into album-card.css; stop album-grid from
resetting card transform or duplicating cover rules that hid New badges.
* docs: CHANGELOG — badge stacking applies to all album rails
* fix(ui): restore rail play buttons above scaled cover layer
Raise play overlay and badge z-index on horizontal rails so WebKit does not paint zoomed cover art over hover controls after the New badge stacking fix.
* fix(search): load album cover for song results
Live Search song rows carried the per-track `mf-…` coverArt id from
search3, which the cover pipeline does not resolve, so the thumbnails went
blank. Album rows worked because they use the album-scoped `al-…` id. A
song's search thumbnail is its album cover anyway — derive it from the
albumId so it loads, and show a music glyph when there is no album.
* docs: changelog for search song cover fix (#984)
* fix(search): reject FTS syntax chars in local index live search queries
FTS5 treats `=` and similar characters as query syntax, so tokens like
1=2 produced unrelated prefix hits. Skip FTS for unsafe tokens instead.
* docs: CHANGELOG for local index FTS syntax query fix (PR #983)
* fix(search): reject syntax junk in server search3 and live search race
Share FTS-safe token guard with search3; skip network invoke for ** and
similar queries; show empty dropdown without misleading source badge.
* fix(search): allow censorship stars in queries, reject wildcard-only tokens
Block ** and **** but keep ***Flawless-style title searches working
in local FTS and search3.
* fix(ui): square song-rail nav buttons to match other rails
The song rail's prev/next (and reroll) buttons were circular while every
other rail uses the square rounded-rect nav button. Align them.
* docs: changelog for square song-rail nav buttons (#982)
* fix(layout): collapse browse toolbar to icons on compact width
On a narrow window the labelled filter buttons wrapped into several rows
and, being a fixed-height sticky header, starved the album grid below them
down to a clipped strip. Wrap each toolbar label in a hideable span and drop
to icon-only in compact (mobile) mode; icons keep their tooltip and
aria-label so the action stays discoverable.
* fix(window): raise minimum window size to 520x640
The old 360x480 floor let the window shrink far past the point the layout
holds together. Floor it at a laptop-friendly size where the compact layout
(icon toolbar + scrollable lists) still works.
* fix(player): scale mobile cover to fit short windows
The cover width was viewport-derived with no height bound, so on a short
window the square grew past its slot and overlapped the title. Bind it to
the shrinkable wrap via max-height and keep it square with auto sizing.
* docs: changelog for small-window layout fixes (#981)
* fix(playlist): suggestion rows match the normal track row
The Suggested Songs table left the Favorite and Rating columns empty and
rendered only a single artist, so multi-artist tracks lost their split and
the blank columns read as a gap between Genre and Duration.
- Extract a shared PlaylistArtistCell that splits the OpenSubsonic artists
array into individually navigable links; use it for both the playlist
rows and the suggestions so a track reads the same before and after it is
added.
- Show the real favorite heart and star rating in suggestion rows (global
song operations), seeded from the song's own starred/userRating, removing
the empty-column gap.
* docs: changelog for suggestion row parity (#980)
* fix: show album artist on featured compilation cards
The 'Also featured on' cards are synthesised from search3 child songs,
which only read the flat `albumArtist` field. Compilation children leave
that empty — the album-artist credit lives in OpenSubsonic's structured
`albumArtists` / `displayAlbumArtist` — so the card fell back to the '—'
placeholder. Carry the structured credit (and the display string) onto
the synthesised album so it resolves a name (and stays navigable when the
server supplies an id).
* docs: changelog for featured-compilation artist credit (#979)
* fix: RC3 queue link underline, genre album artist split, Artists "Other" bucket
Three zunoz reports, one change:
- Queue now-playing card: the artist and album links now underline on hover
(not just recolour), matching clickable names everywhere else. The album
link sits on .queue-current-sub itself; artist links are nested .is-link
spans — both selectors covered.
- Genre album cards split multi-artist credits into individual links like the
rest of the app. Root cause was the local-index genre query hardcoding NULL
for the album raw_json column, so OpenSubsonic artists[] never reached the
card and it fell back to the flat "A • B" single link. Select a.raw_json
instead (parity with the All Albums / advanced-search album queries).
- Artists page alphabet index: # is now digits-only, and a new "Other" bucket
collects accented Latin (Æ/Ø/Å…) and non-Latin scripts (CJK, Cyrillic, …)
that previously fell into the # catch-all. Adds artistBucketKey/compareBuckets
helpers (unit-tested), an OTHER bucket sorted last, and the artists.other
label across 9 locales.
* docs(changelog): queue link hover, genre card artist split, Artists Other bucket (#977)
* fix(tracks): RC3 layout regressions + multi-artist links
- Restore vertical rhythm on the Tracks hub: the header/hero/rails/browse
sections sat two wrapper divs below .tracks-page so its flex gap never
reached them and they collapsed together. New .tracks-hub-stack re-applies
the gap, fixing the tagline-touching-hero and Random-Pick-overlapping-hero
spacing (the rail's nav buttons no longer ride into the box above).
- Stop the sticky browse-table header going transparent on hover: its base
background became opaque but the :hover rule still forced transparent, so
rows bled through. Header now keeps its card background on hover.
- Widen the Duration column (56px -> 72px; 56 -> 64 on mobile) so the
"DURATION" header no longer clips/overruns.
- Split multi-artist tracks into individually clickable artist links in both
the "Track of the moment" hero and the browse list rows (OpenSubsonic
artists[] with single-artist fallback), matching the album tracklist.
* docs(changelog): Tracks RC3 spacing, Duration, header hover, multi-artist (#976)
* fix(mainstage): rename Home Page setting, add start-page fallback + empty state
- Rename "Home Page" personalisation section to "Mainstage" across 9 locales
so the heading matches the sidebar entry; add "mainstage" search keyword.
- Index route "/" now redirects to the first visible library item when the
Mainstage sidebar entry is hidden, instead of stranding the app on a blank
page. New pure resolveStartRoute() mirrors sidebar order + nav-mode gating.
- Show a guided empty state on Mainstage when every section is toggled off,
with a CTA into Settings -> Personalisation.
- Unit tests for resolveStartRoute.
* docs(changelog): Mainstage rename, start-page fallback + empty state (#975)
* fix(ui): pointer cursor on the queue collapse handle
The round handle is a click-to-collapse button but showed the col-resize
cursor like the seam strip. Use a pointer on the handle; the seam keeps
col-resize and a real drag still switches the body cursor to col-resize.
* fix(favorites): make Plays, Last Played and BPM columns sortable
The header marked these columns sortable (pointer cursor) but handleSortClick
gated on a separate, narrower column set, so clicks did nothing. Both the
comparator and the header now share one SORTABLE_COLUMNS source, so the
affordance and the behaviour can't drift apart again.
* docs(changelog): queue handle cursor + favorites column sorting (#974)
* fix(random-mix): limit exclusion toggle click area to checkbox and title
The audiobook exclusion was a full-width label wrapping the checkbox,
title and description, so clicking empty space or the description text
toggled it. Make only the checkbox and its title clickable; the
description and surrounding space are no longer hit targets.
* fix(random-mix): stop excluding Thriller and Fantasy as audiobook genres
These keywords match regular music (e.g. Trance/Metal genre tags, a track
titled "Thriller") because the exclusion checks genre, title, album and
artist by substring, dropping a few legit songs per mix. Remove both from
the audiobook keyword list.
* docs(changelog): random mix audiobook exclusion fixes (#973)
* feat(tooltip): 2s open delay and shared tooltipAttrs helper
Add a 2s hover open delay in TooltipPortal (single behaviour source) so
tooltips no longer flash on quick pointer passes; hiding stays immediate.
Add tooltipAttrs() to pair data-tooltip with a matching aria-label for
buttons touched in the unification work. Covered by Vitest.
* feat(tooltip): lower open delay to 1s
2s felt too long in testing; 1s gives the same anti-flash behaviour
without making intentional hovers wait.
* feat(tooltip): action tooltips on the artist overview
Add tooltips describing the action to Last.fm, Wikipedia, Play All,
Shuffle and Radio. Shuffle/Radio now show a tooltip on desktop too,
not just mobile. Strings added to all 9 locales.
* feat(tooltip): action tooltips on the album overview
Add tooltips describing the action to the desktop Play, Artist Bio and
Download (ZIP) buttons, matching the mobile layout. Strings added to all
9 locales.
* feat(tooltip): action tooltips on the All Albums toolbar
Add tooltips describing the action to the sort, year and genre filter
buttons. SortDropdown gains an optional tooltip prop; the year and genre
filter components carry their own, so the tooltips also appear on the
other browse pages that reuse them. Strings added to all 9 locales.
* feat(tooltip): action tooltips on song-list rows
Add Play and Add-to-queue tooltips to the per-row icons in SongRow, used
by the Tracks browse list, Search and Advanced Search. Also localizes the
aria-labels, which were hardcoded English. New common.addToQueue in all 9
locales.
* fix(tooltip): uniform tooltip placement on the Artists toolbar
The favourite and multi-select buttons forced tooltips below while the
view-mode buttons auto-flipped above, so the row looked inconsistent.
Pin the view-mode buttons below too, matching the rest of the row and the
Albums toolbar.
* feat(tooltip): clarify and align the Advanced Search scope row
Add a leading "Search in:" label and per-chip tooltips so the
All/Artists/Albums/Songs row reads as a scope limiter. Drop the forced
below-placement on the small star filter (used only here) so the
favourites chip flips with the others instead of sitting alone below.
Strings added to all 9 locales.
* docs(changelog): tooltip unification (#972)
* fix(playlists): Smart Playlist editor theme, toggles, and exclude-all-genres
Replace native sort select with CustomSelect, fix mode-button layout shift,
color-code included vs excluded genres, collapse exclude-all to untagged rule,
and handle empty smart playlists without false "not found".
* docs: CHANGELOG and credits for Smart Playlist editor fix (PR #970)
* chore(credits): drop minor fix entries from PR #958 onward
* fix(ui): shrink-wrap floating player bar instead of full-width strip
The bar used fixed left and right insets, which stretched its background
across the whole main column. Center it with max-content width so only
the pill-shaped controls are painted, and soften the drop shadow.
* docs: note PR #969 in changelog and settings credits
* 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
* 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
* 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.
* 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
* 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.
* 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
* 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
* 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)
* 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)
* 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
* 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)
* 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)
* 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)
* 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.
* 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)
* 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.
* 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.
* 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.
* 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.
* 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.
* 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)
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.
* 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.
* 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)
* 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
* 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
Stop replacing the whole Monitor tab when CPU/RSS sampling is unsupported;
show pipeline, UI rate, and analysis sections with an inline platform note.
Also compute UI diagRates when the Rust snapshot returns supported: false.
Replace deprecated libc mach_host_self/mach_task_self with mach2 APIs
while keeping host_processor_info on libc (no mach2 binding).
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.com>
* fix(perf): use Mach host_processor_info for macOS CPU ticks
KERN_CP_TIME and CPUSTATES are not exposed by libc on Darwin; switch
read_host_total_cpu_ticks to host_processor_info so aarch64-apple-darwin CI builds succeed.
* docs: CHANGELOG and credits for macOS perf CI fix (PR #931)
* Revert "docs: CHANGELOG and credits for macOS perf CI fix (PR #931)"
This reverts commit a217217c34.
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.com>
* 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)
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.
* 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)
* 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)
* 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)
* 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)
* fix(deps): bump tar to 0.4.46 (GHSA-3pv8-6f4r-ffg2)
Transitive dependency via tauri-plugin-updater; closes Dependabot alert #16.
* docs(release): CHANGELOG and credits for tar security bump (PR #923)
* revert: drop CHANGELOG and credits for tar security bump
* 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.
Complete the Dependabot #910 bump: update Cargo.toml and migrate backup
archive writes to SimpleFileOptions (zip 4.x API). Fixes lockfile drift
where cargo downgraded zip back to 0.6.6 on every dev build.
* chore(library): squash pre-RC migrations into single 001_initial baseline
Library SQLite never shipped in a release, so fold migrations 002–008 into
001_initial.sql and drop the dev-only mood-facts purge (009). Sets
LIBRARY_DB_SCHEMA_VERSION to 1 for the RC baseline; analysis migrations
unchanged.
* docs: note library migration squash in CHANGELOG and credits (PR #919)
* fix(library): keep migration SQL files on disk after RC baseline squash
Restore 002–009 as historical dev migration scripts. Runner still ships
only 001 for fresh installs; existing DBs with applied versions are
unchanged. Drop credits/CHANGELOG note for this small internal change.
* 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)
* chore(deps): bump rusqlite to 0.40 workspace-wide
Align root src-tauri and workspace crates on rusqlite 0.40 so libsqlite3-sys
resolves to a single version (fixes Dependabot #913 links conflict).
* chore(nix): refresh flake.lock for rustc 1.95 dev shell
libsqlite3-sys 0.38 (rusqlite 0.40) needs cfg_select; nixpkgs pin was on
rustc 1.94. Bump workspace MSRV to 1.95 to match.
* chore(deps): batch remaining Dependabot npm bumps and ignore Symphonia 0.6
Bump @vitejs/plugin-react, @tauri-apps/cli, react-router-dom, and vitest;
configure Dependabot to skip symphonia >=0.6 and adapter-libopus >=0.3 until
the coordinated migration tracked in workdocs.
* chore(nix): sync npmDepsHash with package-lock.json
* chore: retrigger CI for PR checks
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* 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.
* 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.
* 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)
* 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>
* 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).
* 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)
* 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)
* 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.
* 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)
* 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.
* 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
* 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 }.
* 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.
* 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.
* 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>
* fix(analysis): cap HTTP backfill on CPU-seed pipeline load
Aggressive library analytics with multiple workers grew RAM unbounded:
HTTP downloads finish in ~500 ms, but Symphonia decode + R128 loudness
take seconds, so decoded `Vec<u8>` track buffers piled up in the
CPU-seed queue while the HTTP worker kept fetching. On large libraries
the process eventually saturated memory and forced the system into swap.
Backpressure: the HTTP backfill worker now checks the CPU-seed pipeline
depth (queued + running) against a `workers * 2` cap before popping the
next job. High-priority (now-playing) jobs bypass the cap so playback
prefetch is never starved. The CPU-seed worker pings the HTTP queue
after every completed decode, so the gate releases the instant decode
catches up.
The frontend backfill loop already idles at its own watermark when the
HTTP queue is satisfied — this change keeps the pipeline self-limiting
end-to-end even when callers (library top-up, playlist enqueue) submit
faster than decode drains.
Tests cover cap scaling, the floor for workers=1, the idle decision,
and the high-priority bypass.
* docs(changelog): analytics aggressive scan no longer eats memory (#873)
* refactor(queue): wire queue UI to the track resolver (thin-state phase 3)
cucadmuh's phase-3 steps:
- Selectors (useQueueTracks) read resolver-first: getCachedTrack → queue: Track[]
fallback (until phase 4), F4 star/rating overrides merged on read.
- QueueList rows source their track from the resolver (queue fallback); rows show
title/artist/duration only, so no override merge there.
- pendingStarSync star/rating success → invalidateQueueResolver so the cache
reflects the synced value.
- queueResolverBridge re-seeds on queueIndex change too — the prefetch window
travels with the playing track.
Additive: queue: Track[] stays canonical and behaviour is unchanged (rows
resolve to the same data). Phase 4 drops queue: Track[] and the fallbacks.
* docs(changelog): queue panel reads through track cache (#860)
* fix(queue): stop a render loop that froze the UI on long queues
A long virtualized queue + a track change could lock the WebView for ~2 min:
- useVirtualizer was handed a fresh `initialRect` object literal every render, so
it kept re-initializing in a loop. Hoisted it to a stable module constant.
- getCachedTrack did an LRU bump (Map delete+set) during render — a render-time
side effect. Made it a pure read; recency is set at write time in cacheSet.
* perf(mobile): virtualize the mobile player queue drawer
The mobile now-playing queue drawer rendered the full queue with .map; a
multi-thousand-track queue meant thousands of DOM nodes. Virtualize it with
@tanstack/react-virtual (uniform rows, stable initialRect) so the DOM stays at
O(visible rows), matching the desktop QueuePanel. Active track is centred on open
via scrollToIndex.
* perf(mini): virtualize the mini-player queue list
The mini-player queue rendered the full MiniSyncPayload queue with .map.
Virtualize it against the OverlayScrollArea viewport (stable initialRect) so the
mini window's DOM stays at O(visible rows). Drag-reorder is preserved: rows keep
data-mq-idx alongside the virtualizer's measureElement.
* refactor(queue): add resolveQueueTrack/getQueueTracksView helper (thin-state phase 4)
Render-safe ref→Track view for the phase-4 consumer migration off queue: Track[].
Resolver cache → caller fallback (legacy queue[idx] during dual-write) →
placeholder; ref queue-only flags carried, F4 overrides merged. Pure synchronous
read, no cache mutation (the freeze landmine), so it is safe in render.
* refactor(queue): keep queueItems as the canonical in-memory mirror (thin-state phase 4)
Step 1b: dual-write the thin queueItems ref list at every queue write site
(the 11 mutations, next/radio top-up, playTrack, undo/redo restore, instant-mix,
radio, server-queue init, lucky-mix rollback, and hydrate) so it tracks
queue: Track[] in memory, not only at persist time. Identity-preserving maps
(star/rating overrides) keep the same refs and are intentionally left untouched.
Resolves the restore double-role flagged for 1b: queueItemsIndex is now the
restore-pending sentinel that gates hydrateQueueFromIndex, while queueItems
stays canonical -- rebuilt from the whole queue after a full hydrate instead of
cleared. Normal mutations never set the sentinel, so it only fires on a fresh
cold-start restore, not on later server switches.
No behaviour change; queue: Track[] stays the source consumers read until
phase 3. tsc + full vitest suite (1119 tests) green.
* refactor(queue): mobile queue drawer reads through the track resolver (thin-state phase 4)
Step 2: the mobile now-playing queue drawer resolves each row's track from the
resolver cache (→ queue: Track[] fallback until phase 4), matching the desktop
QueueList wired in the phase-3 commit. Subscribes to the resolver version so
rows re-render as the cache fills. Structure (count, order, keys, the playTrack
arg) still comes from queue: Track[] until it is dropped in the final step.
The mobile drawer was the last queue display surface still reading track
metadata straight off the fat queue. tsc + full vitest suite green.
* refactor(queue): ref-native queue mutations + dual-write bridge (thin-state phase 4)
Step 3a: the 11 queueMutationActions now splice/filter/reorder QueueItemRef[]
(matching by trackId + the ref's queue-only flags) instead of Track[].
`bridgeQueueFromItems` rebuilds the dual-written queue: Track[] from the new
refs by id — purely structural (no resolver/override merge), so behaviour is
byte-identical and playerStore.queue.test.ts stays unchanged green. The working
ref list comes from `itemsOf(state)` (derived from queue: Track[] for now); the
final step swaps that one line to state.queueItems once the fat queue is gone.
enqueue / enqueueAt / enqueueRadio seed the resolver cache with incoming tracks
(seed-before-splice) so they resolve without a network round-trip after the fat
queue is dropped. Adds a DEV-only id-parity guardrail (queue vs queueItems);
dev-runtime only, silent in vitest and prod.
tsc + full vitest suite (1119) green; contract test unchanged.
* refactor(queue): ref-native radio/infinite top-ups (thin-state phase 4)
Step 3b: nextAction's proactive infinite-queue and radio top-ups build the new
queue as QueueItemRef[] and bridge back to queue: Track[] (same as the queue
mutations), and seed the resolver cache with the freshly fetched tracks so they
resolve without a network round-trip after the fat queue is dropped. The radio
top-up keeps its HISTORY_KEEP front-trim, now expressed on refs.
The exhausted-queue refill paths hand their new queue to playTrack, which keeps
its fat-queue handling until the final step (its no-arg case needs the resolver-
derived queue that lands with the queue: Track[] removal). tsc + full vitest
(1119) green; contract test unchanged.
* refactor(queue): undo snapshots store thin refs, not Track[] (thin-state phase 4)
Step 4: QueueUndoSnapshot.queue: Track[] becomes queueItems: QueueItemRef[],
killing the undo "hidden multiplier" — 32 snapshots of a 50k queue now cost
refs, not 32×50k full tracks. applyQueueHistorySnapshot rebuilds the display
queue from the refs via resolveQueueTrack: resolver cache → the live queue by id
(covers tracks the edit didn't remove) → placeholder. currentTrack stays a full
track in the snapshot and is restored to the engine unchanged.
The snapshot refs derive from queue: Track[] for now (so the undo/redo contract
cases, which seed only `queue`, stay green); the final step swaps that to
[...s.queueItems]. tsc + full vitest suite (1119) green.
* perf(mini): cap the mini-player queue snapshot to ±100 around the current track (thin-state phase 4)
Step 5: the mini bridge no longer serializes the full queue over IPC on every
push — a 50k Artist-Radio queue would otherwise re-encode in full on every track
advance. snapshot() sends a window of 100 tracks before/after the playing song;
queueIndex is made slice-relative. The mini component stays unchanged (slice-
relative); jump/reorder/remove control events are translated back to absolute
queue indices via the window offset captured on the last push.
tsc + full vitest suite (1119) green. Mini bridge has no unit tests — needs a
quick mini-player smoke (queue shows ±100, jump/reorder/remove land correctly).
* refactor(queue): make queueItems a required PlayerState field (thin-state phase 4)
Foundation for the final consumer migration off queue: Track[]: queueItems has
been written at every queue write site since phase 1b, so promoting it from
optional to required is a no-op at runtime (tsc confirms zero new errors) and
lets the upcoming reader migrations read state.queueItems without `?? []` noise.
* refactor(queue): migrate structural queue readers off queue: Track[] (thin-state phase 4)
First reader batch toward dropping queue: Track[]: the queue-length selectors
(usePlaybackServerId, usePlaybackCoverArt, useQueuePanelDrag, useMiniQueueDrag)
now read state.queueItems.length, and FullscreenPlayer's next-track cover prefetch
resolves through useQueueTrackAt instead of indexing the fat queue. All behaviour-
identical during dual-write (queueItems is in lockstep with queue). tsc + full
vitest suite (1119) green.
Note: getPlaybackServerId() (playbackServer.ts) deliberately stays on queue for
now — it is called from many partially-mocked test stores, so it migrates with
the final field removal where the seedQueue helper covers those tests.
* refactor(queue): QueuePanel save/share/playlist read queueItems (thin-state phase 4)
The id/length reads (save to playlist, share link, create playlist, empty-queue
guards, next-tracks divider) now read state.queueItems instead of the fat queue.
Behaviour-identical during dual-write; queue: Track[] stays for the rendered
QueueList + auto-scroll until the field is dropped. tsc + full suite (1119) green.
* refactor(queue): drop queue: Track[] — thin queueItems is the only queue (thin-state phase 4)
The store no longer holds the fat queue. `queueItems: QueueItemRef[]` is the sole
canonical queue; full `Track`s resolve on demand via the resolver (index batch →
getSong fallback, bounded LRU cache); only `currentTrack` stays a full Track. At
50k tracks the store holds ~hundreds of resolved tracks + the refs, not 50k Track
objects.
- **Persist:** partialize is refs-only (no windowed slice / PERSIST_QUEUE_HALF).
A `merge` migrates every historical blob shape → `queueItems` (existing
`queueItems` → legacy `queueRefs` → pre-ref windowed `queue: Track[]`) and drops
the obsolete `queue` key, so saved queues survive the upgrade.
- **Restore (decision B):** `hydrateQueueFromIndex` eager-resolves the whole
ref list into the cache on cold start (index → getSong, so an index-off queue
still plays), clears the restore sentinel.
- **Resolver bridge:** keeps `[idx-50, idx+200]` warm via `resolveVisibleRange`.
- **Mutations / actions / playback:** operate on refs; the playing track is
`currentTrack`, the next/neighbour tracks resolve from the cache. Navigation
(next/previous/row-jump) keeps `queueItems` and only moves the index — no full
resolve or queue rebuild per track change.
- **Persist tests** cover the three old-blob migrations; `seedQueue` test helper
replaces the `setState({ queue })` seeds.
tsc + full vitest suite (1115) green. Behaviour-preserving by the test contract;
the gapless track change + cold-start restore + mini cap still want a live smoke
before merge.
* fix(queue): star/rating keeps the queue row resolved instead of blanking to "…" (thin-state)
Rating/starring a queue song flashed the row's title to the "…" placeholder
until the next track change. Root cause: on sync success pendingStarSync called
invalidateQueueResolver, which DROPPED the cached track — and with queue: Track[]
gone there's no fat fallback, so the row resolved to a placeholder until the
resolver bridge re-fetched the window.
Fix: add patchCachedTrack(trackId, patch) and use it on star/rating success to
update the cached entry in place (title kept, synced starred/userRating applied)
instead of dropping it. No placeholder flash, no re-fetch.
tsc + full vitest suite (1115) green.
* fix(player): quota-safe persist so a full localStorage can't kill playback
A very large queue (~50k refs) overflows the localStorage quota; the persist
write then threw QuotaExceededError from inside set(), which aborted playTrack
before audio_play — no audio output at all. Back the player persist with a
quota-safe storage wrapper so a failed write degrades to a no-op instead of
throwing. Restoring the full ref list at that ceiling (vs a windowed cap) is
left as a follow-up.
* polish(player): throttle the quota-skip persist warning to once per key
The quota-safe persist logs a skip on every failed write; on a huge queue that
floods the dev console once per mutation. Warn once per key per quota-exceeded
streak, re-armed when a write to that key next succeeds.
* fix(queue): port new cover-pipeline readers to thin-state
Main's cover pipeline (#870) reads s.queue.length and seeds the player
store with queue: [track] in its tests. Under thin-state, queue: Track[]
no longer exists — the canonical queue is queueItems: QueueItemRef[].
These four files were brought across in the merge but still spoke the
old shape; this commit aligns them with the thin-state contract.
- src/cover/usePlaybackCoverArt: queueLength = queueItems.length
- src/cover/usePlaybackCoverArt.test: seed via toQueueItemRefs
- src/api/coverCache.test: same
- src/hooks/useNowPlayingPrewarm.test: same (two test cases)
* fix(queue): canonicalize thin-state server identity for mixed-server queues
`QueueItemRef.serverId` and `PlayerState.queueServerId` are now written as
the URL-derived index key on every writer path, matching the library index
direction. Mixed-server queues with duplicate `trackId` across servers stay
unambiguous because the resolver cache, persistence, and playback bindings
all share one key shape.
- new `canonicalQueueServerKey()` helper (idempotent UUID-or-key normalizer)
- `toQueueItemRefs`, `bindQueueServerForPlayback`, `seedQueueResolver`, and
`hydrateQueueFromIndex` emit canonical keys
- `getCachedTrack` falls back to the canonical lookup so refs persisted in
the legacy UUID shape still resolve through the migration window
- persist `merge` rewrites `queueServerId` and every ref `serverId` on
rehydrate, so the live store never holds mixed shapes
- `removeServer` compares against the resolved id so a profile delete still
clears the matching queue binding
- the two `playbackServer.test.ts` asserts that hard-coded the UUID shape
are updated to the canonical key (existing reader-tolerance is unchanged)
* fix(queue-undo): bind snapshot prepend to snapshot-canonical server identity
When `applyQueueHistorySnapshot` has to prepend the still-playing track
(the snapshot's queue does not contain it), the new ref must follow the
snapshot's playback server, not the live `queueServerId`. A server switch
racing the undo would otherwise stamp the prepended ref with the new
server, mis-resolving the playing track on the very next render.
- `QueueUndoSnapshot` now carries `queueServerId` (captured by
`queueUndoSnapshotFromState`); older in-memory entries fall back through
the snapshot's own refs and finally the live store value
- the prepend in `applyQueueHistorySnapshot` plus the post-restore
`seedQueueResolver` both source the server identity from this snapshot
context, run through `canonicalQueueServerKey` so cache bucket and ref
shape stay in lockstep
* test(queue): regression cluster for mixed-server queues with duplicate trackId
Covers the four invariants the thin-state review called out:
- resolver correctness: same `trackId` on two servers maps to two distinct
cache entries via canonical keys, and legacy UUID-shaped refs still read
the same entries through the compat lookup path
- restore/hydrate: persist `merge` forward-migrates UUID-form blobs in
three shapes (canonical `queueItems`, legacy `queueRefs`, mixed-server
`queueItems`) to canonical keys
- undo snapshot application: prepended ref follows the snapshot's playback
server even when the live queue has been rebound to a different one,
with fallback to snapshot refs and live state for legacy entries
- queue sync id emission: `flushPlayQueuePosition` -> `savePlayQueue`
passes plain track ids and the playback server out of band, no per-ref
`serverId` ever leaks into the request body
Also asserts the write helpers (`toQueueItemRefs`,
`bindQueueServerForPlayback`) emit canonical keys directly.
* perf(queue-header): coalesce resolver burst updates and aggregate in one pass
`QueueHeader` recomputed total and remaining queue durations on every
resolver cache version bump via two separate full-queue reduces. A mass
resolve burst (queue restore, prefetch window slide) bumps the version
dozens of times in one frame, and very long queues turned that into
visible main-thread stutter.
- one pass: a single for-loop produces both total and future-tracks
duration; a 50k-track queue costs one walk per recompute, not two
- `useDeferredValue(version)` coalesces the burst into a single
low-priority commit so the cache version is only sampled once per
React frame instead of once per cache write
* fix(queue): use stable artist seed for radio top-up
The proactive radio top-up in `runNext` seeded `getSimilarSongs2` and
`getTopSongs` from `resolveQueueTrack(nextRef)` metadata. When the next
ref is still cold in the resolver cache, the placeholder track has empty
artist fields, and the top-up would fire `getSimilarSongs2('')` -- silently
returning nothing and leaving the queue dry just before the radio rail
would have refilled.
- prefer the just-played `currentTrack` (always fully resolved in the
player store) and the stored radio seed artist id
- fall back to the next-track metadata only when those are missing
- skip the top-up entirely when no stable seed is available, instead of
emitting a non-deterministic empty request
* docs(changelog): queue mixed-server routing and quota-safe persist (#872)
* feat(library): local lossless index, filters, and conserve dedicated page
Add SQLite-backed lossless album browse and advanced-search filtering,
wire All Albums and artist/album lossless drill-down mode, and hide the
standalone /lossless-albums nav entry from sidebar visibility settings
(conserved route, default off).
* docs(release): note lossless local index in CHANGELOG and credits (PR #871)
* chore(cover): scaffold cover module and rust cover_cache stub
Wave 0: src/cover/ skeleton per contracts.md §12, stub IPC commands
in cover_cache/mod.rs (no-op returns until phase B).
* feat(cover): add unified cover module and tier resolver (phase A)
Wave 1A: tiers, storage keys, resolveJs with cold/sibling races,
useCoverArt, CoverArtImage, layoutSizes, playback scope helpers,
coverSiblings tier ladder, deprecated shims on subsonicStreamUrl.
* feat(cover): rust disk cache and tier-ready events (phase B)
Wave 1B: cover_cache module with WebP tier encode, HTTP canonical 800 fetch,
cover_cache_* commands, cover:tier-ready / cover:evicted events, disk layout tests.
* feat(cover): prefetch hook, tier-ready handoff, library backfill IPC (phase B/C)
Wave 2: useCoverArtPrefetch, cover:tier-ready/evicted bridge, one-time IDB
cover key clear, prefetch registry drain, MainApp wiring.
* feat(cover): migrate dense grids to CoverArtImage and prefetch (phase D)
Wave 3A: dense surfaces use layout-native displayCssPx, surface=dense,
coverPrefetchRegister on Home/Albums/search; AlbumCard cell width from grid.
* feat(cover): migrate sparse surfaces and integrations (phase E sparse)
Wave 3B: sparse CoverArtImage/useCoverArt, lightbox tier 2000, ArtistHeroCover,
MPRIS/Discord/export integrations, playback chrome and detail heroes.
* feat(cover): revalidation scheduler and disk pressure gate (phase E+)
Wave 4: coverCacheMaxMb settings (en/ru), StorageTab disk usage, cover_cache_configure,
useCoverRevalidateScheduler, playbackServer uses cover fetchUrl; pressure watermarks.
* docs: CHANGELOG and credits for cover art pipeline PR #869
* fix(cover): stop webview getCoverArt storm on dense grids (429)
Dense surfaces no longer put rotating getCoverArt URLs in img src; load
disk via Rust ensure + convertFileSrc. Tier-ready notifies listeners instead
of invalidating IDB. Throttle background prefetch and cap Home registry.
* fix(cover): omit empty img src until cover URL is ready
React 19 warns on src=""; CoverArtImage uses undefined until disk/IDB
resolves; queue current track shows placeholder when src is still empty.
* fix(cover): disk cache by host index key, parallel ensure, asset protocol
Bind cover storage to serverIndexKey (library host), rename cover IPC/events,
fix REST base URL and Tauri flat args, enable protocol-asset for disk paths,
add prioritized ensure queue, and wipe legacy profile-UUID cache once.
Limit Vite dep scan to index.html so research/target HTML is ignored.
* fix(cover): WebP tiers, disk peek, home cache, asset URLs for mainstage
Encode lossy WebP (~82), write only missing tiers, library cover backfill,
and cover_cache_peek_batch for fast paint from disk. diskSrcCache + CSP
asset protocol; no IDB fallback when server is up. Session Home feed cache
with warm peek on return; BecauseYouLike deduped cover hook and high prefetch.
* feat(cover): per-server cache strategy and native library backfill
Move cover disk cache settings to Offline & cache with Lazy/Aggressive
per server, per-server clear, and no size cap. Run full-catalog backfill
on the Rust runtime (sync-idle wake, bounded HTTP, bulk 800px writes
without flooding the webview). Drop global prefetch limits from auth store
and waveform clear from the offline storage block.
* fix(build): CSP connect-src for Subsonic API; quieter prod nix build
Prod webview blocked axios ping after cover CSP (missing connect-src).
Drop cargo tauri -v in flake build, raise Vite chunk limit, ignore tsbuildinfo.
* fix(cover): complete WebP ladder in library bulk backfill
Aggressive backfill now writes all derived tiers (128–800), skips IDs
only when the full ladder exists (not 800 alone), avoids fetch-failed
markers on bulk HTTP errors, and stops the pass when the active server
changes.
* fix(cover,home): navigation-priority backfill and Because You Like UX
Pause library cover backfill while navigating; split peek/ensure traffic
so grids and rails win over bulk work. Disk src lookup, grid warm hooks,
and non-blocking mainstage prime for faster visible covers.
Because You Like: session snapshot, staggered horizontal skeleton row,
text hidden until cover is ready, and layout aligned with loaded cards.
* feat(random-albums,library): local-first album fetch + cover art pipeline
Random Albums теперь запрашивает локальный SQLite-индекс (ORDER BY RANDOM()
LIMIT N) вместо сетевого запроса к серверу. При готовом индексе спиннер
исчезает практически мгновенно; сеть используется только как фолбэк.
- advanced_search.rs: добавляет `("random", _) => RANDOM()` в allowlist сортировок
- browseTextSearch.ts: runLocalRandomAlbums — SQLite-рандом для Albums
- RandomAlbums.tsx: doFetchRandomAlbums local-first для обоих путей (без жанра
и с жанром через runLocalAlbumsByGenres + JS-shuffle); speculative reserve
прогревает следующий батч в фоне после каждого Refresh
Также: обновление пайплайна обложек (coverTraffic, peekQueue, ensureQueue,
diskSrcLookup, warmDiskPeek, prefetchRegistry, useCoverArt, useWarmGridCovers,
useCoverNavigationPriority, resolveIntersectionScrollRoot и сопутствующие
компоненты/хуки).
* fix(random-albums): prevent double-load on Zustand rehydration
useEffect([selectedGenres, load]) fired twice on every visit: first with
default store values, then again ~50 ms later when Zustand rehydrated
mixMinRatingFilterEnabled/minAlbum/minArtist from localStorage.
Previously this was invisible because the first network fetch took ~1.5 s,
so loadingRef.current was still true on the second fire. With the new
local-first SQLite path the first load completes in ~50 ms, leaving the
guard cleared before rehydration triggers a second random batch.
Fix: ref-pattern — keep loadRef.current fresh on every render, effect
depends only on selectedGenres. Manual Refresh and genre-filter changes
still call the latest closure correctly.
* fix(random-albums): stop warmCoverDiskSrcBatch in fillReserve from causing visual flash
fillReserve вызывал warmCoverDiskSrcBatch для обложек резервного батча, что
вызывало bumpDiskSrcCache() для каждой новой обложки (~30+ вызовов). Это будило
всех подписчиков useCoverArt на текущей странице, провоцируя видимую перерисовку
примерно через ~1.5 с после загрузки (когда filterAlbumsByMixRatings делает
сетевые запросы к рейтингам артистов).
- fillReserve: убран warmCoverDiskSrcBatch — обложки прогреваются лениво при
consume резерва через primeAlbumCoversForDisplay
- reserve-путь в load(): добавлен primeAlbumCoversForDisplay перед setAlbums
(аналогично non-reserve пути; при уже прогретом кэше — мгновенно)
* feat(because-you-like): reserve-first pattern — instant display on return visits
Каждый визит на Mainstage после первого теперь отдаёт готовую заготовку
мгновенно, вместо spinner → сетевые запросы → контент.
Архитектура:
- resolvePicks / fetchBecauseYouLike вынесены на уровень модуля (выход из
замыкания useEffect); читают текущий localStorage, возвращают
{ anchor, recs, nextAnchorHistory, nextPicksHistory }
- fillBecauseReserve — fire-and-forget фоновая функция: запускается сразу
после отображения результата, кладёт следующий батч в _becauseReserve.
Covers намеренно не прогреваются (bumpDiskSrcCache на текущей странице не
нужен); они прогреваются через primeAlbumCoversForDisplay при consume.
- useLayoutEffect: если reserve готов — не сбрасывает стейт в skeleton
(контент появляется без мигания)
- useEffect: reserve-first path — consume → primeCovers → setState → fill;
full-fetch path сохранён как fallback при первом визите или промахе
Поведение:
- Визит 1: full fetch (как раньше) → показ → fillReserve R1
- Визит 2+: consume R1 → мгновенный показ → fillReserve R2
- При сетевом сбое: restore из session cache (как раньше)
* fix(because-you-like): initialise state from reserve — no skeleton flash on remount
При ремаунте компонент стартовал с refreshing=true/anchor=null/recs=[] и
показывал skeleton на один тик до того как useEffect отработает.
Теперь useState() использует lazy initializers, которые читают _becauseReserve
прямо в первом рендере: если reserve валиден — state сразу refreshing=false,
anchor=X, recs=[...] и skeleton не показывается вообще. Covers уже в diskSrcCache
(из предыдущего показа) и появляются без дополнительных запросов.
useLayoutEffect упрощён: вызывает hasValidReserve() и сбрасывает в skeleton
только если reserve отсутствует (для случая navigation без ремаунта).
* fix(because-you-like): apply reserve in useLayoutEffect to handle async pool arrival
Lazy initializers не могли применить reserve при первом рендере, потому что
mostPlayed/recentlyPlayed/starred приходят из Home.tsx асинхронно — pool=[]
на первом рендере, poolKey не совпадает с reserve.
useLayoutEffect теперь активно ставит стейт из reserve (а не просто не сбрасывает):
когда pool обновляется до реальных данных, useLayoutEffect срабатывает синхронно
до paint, проверяет reserve и сразу применяет anchor/recs/refreshing=false.
При отсутствии reserve — сбрасывает в skeleton как прежде.
* fix(because-you-like): reserve > cache > skeleton — eliminate skeleton flash on mount
Корневая причина: Home.tsx загружает mostPlayed асинхронно через useEffect,
поэтому на первом рендере pool=[], poolKey=''. Reserve хранится с реальным
poolKey → mismatch → lazy initializers запускали skeleton.
Теперь двухуровневый fallback без зависимости от poolKey:
1. reserve (serverId + poolKey совпадают) → мгновенный новый батч
2. becauseYouLikeCache (только serverId) → stale-while-revalidate, контент
доступен сразу с mount, обновляется тихо в фоне
3. skeleton → только при полном отсутствии данных (первый визит)
Применяется одинаково в lazy useState initializers, useLayoutEffect и
full-fetch path useEffect (не сбрасывать в skeleton пока есть cached контент).
* fix(because-you-like): key reserve by serverId only; guard useEffect on empty pool
Проблема: reserve хранился с poolKey, но на первом рендере pool=[] → poolKey=''
→ mismatch → показывался кэш (предыдущий набор) ~500ms пока Home.tsx не загружал
mostPlayed.
Исправления:
- BecauseReserve: убран poolKey — reserve валиден для любого pool-состояния
на том же сервере. Pool (топ-артисты) меняется медленно; один раз показать
reserve с чуть устаревшим anchor лучше чем показывать предыдущий набор 500ms
- hasValidReserve: проверяет только serverId
- fillBecauseReserve: убран poolKey из сигнатуры и хранилища
- useEffect: guard pool.length === 0 → возврат без fetch/consume;
effect перезапустится когда pool заполнится (реальные deps изменятся)
→ reserve применяется из useLayoutEffect ещё до pool, без стале-флэша
Итоговый порядок: reserve (instant, serverId) > cache (stale-while-revalidate)
> skeleton (только первый визит)
* fix(home): remove mix-rating deps from feed useEffect — prevent Zustand rehydration double-fetch
Корень: useAuthStore(mixMinRatingFilterEnabled/Album/Artist) были в deps
useEffect. Zustand persist реhydrates асинхронно — сначала activeServerId,
потом mix-rating значения. Это вызывало двойной запуск эффекта:
- Первый запуск: homeFeedCache hit → показывает набор предыдущего просмотра
- Второй запуск (после rehydration): cache miss или повторный fetch с
реальными mix-настройками → ~500ms → новый набор
Итог: Hero, AlbumRow, BecauseYouLikeRail показывали предыдущий набор
первые ~500ms при каждом возврате на Mainstage.
Fix: убраны mixMinRatingFilterEnabled/Album/Artist из deps. getMixMinRatingsConfigFromAuth()
читается внутри эффекта через getState() — всегда актуальные значения без
пересоздания замыкания. Mix-настройки по-прежнему применяются при fetch,
но не вызывают двойной запуск при rehydration.
* feat(home): local-first discover songs via SQLite ORDER BY RANDOM()
Добавлена runLocalRandomSongs (аналог runLocalRandomAlbums для треков)
в browseTextSearch.ts — использует libraryAdvancedSearch с sort random,
field уже поддерживается Rust-кодом через wildcarded ("random", _) ветку.
В Home.tsx: discoverSongs теперь сначала пробует локальный индекс,
и только при недоступности (индекс не готов, ошибка) падает обратно
на getRandomSongs.view. Ускоряет первую загрузку Mainstage — треки
берутся из SSD вместо сети.
* fix(home): pre-populate state from cache at mount — eliminate empty-state flash on return visits
Причина: Home.tsx размонтируется при навигации. При возврате первый рендер
всегда с пустыми массивами (heroAlbums=[], mostPlayed=[] и т.д.), потом
useEffect читает homeFeedCache и заполняет state. Даже один кадр с пустым
состоянием вызывает перерисовку Hero и BecauseYouLikeRail (pool=[]).
Решение: getInitialHomeFeed() читает homeFeedCache синхронно через
useAuthStore.getState() (не hook) в lazy useState initializers. К моменту
повторного визита store уже rehydrated — все state получают кэшированные
данные до первого рендера.
Дополнительно: wasPrePopulated предотвращает повторный applyFeedSnapshot
в useEffect когда state уже заполнен — иначе новые ссылки на массивы
вызывали бы ненужные ре-рендеры дочерних компонентов с теми же данными.
* fix(mainstage): keep refresh without return flicker
Keep Home and Because You Like visually stable during a single visit while still refreshing data for the next re-enter. Improve mainstage cover warmup by ensuring and pre-decoding above-the-fold artwork so hero and top rails appear instantly after navigation.
* fix(mainstage): stabilize because rail and hero background framing
Measure Because You Like layout before first paint to avoid width snap flicker, and render hero background as centered cover-fit images so the frame no longer jumps from top to middle on mount.
* fix(now-playing): prewarm track data and prevent stale carry-over
Warm Now Playing fetch caches and playback cover art on track change so entering the page no longer waits on first-load requests. Gate key-based sections (top songs, tour, Last.fm) by the active track/artist keys to avoid briefly rendering values from the previous track.
* fix(cover,test): refresh playback scope and default tauri cover mocks
Recompute playback cover scope when queue/server context changes so now-playing art resolves against the correct server after handoffs. Add default cover-cache invoke handlers to the shared Tauri test harness to prevent unhandled rejections in suites that mount cover-aware UI.
* fix(cover,now-playing,test): align prewarm scopes and tighten tauri mocks
Make cover-cache invoke defaults opt-in for tests, align radio prewarm scope with active rendering scope, and add targeted hook tests for prewarm + playback-scope reactivity. Also harden Rust cover URL building to avoid panic on malformed base URLs.
* test(cover): hoist mocked useCoverArt and clean EOF whitespace
Fix the new playback-scope hook test to use a hoisted vi.mock-safe stub and keep branch-wide diff checks clean by removing an accidental trailing blank line.
* fix(cover): align playback ensure auth and harden backfill retry flow
Use playback-server credentials for playback-scoped cover ensures, persist fetch-failed markers for bulk library backfill failures, and avoid advancing backfill cursor when UI-priority hold interrupts a batch.
* fix(ci): resolve clippy lint and update frontend node runtime
Move fetch helper before the test module to satisfy clippy's items-after-test-module rule, and modernize frontend CI to setup-node v6 with lts/* instead of pinned Node 20.
* chore(settings): simplify cover and analytics strategy copy
Move strategy summaries below tables, simplify Lazy/Aggressive wording, keep analytics warning always visible, and localize Russian texts to plain language without technical jargon.
* fix(library): scoped live search FTS, race, and multi-server advanced search
Scope track_fts live search to active server_id so multi-server libraries
no longer show empty or wrong-server hits. Match Navidrome-style any-word
prefix matching and GROUP BY artist/album dedupe on track_fts only.
Frontend: parallel local vs search3 race (empty waits, 8s network timeout),
merge supplemental hits after both settle, debug via Settings → Logging →
Debug (frontend_debug_log). Advanced Search FTS subqueries use the same
server scope fix.
* docs: CHANGELOG and credits for PR #868 live search fix
* fix(analysis): persist failed-track suppression and reduce aggressive polling
Persist unsupported/broken analysis tracks as failed entries and expose them in Settings with track metadata, export, and targeted rescan actions. Also make aggressive-mode completion checks cheap by gating re-entry on live track-count changes with a startup seed and 5-minute recheck cadence.
* fix(analysis): mark unsupported decode tracks as failed in cpu-seed
When full-seed falls back to waveform-only (no EBU loudness) or enrichment decode fails, persist analysis_track status as failed so aggressive backfill does not requeue the same unsupported tracks indefinitely.
* fix(analysis): reconcile legacy ready tracks without loudness
Auto-mark legacy ready tracks that only miss loudness as failed during needs-work checks so analysis progress converges instead of staying permanently pending.
* docs(changelog): add failed-analysis recovery notes for PR 867
Document persistent failed-track handling, analytics strategy controls, and low-cost aggressive-mode recheck behavior in the 1.47.0 changelog.
* fix(i18n): align settings locale coverage across all languages
Sync missing Settings keys for all shipped locales and replace recent English fallbacks with localized strings so analytics and backup UI text stays consistent outside en/ru.
* feat(dev): run dev alongside release with shared app data
Skip tauri-plugin-single-instance in debug builds so `tauri dev` can run
while an installed release instance is open. Keep the same bundle identifier
and data directory; label the dev window "Psysonic (Dev)".
* fix(dev): gate on_second_instance behind release cfg
Avoid dead_code warning in debug builds where single-instance is skipped.
* feat(dev): red sidebar brand and monochrome titlebar chrome
Tag the document in Vite dev and style the logo header with a red
background plus gray window controls so dev is obvious at a glance.
* feat(dev): skip OS hotkeys and add mobile DEV markers
Debug builds no longer register global shortcuts, MPRIS, or Windows
taskbar media controls so release keeps system input when both run.
Flip the tray icon horizontally in dev and show a fixed DEV badge on
narrow layouts.
* docs(changelog): note PR #866 parallel dev alongside release
* fix(dev): satisfy clippy needless_return in debug-only paths
* fix(now-playing): hide zero-valued track metadata badges
Use explicit > 0 checks instead of truthy && so missing numeric fields
(bitDepth, bitRate, samplingRate, year, rating) no longer render as "0".
* chore: note PR #865 in changelog and credits
* chore: drop PR #865 from settings credits (minor fix)
* fix(now-playing): null-safe numeric badge guards for tsc
Use (value ?? 0) > 0 so optional metadata fields satisfy strict TS
while still omitting zero-valued badges in the UI.
* feat(analysis): align index settings and per-server strategies
Rebuild the local index UX to live under Servers with per-server analytics
strategies, and scope analysis queue hints/pruning by playback server so
priorities stay isolated across profiles.
* feat(analysis): add progress tracking and server analysis deletion functionality
Introduce new interfaces for tracking library analysis progress and reporting on server analysis deletions. Implement functions to retrieve analysis progress for a server and to delete all analysis data for a specified server, enhancing the analytics strategy section with real-time progress updates and management capabilities. Update relevant components and localization files to support these features.
* feat(server): implement server index key migration and enhance server ID resolution
Add functionality to migrate server index keys from legacy IDs to new URL-based keys, improving server ID resolution across the application. Introduce new types and commands for handling server key migrations in both analysis and library contexts. Update relevant functions to utilize the new server ID resolution logic, ensuring consistency and accuracy in server-related operations.
* refactor(library): simplify server ID handling in sync progress and idle subscriptions
Refactor the library sync progress and idle subscription functions to directly use the payload's server ID without additional mapping. Update related components to resolve server IDs using a new utility function, ensuring consistent server ID resolution across the application. This change enhances code clarity and maintains functionality.
* refactor(analytics): rename advanced strategy to aggressive and update descriptions
Refactor the AnalyticsStrategySection component to rename the 'advanced' strategy to 'aggressive' for clarity. Update related localization strings to reflect this change, enhancing the user experience by providing clearer descriptions of the analytics strategies. Additionally, remove unused strategy description functions to streamline the code.
* fix(audio): update server ID handling in audio progress functions
Refactor the audio progress handling to utilize the new `getPlaybackIndexKey` function for server ID resolution. This change ensures that the correct analysis server ID is used when processing audio progress, enhancing the accuracy of playback operations. Additionally, a minor update was made to the analysis cache to include a checkpoint after seeding from bytes. Update the library path in live search to reflect the new database structure.
* refactor(analysis): update server ID handling and drop legacy keys
Refactor server ID handling across analysis components to utilize scheme-less keys (host + optional path) instead of legacy scheme-based keys. Introduce SQL migrations to drop legacy analysis rows and library entries keyed by scheme URLs. Update relevant functions and tests to ensure consistent server ID resolution and remove references to the legacy '' scope, enhancing clarity and maintainability.
* refactor(migration): switch to strategy C dual-db flow
Replace destructive server-key migration paths with a blocking inspect/run pipeline that imports into v2 sqlite files, verifies data, then switches active databases with backup safety. Add frontend migration orchestration and post-switch key rewrites while preserving existing user settings behavior.
* fix(migration): harden runtime db switch and startup gate
Switch database promotion through live runtime store/cache connection swaps so migration cannot leave writers on old sqlite inodes, and tighten startup gating to block initialization until migration completes. Also fix empty-bucket warning detection and set the done flag only after a post-run inspect confirms no pending legacy rows.
* feat(migration): enhance migration reporting with skipped server rows tracking
Add new fields to migration interfaces and reports to track skipped rows for removed servers. Update relevant components to display warnings and log messages when such rows are encountered during migration processes, improving visibility and user awareness of migration status.
* fix(migration): avoid startup blocking modal on no-op runs
Keep migration gate completed by default after successful runs and perform done-flag inspections without forcing a blocking phase, so normal app startup no longer flashes migration preparation when no migration is needed.
* fix(migration): enforce startup precheck and purge unknown rows
Prevent stale done-flag bypass by starting migration state in idle and gating completion on orchestrator precheck, and delete unknown removed-server rows from v2 databases before switch so skipped rows are not carried into the new active DB.
* fix(migration): block UI during done-flag precheck
Set inspecting phase before the first migration inspection and treat idle as blocking in the migration gate, so startup precheck cannot render the app before migration status is confirmed.
* fix(migration): hide precheck modal when no migration is needed
Keep startup precheck in a non-blocking idle phase and show the migration modal only after inspect confirms real migration work, removing the recurring half-second migration flash for already-migrated users.
* fix(migration): cleanup legacy db files after path migration
Always remove legacy analysis and library sqlite files (including wal/shm sidecars) when the new database paths are active, so old-path artifacts from previous builds do not linger after migration.
* docs(changelog): add PR #864 release notes and contributor credit
Document the full index-key rebuild scope for 1.47.0 and add the
corresponding settings credit entry for PR #864.
* test(analysis): raise hot-path coverage for analysis cache
Add focused unit tests for analysis cache compute/store hot paths and edge branches so coverage regressions are caught before CI. Make AppHandle entrypoints runtime-generic and enable tauri test utilities in dev dependencies to cover no-cache and registered-cache execute paths.
* fix(migration): make rebind pass resilient to foreign key ordering
Run library and analysis server_id rebind operations inside a foreign-key-disabled transaction and validate with PRAGMA foreign_key_check after commit, so migrations from older databases do not fail on transient FK ordering during bulk updates.
* feat(backup): add dual-database backup flow and blocking UX
Extend backup/export and restore flows to handle library databases with unified archive detection and asynchronous backend execution. Improve backup UI with a global blocking modal and clearer localized copy so long operations do not look like app hangs.
* docs(changelog): add PR #864 backup notes and contributor credit
Update 1.47.0 release notes with backup/restore UX and archive-flow entries for PR #864, and add the matching settings credits contribution line for cucadmuh.
* docs(changelog): sort 1.47.0 entries from old to new
Reorder Added, Changed, and Fixed subsections in the 1.47.0 changelog so entries follow chronological PR order inside each block.
* fix(playback): align offline/hot cache lookup with indexKey scope
Use a canonical playback cache key based on indexKey with legacy UUID fallback so migrated offline and hot-cache entries are still resolved on normal play, resume, queue-undo, and prefetch paths. Refresh PR #864 changelog/credits text to reflect the full migration and backup scope.
* feat(enrichment): oximedia BPM/mood facts, mood search, and queue UI
Run client-side oximedia analysis after CPU seed and persist BPM, mood JSON,
and searchable mood_tag facts. Add product mood groups (joy/sadness/dance/work/
romance) with Advanced Search filter on the local index, queue BPM/mood display,
migration 008 mood_tag index, and refreshed licenses for oximedia crates.
* fix(enrichment): keep mood_groups module comment in English
* feat(search): virtual mood groups, anger filter, and Advanced Search UX
Expand mood search via overlapping virtual groups (tag expansion only),
add anger/Злость group, skip album/artist shortcuts for track-only filters,
and simplify mood search UI (songs-only, hide type tabs). Fix CustomSelect
spurious scrollbar on short option lists.
* feat(analysis): unified track analysis plan and enqueue path
Add TrackAnalysisPlan (waveform, LUFS, enrichment) with a single
enqueue_track_analysis entry for all byte-backed triggers. Run enrichment
when cache is full but library facts are missing; route playback, cache,
and backfill through the planner. Fix browseTextSearch LocalSearchOpts tsc
gap and remove obsolete read_seed_bytes_if_needed helper.
* fix(analysis): wire playback dispatch, preload enrichment, and UI refresh
Route stream, gapless, preload, and local-file playback through analysis_dispatch
so BPM/mood enrichment runs when waveform/LUFS are already cached. Fix audio_preload
cache-hit and hot-cache paths, emit preload-cancelled for retry, and add
analysis:enrichment-updated plus content_cache_coverage key resolution.
* fix(audio): preload local files from disk and stop analysis retry loop
Seed hot/offline next tracks via LocalFilePlayback (512 MiB) instead of copying
into the RAM preload slot. Keep bytePreloadingId set after preload-ready so
progress ticks do not re-invoke audio_preload every second.
* fix(enrichment): clippy, album bpm filter routing, and queue mood display
Clippy-clean analysis_dispatch and engine imports; restrict track-derived album
routing to mood_group/mood_tag only so bpm is skipped on album queries. Log
enrichment plan errors with retry-all plan; filter queue mood labels to oximedia ids.
* fix(enrichment): simplify mood_tag backfill branch in plan_track_enrichment
Remove empty if-block; keep same behaviour when backfill fails and moods row exists.
* docs: CHANGELOG and credits for track enrichment PR #863
* docs(credits): track enrichment PR #863 contributor line
* chore(enrichment): clippy-clean plan branch and trim dead exports
Collapse mood_tag backfill if for clippy; remove unused moodGroupById and
OXIMEDIA_MOOD_LABELS re-exports; stop poll when server BPM is already known.
* fix(enrichment): close R2/S1–S3 limits and Song Info BPM fallback
Return TrackEnrichmentOutcome::Failed on oximedia errors so retries are not
masked as complete; extract mood Advanced Search SQL, unify top-3 mood tag
selection in mood_groups with TS invariant tests, and show measured BPM in
Song Info when tag BPM is missing or zero.
* fix(enrichment): restore offline coverage and show mood in Song Info
Add unit tests for offline download cancel/clear registry after the analysis
seed refactor dropped read_seed_bytes coverage; show localized mood labels in
Song Info when library enrichment facts exist.
* fix(enrichment): soft mood scoring and unblock offline cancel tests
Replace oximedia quadrant happy/excited mapping with valence/arousal
soft scores across all mood tags for display, storage, and backfill;
fix offline cancel unit tests that deadlocked by calling clear while
holding the global offline_cancel_flags mutex.
* fix(enrichment): dedupe joy cluster and cap mood display at two labels
Never show happy and excited together; pick one tag per V/A cluster,
tighten oximedia recalibration, and limit queue/Song Info to two moods
that pass a relative score floor.
* fix(enrichment): disable oximedia mood labels in UI and search tags
Oximedia 0.1.7 mood is a spectral energy heuristic, not independent mood
weights; valence correlates with loud/bright audio and false-labels metal
and lyrical tracks as happy. Hide queue/Song Info mood and stop writing
mood_tag facts until a reliable detector lands; keep V/A/moods JSON stored.
* fix(enrichment): disable oximedia mood analysis and add BPM advanced search
Stop planning, running, and storing oximedia mood facts; purge accumulated
mood rows via migration 009. Hide mood filters in Advanced Search, expose
BPM range filter with dual-storage resolution, and show a BPM column in song
results when that filter is active.
* feat(search): analysis BPM priority, source tooltip, and filter UX
Prefer analysis track_fact over file tags for BPM resolution; show source
in list tooltips. Validate BPM range on blur, add clear button, fix double tooltip.
* fix(enrichment): prefer analysis BPM in Song Info and queue tech row
Show measured track_fact BPM before file tags until analysis completes;
pick the highest-confidence analysis fact when several exist.
* fix(library): sweep orphan tracks after successful full resync
Full resync only upserted server tracks and left server-deleted rows
live in SQLite. Add IS-7 mark-and-sweep via track.resync_gen: stamp
rows on ingest during a re-sync, soft-delete unstamped rows when IS-6
completes. Delta tombstone reconcile is unchanged.
* docs(release): CHANGELOG and credits for PR #861 resync orphan sweep
* fix(library): satisfy clippy unnecessary_min_or_max in orphan sweep
execute() returns usize; drop redundant max(0) so CI clippy -D warnings passes.
* refactor(queue): add queue track resolver (thin-state phase 2a)
Standalone resolver: QueueItemRef → Track via index batch
(library_get_tracks_batch, ≤100/call) → network getSong fallback (P8), into a
bounded LRU cache. Holds raw tracks; session star/rating overrides (F4) merged
on read via applyQueueOverrides. Sync getCachedTrack + subscribeQueueResolver
for selectors; resolveVisibleRange prefetches a [-50, +200] window; carries
queue-only flags from refs; invalidate drops entries after a sync succeeds.
Not wired into the store/UI yet — phase 2b does that.
* refactor(queue): extract toQueueItemRefs helper
Pure helper deriving thin QueueItemRefs from a Track[] queue (per-item
serverId, queue-only flags), shared by the persist partialize and the
upcoming phase-2b resolver bridge. No behaviour change.
* refactor(queue): resolver bridge + queue selectors (thin-state phase 2b)
Seed the resolver cache from the canonical queue (queueResolverBridge, a
windowed [-50, +200] seed around the current index) and add the stable queue
selectors (useQueueTrackAt / useCurrentTrack / useQueueItems).
Additive: the store stays queue: Track[]-canonical; consumers migrate onto the
selectors in phase 3, and the selector impls move to the resolver once
queue: Track[] is dropped in phase 4. No mutation or persist change — the
persisted queueItems keeps its single restore role (no dual-role clash).
* docs(changelog): on-demand queue track loading groundwork (#859)
* refactor(queue): persist thin QueueItemRef list (thin-state phase 1)
Introduce QueueItemRef ({ serverId, trackId, autoAdded?, radioAdded?,
playNextAdded? }) and persist the whole queue as a thin queueItems list,
superseding the queueRefs id list. Dual-write: the windowed queue: Track[]
stays as the index-off fallback; the in-memory store is unchanged.
hydrateQueueFromIndex now prefers queueItems (per-item serverId) and carries
the queue-only flags onto the hydrated tracks (the index doesn't store them),
with a queueRefs fallback for stores persisted before this change.
Phase 1 of the queue thin-state plan; no store/consumer changes yet.
* test(queue): cover legacy queueRefs-only upgrade in hydrate
* docs(changelog): queue section dividers kept on index restore (#858)
* perf(queue): virtualize the queue list
Render the QueuePanel queue through @tanstack/react-virtual so the DOM stays
O(visible rows) instead of O(queue length) — a 10k+ Artist Radio queue no
longer creates tens of thousands of DOM nodes. Row logic (reorder DnD, radio/
auto dividers, lucky-mix loader, context menu) is unchanged, just wrapped in
the virtual rows. Auto-scroll to the next track moves out of useQueueAutoScroll
(which relied on every row being in the DOM) to a virtualizer scroll plus an
exact scrollIntoView on the real target row.
Phase 0 of the queue thin-state plan; no store/persist changes.
* docs(changelog): virtualized queue list (#857)
Add the new lyrics keys (youLyPlus toggle, source fallback/primary hints,
queue 'no sources' hint) to fr/es/nb/nl/ro/ru/zh and drop the now-dead
lyricsMode* keys, completing the i18n for #855 (was on en fallback).
* feat(lyrics): independent YouLyPlus toggle + all-sources-off state
Replace the binary lyricsMode ('standard' | 'lyricsplus') with an
independent youLyPlusEnabled flag so YouLyPlus and the standard sources
are no longer mutually exclusive — turning one off no longer forces the
other on. YouLyPlus (when on) is tried first with the enabled sources as
fallback; off uses only the enabled sources. When YouLyPlus is off and no
source is enabled, useLyrics fetches nothing (issue #810).
Fresh installs ship with every source off; the rehydrate migration only
restores the old on-by-default set for genuine upgrades, not new installs.
* feat(lyrics): YouLyPlus toggle UI + queue 'no sources' hint
Settings: single YouLyPlus toggle replacing the two mutually exclusive
mode switches; the source list is always visible with a context hint
(fallback vs primary). Queue lyric tab shows a hint when no source is
active. en + de strings; other locales fall back to en.
* docs(changelog): lyrics fully disablable (#855)
* fix(library): browse-all-tracks shares the Search song-list view (#841)
"Browse all tracks" rendered virtualized, transform-positioned rows inside
its own scroll box, so the sticky column header got painted over while
scrolling. Extract the Search / Advanced-Search song-list chrome (sticky
header + plain SongRows + IntersectionObserver sentinel paging) into a
shared PagedSongList and route all three through it; Browse now flows in the
page like the Search pages, so the header can no longer be overlapped.
Trade-off: Browse-all drops its bespoke row virtualization to match the
Search pages (DOM grows with scroll as they do); paging is unchanged. Also
removes the duplicated sentinel/observer in SearchResults and AdvancedSearch.
# Conflicts:
# src/components/VirtualSongList.tsx
# src/pages/SearchResults.tsx
* docs(changelog): browse-all-tracks sticky header fix (#854)
* fix(playlist): columns dropdown no longer clipped on short lists (#839)
The column picker rendered inside `.tracklist` (overflow-x: auto, which
makes overflow-y compute to auto). On a 1-song playlist the downward popover
overflowed the short box → clipped behind suggestions, an extra scrollbar,
and the row vanishing when scrolling that inner bar (the virtualizer tracks
the main viewport, not the tracklist). Move the picker outside `.tracklist`
by reusing the shared TracklistColumnPicker (parametrized with allColumns);
fixes the same latent bug in the favorites tracklist and dedupes three
inline copies into one.
* docs(changelog): playlist/favorites column picker fix (#853)
* feat(playback): global speed with three strategies
Add Settings → Audio and player-bar controls for global playback speed
(speed with auto pitch correction as default, varispeed, manual pitch shift).
Time-stretch runs on a background worker; Orbit sessions force 1.0× passthrough.
* fix(playback): align seekbar, seek, and progress on content timeline
Unify UI timebase across varispeed and preserve strategies: full-track
duration, speed-scaled progress for DSP paths, and content-timeline seeks
without varispeed scaling. Reset the sample counter after seek so clicks
land correctly; restart playback on strategy/enable changes instead of
fragile hot-switching.
* fix(ui): anchor playback speed popover like volume controls
Replace the centered EQ-style modal with a player-bar popover (outside
click, Escape, reposition on scroll). Show compact controls in the bar
and overflow menu; keep strategy hints and labels in Settings only.
* docs(release): CHANGELOG and credits for playback speed (PR #852)
* docs(changelog): add playback speed entry for PR #852
* fix(clippy): simplify raw_counter_samples branch for CI
Collapse duplicate if branches flagged by clippy::if-same-then-else.
* fix(ui): wheel on pitch slider adjusts pitch in speed popover
In compact player-bar controls, scroll over the pitch row changes pitch;
elsewhere in the panel changes speed. Stop propagation so overflow menu
wheel does not tweak volume.
* fix(playback): address PR #852 review and drop ineffective dynamic imports
Translate playback-rate strings for de/fr/es/zh/nb/nl/ro; restamp sample
counter on live preserve-path speed changes; use neutral rate atomics for
radio progress; static-import playerStore in playListenSession (move preview
volume sync to previewPlayerVolumeSync side-effect module).
* fix(i18n): translate playback-rate strategy labels in all locales
Replace leftover English Varispeed/Pitch strings in ru and other non-en
settings blocks so popover strategy buttons and hints read natively.
* fix(i18n): refine German varispeed label to "Tonhöhe folgt dem Tempo"
---------
Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
* fix(statistics): keep player stats tab visible without local index
Show the tab always and explain that player statistics require the local
library index, with a link to library settings, instead of hiding the tab.
* docs(release): CHANGELOG and credits for player stats tab UX (PR #851)
* docs(credits): drop minor library index and player stats tab fixes
Per team policy, small UX fixes (PR #850, #851) stay in CHANGELOG only.
* feat(player-stats): local listening history tab with heatmap and summaries
Record play sessions in library.sqlite when the library index is enabled,
add Rust read APIs and Tauri commands for year/day aggregates, and ship the
Player stats UI with session clustering, event-driven live refresh, and a
notice when some servers are excluded from indexing.
* test(player-stats): split play_session repo and expand test coverage
Move the repository into play_session/ (completion, cluster, integration tests),
add remap/purge/FK coverage in Rust, and cover ingestion gates plus live-refresh
hooks on the frontend per spec v0.3.
* docs(release): CHANGELOG and credits for player stats (PR #849)
* fix(player-stats): satisfy tsc and clippy CI gates
Use InternetRadioStation field names in the radio skip test and replace
manual month/day range checks with RangeInclusive::contains.
* feat(library): race local index vs network on browse text search
Wire Artists, Composers, Tracks, and SearchResults to parallel local FTS
and network search3 with graceful fallback when remote fails while the
index is ready.
* feat(library): local browse for albums/artists and dev race logging
Serve All Albums and Artists catalog from the local index when ready,
with network fallback. Log browse text-search race outcomes to DevTools
(`[psysonic][library] browse-race …`) including winner, timings, and hits.
* docs(changelog): note PR #847 browse local index race and catalog paths
* refactor(library): unify DevTools search log format
Live Search, Advanced Search, and browse races emit one-line
`search [surface] …` entries via formatLibrarySearchLine (DEV only).
* fix(playlist): sorting a column no longer snaps the viewport (#840)
Sorting flipped `isFiltered` (which means displayedSongs !== songs, so it
also goes true once a sort is active), and the scroll-to-list effect fired
on `[id, isFiltered]` → the viewport snapped down to the list. Drive that
effect from a dedicated `hasActiveFilter` (filter text only), so sorting
applies in place; filter and playlist-switch scrolling are unchanged.
* docs(changelog): playlist sort viewport fix (#848)
* feat(library): scaffold psysonic-library crate with v1 schema and store (#791)
Adds a new workspace crate that will host the unified track store and the
upcoming sync engine. PR-1a covers spec phases A1–A6:
- migrations/001_initial.sql: full v1 schema — sync_state, track, album,
artist, track_fts (+ ai/ad/au triggers), track_extension, track_offline,
track_id_history, track_fact, track_artifact, canonical_track,
canonical_identity, track_canonical_link, canonical_enrichment_link, and
all §5.2 partial indexes.
- store::LibraryStore: WAL + foreign_keys=ON SQLite connection rooted at
app_data_dir/library.sqlite (distinct from the analysis cache, which
uses app_config_dir). schema_migrations table + idempotent embedded
migration runner; LIBRARY_DB_SCHEMA_VERSION = 1.
- repos::TrackRepository::upsert_batch: 35-column transactional upsert
with ON CONFLICT(server_id, id) all-fields rewrite; FTS rows follow via
the triggers.
- search::search_tracks: minimal bm25-ordered FTS5 helper scoped to a
single server_id, filtering deleted rows.
- filter::FilterFieldRegistry: static v1 registry (text, genre, year,
starred = V1; bpm = SchemaV1UiLater; user_rating/suffix/bit_rate =
Planned). Entity routing is a silent skip per §5.13.3.
No Tauri commands, no frontend, no sync — those land in PR-2..PR-7.
PR-1b will follow with the migration-runner edge-case tests, the
initial_sync_cursor_json read/write API, and the breaking-migration hook
stub (P22).
* feat(library): A7 migration-runner safety net + initial-sync cursor API (PR-1b) (#792)
* feat(library): wire migration-runner safety net and initial-sync cursor API
PR-1b — Phase A7 infrastructure on top of PR-1a. Production behaviour
is unchanged at v1 launch; everything here is plumbing that PR-3 will
consume.
- store::run_migrations_with: testable entry point that takes an explicit
migration slice, a min-compatible-version threshold, and a breaking-bump
hook. The prod `run_migrations` fixes those to MIGRATIONS,
LIBRARY_DB_MIN_COMPATIBLE_VERSION, and the no-op stub. The slice is now
sorted defensively before applying.
- store::LIBRARY_DB_MIN_COMPATIBLE_VERSION: new public constant (currently
equal to LIBRARY_DB_SCHEMA_VERSION). When a future release needs to
invalidate v1 data, bumping this above the max applied version trips
the hook on next open per spec §5.7 / P22.
- store::MigrationOutcome (Applied | BreakingBump): crate-internal signal
callers can branch on. PR-1b consumers ignore it; PR-3 / Settings will
surface the "library rebuilt after update" toast when it surfaces.
- store::handle_breaking_schema_bump: documented no-op stub. The drop +
resync logic lands with the first real breaking bump.
- repos::SyncStateRepository: ensure(server_id, scope) idempotently
inserts a default row; get_initial_sync_cursor / set_initial_sync_cursor
read and write sync_state.initial_sync_cursor_json via
serde_json::Value. The set uses ON CONFLICT … DO UPDATE scoped to the
cursor column only, so phase / poll-stats / tier survive cursor writes
intact.
- Tests cover: additive 002-style migration preserves prior data
(spec §5.7 explicit integration test), runner sorts an unsorted source
slice, breaking-bump hook fires when max applied < min_compatible,
hook does not fire on a fresh DB, cursor round-trips a nested
serde_json::Value, ON CONFLICT preserves sibling columns, library_scope
separates rows per server.
End-to-end "kill mid-500k-sync → resume same cursor" stays out of scope
per the kickoff answer — it belongs to PR-3 / C2 where the
InitialSyncRunner lives.
* test(library): cover AC A3 — 500-row upsert_batch under perf budget
* feat(library): Subsonic REST client for the sync engine (Phase B, PR-2) (#793)
Phase B (B1-B9 per spec §10) — pure-Rust Subsonic client that the
library-sync engine (PR-3) will drive. No Tauri commands, no events;
the surface is added internally to psysonic-integration as a sibling
of the existing navidrome native-REST module.
- B1 — SubsonicClient + ping over /rest/{method}.view. Auth via the
legacy salted-md5 token (spec v1.13+, advertised as 1.16.1). New
SubsonicCredentials helper computes token = md5(password || salt)
and ships a per-process unique salt nonce so back-to-back calls
don't repeat.
- B2 — get_scan_status → ScanStatus { scanning, count, folder_count,
last_scan }. Lightweight poll for the Huge-tier path (§6.2.2).
- B3 — get_album_list2(type, size, offset, musicFolderId?) +
get_album(id). The two-call pattern the sync engine walks during
initial ingest (§6.3).
- B4 — search3(query, songCount, songOffset, musicFolderId?). Empty
query → all songs paged (Navidrome quirk, spec §2.4).
- B5 — get_indexes(musicFolderId?, ifModifiedSince?). Conditional
fetch for file-tree fallback (S3 / §3.1).
- B6 — get_song(id). Error code 70 maps to the dedicated
SubsonicError::NotFound variant so the tombstone reconciler can
match on the variant instead of parsing strings.
- B8 — get_artists(musicFolderId?). ID3-path artist index; clients
compare ArtistIndex.last_modified_ms against the local watermark
to decide if a delta pass is needed (§2.2.1).
- B9 — fingerprint_sample helper picks every-Nth track id for the
server-fingerprint verify pass. Sampling is deterministic so
reruns probe the same tracks. The verify-and-compare glue itself
is library-side (PR-3 territory, deps on the store).
Tests cover envelope parsing (status=ok/failed, code 70 → NotFound,
missing body key), credentials (md5 vectors, salt uniqueness across
1k rapid calls, salt differs per from_password call), each endpoint
end-to-end through wiremock with query-param matchers, OpenSubsonic
forward-compat (unknown fields ignored on Song), and the trailing-slash
base-URL normalisation.
Cargo.toml — adds query + form + multipart to psysonic-integration's
reqwest feature set. PR-2's client needs `query`; the other two were
already used by existing navidrome::covers / remote::lastfm code and
only worked via top-crate feature unification. Aligning the crate's
own deps means `cargo test -p psysonic-integration` now compiles
without depending on the workspace build.
Out of scope: capability detection (C1 / PR-3), Navidrome native
bulk path (uses existing psysonic-integration::navidrome::queries),
fixtures harness expansion (G1).
* feat(library): subsonic client follow-ups from PR-2 review (PR-2b) (#794)
Picks up the three non-blocking items from cucadmuh's PR-793 review
(handoffs/2026-05-19-pr-793-review.md) before PR-3 starts on top.
- Fresh `(token, salt)` per request. `SubsonicClient` now caches the
plaintext username + password and derives a new `SubsonicCredentials`
inside `send()` for every endpoint call — matches the frontend's
`subsonicClient.ts` `getAuthParams()` lifecycle and follows Subsonic
replay-resistance guidance. Test path keeps a `with_static_credentials`
constructor so wiremock matchers stay deterministic. New
`build_credentials` (`pub(crate)`) routes the two modes.
- `SUBSONIC_CLIENT_ID` now carries the crate version
(`psysonic/<CARGO_PKG_VERSION>`) — aligns with the frontend's
`psysonic/${version}` so Navidrome log lines correlate across the
WebView and Rust sync paths.
- `Song.mbid_recording` gains the `musicBrainzId` serde alias (plus the
schema-column spelling) so the OpenSubsonic field lands on the same
hot column the §5.1 schema names. P13 strong-key matching can now key
off it on ingest.
- `get_song_with_raw` / `get_album_with_raw` return both the typed
projection and the raw `serde_json::Value` body sub-tree. PR-3 ingest
will write that raw value verbatim into `track.raw_json`, so
OpenSubsonic extensions (`contributors`, `replayGain`, future fields)
survive without manual field mirroring. Internal `parse_envelope_body`
extracts the validation + body-key lookup once; `parse_envelope` and
the new `parse_envelope_with_raw` share it.
Tests cover: `from_password` produces unique salt/token across two
back-to-back calls (direct + over-the-wire via wiremock
`received_requests`), static mode returns the same triple,
`c` query param starts with `psysonic/` and equals `SUBSONIC_CLIENT_ID`,
`get_song_with_raw` preserves untyped fields (`replayGain`,
`contributors`) in the raw value, `get_album_with_raw` keeps per-track
extensions in `raw.song[i]`, error 70 still maps to `NotFound` on the
raw variant, and `Song` deserializes `musicBrainzId` and
`mbid_recording` interchangeably.
B9 fingerprint-verify glue and the wider raw-ingest call sites stay
with PR-3 / C2 as the review's §5 / §7 checklist directs.
* feat(library): capability probe + sync_state accessors (Phase C1+C7, PR-3a) (#795)
First sub-PR of Phase C (sync orchestrator). Lands the foundation that
PR-3b's InitialSyncRunner consumes — pure plumbing, no runners or
background tasks yet.
- C1 capability probe. `psysonic_library::sync::CapabilityProbe::run`
drives the §6.1 probe chain: Subsonic ping (captures `ServerInfo`
envelope metadata for server-type / OpenSubsonic detection), then
best-effort probes for search3 / getScanStatus / getIndexes, plus an
optional Navidrome native bulk probe (caller passes
`NavidromeProbeCredentials`). `CapabilityFlags(u32)` matches the
§6.1.1 bitfield: NavidromeNativeBulk / SubsonicSearch3Bulk /
ScanStatusAvailable / OpenSubsonic / UnstableTrackIds / FileTreeBrowse.
- C7 sync_state accessors. `SyncStateRepository` gains get/set
capability_flags, get/set sync_phase (idle / probing / initial_sync
/ ready / error), and column-scoped setters for server_last_scan_iso,
indexes_last_modified_ms, artists_last_modified_ms, library_tier.
Every setter uses `ON CONFLICT … DO UPDATE` scoped to its own column
so concurrent watermark writes don't clobber each other.
- Supporting additions in `psysonic-integration`:
- `subsonic::SubsonicClient::server_info()` extracts `ServerInfo`
from the ping envelope (server_type, server_version, api_version,
open_subsonic). Re-uses `send()` so auth lifecycle is the same.
- `navidrome::probe::native_bulk_available(url, token)` does the
`GET /api/song?_start=0&_end=1` Bearer-auth probe. Returns
Ok(true) on 2xx, Ok(false) on 4xx (auth ok but endpoint missing),
Err on 5xx. Probe-only — full nd_list_songs port is PR-3b.
- `psysonic-library/Cargo.toml` gains a `psysonic-integration`
dependency (sync calls into Subsonic + Navidrome probes). DAG stays
acyclic: integration does not depend on library.
Per cucadmuh's PR-3 kickoff answer (handoff `2026-05-19-pr3-kickoff.md`):
- Crate placement: option A — sync lives in `psysonic-library/src/sync/`,
no new psysonic-sync crate.
- N1 gate: probe is `/api/song?_start=0&_end=1` only; `nd_list_artists_by_role`
is NOT required (Q3 answer + N1 ingest port lands in PR-3b).
- UnstableTrackIds: set for Navidrome via `ServerInfo.server_type`,
cleared for generic Subsonic.
Tests added: 23 across library/sync, library/repos/sync_state,
integration/subsonic, integration/navidrome/probe. Cover bitfield
contains/insert/remove + spec bit values, probe across mixed-capability
servers (full Navidrome, minimal Subsonic, broken endpoints), ping-failure
short-circuit, optional Navidrome creds gating N1, sync_state column-scoped
upserts (capability_flags / sync_phase / watermarks / library_tier),
cross-column independence (capability writes don't reset cursor),
ServerInfo extraction from ping envelope, Navidrome bulk probe across
2xx/4xx/5xx.
* feat(library): InitialSyncRunner + C12 backoff + C13 id remap (Phase C2/C12/C13, PR-3b) (#796)
Second sub-PR of Phase C — wires the actual ingest path on top of
PR-3a's capability + sync_state foundation. Runner is pure async Rust:
PR-3d will spawn it inside a tokio task and emit Tauri progress events
on top.
- C2 InitialSyncRunner. Drives spec §6.3 IS-1 → IS-6: probe-derived
IngestStrategy (enum N1/S1/S2/S3, selector picks N1 → S1 → S2 chain
per kickoff Q3), per-page upsert loop, cursor flush after every
successful batch, IS-4 best-effort getArtists watermark, IS-5
getScanStatus.lastScan capture, IS-6 phase=ready + cursor cleared.
Resume is automatic: a non-empty initial_sync_cursor_json restarts
at the persisted offset; a strategy mismatch between cursor and
capability flags surfaces as SyncError::CursorIncompatible.
- C12 backoff. sync::backoff::Backoff implements the §6.8 schedule
(2s → 4s → … cap 120s) with ±25% jitter via deterministic salt.
retry_with_backoff wraps every endpoint call: transport / Navidrome
failures retry up to MAX_ATTEMPTS_PER_BATCH (5), the cursor never
advances on failure, success resets the counter. Cancellation
AtomicBool is checked between attempts.
- C13 id remap. TrackRepository::upsert_batch_with_remap performs the
§6.9 detect-and-rebind pass inside the same SQLite transaction as
the upsert: a content_hash or server_path collision on a different
existing id triggers UPDATE of child tables (track_offline,
track_extension, track_fact, track_artifact, track_canonical_link),
INSERT INTO track_id_history, DELETE old track row. Off when
UnstableTrackIds is clear (generic Subsonic). New
TrackIdHistoryRepository read-side helper for forward lookups
(analysis cache reuse, Phase E).
- IngestStrategy enum + selector (sync::strategy) — N1 → S1 → S2;
N1 requires Navidrome bearer credentials at runtime (skipped when
None). S3 is enumerated for future file-tree fallback but returns
StrategyUnsupported in v1 per kickoff Q3.
- InitialSyncCursor (sync::cursor) — JSON-serialisable
{ strategy, phase, library_scope, ingested_count, strategy_state }.
StrategyState tagged enum: LinearOffset { offset } for N1/S1,
AlbumCrawl { album_offset, current_album_id } for S2.
- mapping::subsonic_song_to_track_row + navidrome_song_to_track_row
centralise the JSON → TrackRow projection. Subsonic path also reads
replayGain.{trackGain,albumGain} from the raw value so PR-3b doesn't
drop the columns that PR-2b reserves on TrackRow.
- Supporting bits in psysonic-integration:
- subsonic types now derive Serialize so the runner can round-trip
a typed Song back into raw JSON when feeding upsert.
- navidrome::queries gains nd_list_songs_internal — pure async
function (no #[tauri::command] decorator) that the N1 ingest
loop calls directly. The existing Tauri command wraps it.
Tests added across sync::* and repos::track_id_history. Wiremock
covers S1 happy-path, mid-cursor resume from a persisted offset,
strategy mismatch → CursorIncompatible, 503 transient → retry-then-
succeed, AtomicBool cancellation → Cancelled, N1 paginated /api/song
ingest, S2 album crawl, and §6.9 remap firing under UnstableTrackIds
during an actual sync. Backoff schedule + jitter formula pinned.
TrackRepository remap path covered by content_hash collision,
server_path collision, hash+path-missing skip, identity-noop, and
remap-off compatibility with the existing upsert_batch contract.
Also fixes cucadmuh's PR-3a review minor 1: drops the dead
`mount_ok` scaffolding from sync::capability tests.
Out of scope per kickoff Q2:
- DeltaSyncRunner + tombstones → PR-3c
- Background task lifecycle, cancellation wiring, progress emit
throttle, adaptive scheduler, request budget, bandwidth lane → PR-3d
- Tauri command surface for "sync now" / progress events → PR-5
* feat(library): search3 raw envelope fidelity for S1 ingest (PR-3b follow-up) (#797)
Picks up cucadmuh's PR-3b review minor 1: the S1 path in
InitialSyncRunner was reserialising the typed `Song` for
`track.raw_json`, dropping unknown OpenSubsonic extensions
(`replayGain`, `contributors`, …). N1 and S2 already carry the raw
sub-tree verbatim through `nd_list_songs_internal` and
`get_album_with_raw`; S1 now matches via the new
`SubsonicClient::search3_with_raw` mirror of the PR-2b pattern.
- subsonic::SubsonicClient::search3_with_raw — returns
`(SearchResult, serde_json::Value)`; uses the existing
`parse_envelope_with_raw` so error 70 / `Api { code, .. }` mapping
stays consistent.
- sync::initial::run_s1 now calls `search3_with_raw` and feeds the
per-song raw sub-tree (`raw_body.song[i]`) into
`subsonic_song_to_track_row` instead of a typed reserialise.
Tests cover `search3_with_raw` round-trip on a payload with
`replayGain` + `contributors` (verifies the raw value preserves both)
and the empty-result case where the body is `searchResult3: {}`.
Plus an end-to-end S1 ingest test that asserts the persisted
`track.raw_json` column contains the OpenSubsonic extensions after a
full runner pass, and that `replay_gain_track_db` / `_album_db` still
land on the typed columns via the mapping helper.
Full review: psysonic-workdocs/internal/collaboration/handoffs/2026-05-19-pr-796-review.md
* feat(library): DeltaSyncRunner + TombstoneReconciler (Phase C3/C4, PR-3c) (#798)
Third sub-PR of Phase C — drives targeted delta passes on top of
PR-3a/b's foundation. Pure async; PR-3d will spawn it inside the
background scheduler.
- C3 DeltaSyncRunner. Walks spec §6.4 DS-0 … DS-9:
- DS-0/1/2/3 cheap probe via `getArtists` (small/medium tier) or
`getScanStatus` (huge tier when `ScanStatusAvailable`). Server
watermark match → up_to_date short-circuit, scan-in-progress →
deferred_scanning report; zero further requests in either case.
- DS-4 targeted ingest. Strategy from capability_flags: N1-delta
when NavidromeNativeBulk is set, otherwise S2-delta. S1 has no
delta semantic so it's not used here.
- N1-delta: GET /api/song _sort=updated_at _order=DESC, pages
until rows fall under the local `MAX(server_updated_at)`
watermark; out-of-band rows in the same page are dropped.
- S2-delta: getAlbumList2 type=newest then type=recent, up to
a small page cap; getAlbum is fetched only for album_ids the
local store doesn't already have. Known albums are skipped
so a play-bump under "recent" doesn't re-ingest the whole
tracklist.
- DS-6 id remap reuses TrackRepository::upsert_batch_with_remap.
- DS-9 stamps next watermark (artists_last_modified_ms or
server_last_scan_iso) + last_delta_sync_at.
- DS-5 canonical matcher (Phase H) and DS-7 starred delta are out
of scope for PR-3c.
- C4 TombstoneReconciler. Caller-driven streaming: each
`reconcile_chunk(budget)` picks the next `budget` ids ordered by
synced_at ASC, calls getSong, marks deleted=1 on code 70, and
refreshes synced_at on every checked id so the queue rotates.
Mode A (manual integrity) loops until checked == 0; Mode B
(auto-threshold) tests `should_auto_reconcile(local, server, pct)`
per delta tick and runs a small budgeted chunk. Memory bounded —
no full local-id list ever held in RAM.
- SyncStateRepository: new getters for artists_last_modified_ms,
server_last_scan_iso, library_tier; new
set_last_delta_sync_at stamp helper. All column-scoped upserts
preserve neighbouring fields.
Tests cover DS-2 short-circuit (watermark match), DS-3 defer
(scanning=true), N1-delta watermark cutoff (3 fresh + 2 stale rows →
only 3 upserted), S2-delta known-album skip (mock 404 on al_known
guards the assertion), DS-9 watermark + last_delta stamping,
should_auto_reconcile threshold cases (gap, tolerance, server=0,
local<=server), reconcile_chunk code-70 → deleted=1, budget +
ordering (oldest first, newest untouched), empty-store noop, and
cancellation.
PR-3d (background task, probe→flags wiring, progress emit, adaptive
scheduler, request budget, bandwidth throttle) lands next on the
same integration branch.
* feat(library): sync supervisor + progress channel + DS-8 wiring (Phase C5/C6, PR-3d1) (#799)
First half of PR-3d (cucadmuh-approved split per kickoff Q2).
Pure-Rust lifecycle + progress infrastructure on top of the runners
from PR-3a/b/c. Tauri events stay in the top crate (PR-5); this PR
only ships the channel the top crate will subscribe to.
- C5 SyncSupervisor. Spawns a sync workload inside a tokio task,
owns the cancellation AtomicBool, and exposes a single-consumer
mpsc receiver for ProgressEvent. join() returns the inner
Result<(), SyncError>; panics surface as Storage so callers
never need to know about tokio internals.
- C6 progress channel. New sync::progress module:
- ProgressEvent enum — lean variants
(PhaseChanged / IngestPage / Remapped / Tombstoned /
Completed / Error). Server / scope context lives on the
channel side (one supervisor = one scope).
- Progress trait + NoopProgress default + ChannelProgress
forwarding through tokio mpsc. Throttle is the simple
last-emit-timestamp gate; terminal events (Completed /
Error) bypass it.
- InitialSyncRunner + DeltaSyncRunner gain with_progress(...)
builders. IS-1 / IS-6 emit PhaseChanged + Completed; delta
emits PhaseChanged at strategy pick, Tombstoned at DS-8, and
Completed at DS-9. Defaults to NoopProgress so existing call
sites keep working.
- DS-8 wired. DeltaSyncRunner::with_tombstone_budget(n) drives
TombstoneReconciler::reconcile_chunk(n) after DS-4 ingest;
shares the runner's cancellation flag + sleep override. The
DeltaSyncReport gains tombstones_checked / tombstones_deleted
so callers can act on the counts.
- capability::probe_and_persist helper. Chains
CapabilityProbe::run with sync_state writes: sets phase to
"probing" before the probe, persists capability_flags, then
drops back to "idle". PR-3d2 (the scheduler) will call this in
front of every initial / delta run so the stored flags reflect
the live server.
Tests cover: ChannelProgress throttle (zero-interval pass-through,
terminal bypass, non-terminal collapse, sender alive after
receiver drop), SyncSupervisor task completion + cancel +
panic-as-Storage + receiver-take-once, probe_and_persist
round-trip through SyncStateRepository (flags persisted, phase ends
at "idle"), DS-8 reconcile-after-ingest landing tombstones on
code 70 returns.
PR-3d2 follows with the adaptive scheduler (C8), request budget
(C9), poll EWMA (C10), and the bandwidth / queue priority lane
(C11).
* feat(library): adaptive scheduler + request budget + EWMA poll + bandwidth (Phase C8/C9/C10/C11, PR-3d2) (#800)
Second half of PR-3d per cucadmuh's kickoff-Q2 split. Wraps the
runners + supervisor from PR-3a/b/c/d1 into a tick-driven background
scheduler. Top crate (PR-5) plumbs the timer.
- C8 BackgroundScheduler. Tick-based — caller drives the interval,
scheduler decides whether the tick should run. is_due(now_ms)
checks sync_state.next_poll_at; tick(now_ms) either skips
(not due / PrefetchActive pause), or runs a DeltaSyncRunner with
the right budget + tombstone trigger, then stamps the next
poll_at via the adaptive formula. No tokio task ownership —
tests stay deterministic, PR-5 plugs spawn behaviour to taste.
- C9 RequestBudget. PassKind enum (PollTick / DeltaLight /
DeltaMismatch / InitialSync) with caps per spec §6.2.5
(1 / 50 / 200 / unlimited). RequestBudget::has_room(used) gates
the runner; PR-3d2 ships the data type, runner enforcement of
the cap is a future tightening (DeltaSyncRunner already has its
own page cap so the soft cap mostly informs Settings).
- C10 PollStats EWMA. New sync::poll_stats with PollStats
(artist_count, ewma_bytes, ewma_duration_ms, library_tier),
observe()/set_artist_count()/reclassify() helpers, the §6.2.2
tier table (<2k / 2k-15k / >15k or ewma_bytes >2MB), and
next_interval_ms following the spec formula
(base * load_factor * artist_factor, load_factor clamped
[1, 10]).
- C11 PlaybackHint + ParallelismBudget. PlaybackHint enum
(Idle / Playing / PrefetchActive) resolved to a
ParallelismBudget { max_concurrent, min_request_gap_ms }.
PrefetchActive pauses bulk (`max_concurrent = 0`) per
§6.2.4; the scheduler honours it via tick short-circuit.
- Auto-tombstone wire. Before running the DeltaSyncRunner the
scheduler tests `should_auto_reconcile(local, server, pct)`
against the persisted counts; on threshold trip it sets
`with_tombstone_budget(200)` (the §6.2.5 DeltaMismatch cap).
- SyncStateRepository gains poll_stats_json get/set,
next_poll_at get/set, local_track_count get/set, and
server_track_count get/set — all column-scoped upserts.
Tests: ~30 new across poll_stats / budget / bandwidth / scheduler.
EWMA seed + smoothing, tier-classification edges (artist + size
overrides), next-interval formula bounds (idle base, slow-network
load_factor clamp), RequestBudget caps per pass, ParallelismBudget
resolution, scheduler is_due (no schedule / future schedule),
tick short-circuit (not due, PrefetchActive pause), tick runs
delta and persists next_poll_at, auto-tombstone trigger above
5 % threshold, PollStats round-trip through SQLite.
Together with PR-3d1 this finishes Phase C — Tauri command surface
(D1-D4) lands with PR-5.
* feat(library): read-only Tauri command surface (Phase D1 part 1, PR-5a) (#801)
First sub-PR of Phase D per cucadmuh's kickoff Q1 split. Lands the
LibraryRuntime Tauri State plus the 8 read-only library commands
from spec §7.1. No SyncSupervisor spawn, no sync lifecycle commands,
no credentials store — those land in PR-5b.
- New psysonic_library::runtime::LibraryRuntime — Tauri State
wrapping Arc<LibraryStore>. Top crate's lib.rs setup() now calls
LibraryStore::init(app), wraps the result in the runtime, and
app.manage's it. Mirrors the AnalysisCache wiring above it.
- New psysonic_library::dto module — camelCase wire DTOs per
src-tauri/CLAUDE.md: SyncStateDto, LibraryTrackDto (flat
projection over the track hot columns + raw_json sub-tree),
LibraryTracksEnvelope, TrackArtifactDto, TrackFactDto,
OfflinePathDto, TrackRefDto. local_tracks_max_updated_ms helper
surfaces the implicit N1-delta watermark on the SyncStateDto.
- New psysonic_library::payload module — pure
ProgressEvent → LibrarySyncProgressPayload mapper (the
payload Tauri events carry once PR-5b plugs the supervisor's
mpsc receiver into AppHandle::emit). Constants for the event
names too. Unit-testable without Tauri runtime.
- New psysonic_library::commands module with 8 #[tauri::command]
handlers:
- library_get_status — joins the sync_state row + the
track-watermark MAX query into one SyncStateDto.
- library_search — FTS5 via the existing search_tracks helper,
paginated; hydrates hits to full LibraryTrackDto.
- library_get_track — single SELECT through new
TrackRepository::find_one.
- library_get_tracks_batch — capped at 100 refs/call per spec,
preserves caller-supplied order, drops unknowns silently.
- library_get_tracks_by_album — ordered by
disc/track/id via new TrackRepository::find_by_album.
- library_get_artifact — flexible WHERE over track_artifact
(artifact_kind required, source/format optional), latest
fetched_at wins.
- library_get_facts — fact_kinds filter optional;
returns all rows for the (server_id, track_id) pair when
none specified, sorted by fact_kind + fetched_at DESC.
- library_get_offline_path — returns local_path with a
`missing: true` flag when the row is absent.
- TrackRepository gains find_one / find_batch / find_by_album
with a shared row-to-TrackRow mapper. SQL constants pinned next
to the existing UPSERT_SQL so a schema change touches one file.
- src-tauri/src/lib.rs: LibraryStore::init in setup(), the eight
command handlers added to invoke_handler!.
Tests cover: DTO field-name camelCase (IPC contract guard),
LibraryTrackDto round-trip through TrackRow, raw_json fallback to
Value::Null on bad input, local_tracks_max_updated_ms ignores
deleted rows, TrackRepository::find_one / find_batch / find_by_album
ordering + unknown-ref drop, ProgressEvent mapper across all six
variants + serialization keys camelCase. Library tests at 166;
workspace stays green.
Out of scope per kickoff Q1:
- Mutating commands (library_sync_*, library_patch_*,
library_put_*, library_purge_*, library_delete_*) → PR-5b
- SyncSupervisor spawn + background scheduler tick loop +
progress emit → PR-5b
- library_sync_bind_session / clear_session credentials → PR-5b
- TS wrappers + Settings UI + server-remove modal → PR-5c
- library_advanced_search / library_search_cross_server SQL
builders → PR-5d
* feat(library): sync lifecycle + mutate + purge Tauri surface (Phase D1 part 2, PR-5b) (#802)
Second sub-PR of Phase D per cucadmuh's kickoff Q1 split. Adds the
mutating side of §7.1 plus the SyncSession credentials store, the
PlaybackHint setter, the orchestrator that runs InitialSyncRunner /
DeltaSyncRunner under a Tauri AppHandle and emits library:sync-progress
and library:sync-idle events, and the top-crate scheduler tick task
that sweeps every bound session through BackgroundScheduler::tick.
- LibraryRuntime extended per kickoff Q2: sync_sessions HashMap,
playback_hint cell, current_job (cancel handle + identity), and
scheduler_cancel flag the tick task watches. Kickoff sketch said
Mutex<Option<SyncSupervisor>> — supervisor's join() consumes self,
so holding it in the mutex would block library_sync_cancel behind
the orchestrator's join; CurrentJob carries the Arc<AtomicBool>
cancel + metadata instead, orchestrator task owns supervisor /
receiver / join.
- New commands (spec §7.1):
- library_sync_bind_session — caches Subsonic creds in memory,
tries navidrome_token once for bearer cache, runs
probe_and_persist so capability_flags reflect the live server.
- library_sync_clear_session — drops cached credentials.
- library_set_playback_hint — JS pushes idle / playing /
prefetch_active from existing audio listeners.
- library_sync_start — dispatches InitialSyncRunner (mode='full')
or DeltaSyncRunner (mode='delta', with auto-tombstone budget
when local/server count gap exceeds threshold). Spawns runner
+ orchestrator task that drains the progress mpsc into
library:sync-progress emits and emits library:sync-idle when
the runner exits.
- library_sync_cancel — trips the current job's cancel flag.
- library_patch_track — sparse JSON patch (starredAt, userRating,
playCount, playedAt) per §6.5.
- library_put_artifact / library_put_fact — upserts with
ON CONFLICT scoped to the PK so lyrics / BPM writes survive
re-fetches.
- library_purge_server — transactional DELETE across the v1
schema tables for this server_id. include_offline (default
false) controls track_offline + bytes_freed.
- library_delete_server_data — alias that always purges offline
too (logout flow).
- src-tauri/src/lib.rs setup() spawns a 30 s
MissedTickBehavior::Skip task that snapshots bound sessions and
drives BackgroundScheduler::tick(now_ms) for each. Honours
runtime.scheduler_cancel + the current PlaybackHint. Background
ticks stay silent (NoopProgress) — Tauri emit for the
scheduler path lands when Settings (PR-5c) surfaces it.
- psysonic-integration::navidrome re-exports navidrome_token so the
bind_session command can drive the bearer cache without making
the client module pub.
Tests cover: LibraryRuntime session round-trip (set/get/clear
scopes per server), playback_hint default + setter, snapshot
returns clones so callers can mutate freely. Existing library tests
stay green (171 → 171; new code paths under the Tauri command
surface — devtools integration smoke is PR-5c's job).
Out of scope per kickoff Q1:
- src/library/ TS wrappers + Settings UI subsection + server-remove
modal → PR-5c
- library_advanced_search / library_search_cross_server SQL
builders → PR-5d
- Background-tick Tauri emit (NoopProgress today) → PR-5c
- analysis_cache cross-purge in library_purge_server → PR-6
* feat(library): typed invoke wrappers + verify_integrity command (Phase D2 + part of D1, PR-5c) (#803)
Frontend-facing slice of Phase D. Ships the typed src/api/library.ts
wrapper layer that any Settings / browse code will import from, plus
the manual-integrity backend command PR-5b's review §5 note 2 called
out as missing.
Scope cut from cucadmuh's PR-5 kickoff Q1 split: that proposal had
PR-5c = D2 + D3 + D4 (wrappers + Settings subsection + server-remove
modal). The Settings UI + server-remove + audio playback-hint
wiring + authStore extensions + i18n strings turn into a thick frontend
patch in their own right; landing them in one PR with the wrappers
would mix Tauri-surface review with Settings UX review. The split:
- PR-5c (this PR) — D2 wrappers + library_sync_verify_integrity.
- PR-5c-ui (follow-up) — D3 Library Settings subsection, D4
server-remove modal contract, playback hint feed, authStore /
i18n.
Per kickoff exit clause ("Do not split 5c unless review size
forces it"). Reviewable as a clean Tauri-surface vs UX boundary.
- Backend: `library_sync_verify_integrity { serverId, libraryScope? }`
command — same dispatch shape as `library_sync_start { mode:'delta' }`
but always forces the full `DELTA_MISMATCH_CAP` tombstone budget
regardless of the local/server count gap. Spec §6.7 Mode A user-
initiated full reconcile bypasses the threshold check that
governs background ticks.
`library_sync_start` itself is refactored to delegate to a private
`library_sync_start_inner(force_full_tombstone)` so both entry
points share the runner-spawn + orchestrator + emit code.
- Frontend `src/api/library.ts`: full typed wrapper layer over the
19 `library_*` Tauri commands. DTO mirrors carry the camelCase
wire shape (`SyncStateDto`, `LibraryTrackDto`, `TrackArtifactDto`,
`TrackFactDto`, `OfflinePathDto`, `PurgeReportDto`, `SyncJobDto`,
`TrackRefDto`, `ArtifactInputDto`, `FactInputDto`). Plus the
`LibrarySyncProgressPayload` / `LibrarySyncIdlePayload` interfaces
and `subscribeLibrarySyncProgress` / `subscribeLibrarySyncIdle`
helpers that wrap `@tauri-apps/api/event` listen. PlaybackHint
literal type lives here too (`'idle' | 'playing' | 'prefetch_active'`)
so the audio listeners in PR-5c-ui can import a single source of
truth.
- `src-tauri/src/lib.rs` adds the new verify_integrity handler to
the `invoke_handler!` aggregate.
Tests: library tests stay at 171 — verify_integrity is exercised
through the existing `sync_start_inner` paths; the wrapper layer is
trivial passthrough that TypeScript types already check. Vitest
coverage for the typed wrappers belongs with PR-5c-ui where there
are real consumers (LibraryTab) to drive integration tests.
PR-5c-ui (next) lands:
- Library Settings subsection (§7.3 minus advanced toggles)
- ServerRemoveModal extension (keep vs delete local index per §5.6)
- authStore: libraryIndexEnabledByServer + auto-reconcile toggle
- src/store/audioListenerSetup audio:playing / ended /
setDeferHotCachePrefetch → library_set_playback_hint
- i18n keys for the new strings
* feat(library): Settings library index UI + playback hint + purge-on-remove (Phase D3/D4, PR-5c-ui) (#804)
* feat(library): Settings library index UI + playback hint + purge-on-remove (Phase D3/D4, PR-5c-ui)
Frontend half of Phase D, on top of PR-5c's typed wrappers. Wires the
Settings → Library subsection (§7.3), the audio playback-hint feed
(§6.2.4), and the server-remove keep-vs-delete choice (§5.6).
- New libraryIndexStore (Zustand, persisted) — per-server enable flag
+ auto-reconcile toggle. Kept out of authStore so the index feature
evolves independently and the persisted blob stays small.
- New LibraryIndexSection in Settings → Library:
- Per-server "Enable local library index" toggle → binds /
clears the Rust sync session with the active server's
credentials. Off by default (P6).
- Read-only status (Idle / Checking / Initial sync / Ready (n) /
Error) polled from library_get_status every 3 s, overlaid with
live library:sync-progress events.
- Sync now / Verify integrity / Cancel buttons. Verify runs one
§6.7 pass (budget 200) per click; the status line shows the
checked/removed counts so large libraries can be continued with
another click (auto-resume loop is a follow-up).
- Auto-reconcile toggle.
- Subscribes to library:sync-progress + library:sync-idle for the
active server; errors surface as a toast.
- Audio playback hint: handleAudioPlaying → 'playing',
handleAudioEnded → 'idle' via notifyLibraryPlaybackHint, which
gates on the per-server index toggle + dedupes repeated hints so
the IPC boundary isn't spammed on every progress tick.
- ServersTab delete flow: when a server with an enabled index is
removed, a second confirm offers keep-vs-delete of the local
library cache (OK = library_delete_server_data, Cancel = retain
for offline). Always clears the sync session.
- i18n: en + de keys for the new strings; other locales fall back
to en via i18next (later sweep).
Per PR-803 review §5: verify-integrity resume UX is one-pass-per-click
with a visible counter; sync_start idempotency (replaces in-flight)
is surfaced via the Cancel button appearing while busy.
Out of scope:
- VirtualSongList / playerStore local-mode consumers → PR-7 (F1/F3/F5)
- library_advanced_search / cross-server UI → PR-5d + PR-7 F2
- Auto-resume loop for very large verify-integrity runs → follow-up
- Search-all-servers + threshold input (advanced §7.3) → later
* fix(library): normalize server base URL before bind probe
The bind toggle threw "subsonic transport: builder error | relative
URL without a base" — `server.url` is stored bare (e.g.
`nas.example.com`) and reqwest needs a scheme. Two-sided fix:
- Frontend: LibraryIndexSection passes `authStore.getBaseUrl()`
(adds http:// + strips trailing slash) instead of the raw
`server.url`, matching the existing `subsonic.ts` convention.
- Backend: `library_sync_bind_session` normalizes the incoming
`base_url` defensively so the stored session + every downstream
caller (sync_start, scheduler tick, navidrome_token) gets a
scheme-qualified URL regardless of what the WebView sends.
Tests: normalize_base_url covers bare host, trailing slash, existing
http/https scheme, and whitespace.
* fix(library): re-bind sync session on startup + server switch
"Library sync failed: no bound session" — the per-server index toggle
persists in localStorage but the Rust sync session (credentials +
bearer) lives in process memory and is gone after an app restart, so
the toggle showed "on" while no session existed. Per PR-5 kickoff Q5
("on server connect if index already on").
- New `ensureActiveServerSessionBound()` helper: re-binds the active
server's session when its index toggle is enabled. Best-effort —
silent on failure (Settings surfaces the real error on explicit
toggle).
- MainApp re-binds on every `activeServerId` change (covers app
startup + server switch — `setActiveServer` drives the effect).
- LibraryIndexSection re-binds on mount before the first status poll,
so Sync now / Verify integrity work immediately even when the
toggle was already on from a previous run.
* fix(library): trigger initial full sync on first enable (PR-804 review §5.1)
cucadmuh's PR-804 review flagged this as release-blocking: the toggle
only bound the session and «Sync now» / the background tick ran
delta-only, so a fresh enable left the index empty — delta can't
populate a never-synced library.
- On first enable, after bind, fetch status and dispatch
`library_sync_start { mode: 'full' }` when `lastFullSyncAt` is null
(matches spec §6.2 "initial sync always background").
- «Sync now» now picks mode adaptively: `full` until a full sync has
completed, `delta` afterward — so the button works both for the
initial population and incremental updates.
Other PR-804 review notes (auto-reconcile toggle → backend wiring,
prefetch_active hint, clear-old-session-on-switch) stay as documented
non-blocking follow-ups.
* feat(library): advanced search + cross-server SQL builders (Phase D-search, PR-5d) (#806)
* feat(library): advanced search + cross-server SQL builders + commands (Phase D-search, PR-5d)
- FilterFieldRegistry SQL resolution: SqlFragment, compare_fragment, validate_for_entity (§5.13.5)
- Advanced Search builder: per-entity track/album/artist queries; genre (case-insensitive), year, starred, bpm filters; bpm dual-storage resolution (§5.13.4); libraryScope; sort allowlist; full-match totals
- Cross-server FTS union (§5.5B / §5.9 A') with canonical-id dedup
- library_advanced_search + library_search_cross_server commands, registered in the shell
* feat(library): typed advanced search / cross-server invoke wrappers (PR-5d)
Mirror request/response DTOs and add libraryAdvancedSearch / librarySearchCrossServer
in src/api/library.ts. UI parity (AdvancedSearch.tsx) stays PR-7.
* fix(library): self-heal stale/unreadable initial-sync cursor instead of bricking (#807)
The initial-sync cursor records the ingest strategy it was created under.
When a re-probe later selects a different strategy (e.g. the Navidrome
native bearer is briefly unavailable, downgrading N1->S2), the cursor guard
returned a hard error — and since nothing clears the cursor, every later
full sync failed with no recovery path.
Reset the stale (or unreadable) cursor and start fresh under the selected
strategy instead of erroring. Re-ingest is idempotent (upsert); the
tombstone pass reconciles leftovers.
* fix(library): emit per-batch progress during initial sync (#808)
The initial-sync runner only emitted PhaseChanged (start) and Completed
(end), so the Settings status sat at "initial_sync" with no count for the
entire ingest — looking stuck on large libraries even while rows landed.
Emit IngestPage per batch from the N1/S1/S2 loops with the running ingested
total; the existing <=2 Hz throttle paces it. The frontend already renders
the count from these events.
* feat(library): Advanced Search reads the local index when ready (Phase F2, PR-7a) (#811)
When the active server's index is fully synced, Advanced Search serves
query / genre / year / result-type from library_advanced_search (instant +
offline) and pages songs locally. On not-ready or any failure it falls back
to the existing network path unchanged (spec 5.13.6). Results map from each
entity's stored Subsonic rawJson, with the flat hot columns as a fallback.
* feat(library): canonical matcher — link tracks by ISRC/MBID on ingest (Phase H1/H2, PR-4a) (#812)
Adds the strong-key cross-server matcher (spec §5.5A): on every track upsert,
link (server_id, track_id) to a canonical id derived from its ISRC (preferred)
or MBID recording. Deterministic id (`{kind}:{value}`) keeps it O(1) and
idempotent — no lookup-then-create race, no fuzzy loop on the bulk path.
Tracks without a strong key stay standalone (fuzzy/search-time matching is H3).
* feat(library): cross-server fuzzy fallback in search (Phase H3, PR-4b) (#813)
library_search_cross_server now returns a `fuzzy` list alongside the exact
FTS `hits` (spec §5.9): per-server `title LIKE %query%` for matches the exact
pass missed (diacritics, partial words), capped per server, excluding exact
hits and deduped by canonical id against them. Shared `like_contains` moved
to the `search` module.
* feat(library): FactRepository with TTL + provenance rules (Phase E4, PR-6a) (#814)
Typed CRUD over track_fact behind library_get_facts / library_put_fact
(spec §5.12): get lazily deletes the track's expired facts then returns the
survivors (no background GC, P34); a `user` bpm fact also writes the hot
track.bpm column so the override wins and survives a resync (R6-3.4). The
commands now delegate here instead of inlining the SQL.
* feat(library): ArtifactRepository with TTL + 512KB cap (Phase E4, PR-6b) (#815)
* fix(integration): decode OpenSubsonic isrc string-array on Song (#818)
OpenSubsonic types `isrc` as `string[]`; Navidrome 0.61.2 ships it as
`isrc: []` or `["USRC…"]`. The typed `Song.isrc: Option<String>` could
not decode either form, which broke the S1 (`search3`) and S2
(`getAlbum`) ingest paths on real Navidrome libraries — initial sync
could not complete past the first array-valued track.
Add a tolerant `de_string_or_seq` deserializer: plain string →
`Some`, non-empty array → first usable value (string element, or an
object element's `name` for the `[{ "name": … }]` shape), `[]`/null →
`None`. The full multi-value set still survives verbatim in
`track.raw_json` (ADR-7). Applied to `Song.isrc`.
Per maintainer policy R7-15 (workdocs question
2026-05-20-large-library-ingest-client-only, checklist item 1):
treat Navidrome as a black box, harden the client decode.
Tests cover `isrc: []` → None, populated array, and the legacy
single-string form.
* feat(library): large-library ingest strategy — S1 over N1 (R7-15) (#819)
Per maintainer policy R7-15 (large-library ingest, client-only): very
large Navidrome catalogs must not start initial sync on N1 — its native
`/api/song` returns HTTP 500 beyond a deep offset and can never finish.
S1 (`search3`) does not hit that wall.
- Add `IngestStrategy::select_initial_strategy(flags, server_track_count,
n1_bulk_unreliable)`. Large libraries (count > LARGE_LIBRARY_THRESHOLD,
default 40_000) or servers flagged `n1_bulk_unreliable` route to S1 — or
S2 when search3 bulk is absent. Normal-size libraries keep the cheapest
N1 → S1 → S2 chain unchanged.
- Persist the learned per-server `n1_bulk_unreliable` flag on `sync_state`
(additive migration 002, DEFAULT 0). The mid-run N1→S1 fallback that
sets it lands in a follow-up.
- Capture `getScanStatus.count` in the capability probe and persist it as
the `server_track_count` watermark, so the threshold applies from the
first sync rather than only after N1 hits the wall once. A count-less
probe never clobbers a watermark from a prior run.
- The initial-sync runner now selects via the new policy.
Tests: selector table (all branches incl. threshold boundary and the
search3-absent fallback), repo flag roundtrip, probe count capture +
watermark-preservation, migration head-version bookkeeping.
* feat(library): freeze ingest strategy on resume (R7-15 Q3) (#820)
A persisted initial-sync cursor that has already made progress must resume
under its own strategy and ignore what a fresh capability probe would now
pick. Previously any strategy mismatch reset the cursor to a fresh one — so
a flapping Navidrome bearer (N1 flag toggling between probes) restarted
ingest from offset 0 on every launch, which is why large initial syncs
never completed across restarts.
`load_or_init_cursor` now:
- resumes the cursor's strategy when it has progress (`ingested_count > 0`
or `phase != Ingest`), regardless of the re-selected strategy;
- adopts the freshly-selected strategy only when there is no resumable
progress (offset 0), where re-selecting costs nothing;
- still resets a corrupt/unreadable cursor rather than hard-erroring.
One guarded exception: a cursor still on N1 after the server was learned
`n1_bulk_unreliable` is known-broken and re-selects onto the non-N1 path
instead of resuming a wall-bound N1 loop (the mid-run N1→S1 fallback that
preserves progress lands next).
Tests: resume-with-progress freezes strategy and keeps the count;
no-progress cursor adopts the re-selected strategy; known-broken N1 cursor
re-selects; unreadable cursor still resets.
* feat(library): one-way N1→S1 fallback on deep-offset 500 (R7-15 Q5) (#821)
When the N1 ingest loop hits a persistent HTTP 500 at or beyond the
deep-offset safety line (`N1_DEEP_OFFSET_SAFE`, 50_000) it now treats it
as Navidrome's server-side deep-offset wall rather than a transient error:
it learns `n1_bulk_unreliable` for the server and finishes the sync on S1.
- `run_n1` catches the wall after retry exhaustion (`n1_hit_deep_offset_wall`:
HTTP 500 AND offset >= the safety line) and hands off to `fall_back_n1_to_s1`.
A 500 below the line stays a propagated error — no silent downgrade.
- The fallback flags the server, then restarts S1 from offset 0. N1 (`id ASC`)
and S1 (`search3` default order) don't share an offset space, so resuming
from the N1 offset would skip songs; re-ingest is idempotent (PK upsert),
duplicate work over the rows N1 already wrote is acceptable for v1. The
cursor is rewritten in place, never zeroed.
- One-way only: S1 never flips back to N1 mid-run. Combined with the
persisted flag and the resume freeze, a future sync selects S1 directly.
- `N1_DEEP_OFFSET_SAFE` is overridable on the runner so the fallback is
testable without 50k rows of fixture data.
Tests: deep-offset 500 falls back to S1, ingests the full set without
duplicating N1's rows, and persists the flag; a shallow 500 propagates and
does not flag the server.
* feat(library): cache + retry Navidrome bearer, keep N1 flag on transient loss (R7-15 Q3) (#822)
A flaky `/auth/login` previously stripped N1 for a whole bind: the bearer
was fetched once, best-effort, and a single miss dropped to Subsonic-only.
Per R7-15 Q3 a transient `navidrome_token` failure must not drop the
`NavidromeNativeBulk` capability.
- `bind_session` fetches the bearer with `navidrome_token_with_retry`
(3 attempts, short backoff); if it still fails, it keeps the bearer
cached from a prior bind instead of overwriting it with `None`. The token
/ credentials are never logged.
- `probe_and_persist` preserves a previously-learned `NavidromeNativeBulk`
flag when it probes without a token — the server still supports
`/api/song`; only the bearer is missing this bind. The capability is a
stable server property, so a token-less probe must not clear it.
- `library_sync_start_inner` masks `NavidromeNativeBulk` from *this run's*
strategy selection when the session has no token, so the run proceeds
Subsonic-only (S1/S2) instead of selecting N1 with no creds. The
persisted capability stays intact for a later bind that recovers the
token. The in-flight cursor is already protected by the resume freeze.
Tests: token retry yields the token on success and `None` after exhausting
attempts; the probe keeps a learned N1 flag across a token-less re-probe.
* feat(library): mid-run S1→S2 fallback on persistent S1 failure (R7-15 Q8) (#823)
The N1→S1 fallback (#821) had no analogue when S1 itself fails on a server.
Per R7-15 Q8, a persistent S1 failure (C12 retries already exhausted) must
fall back to the universal S2 album crawl — no new artist-walk strategy.
- `run_s1` catches a persistent fetch failure from the `search3` retry loop
(`is_fetch_failure`: transport / HTTP / decode / Subsonic API / not-found)
and hands off to `fall_back_s1_to_s2`. Cancellation and storage errors
propagate untouched.
- The fallback restarts S2 from scratch. S1 (`search3` order) and S2
(album-list order) don't share an offset space, so resuming from the S1
offset would skip songs; re-ingest is idempotent (PK upsert). The cursor is
rewritten in place, never zeroed — the resume freeze then keeps the run on
S2 across restarts.
This completes the ingest fallback chain N1→S1→S2 from the §6.3 strategy
order; the start-time "no search3 → S2" selection was already covered (#819).
Tests: a persistent S1 500 falls back to S2 and the album crawl ingests the
track.
* fix(library): resume interrupted initial sync on startup (#824)
* fix(library): resume interrupted initial sync on startup
An initial sync killed mid-run (app restart) sat at `idle` until the user
clicked «Sync now» — the background scheduler is delta-only and the
auto-full-sync only fired on the index toggle, not on the startup re-bind.
`resumeInitialSyncIfIncomplete` runs after the active server's session is
re-bound (startup + server switch): if no full sync has completed yet
(`!lastFullSyncAt`) it dispatches `library_sync_start { mode: 'full' }`,
which resumes from the persisted cursor instead of restarting from zero.
Once a full sync has landed it is a no-op, so delta stays the scheduler's
job. Best-effort — errors stay silent (Settings surfaces them on explicit
action).
Tests: starts a full sync when none has completed, no-ops once a full sync
has landed, stays silent when the status lookup fails.
* fix(library): silence cancelled-sync toast, de-dupe startup resume
Two rough edges from the startup resume:
- A cancelled sync surfaced as «Library sync failed: sync cancelled». The
orchestrator emitted the runner's `Cancelled` result as an error on the
sync-idle event. Cancellation is expected — the user cancelled, or a newer
`library_sync_start` superseded the job (server switch / startup resume) —
and is documented as silent. `sync_outcome_to_result` now maps
`SyncError::Cancelled` to a clean idle, only real errors toast.
- `resumeInitialSyncIfIncomplete` is now de-duped per server. React
StrictMode fires the startup effect twice, so a second `library_sync_start`
cancelled the first (`set_current_job` is cancel-and-replace) — harmless
with the fix above, but the dedupe avoids the wasted job + probe entirely.
Tests: `sync_outcome_to_result` keeps `Cancelled` silent and forwards real
errors; concurrent resume calls start a single full sync.
* fix(library): run DB read commands off the main thread (async) (#825)
The 10 library read commands were synchronous (`pub fn`). Per the Tauri v2
docs, commands without `async` run on the main thread — so a read that
blocks freezes the UI. During an initial sync the runner holds the single
`Mutex<Connection>` for a whole batch write (500 rows × per-row remap on
Navidrome + upsert + FTS, one transaction), and the Settings library
section polls `library_get_status` on an interval. Each batch write blocked
that polled read on the main thread → the window greyed out until the batch
finished, with the freeze growing as the DB grew.
Make the DB-touching read commands `async` so they run off the main thread:
`library_get_status`, `library_search`, `library_get_track`,
`library_get_tracks_batch`, `library_get_tracks_by_album`,
`library_get_artifact`, `library_get_facts`, `library_get_offline_path`,
`library_advanced_search`, `library_search_cross_server`. Reads still
serialize behind the writer (the single connection is intentional — the
schema mirrors `analysis_cache`, spec §5.1), but the wait no longer blocks
the UI. State-only commands stay sync. Invoke names / payloads are
unchanged, so the frontend is unaffected.
Spec §15 R7-15 follow-up — surfaced in live QA on a 170k library.
* feat(library): scope analysis cache by server_id (E1, schema only) (#826)
Add a versioned migration to audio-analysis.sqlite so waveform/loudness
rows are keyed per server. This is the schema-only step (PR-6c-1): every
existing row migrates to server_id='' and behaviour is unchanged. The
server_id write/read wiring, legacy fallback and lazy re-tag follow in 6c-2.
- migrations 001 (baseline = the pre-versioning schema) + 002 (rebuild the
three tables with server_id; PK (server_id, track_id, md5_16kb), loudness
+ target_lufs)
- versioned runner mirroring the library store; each migration commits its
schema change and version marker in one transaction, so a failure or crash
rolls the whole migration back and retries cleanly
- VACUUM INTO snapshot before the table rewrite as a safety net beyond the
transaction (disk-full at COMMIT, FS corruption)
- TrackKey gains server_id; all callers pass "" for now
* feat(library): analysis cache server_id wiring (E1, 6c-2) (#827)
* feat(library): scope analysis cache writes/reads/deletes by server_id (E1 wiring)
Build on the 6c-1 schema migration: thread the playback server scope
(playbackServerId ?? activeServerId) through the analysis cache so a server
switch can no longer surface another server's waveform/loudness for the same
bare track_id.
- Write: seed_from_bytes_* and the CPU-seed / HTTP-backfill queues carry a
server_id; every audio write path (in-memory, ranged, legacy stream, local
file, spill, preload), the syncfs offline/hot caches, and the backfill
command write under the playback server (empty = legacy '').
- Read: get_latest_*_for_track and the exact-key lookup try the server scope
first, then fall back to the legacy '' rows; a legacy hit is re-tagged onto
the server scope (INSERT OR IGNORE, never clobbers a precise row). No bulk
backfill — existing caches re-tag lazily on play, so they are not
re-analysed wholesale.
- The backend gain-resolution path (loudness normalization, replay-gain
updates, device resume) is scoped via a pinned current_playback_server_id on
the audio engine, so normalization keeps working for server-scoped rows.
- Delete: delete_*_for_track_id scope to (server + legacy ''); reseed on one
server no longer wipes another server's analysis. delete_all_waveforms stays
global (Settings -> Storage).
Tauri boundary: analysis_get_waveform(_for_track), analysis_get_loudness_for_track,
analysis_delete_waveform/loudness_for_track and analysis_enqueue_seed_from_url
gain an optional serverId; audio_play and audio_preload gain an optional
serverId. All additive (absent = legacy '').
* feat(library): pass playback serverId to analysis IPC (E1 wiring)
Send getPlaybackServerId() (queueServerId ?? activeServerId) with every
analysis-cache call so reads/writes/deletes scope to the right server:
- audio_play / audio_preload (playTrack, resume, queue-undo restore, gapless
byte-preload)
- analysis_get_waveform_for_track / analysis_get_loudness_for_track (waveform +
loudness refresh)
- analysis_delete_waveform/loudness_for_track + analysis_enqueue_seed_from_url
(reseed + loudness backfill)
Absent serverId stays backward-compatible (legacy '' scope).
* feat(library): content_hash from playback (E2, 6d) (#828)
* feat(library): record playback content_hash into the track store (E2)
Bridge the playback-derived md5_16kb into library `track.content_hash` (R7-16 Q4)
so id-remap can rebind a track when the server reassigns ids (§6.9).
- New `ContentHashSink` port in psysonic-core (closure handle, mirrors
PlaybackQueryHandle): keeps psysonic-analysis decoupled from psysonic-library.
- `seed_from_bytes_into_cache` returns the computed md5; `seed_from_bytes_execute`
fires the sink after a successful seed (Upserted or cache-hit) when a real
server is known. The shell crate registers the sink to patch the library.
- `patch_content_hash` + `library_patch_track`'s new optional `contentHash`
field write it; both no-op when the library has no row for (server_id, id),
i.e. the index is off for that server.
- Sync upsert no longer clobbers it: `content_hash = COALESCE(NULLIF(
excluded.content_hash,''), track.content_hash)` — a sync (which passes NULL)
preserves the playback hash, a non-empty incoming hash still wins.
No schema migration — the `content_hash` column already exists. Tauri boundary:
`library_patch_track` gains optional `contentHash` (additive).
* feat(library): expose contentHash on libraryPatchTrack wrapper (E2)
Add optional `contentHash` to the `libraryPatchTrack` patch type so the TS
contract matches the extended Rust command. Normally written by the Rust
analysis bridge; exposed for completeness.
* feat(library): enrichment summary on library_get_track (E3, 6e) (#829)
* feat(library): enrichment summary on library_get_track (E3)
Add an optional `enrichment { waveformReady, loudnessReady, lyricsCached }` to
the single-track `library_get_track` read (R7-16 Q5). Read-only, per-server,
never blocks on the network; list/batch projections leave it unset.
- New `AnalysisReadinessQuery` port in psysonic-core (closure handle, mirrors
ContentHashSink) keeps psysonic-library decoupled from psysonic-analysis. The
shell crate registers it to probe the analysis cache by exact
(server_id, track_id, content_hash) key with legacy '' fallback — read-only,
no re-tag. waveform/loudness readiness is gated on a known content_hash (E2).
- `lyricsCached` from a new pure-read `ArtifactRepository::lyrics_cached`
(valid, non-expired, non-not_found lyrics row).
- `library_purge_server`'s `includeAnalysis` documented as a deliberate v1
no-op (R7-16 Q7): analysis is never deleted on purge / server remove.
Tauri boundary: `LibraryTrackDto` gains optional `enrichment` (additive).
* feat(library): mirror enrichment on LibraryTrackDto wrapper (E3)
Add `TrackEnrichmentDto` + optional `enrichment` to the TS `LibraryTrackDto` so
the contract matches the extended `library_get_track` response.
* feat(library): VirtualSongList browses the local index when ready (F1) (#830)
The all-songs browse now serves pages from the local library index when it is
ready for the active server, falling back to the unchanged network path
otherwise.
- `runLocalSongBrowse` (reuses the F2 local-read adapters): empty-query
browse-all via `library_advanced_search`, whose default track order
(`t.title COLLATE NOCASE ASC`) matches the network `ndListSongs('title','ASC')`
path, so paging stays coherent across a local↔network boundary.
- Gated per page on `libraryIsReady` + `source === 'local'`; any miss / failure
returns null → VirtualSongList uses the existing browse path unchanged.
- Search (non-empty query) stays on the network path for now; rich search is
already covered by Advanced Search (F2).
* feat(library): patch-on-use for star/rating/scrobble (PR-7 F3) (#831)
* feat(library): library_patch_track clears nullable fields on explicit null (F3)
Extract the patch logic into a testable `apply_track_patch`. Nullable integer
fields (`starredAt` / `userRating` / `playCount` / `playedAt`) now distinguish an
absent key (leave untouched) from an explicit `null` (clear the column), so
`unstar` ({ starredAt: null }) actually un-stars the local row. `.map` keeps the
present/absent distinction; `as_i64()` yields the value or `None` → bound as SQL
NULL. F3 is the first caller that sends null, so no existing behaviour changes.
* feat(library): patch-on-use wiring for star / rating / scrobble (F3)
After a successful star/unstar, setRating, or play scrobble, mirror the change
into the local library index via `library_patch_track` so its reads (browse F1,
advanced search F2) reflect the action immediately — no stale list after a rate,
no full resync.
- `patchLibraryTrackOnUse` helper: fire-and-forget, gated on the index being
enabled for the server; the Rust command additionally no-ops when no row
matches (album/artist id, or index off).
- Wired at the central API chokepoints: `star`/`unstar` (song only) →
`starredAt`, `setRating` → `userRating`, `scrobbleSong` → `playedAt`.
- `play_count` is left to the next sync (the patch sets absolute values; a
correct increment needs the current base).
F4 (deprecating the player-store override maps) is intentionally separate —
removing them would break instant star feedback when the index is off.
* feat(library): full-queue restore from the index on startup (PR-7 F5) (#832)
Persist the whole queue as a lightweight ref list and rehydrate it from the
local index on startup, so the entire queue survives a restart instead of only
the windowed slice (R7-17 / §8.6).
- Persist adds `queueRefs` (full ordered ids) + `queueRefsIndex` alongside the
existing windowed `queue`. Ids are tiny; the windowed objects stay as the
no-index fallback.
- `hydrateQueueFromIndex` (startup, after session bind): when the library index
is ready for the queue's server, hydrate the full queue via
`library_get_tracks_batch` (batched ≤100), map `songToTrack ∘ trackToSong`,
re-locate the current track so `queueIndex` stays aligned, then clear the refs.
- Index not ready / missing rows / current track not found → keep the windowed
fallback (queue never empty when the index is off, the P6 default). Old
persisted shape without refs loads unchanged.
- `trackToSong` exported from the F2 local-read adapters (one mapper).
Kept the windowed-objects persist (did not drop the cap per R7-17 note): the
index-off default needs the embedded fallback or the queue would restore empty.
* feat(library): pending-sync for song star/rating (PR-7 F4) (#833)
* feat(library): central pending-sync helper for song star/rating (PR-7 F4)
`queueSongStar` / `queueSongRating` (spec §6.5 / R7-18): set the player-store
override optimistically, retry the Subsonic API with exponential backoff (flush
on `online` / window focus), and on success clear the override + patch the
in-memory Track so the UI stays correct without it. The F3 index patch-on-use
runs inside the API layer, unchanged.
- No rollback on the first network error (the override survives until the retry
succeeds or the app restarts; overrides are session-only, not persisted).
- Latest-toggle-wins coalescing + an identity guard so a fast re-toggle while a
request is in flight can't retire the newer task.
- v1: songs only.
* feat(library): route song star/rating through the pending-sync helper (PR-7 F4)
Replace the scattered optimistic-set + API-call + rollback logic with the single
`queueSongStar` / `queueSongRating` helper across cucadmuh's named v1 surfaces:
PlayerBar, FullscreenPlayer, MobilePlayerView, both context menus (song + queue
row), both shortcut paths, the song-rating hook + player-bar stars, skip→1★, and
AlbumDetail (song star + rating). The 30+ override read sites are unchanged —
they already read `override ?? track`, and the override now clears on success.
Standalone page toggles (Favorites, RandomMix, NowPlaying star) and the separate
mini-player webview keep their existing path — no regression (a non-migrated
override simply lingers as before) — and move to a follow-up.
* feat(library): route remaining song star/rating sites through pending-sync (F4 follow-up) (#834)
Migrate the three standalone song write sites left out of #833 onto the
central queueSongStar / queueSongRating helper:
- Favorites: handleRate + removeSong (un-star)
- RandomMix: toggleSongStar (drops local try/catch rollback per no-rollback policy)
- useNowPlayingStarLove: toggleStar (keeps local view state, helper owns override + retried sync)
MiniContextMenu stays on its direct path (separate webview, no shared store).
No behaviour change for album/artist rating paths.
* feat(library): route playlist song star/rating through pending-sync (F4 follow-up) (#835)
The playlist-detail star/rating hook was the last shared-store song write
site still calling the Subsonic API directly. Route handleRate +
handleToggleStar through queueSongRating / queueSongStar, matching the
Favorites and RandomMix follow-ups; keep the local ratings/starredSongs
view state, drop the inline override.
MiniContextMenu remains on its direct path (separate webview).
* feat(library): BPM range filter UI in Advanced Search (PR-7 F6) (#836)
* feat(library-sync): parallel initial ingest (S2 + N1/S1 prefetch)
Wire C11 ParallelismBudget (max 4 when idle) into InitialSyncRunner:
parallel getAlbum for S2, up to 4 in-flight pages for N1/S1, and persist
S2 cursor once per album-list page instead of per album.
* fix(library-sync): defer scheduler during initial sync and improve ingest diagnostics
Background delta/tombstone ticks every 30s were competing with IS-3 bulk ingest
for the write mutex (20–60s lock waits on large libraries). Skip scheduler while
sync_phase is initial_sync/probing or bulk ingest is active.
Serialize ingest batch metrics as camelCase for DevTools, add bulk-ingest FTS/index
suspension, combined cursor persist, write-op tracing, live local search, and
library dev logging helpers.
* fix(library-search): scoped FTS, cancel stale live search, skip 1-char queries
Use column-scoped FTS for artists/albums/songs, min two graphemes for local
FTS, capped match counts in Advanced Search, and title browse index (m004).
Live Search aborts superseded network requests, passes requestEpoch to drop
stale Rust FTS, and avoids search3 fallback for too-short queries.
* fix(library-search): prefix FTS, fast subquery joins, hide BPM in Advanced Search
Live and Advanced Search now use FTS5 prefix tokens ("metal"*) and limit
bm25 ranking inside rowid subqueries so large libraries stay in the ms range.
Advanced Search BPM filter is removed from the UI until enrichment ships.
* feat(library-search): race local index vs search3, show first result
Live Search and Advanced Search text queries run library and network
backends in parallel; the faster source wins. Adds searchRace helper
and search_race dev logging.
* feat(library-index): multi-server UI, serial sync queue, scoped local search
Add master library index toggle with per-server rows, offline retry, and a
frontend sync queue so initial ingest runs one server at a time. Scope Live
Search and Advanced Search to the sidebar music library filter via library_id
and raw_json fallbacks; coerce numeric libraryId on ingest. Promote idle sync
state to ready when a full sync stamp exists and block cross-server initial
sync starts in Rust.
* chore(library-store): compliance — clippy, i18n, CHANGELOG
Fix clippy/tsc blockers (request structs, IngestPageCtx, type aliases),
add library index strings to all 9 locales, and document the preview
feature in CHANGELOG [1.47.0] with Psychotoxical + cucadmuh attribution.
* docs(credits): library index preview contributions
Credit Psychotoxical for the local library store foundation and cucadmuh
for multi-server UI, scoped search, and i18n. Drop removed scan-trigger
wording from PR #780 entry.
* docs(release): link library index preview to PR #846
* docs(changelog): sort [1.47.0] entries by ascending PR number
* docs(changelog): mark library index as Added in [1.47.0]
* docs(changelog): restructure [1.47.0] into Added/Changed/Fixed
Match 1.46.0 layout: new features in Added (incl. library index),
enhancements in Changed, bug fixes in Fixed — PR ascending within each block.
* fix(library): address PR #846 review — delta guard + FTS order
Skip background scheduler delta when LibraryRuntime already has a
foreground sync job for the same server. Preserve bm25 rowid ordering
in live search track/artist/album fetches.
* fix(library): address remaining PR #846 review items
S2 resume persists current_album_id per album; same-server resync awaits
the previous runner. N1 delta watermark uses strict less-than; Navidrome
HTTP 500 detection is structured. Adds genre/year indexes, backoff jitter
salt, LiveSearch failure toast, and user-facing search badge copy.
* fix(library): close resync notify race and tighten FTS trigger test
Use notify_one() so an early runner completion cannot lose the drain
signal before same-server full resync awaits. FTS test now compares
normalized trigger bodies from migration vs suspend/restore roundtrip.
---------
Co-authored-by: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com>
The unreleased 1.47.0 block still advertised the server-card Quick/Full
scan buttons (#780); those were removed before release (#843), so drop
that line — the edit-existing-profile half of #780 stays. Add the album
bulk "Add to playlist" selection fix (#844) under Fixed.
The selection's outside-click handler ran on every mousedown outside the
.tracklist, and the bulk-action toolbar is a DOM sibling of it. Clicking
"Add to playlist" fired mousedown -> clearAll() -> inSelectMode=false, so
the toolbar unmounted before the button's onClick could open the picker:
selection vanished, no dialog.
Skip the clear when the mousedown lands inside .album-track-toolbar (filter,
add-to-playlist picker, clear button) — that UI belongs to the selection.
Clicks elsewhere (header, empty page) still clear as before.
Drops the Quick/Full scan actions and all supporting logic — they are no
longer needed. Removes the ServerScanActions component (incl. the unused
compact variant), the subsonicScan API, the scanStore, and the app-root
useScanPolling hook (no more background scan polling). Cleans up the
.server-scan-* CSS and the settings.scan i18n block across all 9 locales.
440 deletions, no new code; tsc + bundle clean.
* feat(favorites): virtualize songs tracklist + memoize rows
10k+ starred songs no longer mount every row into the DOM. Same fix
shape as playlist virtualization: @tanstack/react-virtual windowing,
memoized FavoriteSongRow with stable callback bundle, scrollMargin
ResizeObserver anchored to .content-body. visibleTracks memoized once
per visibleSongs ref to avoid O(n) songToTrack on every click.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(favorites): review follow-ups for virtualization PR #805
Add maintainer comments for scrollMargin layout coupling and bulk-bar
useLayoutEffect dep. Record PR #805 in CHANGELOG and settings credits.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TELEMETRY.md captures the standing no-telemetry stance as a standalone
document next to PRIVACY.md. The README Privacy section now links
both: TELEMETRY.md for the policy itself, PRIVACY.md for how each
opt-in integration handles data.
The compact ServerScanActions rendered inside the server-switcher
dropdown crowded each row with two extra icons. Drop the scan controls
from the dropdown — they remain available on the Settings server
cards (variant="card") — and restore the single-button row layout with
the check / spinner pinned to the right edge.
Partial revert of #780 for the switcher only; Settings card actions
unchanged.
Album-card play and enqueue overlay buttons revert to a pill shape
(var(--radius-full)); the small play button next to track numbers in
the album track list / artist top-tracks reverts to a circle (50%).
The square shape from the consistency sweep felt off against the round
covers — back to the original round/pill family for these specific
icons. All other surfaces touched in #745 stay on var(--radius-sm).
* fix(playback): Lucky Mix after switching Subsonic servers
Hand off unpinned legacy queues to the browsed server, start the mix lock
before async work, skip cross-server enqueue toasts while rolling, bind
queue server before batch enqueues, and restore queueServerId on failed builds.
* chore(release): CHANGELOG and credits for PR #785
* fix(ui): stable play/enqueue hover on album rails and mainstage cards
Horizontal Home rails (Discover): drop content-visibility:auto and scroll-snap,
pin controls to cover hover, dim overlay via background. Grids and song cards:
pointer-events on overlays so WebKitGTK/Wayland GPU does not lose :hover.
* chore: CHANGELOG and credits for PR #787
* refactor(playback): extract fadeOut helper
Used by playAlbum.ts today; about to be reused for the radio delete
fade-out so it lives in its own module instead of being copy-pasted.
* fix(player): disable Repeat button while a radio stream is active
Matches the Prev / Next buttons that already gate on isRadio — repeat
has no meaning for a live stream, and the live tooltip plus accent
colour suggested it was interactive.
* fix(radio): fade out before deleting the playing station
Mirrors the 700 ms fade-out playAlbum uses on track changes — beats
the abrupt cut when the deleted station is the one currently on air.
* fix(radio): add Play/Stop tooltip to the cover-overlay button
Cast / X icon now describes itself, matching the favourite and delete
buttons that already had tooltips.
* fix(radio): use Square stop icon on the active card
The play-overlay and delete buttons both used X, making it ambiguous
which click stopped the stream and which deleted the station. Stop
gets a filled Square; delete keeps its X.
* fix(radio): cancel auto-reconnect when the user pauses a stream
Closes#779.
Root cause: a 'stalled' event during a paused stream still scheduled a
4 s reconnect timer that called play(), and the timer was not cancelled
on pauseRadio(). On macOS WKWebView the underlying TCP socket droops
roughly a minute after pause, the browser fires 'stalled', and the
stream resumes against the user's intent. The store's isPlaying flag
stays false because nothing on the reconnect path syncs it, so the
play/pause button stops matching reality.
Fix: skip the schedule when radioAudio.paused, re-check inside the
timer callback (covers a pause within the 4 s window), and clear any
pending timer in pauseRadio().
* docs: changelog for radio card polish + paused-stream fix (#786)
Extend sync-tauri-version-from-package.js to align psysonic* crate
version fields in Cargo.lock with package.json. Include the lockfile in
promote and post-release main-bump commits. Fixes drift where lock stayed
on the previous -dev while Cargo.toml already matched the channel bump.
* fix(ui): in-page browse virtual lists and cover load priority
Align virtualizer scrollMargin with the in-page scroll viewport so
artist/album rows do not vanish on deep scroll after #731. Resolve
CachedImage IntersectionObserver root to the nearest scrolling pane so
network fetch slots favor visible covers; throttle Artists infinite scroll
to one page per visibleCount update.
* chore(release): CHANGELOG and credits for PR #783
* feat(linux): session GDK defaults, nvidia-quirk, optional x11-legacy wrap
Ship PSYSONIC_ALLOW_NATIVE_GDK from Nix/AUR instead of pinning WEBKIT_DISABLE_*
and GDK x11. Add flake psysonic-x11-legacy for the old wrap; alias gdk-session
to psysonic. Startup uses webkit2gtk-nvidia-quirk and Wayland-aware compositing;
refresh Help (a45) and nixos-install docs.
* fix(linux): session GDK and nvidia-quirk only; drop wrapper env heuristics
Remove PSYSONIC_ALLOW_NATIVE_GDK and devShell GDK/WEBKIT exports; stop
synthesizing GDK/WebKit vars in main.rs. Update Nix/AUR wrappers, install
docs, CHANGELOG, and help FAQ with practical user-facing workarounds.
* fix(linux): X11-pinned GDK uses DMABUF quirk path, not Wayland explicit-sync
When GDK_BACKEND is forced to x11 on a wayland user session, webkit2gtk-nvidia-quirk
would still apply __NV_DISABLE_EXPLICIT_SYNC and gray out the webview. Map that case
to WEBKIT_DISABLE_DMABUF_RENDERER like native X11.
* fix(ui): stabilize WebKitGTK/Wayland hover paint for nav and media cards
Sidebar nav links avoid transition:all and promote icons with translateZ(0).
Artist rows and album/artist/song cards use compositing hints; card shadows
and borders no longer interpolate so cover zoom can stay smooth without jitter.
* fix(ui): isolate artist/album card text and cover paint on WebKitGTK
Promote cover blocks with contain/paint and text stacks with translateZ(0);
use artist-card-info on the artists grid for the same layout as other cards.
* feat(artists): in-page overlay scroll and locked main viewport
Move list/grid into an inner OverlayScrollArea, stop sticky toolbar from
owning the route scroll, align the rail with the main panel edge, and skip
the main-route overlay thumb when the viewport cannot scroll vertically.
* feat(browse): extend in-page overlay scroll to more library routes
Reuse the locked main viewport pattern from Artists for Albums, Composers,
Lossless albums, and New releases; wire VirtualCardGrid and scroll chrome
to the matching in-page viewport ids.
* fix(linux): improve Wayland GPU compositing text clarity in WebKitGTK
Use on-demand hardware acceleration on main and mini webviews when the
session is Wayland and compositing stays on; gate subpixel body AA on the
same conditions via new Tauri probes. Document PSYSONIC_SKIP_WAYLAND_FONT_TUNING
for opt-out and changelog.
* fix(rust): satisfy clippy needless_return in Linux webkit helpers
* fix(linux): tune Wayland text rendering with HW policy env and CSS
Allow PSYSONIC_WEBKIT_WAYLAND_HW_POLICY to select WebKit hardware
acceleration policy (never/always vs default on-demand). Extend Wayland
font CSS to #root with geometricPrecision and text-size-adjust on html.
* feat(linux): Wayland text presets in settings, safe WebKit apply, CPU default
Persist profile to app config; apply WebKit policy at startup/mini only to
avoid WebKitGTK hangs on live toggles. UI + CSS preview stays live; default
preset is sharp (CPU-friendly).
* fix(linux): map Wayland sharp preset to OnDemand WebKit policy
HardwareAccelerationPolicy::Never at startup broke main-viewport wheel
scrolling on WebKitGTK+Wayland; sharp vs balanced remains a CSS AA path.
Use PSYSONIC_WEBKIT_WAYLAND_HW_POLICY for a true Never policy.
* fix(rust): gate Linux-only Wayland WebKit helpers for Windows builds
Re-export startup helpers only under cfg(linux) and drop non-Linux stubs so
Windows compiles without unused-import and dead-code warnings.
* chore(release): CHANGELOG + credits for Linux session/WebKit work (PR #731)
Consolidate scattered incremental changelog notes into two [1.47.0]
entries with PR link; remove duplicate Linux blocks from [1.46.0] Fixed.
Append settings credit line for cucadmuh.
* experiment(zoom): allow setting webview zoom via core capability
* experiment(zoom): drive uiScale through Tauri's native webview zoom
Replace the CSS `zoom: uiScale` on `.main-content-zoom` (which only
scaled the main content column, leaving the sidebar, queue panel,
player bar and portaled overlays at 1.0) with a `setZoom` call on the
current webview. That scales everything inside the window the same way
Ctrl+/− does in a browser, including portals and the queue panel.
Effect runs whenever `uiScale` changes and once on mount, so the
persisted setting is reapplied on launch.
* docs: changelog + credits for interface scale (#781)
* feat(server-scan): plumbing for triggering library scans
Adds `startScan` / `getScanStatus` against the Subsonic API
(`fullScan=true` is Navidrome's extension), a small per-server scan
store, and a global polling hook (2 s cadence) that emits a toast when
each scan finishes. Scans can run on any configured server, including
inactive ones, by reusing `apiForServer`.
UI surfaces follow in the next commit.
* feat(server-scan): expose Quick / Full Scan in switcher + settings cards
Adds a `ServerScanActions` component with two variants (compact for the
server-switcher dropdown, card for the Settings server cards) backed by
the scan store from the previous commit. Full Scan requires a second
click within 3 s to confirm, matching the playlist-delete pattern.
Status slot shows a spinner with running track count while scanning, a
green check when finished, and a red icon on error.
The switcher row is converted from a single button to a flex container
so per-server scan controls don't hijack the server-switch click.
i18n added across all 9 locales.
* fix(server-scan): reorder switcher row to check / name / scan actions
Moves the check / spinner slot from the right edge to the left so the
spinner pop-in on server switch doesn't sit next to the scan icons.
Removes the layout shift that briefly hovered the Quick scan button
when the row re-rendered.
* feat(servers): edit existing server profiles in Settings → Servers
* Pencil-button on each server card opens an inline edit form that
replaces the card (prefilled name / URL / username / password).
* `AddServerForm` reused with an `editingServer` prop — title flips to
"Edit Server", submit label to "Save", magic-string field hidden (the
edit scope is manual fields; magic-string remains an add-time invite
shortcut).
* Edit saves unconditionally — ping runs post-save as a status indicator
(analog to the existing Test button) instead of gating the save. Lets
users update a profile when the server is currently unreachable.
* Translations across all 9 locales (`editServer`, `editServerTitle`).
* fix(servers): submit Add/Edit Server form on Enter
Wrapped the form body in a real <form>, made the submit button
type="submit", marked Cancel as type="button" so Enter no longer
cancels. Add-Mode now also responds to Enter — same flow, consistent
across both modes.
* fix(servers): collapse card action buttons to icon-only on narrow screens
* Quick-Scan / Full-Scan / Test buttons in each server card hide their
text label below 1100px viewport via the .server-card-btn-label class
and a single media query in connection-indicator.css.
* Labels remain accessible via data-tooltip and aria-label so screen
readers + hover both keep working in the collapsed state.
* No content reflow above the breakpoint — pure additive CSS.
* fix(servers): include Use button in icon-only narrow-screen collapse
The Use ("Verwenden") button on inactive server cards lacked the
.server-card-btn-label wrapper, so its text stayed visible at narrow
viewports and pushed Edit/Delete off-screen. Added a Power icon and
wrapped the label so it collapses alongside the other action buttons.
* docs(changelog,credits): #780 server scan + edit
* feat(settings): dim disabled toggle rows + rename Clear → Clear queue
* Settings toggle rows (`.settings-toggle-row`, `.sidebar-customizer-row`) dim
their non-toggle content to 0.6 opacity when the switch is off. Driven by a
single `:has(.toggle-switch input:not(:checked):not(:disabled))` rule so it
applies across every Settings tab without per-row markup. Mutex-disabled
toggles (Crossfade/Gapless) are excluded to avoid stacking with the existing
inline 0.45 dim on their row.
* Queue toolbar "Clear" button now reads "Clear queue" in all 9 locales for
parity with "Shuffle queue" and to distinguish from playlist clear/delete.
Adopted from @kveld9's PR #558 — cherry-picked the spirit, rewrote the dim as
a single global selector instead of three inline-styled customizer rows, and
extended the rename to the Romanian locale that landed after #558 was opened.
Co-Authored-By: kveld9 <179108235+kveld9@users.noreply.github.com>
* docs(changelog,credits): credit @kveld9 for #778
Co-Authored-By: kveld9 <179108235+kveld9@users.noreply.github.com>
---------
Co-authored-by: kveld9 <179108235+kveld9@users.noreply.github.com>
--title "chore(release): bump main to ${VERSION}" \
--body "Auto-generated after stable release: updates \`package.json\`, \`package-lock.json\`, \`src-tauri/Cargo.toml\`, and \`src-tauri/tauri.conf.json\` to the next development version."
--body "Auto-generated after stable release: updates \`package.json\`, \`package-lock.json\`, \`src-tauri/Cargo.toml\`, \`src-tauri/Cargo.lock\`, and \`src-tauri/tauri.conf.json\` to the next development version."
@@ -41,7 +41,7 @@ Open pull requests against `main`. `next` and `release` are maintainer-driven pr
- **AUR packaging problems** — follow the AUR links in [README](README.md); those packages are maintained separately from this repository.
- **Large features or UX overhauls** — consider discussing in chat or opening an issue early so effort aligns with product direction.
- **Changes to the Tauri boundary** — read [The Rust ↔ frontend (Tauri) contract](#the-rust--frontend-tauri-contract) before opening a PR; reviewers will ask for a clear justification.
- **Security issues** — please do **not** open a public issue. Reach a maintainer privately via Discord or Telegram first; we'll coordinate disclosure from there.
- **Security issues** — please do **not** open a public issue. See [SECURITY.md](SECURITY.md) for how to report vulnerabilities privately (Discord or Telegram).
---
@@ -73,7 +73,7 @@ If you use **Nix**, `nix develop` (see [`flake.nix`](flake.nix)) provides the pi
| Topic | Location |
|--------|----------|
| Frontend test stack (Vitest, Tauri/Subsonic mocks, store resets, i18n in tests) | [`src/test/README.md`](src/test/README.md) |
| What CI runs for frontend / backend | [`frontend-tests.yml`](.github/workflows/frontend-tests.yml), [`rust-tests.yml`](.github/workflows/rust-tests.yml) |
| What CI runs for frontend / backend | [`frontend-tests.yml`](.github/workflows/frontend-tests.yml), [`eslint.yml`](.github/workflows/eslint.yml), [`rust-tests.yml`](.github/workflows/rust-tests.yml) |
| Frontend "hot path" files held to a coverage threshold | [`frontend-hot-path-files.txt`](.github/frontend-hot-path-files.txt), [`check-frontend-hot-path-coverage.sh`](scripts/check-frontend-hot-path-coverage.sh) |
2.**Match existing style** in touched files (naming, module layout, comment density). Avoid drive-by refactors unrelated to the task.
3.**Linting and formatting:**there is no enforced JS/TS formatter or ESLint config in the repo today — `tsc --noEmit` is the only frontend gate beyond tests. For Rust, `cargo clippy --workspace --all-targets -- -D warnings` is the lint gate; `cargo fmt` is not currently required but won't hurt.
3.**Linting and formatting:**ESLint (strict `eslint.config.mjs`) and **`npm run dep:check`** (dependency-cruiser layering/cycle guard) run in CI on frontend paths; run both locally before opening a frontend PR. `tsc --noEmit` is also required. For Rust, `cargo clippy --workspace --all-targets -- -D warnings` is the lint gate; `cargo fmt` is not currently required but won't hurt.
4.**Commit messages:** a short **human-readable** summary of what changed and why; Conventional Commits-style prefixes (`feat:`, `fix:`, ...) are fine if you prefer them. Do not include meta references (IDEs, assistants, or how the message was produced) — only what matters for project history.
5.**License:** new code must remain compatible with the project's GPLv3.
6.**Tests:** when you change behaviour users rely on, add or update tests next to the code (see [`src/test/README.md`](src/test/README.md)). Purely visual tweaks may not need tests, but behavioural regressions should be covered where the suite can catch them.
7.**i18n:** user-visible strings live in `src/locales/*.ts` (one TypeScript module per language) and are wired up in `src/i18n.ts`. English (`en.ts`) is the baseline — always add the key there. Other locales may be left for follow-up translation PRs if you don't speak the language, but keep the object shape consistent so missing keys are obvious.
- **Adding a new language (not just keys):** the Rust cluster-key normalizer (`src-tauri/crates/psysonic-library/src/identity/norm.rs`) folds diacritics/ligatures per shipped locale so library items match across servers. When a locale introduces a script or letters it does not yet cover, extend its decomposition table for that language and **bump `NORM_VERSION`** (existing `library-cluster.db` keys rebuild automatically). CJK locales are intentionally left verbatim. The module header carries the same checklist.
---
@@ -108,16 +109,37 @@ Align early: open an issue or chat thread before sending a PR that renames `invo
---
## Frontend architecture and layering
The frontend uses a feature-folder architecture (introduced in #1225) with a layering contract that CI enforces through **`npm run dep:check`** (dependency-cruiser). The rule is simple: **a lower layer must never import a higher one.**
- **`lib/**` is the floor** — feature-free infrastructure (API clients, formatting, i18n, util, media, server, navigation). It must **not** import from `store`, `ui`, `features`, `cover`, `music-network`, or `app`. If a `lib` helper needs auth/server state, pass it in as an argument or keep the helper in the layer that owns that state (`store` or `features`) — do not reach into a store from `lib`.
- **`store` / `ui`** may import `lib` only.
- **`cover` / `music-network`** are top-level domains and may import `lib`, `store`, `ui`.
- **`features/<x>`** may import `lib`, `store`, `ui`, `cover`, `music-network`, and other features — but cross-feature access goes **only** through the `@/features/<x>` barrel, never a deep path.
- **`app`** (shell + bridges) may import anything.
- **No import cycles** anywhere under `src/`.
The authoritative rules and rationale live in [`.dependency-cruiser.cjs`](.dependency-cruiser.cjs) (its header documents the layers and the known-violation ratchet). Any **new** layering or cycle violation fails the `dependency-cruiser` job and blocks `ci-ok`, so run **`npm run dep:check`** locally before opening a frontend PR. The known-violations baseline in `.dependency-cruiser-known-violations.json` only ratchets **down** — don't regenerate it to silence a new violation; fix the import instead.
---
## CI on pull requests to `main`
PRs must target `main`. `next` and `release` are maintainer-driven promotion branches — don't target them directly.
Workflows are path-filtered (see the YAML for exact `paths` / `paths-ignore`):
- **Frontend** (`src/**`, lockfile, Vitest/Vite/tsconfig, etc.):`npm test` (Vitest), `npx tsc --noEmit`, then a coverage run.
- **Frontend** (`src/**`, lockfile, Vitest/Vite/tsconfig, ESLint config, dependency-cruiser config, etc.): `npm run lint`, **`npm run dep:check`** (layering + cycle guard),`npm test` (Vitest), `npx tsc --noEmit`, then a coverage run.
- **Rust** (`src-tauri/**`): `cargo test --workspace --all-targets`, `cargo clippy --workspace --all-targets -- -D warnings`, then coverage.
Hot-path coverage gates are currently **soft** (warnings only — the workflow carries `continue-on-error: true`). They will be flipped to required when the floors stabilise; see the headers in [`frontend-hot-path-files.txt`](.github/frontend-hot-path-files.txt) and [`hot-path-files.txt`](.github/hot-path-files.txt) for the current state of each list.
The **`ci-ok`** job in [`ci-main.yml`](.github/workflows/ci-main.yml) is the merge gate: it waits for every required job above whose pathfilter matched the PR, and fails if any of them failed or did not finish in time.
Hot-path coverage gates are **required** on pull requests: the `coverage` jobs in [`frontend-tests.yml`](.github/workflows/frontend-tests.yml) and [`rust-tests.yml`](.github/workflows/rust-tests.yml) fail when any listed file drops below the floor. See the headers in [`frontend-hot-path-files.txt`](.github/frontend-hot-path-files.txt) and [`hot-path-files.txt`](.github/hot-path-files.txt) for curation rules and thresholds.
---
@@ -129,7 +151,10 @@ Assume the repository root is `psysonic/` (for example after `git clone https://
@@ -40,8 +40,9 @@ Click **Psy Orbit** in the top bar → **Create a session**. The start modal ope
- **Session name** — a random playful name is generated; edit it or reroll with the dice button.
- **Max guests** — cap on concurrent participants (1–32). You don't count.
- **Session server** — when your library scope includes several servers, choose the Navidrome instance that will host the session. Psysonic temporarily narrows the library scope and mixed queue to that server, then restores the previous scope when the session ends.
- **Invite link** — ready to copy and share the moment the modal opens. Pre-generated from a fresh session id + the slugified name.
- **Clear my queue first** — optional. Start with an empty queue (guest suggestions land fresh) vs. keep your current queue and share it with the guests.
- **Clear my queue first** — optional. Start with an empty queue (guest suggestions land fresh) vs. keep the chosen server's queued tracks and share them with the guests.
Click **Start Orbit**. The session bar appears at the top of the window (session name, participant count, shuffle countdown, settings / share / help / exit buttons). The link is now live — share it.
@@ -267,11 +268,10 @@ The state blob is size-bounded to 4 KB (serialised JSON). `serialiseOrbitState`
### Cleanup
Three layers of defense against orphaned playlists:
Two layers of defense against orphaned playlists:
1.**Explicit exit.**`endOrbitSession` (host) or `leaveOrbitSession` (guest) deletes the participant's own playlists synchronously. The happy path.
2.**Server-switch teardown.** Switching Navidrome servers tears the current session down first (up to 1.5 s), then switches. Prevents "in session against wrong server" states.
3.**App-start orphan sweep.** Every app launch runs `cleanupOrphanedOrbitPlaylists`: lists every `__psyorbit_*` playlist the current user owns, parses the heartbeat from the comment, deletes anything with a heartbeat older than 5 minutes (or `ended: true`, or an unparseable comment). The current local session is always protected.
2.**App-start orphan sweep.** Every app launch runs `cleanupOrphanedOrbitPlaylists`: lists every `__psyorbit_*` playlist the current user owns, parses the heartbeat from the comment, deletes anything with a heartbeat older than 5 minutes (or `ended: true`, or an unparseable comment). The current local session is always protected.
The 5-minute TTL is a conservative compromise: long enough to survive a brief app restart (and a session running on another device of yours), short enough that a dead session doesn't clutter the server indefinitely.
@@ -295,7 +295,7 @@ The 5-minute TTL is a conservative compromise: long enough to survive a brief ap
- **Bulk "Play All" in-session.** Dialog: "Add 14 tracks to the Orbit queue?" On confirm, appended. On cancel, no-op.
- **Single-click on song row in-session.** Swallowed; shows "Double-click to add" toast.
- **Multiple accounts on target server.** Paste flow opens an account picker modal. Keyboard-navigable.
- **Server switch while in session.** Teardown runs before switch. Any server-resident session playlists get cleaned up by their host's next app-start sweep.
- **Server switch while in session.** The session remains bound to its original server; changing the visible active server cannot redirect playlist reads, writes, suggestions or cleanup.
- **Initial sync race.** The guest's first tick retries on 500 ms cadence until the player state actually matches the host's last-known track (up to 2 s per attempt, then falls through with a best-effort mirror).
- **`positionAt` stale on join.** Seek fraction is clamped to [0, 0.99] — prevents `audio:ended` from firing at the very start of a join.
- **Outbox deletion mid-session** (cleanup race): host sees the guest drop out on the next sweep; guest's next heartbeat recreates the outbox if they're still connected.
@@ -312,7 +312,7 @@ The 5-minute TTL is a conservative compromise: long enough to survive a brief ap
-`src/utils/orbitBulkGuard.ts` — standalone confirm-dialog helper invoked from `playerStore` when `>1` tracks land in the queue while a session is active.
-`src/utils/switchActiveServer.ts` — wires Orbit teardown into server-switch.
-`src/utils/server/switchActiveServer.ts` — switches the active account without tearing down the source-bound Orbit session.
@@ -9,13 +9,19 @@ All third-party integrations listed below are **opt-in**. Nothing is sent until
### Your Subsonic / Navidrome server
Your server URL, username, and password are stored locally in the app's data directory. All playback and library requests go directly to your own server. Psysonic has no access to this data.
### Last.fm
If you connect a Last.fm account in Settings, Psysonic sends:
- **Scrobbles** — track title, artist, album, and timestamp when a song reaches 50% playback
- **Now Playing** — the currently playing track (title, artist, album)
- **Love / Unlove** — when you mark a track as loved or unloved
### Music Network (scrobble & enrichment services)
Psysonic can connect to one or more scrobble services in Settings → Integrations. Each service you connect is opt-in and independent; nothing is sent to a service you have not connected. Supported service classes:
All requests go to the [Last.fm API](https://www.last.fm/api). Your Last.fm credentials are stored locally and never leave your device. You can disconnect your account at any time in Settings.
- **Audioscrobbler / GNU FM services** — Last.fm, Libre.fm, Rocksky (AT Protocol), and any self-hosted GNU FM-compatible instance
- **ListenBrainz** — the public ListenBrainz.org service, or a self-hosted instance (e.g. Koito) via its ListenBrainz-compatible API
- **Maloja** — your own self-hosted Maloja server (native API or its ListenBrainz-compatible API)
To each connected service, Psysonic may send:
- **Scrobbles** — track title, artist, album, and timestamp when a song reaches 50% playback
- **Now Playing** — the currently playing track (title, artist, album), where the service supports it
- **Love / Unlove** — when you mark a track as loved, on services that support it
Additionally, the one service you choose as your **primary** is queried to enrich the UI (your loved tracks, similar artists, and listening stats). All requests go directly from your device to the service's own host — the public service's host (e.g. the [Last.fm API](https://www.last.fm/api), [ListenBrainz](https://listenbrainz.org)) or, for self-hosted services, the server URL you entered. Credentials (session keys / API tokens) are stored locally and never leave your device. You can disconnect any service at any time in Settings.
### LRCLIB (Lyrics)
When lyrics are fetched from LRCLIB, Psysonic sends the track title, artist, album, and duration to [lrclib.net](https://lrclib.net) as a search query. No account is required. This feature can be disabled in Settings → Lyrics.
@@ -37,7 +43,7 @@ If Discord is running and Rich Presence is not disabled, Psysonic connects to th
The following data is stored exclusively on your device in the app's local storage directory and is never transmitted:
- Server profiles (URL, username, password)
-Last.fm session key
-Scrobble service credentials (session keys / API tokens)
- Playback preferences, themes, keybindings, and all other settings
@@ -32,104 +32,148 @@ More translations are added over time.
---
> [!WARNING]
> Psysonic is under active development. Bugs and rough edges can happen, and features may change as the project evolves.
## What is Psysonic?
Psysonic is a desktop music client for self-hosted music libraries. It is designed for people who want the freedom of their own server without giving up the comfort, polish and speed of a modern music app.
It is built with **Rust**, **Tauri v2** and **React**, with a strong focus on responsiveness, customization, practical music-library workflows and a user interface that does not require a manual before you can press play.
Psysonic is **optimized first and foremost for Navidrome**. Other Subsonic-compatible servers can work well too, but advanced features may depend on server-side support.
Psysonic is **optimized first and foremost for Navidrome**, and it leans into that on purpose: instead of being one more generic Subsonic client, it is **the Navidrome-first desktop client that does things no other client does.** Other Subsonic-compatible servers can work well too, but advanced features may depend on server-side support.
---
# Highlights
# ⭐ Key Features
## Playback & Queue
These are the things that set Psysonic apart. To our knowledge, no comparable self-hosted desktop client ships them.
## 🪐 Orbit — Shared Listening
**Listen together, in sync, over your own server.**
Orbit brings real-time synchronized group listening into Psysonic. Start a session, invite people with a link, and everyone hears the same thing at the same time — with host-controlled playback, a shared queue and guest song suggestions.
The clever part: Orbit rides entirely on **your own Navidrome**. There is no external relay, no third-party service and no extra accounts. The session lives on your server, where it belongs. It is built for real-world music sharing without turning your self-hosted setup into a social-media circus.
**A local index of your whole collection, so the app stays fast no matter what your connection does.**
Psysonic keeps a local library of your collection's metadata right on your machine. Because the app already knows your tracks inside out, browsing, searching and starting playback are instant — even a 500 MB FLAC starts the moment you hit play, because nothing has to be fetched or parsed first.
It also means the connection to your server stops being a bottleneck. Even on a slow, flaky or distant link, Psysonic stays responsive and **behaves almost like an offline player**, whatever the network is doing.
And it's the foundation everything else is built on: the local library is what makes on-device analysis, smart audio and snappy navigation possible in the first place.
## 🧠 On-Device Audio Analysis
**One of the most powerful things Psysonic does — entirely on your own machine.**
Built on top of the local library, Psysonic analyzes your tracks locally — **loudness, waveform and tempo** — with no cloud service and no required server-side plugin. That analysis is what powers content-aware AutoDJ transitions, LUFS-based loudness normalization and playback-speed control.
This is a deep, genuinely useful layer that most clients simply don't have, and because it runs locally, it works exactly the same whether you're fully online or barely connected.
## 🎧 AutoDJ — Content-Aware Crossfade
**A DJ that listens to the music, not a stopwatch.**
Most players do fixed-time crossfade: blend the last N seconds into the next N seconds, dead air and all. AutoDJ uses Psysonic's own audio analysis to **trim the silence at the edges of a track and blend out of the actual music** — for transitions that sound deliberate instead of mechanical. It is a standalone playback mode with smooth skip/interrupt handling and a configurable overlap.
## 🔗 Navidrome-Native, Deeply
**Not a generic Subsonic client wearing a Navidrome hat.**
Psysonic binds Navidrome's native capabilities directly: server-side smart-playlist create/edit, playback reporting and OpenSubsonic capability probing. Most clients in this space stay Subsonic-generic. Psysonic goes deeper, so Navidrome users get the features their server can actually deliver.
## 🎨 Community Theme Store
**A real marketplace for themes — installable and schedulable.**
Beyond a big set of built-in themes, Psysonic has a first-party theme registry: browse community themes, install them in-app, and let the **Theme Scheduler** switch looks automatically between day and night.
---
> ### Built to be trusted
>
> We take an enterprise-grade approach to development — continuously improving our automated testing and maintaining strict contracts between the backend and the frontend. Releases are cut from green CI, not vibes.
---
# ✨ More Highlights
Features that go well beyond the basics. Not all of these are unique to Psysonic, but few clients bring this many together.
## Audio & Loudness
* Gapless playback
* Crossfade
* ReplayGain support
* LUFS-based Smart Loudness Normalization
*[AudioMuse-AI](https://github.com/NeptuneHub/AudioMuse-AI) support
*Infinite Queue
* Smart Radio sessions
* Fast and responsive playback handling
* Low memory usage compared to heavy web-first clients
## Audio Tools
* 10-band Equalizer
* Equalizer presets
*ReplayGain support and loudness-aware playback
*10-band Equalizer with presets
* AutoEQ headphone correction
* Per-device optimization
*Loudness-aware playback options
* Per-device EQ and output optimization
*Adjustable playback speed
## Library Management
## Lyrics & Listening
* Synced lyrics with seek support, from multiple providers ([YouLy+](https://github.com/ibratabian17/YouLyPlus), LRCLIB, NetEase)
* Auto-scrolling sidebar lyrics and a fullscreen lyric mode
* Last.fm scrobbling, similar artists, loved tracks and listening stats
* Smart Radio sessions and an Infinite Queue
* [AudioMuse-AI](https://github.com/NeptuneHub/AudioMuse-AI) support for sonic-similarity discovery (requires an AudioMuse-AI server)
## Artwork & Visuals
* Optional external artist imagery via **fanart.tv** — opt-in, shown on the artist page, fullscreen player and home hero (Navidrome stays the canonical cover-art source)
* Cover art surfaced across the app, OS media controls and Discord Rich Presence
## Library & Playlists
* Fast search across large libraries
* Albums, artists, tracks and genres
* Ratings support
* Multi-select bulk actions
* Drag & drop playlist management
* Smart Playlists
*Built for large self-hosted collections
*Drag & drop playlist management
* Multi-select bulk actions
## Lyrics & Discovery
## Sharing
*Synced lyrics with seek support
*Lyrics provider support: [YouLy+](https://github.com/ibratabian17/YouLyPlus), LRCLIB and NetEase
* Auto-scrolling sidebar lyrics
* Fullscreen lyric mode
* Last.fm scrobbling
* Similar artists
* Loved tracks and listening stats
*Magic Strings sharing for albums, artists and queues
*Navidrome user-management helpers for fast account sharing
## Sharing & Social Listening
## Offline, Sync & Deployment
*Magic Strings sharing:
*share albums, artists and queues
*Navidrome user management helpers
*fast account sharing
*Orbit shared listening sessions:
* host-controlled synchronized playback
* session invites via link
* guest song suggestions
* real-time queue interaction
*Offline playback and downloads
* USB / portable sync
*LAN / remote auto-switching
*Custom HTTP headers for reverse-proxy-gated servers (e.g. Cloudflare Access, Pangolin)
*Backup and restore settings
*In-app auto updater
## Personalization & Accessibility
*Large theme collection
* Catppuccin and Nord inspired styles
* Glassmorphism effects
* Font customization
* Zoom controls
*Font customization and zoom controls
* Keybind remapping
* Theme Scheduler for automatic day/night switching
Orbit brings synchronized shared listening sessions directly into Psysonic.
The things you simply expect from a serious music player — and Psysonic does them well.
Start a session, invite others with a link and listen together with host-controlled playback, shared queue interaction and guest song suggestions. It is built for real-world music sharing without turning your self-hosted setup into a social-media circus.
* Gapless playback and crossfade
* Fast search across large libraries
* Browse albums, artists, tracks and genres
* Ratings
* Queue management
* Keyboard navigation
* Media key support
* Low memory usage and native performance compared to heavy web-first clients
* Built for large self-hosted collections
---
@@ -137,7 +181,7 @@ Start a session, invite others with a link and listen together with host-control
Linux builds are also available through GitHub Releases, AUR and Cachix/Nix.
> **AppImage runs under X11/XWayland** — it pins `GDK_BACKEND=x11` for a stable WebKitGTK stack. For a native-Wayland launch, use the `.deb`, `.rpm`, AUR, or Nix packages, which follow your session's display server.
## Windows
Download the latest installer from the [GitHub Releases](https://github.com/Psychotoxical/psysonic/releases/latest).
Download the latest installer from the [GitHub Releases](https://github.com/Psychotoxical/psysonic/releases/latest).
or,
install via Windows Package Manager (WinGet):
```powershell
wingetinstallPsysonic
```
You can also browse and install it on [winstall.app](https://winstall.app/apps/Psychotoxical.Psysonic).
## macOS
@@ -191,6 +244,14 @@ Psysonic is built for self-hosted music collections. Your library is yours.
* No analytics harvesting
* No hidden tracking
See [TELEMETRY.md](TELEMETRY.md) for the telemetry stance and [PRIVACY.md](PRIVACY.md) for how each opt-in integration handles data.
---
# Reviews
* [An independent review at falu.github.io](https://falu.github.io/2026/06/19/psysonic.html)
@@ -21,12 +21,14 @@ Direct push to these branches is not part of normal human workflow. Use PRs and
## 2) Versioning rules (mandatory)
Version is authoritative in `package.json` and `package-lock.json`.
Version is authoritative in `package.json` and `package-lock.json`. Promotion workflows run `scripts/sync-tauri-version-from-package.js`, which also aligns `[workspace.package]` in `src-tauri/Cargo.toml`, `tauri.conf.json`, and the `psysonic*` workspace crate version fields in `src-tauri/Cargo.lock` (local `cargo build` alone does not commit that lock metadata).
-`main` version format: `X.Y.Z-dev`
-`next` version format: `X.Y.Z-rc.N`
-`release` version format: `X.Y.Z`
WiX/MSI bundle version: alphabetic pre-releases (`-dev`, `-rc.N`) map to monotonic `major.minor.patch.build` in `bundle.windows.wix.version` via `scripts/sync-wix-bundle-version.mjs` — dev `.1`, rc `.10000+N`, stable `.65534` (in-place MSI upgrade across promotion). About/updater still show the real package version. NSIS accepts full semver without this mapping.
Rules:
1. Never edit versions manually in random commits.
@@ -49,6 +51,7 @@ Rules:
### Step B: Promote to RC (`next`)
0. Confirm `WHATS_NEW.md` has a `## [X.Y.Z]` section for the release line about to ship (user-facing copy for the in-app What's New screen; CI uploads it as `whats-new.md` on the release tag).
1. Run workflow: **Promote main to next**.
2. Workflow behavior:
- validates required `main` checks before promotion (default: `ci-ok`, or UI-style `ci-main / ci-ok`; either satisfies the gate)
- **AUR packages** ([`psysonic`](https://aur.archlinux.org/packages/psysonic), [`psysonic-bin`](https://aur.archlinux.org/packages/psysonic-bin)) are maintained separately; packaging issues there should go through the AUR unless they reflect a vulnerability in the upstream app itself.
- **Your music server** (Navidrome, Gonic, etc.) is outside this project's scope; report server-side issues to those projects.
## Secure development
Pull requests are reviewed on `main`. **Dependency security updates** are tracked via Dependabot (PRs only for reported vulnerabilities and their fix paths—not routine version bumps). For general contribution expectations, see [CONTRIBUTING.md](CONTRIBUTING.md).
Privacy is not an afterthought in Psysonic. It has been part of the project’s foundation from the very beginning.
Psysonic was built with the clear intention of giving users control over their own music experience without silently observing what they do. We believe that people should be able to use software on their own systems without being monitored by the developers behind it.
## No Telemetry
Psysonic does **not** collect telemetry.
That means:
- no usage statistics
- no background tracking
- no hidden analytics
- no hardware or system profiling
- no automatic crash or behavior reporting
- no data harvesting of any kind
Psysonic itself does not collect, store, sell, analyze, or transmit personal usage data.
## A Conscious Decision
We are aware that telemetry can make software development easier.
Anonymous usage statistics, crash reports, platform information, and diagnostic data can help developers find bugs faster, understand which systems are used most often, and prioritize fixes more efficiently.
However, we made a conscious decision not to build Psysonic around that model.
For us, respecting user privacy is more important than collecting additional data for convenience. Modern software already tracks more than enough, often by default and often without users fully understanding what is being collected. Psysonic intentionally takes a different approach.
## External Services Are Opt-In
Some Psysonic features can communicate with external services, such as:
- Last.fm
- Bandsintown
- Discord Rich Presence
- Fanart.tv
These integrations are optional and clearly presented as opt-in features. They are never required for using Psysonic.
If you enable one of these integrations, data may be transmitted to the respective external service provider as part of how that service works. Psysonic itself does not collect or process that data for its own analytics.
You decide which integrations you want to use.
## Community Feedback Instead of Silent Tracking
Instead of telemetry, Psysonic relies on direct community feedback.
Bug reports, feature requests, platform issues, and general discussions happen openly through community channels such as Discord and Telegram. Additional contact options, including WhatsApp and Facebook groups, are planned for the future.
We prefer talking to users directly over silently collecting information in the background.
This approach may sometimes require more communication, but it also keeps the relationship between the project and its users transparent and respectful.
## Our Position
Psysonic is built for people who want to enjoy their own music collection on their own terms.
No hidden tracking.
No telemetry by default.
No analytics quietly running in the background.
This is intentional, and it is not planned to change.
### Multi-library filter — browse across your libraries
- The sidebar library picker now supports **multi-select with priority ordering** — browse, search, genres, and album/artist detail views aggregate across the libraries you pick and de-duplicate shared items by priority.
- Cross-library matching normalises names per locale (German ß, Norwegian æ, French œ, Romanian ș/ț, Cyrillic ё/й); CJK titles are matched as-is.
- The Genres page and album browse genre filter list the full catalog on large libraries when **All libraries** is selected.
### Navidrome public share links — listen without logging in
- Paste or search a Navidrome **public share** URL (`/share/{id}`) to preview the shared track list, then play the full queue with no server account.
- Share playback stays isolated from your logged-in Navidrome queue — idle server play-queue pull cannot replace a share session while you are connected elsewhere.
- While a share queue is active, **Save Playlist** is hidden in the queue toolbar; **Share** copies the original Navidrome `/share/{id}` page URL.
### Fullscreen player — Minimal, Immersive, and Prism
- **Settings → Appearance → Fullscreen player style** now offers three looks: **Minimal** (the current sharp view), **Immersive** (artist photo/backdrop with rail or Apple-style scrolling lyrics), and **Prism** (full-bleed artist backdrop, glass lyrics panel, and a single glass control bar).
- In Immersive, **Show artist photo** and **Photo dimming** are configurable; Prism drives progress and the active lyric line from the cover-derived accent colour.
### Lyrics that follow the singer, word by word
- The **Server** lyrics source now highlights lyrics word by word as a track plays, so karaoke sync no longer needs the third-party YouLyPlus backend. It requires Navidrome 0.63 or newer and lyrics that carry word timing (TTML or Enhanced LRC files) — everything else keeps highlighting line by line. The requirements are spelled out under **Settings → Lyrics → Lyrics Sources**.
- Embedded Enhanced LRC no longer prints raw word timing codes in the lyric text; FLAC, Ogg Vorbis, Opus, and Speex files with synced lyrics in the `SYNCEDLYRICS` tag show embedded lyrics again.
### Player bar — build your own, plus shuffle
- **Settings → Personalisation → Player bar** now also hides the **stop button** and shows the **album name** under the artist (off by default; clicking it opens the album). Star rating, favourite, love, playback speed, equalizer, and mini player can be **dragged into any order** you like — the section is no longer behind **Advanced**.
- A **shuffle toggle** in the player bar shuffles the queue from the current track onwards while keeping the playing track in place; turning shuffle off restores the original order. It survives restarts and keeps Orbit guests in sync with the host. Hide the button from **Settings → Personalisation → Player bar** if you prefer.
### Track lists — optional album cover thumbnails
- Browse and queue track rows can show each track's **album** cover (per-disc art when the album has distinct disc covers).
- **Settings → Appearance** adds separate toggles for queue vs browse tracklists; playlist, Favorites, and album-detail grids gain a flex-resize handle on the title column when covers are shown.
- Album detail pages skip per-row cover thumbs when the album art is already shown above the list.
### Discord — Server cover art without exposing your login
- **Settings → Integrations → Discord → Cover art source** includes a **Server** option alongside **None** and **Apple Music**. It resolves artwork through the server's public album image link — never an authenticated URL that could expose your login credentials. Requires a publicly reachable server.
### Artists browse — album artists or track credits
- Toggle **Album artists** vs **Track artists** on the Artists page — album mode lists indexed album artists; track mode includes featured and guest performers from the local artist index. The choice persists across restarts like **Show artist images**.
- Artist name search no longer depends on query letter case for Cyrillic and other non-ASCII names when the local library index is enabled.
### Theme Store — what's new on each theme
- Each theme card has an expandable **What's new** with per-version release notes from the author — including non-visual fixes.
- Installed themes with an available update now appear at the top of the store list so you do not have to hunt for them.
- **Settings → System → Contributors** lists community theme authors in a **Themes** section alongside app contributors; author names refresh quietly from the store in the background.
### Italian and Bulgarian — now in your language
- Psysonic is now available in **Italian (Italiano)** and **Bulgarian (Български)** — pick either from the language menu on the **Settings** and **Login** screens.
### Start minimized to tray
- New **Start Minimized to Tray** toggle under **Settings → System → Behavior** — the next cold start keeps the main window hidden and Psysonic runs from the system tray until you show it from the tray icon. Requires **Show Tray Icon**; applies on the next launch only.
- Opening the main window from the tray after a cold start renders the sidebar and main content immediately — including on Linux tiling window managers — instead of leaving them invisible or blank until a restart.
### Square corners — a sharper, boxier look
- New **Square Corners** toggle under **Settings → Appearance → Visual Options → Display** strips the rounded corners off cards and cover art across the app — handy when a theme's rounding does not suit your album covers. Off by default; buttons, inputs, and dialogs keep the theme's corners.
## Improved
- With **Remember EQ per device** on and **System Default** selected, the equalizer now keys profiles to the active OS default output and switches when that default changes outside the app (Windows sound settings, Stream Deck, PipeWire / `wpctl`, and similar). **Linux:** when PipeWire has already moved the stream to the new default, the device watcher skips a redundant reopen to avoid a post-switch stutter. **Windows:** release builds no longer freeze on the loading splash; audio output devices use stable backend IDs with clearer labels, and device-change detection works again after upgrade.
## Fixed
### Playback and audio
- Playing a song from a playlist no longer shows the track's own cover in Now Playing when the album page would show album art — Now Playing consistently uses the album cover.
- ReplayGain applies when stream or queue metadata resolves late; gapless auto-advance no longer leaves the playbar on the previous track.
- Pausing a large queue behind a reverse proxy (e.g. Nginx) no longer snaps playback back to an earlier track — Navidrome saves via POST when supported, and a failed save no longer lets idle auto-pull overwrite your queue.
- Internet Radio equalizer presets and slider changes now apply to live stations — not just library tracks.
### Offline, Now Playing, and Navidrome
- Desktop builds no longer get stuck showing "offline" when WebKitGTK leaves `navigator.onLine` at `false` while the server is reachable — the app confirms with a real server probe instead.
- When browsing offline, Artists, Albums, Tracks, and Genres list only content with on-disk bytes — pins, favorites-auto saves, and hot-cache playback — instead of the full server or local index catalog.
### Themes and integrations
- Connecting a scrobble service in **Settings → Integrations → Music Network** now shows the underlying error alongside the generic network message, so a bad URL or rejected token is easier to tell apart from a reachability problem on your machine.
- Horizontal album rails in themes with drop shadows no longer clip card shadows at the edges — scroll arrows keep working without theme authors overriding rail overflow.
### Browse and library
- Adding tracks to a playlist no longer fails past ~341 songs — writes go to the server in batches, and large-playlist edits are faster.
- Queue rows far from the playing track no longer stay stuck on a "…" placeholder — the queue loads details for whatever you scroll to, in the desktop panel, mobile drawer, and fullscreen **Up next** overlay.
- The year filter on **All Albums** no longer clamps on every keystroke while you type a four-digit year — it commits on blur, Enter, or outside click.
- Starring an album on the detail page fills the heart immediately and keeps it filled after reload; album-level stars and ratings reconcile consistently across browse and Favorites.
- Renamed artists no longer linger as ghost entries that open to "Artist not found"; album artist links and cover tiles in **Random Albums** stay consistent after resync.
- Custom playlist and internet radio covers uploaded in Navidrome show again on cards and detail headers.
- Sorting albums by artist now follows the name shown on each row — featured-guest releases no longer land under the wrong artist in **Artist / Year** order.
### Other
- Servers behind a custom HTTP header gate (Cloudflare Access, Pangolin, and similar) now work for the full app — add-server errors stay on the form with a clear reason, browse and detail views load natively behind the gate, streaming and covers carry the header reliably, and returning to a LAN address upgrades the connection automatically when both LAN and public endpoints are configured.
- Modal dialogs now announce their title to screen readers when they open.
- **Windows:** **Who is listening?** no longer shows `psysonic/undefined` as the client id.
## [1.49.0]
## Highlights
### Play queue sync — pick up where you left off on another device
- Click the header connection indicator to **pull** the active server's play queue when it differs from yours; a yellow LED shows when browse and playback servers do not match.
- While paused or stopped, **idle auto-pull** checks every 10 seconds and applies server changes when you have been still for 30+ seconds.
- Queue **push** sends only tracks owned by the playback server, so mixed-server queues stay sane when you switch servers.
- Local queue edits while paused are no longer overwritten by auto-pull; pressing **Play** pushes your changes immediately, and the sync LED no longer flashes on every track during normal playback.
- After the last track ends with repeat off, idle pull no longer rewinds to an earlier server position — the queue stays where playback finished.
### AutoDJ — minimum pauses, maximum music
- New **AutoDJ** mode — a smart crossfade that blends tracks intelligently: it trims dead air, rides natural fades, and keeps handovers musical instead of abrupt. Its own button in the queue toolbar and its own entry under **Settings → Audio**, alongside Crossfade and Gapless — only one at a time. Off by default; classic **Crossfade** is unchanged.
- **Smooth skip** (on by default with AutoDJ) crossfades manual Next/Previous and track picks from where you are listening instead of hard-cutting; the play/pause button pulses while a blend is active.
- Cap how long overlaps may last: **Auto** (content-driven, up to 12 s) or **Limit** (slider 2–30 s) under **Settings → Audio → Track transitions**.
- The last track in the queue plays through to the end instead of being trimmed when nothing follows.
### Playlist folders — your playlists, organised
- Folders on the **Playlists** page and in the sidebar keep long lists tidy — group by mood, occasion, or anything you like. Drag playlists in, rename and collapse folders, or choose **Move to folder** from the right-click menu. Switch back to a flat list whenever you prefer.
### Settings — tidier and easier to scan
- Settings are grouped into clear, labelled panels so related options sit together — less hunting around. The **Native Hi-Res Playback** option now explains in plain language what it actually does.
- **Normalization** and **Track transitions** are now their own sections under **Settings → Audio**, and the queue options (display mode, toolbar, and Play-Next order) are gathered into one **Queue Settings** group under **Personalisation**.
### Japanese, Hungarian, and Polish — now in your language
- Psysonic is now available in **Japanese (日本語)**, **Hungarian (Magyar)**, and **Polish (Polski)** — pick any of them from the language menu on the **Settings** and **Login** screens.
### Theme store — spot updates, pick your style
- Version numbers on store themes and ones you have installed make it obvious when an update is ready.
- Filter for **animated** or **static** themes only — less scrolling when you already know the look you want.
### Hi-Res playback — smoother transitions between sample rates
- Under **Settings → Audio → Native Hi-Res**, choose a **blend rate** (44.1 / 88.2 / 96 kHz) for crossfade, AutoDJ, and gapless when adjacent tracks differ in sample rate — mixed 88.2 ↔ 44.1 kHz handovers no longer tear mid-transition.
### Artist artwork — richer home, artist, and fullscreen views
- Switch on **External Artwork Scraper** under **Settings → Integrations** to pull artist imagery from fanart.tv: a wide backdrop on the fullscreen player, a banner across the top of the artist page, and now the artist's backdrop behind the home screen's **mainstage** too. Off by default, your Navidrome covers stay in charge, and turning it back off removes the fetched images again.
- Choose which images each place uses as its background, and in what order — drag to reorder or switch a source off — right under the same setting. The mainstage also loads the next backdrops ahead of time so they appear without a blank gap.
### Equalizer — a profile per output device
- Turn on **Remember EQ per device** under **Settings → Audio** and Psysonic keeps a separate equalizer setup for each output — speakers, headphones, a USB DAC — and switches to the right one automatically when you change devices, including when **System Default** is selected and the OS default output changes outside the app. Off by default.
### Orbit — everyone hears transitions the host chose
- In a shared **Orbit** session, the host's crossfade, gapless, or AutoDJ settings — including length and smooth skip — apply to all guests until you leave. Transition controls in **Settings → Audio** and the queue toolbar show as host-controlled while you are a guest.
### Themes — follow your system's light and dark mode
- The theme scheduler can now match your **system's light/dark setting** instead of a fixed clock: pick a light theme and a dark one, and Psysonic switches along with your OS. Choose **Time of Day** or **System Theme** under **Settings → Themes** — the existing time-based schedule is still there.
### Servers behind a reverse proxy — custom HTTP headers
- Per-server **custom HTTP headers** in **Settings → Servers** for Cloudflare Access, Pangolin, and similar gates — applied to library sync, playback, covers, offline download, and the rest without putting secrets in invite links.
### Album details — every genre, not just the first
- Album details now show **all** the genres a release spans: the main genre appears inline with a **+N** chip that opens the full, clickable list, each genre linking to its own page. Genres combine album and track tags and read from the local library index, so they work offline too.
### Compact buttons — switch to icon-only controls
- New **Compact buttons** option under **Settings → Appearance** switches the action and toolbar buttons between large labelled buttons and small icon-only ones — across album, artist and playlist headers, the shared browse toolbars, and the Most Played controls. Defaults to large; on phones the album header keeps its large touch targets.
### Playlists — sort by date added
- Sort a playlist by **Date added** (newest or oldest first), or by title, artist, album and the other columns, from a new sort dropdown in the playlist toolbar. The Subsonic API has no per-track "added on" date, so this follows the playlist's own order — servers add new tracks at the end, so newest-first puts your latest additions on top.
## Improved
- **macOS:** the window's title bar now follows the active theme instead of the grey system bar; the native window buttons stay in place, floating over the themed bar.
- Pressing **Play**, **Shuffle**, or **Add to queue** on a playlist starts playback without reloading the whole page with a spinner — editing the playlist still refreshes the list as before.
- Dragging sidebar items in **Settings → Personalisation → Sidebar** (or long-pressing in the sidebar itself) keeps each item exactly where you release it — no snap-back or off-by-one landing.
## Fixed
### Playback and audio
- **Timeline** mode keeps your session play-history strip when you **Play** an album or playlist; the current track stays pinned at the top, and replaying a history row inserts after the playing track instead of replacing the queue.
- **Opus/Ogg** tracks no longer fight the seekbar while they are still loading — scrub to where you want to be and keep listening.
- The equalizer preset picker shows the active **AutoEQ** profile name again instead of going blank.
### Offline, Now Playing, and Navidrome
- The **Live** listener count in the header stays up to date even when the "Who is listening?" popover is closed.
### Browse and library
- Album and artist covers — and the full-size view when you click a cover — open at full resolution again instead of looking soft or small.
- Albums sorted by artist now list each artist's work A–Z by title — no more random order within a name.
- **Artist → Year** keeps artists grouped but walks through their albums chronologically, oldest first.
- Genres with no remaining tracks disappear after you retag and resync the library, without restarting the app.
- The **Artists** A–Z index matches Navidrome ignored articles — **The Beatles** lands under **B**, not **T**.
- **All Albums → Only compilations** and **Favorites** return the albums you expect instead of an empty or partial list.
### Player and playlists
- **Add to playlist** from the player bar adds the song you are hearing, not the whole album.
- On **Favorites**, bulk **Add to playlist** and **Play selected** / **Add selected to queue** act on every checked row.
- **Play Now** on a playlist in the right-click menu starts playback instead of only opening the list.
- Playlists page header buttons wrap on narrow windows instead of clipping off-screen when the queue panel is open.
### Other
- **Orbit** sessions stay reliable on long listens — guests keep receiving updates, radio no longer pollutes the shared queue, and opening Psysonic on a second device does not delete a live session elsewhere.
- On the artist page, the header uses the fanart.tv background when no banner is available — the same image the fullscreen player already showed.
- **Windows:** Previous, Play/Pause, and Next are back when you hover the taskbar icon — and Play/Pause shows whether music is playing or paused.
- **macOS:** the dock icon matches native app sizing instead of looking oversized.
- **Linux:** **Niri** is recognised as a tiling compositor and gets the same custom title bar behaviour as Hyprland and Sway; the "new version available" popup reads clearly on setups where the background blur used to bleed through.
## Under the hood
- If a screen hits an unexpected error, the app now shows a small recoverable card (**Try again** / **Reload app**) and keeps playing, instead of the whole window going blank.
## [1.48.1]
## Fixed
### Playback and audio
- Changing tracks — skipping, or the automatic advance at the end of a song — no longer freezes the interface for a few seconds: the progress bar and lyrics keep updating, and on **Windows** a change of output device now takes effect right away.
- Seeking an **Opus/Ogg** track — and then pressing **Stop** — no longer crashes the app.
- **macOS:** pausing or stopping playback and then unplugging headphones (or switching the output device) no longer makes playback restart — it stays paused or stopped.
### Offline, Now Playing, and Navidrome
- On large **Navidrome** libraries, background library sync no longer locks up database writes for minutes at a time, so play history, ratings, and other saves go through without long delays.
### Themes and integrations
- **Discord** Rich Presence shows the album cover again when a server profile has both a local and a public address.
### Other
- **Windows:** the system media controls (Quick Settings media tile, lock screen, and third-party flyouts) now show the album cover and display **Psysonic** with its icon instead of "Unknown application".
- **macOS:** closing the window with the red close button now respects **Minimize to Tray** — with it on, the window hides to the tray instead of quitting.
## [1.48.0]
## Highlights
### Offline listening
- When the server is unreachable, browse and detail pages show what you already have locally instead of empty errors — albums, artists, playlists, and cross-server favorites.
- Starred tracks, pinned albums, and playlists live under one **media** folder; browse them in **Offline Library** and see disk usage at a glance.
- **Favorites auto-sync** keeps loved songs on disk; pinned albums and playlists refresh when the library index updates.
### Music Network — scrobble beyond Last.fm
- **Settings → Integrations** now hosts a **Music Network**: connect **Last.fm**, **Libre.fm**, **ListenBrainz**, **Maloja**, **Rocksky**, **Koito**, or your own **GNU FM** instance — and scrobble to several at once.
- Pick a **primary** service for loved tracks, similar artists, and stats; other connections still receive scrobbles. Your existing Last.fm setup migrates automatically.
- A master switch turns the whole network on or off.
### Theme Store
- Browse and install community themes from **Settings → Themes** — search, dark/light filter, full-size previews, and sort by popularity or date.
- Six palettes ship with the app; everything else installs on demand and works offline after the first download.
- **Now Playing** follows every theme cleanly, including light palettes.
- Import a theme from a local `.zip` when you have a package from a friend or your own design.
- The sidebar nudges you when an installed theme has an update; one-click update from the theme card.
### Fullscreen player
- Rebuilt for much lower CPU and memory use: a calm, sharp fullscreen view with album art, waveform seekbar, up-next queue, synced lyrics, ratings, and a clock that follows your **Clock format** setting.
- The song title no longer shows a leading track number, and descenders (g, j, p, q, y) are no longer clipped.
### Live — richer now playing on Navidrome 0.62+
- On servers with OpenSubsonic **playbackReport** (Navidrome ≥ 0.62), **Live** shows who is playing or paused, how far into the track they are, and playback speed when another client sends it — with smooth position updates between refreshes.
- In **Who is listening?**, each listener shows a small status dot (playing, paused, or idle) instead of a vague “minutes ago” line.
### Queue — Timeline mode
- A third queue layout keeps the current track in the middle with history above and up next below — great for long listening sessions. Cycle the header control or pick it in **Settings → Personalisation → Queue display**.
### Settings → Servers
- Each card shows the server software and version (e.g. **Navidrome 0.62.0**) under the name, with a cleaner two-line layout and compact actions.
- Navidrome **0.62+** shows a green **AudioMuse-AI** badge when the plugin is detected — no manual toggle on current Navidrome.
### Sidebar — pin Now Playing to the top
- New **Settings → Sidebar** toggle moves **Now Playing** to the top of the sidebar instead of the bottom (off by default).
### Startup
- A themed loading splash appears while the app starts — colours follow your active theme, including community palettes.
## Improved
- Audio decoding runs on **Symphonia 0.6**; streams start sooner and recover from stalls without restarting the player.
- The **Preload Next Track** toggle under **Settings → Storage → Buffering** is gone — playback no longer waits on that extra RAM prefetch. Gapless, crossfade, and Hot Cache behave as before.
- New **Semitones** playback-speed strategy (±12 st, 0.1 step) with two-decimal speed readout; optional fine steps in **Settings → Audio → Advanced**.
## Fixed
### Playback and audio
- **Windows:** the app no longer keeps the audio device open while idle, so the system can sleep when music is not playing.
- **macOS:** steady playback stutter from background device polling is gone on the default output path.
- After a long pause, the seekbar shows the saved position immediately and the next **Play** resumes without an audible blip at track start.
- **Stop** keeps the real waveform on the seekbar instead of falling back to flat bars.
### Offline, Now Playing, and Navidrome
- Now Playing cards (**from this album**, discography, most played) stay populated during cached and offline playback instead of blanking out on track change.
- Navidrome **Show in Now Playing** and play-count scrobbles work when audio plays from hot cache, offline pins, or auto-synced favorites.
- Mixed-server queues still report to the correct Navidrome server.
### Themes and integrations
- Self-hosted Music Network targets (Koito, Maloja, custom GNU FM with a pasted token) scrobble again — reconnect once if you connected before this fix.
- Favoriting from the player bar, fullscreen player, or shortcuts updates the star in track lists and playlists immediately.
- Discord Rich Presence shows album art again when covers come from the server.
- Focus rings and dropdown borders follow the active theme consistently.
### Browse and library
- Tracks tagged with several genres in one field (e.g. `Metal/Ambient/Experimental`) match **each genre** again in browse, filters, and search.
- **All Albums → Only compilations** returns results for common tagging patterns.
- Album grids show the album artist on compilations instead of a random track artist.
- Song rails (**Random Picks**, **Discover Songs**, etc.) link each name in multi-artist credits separately.
- **Artist → Top Tracks** play works even when the artist page has no albums loaded yet.
- **Home → Most Played** no longer jumps the page when you load more albums.
- **Mainstage** hero backdrop stays in sync when you skip albums quickly.
### Other
- **Linux:** the `curl | bash` auto-installer works again.
- **Linux:** internet radio no longer appears twice in the desktop now-playing overlay.
- On Navidrome **0.62+**, add/edit/delete radio stations is shown only to admin accounts; everyone can still play and favourite stations.
- **Linux custom title bar:** pick window button styles (dots, flat, pill, and more) and optionally hide minimize in **Settings → Appearance**.
- The active server card under **Settings → Servers** draws a complete border on all sides.
## Under the hood
- Navidrome **0.62+** auto-detects **AudioMuse-AI** and routes Instant Mix / Lucky Mix through the smarter API when the plugin is present — older Navidrome keeps the manual toggle you already know.
@@ -85,25 +85,27 @@ environment.systemPackages = with pkgs; [
];
```
### Linux wrapper: default vs gdk-session
### Linux wrapper (default vs legacy X11)
The flake exposes **two** installable packages on Linux. They are the same build; only the **wrapped runtime environment** differs:
The flake exposes **three** Linux attributes (two are the **same derivation**):
| Flake attribute | Wrapper behaviour |
|----------------|-------------------|
| **`psysonic`** (and **`default`**) | Sets **`GDK_BACKEND=x11`** together with the usual WebKit / GStreamer / AppIndicator paths. This is the **recommended default**: it matches the dev shell assumptions and avoids many WebKitGTK + Wayland edge cases. |
| **`psysonic-gdk-session`** | **Does not** set `GDK_BACKEND`; GTK follows the session (e.g. native Wayland when available). Can improve **HiDPI sizing** on some desktops, but may cause **black window, broken scrolling, or tray quirks** on other GPU/compositor stacks—the same class of issues described under Linux / WebKit in the in-app Help. **Not default** on purpose. |
| **`psysonic`**, **`default`**, **`psysonic-gdk-session`** | Wrappers prefix **libraries only** (**GStreamer**, **AppIndicator**); **`GDK_BACKEND`** is **not** pinned. The binary invokes **`webkit2gtk-nvidia-quirk`** early on Linux (unless **`PSYSONIC_WEBKIT_GPU_ACCEL`** is set); no extra **`WEBKIT_DISABLE_*`** heuristics in **`main.rs`**. Override with **`GDK_BACKEND`**, **`WEBKIT_DISABLE_*`**, etc. whenever you want. |
| **`psysonic-x11-legacy`** | Former default: **`GDK_BACKEND=x11`** pinned in the wrapper. Use if you relied on **XWayland-ish** stability on messy stacks. Same binary as **`psysonic`**. |
Use the alternate package when you understand that trade-off:
`psysonic-gdk-session` remains a **back-compat alias** for **`psysonic`** (identical store path).
Or one-shot (quote the URL in **zsh** — `?` / `#` are special):
```bash
nix run 'github:Psychotoxical/psysonic#psysonic-gdk-session' -- --help
nix run 'github:Psychotoxical/psysonic#psysonic-x11-legacy' -- --help
```
### Pinning a revision, branch, or tag
@@ -142,7 +144,7 @@ From any machine with flakes:
nix run 'github:Psychotoxical/psysonic'
```
Same as `nix build` / `packages.<system>.default` (the **x11-wrapped** binary); uses the flake `apps` output. For the session-GDK variant, use `'github:Psychotoxical/psysonic#psysonic-gdk-session'` (see [Linux wrapper](#linux-wrapper-default-vs-gdk-session) above). With a branch pin, keep the **whole** `github:…?ref=…#…` string in **single quotes** under **zsh**.
Same as `nix build` / `packages.<system>.default` (session-native **GDK**); uses the flake `apps` output. For an **X11-pinned** launcher (old default), use `'github:Psychotoxical/psysonic#psysonic-x11-legacy'` (see [Linux wrapper](#linux-wrapper-default-vs-legacy-x11) above). `psysonic-gdk-session` is an **alias**—same as **`psysonic`**. With a branch pin, keep the **whole** `github:…?ref=…#…` string in **single quotes** under **zsh**.
### Apply configuration
@@ -179,8 +181,6 @@ From a **flake-enabled** clone of the repo:
The flake **`devShell`** uses the same **`nixpkgs`** input as **`packages.psysonic`** (see **`flake.nix`**).
Optional **local-only** helpers (`dev.sh`, `shell.nix`, `prod.sh`) are **gitignored** — not part of the upstream tree; keep your own copies if you use them (e.g. a small `dev.sh` that runs `nix develop` and `npm run tauri:dev`).
## Desktop entry
The flake package installs a **`.desktop`** file and icon via `copyDesktopItems`; after `nixos-rebuild switch` (or a Home Manager activation that includes the package), Psysonic should appear in your application launcher like any other desktop app.
"build:verify":"npm run build && npm run check:boot-chunks",
"test":"npm run prebuild:release-notes && vitest run && node --test scripts/extract-release-section.test.mjs scripts/wix-bundle-version.test.mjs scripts/sync-version-pipeline.test.mjs && npm run check:css-imports && npm run dep:check:barrels",
"test:watch":"vitest",
"test:coverage":"vitest run --coverage && npm run check:css-imports"
"test:coverage":"npm run prebuild:release-notes && vitest run --coverage && npm run check:css-imports"
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.