448 Commits

Author SHA1 Message Date
github-actions[bot] 4e05d32b88 chore(release): finalize release version 1.50.0 2026-07-19 21:35:05 +00:00
github-actions[bot] dc7addae03 chore(release): bump next channel to 1.50.0-rc.4 2026-07-19 21:01:38 +00: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 f9760fe8a8 fix(dev): push manifest-only changes in theme-watch (#1318) 2026-07-17 02:19:40 +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
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 6d9018e6b6 fix(tray): blank Mainstage after cold-start minimized to tray (#1303) 2026-07-15 11:05:43 +03: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 9ca762dfd6 feat(ui): album art thumbs in track lists via standard cover pipeline (#1280) 2026-07-13 01:16:55 +00: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 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 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 89140523dc chore(build): bind Windows cargo-test binaries to Common-Controls v6 manifest (#1257)
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.
2026-07-07 18:55:41 +02: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
cucadmuh 80a84481c9 fix(album): album favorite heart and server-backed album rating on detail (#1247) 2026-07-06 14:33:35 +03:00
cucadmuh 4e6b58967c feat(library): multi-library filter — browse & search across selected libraries (#1241) 2026-07-06 03:02:15 +03:00
cucadmuh 9557f8e10a feat(cli): relative volume and quieter CLI startup (#1238) 2026-07-05 14:57:39 +03:00
cucadmuh f48089e222 fix(artists): case-insensitive browse search for Cyrillic names (#1237) 2026-07-05 14:19:44 +03:00
cucadmuh bf0e73ce6c feat(artists): album vs track credit mode browse (#1209) (#1232) 2026-07-04 17:39:58 +03:00
Psychotoxical 77ab95170d refactor(ipc): complete tauri-specta typed-IPC cutover, contract guards, and layering cleanup (#1230) 2026-07-04 17:00:31 +03:00
cucadmuh fdbb9deac6 fix(playback): ReplayGain prefetch, gapless UI sync, library peak column (#1231) 2026-07-04 16:00:03 +03:00
Psychotoxical 751ad35365 feat(ipc): typed bindings for library catalog/genre browse commands
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.
2026-07-01 13:52:39 +02:00
Psychotoxical c976b79c7d chore(ipc): bootstrap tauri-specta generator (1 command, no cutover)
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.
2026-07-01 13:34:55 +02:00
github-actions[bot] c9fa69a0ad chore(release): bump main to 1.50.0-dev (#1212)
* chore(release): bump main to 1.50.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-06-29 02:47:22 +02:00
Psychotoxical 184501744b fix(cover): restore full-resolution album and artist covers (#1205)
* 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.
2026-06-28 04:23:12 +02:00
cucadmuh 281e86fd3b fix(queue): persist timeline play history across queue replace (#1096) (#1204)
* fix(queue): persist timeline play history across queue replace (#1096)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Replace in-place push with a fresh array so getSnapshot returns a new
reference and React re-renders on live appends without a playerStore update.
2026-06-28 02:08:02 +00:00
Psychotoxical ed02ba4e1d feat(titlebar): theme-colored window bar on macOS (#1199)
* 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
2026-06-27 14:17:55 +02:00
cucadmuh 7b9e676af7 chore(library): CJK artist name_sort regression tests (#1178) 2026-06-24 23:27:26 +03:00
cucadmuh ec98fcc4ff fix(genres): hide empty genres after library resync (#1176) 2026-06-24 23:07:22 +03:00
cucadmuh 58e6efc68c feat(autodj): configurable overlap cap (auto or 2–30 s) (#1173) 2026-06-24 22:00:35 +03:00
Psychotoxical c3e6be537c fix(artist): show fanart in the artist-detail hero, not the Navidrome cover (#1172)
* 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)
2026-06-24 20:41:16 +02:00
cucadmuh 09f24beb32 feat(audio): hi-res transition blend rate for crossfade, AutoDJ, and gapless (#1171) 2026-06-24 21:04:42 +03:00
Psychotoxical 53a4bf9330 fix(macos): pad dock icon to Apple icon grid (#1169)
* 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)
2026-06-24 16:00:15 +02:00
cucadmuh 15cecb5d7d feat(server): custom HTTP headers for reverse-proxy gates (#1095) (#1156) 2026-06-22 16:25:28 +03:00
cucadmuh 9a31fe8295 fix(library): avoid UTF-8 panic in artist article strip (#1152) 2026-06-22 03:12:45 +03:00
cucadmuh d9969ed76f fix(library): Navidrome artist letter buckets + safer index open/swap (#1144) (#1145) 2026-06-21 20:55:35 +03:00
Psychotoxical a5313d5cb1 fix(cover): embed fanart.tv project key so from-source builds work (#1139)
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.
2026-06-20 23:03:12 +02:00