1661 Commits

Author SHA1 Message Date
Psychotoxical c7a01e7d1f chore(aur): bump PKGBUILD to 1.50.0 (#1325) 2026-07-20 00:35:05 +02:00
github-actions[bot] d663c8a0b5 chore(release): bump main to 1.51.0-dev (#1324)
* chore(release): bump main to 1.51.0-dev

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

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-20 00:16:50 +02:00
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 f9760fe8a8 fix(dev): push manifest-only changes in theme-watch (#1318) 2026-07-17 02:19:40 +02:00
Psychotoxical 4e876f6e12 fix(ci): tolerate transient GitHub API errors in ci-ok aggregate (#1317) 2026-07-17 02:16:02 +02:00
Psychotoxical 51b12eeec0 feat(dev): toast on successful theme-watch sync (#1316) 2026-07-17 02:12:39 +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
Psychotoxical e548c47078 fix(dev): real metadata and session-only installs for theme-watch (#1314)
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.
2026-07-16 23:06:27 +02:00
Psychotoxical fa390e9211 feat(dev): extend --theme-watch to a themes-repo checkout (#1313)
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.
2026-07-16 22:24:13 +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 108a1fb74a chore(nix): refresh nixpkgs pin in flake.lock (#1281) 2026-07-13 06:05:17 +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 ed99c8f2f7 test(lyrics): cover the embedded word-lyrics chain (#1268)
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.
2026-07-10 04:06:43 +02:00
Psychotoxical 199eb24465 chore(audio): satisfy clippy::question_mark on the hi-res spill read (#1269)
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.
2026-07-10 03:58:08 +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
cucadmuh 4bae7005cd fix(sync): self-heal failed play-queue push without a nagging LED (#1264) 2026-07-09 17:46:07 +03:00
cucadmuh 51d08b7861 docs(contributing): document frontend layering contract (#1263) 2026-07-09 16:51:11 +03:00