518 Commits

Author SHA1 Message Date
Psychotoxical c74e8f1921 fix(api): align native client UA with the WebView to collapse the duplicate Navidrome session (#1322)
* fix(api): align native client UA with the WebView to collapse the duplicate Navidrome session

* docs(changelog): duplicate Navidrome session fix (#1322)
2026-07-17 13:56:18 +02:00
Psychotoxical 7dfe58dba6 feat(artist): add "add to queue" for the whole discography (#1321)
* feat(artist): enqueue whole discography from artist page

* i18n(artist): add enqueue-all button strings

* docs(changelog): artist discography enqueue (#1321)
2026-07-17 12:37:25 +02:00
Psychotoxical 6759ee40e2 fix(themes): square corners for player bar cover and list thumbnails (#1320)
* fix(themes): square corners for player bar cover and list thumbnails

* docs(changelog): add square corners coverage fix
2026-07-17 03:33:27 +02:00
Psychotoxical fa69d11885 fix(i18n): translate cover art settings keys across locales (#1319)
* fix(i18n): translate cover art settings keys across locales

* docs(changelog): add cover art settings translation fix
2026-07-17 03:23:32 +02:00
Psychotoxical 8610b2230e perf(themes): gate inactive injected theme styles out of style matching (#1315)
* 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
2026-07-16 23:40:47 +02:00
cucadmuh 25b8b57328 revert: multi-server library scope (#1309) (#1310) 2026-07-16 13:56:08 +03:00
cucadmuh 599ac31306 feat(library): add unified multi-server library scope (#1309)
* feat(library): add multi-server scope foundation

* feat(library): wire unified multi-server browse

* feat(library): complete multi-server ownership flows

* fix(library): harden multi-server ownership

* fix(library): close multi-server edge cases

* docs: add multi-server library release notes
2026-07-16 08:10:29 +03:00
cucadmuh 16aee64d66 feat(playlist): scoped header search on Playlists browse (#1308) 2026-07-16 04:30:41 +03:00
Psychotoxical fa1dc1a328 feat(playlist): add play and queue actions to the playlist card context menu (#1307)
* 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)
2026-07-16 01:41:22 +02:00
cucadmuh 0df547e3be docs(release): consolidate 1.50.0 CHANGELOG and WHATS_NEW (#1304) 2026-07-15 11:51:31 +03:00
Psychotoxical 9509b78073 fix(library): sort albums by the artist the row actually shows (#1217) (#1292)
* 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`.
2026-07-15 10:48:40 +02:00
cucadmuh 1b73e929f0 fix(api): psysonic/undefined client id on Windows release builds (#1290) 2026-07-15 11:15:05 +03:00
cucadmuh 6d9018e6b6 fix(tray): blank Mainstage after cold-start minimized to tray (#1303) 2026-07-15 11:05:43 +03:00
Psychotoxical 0a52e875a2 fix(settings): revalidate the registry behind the theme credits (#1302)
* 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)
2026-07-14 18:26:41 +02:00
Ali Mahmmoud ab70cbd528 fix(a11y): use useId() for Modal aria-labelledby and add test (#1301)
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.
2026-07-14 17:50:52 +02:00
Psychotoxical 1e8db450c4 fix(ui): reserve shadow room in album rails so themes need no overflow hack (#1300)
* 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)
2026-07-14 16:15:03 +02:00
Psychotoxical a451509d94 feat(discord): restore server cover source via public album-info URLs (#1299)
* 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.
2026-07-14 13:52:05 +02:00
cucadmuh 0331ac173d fix(tray): second-instance restore stays generic over Runtime (#1298) 2026-07-14 10:58:49 +03:00
cucadmuh 2e023fb8d3 fix(tray): restore sidebar after cold-start minimized to tray (#1296) 2026-07-14 10:08:33 +03:00
cucadmuh 398adaf214 fix(cover): playlist and radio custom covers (fetch-only getCoverArt ids) (#1295) 2026-07-14 08:47:08 +03:00
cucadmuh fd19419ac2 fix(ui): no cover thumbs on album detail tracklist (#1291) 2026-07-14 00:44:42 +03:00
Psychotoxical f5ddb28d05 fix(discord-banner): give the icon an explicit size so it does not blow up on Windows (#1289)
* 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
2026-07-13 22:53:59 +02:00
Psychotoxical 163e0e46a8 feat(player-bar): persistent shuffle mode (#1288)
* 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
2026-07-13 22:16:38 +02:00
Psychotoxical d0edd925e4 feat(player-bar): configurable stop button, album line and reorderable actions (#1287)
* 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
2026-07-13 21:29:46 +02:00
Psychotoxical dac7bf56d9 fix(music-network): surface the underlying cause of a connect NETWORK error (#1285)
* 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
2026-07-13 20:27:48 +02:00
cucadmuh 52d927ca0b feat(radio): Web Audio EQ on HTML5 streams (fixes #1276) (#1284) 2026-07-13 15:36:16 +03:00
cucadmuh efb8e5782c docs(release): CHANGELOG order and WHATS_NEW for 1.50.0 (#1282) 2026-07-13 06:20:06 +03:00
cucadmuh 9ca762dfd6 feat(ui): album art thumbs in track lists via standard cover pipeline (#1280) 2026-07-13 01:16:55 +00:00
cucadmuh ad5d88d105 feat(queue): adapt toolbar for Navidrome public share queues (#1279) 2026-07-12 23:22:36 +03:00
cucadmuh 6e448dcc3c fix(build): WiX MSI version mapping and album dynamic import (#1278) 2026-07-12 22:45:22 +03:00
cucadmuh 54bc9814f1 fix(windows): startup hang, stable device IDs, boot barrel guards (#1277) 2026-07-12 21:28:58 +03:00
cucadmuh 097438f9da feat(share): open Navidrome public share links for anonymous playback (#1275) 2026-07-12 17:21:03 +03:00
cucadmuh 83aaf253cc feat(eq): follow OS default output for per-device EQ (#1233) (#1274) 2026-07-12 01:08:02 +03:00
cucadmuh 96530f244e feat(servers): connect and run fully behind a custom-header gate (#1273)
* 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.
2026-07-11 16:17:17 +03:00
cucadmuh 1625f4a8be feat(settings): start minimized to tray on cold launch (#1271) 2026-07-11 01:41:44 +03:00
Psychotoxical 51140c613a fix(lyrics): read SYNCEDLYRICS from the concrete Vorbis comment block (#1267)
* 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)
2026-07-10 04:07:03 +02:00
Psychotoxical c10b13944b fix(lyrics): strip Enhanced LRC word markers from displayed text (#1266)
* 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)
2026-07-10 03:17:09 +02:00
Psychotoxical a307f04b88 feat(lyrics): word-level lyrics from OpenSubsonic songLyrics v2 (#1265)
* 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.
2026-07-10 02:57:49 +02:00
norperz 8e0e707544 fix(sync): use formPost for large savePlayQueue to avoid HTTP 414 (#1262) 2026-07-09 16:50:35 +03:00
cucadmuh fbd3aaa094 fix(library): prune orphaned cluster identity keys on rebuild (#1255) 2026-07-07 19:15:36 +03:00
cucadmuh 6445e12eb4 fix(library): heal stale album-artist reference on resync (#1256) 2026-07-07 19:08:36 +03:00
cucadmuh 15d9f2bd4e fix(library): #1252 Random Albums — artist links + missing tile cover art (#1254) 2026-07-07 18:22:22 +03:00
cucadmuh 9a0e388e4e fix(library): prune orphaned artist/album rows after resync (#1253) 2026-07-07 16:27:21 +03:00
Daniel Aquino fc56e0823b feat(i18n): add Italian translation (#1250)
Full Italian (Italiano) UI translation, selectable from the language picker on the Settings and Login screens.
2026-07-07 00:27:41 +02:00
Psychotoxical fa7034f100 Fullscreen player — Prism style (#1251)
* 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)
2026-07-06 18:14:45 +02:00
Psychotoxical e25f904b04 feat(fullscreenPlayer): selectable Minimal and Immersive fullscreen player styles (#1249)
* 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
2026-07-06 16:49:51 +02:00
Psychotoxical 01633e3501 feat(themes): credit community theme authors and refine card what's-new (#1248)
* 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)
2026-07-06 13:59:22 +02:00
cucadmuh 80a84481c9 fix(album): album favorite heart and server-backed album rating on detail (#1247) 2026-07-06 14:33:35 +03:00
Psychotoxical f24d605ca0 fix(discord): drop server cover source that leaked credentials (#1246)
* 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)
2026-07-06 12:43:06 +02:00
cucadmuh c8d2a55e86 fix(albums): keyboard year filter entry without per-keystroke clamp (#1244) 2026-07-06 12:09:16 +03:00