Compare commits

...

372 Commits

Author SHA1 Message Date
Psychotoxical 213431ccd9 fix(library): order album detail tracks by disc then track number (#1332)
* fix(library): order album detail tracks by disc then track number

Multi-disc album detail ordered tracks by track_number before disc_number, so
Play-All queued them interleaved (D1T1, D2T1, D1T2, D2T2, ...) instead of disc 1
in full, then disc 2. Order by (disc_number, track_number), matching the existing
convention in repos/track.rs. Adds a regression test.

* docs(changelog): note multi-disc album play order fix (#1332)

* fix(library): treat missing disc number as disc 1 in album ordering

Follow-up review. Album track ordering sorted NULL disc numbers last, but the
album UI renders a missing disc as disc 1 (discNumber ?? 1), so an untagged
disc-1 track was shown under Disc 1 while Play-All started with an explicit
disc 2. Order both album-track loaders — the scoped merge query and the direct
find_by_album query — by COALESCE(disc_number, 1), with id as the final
tie-break so equal disc/track metadata is deterministic across both paths.
Extends the regression test with a NULL-disc track and a duplicate disc/track
pair.

* fix(library): make album track order total across servers, cover direct loader

Re-review follow-up. The scoped album loader's `id` tie-break is only
server-local (the track primary key is (server_id, id)), so two cross-server
tracks surviving a merged album could tie on disc/track/id and be returned in an
arbitrary order. Add `server_id ASC` as the final key in both album-track loaders
so the Play-All order is total. Strengthen the direct find_by_album test to cover
the missing-disc (COALESCE) and duplicate-position (id) cases, and add a
multi-server regression asserting the tie resolves deterministically by server_id.
2026-07-23 00:11:31 +02:00
Psychotoxical d446de031f fix(library): group artist detail releases by type again (#1329)
* fix(library): group artist detail releases by type again

The multi-scope artist detail builds albums from tracks and keeps the album raw_json NULL, so the DTO lost the releaseTypes the network getArtist used to provide and the artist page collapsed its Albums / Singles / EPs / Live / Compilations grouping into one flat list. Derive each album release type set from a representative track raw_json.tags.releasetype (Navidrome MusicBrainz RELEASETYPE tag), order preserved, so the existing frontend grouping works again.

* docs(changelog): note artist detail release-type grouping fix (#1329)

* fix(library): also read top-level OpenSubsonic release types

* fix(library): validate artist release-type candidates before precedence

Re-review follow-up. The release-type lookup treated any non-null JSON value
as a candidate before validating it, which had three effects:

- an empty or malformed top-level OpenSubsonic `releaseTypes` (the S2 ingest
  copies empty album arrays verbatim) suppressed a valid nested
  `tags.releasetype`, leaving the album ungrouped;
- the representative-track subquery stopped at the first merely non-null value
  with no ordering, so an unusable track could hide a valid one on the same
  album, non-deterministically;
- only "non-empty array" was checked, so non-string members (e.g. `["EP",null]`)
  reached ArtistDetail.tsx, where `r.toLowerCase()` throws during render.

Select only a non-empty array of strings, validated per representation before
precedence and before `LIMIT 1`, with a deterministic order; the top-level API
field stays preferred when itself usable, else it falls back to the nested one.
The row now reuses the shared `map_album_list_row` + `album_row_to_dto` mapper
and attaches the validated array, removing the duplicate DTO construction path.
Adds regression coverage for empty top-level, hidden-valid-track, and
non-string members.

* fix(library): guard release-type lookup against malformed track raw_json

Follow-up review finding. `usable_release_types_expr` called SQLite JSON1
functions (json_type/json_each/json_array_length/json_extract) directly on
track.raw_json, which is unconstrained text the library tolerates as invalid
(LibraryTrackDto::from_row maps invalid JSON to Value::Null). Inside the
per-album correlated lookup, one malformed row raised `malformed JSON` and
aborted the whole artist-detail query before a later valid track could win.

Wrap candidate evaluation in a lazy `CASE WHEN json_valid(raw_json)` guard so a
malformed row contributes no release types. Adds a regression test with a
malformed earlier track and a valid later track on one album — it fails with
`malformed JSON` without the guard.
2026-07-22 22:18:26 +02:00
cucadmuh abeed041db fix(library): repair multi-server scope fallback and add diagnostics (#1331) 2026-07-22 22:35:48 +03:00
Psychotoxical 99d12fe4f0 fix(library): show canonical artist name on artist detail header (#1328)
* fix(library): show canonical artist name on artist detail header

Multi-scope artist detail derived the header name from MAX(t.artist) over the artist tracks, so one track carrying a per-track featured-guest credit renamed the whole artist. Read the canonical name from the artist row per (server_id, id) instead, matching the browse list; fall back to the track-derived name when no artist row exists.

* docs(changelog): note artist detail canonical-name fix (#1328)
2026-07-22 11:44:10 +02:00
cucadmuh fa9baa7cba chore(deps): resolve Dependabot security alerts (#1330) 2026-07-22 12:05:22 +03:00
cucadmuh 422e08afad Merge pull request #1326 from Psychotoxical/perf/rebuild-multi-server-scope
feat: add true simultaneous multi-server support
2026-07-22 03:08:29 +03:00
cucadmuh 06c58b3399 fix(new-releases): dedupe freshness overlay 2026-07-22 02:56:53 +03:00
cucadmuh df7bf8ef3b fix: harden delayed home rail updates 2026-07-22 01:46:35 +03:00
Psychotoxical e701f0f155 fix(home): apply late New/Latest rail results instead of dropping them
The chronological New/Latest loaders used the shared 4s request timeout, so a correct-but-slow local read was discarded and the rails stayed empty until a manual toggle. Give them a dedicated generous timeout, decouple the Hero/loading state from them so they patch in on arrival, and re-run the feed when the local index master flag flips.
2026-07-21 23:54:34 +02:00
Psychotoxical 80ba158410 fix(home): stop the new-releases badge starving the Home rails
The sidebar unread-badge check ran loadLocalNewReleases with includeGenreCounts=true, paying a ~3s genre aggregation it never reads on every mount, scope change and poll. Those runs serialize on the single mainstage read connection and delayed the Home New/Latest rails by ~15s. Skip genre counts for the badge and debounce burst refreshes into one trailing run.
2026-07-21 23:54:33 +02:00
Psychotoxical 8feeb43e27 fix(library): keep new-release rank rebuild on partial track indexes
capture_invalidated_rank_partitions joined track without a deleted = 0 predicate, so the partial idx_track_album / idx_track_artist indexes were unusable and the rebuild fell back to a full primary-key scan that held the write connection and froze the GUI on large multi-server libraries. Add deleted = 0 to the three CROSS JOIN track clauses so the partial indexes apply.
2026-07-21 23:54:33 +02:00
cucadmuh 4d0d4728fd fix(library): refresh planner stats after bulk sync 2026-07-21 20:47:05 +03:00
cucadmuh 1dfd20ebd6 fix(ui): reduce live indicator idle CPU 2026-07-21 16:32:19 +03:00
cucadmuh 8f27b0a8dd fix(ui): flag unavailable switch targets 2026-07-21 14:53:07 +03:00
cucadmuh 831a6b701c fix(ui): refine server choice popovers 2026-07-21 14:39:11 +03:00
cucadmuh a6adc8299d feat(orbit): isolate sessions to selected server 2026-07-21 14:18:54 +03:00
cucadmuh 6928f1a4aa fix(queue): choose server for mixed shares 2026-07-21 13:47:29 +03:00
cucadmuh 62c30a0d63 fix(ui): clarify multi-server scope counts 2026-07-21 12:22:52 +03:00
cucadmuh ac83f88547 fix: close remaining multi-server review findings 2026-07-21 11:53:01 +03:00
cucadmuh 4c030bfb91 fix(library): resolve multi-server correctness blockers 2026-07-21 04:06:58 +03:00
cucadmuh ebbbc39014 fix(library): bound post-sync maintenance 2026-07-21 00:42:51 +03:00
cucadmuh 92954fbbc3 fix: close multi-server ownership gaps 2026-07-20 18:57:22 +03:00
cucadmuh f8799228e2 test: await most-played cover wake effect 2026-07-20 17:46:27 +03:00
cucadmuh b4623a3eaa docs: describe simultaneous multi-server support 2026-07-20 17:33:10 +03:00
cucadmuh 27624d4c69 docs: add multi-server scope release notes 2026-07-20 17:19:20 +03:00
cucadmuh f7693892bb Merge remote-tracking branch 'origin/main' into perf/rebuild-multi-server-scope 2026-07-20 17:02:00 +03:00
cucadmuh 9522248e06 fix(startup): keep splash until initial content is ready 2026-07-20 17:01:24 +03:00
cucadmuh 9adae41a04 fix(library): make identity maintenance incremental 2026-07-20 17:00:59 +03:00
cucadmuh 1f11f94d98 feat(library): expose entity source choices 2026-07-20 02:08:55 +03:00
cucadmuh 2b687cb391 feat(playback): choose alternative failed sources 2026-07-20 01:43:04 +03:00
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
cucadmuh 15bbfe22ac feat(library): resolve whole-server scope sources 2026-07-20 01:15:28 +03:00
cucadmuh 2a0696037a fix(library): align album identity partitions 2026-07-20 00:09:26 +03:00
cucadmuh 68a7c212af fix(share): preserve explicit server ownership 2026-07-19 23:11:38 +03:00
cucadmuh ca1980005e feat(album): preserve scoped detail ownership 2026-07-19 22:57:48 +03:00
cucadmuh fbb4546c85 feat(search): respect selected library scope 2026-07-19 22:11:42 +03:00
cucadmuh 75dfaaa241 feat(library): aggregate composers across selected scopes 2026-07-19 20:37:29 +03:00
cucadmuh 5c81086d11 feat(scope): move server selection into create dialogs 2026-07-19 18:37:11 +03:00
cucadmuh b6e65cb1e1 fix(library): suppress no-op sync refreshes 2026-07-19 18:03:40 +03:00
cucadmuh 4ece028a77 fix(library): harden multi-server sync recovery 2026-07-19 17:11:15 +03:00
cucadmuh 6388d3d37a fix(orbit): preserve session server ownership 2026-07-19 11:15:22 +03:00
cucadmuh 5a5b95c54e fix(lyrics): preserve playback server ownership 2026-07-19 10:25:32 +03:00
cucadmuh 7f4b2cc4bb fix(device-sync): preserve server ownership 2026-07-19 09:50:52 +03:00
cucadmuh 2d7b0d4fa2 test(albums): await metadata wake effect 2026-07-19 09:04:33 +03:00
cucadmuh d0030a4360 fix(playback): require stable analysis server keys 2026-07-19 09:04:19 +03:00
cucadmuh a1058f11de fix(playback): scope analysis state by server 2026-07-19 06:06:47 +03:00
cucadmuh 32a777055d fix(playback): preserve queue top-up ownership 2026-07-19 05:35:51 +03:00
cucadmuh 0a41b64adb fix(playback): preserve mixed queue ownership 2026-07-19 05:12:07 +03:00
cucadmuh 8e8cf72f23 fix(context-menu): preserve item server ownership 2026-07-19 04:42:56 +03:00
cucadmuh 645a1c6293 fix(song-info): preserve track server ownership 2026-07-19 04:34:25 +03:00
cucadmuh 011f2d9d46 fix(radio): preserve server ownership across flows 2026-07-19 04:08:48 +03:00
cucadmuh 55d0b93268 fix(genres): aggregate selected library indexes 2026-07-19 03:09:48 +03:00
cucadmuh 7d54aa9dc4 fix(playlists): preserve server ownership across flows 2026-07-19 02:41:08 +03:00
cucadmuh eec6615fe7 fix(library): sync active server to scope priority 2026-07-18 23:01:01 +03:00
cucadmuh 27b8645a24 fix(library): exclude unavailable servers from browse scope 2026-07-18 22:45:21 +03:00
cucadmuh cfc40919d1 fix(artists): deduplicate albums across servers 2026-07-18 04:54:04 +03:00
cucadmuh 6856ede197 fix(library): stabilize primary browse server 2026-07-18 04:30:16 +03:00
cucadmuh 21e8014b9a fix(artists): preserve server ownership across navigation 2026-07-18 04:19:52 +03:00
cucadmuh 46b9ede010 fix(artists): stabilize scoped top tracks 2026-07-18 03:20:11 +03:00
cucadmuh acaa9c1c50 fix(albums): scope covers and speed detail reads 2026-07-18 02:03:19 +03:00
cucadmuh db8863bde8 fix(artists): scope covers and speed detail reads 2026-07-18 01:46:58 +03:00
cucadmuh bf18b432a6 fix(sync): prevent stale multi-server state 2026-07-18 01:04:32 +03:00
cucadmuh 15e9fc498d fix(library): preserve multi-server ownership 2026-07-18 00:59:41 +03:00
cucadmuh e53d5fb350 feat(library): scope most played across servers 2026-07-18 00:27:08 +03:00
cucadmuh 9d4227052f fix(library): preserve folder browse identities and durations 2026-07-17 23:17:34 +03:00
cucadmuh 54fd143b46 feat(folders): browse selected servers 2026-07-17 17:30:35 +03:00
cucadmuh deb15f52df perf(library): scope statistics and detail reads 2026-07-17 17:12:59 +03: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
cucadmuh 215d6783ad debug(artists): trace catalog count work 2026-07-17 12:52:58 +03:00
cucadmuh d9869f7744 debug(library): attribute lossless read contention 2026-07-17 12:43:50 +03:00
cucadmuh be77131993 perf(library): isolate mainstage read scans 2026-07-17 12:41:43 +03:00
cucadmuh 2749ca00da perf(home): skip unused release genre counts 2026-07-17 12:25:44 +03:00
cucadmuh 3385f51b35 fix(favorites): avoid duplicate local snapshots 2026-07-17 12:21:38 +03:00
cucadmuh 88945796a6 fix(artists): preserve album credits across scopes 2026-07-17 06:12:34 +03:00
cucadmuh 7791fe5dd7 fix(artists): search Unicode names across scopes 2026-07-17 05:12:02 +03:00
cucadmuh 27b1f17ead fix(artists): report global browse scope 2026-07-17 04:57:50 +03:00
cucadmuh ab5db31628 debug(artists): add copyable browse timing report 2026-07-17 04:50:24 +03:00
cucadmuh e0b8ad90c5 perf(tracks): page catalog through scoped browse 2026-07-17 04:39:20 +03: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
cucadmuh 91b0d4b525 perf(albums): page catalog through scoped browse 2026-07-17 03:51:48 +03:00
cucadmuh 587fc1a70a feat(library): add scoped browse projection foundation 2026-07-17 03:48:16 +03: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
cucadmuh ec9b5e8c63 debug(albums): add copyable browse timing report 2026-07-17 02:40:58 +03:00
cucadmuh fe4215e994 fix(playback): reconcile queues by owner server 2026-07-17 02:29:14 +03:00
cucadmuh ca174e0c30 fix(releases): scope genre filters to catalog feed 2026-07-17 02:15:55 +03:00
cucadmuh b5d906a3b9 feat(releases): refresh hot albums after local load 2026-07-17 02:04:06 +03:00
cucadmuh 49170898ea feat(releases): merge selected server catalog feeds 2026-07-17 01:45:04 +03:00
cucadmuh ba635c5d4e feat(lucky-mix): select AudioMuse libraries across servers 2026-07-17 01:17:12 +03:00
cucadmuh aa4cc67f80 fix(ratings): keep cache resolver below store layer 2026-07-17 01:10:34 +03:00
cucadmuh c9ff5d6082 refactor(library): consolidate branch migrations 2026-07-17 01:01:13 +03:00
cucadmuh d16df28933 debug(home): gate mainstage diagnostics in psylab 2026-07-17 00:59:01 +03:00
cucadmuh e481635428 fix(library): avoid lossless browse full scan 2026-07-17 00:51:30 +03:00
cucadmuh 7f4db455c2 perf(home): sample discover artists from local index 2026-07-17 00:42:47 +03: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 09169faa57 perf(library): index local lossless albums 2026-07-17 00:32:16 +03:00
cucadmuh c98193ae09 perf(ratings): resolve mix filters from library cache 2026-07-17 00:20:39 +03: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
cucadmuh 5074f75c5e debug(home): instrument mainstage loading 2026-07-16 23:56:52 +03: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 77abe0ff70 fix(home): keep multi-server feeds responsive 2026-07-16 20:24:46 +03:00
cucadmuh 9ac6da2057 fix(library): preserve multi-server ownership 2026-07-16 19:37:01 +03:00
cucadmuh b9bac6788d feat(home): aggregate selected servers 2026-07-16 18:33:55 +03:00
cucadmuh 0a525a79ee fix(queue): reconcile server queues after restart 2026-07-16 17:24:46 +03:00
cucadmuh ef286a27a5 refactor(now-playing): group listeners by server 2026-07-16 17:24:32 +03:00
cucadmuh cf4a86d088 fix(now-playing): close cross-server sessions 2026-07-16 17:02:22 +03:00
cucadmuh 6c9acdae23 feat(now-playing): aggregate selected servers 2026-07-16 16:36:37 +03:00
cucadmuh b0dfb3e7c8 fix(queue): sync mixed queues per server 2026-07-16 16:21:11 +03:00
cucadmuh 16320913d8 feat(library): rebuild multi-server browse scope 2026-07-16 15:54:57 +03: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
norperz 8e0e707544 fix(sync): use formPost for large savePlayQueue to avoid HTTP 414 (#1262) 2026-07-09 16:50:35 +03: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
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 a0c8da073b fix(album): localize track count on album detail header (#1245) 2026-07-06 12:28:49 +03:00
cucadmuh c8d2a55e86 fix(albums): keyboard year filter entry without per-keystroke clamp (#1244) 2026-07-06 12:09:16 +03:00
cucadmuh cf982a6ac2 fix(offline): on-disk-only local browse for hot cache and pins (#1243) 2026-07-06 05:51:57 +03:00
cucadmuh 37190775cb fix(library): load full genre catalog for all-libraries scope (#1242) 2026-07-06 03:39:38 +03:00
cucadmuh 4e6b58967c feat(library): multi-library filter — browse & search across selected libraries (#1241) 2026-07-06 03:02:15 +03:00
Psychotoxical a874192408 feat(themes): surface installed-theme updates in the store (#1240)
* feat(themes): show per-theme changelog and pin updatable themes

Surface theme updates in the store: each card gains an expandable "What's
new" built from the theme's optional manifest changelog (versions listed
newest first), and installed themes with a pending update now float to the
top of the list in both sort modes so they are easy to find.

* i18n(settings): add themeStoreWhatsNew across locales

New "What's new" label for the theme-store changelog disclosure, in all 13
locales.

* docs(changelog): add theme store changelog and pinned-updates entry (#1240)
2026-07-06 01:45:34 +02:00
ImAsra 41ae30f05d feat(discord): add dismissible community banner 2026-07-05 18:32:14 +02:00
Psychotoxical 9a556dfa12 chore(ci): repoint frontend hot-path coverage list to post-restructure paths (#1239) 2026-07-05 14:54:35 +02:00
Psychotoxical 84164570b9 fix(cover): show album cover for playlist tracks in Now Playing (#1218)
* fix(cover): show album cover for playlist tracks in Now Playing

* docs(changelog): note playlist now-playing cover fix (#1218)
2026-07-05 14:44:53 +02: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 db645ca72f fix(queue): resolve queue rows on scroll so off-window items stop showing '…' (#1236) 2026-07-05 14:07:16 +03:00
cucadmuh 32a21ec2c3 fix(playlist): batch playlist writes past the GET URL limit (#1227) (#1235)
* fix(playlist): batch playlist writes past the GET URL limit (#1227)

Adding tracks failed past ~341 songs because the write path re-sent the
entire song list as createPlaylist.view?songId=<all> query params on a GET,
blowing past the server's ~8 KiB URL limit. Writes now append incrementally
via updatePlaylist.view?songIdToAdd=<batch> (and songIndexToRemove for
clears/removals) in 150-id batches, so there is no practical size cap. A
per-server in-memory membership cache removes the full getPlaylist refetch
on every dedup, fixing the "slow add on big playlists" report.

Layering detangle (keeps the new cache from adding dep-cruiser cycles):
- lib/api/subsonicPlaylists.ts is pure of the store again; cache invalidation
  on write failure moved to the feature callers' catch blocks.
- membership cache extracted to the core layer (src/store/playlistMembershipStore.ts)
  so offline/orbit/contextMenu/playlist read it directly instead of routing
  through the @/features/playlist barrel.
- severed the offline -> playlist-barrel edge (pinnedOfflineSync name fallback
  through the live playlist list was dead: nameless callers are all gated by
  isSourcePinnedOffline, where offline meta already carries the name).
- confirmAddAllDuplicates moved into the playlist feature; contextMenu submenus
  import the add/merge helpers via the playlist barrel, not deep paths.

dep-cruiser baseline regenerated: 742 -> 714 (net -28, all no-circular). The
churn in the baseline is path-shift of the frozen playerStore SCC (cover/
playback/orbit), not new coupling; the new playlist modules have zero violations.

* docs(changelog): record #1235 playlist URL-limit fix (changelog + credits)

* fix(playlist): seed membership cache from full list, not library-scoped view (#1235 review)

F1: runPlaylistLoad seeded the dedup membership cache from the
library-scope-filtered songs, so out-of-scope members looked "new" and
addTracksToPlaylistWithDedup/collectMergeSongIds could re-add them as
duplicates. Cache now holds the full unfiltered server list while the UI
still shows the filtered view; add a regression test.

Also documents the two accepted trade-offs flagged in review:
- F2: >batch updatePlaylist clears then appends non-atomically (URL-limit
  workaround); a mid-step failure truncates server state, cache invalidation
  lets the client re-read truth.
- F3: dedup read-modify-append isn't atomic across the await; rare missed
  dedup on concurrent adds, self-heals on next load.
2026-07-05 01:24:04 +03:00
cucadmuh 5e075b2c36 fix(connection): ignore spurious navigator.onLine offline hint in Tauri (#1234) 2026-07-04 20:56:31 +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
Alexander Kirichev 9183c3d657 Added Bulgarian translation (#1228) 2026-07-03 14:59:31 +03:00
Psychotoxical 114ad4633f docs(changelog): record #1225 frontend restructure (changelog + credits) (#1226)
Retroactive changelog + credits for the already-merged feature-folder restructure (PR #1225). Adds a 1.50.0 Changed entry (internal restructure, no user-facing change; additional architecture credited to cucadmuh) and a contributions line under Psychotoxical. cucadmuh is intentionally not added to the settings credits list.
2026-07-01 17:01:52 +02:00
Psychotoxical 05156727a9 Merge pull request #1225 from Psychotoxical/refactor/frontend-restructure
refactor(frontend): migrate src to a feature-folder architecture
2026-07-01 16:04:29 +02:00
Psychotoxical fb5dee0701 refactor(ipc): consume generated bindings for library catalog/genre reads
FE cutover for the D1 slice: libraryGetCatalogYearBounds and
libraryGetGenreAlbumCounts now call the generated commands.* bindings instead of
a hand-written invoke<T>('cmd', ...), unwrapping the specta Result union (rethrow
on error — behavior-preserving). The duplicated hand DTOs collapse into named
aliases of the generated CatalogYearBoundsDto / GenreAlbumCountDto, so the shape
lives in one place (the contract) and consumers stay unchanged.

Verified the guard end to end: a serde-rename of the Rust field (contract change,
Rust still compiles) regenerated bindings and made tsc fail at the FE consumer;
reverted. cargo + tsc now enforce this slice's contract at compile time.
2026-07-01 14:58:25 +02:00
Psychotoxical b722b1abaf refactor(server): move serverCapabilities into lib
The serverCapabilities cluster (catalog, context, resolve, storeView, types) is
lib-level infra: it imports only @/lib/server and is consumed by both lib and
feature code, so a feature home would invert the two lib consumers. Move it to
src/lib/serverCapabilities and repoint consumers.

Pure move + import updates, no behavior change. Baseline gains one lib->store
entry (storeView reads authStore, only now caught under lib/).
2026-07-01 14:45:15 +02:00
Psychotoxical f2cf8a08df refactor(hooks): move cross-cutting hooks into lib/hooks
The five residual src/hooks/ hooks (useConnectionStatus, useCardGridMetrics,
useTracklistColumns, useNavidromeAdminRole, useAnalysisPerfListener) are all
domain-agnostic — none imports @/features — so they join the other shared hooks
in src/lib/hooks/ (with their two colocated tests). Consumers repointed;
relative-up imports in the moved files become the @/ alias. src/hooks/ is removed.

Pure move + import updates, no behavior change. The layering baseline gains five
lib->store entries (the three hooks that read authStore/devOfflineBrowseStore are
only now caught under lib/) — same documented debt class as the api move.
2026-07-01 14:38:23 +02:00
Psychotoxical 25055dc79a refactor(ui): drain src/components into ui + owning features
The legacy src/components/ dir held eight shared modals. Audited each: the five
generic primitives (Modal, ConfirmModal, GlobalConfirmModal, ExportPickerModal,
ThemeMigrationNotice) move to src/ui (the shared-primitive home). The three that
import @/features/* are feature-owned, so they move into their feature instead of
polluting ui with core->feature edges: SongInfoModal -> features/playback,
LicenseTextModal -> features/settings, PasteClipboardHandler -> features/share.

Pure move + import updates, no behavior change. The layering baseline is unchanged
(every relocated import lands in a legal direction: feature->ui/store/lib, or
cross-feature only via the barrel). src/components/ is now removed.
2026-07-01 14:31:33 +02:00
Psychotoxical f4fa766b20 refactor(api): collapse src/api into src/lib/api
The seven remaining src/api/ modules (analysis, azuracast, bandsintown,
coverCache, migration, network, runtimeLogs) were the last IPC-layer files
outside src/lib/api/, where the rest of the FE IPC clients already live. Move
them (+ the colocated coverCache test) into lib/api and repoint every consumer;
relative-up imports in the moved files become the depth-independent @/ alias.

Pure move + import updates — no behavior change. The layering baseline is
regenerated in the same commit: the moved files' pre-existing store/cover edges
are only now caught under lib/, and the cover<->coverCache cycles are the same
edges re-keyed to the new path (E4 shrinks the allowlist later).
2026-07-01 14:17:56 +02: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
Psychotoxical 73c32882e0 test(scenario): URL remigration x store-key rewrite
After a server URL edit, rewriteFrontendStoreKeysForRemap must repoint the old
index key to the new one across offline album keys, local-playback entries, and
the player queue (queueServerId + per-item refs), leaving unrelated keys
untouched. A same-key remap is a no-op.
2026-07-01 12:22:07 +02:00
Psychotoxical f982e47229 test(scenario): server switch x active orbit + queue
Cover switchActiveServer end to end with the heavy deps mocked and the real orbit
+ auth stores driving assertions: unreachable aborts; host tears down via
endOrbitSession, guest via leaveOrbitSession, both reset the role; the old
server's queue is flushed, the active server rebinds, and a queue handoff is
marked. Closes the switchActiveServer QA.
2026-07-01 12:22:07 +02:00
Psychotoxical 93fe34b332 test(scenario): orbit session x bulk enqueue
Route a multi-track enqueue through the real enqueue action and the orbitRuntime
bulkGuard seam: over-threshold + accept commits the tracks, reject commits none,
and a single track bypasses the guard entirely.
2026-07-01 12:22:07 +02:00
Psychotoxical d3c86cf666 test(scenario): network guard x local bytes
Exercise the real hasLocalPlaybackUrl + subsonicNetworkGuard: a track with local
psysonic-local:// bytes (any tier) skips the reachability probe even when the
server is unreachable, while a non-local track stays gated by reachability.
Closes the local-bytes network-skip QA (review residual #1).
2026-07-01 12:22:07 +02:00
Psychotoxical 567a8b92a4 test(scenario): offline mode x media resolution
Register the real offlineMediaResolve into the mediaResolver seam and drive its
offline-vs-network routing through the actual decision inputs: offline-browse
active + local browse enabled reaches the local-bytes path, otherwise the
network path. Asserts which data source the seam call reaches. Closes the
offline-aware media resolution QA (review residual #2).
2026-07-01 12:22:07 +02:00
Psychotoxical 577b1ec7cf test(store): persist upgrade/merge
Guard shape drift when an old persisted blob rehydrates. Cover the
analysisStrategyStore v0 to v1 migrate (preserves strategy, clamps parallelism,
adds the per-server maps) plus a v1 passthrough, and add cold-start rehydrate
round-trips for localPlaybackStore (entries) and offlineStore (albums).
2026-07-01 12:00:13 +02:00
Psychotoxical 9cb55cbcc5 test(store): seam contracts
Pin the contract of the three core-feature seams the refactor rests on: before
registration each delegator returns its documented default (mediaResolver
network-only, orbit neutral snapshot + allow, bridge null / no-op); after
registerX(fake) it forwards to the registered impl with the given args and
propagates its result. Underpins the boot smoke and the scenario suite.
2026-07-01 12:00:13 +02:00
Psychotoxical 201ac309c4 test(store): hasLocalPlaybackUrl direct coverage
hasLocalPlaybackUrl mirrors resolvePlaybackUrl's local-source branch so the
network guard can skip the reachability probe for local tracks; its only other
test mocks it away. Cover it directly against real stores: per-tier hits
(library / favorite-auto / ephemeral), the no-bytes false case, index-key vs raw
profile-id resolution, and an equivalence guard against resolvePlaybackUrl's
local branch that locks the bit-identical claim.
2026-07-01 12:00:13 +02:00
Psychotoxical 21b8acec7c test(app): lazy-route resolvability smoke
Import every lazy(() => import('@/features/*/pages/*')) page up front and assert
a default-exported component, so a broken specifier fails at CI instead of only
when the route is first navigated to. A drift guard reads the real route sources
and fails if the loader table and the app fall out of sync, so a newly added
route cannot silently skip coverage.
2026-07-01 11:29:30 +02:00
Psychotoxical 1bc65ca8bf test(app): seam-registration boot smoke
Guard the boot-order invariant the three core-feature seams rely on: importing
the real app entry module runs the registration side effects (playback bridge
via MainApp, media resolver via the offline barrel, orbit runtime via AppShell's
orbit barrel), and the smoke asserts each seam is the registered implementation,
not its neutral default. Drops any of those imports later, and the matching
assertion fails at CI instead of silently at runtime.

Each seam gains a small behavior-free isRegistered() introspection so the guard
reads registration directly rather than coupling to store internals.
2026-07-01 11:29:30 +02:00
github-actions[bot] 27ef3a3881 chore(nix): sync npmDepsHash with package-lock.json 2026-07-01 09:15:43 +00:00
Psychotoxical 47f2883ca2 test(arch): layering + cycle guard (dependency-cruiser) with current-violation allowlist
Encode the feature-folder layering contract as a CI gate: lib is the floor,
store/ui must not import features/app, cross-feature only via the barrel, no
cycles. dependency-cruiser resolves the @/ alias via tsconfig; tests excluded.

Land as a ratchet: the 738 current violations are seeded into
.dependency-cruiser-known-violations.json and ignored via --ignore-known, so
HEAD is green and any NEW violation fails CI. Regenerate the baseline as the
drain removes an exception; the count is the tracked residual debt.

Wired via `npm run dep:check` into a new dependency-cruiser job in eslint.yml
and the ci-ok required-check aggregate.
2026-07-01 11:14:48 +02:00
Psychotoxical 57811f2b3f Merge remote-tracking branch 'origin/main' into refactor/frontend-restructure 2026-07-01 02:18:43 +02:00
Psychotoxical a323212d0d refactor(cover): relocate imageCache into the cover layer (barrel-only external access) 2026-07-01 00:57:07 +02:00
Psychotoxical 7f48071ec4 refactor(search): extract advanced-search runner into a hook 2026-07-01 00:13:31 +02:00
Psychotoxical 87c3c588d9 refactor(search): extract advanced-search results area into a component 2026-07-01 00:03:26 +02:00
Psychotoxical 386742e2f2 refactor(search): extract advanced-search filter panel into a component 2026-06-30 23:59:42 +02:00
Psychotoxical 97bd7afed1 refactor(search): extract LiveSearch query effect into a hook 2026-06-30 23:53:23 +02:00
Psychotoxical 0c1a9e249e refactor(search): extract LiveSearch results dropdown into a component 2026-06-30 23:41:07 +02:00
Psychotoxical 16e014ca28 refactor(search): extract LiveSearch header-collapse logic into a hook 2026-06-30 23:32:41 +02:00
Psychotoxical ece4ae651c refactor(search): extract LiveSearch result thumbnails into a sibling module 2026-06-30 23:21:17 +02:00
Psychotoxical e021796f16 refactor(lib): split api/library god-module into concern modules behind a barrel
The 945-LOC lib/api/library.ts (cucadmuh's deliberate 'one thin file' library_*
command surface) exceeded the ~500 LOC ceiling. Split by its own PR-5 section
comments into library/{dto,reads,sync,stats,events}.ts + a private library/internal.ts
(the shared serverId↔indexKey helpers). lib/api/library.ts is now a barrel that
re-exports all of them, so @/lib/api/library stays the single import point — every
consumer (incl. the Settings LibraryTab) and all 11 vi.mock('@/lib/api/library')
test factories are unchanged. Pure reorganization, no logic change; tests pass
unmodified. Largest module is now dto.ts (466, pure types).
2026-06-30 22:43:10 +02:00
Psychotoxical 92635381ea refactor(share): split share cluster into lib/share + features/share
a11y-branch hold lifted. Pure share machinery (no @/features runtime imports) →
lib/share: shareLink, shareSearch, shareServerOriginLabel, copyEntityShareLink
(+tests). The two orchestrators that runtime-import offline/playback/orbit →
new features/share: applySharePaste, enqueueShareSearchPayload (+test); doc-only
barrel, consumers use deep paths. No low-layer consumes the orchestrators, so no
seam is needed — pure move, tests pass unmodified. Drains utils/share.

Only import lines changed in the PasteClipboardHandler consumer (logic untouched).
2026-06-30 22:21:57 +02:00
Psychotoxical 6590bd15ef refactor(settings,lib): home licensesData + userMgmtHelpers to owners
a11y-branch hold lifted (Frank: modals get re-refactored separately, no rebase to
protect). Both were only blocked by their modal consumers:
- licensesData (pure licenses.json lazy-loader) → features/settings/utils; main
  consumer is the settings LicensesPanel. JSON dynamic import absolutized to
  @/data/licenses.json.
- userMgmtHelpers (pure formatLastSeen, lib/format only) → lib/format; multi-feature
  consumer set, same class as playlistDetailHelpers.
Drains utils/componentHelpers (dir removed). Only import lines changed in the
LicenseTextModal/SongInfoModal consumers (logic untouched); tests pass unmodified.
2026-06-30 22:15:46 +02:00
Psychotoxical ccaecdddd8 refactor(lib): move uploadArtistImage to subsonicArtists (M5 misplacement)
uploadArtistImage lived in lib/api/subsonicPlaylists but is artist-domain and
consumed only by features/artist/runArtistDetailActions. Relocated to
lib/api/subsonicArtists (its rightful API module); consumer repointed. Also fixes
a stale doc comment in features/randomMix/index.ts (mixRatingFilter now lives in
features/playback). No behavior change; tests pass unmodified.
2026-06-30 21:33:38 +02:00
Psychotoxical 10d948db61 refactor(lib): relocate cardGridLayout → lib/util (store-free)
cardGridLayout's only store dep was three grid-column constants it imported from
authStoreDefaults. Flipped ownership: the constants now live in lib/util/cardGridLayout
(pure layout config) and authStoreDefaults re-exports them (store→lib), so the auth
settings clamp/default and every existing consumer are unchanged. cardGridLayout is now
store-free and homes in lib/util alongside the other pure helpers — consumed cleanly by
ui/VirtualCardGrid + cover/layoutSizes. Resolves the documented store-free-lib block for
the card-grid layout helper. Pure move + constant relocation; tests pass unmodified.
2026-06-30 21:28:03 +02:00
Psychotoxical c37d5f7389 refactor(network): decouple network-reachability layer from features → lib/network
The connection-reachability + Subsonic network-guard helpers were pinned low by
four lib/api consumers (subsonicLibrary/Playlists/Ratings/Scrobble) yet ran two
lower-layer→feature inversions. Both removed without a registry:

- devOfflineBrowseStore is a self-contained DEV-only toggle with zero offline-
  feature coupling — it was only colocated there. Relocated features/offline/store
  → store/ (global). The @/features/offline barrel re-exports it so feature/UI
  consumers are unchanged; the three lower-layer readers (subsonicNetworkGuard,
  activeServerReachability, useConnectionStatus) now import it from @/store directly.

- subsonicNetworkGuard's only other feature dep was playback's resolvePlaybackUrl,
  used solely for the psysonic-local:// skip check. Added hasLocalPlaybackUrl to the
  existing M4 substrate store/localPlaybackResolve — it mirrors resolvePlaybackUrl's
  local-source branch exactly (same profile resolution; the empty-serverId playback
  fallback never applies in the guard), so the skip stays bit-identical.

network/ (subsonicNetworkGuard + activeServerReachability + tests) → lib/network,
now @/features-free. useConnectionStatus is now iron-rule-clean and stays in hooks/
(cross-cutting). Test mocks retargeted to the new seam modules.

Behavior-adjacent (covered by suite; default paths identical): the local-bytes skip
helper — flag for offline-playback runtime QA alongside the M4 media-resolver seam.
2026-06-30 21:23:44 +02:00
Psychotoxical d4ab56f5a6 refactor(lib,playback): relocate albumDetailNavigation + mixRatingFilter decouple-knots
Two lower-layer→feature inversions removed:

- Detail-route predicates (isAlbumDetailPath/isArtistDetailPath/isComposerDetailPath)
  were defined in features/album's browse store but are pure URL checks. Extracted
  to lib/navigation/detailRoutePaths; the browse store re-exports them so the
  @/features/album barrel surface is unchanged. This frees albumDetailNavigation of
  its only @/features import, so it moves utils/navigation → lib/navigation (drains
  utils/navigation). Also fixes the M5 isArtistDetailPath misplacement.

- mixRatingFilter's sole feature dep is playback's userRatingOverrides and its only
  binding consumer is playback's buildInfiniteQueueCandidates, so it belongs in the
  playback feature: utils/mix → features/playback/utils (drains utils/mix). The
  earlier handoff note that lib/api/subsonicStarRating consumes it was stale — that
  is only a code comment, verified no runtime import.

Pure moves; tests pass unmodified. The share cluster + switchActiveServer stay in
utils/ — they are imported by the a11y-HELD PasteClipboardHandler, so relocating
them would rewrite a do-not-touch file.
2026-06-30 21:17:01 +02:00
Psychotoxical fbca1831a1 refactor(utils): split componentHelpers grab-bag to owning layers
Per-file routing of the 9-file utils/componentHelpers grab-bag, each driven by
its verified consumer set:
- contextMenu{Actions,Helpers} → features/contextMenu/utils (sole consumer)
- queuePanelHelpers → features/queue/utils (sole consumer)
- nowPlayingHelpers → features/nowPlaying/utils; isRealArtistImage split out to
  cover/ (consumed by cover/artistHero, a lower layer)
- appUpdaterHelpers(+test) + listReorder(+test) → lib/util (config/ + lib/hooks
  consume them; pure)
- playlistDetailHelpers → lib/format (consumed by 3 features; pure)
- appShellHelpers → app/ (app-shell consumers only)
- userMgmtHelpers left in place: consumed by the a11y-HELD SongInfoModal, so a
  move would rewrite a do-not-touch file (same block as licensesData).

Pure moves; tests pass unmodified.
2026-06-30 21:05:16 +02:00
Psychotoxical f43dcc0e76 refactor(app): relocate utils/migrations → app/migrations
Both one-time boot migrations are consumed only by app/MainApp; advancedModeMigration runtime-imports @/features/artist + @/features/playlist, so it is app-level orchestration (an inversion if kept in utils/ infra). Pure move.
2026-06-30 21:05:08 +02:00
Psychotoxical 13142e6ae3 refactor(lib): extract clean server + navigation infra to lib/ (decouple-knot files stay in utils/ pending seam) 2026-06-30 20:44:34 +02:00
Psychotoxical 7d98e649da refactor(hooks): relocate misplaced useTracklistColumns hook from utils/ to hooks/ (cross-cutting) 2026-06-30 20:39:22 +02:00
Psychotoxical 390fdfbcca refactor(lib): fold utils/perf into lib/perf + utils/ui (DOM utilities) into lib/dom 2026-06-30 20:36:23 +02:00
Psychotoxical 6becd57798 refactor(lib,playback): fold utils/cache+themes into lib; playbackScheduleFormat→playback (drains utils/format) 2026-06-30 20:33:25 +02:00
Psychotoxical 31d4a87401 refactor(lib): fold utils/audio + autodj-overlap into lib/audio (audio-transition infra; drains utils/playback) 2026-06-30 20:30:07 +02:00
Psychotoxical aea8b7750b refactor(lib,playback,whatsNew): home waveform→lib, timeline utils→playback, releaseNotes+changelog→whatsNew 2026-06-30 20:26:34 +02:00
Psychotoxical 695462ed87 refactor(cover,lib): fold utils/cover into cover/ + utils/media into lib/media (generic infra) 2026-06-30 20:21:15 +02:00
Psychotoxical 982499d32b refactor(orbit,playback): move feature-owned stores (helpModal→orbit; libraryPlaybackHint+playerBarLayout→playback) 2026-06-30 20:16:55 +02:00
Psychotoxical 03ce57feff refactor(app): co-locate shell-only lifecycle hooks in app/hooks; hooks/ now holds only cross-cutting hooks 2026-06-30 20:09:22 +02:00
Psychotoxical 79c02a93cf refactor(app): move Tauri IPC bridge hooks (tauriBridge/) into app/ where the shell composes them 2026-06-30 20:06:05 +02:00
Psychotoxical 7ae259cacf refactor(updater,settings): move feature-owned updater + theme-update hooks into their features 2026-06-30 20:03:25 +02:00
Psychotoxical 312fa78240 refactor(playback,playlist): move player-bar overflow/popover/delay + bulk-picker hooks into their features 2026-06-30 19:59:38 +02:00
Psychotoxical c2461c88d5 refactor(playback): co-locate floating-player-bar + fs-idle-fade hooks (and orphaned playback tests) with their features 2026-06-30 19:55:20 +02:00
Psychotoxical f03a5f13e8 refactor(cover): move cover-coupled hooks into cover/; drop deprecated usePlaybackCoverArt shim 2026-06-30 19:52:29 +02:00
Psychotoxical 28e049425e refactor(music-network): move enrichment-primary icon/label hooks into music-network/ui 2026-06-30 19:48:54 +02:00
Psychotoxical 777c72adf7 refactor(lib): move library-index hooks into lib/library/hooks 2026-06-30 19:48:49 +02:00
Psychotoxical 1f93984482 refactor(hooks): move generic scroll/pagination/dnd UI hooks into lib/hooks 2026-06-30 19:34:28 +02:00
Psychotoxical 31bc24a178 refactor(hooks): co-locate feature-coupled hooks (playback server/navigate/timeline/audio-devices->playback, smart-collage/pending-polling->playlist, contextmenu rating/keyboard->contextMenu) 2026-06-30 19:30:41 +02:00
Psychotoxical e0d6623bf1 refactor(lyrics): co-locate lyrics feature into features/lyrics (LyricsPane + hooks + cache + lrclib/lyricsplus/netease providers; lyricsStore stays global) 2026-06-30 19:21:21 +02:00
Psychotoxical d9dfdf66e9 refactor(music-network): co-locate MusicNetworkIndicator + presetIcon into music-network/ui (barrel-exported) 2026-06-30 19:15:56 +02:00
Psychotoxical 15a6590e77 refactor(updater+contextMenu): co-locate updater feature (AppUpdater+Changelog) and the context-menu subsystem into features/ 2026-06-30 19:12:26 +02:00
Psychotoxical 7d2645171b refactor(components): co-locate remaining UI/feature components (TracklistColumnPicker+LosslessModeBanner->ui, fixedThemes->utils/themes, ThemeUpdateBanner/WindowButtonPreview/AboutPsysonicLol->settings, BottomNav/MobileMoreOverlay->sidebar, MobilePlayerView->nowPlaying, WhatsNewBanner->whatsNew) 2026-06-30 19:07:46 +02:00
Psychotoxical ba9e0d6541 refactor(app): move app-shell chrome into app/ (TitleBar, ConnectionIndicator, ErrorBoundary, AppShellQueueResizerSeam, dev overlays + PerfOverlaySparkline) 2026-06-30 19:04:37 +02:00
Psychotoxical d876781fcb refactor(components): co-locate feature-specific components (HostApprovalQueue->orbit, Playback{Delay,Schedule}->playback, LicensesPanel->settings) 2026-06-30 18:55:48 +02:00
Psychotoxical 9eee4eed5b refactor(components): GenreFilterBar -> ui/ (shared), SongCard -> features/home (sole consumer) 2026-06-30 18:50:46 +02:00
Psychotoxical 0a8d82fe2a refactor(ui): move GenreFilterBar (shared genre filter) into ui/ 2026-06-30 18:50:36 +02:00
Psychotoxical afa8999d37 refactor(search): co-locate tracks song-browse cluster (SongRow/PagedSongList/SongBrowseSection/TracksPageChrome/useSongBrowseList) into features/search 2026-06-30 18:41:31 +02:00
Psychotoxical 5c3a135040 refactor(playback): co-locate player bar UI (PlayerBar + playerBar/* + PlaybackBufferingOverlay) into features/playback/components 2026-06-30 18:37:06 +02:00
Psychotoxical 5cb5cf1d24 refactor(equalizer): co-locate equalizer UI into features/equalizer (eqStore/eqCurve stay audio-core) 2026-06-30 18:34:11 +02:00
Psychotoxical 41877d10d8 refactor(ui): move generic presentational primitives into ui/ (CoverLightbox, StarRating, LongPressWaveOverlay, icons, VirtualCardGrid) 2026-06-30 18:28:54 +02:00
Psychotoxical 8cd23e86db refactor(ui): move generic filter/list primitives into ui/ (filter buttons, SortDropdown, MarqueeText, SelectionToggleButton, InpageScrollSentinel) 2026-06-30 18:25:20 +02:00
Psychotoxical b3b7af0390 refactor(help): co-locate help page into features/help; pages/ now empty 2026-06-30 18:16:44 +02:00
Psychotoxical 1e3c7f1915 refactor(whatsNew): co-locate release-notes page into features/whatsNew 2026-06-30 18:16:44 +02:00
Psychotoxical 132db4e620 refactor(auth): co-locate login page into features/auth 2026-06-30 18:16:44 +02:00
Psychotoxical 2f4303ecc8 refactor(home): co-locate Mainstage/home feature into features/home 2026-06-30 18:12:27 +02:00
Psychotoxical f05183d412 refactor(randomMix): co-locate random/lucky-mix feature into features/randomMix 2026-06-30 18:08:15 +02:00
Psychotoxical f443a60c54 refactor(composers): co-locate composers feature into features/composers 2026-06-30 18:03:43 +02:00
Psychotoxical 5fdec6cd2a refactor(genre): co-locate genre browse pages into features/genre 2026-06-30 17:59:06 +02:00
Psychotoxical 5808ef0839 refactor(folderBrowser): co-locate folder-browser feature into features/folderBrowser 2026-06-30 17:57:17 +02:00
Psychotoxical ac6974d4f1 refactor(album): co-locate NewReleases + MostPlayed browse pages into features/album 2026-06-30 17:54:25 +02:00
Psychotoxical 8690e1529a docs(src): refresh CLAUDE.md + test/README.md key-file paths after the restructure
The frontend moves left the subsystem docs pointing at pre-campaign locations.
Update the src/CLAUDE.md key-files table + prose and the test/README.md examples
to current homes: playerStore/playAlbum -> features/playback, Track model +
songToTrack -> lib/media, sidebar -> features/sidebar, QueuePanel ->
features/queue, CachedImage/TooltipPortal/CustomSelect -> ui/, toast -> utils/ui,
subsonic/i18n -> lib, App.tsx split into app/{MainApp,AppShell,RequireAuth}.
Docs only; no code touched.
2026-06-30 17:32:34 +02:00
Psychotoxical 572dce4703 refactor(lib): move songToTrack + pure server-scope helpers to lib/media; split trackServerScope
songToTrack (the canonical Subsonic-song -> Track mapper) and the pure
server-scope helpers (activeServerProfileId, stampTrackServerId/s,
isMultiServerQueue, profileIdFromQueueRef) operate only on the lib/media model +
authStore + server-key utils -- no playback-store read. Move them to lib/media so
the ~58 app-wide consumers (cover, context menus, sharing, lucky-mix, library
browse, pages, hooks) depend on lib, not on the playback feature.

trackServerScope is split: the store-reading queue helpers (queueItemRefAt,
filterQueueRefs*, activeServerQueueTrackIds) stay in
features/playback/utils/playback/trackServerScope and build on the pure lib half.

With Track/QueueItemRef (prior commit), songToTrack and shuffleArray now in lib,
the media-domain model is fully out of features/playback. The remaining
playerStoreTypes imports are PlayerState only (the store shape). tsc 0, lint 0,
full suite 2353/2353, build OK.
2026-06-30 17:24:29 +02:00
Psychotoxical 9058abd340 refactor(lib): extract Track/QueueItemRef domain model to lib/media/trackTypes
Track is the app's normalized song model and QueueItemRef its thin queue
identity -- consumed app-wide (queue, cover, context menus, sharing, lucky-mix,
library browse), not a playback-internal detail. They lived in
features/playback/store/playerStoreTypes, forcing ~115 core/feature files to
reach into the playback feature for the central media type.

Move the two interfaces to lib/media/trackTypes; playerStoreTypes keeps
PlayerState and now imports them from lib. 115 importers repointed (mixed
'PlayerState, Track' lines split so PlayerState stays). Pure type move (erased at
runtime). tsc 0, lint 0.
2026-06-30 17:16:56 +02:00
Psychotoxical 2756cb7698 refactor(lib): relocate the local-library subsystem utils/library -> lib/library
utils/library is the local-index query/browse/sync engine -- feature-free shared
infra consumed by the album, search, genre, artist and offline features. With
its last feature edges removed (queue resolver, shuffleArray, albumBrowseCatalogChunk,
genreBrowsePlayback all relocated earlier this branch), the whole directory is
pure infra and belongs under lib/.

Whole-directory move (58 files, 76 consumers rewritten) via resolver-based
rewrite: cross-cluster relatives absolutized to @/, intra-cluster siblings kept
relative. utils/library is gone; lib/library imports zero @/features. Three
stale doc comments in the album/search/artist barrels repointed.

tsc 0, lint 0, full suite 2353/2353, production build OK.
2026-06-30 17:06:03 +02:00
Psychotoxical e5705f853e refactor(playback): move genreBrowsePlayback into the feature; utils/library now feature-free
genreBrowsePlayback is a "play X" queue builder -- it turns a genre seed into a
Track[] for the player, the genre analogue of playArtistShuffled which already
lives in features/playback/utils/playback. Consumed only by the 3 genre pages,
by no utils/library sibling, so the whole-file move is clean (no split). Its
Track/songToTrack edges become intra-feature; its remaining utils/library
siblings (advancedSearchLocal, albumBrowseSort, genreCatalogCountsCache,
genreAlbumBrowse, libraryReady) are plain infra deps.

utils/library now has ZERO @/features importers (source AND tests) -- the bulk
is ready to relocate to lib/library. tsc 0, lint 0, genre suite green.
2026-06-30 17:00:57 +02:00
Psychotoxical edab32d7ee refactor(lib): relocate shuffleArray to lib/util; albumBrowseCatalogChunk to features/album
shuffleArray is a pure generic Fisher-Yates over T[], misfiled in
features/playback. Move to lib/util (next to dedupeById): fixes the existing
core->feature edges (utils/componentHelpers/*) and drops one of
genreBrowsePlayback's playback deps.

albumBrowseCatalogChunk is the feature-layer orchestrator that picks the offline
branch on top of the pure lib catalog loaders; its sole consumer lives in
features/album and its offline dep is a legal feature->feature edge there. Its
.test.ts stays in utils/library -- that test exercises fetchLocalAlbumCatalogChunk
from albumBrowseLoad (misnamed), which is not moving.

utils/library now has a single remaining feature importer: genreBrowsePlayback
(songToTrack + type Track), blocked on the Track domain model living in
features/playback. tsc 0, lint 0, targeted suites green.
2026-06-30 16:55:18 +02:00
Psychotoxical f41005682d refactor(playback): move thin-state queue resolver out of utils/library into the feature
The queue-resolver family (queueTrackResolver, queueRestore, queueItemRef,
queueTrackView) runtime-imports usePlayerStore / playerStoreTypes — it is the
playback engine's thin-state queue subsystem, not shared library infra. It sat
in utils/library only because it queries the local index. Co-locate into
features/playback/store so those edges become intra-feature; its remaining deps
(advancedSearchLocal, libraryReady, serverLookup, serverIndexKey, authStore) are
plain core/infra (feature -> infra, no inversion).

Removes 4 of the 6 store/utils -> feature runtime inversions that blocked the
utils/library -> lib move. 8 files moved, 46 consumers rewritten. tsc 0, lint 0,
targeted suites green.
2026-06-30 16:50:29 +02:00
Psychotoxical cb1a110afb refactor(playback): move the audio engine into features/playback
Relocate the playback/queue/transport/audio-output engine out of the type-first
store/ + utils/playback/ + utils/audio/ dirs into a cohesive src/features/playback/,
structure-preserving:
  store/<x>                    -> features/playback/store/<x>
  store/audioListenerSetup/<x> -> features/playback/store/audioListenerSetup/<x>
  utils/playback/<x>           -> features/playback/utils/playback/<x>
  utils/audio/<x>              -> features/playback/utils/audio/<x>

184 files moved (107 source + 77 tests), 365 consumers rewritten. Pure move — no
behavior change, no state-split (the playerStore state-split stays a separate M5
question). Enabled by this session's decouple seams (artist/offline/orbit/auth →
core registries), so the engine carries no inbound core->feature inversion: store/
now holds only the 50 cross-cutting global stores (auth family, the seams, library
index, UI/settings stores).

KEPT OUT of the move (would re-create global->engine edges): the 3 pure config
helpers utils/audio/{loudnessPreAnalysisSlider,hiResCrossfadeResample} +
utils/playback/autodjOverlapCap (authStore + settings UI read them — they stay in
utils/). Ambiguous view-state stores (eqStore, queueToolbarStore,
playerBarLayoutStore) stay global (no engine imports).

Consumers use DEEP paths (@/features/playback/...), no barrel — matches the lib/
approach and avoids barrel-mock-collapse across the 140 usePlayerStore consumers.
Two tolerated type-only core->feature edges remain (localPlaybackStore->QueueItemRef,
localPlaybackMigration->HotCacheEntry, both erased).

tsc 0, lint 0, full suite 319/2353 green, iron-rule clean (no runtime store->feature
import). Behavior-touching only via the prerequisite bridge seam (already QA-flagged);
the move itself is pure.
2026-06-30 15:00:20 +02:00
Psychotoxical 6651abbc6f refactor(decouple): playback-engine bridge — break authStore→engine edges
The last core→engine inversions blocking the playback-core move live in the
authStore settings/profile family: authServerProfileActions reads
usePlayerStore.queueServerId + calls clearQueueServerForPlayback (on server
delete), and authAudioSettingsActions calls
usePlayerStore.updateReplayGainForCurrentTrack from ~8 ReplayGain/normalization
setters. Both are re-exported through the authStore barrel, so a global store
would depend on the (soon-to-move) engine.

Add core seam store/playbackEngineBridge.ts: a registry exposing getQueueServerId
/ clearQueueServerForPlayback / updateReplayGainForCurrentTrack with no-op/null
defaults. The engine registers its impls via store/playbackEngineBridgeRegister.ts
(side-effect-imported by MainApp at boot). authStore actions now call the neutral
delegators instead of importing the engine.

Default is safe: the only callers are user-triggered settings/profile actions that
fire long after boot (engine already registered); at boot there's no current track
or queue binding, so the no-op would be correct anyway. authStore.servers.test
imports the register module so removeServer's queue-clear runs through the real
engine wiring.

Prepares the playback-core move (Engine → features/playback): with this + keeping
the 3 config-helper utils in utils/ + tolerating 2 type-only edges, the engine has
no inbound core→feature inversions left. tsc 0, lint 0, suite 319/2353 green.
Behavior-touching (server-delete queue clear, settings→gain refresh) → Frank QA.
2026-06-30 14:45:34 +02:00
Psychotoxical 651a2adba4 refactor(decouple): orbit seam — invert orbit off the audio core
Add core seam store/orbitRuntime.ts: a registry (registerOrbitRuntime) exposing a
neutral session snapshot {role,phase,state} + an async bulkGuard, plus pure
derivations mirrored from the feature (isInOrbitSession, isOrbitPlaybackSyncActive,
estimateLivePosition). Default (unregistered) = neutral snapshot + bulkGuard allow
— bit-identical to today's no-session behavior, and a session can only start via
the topbar which loads the @/features/orbit barrel (→ registers) first.

features/orbit/utils/orbitBulkGuard registers the runtime at module init
(store-backed getSnapshot + the existing confirm-modal orbitBulkGuard as the gate).
The orbit feature keeps its own copies of the pure helpers for UI (incl. the
arg-form isOrbitPlaybackSyncActive(role,phase) used by two settings/player-bar
components), so nothing UI-facing changes.

Repointed the 9 audio-core sites (playbackRateStore, previewStore,
playbackReportSession, nextAction, resumeAction, playTrackAction,
queueMutationActions, playAlbum) from @/features/orbit to @/store/orbitRuntime;
state reads (useOrbitStore.getState().role/.state) become orbitSnapshot().
Migrated 6 audio-core test mocks to @/store/orbitRuntime via importOriginal-spread
(keeps registerOrbitRuntime callable). enqueueShareSearchPayload stays on the orbit
barrel (share util, not audio core) — its test mock unchanged.

Decouple Step 3 — last seam. The audio ENGINE is now free of @/features/* runtime
imports (only type-only edges + the non-engine composerBrowseSessionStore browse
store remain). Unblocks the playback-core move + utils/library→lib.

tsc 0, lint 0, full suite 319/2353 green.
NEEDS Frank's live-session QA before relying on it (host+guest bulk-replace modal,
guest catch-up, rate/preview/scrobble suppression).
2026-06-30 14:19:22 +02:00
Psychotoxical 42ef09f1e4 refactor(decouple): media-resolver seam — invert resolve* off the audio core
Add core seam store/mediaResolver.ts: a registry (registerMediaResolver) with
delegating resolveAlbum/resolveArtist/resolvePlaylist + the pure auth wrappers
resolveMediaServerId/resolveAlbumForActiveServer/resolveAlbumForServer and the
ResolvedAlbum type. Default (unregistered) = network-only safety net (no
library-index/offline branch — those stay in the feature).

features/offline/utils/offlineMediaResolve keeps the offline-aware policy impls
and registers them at module init (registerMediaResolver). The call runs at boot:
AppShell eagerly imports the @/features/offline barrel, whose export * evaluates
offlineMediaResolve. The 3 thin wrappers + ResolvedAlbum move to the core seam and
are re-exported from offlineMediaResolve so the offline barrel still surfaces them
for the ~25 UI consumers (unchanged).

Repointed the 5 audio-core call sites (fetchTracksForSource, playAlbum,
playArtistShuffled, playByOpaqueId, luckyMixHelpers) from @/features/offline to
@/store/mediaResolver. Migrated playAlbum.test's mock to the new module via
importOriginal-spread (keeps registerMediaResolver callable when the offline
barrel loads transitively — partial-mock-collapse otherwise).

Decouple Step 2b. The audio core's only remaining @/features/offline reference is
now the type-only localPlaybackMigration import (erased). Runtime offline edge gone;
orbit seam (Step 3) remains.

tsc 0, lint 0, full suite 319/2353 green.
2026-06-30 14:08:43 +02:00
Hive Mind e23f879f91 fix(i18n): correct Polish locale typos and strings (#1223) 2026-06-30 14:52:11 +03:00
Psychotoxical bd5143d98c refactor(decouple): extract local-playback resolve substrate to store/
Pull findLocalPlaybackUrl/findLocalPlaybackEntry/hasLocalPersistentPlaybackBytes
+ the entry/index-key membership helpers (entryBelongsToServer,
indexKeyBelongsToServer, findFavoriteAutoEntry, hasLocal{Library,FavoriteAuto}Bytes)
out of features/offline/utils/offlineLibraryHelpers into a new core module
store/localPlaybackResolve.ts. The substrate depends only on authStore +
localPlaybackStore + serverIndexKey utils (no useOfflineStore), so the audio
core can resolve on-disk bytes without inverting into @/features/offline.

Repointed the audio-core consumers (crossfadePreload, playbackUrlRouting,
resolvePlaybackUrl, playTrackAction, promoteStreamCache, hotCachePrefetch,
hotCacheStore) + offline-internal callers + favorites hook; moved the two
barrel vi.mocks onto the new leaf module. offlineLibraryHelpers re-imports the
two primitives it still needs internally.

Decouple Step 2a. Removes the local-bytes offline edge from the audio core;
the resolve* media-resolution family (Step 2b) + orbit seam (Step 3) remain.

tsc 0, lint 0, full suite 319/2353 green.
2026-06-30 09:10:18 +02:00
Psychotoxical 36b0042d9a refactor(decouple): move coerceOpenArtistRefs to lib/api (off artist feature)
First step of decoupling the audio core from feature imports. coerceOpenArtist
Refs is a pure Subsonic-response normalizer (one-object-vs-array quirk) that was
mis-membered in features/artist but consumed by the audio core (songToTrack,
trackArtistRefs) and others. Relocate it beside its SubsonicOpenArtistRef type in
lib/api/; drop the artist-barrel re-export; repoint the 4 barrel consumers.

Removes the @/features/artist runtime edge from the audio core (utils/playback)
entirely. tsc 0, lint 0/0, suite 319/2353 green.

(composerBrowseSessionStore's ALL_SENTINEL import from @/features/artist remains
— that's a non-audio global browse store, separate from this decouple.)
2026-06-30 08:53:56 +02:00
Psychotoxical a27b26abbd fix(shortcuts): keep shortcut contract in config/ (Rust include_str! path)
The shortcut contract (shortcutActions.ts + shortcutTypes.ts) is NOT a
pure-frontend file: src-tauri/src/cli/parse.rs include_str!s
'/../src/config/shortcutActions.ts' at compile time, so moving it to
lib/shortcuts/ (slice 7ad19671) broke the Rust build (couldn't read the file ->
dev build won't start). Revert the contract back to src/config/ — it has a
build-time backend path contract and belongs where Rust anchors it, not in lib/.
No Rust file touched (fix = restore the frontend path the backend expects).

cargo check green (13s), tsc 0, lint 0/0, frontend suite 319/2353 green.

Lesson: grep src-tauri for include_str!/include_bytes! '../src/...' before
moving ANY frontend file — Rust can pin a TS file by path.
2026-06-30 08:46:59 +02:00
Psychotoxical d5bbabac1d refactor(queue): co-locate QueuePanel UI into features/queue
Extract the queue UI (QueuePanel + queuePanel/* components + useQueue* hooks)
into features/queue/ with a barrel. This is the chosen playback/queue boundary:
queue = the QueuePanel UI that CONSUMES the playback store; queue STATE and the
audio engine stay in store/ (playback-core, moved separately). Verified
one-way: no playback-core file imports the queue UI back (only doc comments
mention it).

Excluded as NOT queue-UI: useIdlePlayQueuePull (AppShell) and
usePlayQueueSyncLedState (ConnectionIndicator) are queue-SYNC orchestration,
not panel UI — left in hooks/ to move with playback.

Pure move. One test fix: QueuePanel.test.tsx reads its subject via a hardcoded
readFileSync('src/components/QueuePanel.tsx') path (architecture-pin grep for
forbidden HTML5 DnD) — repointed to the new location (the move tool can't
rewrite non-import string paths). tsc 0, lint 0/0, suite 319/2353 green.
2026-06-30 08:40:51 +02:00
Psychotoxical 7ad196711e refactor(lib): move generic hooks, DnD engine, shortcut contract to lib
Continue M4 lib/ de-flattening with domain-agnostic infra:
- lib/hooks/: 9 pure generic hooks (useDebouncedValue, useIsMobile,
  useLongPressAction, useRangeSelection, useResizeClientHeight,
  useWindowVisibility, useSystemPrefersDark, useVirtualizerScrollMargin,
  useRemeasureGridVirtualizer) — all PURE (only react/zustand/tanstack deps).
- lib/dnd/DragDropContext.tsx: the generic mouse-event DnD engine (WebKitGTK
  HTML5-DnD workaround, ~24 importers, self-contained) — empties src/contexts/.
- lib/shortcuts/: shortcutActions + shortcutTypes (the action-id + binding
  contract; registry/dispatch/bindings stay app-level and import the contract,
  app->lib direction).

useWindowFullscreenState deliberately NOT moved — kept in hooks/ as app-shell
per the handoff iron-rule list (overrides its pure-helper appearance).

Pure move via deep @/lib/* specifiers. tsc 0, lint 0/0, full suite 319 files /
2353 tests green.
2026-06-30 08:20:11 +02:00
Psychotoxical 209dd61442 refactor(lib): consolidate generic infra into src/lib
Move domain-agnostic, feature-free helpers out of the flat utils/ and store/
roots into src/lib/ (plan M4, §3 lib/ layer):
- lib/format/: formatBytes, formatClockTime, formatDuration, formatHumanDuration,
  relativeTime (pure format/date/byte/clock helpers)
- lib/i18n.ts: i18next bootstrap (global app infra)
- lib/util/: sanitizeHtml, platform, dedupeById, safeStorage (pure helpers +
  the zustand storage adapter)

playbackScheduleFormat stays in utils/format (runtime usePlayerStore dep =
audio-core coupling, not generic). formatClockTime keeps a type-only
@/store/authStoreTypes import (erased, no runtime inversion — accepted per the
deviceSync precedent).

Pure move via deep @/lib/* specifiers (no barrel → no mock-collapse surface).
tsc 0, lint 0/0, full suite 319 files / 2353 tests green.

Tooling note: lib_move.py regex extended to also rewrite side-effect imports
(import './i18n') and vi.importActual paths — both were silent gaps.
2026-06-30 08:07:04 +02:00
Psychotoxical 8cc022581f refactor(api): pull feature-resident subsonic clients into lib/api
Move the 5 subsonic api modules that M3 had co-located into features
(subsonicAlbumInfo/Artists/Playlists/Radio/Statistics) into src/lib/api/, the
feature-free infra layer, and drop their feature-barrel re-exports. Consumers
now import these protocol calls directly from @/lib/api/subsonic*; the feature
barrels export only domain UI/hooks/state.

This is the consistent api placement (plan §10.3, revised: ALL subsonic protocol
clients are feature-free infra, not per-feature) and it kills the documented
api-induced feature<->feature cycles: offline->artist/playlist (getArtist/
getPlaylist*), album->artist (getArtistInfo), deviceSync/nowPlaying/orbit->*.
Remaining artist<->album refs are UI-only (OpenArtistRefInline/coerceOpenArtist
Refs) and offline->playlist is store-level (usePlaylistStore) — both deeper,
tracked for M5, not api placement.

Pure move: import specifiers + vi.mock/spy targets repointed; no behaviour
change. tsc 0, lint 0/0, full suite 319 files / 2353 tests green.

Tooling note: barrel-imported and dynamic-import api symbols were split out of
@/features/* to @/lib/api/* (tsc-driven); 12 vi.mock barrels retargeted to the
deep lib module (mock-collapse sweep), AlbumHeader's UI mock left untouched.
2026-06-30 08:02:19 +02:00
Psychotoxical 7e9cb60763 refactor(api): co-locate server-protocol clients into lib/api
Move the Subsonic + Navidrome protocol clients and the library IPC facade
(subsonic*, navidrome*, library.ts + tests) from src/api/ into src/lib/api/,
the feature-free infra layer (plan M4, decision §10.3: shared client core → lib/).
Pure move: deep-path import specifiers @/api/* -> @/lib/api/* across ~150
consumers; no behaviour change. Domain REST (lyrics/events) and cover/analysis
infra stay in src/api/ pending their own M4 placement.

tsc 0, lint 0/0, full suite 319 files / 2353 tests green.
2026-06-30 07:53:25 +02:00
Psychotoxical 429e632598 test(m3): complete genreAlbumBrowse mock pulled in via album barrel
The album barrel reaches genreBrowsePlayback.test's transitive graph through the
artist↔album edge (songToTrack → @/features/artist → @/features/album →
useGenreAlbumBrowse), which needs GENRE_ALBUM_FIRST_PAGE from the partially
mocked ./genreAlbumBrowse. Spread importOriginal so it stays real.
2026-06-30 01:54:32 +02:00
Psychotoxical fecda65f11 test(m3): retarget collapsed barrel mocks to deep submodules
The move tool rewrote single-module mocks (e.g. vi.mock('@/utils/orbitBulkGuard'))
into feature-BARREL mocks (vi.mock('@/features/orbit', ...)). A partial barrel
factory shadows every other barrel export to undefined — and src/test/helpers/
storeReset.ts reads useOrbitStore.getState() at module load, so 6 test files
(playerStore.misc/events/queue/playbackActions, ContextMenu, QueuePanel) crashed
at collection (0 tests run, mis-read as flaky teardown). Retarget each collapsed
mock to its deep submodule so the barrel re-exports the stub while siblings
(useOrbitStore, offlineActionPolicy, usePlaylistStore) stay real.
2026-06-30 01:50:37 +02:00
Psychotoxical 862941c145 refactor(playlist): co-locate playlist feature into features/playlist 2026-06-30 01:37:05 +02:00
Psychotoxical 2a425862ae refactor(album): co-locate album feature into features/album 2026-06-30 01:33:29 +02:00
Psychotoxical 9ffc42688c refactor(artist): co-locate artist feature into features/artist 2026-06-30 01:27:01 +02:00
Psychotoxical 17ea96dcdb test(offline): mock useOfflineBrowseReloadToken submodule in useSongBrowseList
The offline move collapsed a single-module mock into a `@/features/offline`
barrel mock, which shadowed the real `useOfflineBrowseContext` the test relied
on. Mock the reload-token submodule directly so the barrel re-exports the stub
while the sibling context hook stays live.
2026-06-30 01:26:46 +02:00
Psychotoxical 321d5ff6ab refactor(orbit): co-locate orbit feature into features/orbit 2026-06-30 01:02:58 +02:00
Psychotoxical d6dbb615fd refactor(offline): co-locate offline feature into features/offline 2026-06-30 00:56:08 +02:00
Psychotoxical 70c145db06 refactor(waveform): move waveform data pipeline back to audio-core
The M0 pilot mis-membered the waveform data pipeline into features/waveform,
creating a core->feature inversion: ~12 audio-core files in store/ imported
refreshWaveformForTrack / fetchWaveformBins / bumpWaveformRefreshGen from the
feature barrel. These are playback coordination (write waveformBins into
playerStore on track change, last-write-wins gen guard), not seekbar rendering.

Move waveformRefresh + waveformRefreshGen (+ tests) to src/store/ and
waveformParse (+ test) to src/utils/waveform/ (beside waveformSilence). The
feature now holds only the seekbar UI (WaveformSeek/SeekbarPreview + their
hooks/render utils); its barrel exports UI only. Core consumers import the
pipeline directly from @/store/*. Restores the no-core->feature-inversion rule.
2026-06-30 00:07:34 +02:00
Psychotoxical e945da693a refactor(settings): co-locate settings feature into features/settings
Theme infra (fixedThemes, theme utils), music-network presetIcon, audio-device
probe, library-index sync, and the auth*SettingsActions slices stay in the
core/global layer (consumed via @/ alias) to avoid inverting core->feature.
2026-06-29 23:48:19 +02:00
Psychotoxical fbc37db64e refactor(search): co-locate search feature into features/search 2026-06-29 23:42:22 +02:00
Psychotoxical 931c47e19e refactor(nowPlaying): co-locate now playing feature into features/nowPlaying 2026-06-29 23:38:09 +02:00
Psychotoxical e0e10e0034 refactor(sidebar): co-locate sidebar feature into features/sidebar 2026-06-29 23:26:00 +02:00
Psychotoxical e6269c7b85 refactor(miniPlayer): co-locate mini player feature into features/miniPlayer 2026-06-29 23:22:00 +02:00
Psychotoxical 6dd0d4a130 refactor(fullscreenPlayer): co-locate fullscreen player feature into features/fullscreenPlayer 2026-06-29 23:17:14 +02:00
Psychotoxical 896fe3f407 refactor(radio): co-locate internet radio feature into features/radio 2026-06-29 23:04:34 +02:00
Psychotoxical dbffe59e58 refactor(favorites): co-locate favorites feature into features/favorites 2026-06-29 22:59:21 +02:00
Psychotoxical ea3e3f1adf refactor(deviceSync): co-locate device sync feature into features/deviceSync 2026-06-29 22:55:35 +02:00
Psychotoxical 236acc33dd refactor(stats): co-locate statistics feature into features/stats 2026-06-29 22:51:54 +02:00
Psychotoxical 431d65b587 refactor(ui): co-locate CustomSelect as a shared primitive in src/ui 2026-06-29 22:36:43 +02:00
Psychotoxical 53b244de1f refactor(ui): co-locate CachedImage as a shared primitive in src/ui 2026-06-29 22:36:12 +02:00
Psychotoxical bd2d669b99 refactor(ui): co-locate OverlayScrollArea as a shared primitive in src/ui 2026-06-29 22:35:41 +02:00
Psychotoxical 146727e980 refactor(ui): co-locate TooltipPortal and tooltipAttrs as shared primitives in src/ui 2026-06-29 22:35:14 +02:00
Psychotoxical a18f8fc857 refactor(ui): co-locate BackToTopButton as a shared primitive in src/ui 2026-06-29 22:34:34 +02:00
Psychotoxical 6d7464fddf refactor(waveform): co-locate seekbar feature into features/waveform 2026-06-29 22:30:53 +02:00
Psychotoxical d9f8d01be9 build(restructure): wire @/ alias into vite dev/build resolver 2026-06-29 22:15:57 +02:00
Psychotoxical e66b674937 Revise README for feature enhancements and Polish language (#1221)
Updated README.md to reflect new features and language support.
2026-06-29 20:20:13 +02:00
Psychotoxical e1ff4385d6 feat(settings): add "Square Corners" appearance toggle (#1215)
* feat(settings): add "Square Corners" appearance toggle

A Display toggle in Settings → Appearance → Visual Options that overrides
the active theme to render cards and cover art with square (non-rounded)
corners, for a sharper, boxy look.

Covered surfaces: grid cards (album/playlist/artist/song/because) and
their covers, detail-header heros (album/playlist/tracks), the Now
Playing / Radio view, the fullscreen player, the cover lightbox, the
queue panel cover, and the mini player.

Persisted in themeStore and driven by an `html[data-square-corners]`
attribute set in App.tsx — applied in both the main and mini-player
webviews (the mini player rehydrates the theme store on the shared
`storage` event). Strings added for all 12 locales.

* docs(changelog): add Square Corners to 1.50.0 CHANGELOG and What's New
2026-06-29 06:48:26 +02:00
Psychotoxical 4c102d88a5 chore(aur): bump PKGBUILD to v1.49.0 (#1214) 2026-06-29 03:09:23 +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
cucadmuh 6b1a898151 docs(release): sync 1.49.0 CHANGELOG, What's New, and credits (#1211)
Set the 1.49.0 release date, fix Added PR order for Polish (#1185),
refresh WHATS_NEW highlights for missing user-facing fixes, and credit
ImAsra for WinGet release automation (#1077).
2026-06-29 02:33:40 +03:00
Psychotoxical 19860672eb Update TELEMETRY.md (#1210)
added fanart to external services
2026-06-28 23:20:22 +02:00
Psychotoxical 2b80a87bfc refactor(settings): shared id-based reorder for the customizer panels (#1207)
Extract the drag-to-reorder boilerplate the sidebar, artist-layout, lyrics,
queue-toolbar and servers customizers each hand-rolled into a single
`useListReorderDnd` hook plus an id-based `applyListReorderById`, and migrate
all five onto it. Reorders resolve by stable item id, never positional index,
so a render filter can never share an index space with the reorder and desync
it (the class behind #1164). No user-facing change.

- New: useListReorderDnd, applyListReorderById (+ tests), ReorderGripHandle.
- applySidebarReorderById now builds on the shared core; section/conserved
  guards stay sidebar-specific.
- Queue/mini-queue/playlist reorder stay backend-index based (out of scope).
2026-06-28 16:17:31 +02:00
Psychotoxical fcdb58e0d8 fix(sidebar): reorder sidebar nav items by id to stop drag drift (#1206)
* fix(sidebar): reorder nav items by id instead of positional index

The Settings sidebar customizer rendered library rows with one filter and
reordered them with another, so a hidden/gated item (luckyMix when AudioMuse
is unavailable) made the rendered list shorter than the reorder list and drag
indices landed one slot off — rows jumped back on drop. Replace the index-based
reorder with an id-based primitive shared by the customizer and the in-sidebar
long-press DnD, so a render filter can no longer desync the move. Unknown,
cross-section, conserved, or no-op drops are rejected.

* docs(changelog): note sidebar reorder fix (#1206)
2026-06-28 15:42:35 +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 979eb85ad1 docs(whats-new): refresh the 1.49.0 release highlights (#1203) 2026-06-27 22:36:16 +02:00
Psychotoxical 604cdd54d6 feat(updater): show the WinGet update command in the Windows update modal (#1202)
* feat(updater): show WinGet update command in the Windows update modal

* docs(changelog): WinGet update command in the Windows update dialog
2026-06-27 15:56:54 +02:00
Psychotoxical 6d4d82d6a3 fix(playlist): stop the detail page reloading on Play/Shuffle/Enqueue (#1201)
* fix(playlist): stop the detail page reloading on Play/Shuffle/Enqueue

* docs(changelog): playlist play no longer reloads the page
2026-06-27 15:24:28 +02:00
Psychotoxical 7e6a2100e5 fix(updater): hold Windows update notice during WinGet moderation window (#1200)
* fix(updater): hold Windows update notice during WinGet moderation window

* docs(changelog): Windows update notice WinGet moderation delay
2026-06-27 14:50:37 +02: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
ImAsra c609beddfa add GitHub Action to automate WinGet package updates (#1077)
* Add GitHub Actions workflow for WinGet publishing

* Delete .github/workflows/winget-publish.yml

* Add GitHub Actions workflow for WinGet publishing

* Update winget-publish workflow to derive version
2026-06-27 13:38:46 +03:00
Psychotoxical d70060923b refactor(settings): interface scale as a segmented picker (#1197)
Replace the Interface Scale zoom slider and its tick row with the shared
SettingsSegmented control — one button per preset (80–150%), matching the
Lyrics, Queue and Track-transitions sections. Drops the slider/tick
alignment issue entirely. Legacy off-preset values snap to the nearest
preset so one button is always active. Removes the now-dead slider CSS.
2026-06-27 02:11:09 +02:00
Psychotoxical d5ad275851 fix(hero): mainstage backdrop falls back to the album cover, reorder defaults (#1196)
* fix(hero): use the album cover as the mainstage backdrop's last fallback

The mainstage hero inherited the artist-detail fallback chain, ending on
the Navidrome artist image. The hero frames an album, so its last-resort
backdrop should be the album's own cover (the layer shown when the
feature is off), not the artist image. Resolve it scope-true from the
album cover ref already in hand. Artist-detail keeps the artist cover.

* fix(hero): default backdrop order to fanart, Navidrome, then banner

Lead both heroes with the artist fanart and fall back to the wide banner
last, instead of trying the banner first. The fullscreen player keeps
fanart then Navidrome (it has no banner). Affects defaults / reset only;
saved per-surface orders are untouched and remain user-reorderable.
2026-06-27 01:46:01 +02:00
Psychotoxical c59bf937e4 refactor(settings): unify exclusive pickers on a shared segmented control (#1195)
Extract a reusable SettingsSegmented helper for the settings-segmented
pill picker. Track transitions adopts it unchanged (mode picker + AutoDJ
overlap cap). Lyrics scroll style and Queue display mode move from
stacks of mutually-exclusive toggles to the segmented picker, with the
active option's description shown below.
2026-06-27 01:14:51 +02:00
Psychotoxical 86ae462ad6 feat(hero): album-artist backdrop, configurable per-surface sources, and prefetch (#1193)
* refactor(cover): extract shared pickArtistBackdrop priority helper

* feat(hero): show the album artist's fanart as the mainstage hero backdrop

* feat(settings): configurable per-surface artist backdrop sources

Each artist-backdrop surface (mainstage hero, artist-detail header, fullscreen player) gets its own enable toggle + an ordered, individually-toggleable source list, configured under External Artwork Scraper on the Integrations tab (shown when the scraper is on). Reorder via the shared useDragSource/psy-drop drag infra plus keyboard-accessible up/down buttons; each source has its own on/off.

The shared chooser pickArtistBackdrop is generalised to resolveBackdrop + backdropFromConfig (ordered candidate list with the same pending/miss/centred-framing semantics), so all three surfaces resolve identically. themeStore persist bumped to v2; defaults reproduce today's order, so there is no visible change without user action.

Gating decoupled: the three surfaces are gated solely by their own per-surface flag, not by enableCoverArtBackground (which stays scoped to album/playlist-header cover blur). Reorder maths extracted to a pure, unit-tested module. i18n en + de (other locales TODO before PR).

Tests: resolver (10) + reorder (7).

* feat(cover): make the ensure queue surface-aware for artist backdrops

coverEnsureQueued now threads optional CoverEnsureOpts through to the Rust ensure and weaves the external surface into the in-flight key, so the fanart and banner surfaces of one artist no longer collide on one download chain. External surfaces also bypass the disk-src memory short-circuit (their {tier}-{surface}.webp never seeds those caches, and the canonical cover must not read as a hit). New thin ensureArtistBackdropQueued wrapper. Backward-compatible: plain covers append nothing to the key; the 5 queue tests stay green.

* fix(cover): reset the artist external-image hook synchronously on artist change

The hook reset src in an effect (one render late), so for the render between an artist change and that effect a consumer read the *previous* artist's resolved image. The mainstage hero then froze (and cached into per-album memory) a neighbouring slide's banner. Reset synchronously via the React adjust-state-on-prop-change pattern. Also removes brief stale-image flashes on the artist-detail header and fullscreen player.

* feat(hero): prefetch artist backdrops and show-ready-now / upgrade-on-re-entry

warmHomeMainstageCovers now prefetches each hero slide's artist backdrop (banner/fanart) at static slide-index priorities (idx1=high, idx0=low, rest=middle; no reprioritise on navigation), then predecodes every slide already on disk. useHeroBackdrop shows the best source ready at entry (Navidrome on a cold first visit, the prefetched/cached external one on re-entry) and freezes that source choice for the visit so nothing swaps mid-dwell; the url is derived live from the frozen choice, with a per-album disk memory for re-entry. HeroBg now crossfades only after the image bytes load (onLoad/complete gate + onError + fallback). Inert when the scraper is off. Tests: per-album memory (5).

* docs(changelog): configurable artist backdrops + mainstage hero (PR #1193)

CHANGELOG Added entry, settingsCredits line, and the What's New Artist-artwork highlight extended to cover the mainstage hero backdrop and per-place source config.

* docs(changelog): fold mainstage hero + per-place backdrops into the fanart entry (PR #1193)

Merge the configurable-backdrops changes into the existing 'Artist artwork from fanart.tv' block (now PR #1137 and #1193) instead of a separate entry.

* fix(hero): revert HeroBg load-gate that blanked the app (Maximum update depth)

The byte-load gate I added drove the crossfade reveal from an inline img ref + onLoad that re-fires on every render and scheduled a setTimeout each call; frequent re-renders (playback, marquee) stacked nested updates until React threw 'Maximum update depth exceeded' — and with no ErrorBoundary the whole window blanked. Reverted HeroBg to the proven timer-based crossfade. The hero only ever receives ready/predecoded urls, so the gate was cosmetic.

* fix(hero): gate the HeroBg crossfade on image load to stop the slide flicker

The bare 20 ms reveal faded a layer in before its bytes were ready (notably the Navidrome raw url), so a slide change flickered. Now an Image() preloader in the [url] effect reveals the layer on load (or a cached complete check), with a fallback. Everything is scheduled once per url — no per-render <img> ref/onLoad — so unlike the reverted gate it can't stack nested updates.

* feat(i18n): translate the backdrop-source settings into the remaining 10 locales

Adds the per-surface backdrop config strings (es, fr, nl, zh, nb, ru, ro, ja, hu, pl) and extends externalArtworkDesc to mention the mainstage hero where the key exists (ja has none → falls back to en).
2026-06-27 00:42:32 +02:00
Psychotoxical 014d57c53c feat(app): top-level error boundary so a render error no longer blanks the app (#1194)
* feat(app): add a top-level error boundary so a render error no longer blanks the app

Until now any thrown error during render took the whole window down with no recovery (issue #382). A class ErrorBoundary around the authenticated shell catches it and shows a recoverable fallback (Try again / Reload app) while playback keeps going. Hook-free + English-only with literal CSS var() fallbacks so it renders even when i18n/theme/state is what broke.

* docs(changelog): error boundary prevents the app blanking on a render error (PR #1194)
2026-06-26 20:51:42 +02:00
kilyabin afe5b377e0 feat(i18n): Russian locale — missing strings (#1181) 2026-06-25 21:22:45 +00:00
Hive Mind eb42a32315 feat(i18n): add Polish (pl) translation (#1185)
* Translated settings. Added polish option to other languages and in the fronted UI itself. Translated settings to polish

* Corrections to the settings. Translated help page

* Translated orbit. Made corrections to help and settings

* playlists translated

* deviceSync translated

* search translated

* Statistics translated

* musicNetwork translated

* common translated

* player translated

* queue translated

* albumDetails translated

* connection and random mix translated

* artistDefails translated

* albums translated

* smartPlaylists translated

* nowPlaying translated. smartPlaylists corrections

* sidebar translated

* contextMenu translated

* translated radio

* translated sharePaste

* favorites translated

* nowPlayingInfo translated

* translated home and login

* translated artists, genres, licences, migration, mostPlayed, songInfo and tracks

* translated changelog, composerDetail, composers, entityRating, folderBrowser, hero, losslessAlbums, luckyMix, miniPlayer, randomAlbums, randomLanding, tray and whatsNew

* chore(release): CHANGELOG and credits for Polish locale (PR #1185)

* chore(release): mention Polish in 1.49.0 What's New highlights

* settings updated with new strings. Deleted old unecessary ones

* translated missing translations

* Fixed typos

* translated missing strings

* Added missing translations that appeared in #1186 and #1189
2026-06-25 21:32:52 +03:00
cucadmuh cea814e993 fix(connection): strengthen sidebar recovery after reconnect (#1160) (#1190) 2026-06-25 19:52:40 +03:00
Psychotoxical 00512df207 feat(playlist): sort playlist tracks by date added (#1191)
* feat(sort-dropdown): support a right-aligned popover via align prop

* feat(playlist): sort playlist tracks by date added

* docs(changelog): playlist sort by date added (#1191)

* docs(credits): playlist sort by date added (#1191)
2026-06-25 18:29:35 +02:00
Psychotoxical d49424e95b feat(settings): add a Compact buttons appearance toggle (#1189)
* feat(ui): prototype compact hero and toolbar buttons (Large/Small appearance setting)

* feat(settings): rename action-button size toggle to "Compact buttons"

Promote the hero-button prototype to a real, app-wide setting.

- rename heroButtonSize → buttonSize, data-hero-buttons → data-button-size,
  hero-action-bar/hero-btn-label → compact-action-bar/compact-btn-label
- relabel "Hero buttons" → "Compact buttons" and broaden the description
  (action + toolbar buttons across detail pages and browse views) in all 11 locales
- move the feature CSS out of cover-lightbox.css into its own compact-buttons.css
- add tests: themeStore buttonSize toggle, SelectionToggleButton

* docs(changelog): compact buttons appearance toggle (#1189)

* docs(credits): compact buttons appearance toggle (#1189)
2026-06-25 14:49:09 +02:00
Psychotoxical 8b89596fcf feat(album): show all genres in album details, linkable to genre pages (#1186)
* feat(album): show all genres in album details, linkable to genre pages

The album header listed only a single genre. It now renders every genre
from the album's OpenSubsonic genres[] (falling back to splitting the
legacy genre string), each linking to its genre page via the existing
genre route.

* docs(changelog): multiple genres in album details (#1186)

* refactor(genres): extract genreColor into a shared util

* feat(album): union album and track genres behind a +N cursor menu

* feat(album): read album detail from the library index when ready

* docs(changelog): expand the album genres entry

* feat(album): genre pill row with keyboard-navigable popover

* test(offline): cover index-first resolveAlbum path with mocked libraryIsReady

* refactor(styles): move album-detail genre styles out of cover-lightbox.css
2026-06-25 13:16:24 +02:00
cucadmuh 4aa6427727 feat(login): custom HTTP headers on initial Add Server dialog (#1187) (#1188) 2026-06-25 13:44:50 +03:00
cucadmuh a88d5f3181 fix(autodj): last-track tail and queue-end idle pull rewind (#1183) 2026-06-25 00:31:12 +03:00
Psychotoxical b6ac879b14 refactor(settings): SettingsSubCard primitives across all of Settings (#1182)
* feat(settings): add SettingsSubCard primitives for sub-section controls

Reusable wrappers (SettingsSubCard / SettingsField / SettingsRow /
SettingsValue / SettingsCallout) that encapsulate the settings-norm-* sub-card
styles. Per-mode and detail controls inside a settings section should use these
instead of hand-rolling the box with inline padding or one-off classes — the
lack of a shared primitive is what let recent additions drift off the pattern.

* refactor(settings): move the Audio tab onto the SettingsSubCard primitives

Normalization, Track transitions and Hi-Res now build their sub-cards from the
shared primitives instead of raw settings-norm-* markup. Track Previews, which
rendered its sub-options bare with inline styles and dividers, now uses the same
sub-card so the whole Audio tab is consistent. Behaviour and styling unchanged;
Track Previews' start/duration become labelled slider rows like Normalization.

* refactor(settings): move the remaining tabs onto SettingsSubCard

Apply the shared sub-card primitives across the rest of Settings so every tab
matches the Audio-tab reference look:

- Appearance: grid columns, window buttons, UI scale, font, seekbar
- Library: random-mix blacklist, rating-filter detail
- Input: in-app + global keybind lists
- Integrations: Discord templates, external-artwork BYOK
- System: language, Linux Wayland text render, clock format, logging
- Storage: media dir, hot-cache detail, downloads folder
- Backup: mode picker + per-mode export/import

Detail/per-mode controls now sit in the bordered sub-card; toggle groups stay on
SettingsToggle. Behaviour unchanged.

* refactor(settings): box the data-heavy and shared sections in SettingsSubCard

Bring the previously-skipped 'borderline' sections onto the sub-card too:

- Playback speed: wrap the settings-mode controls in a sub-card; the compact
  player-popup path is left untouched (shares the component via the compact flag)
- Theme scheduler: mode picker + theme/time selects move into the sub-card
- Cover-cache + analytics strategy: the per-server tables sit in a sub-card

Behaviour unchanged; the table layouts and the playback-rate-* internals stay as
they are, only the surrounding sub-card is added for visual consistency.
2026-06-24 23:13:10 +02:00
cucadmuh 4e8f16b8ae docs: refresh WHATS_NEW and CHANGELOG for 1.49.0 (#1180) 2026-06-25 00:04:16 +03: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
Psychotoxical 1031590742 fix(settings): align AutoDJ and Hi-Res sub-options with the Normalization design (#1175)
* fix(settings): box the AutoDJ and Hi-Res sub-options like Normalization

The AutoDJ overlap-cap and the Hi-Res blend-rate options sat bare in their
sections instead of in the bordered sub-card the Normalization block uses for
its per-mode controls. Wrap both (and the crossfade-seconds slider, for
consistency within Track transitions) in the shared settings-norm-block card,
and drop the redundant SettingsGroup wrapper from the Hi-Res block — the Audio
tab already wraps that section in a group, so the block added a second border.

* docs(changelog): audio sub-section design fix; move recent entries to the end of Fixed

The #1172 and #1174 entries were prepended to the Fixed section instead of
appended; move them to the end where new entries belong, and add the #1175
audio settings entry.
2026-06-24 21:53:04 +02:00
Psychotoxical 9bbe69e7e7 fix: context menu Play Now playback and resize behaviour (#1174)
* fix(playlist): play the playlist from the context menu Play Now action

The Playlists-page context menu 'Play Now' only navigated to the playlist
detail page instead of starting playback. It now loads the playlist's songs
and plays them via the shared playPlaylistAll action (same path as the detail
page 'Play All' button), with a guard against load failures.

* fix(ui): close the context menu on window resize

The context menu is absolutely positioned at fixed coordinates, so resizing
the window left it stranded and drifting off-screen. Whether a resize closed
it was inconsistent across setups (it stayed open on some Windows and Linux
environments). Always close it on resize so the behaviour is the same
everywhere.

* docs(changelog): context menu Play Now and resize fixes (#1174)

* docs(changelog): credit the reporter for the context menu fixes
2026-06-24 21:19:04 +02: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 0b7b5df30c test(ui): smoke-test the #1165 hook-guard split and recent-search rename (#1168)
* test(cover): smoke-test CoverArtImage hook-guard split (#1165)

* test(search): smoke-test MobileSearchOverlay recent-search (#1165)
2026-06-24 17:49:13 +02:00
cucadmuh de36f79e46 chore(ci): ESLint workflow and path-aware ci-ok merge gate (#1170) 2026-06-24 18:42:59 +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
Psychotoxical 7c724a642f chore(eslint): add ESLint toolchain and clean src to strict 0/0 (#1165)
* chore(eslint): add eslint toolchain and configs

* fix(eslint): resolve gradual-config errors (rules-of-hooks, no-empty, prefer-const, …)

* chore(eslint): clear unused vars in config, contexts, app and test

* chore(eslint): clear unused vars in api and music-network

* chore(eslint): clear unused vars in utils

* chore(eslint): clear unused vars in store

* chore(eslint): clear unused vars in cover and hooks

* chore(eslint): clear unused vars in components

* chore(eslint): clear unused vars in pages

* chore(eslint): remove explicit any in src

* chore(eslint): align react-hooks exhaustive-deps

* chore(eslint): zero gradual config on src

* chore(eslint): strict hook rules in store and utils

* chore(eslint): strict hook rules in hooks and cover

* chore(eslint): strict hook rules in components

* chore(eslint): strict hook rules in pages and contexts

* chore(eslint): document scripts ignore in eslint config

* chore(eslint): add lint script and zero strict findings

* chore(eslint): address review round 1 (gradual 0/0, per-site disable reasons)

* chore(eslint): tighten two set-state disable comments (review round 2 LOW)

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

* docs(changelog): add Under the Hood entry for ESLint setup (PR #1165)

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-24 01:33:34 +02:00
Psychotoxical c7d76af790 feat(themes): add follow-system mode to the theme scheduler (#1163)
* feat(themes): add follow-system mode to the theme scheduler

The theme scheduler can now switch the day/night theme pair by the OS
light/dark preference instead of a clock schedule. A segmented control
picks the trigger; in system mode the time inputs are hidden and the two
theme pickers read as Light/Dark.

The OS theme is read via the native Tauri window theme API (theme() +
onThemeChanged) rather than the Web prefers-color-scheme media query,
which is unreliable through WebKitGTK on Linux. Live updates land
instantly where the platform forwards them; on Linux setups that don't,
a hint notes the change applies after an app restart.

* docs(changelog): add follow-system theme mode entry (#1163)

CHANGELOG Added entry, contributor credit and What's New highlight for the
theme scheduler's new system-theme mode.
2026-06-23 12:13:26 +02:00
Psychotoxical 78a177b1bc fix(orbit): outbox lost-update recovery and session hardening (#1159)
* fix(orbit): harden the guest outbox — recover lost suggestions, avoid a duplicate outbox

Two outbox robustness issues on the poll-based, non-atomic playlist
transport:

- Lost-update: a track a guest appends to its outbox can be wiped by a
  concurrent host sweep-clear before the host recorded it, leaving the
  suggestion lost AND stuck on "waiting on host" forever (it only clears
  once it reaches the host's play queue). The playlist read-modify-write
  can't be made atomic, so recover: re-send a pending suggestion the host
  hasn't recorded (absent from state.queue) past a grace window — the host
  dedupes by (user, trackId), so it's idempotent — and give up + toast past
  a 45s window so the row stops hanging. New pendingResend planner module
  with unit tests; suggest + tick share ensureTrackInOutbox.

- Duplicate outbox: a transient getPlaylists failure at join was swallowed
  to [], so the existing-outbox check missed and a second outbox was
  created. Distinguish "lookup failed" from "genuinely absent" and retry
  once before falling back to create.

New i18n key for the give-up toast across all locales.

* fix(orbit): reject non-http(s) schemes in share invites

normalizeShareServerUrl only prepends http:// when the server string doesn't
already start with "http", so an invite carrying a scheme like httpx:// or
https-phish:// slips through unchanged. Reject anything whose parsed protocol
isn't http(s) at parse time, before the known-server match. Add round-trip
tests covering the accepted and rejected schemes.

* fix(orbit): read exit-modal role fresh to avoid a stale keydown closure

The exit modal's Enter/Escape handler binds once per open (deps [isOpen]) and
closed over the role prop, so a role change while the modal was up would act
on a stale value. Read role from the store inside the action instead, and drop
the now-unused reactive subscription.

* docs(changelog): consolidate Orbit fixes into one block, add 1159
2026-06-22 17:32:53 +02:00
Psychotoxical 9b03949f34 feat(orbit): sync the host's track-transition settings to guests (#1158)
* feat(orbit): mirror host track-transition settings into the session

Each client previously used its own local crossfade / gapless / AutoDJ
settings, so guests blended tracks differently from the host and drifted
out of sync — the exact drift the Catch-Up button exists to paper over.

Add an optional OrbitSettings.transitions (crossfade enabled/secs/trim,
AutoDJ smooth-skip, gapless), additive on the wire so older sessions
simply omit it. The host seeds its own prefs at start and refreshes them
every tick, so a mid-session change propagates. Guests snapshot their own
prefs on join, adopt the host's for the session (idempotent — no setState
churn / audio-sync re-fire when unchanged), and restore their own on
leave. Applying via authStore.setState reuses the existing authSyncListener
path to the Rust engine, so no new IPC. Without the Hot cache a guest's
AutoDJ blend degrades gracefully via the existing byte-preload fallback.

Add a bridge module with unit tests for read/apply/save/restore.

* feat(orbit): lock guest transition controls during a session

Since a guest now mirrors the host's track-transition settings (re-applied
every read tick), their own controls would visibly fight the sync. Disable
them while a guest is in a live session and explain why.

Settings -> Audio -> Track transitions greys out the mode picker, seconds
slider and smooth-skip toggle with a "controlled by the Orbit host" note;
the queue toolbar's Gapless / Crossfade / AutoDJ quick-toggles get the same
disabled state and tooltip. Host controls are untouched. New i18n key added
across all locales.

* docs(changelog): add Orbit transition sync (1158)
2026-06-22 16:44:20 +02:00
Psychotoxical a4fbd45d5d fix(orbit): host-side session guards — settings default, maxUsers, push serialisation (#1157)
* fix(orbit): unify session-settings default to the canonical preset

updateOrbitSettings merged patches onto a hand-rolled
{ autoApprove: true, autoShuffle: true } fallback when a legacy session's
blob predated the settings field. That literal disagreed with
ORBIT_DEFAULT_SETTINGS (autoApprove: false), so toggling autoShuffle on
such a session silently flipped autoApprove on, landing guest suggestions
without host approval. Use ORBIT_DEFAULT_SETTINGS as the single source of
truth for the fallback. Add a behavioural test for the settings-less,
preserve-existing, and not-hosting paths.

* fix(orbit): enforce maxUsers host-side in the participant rebuild

The join gate only sees a one-tick-old state blob, so two guests can both
pass the participants.length < maxUsers check and join past the cap at
once; maxUsers was advisory. The host is the single writer of the
canonical state, so enforce the cap there: admit at most maxUsers guests
in the participant rebuild, earliest joiners winning (an established
participant is never displaced by a newcomer) with a deterministic
username tie-break for same-tick joins. Suggestions from an over-cap guest
are ignored, consistent with their absence from the participant list.
Reorders the fold so the admitted set gates the queue-additions loop. Add
tests for the cap, the no-displacement rule, and the ignored over-cap
suggestion.

* fix(orbit): serialise host state pushes to stop overlapping ticks dropping writes

pushState does several awaited round-trips (sweep -> resolve -> write) and
is fired from three sources: the mount, the 2.5 s timer, and a play/pause
subscription. With no in-flight lock these can overlap and race
last-writer-wins: a slow run that already swept and cleared an outbox can
lose its write to a faster run, dropping those suggestions from both the
outbox and the published state. The lastPushedAtRef was only ever written,
never used as a guard.

Add makeCoalescedRunner: a pure helper that serialises an async task and
coalesces any mid-flight request into a single rerun, so no trigger is
lost. The host hook wraps pushState in it; the dead ref is removed. Add a
unit test for the serialise + coalesce semantics.

* docs(changelog): add Orbit host-side session guards (1157)
2026-06-22 15:25:39 +02:00
cucadmuh 15cecb5d7d feat(server): custom HTTP headers for reverse-proxy gates (#1095) (#1156) 2026-06-22 16:25:28 +03:00
Psychotoxical 2c9b2eeb46 fix(orbit): cleanup regex, state-blob budget, radio top-up lockout (#1155)
* fix(orbit): stop cleanup sweep from deleting live sessions on other devices

The orphan-cleanup regex anchored the trailing `__` inside the optional
`_from_…` group, so the canonical session playlist name
(`__psyorbit_<sid>__`) never matched. It then fell into the
"unrecognised → prune" branch, which deletes unconditionally and bypasses
the orphan-TTL grace that exists precisely to protect a session running on
the user's other device. Opening Psysonic on a second device wiped the
first device's live session out from under its guests.

Move the trailing `__` outside the optional group so both the session and
outbox names match; the existing sid/TTL/ended logic then applies as
intended. Add a behavioural test covering keep-fresh / prune-stale /
prune-ended / skip-current / outbox / corrupt / foreign-owner cases.

* fix(orbit): keep host publishing when the state blob grows past budget

OrbitState.queue (the suggestion/attribution history) was append-only at
the sweep-fold step and never bounded. On a long session it grew until the
serialised blob exceeded ORBIT_STATE_MAX_BYTES; serialiseOrbitState then
threw, writeOrbitState's caller swallowed the error and retried the same
over-budget state every tick, so the host silently stopped publishing and
guests froze into a host-timeout.

Two layers:
- Cap queue at ORBIT_QUEUE_HISTORY_LIMIT in applyOutboxSnapshotsToState,
  evicting oldest-by-addedAt (robust to the periodic queue shuffle). The
  dropped tracks have long since played, so their attribution/dedupe entry
  is dead weight.
- Add serialiseOrbitStateForWire: a budget-aware serialiser that sheds
  oldest history, then the play-queue tail, on a copy before giving up.
  writeOrbitState now uses it, so a transient over-budget tick degrades the
  published blob instead of taking the host offline. Local store state is
  untouched.

Tested: history cap eviction order, wire-trim byte budget + oldest-first
drop, play-queue fallback, and within-budget passthrough.

* fix(orbit): lock out the proactive radio top-up during a session

The ≤2-remaining radio top-up in runNext lacked the isInOrbitSession()
guard that its infinite-queue sibling has at both entry and resolution.
A guest who joined with residual radioAdded tracks (before the first
syncToHost replaces the queue) could fire it, appending up to 10 unrelated
radio tracks and trimming queue history — drifting the guest off the host's
playlist.

Add the guard at entry and re-check inside the resolution .then(), mirroring
the infinite-queue branch; the existing finally() still clears the fetching
flag. The queue-exhausted radio fallback already returns early in Orbit, so
only this mid-queue path was unguarded.

* docs(changelog): add Orbit cleanup / state-budget / radio fixes (#1155)

* docs(changelog): move Orbit fixes to bottom of Fixed section (#1155)
2026-06-22 13:33:09 +02:00
Psychotoxical bd43ea5240 fix(playlists): wrap header action buttons instead of overflowing (#1153)
* fix(playlists): wrap header action buttons instead of overflowing

The Playlists header action row was a fixed flex row, so at narrow
widths (e.g. with the queue panel open) the buttons overflowed and
were clipped off-screen. Add flex-wrap so they wrap, left-aligned,
matching the Albums toolbar behaviour.

* docs(changelog): add 1.49.0 entry for Playlists header button wrap (#1153)
2026-06-22 03:05:38 +02:00
cucadmuh 9a31fe8295 fix(library): avoid UTF-8 panic in artist article strip (#1152) 2026-06-22 03:12:45 +03:00
cucadmuh 9323f5fee6 fix(albums): All Albums compilation and favorites filters (#1143) (#1151) 2026-06-22 02:54:41 +03:00
Psychotoxical d162c2557b chore(deps): override undici to ^7.28.0 (#1150)
* chore(deps): override undici to ^7.28.0 to clear dev-only advisories

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

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-22 01:15:12 +02:00
Psychotoxical 93166c774a feat(i18n): add Hungarian (hu) translation (#1149)
* feat(i18n): add Hungarian (hu) translation files

* feat(i18n): register Hungarian locale and language option

* docs(changelog): Hungarian translation entry and credit (#1149)

* fix(i18n): list Romanian in the Login language picker
2026-06-22 00:56:37 +02:00
Psychotoxical e69b89b28a docs(readme): add reviews section and winstall install link (#1148)
* docs(readme): add a Reviews section linking the first independent review

* docs(readme): add winstall.app install link for Windows
2026-06-21 23:27:06 +02:00
Psychotoxical ec8cfbc1c8 fix(eq): show the active AutoEQ profile name in the preset picker (#1147)
* fix(eq): surface the active AutoEQ profile name in the preset picker

* i18n(settings): add AutoEQ preset group label (10 locales)

* docs(changelog): AutoEQ active-profile picker fix (#1147)
2026-06-21 22:58:33 +02:00
Psychotoxical 887c940e1b feat(eq): remember EQ profile per audio output device (#1146)
* feat(eq): add per-device EQ snapshot state to eqStore

* feat(eq): wire per-device EQ sync into audio listener setup

* feat(settings): add 'remember EQ per device' toggle to audio output section

* i18n(settings): add 'remember EQ per device' strings (10 locales)

* test(eq): cover per-device EQ snapshot store + device sync orchestration

* docs(changelog): per-device EQ entry, credit and what's new (#1146)

* i18n(settings): note per-device EQ scope (system default + explicit switch) in toggle description

* test(eq): cover system-default bucket and device-reset (null) path
2026-06-21 22:50:06 +02:00
cucadmuh d9969ed76f fix(library): Navidrome artist letter buckets + safer index open/swap (#1144) (#1145) 2026-06-21 20:55:35 +03:00
cucadmuh 986550c854 fix(favorites): bulk add-to-playlist and play/enqueue selected (#1140) 2026-06-21 14:29:05 +03:00
Psychotoxical 30ccaf51a8 fix(updater): rebuild the update modal on a reusable Modal (#1142)
* feat(ui): add reusable Modal component

Portal + dimmed backdrop (no backdrop-filter — on WebKitGTK the blur bleeds
onto modal content on some GPU stacks), Escape/backdrop/X to close, header
(icon/title/subtitle) + scrollable body + footer slot. First consumer is the
update modal; the other hand-rolled modals migrate onto it over time.

* fix(updater): rebuild the update modal on the reusable Modal

Fixes a user report (Manjaro / AMD / X11): blurry modal text (the eq-popup
backdrop-filter bled onto the content), the version arrow sitting between the
two header rows, and the unclear Skip / Remind buttons. Now uses the no-blur
Modal shell; the header icon aligns with the title; Skip is a clear button and
Remind me later is the accent action when there is no in-app install. Drops the
now-dead update-modal shell CSS.

* docs(changelog): update notification popup fix (PR #1142)
2026-06-21 00:48:26 +02:00
Psychotoxical 538dbe3db1 fix(settings): complete the external-artwork disclosure (#1141)
The disclosure named fanart.tv but omitted musicbrainz.org, which the
name-to-MBID identity lookup contacts with the artist/album name when an artist
has no tag MBID; it also mis-stated what is sent. Rewrite the note across all 9
locales: names both fanart.tv (artwork source) and musicbrainz.org (identity
lookup with the artist/album name), and is accurate about on-demand/read-only
and that library tags are never changed. Satisfies fanart.tv terms ("inform
your users") and design-review §9.
2026-06-21 00:12:26 +02: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
Psychotoxical b950d4704b feat(cover): artist artwork from fanart.tv (off by default) (#1137)
* feat(cover): add artist_artwork_lookup table + accessors

Image-scraper P0 (design-review §12): additive library-SQLite migration 013
plus get/upsert/clear-per-server accessors for the external artist-artwork
lookup (fanart.tv). Render never reads it; the on-demand cover ensure path
and the mbid_ambiguous 24h negative cache use it. server_id = serverIndexKey.

* feat(cover): add fanart.tv + getArtistInfo2 provider layer

Image-scraper P0 (§7/§19/§23): new cover_cache/external.rs — Rust-side
getArtistInfo2 tag-MBID resolution plus fanart.tv v3/music URL + first
artistbackground fetch (BYOK client_key sent in addition to the project
api_key per fanart.tv ToS, §22). Extract a shared build_subsonic_url helper
in fetch.rs (cover URL behaviour unchanged). Add a dedicated low-concurrency
fanart_http_sem so external HTTP never starves Navidrome (§26). URL builders
unit-tested; wired into ensure_inner next.

* feat(cover): wire fanart.tv external branch into ensure_inner

Image-scraper P0 (§16): on-demand artist `fanart` ensures try fanart.tv
before the Navidrome fallback. MBID resolved Rust-side via getArtistInfo2
(§23, tag MBID); on a miss it falls through WITHOUT a .fetch-failed marker so
Navidrome stays the display fallback (§28). External tiers are written as
{tier}-fanart.webp in the same entity dir (same cacheKind, §16) — 2000 + 512
(matryoshka §17); peek prefers them for the fanart surface. Dedicated
low-concurrency fanart lane (§26); .miss-fanart ~30min negative marker.
Additive IPC args (externalArtworkEnabled, surfaceKind), off by default and
gated by PSYSONIC_FANART_KEY — inert until a render surface opts in (P1).
Quality gate (§11), name->MusicBrainz (§19), and lookup-table writes are P1.

* feat(cover): fanart-first peek for the fanart surface

Image-scraper P0: for an artist `fanart` ensure, the early peek serves only
the external {tier}-fanart.webp tiers; if none exist yet it returns None so
ensure runs the external branch (fetch fanart) instead of short-circuiting on
a cached Navidrome tier. Realises "fanart prioritised" (§18) for the opt-in
surface; Navidrome stays the fallback inside the branch's miss path.

* chore(cover): dev-only artist-fanart spike helper

DEV-only window.psyFanartSpike(name) — resolves an artist by name and fires
the real cover_cache_ensure with externalArtworkEnabled+surfaceKind=fanart to
verify the P0 pipeline against a live server (with PSYSONIC_FANART_KEY set).
Not wired in production.

* feat(cover): §11 quality gate for the fanart surface

Before an external fanart fetch, check whether a Navidrome tier already on
disk is an HQ ~16:9 image (width >= 1280, aspect 1.6-2.0) and skip the fetch
if so — square artist portraits never satisfy it, so the common case still
fetches. Reads tier dimensions only (no full decode). Rust consts per the
design review. Unit-tested predicate.

* feat(cover): persist fanart resolution in artist_artwork_lookup (§12)

Wire the lookup table as both the MBID resolution cache and the negative
cache: a cached MBID skips the getArtistInfo2 round-trip; no_mbid/mbid_ambiguous
back off 24h and miss 30min from updated_at before re-querying. Writes
hit/miss/no_mbid with mbid/mbid_source/provider; transient network errors are
not cached. All store reads/writes run off the async executor via
spawn_blocking and no-op before login. Store reached via app.try_state::<LibraryRuntime>().

* feat(cover): compile-time fanart key fallback + album/name IPC args

A runtime PSYSONIC_FANART_KEY still wins (dev), else the key baked in at build
time via option_env! (release). Add additive artistName/albumTitle ensure args
as context for the §19 name->MusicBrainz fallback (inert until the render
passes them). Library backfill passes None.

* feat(settings): add External Artwork Scraper toggle under Integrations

Master toggle (themeStore, off by default per §20) in a new Integrations
subsection, alongside the other opt-in third-party categories. Contacts
fanart.tv only when enabled. i18n across all 9 locales.

* feat(cover): wire fanart background into the fullscreen player (§28)

New useArtistFanart hook resolves a fanart.tv 16:9 background via a dedicated
cover_cache_ensure (surfaceKind=fanart) — it bypasses the shared peek/disk-src
cache (the {tier}-fanart.webp surface is keyed differently) and reuses
coverDiskUrl for the asset URL. Fullscreen background priority is now
fanart -> Navidrome artist image (cover pipeline) -> album cover; the live
useFsArtistPortrait probe is deleted (§28). Additive ensure opts
(surfaceKind/artistName/albumTitle); externalArtworkEnabled is derived in
ensureArgsFromRef from the master toggle and restricted to the artist fanart
surface, so plain cover ensures are unaffected.

* feat(cover): generalize external surface to fanart + banner (§13)

surfaceKind='banner' fetches the fanart.tv musicbanner array -> {tier}-banner.webp
in the same entity dir; fanart stays the 16:9 artistbackground. The ensure
branch, peek, lookup rows (per-surface surface_kind), miss marker
(.miss-<surface>) and tier suffix are all surface-parameterised. The §11
quality gate stays fanart-only (the banner strip has its own aspect). Unit
test for the surface->fanart JSON key map.

* feat(artist): fanart banner on the artist-detail header (§13, Option B)

The artist-detail header gets an album-detail-style background layer: fanart.tv
banner (musicbanner) -> the 16:9 fanart background cropped to the strip ->
empty. Both via a shared useArtistExternalImage hook (useArtistBanner /
useArtistFanart); each fetches on demand and shares the Rust cache, so the
header and the fullscreen player warm each other's images. The header is its
own stacking context (isolation) so a z-index:-1 banner clips behind the avatar
+ meta with no content wrapper; the album-style framing (padding/radius/clip)
is applied only when a banner is shown, so the off-by-default case stays
pixel-identical.

* refactor(artist): reuse album-detail header structure for the fanart banner

The artist-detail header now uses the same album-detail-* container classes as
AlbumHeader (header/bg/overlay/content/hero) with the fanart banner as the
background; the Back button moves inside the header. A surgical
`artist-detail-bleed` cancels the artist page's .content-body padding so the
banner is full-bleed to the container edges, matching the album header exactly
instead of the earlier inset card. Reverts the experimental artist-specific bg
CSS.

* feat(cover): drop artist_artwork_lookup rows on clear-cover-cache (§12/B.4)

cover_cache_clear_server already removed the server's whole cover dir (so the
{tier}-fanart.webp / -banner.webp tiers + .miss-* markers go with it); also
clear the artist_artwork_lookup rows for that server (off-thread) so no stale
resolution state lingers. Automatic toggle-off purge deferred — turning the
toggle off already hides external artwork (render is gated), and explicit
cache-clear now cleans external state too.

* feat(cover): name->MusicBrainz album-confirmed MBID resolution (§19)

When getArtistInfo2 has no tag MBID and the ensure carries the artist name +
an album in context (fullscreen), one MusicBrainz release-search query resolves
the artist MBID: the primary artist across score>=90 releases wins, conflicting
ids -> mbid_ambiguous (24h backoff), none -> no_mbid. Sends the required
User-Agent; a single-permit musicbrainz_sem + >=1s spacing holds us under MB's
rate limit. mbid_source=musicbrainz persisted. Banner surface (no album
context) correctly skips this. Pure classify/escape helpers unit-tested.

* fix(cover): enable banner surface in ensureArgsFromRef

externalEnsureFields only set externalArtworkEnabled for surfaceKind 'fanart',
so the 'banner' surface never fired — the artist-detail header always fell back
to the fanart image instead of the fanart.tv musicbanner. Both external artist
surfaces (fanart/banner) now enable the external branch.

* feat(settings): optional BYOK personal fanart key field

Add an optional personal fanart.tv API key field to the External Artwork
Scraper block (shown when the toggle is on): a masked input, a saved/in-use
status line, and the simple note that it is sent in addition to the app key.
Persisted in themeStore and plumbed through cover_cache_ensure
(externalArtworkByok); Rust prefers the settings key, falling back to the
PSYSONIC_FANART_CLIENT_KEY dev env. i18n x9.

* fix(cover): resolve artist-page fanart image collision on navigation

The artist-detail header keyed its fanart/banner hooks on the route `id`,
which flips immediately on navigation while `artist`/`albums` refetch a beat
later. The mismatched ensure wrote the previous artist's image under the new
artist's id (e.g. Sepultura's image under Lordi's id).

- key on the loaded `artist.id`, not the route `id`, so id/name/album always
  describe the same artist
- pick the §19 album context from an album that actually belongs to this
  artist (`albums.find(a => a.artistId === artist.id)`), so a stale album can't
  run a mismatched name→MusicBrainz query or cache a wrong `no_mbid`
- reset `src` on every input change in `useArtistExternalImage` so a previous
  artist's image never lingers while the new one resolves

* fix(cover): strip trailing album qualifier before MusicBrainz lookup

Library titles like "Show No Mercy (2004 Remastered)" or "Album [Deluxe
Edition]" failed the §19 MusicBrainz release query, blocking name-confirmed
MBID resolution. `normalize_album_for_mb` strips a single trailing
parenthetical/bracketed qualifier; leading qualifiers (e.g. "(What's the
Story) Morning Glory?") are left intact. Unit-tested.

* fix(cover): don't cache no_mbid when album context is unavailable

The banner ensure could fire before the artist's albums loaded, with no album
in context. The old code cached `no_mbid` there and the 24h backoff then
blocked the later ensure that arrived *with* album context. Could-not-attempt
is not tried-and-failed: the no-album branch now returns without persisting.

* fix(cover): don't emit tier-ready for external fanart/banner surfaces

`try_external_fanart` emitted `cover:tier-ready` with the `{tier}-{surface}.webp`
path. That event is keyed by the canonical cover key (cacheKind/cacheEntityId/
tier, no surface), so the frontend `useCoverArtBridge` listener seeded the
Navidrome artist cover's disk-src cache with the external image — leaking
fanart/banner into the plain artist cover (avatar, fullscreen "navidrome-artist"
fallback) even with the scraper toggled off.

Remove the emit: the fanart/banner hooks read the path from the
`cover_cache_ensure` return value, so no event is needed. (No disk-level
overwrite — the suffixed files are never matched by `tier_exists`; this was
frontend disk-src-cache cross-contamination.)

* fix(cover): wait for the final external background before showing it, with fade-in

The fullscreen player and artist-detail header flashed several backgrounds in
sequence while the fanart resolved (upscaled album cover → Navidrome artist
image → fanart), and the artist header could show the fanart first and then
swap to the banner.

- the album cover is no longer a background source — it only feeds the
  foreground thumbnail
- the external-artwork hooks return `{ src, pending }` so callers can tell
  "still resolving" (hold back) from "resolved, no image" (fall back now)
- fullscreen background: scraper on → fanart, empty while it resolves, Navidrome
  artist image only on a confirmed miss; scraper off → Navidrome artist image
- artist header: the banner is preferred — nothing shows while it resolves
  (no fanart flash), fanart is the fallback only once the banner misses
- both backgrounds preload the chosen image and fade it in (`onLoad` plus a
  `ref` `complete` check so an already-cached image, whose load event can fire
  before React attaches the handler, still appears). The header fade is a
  scoped inline opacity so the shared `album-detail-bg` class is untouched.

* ci(release): pass PSYSONIC_FANART_KEY into the macOS + Linux builds

* refactor(cover): extract external-artwork ensure into its own module

Pure code move: the on-demand fanart/banner fetch, the quality gate, the
surface-aware peek and the lookup-table cache move from cover_cache/mod.rs
into cover_cache/external_ensure.rs. Behaviour unchanged; mod.rs 1877 -> 1488.

* chore(cover): remove dev-only fanart spike helper

The real render wiring now exercises the external ensure branch, so the
dev-only window.psyFanartSpike helper is redundant.

* feat(cover): purge external artwork on opt-out (B3)

New cover_cache_purge_external command: when the External Artwork toggle is
turned off, drop every fetched {tier}-{provider}.webp, .miss-{provider}
marker and artist_artwork_lookup row across all configured servers, leaving
the canonical Navidrome covers intact. Opting out now removes the
third-party-sourced data instead of just hiding it (design-review §9/§12/B.4).

* docs: changelog, credits and what's new for artist fanart (PR #1137)
2026-06-20 21:04:21 +02:00
cucadmuh 23f8008248 fix(queue): suspend idle pull only on user queue edits (#1136) 2026-06-20 17:11:06 +03:00
cucadmuh b9b4f76c11 fix(connection): retry connect ping before unreachable (#1135) 2026-06-20 17:05:57 +03:00
Soli d8e5d4eed4 feat(i18n): add Japanese translation (#1134)
* feat(i18n): add Japanese translation

Signed-off-by: Soli0222 <github@str08.net>

* docs: changelog, what's new and credit for Japanese translation

---------

Signed-off-by: Soli0222 <github@str08.net>
Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
2026-06-20 13:52:10 +02:00
cucadmuh 2d1a078186 fix(queue): push edited queue when playback starts on paused client (#1133) 2026-06-20 01:15:28 +03:00
2473 changed files with 174074 additions and 30405 deletions
File diff suppressed because it is too large Load Diff
+91
View File
@@ -0,0 +1,91 @@
// Architecture / layering guard for the feature-folder structure (PR #1225, group A1).
//
// Encodes the layering contract the restructure established:
//
// lib (feature-free infra: api clients, format, i18n, util, media, server, navigation)
// ▲
// store / ui (cross-cutting global stores; domain-agnostic primitives) — may import lib
// ▲
// cover / music-network (top-level domains — peers; may import lib, store, ui)
// ▲
// features/<x> (may import lib, store, ui, cover, music-network, other features via barrel only)
// ▲
// app (shell + bridges — may import anything)
//
// A lower layer may NOT import a higher one. Cross-feature access goes through the
// `@/features/<x>` barrel only, never a deep path. No import cycles anywhere.
//
// Ratchet: current known violations (residual legacy dirs + documented inversions) are
// captured in `.dependency-cruiser-known-violations.json` and ignored via `--ignore-known`
// (see `npm run dep:check`). Any NEW violation fails CI. As the drain (group E) removes an
// exception, regenerate the baseline so the count ratchets toward zero.
/** @type {import('dependency-cruiser').IConfiguration} */
module.exports = {
forbidden: [
{
name: 'no-circular',
severity: 'error',
comment: 'No import cycles anywhere under src/ — they make the module graph impossible to reason about.',
from: {},
to: { circular: true },
},
{
name: 'lib-is-the-floor',
severity: 'error',
comment:
'lib/** is feature-free infra and must not import a higher layer ' +
'(features, store, ui, app, cover, music-network).',
from: { path: '^src/lib/' },
to: { path: '^src/(features|store|ui|app|cover|music-network)/' },
},
{
name: 'no-core-to-feature',
severity: 'error',
comment:
'store/** and ui/** are cross-cutting core and must not import features or the app shell ' +
'(the inversions the seams removed — keep them out).',
from: { path: '^src/(store|ui)/' },
to: { path: '^src/(features|app)/' },
},
{
name: 'no-deep-cross-feature',
severity: 'error',
comment:
'A feature must reach another feature only through its `@/features/<x>` barrel (index), ' +
'never a deep path. Same-feature deep imports are fine.',
from: { path: '^src/features/([^/]+)/' },
to: {
path: '^src/features/([^/]+)/[^/]+',
pathNot: [
'^src/features/$1/', // same feature — allowed
'^src/features/[^/]+/index\\.(ts|tsx)$', // the barrel — allowed
],
},
},
],
options: {
doNotFollow: { path: 'node_modules' },
// Type-only edges are tolerated by the iron rule (erased at runtime), but the ratchet
// records whatever exists today regardless of kind; new edges of any kind fail.
tsPreCompilationDeps: true,
tsConfig: { fileName: 'tsconfig.json' },
enhancedResolveOptions: {
extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
mainFields: ['module', 'main', 'types', 'typings'],
},
// The layering contract is about the production module graph. Tests, test helpers,
// ambient declarations and non-src trees are not part of it.
exclude: {
path: [
'\\.test\\.(ts|tsx)$',
'^src/test/',
'\\.d\\.ts$',
'^src/vite-env',
],
},
reporterOptions: {
text: { highlightFocused: true },
},
},
};
+19 -15
View File
@@ -13,25 +13,29 @@
# OUTSIDE the gate until their tests grow. Add them as Phase 14 coverage
# work lands.
#
# Paths follow the feature-folder layout (`src/features/**`, `src/lib/**`,
# `src/cover/**`) after the frontend restructure; the files below moved out
# of the old `src/utils/**` tree but their contents are unchanged.
#
# Deferred from the gate, with current coverage shown for reference:
# - src/store/playerStore.ts (40 % — F1 closed under the 50 % floor; further coverage TBD)
# - src/features/playback/store/playerStore.ts (40 % — F1 closed under the 50 % floor; further coverage TBD)
# - src/store/authStore.ts (79 % — F2 cleared 60 % floor; staying out one or two PRs to verify stability)
# - src/api/subsonic.ts (13 % — F3 covered the URL-builder + parser surface; async API endpoints need axios mocking, deferred)
# - src/lib/api/subsonic.ts (13 % — F3 covered the URL-builder + parser surface; async API endpoints need axios mocking, deferred)
# ── utils (already at or above threshold) ────────────────────────────
src/utils/cover/coverArtRegisteredSizes.ts
src/utils/server/serverDisplayName.ts
src/utils/server/serverMagicString.ts
src/utils/share/shareLink.ts
src/utils/ui/dynamicColors.ts
src/utils/playback/resolvePlaybackUrl.ts
src/utils/share/copyEntityShareLink.ts
# ── extracted helpers (already at or above threshold) ────────────────
src/cover/coverArtRegisteredSizes.ts
src/lib/server/serverDisplayName.ts
src/lib/server/serverMagicString.ts
src/lib/share/shareLink.ts
src/lib/dom/dynamicColors.ts
src/features/playback/utils/playback/resolvePlaybackUrl.ts
src/lib/share/copyEntityShareLink.ts
# ── M0: pure helpers extracted from playerStore.ts (2026-05-12) ──────
src/utils/playback/shuffleArray.ts
src/utils/audio/resolveReplayGainDb.ts
src/utils/playback/songToTrack.ts
src/utils/playback/buildInfiniteQueueCandidates.ts
src/lib/util/shuffleArray.ts
src/features/playback/utils/audio/resolveReplayGainDb.ts
src/lib/media/songToTrack.ts
src/features/playback/utils/playback/buildInfiniteQueueCandidates.ts
# ── Phase B.1: pre-React bootstrap + window-kind detector (2026-05-12) ──
src/app/windowKind.ts
@@ -41,4 +45,4 @@ src/app/bootstrap.ts
src/app/MiniPlayerApp.tsx
# ── stores (added as their tests grew past the floor) ────────────────
src/store/previewStore.ts
src/features/playback/store/previewStore.ts
+231
View File
@@ -0,0 +1,231 @@
/**
* Path-aware ci-ok gate: wait for required workflow job checks on a ref, then pass
* or fail. Mirrors path filters in frontend-tests.yml, eslint.yml, rust-tests.yml.
*/
const FRONTEND_PATH_RE =
/^(src\/|package\.json$|package-lock\.json$|vitest\.config\.ts$|vite\.config\.ts$|tsconfig\.json$|eslint\.config\.mjs$|\.dependency-cruiser\.cjs$|\.dependency-cruiser-known-violations\.json$|\.github\/workflows\/frontend-tests\.yml$|\.github\/workflows\/eslint\.yml$|\.github\/frontend-hot-path-files\.txt$|scripts\/check-frontend-hot-path-coverage\.sh$|scripts\/check-css-import-graph\.mjs$)/;
const RUST_PATH_RE = /^(src-tauri\/|\.github\/workflows\/rust-tests\.yml$)/;
const FRONTEND_JOBS = [
'vitest run',
'tsc --noEmit',
'vitest --coverage (baseline + hot-path file gate)',
'eslint',
'dependency-cruiser',
];
const RUST_JOBS = [
'cargo test --workspace',
'cargo clippy --workspace',
'cargo llvm-cov (baseline + hot-path file gate)',
];
const POLL_MS = 30_000;
const TIMEOUT_MS = 90 * 60 * 1000;
const OK_CONCLUSIONS = new Set(['success', 'neutral', 'skipped']);
/**
* GitHub API hiccups (5xx, secondary rate limits, dropped connections) must
* not fail the gate — the answer is to poll again, not to go red while the
* real jobs are green. 4xx config errors (401/404 …) still throw.
*/
export function isTransientApiError(err) {
const status = typeof err?.status === 'number' ? err.status : 0;
return status === 0 || status === 429 || status >= 500;
}
export async function withTransientRetry(label, fn, core, attempts = 5, delayMs = POLL_MS) {
for (let attempt = 1; ; attempt++) {
try {
return await fn();
} catch (err) {
if (!isTransientApiError(err) || attempt >= attempts) {
throw err;
}
// err.message can be a whole HTML error page — log only the status.
core.info(
`${label}: transient API error (status=${err?.status ?? 'network'}), retry ${attempt}/${attempts - 1} in ${delayMs / 1000}s`,
);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}
export function pathTriggersFrontend(file) {
return FRONTEND_PATH_RE.test(file);
}
export function pathTriggersRust(file) {
return RUST_PATH_RE.test(file);
}
export function requiredJobNames(changedFiles) {
const names = [];
if (changedFiles.some(pathTriggersFrontend)) {
names.push(...FRONTEND_JOBS);
}
if (changedFiles.some(pathTriggersRust)) {
names.push(...RUST_JOBS);
}
return names;
}
export function newestChecksByName(checks, excludeRunId) {
const newest = new Map();
for (const check of checks) {
if (excludeRunId && (check.details_url || '').includes(`/actions/runs/${excludeRunId}/`)) {
continue;
}
const key = check.name;
const prev = newest.get(key);
if (!prev) {
newest.set(key, check);
continue;
}
const prevTime = Date.parse(prev.started_at || prev.completed_at || '') || 0;
const curTime = Date.parse(check.started_at || check.completed_at || '') || 0;
if (curTime >= prevTime) {
newest.set(key, check);
}
}
return newest;
}
export function evaluateRequiredJobs(required, newestByName) {
const pending = [];
const failures = [];
for (const name of required) {
const latest = newestByName.get(name);
if (!latest) {
pending.push(`${name}: not started`);
continue;
}
if (latest.status !== 'completed') {
pending.push(`${name}: status=${latest.status}`);
continue;
}
if (!OK_CONCLUSIONS.has(latest.conclusion || '')) {
failures.push(`${name}: conclusion=${latest.conclusion}`);
}
}
return { pending, failures, done: pending.length === 0 && failures.length === 0 };
}
export async function listChangedFiles(github, context) {
const { owner, repo } = context.repo;
if (context.eventName === 'pull_request') {
const files = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: context.payload.pull_request.number,
per_page: 100,
});
return files.map((f) => f.filename);
}
if (context.eventName === 'push') {
const before = context.payload.before;
const after = context.sha;
if (!before || /^0+$/.test(before)) {
const commit = await github.rest.repos.getCommit({ owner, repo, ref: after });
return commit.data.files?.map((f) => f.filename) ?? [];
}
const compare = await github.rest.repos.compareCommits({
owner,
repo,
base: before,
head: after,
});
return compare.data.files?.map((f) => f.filename) ?? [];
}
if (context.eventName === 'workflow_run') {
const pr = context.payload.workflow_run.pull_requests?.[0];
if (pr?.number) {
const files = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: pr.number,
per_page: 100,
});
return files.map((f) => f.filename);
}
const headSha = context.payload.workflow_run.head_sha;
const commit = await github.rest.repos.getCommit({ owner, repo, ref: headSha });
return commit.data.files?.map((f) => f.filename) ?? [];
}
return [];
}
export function resolveTargetSha(context) {
if (context.eventName === 'pull_request') {
return context.payload.pull_request.head.sha;
}
if (context.eventName === 'workflow_run') {
return context.payload.workflow_run.head_sha;
}
return context.sha;
}
export async function runCiOkAggregate(github, context, core) {
const { owner, repo } = context.repo;
const sha = resolveTargetSha(context);
const excludeRunId = String(context.runId);
const changedFiles = await withTransientRetry(
'listChangedFiles',
() => listChangedFiles(github, context),
core,
);
const required = requiredJobNames(changedFiles);
core.info(`ci-ok @ ${sha}; ${changedFiles.length} changed file(s)`);
if (required.length === 0) {
core.info('No path-filtered test workflows apply — ci-ok passes.');
return;
}
core.info(`Waiting for required job checks: ${required.join(', ')}`);
const deadline = Date.now() + TIMEOUT_MS;
while (Date.now() < deadline) {
let checksAll;
try {
checksAll = await github.paginate(github.rest.checks.listForRef, {
owner,
repo,
ref: sha,
per_page: 100,
});
} catch (err) {
if (!isTransientApiError(err)) {
throw err;
}
// Same as an inconclusive poll: wait out the hiccup, the 90-minute
// deadline stays the backstop.
core.info(`checks.listForRef: transient API error (status=${err?.status ?? 'network'}) — retrying next poll`);
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
continue;
}
const newestByName = newestChecksByName(checksAll, excludeRunId);
const { pending, failures, done } = evaluateRequiredJobs(required, newestByName);
if (failures.length > 0) {
core.setFailed(`Required checks failed:\n${failures.join('\n')}`);
return;
}
if (done) {
core.info('All required checks are green.');
return;
}
core.info(`Pending (${pending.length}): ${pending.join('; ')}`);
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
}
core.setFailed(`Timed out after ${TIMEOUT_MS / 60_000} minutes waiting for: ${required.join(', ')}`);
}
+128
View File
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
evaluateRequiredJobs,
isTransientApiError,
newestChecksByName,
pathTriggersFrontend,
pathTriggersRust,
requiredJobNames,
withTransientRetry,
} from './ci-ok-aggregate.mjs';
test('pathTriggersFrontend matches frontend workflow paths', () => {
assert.equal(pathTriggersFrontend('src/App.tsx'), true);
assert.equal(pathTriggersFrontend('eslint.config.mjs'), true);
assert.equal(pathTriggersFrontend('README.md'), false);
});
test('pathTriggersRust matches rust workflow paths', () => {
assert.equal(pathTriggersRust('src-tauri/src/lib.rs'), true);
assert.equal(pathTriggersRust('src/App.tsx'), false);
});
test('requiredJobNames unions frontend and rust jobs', () => {
const names = requiredJobNames(['src/foo.ts', 'src-tauri/bar.rs']);
assert.ok(names.includes('eslint'));
assert.ok(names.includes('cargo test --workspace'));
});
test('evaluateRequiredJobs fails on red conclusions', () => {
const newest = newestChecksByName([
{
name: 'eslint',
status: 'completed',
conclusion: 'failure',
started_at: '2026-01-01T00:00:00Z',
details_url: '',
},
]);
const result = evaluateRequiredJobs(['eslint'], newest);
assert.equal(result.done, false);
assert.equal(result.failures.length, 1);
});
test('evaluateRequiredJobs passes when all required jobs succeeded', () => {
const checks = ['eslint', 'vitest run'].map((name) => ({
name,
status: 'completed',
conclusion: 'success',
started_at: '2026-01-01T00:00:00Z',
details_url: '',
}));
const result = evaluateRequiredJobs(['eslint', 'vitest run'], newestChecksByName(checks));
assert.equal(result.done, true);
});
test('isTransientApiError treats 5xx, 429 and network errors as transient', () => {
assert.equal(isTransientApiError({ status: 503 }), true);
assert.equal(isTransientApiError({ status: 500 }), true);
assert.equal(isTransientApiError({ status: 429 }), true);
assert.equal(isTransientApiError(new Error('socket hang up')), true);
assert.equal(isTransientApiError({ status: 404 }), false);
assert.equal(isTransientApiError({ status: 401 }), false);
});
const silentCore = { info: () => {} };
test('withTransientRetry retries transient errors and returns the late success', async () => {
let calls = 0;
const result = await withTransientRetry(
'test',
async () => {
calls += 1;
if (calls < 3) {
const err = new Error('unavailable');
err.status = 503;
throw err;
}
return 'ok';
},
silentCore,
5,
1,
);
assert.equal(result, 'ok');
assert.equal(calls, 3);
});
test('withTransientRetry rethrows non-transient errors immediately', async () => {
let calls = 0;
await assert.rejects(
withTransientRetry(
'test',
async () => {
calls += 1;
const err = new Error('not found');
err.status = 404;
throw err;
},
silentCore,
5,
1,
),
/not found/,
);
assert.equal(calls, 1);
});
test('withTransientRetry gives up after the attempt budget', async () => {
let calls = 0;
await assert.rejects(
withTransientRetry(
'test',
async () => {
calls += 1;
const err = new Error('unavailable');
err.status = 503;
throw err;
},
silentCore,
3,
1,
),
/unavailable/,
);
assert.equal(calls, 3);
});
+19 -4
View File
@@ -1,15 +1,30 @@
name: ci-main
on:
push:
branches: [main]
pull_request:
branches: [main]
push:
branches: [main]
workflow_run:
workflows: [frontend-tests, rust-tests, eslint]
types: [completed]
workflow_dispatch:
permissions:
contents: read
checks: read
pull-requests: read
jobs:
ci-ok:
runs-on: ubuntu-latest
steps:
- name: ci sentinel
run: echo "main CI sentinel is green"
- uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.head.sha || github.event.workflow_run.head_sha || github.sha }}
- name: aggregate required checks
uses: actions/github-script@v9
with:
script: |
const { runCiOkAggregate } = await import('${{ github.workspace }}/.github/scripts/ci-ok-aggregate.mjs');
await runCiOkAggregate(github, context, core);
+66
View File
@@ -0,0 +1,66 @@
name: eslint
on:
pull_request:
branches: [main]
paths:
- 'src/**'
- 'package.json'
- 'package-lock.json'
- 'vitest.config.ts'
- 'vite.config.ts'
- 'tsconfig.json'
- 'eslint.config.mjs'
- '.dependency-cruiser.cjs'
- '.dependency-cruiser-known-violations.json'
- '.github/workflows/eslint.yml'
- '.github/frontend-hot-path-files.txt'
- 'scripts/check-frontend-hot-path-coverage.sh'
- 'scripts/check-css-import-graph.mjs'
push:
branches: [main]
paths:
- 'src/**'
- 'package.json'
- 'package-lock.json'
- 'vitest.config.ts'
- 'vite.config.ts'
- 'tsconfig.json'
- 'eslint.config.mjs'
- '.dependency-cruiser.cjs'
- '.dependency-cruiser-known-violations.json'
- '.github/workflows/eslint.yml'
- '.github/frontend-hot-path-files.txt'
- 'scripts/check-frontend-hot-path-coverage.sh'
- 'scripts/check-css-import-graph.mjs'
workflow_dispatch:
permissions:
contents: read
jobs:
eslint:
name: eslint
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 'lts/*'
cache: 'npm'
- run: npm ci
- name: eslint
run: npm run lint
dependency-cruiser:
name: dependency-cruiser
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 'lts/*'
cache: 'npm'
- run: npm ci
- name: layering + cycle guard
run: npm run dep:check
+2
View File
@@ -10,6 +10,7 @@ on:
- 'vitest.config.ts'
- 'vite.config.ts'
- 'tsconfig.json'
- 'eslint.config.mjs'
- '.github/workflows/frontend-tests.yml'
- '.github/frontend-hot-path-files.txt'
- 'scripts/check-frontend-hot-path-coverage.sh'
@@ -23,6 +24,7 @@ on:
- 'vitest.config.ts'
- 'vite.config.ts'
- 'tsconfig.json'
- 'eslint.config.mjs'
- '.github/workflows/frontend-tests.yml'
- '.github/frontend-hot-path-files.txt'
- 'scripts/check-frontend-hot-path-coverage.sh'
+21
View File
@@ -0,0 +1,21 @@
name: Publish to WinGet
on:
release:
types: [released]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Derive winget version from app-v* tag
id: ver
env:
TAG: ${{ github.event.release.tag_name }}
run: echo "value=${TAG#app-v}" >> "$GITHUB_OUTPUT"
- name: Submit to WinGet Community Repository
uses: vedantmgoyal9/winget-releaser@v2
with:
identifier: Psychotoxical.Psysonic
version: ${{ steps.ver.outputs.value }}
installers-regex: 'Psysonic_.*_x64-setup\.exe$'
token: ${{ secrets.WINGET_TOKEN }}
+6 -2
View File
@@ -37,8 +37,12 @@ src-tauri/lcov.info
# Frontend test coverage
coverage/
# Generated at build/test/dev time (scripts/generate-release-notes-bundle.mjs)
src/generated/
# Generated at build/test/dev time (scripts/generate-release-notes-bundle.mjs).
# Ignore the contents (not the dir) so the committed tauri-specta FE↔BE contract
# snapshot below can be re-included — a `!` exception cannot escape an ignored dir.
src/generated/*
# ...except the committed tauri-specta contract snapshot, so CI diffs catch drift.
!src/generated/bindings.ts
# Documentation
CLAUDE.md
+748 -2
View File
@@ -9,7 +9,474 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
>
## [1.49.0]
## [1.51.0]
## Added
### True simultaneous multi-server support — use every server as one music library
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1326](https://github.com/Psychotoxical/psysonic/pull/1326)**
* Select music folders from several configured servers in the same priority-ordered library scope. Psysonic browses them simultaneously without making you switch the active server before every search, album, artist or playback action.
* **Home, Albums, Artists, Composers, Genres, Favourites, Playlists, Folder Browser, Search, Most Played, Statistics, album details and artist details** aggregate the selected servers into one catalogue. Shared music is de-duplicated by scope priority instead of appearing once per server.
* De-duplication no longer discards physical ownership: each logical track, album and artist retains every concrete server source. Psysonic can therefore show one clean catalogue while still knowing exactly which server must handle playback, artwork, metadata and mutations.
### Mixed-server playback — one queue can play tracks from different servers
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1326](https://github.com/Psychotoxical/psysonic/pull/1326)**
* A single queue can contain tracks owned by different servers. Each item resolves its stream, cover, lyrics, ReplayGain data and analysis against its own server instead of whichever server is currently active.
* Server play queues are pulled, updated and reconciled per owner, so mixed queues survive restart without one server replacing another server's tracks. Gapless, crossfade, infinite queue, shuffle, history and queue restore keep the same ownership.
* When one copy cannot play, a new source chooser can offer equivalent copies from the selected servers rather than failing the whole action. Album, artist and track source controls also let you choose a specific physical copy when that distinction matters.
### Server-aware destinations and actions
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1326](https://github.com/Psychotoxical/psysonic/pull/1326)**
* Playlist creation, smart-playlist editing, radio actions and other destination-sensitive flows select the target server explicitly instead of silently using the active server.
* Context menus, ratings, favourites, sharing, offline pins, device sync and Orbit carry the item's owner through the complete action. Creating an Orbit session from a multi-server scope now asks which server should host it, temporarily keeps only that server in the library scope and shared queue, then restores the previous scope when the session ends.
* Sharing a mixed-server queue opens a compact server picker directly below the share button; choosing a server copies its tracks immediately, while single-server queues still copy without an extra prompt. Unavailable servers are marked with an explanatory warning.
### Debug logging — selectable multi-server diagnostics
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1331](https://github.com/Psychotoxical/psysonic/pull/1331)**
* **Settings → System → Logging** and **PsyLab → Logs** offer basic and verbose debug-depth levels while Debug logging is enabled. Verbose mode adds structured multi-server scope, reachability, music-folder and New Releases diagnostics, with credentials and sensitive URL data redacted.
## Changed
### Library index — designed for several live servers at once
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1326](https://github.com/Psychotoxical/psysonic/pull/1326)**
* The local SQLite library now keeps server ownership, music-folder scope and cross-server identity as separate indexed concepts. Scoped browse projections power combined catalogues without scanning or merging every server response in the UI.
* Home feeds, text search, genre counts, details, Most Played and Statistics use indexed multi-server reads when local data is ready, while retaining network fallbacks for a server that has not completed its index yet.
* Identity matching and browse projections update incrementally after each server sync. A durable invalidation journal resumes unfinished work after restart instead of forcing repeated full-catalogue rebuilds on startup.
### Sync and reachability — one unavailable server no longer blocks the others
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1326](https://github.com/Psychotoxical/psysonic/pull/1326)**
* Psysonic tracks readiness, sync progress and reachability independently for every configured server. Available servers remain browsable while another selected server is offline, still indexing or recovering from an interrupted sync.
* Full and delta sync preserve source ownership through remaps, deletions and tombstones; no-op syncs avoid unnecessary catalogue refreshes, and stale multi-server state is repaired automatically.
* Scope indicators retain the total server count when one is unavailable, while the Library picker reports selected music libraries instead of server profiles.
## Fixed
### Cross-server ownership — IDs no longer collide or drift to the active server
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1326](https://github.com/Psychotoxical/psysonic/pull/1326)**
* Album and artist navigation, covers, lyrics, ratings, favourites, playlists, radio, sharing, offline browse, device sync and context-menu actions no longer jump to the wrong server when two servers reuse the same entity ID.
* Orbit host/guest state, queue suggestions and cleanup stay bound to the session server; switching the visible library or active server cannot redirect an existing session.
* Composer, genre, favourite, playlist and folder views preserve the selected multi-server scope through filtering, sorting, selection and playback instead of collapsing back to a single server.
### Startup — keep the loading splash until initial content is ready
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1326](https://github.com/Psychotoxical/psysonic/pull/1326)**
* Cold starts no longer reveal a blank or partially committed app shell between the splash and the first route content; the handoff is atomic after the initial screen is ready.
### Live indicator — preserve the pulse without high idle CPU
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1326](https://github.com/Psychotoxical/psysonic/pull/1326)**
* The header Live icon keeps its pulsing state while listeners are present, but confines the animation to an isolated 10 FPS layer and pauses it while the window is unfocused or reduced motion is requested.
### Library startup — recover interrupted large syncs without a CPU spin
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1326](https://github.com/Psychotoxical/psysonic/pull/1326)**
* Large fresh libraries now refresh SQLite's query-planner statistics after bulk indexing. Restarting after an interrupted multi-server sync no longer enters a minutes-long single-core identity rebuild, and existing affected databases repair their stale statistics automatically.
### New Releases — remove duplicate albums from the freshness overlay
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1326](https://github.com/Psychotoxical/psysonic/pull/1326)**
* The first New Releases page now keeps the local catalogue's logical album de-duplication when fresh network results arrive, including alternate physical copies on one server and matching copies across several servers.
### Artist details — show the artist name, not a featured-guest track credit
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1328](https://github.com/Psychotoxical/psysonic/pull/1328)**
* The artist detail header no longer adopts a per-track "featuring" credit when a single track in the discography is tagged with a guest artist. It shows the artist's own name, matching the artist browse list.
### Artist details — group releases by type again
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1329](https://github.com/Psychotoxical/psysonic/pull/1329)**
* The artist page groups a discography into Albums, Singles, EPs, Live and Compilations again instead of one flat list. The release type is read from each album's tags; releases without a type tag stay in the default group.
### Multi-server library scope — recover empty persisted selections
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1331](https://github.com/Psychotoxical/psysonic/pull/1331)**
* Existing profiles that retained an empty Library server selection recover the configured active server when another server is added. Music-folder discovery and New Releases no longer collapse to an empty result in that legacy state.
* Artist browse no longer waits for a full identity rebuild on the first launch after upgrading an existing large library.
### Album details — play multi-disc albums in disc order
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1332](https://github.com/Psychotoxical/psysonic/pull/1332)**
* Playing a multi-disc album from the header no longer interleaves the discs (disc 1 track 1, disc 2 track 1, disc 1 track 2, …). Tracks queue in disc-then-track order, so disc 1 plays in full before disc 2. Tracks without a disc number are treated as disc 1, matching the track list.
## [1.50.0]
## Added
### Square corners — sharp-edged cards and covers
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1215](https://github.com/Psychotoxical/psysonic/pull/1215)**
* New **Square Corners** toggle under **Settings → Appearance → Visual Options → Display** overrides the active theme to render cards and cover art with square, non-rounded corners. Covers album, playlist, artist and song cards, detail-page cover art, the Now Playing / Radio and fullscreen views, the cover lightbox, the queue cover, and the mini player. Off by default; buttons, inputs and dialogs keep the theme's corners.
### Discord community banner
**By [@ImAsra](https://github.com/ImAsra), PR [#1222](https://github.com/Psychotoxical/psysonic/pull/1222)**
* A dismissible banner inviting you to join the Psysonic community on Discord appears after 20 hours of accumulated app use. **Join** opens the invite; dismiss it for the session, or choose **Never show again** to hide it permanently. The icon renders at a consistent size on every platform, including Windows.
### Bulgarian translation
**By [@akirichev](https://github.com/akirichev), PR [#1228](https://github.com/Psychotoxical/psysonic/pull/1228)**
* Full Bulgarian (Български) UI translation — selectable from the language picker on the Settings and Login screens.
### Artists browse — album vs track credit mode
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1232](https://github.com/Psychotoxical/psysonic/pull/1232)**
* Toggle **Album artists** vs **Track artists** on the Artists page — album mode lists indexed album artists; track mode includes performers from the local artist index (featured/guest credits). Star filter works in both modes; the choice persists across app restarts like **Show artist images**.
* Letter bucket filter (`A``Z`, `#`, `OTHER`) runs in local SQL instead of scanning catalog chunks client-side, so late-alphabet picks load promptly on large libraries.
* Artist name search no longer depends on query letter case for Cyrillic (and other non-ASCII) names when the local library index is enabled.
### CLI — relative volume and quieter scripting output
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1238](https://github.com/Psychotoxical/psysonic/pull/1238)**
* `psysonic --player volume +5` / `volume -10` adjust the current level by that many percent; `volume 80` still sets an absolute level (use `-q` before `--player` when the delta is negative so it is not parsed as a flag).
* CLI invocations no longer print WebKit/NVIDIA workaround notes on stderr; on Linux, remote `--player` forwarding runs before WebKit startup so helper processes exit with less noise.
### Theme Store — per-theme changelogs and pinned updates
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1240](https://github.com/Psychotoxical/psysonic/pull/1240)**
* Each theme card now has an expandable **What's new** with per-version release notes, so you can see what a theme update changed — including non-visual fixes. Provided by theme authors; themes without notes just don't show the section.
* Installed themes with an available update now appear at the top of the store list instead of wherever the sort placed them, so you don't have to hunt for them.
### Multi-library filter — browse and search across selected libraries
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1241](https://github.com/Psychotoxical/psysonic/pull/1241)**
* The sidebar library picker now supports **multi-select with priority ordering**: browse, search, genre and album/artist detail views aggregate across the chosen libraries and de-duplicate shared items by priority. Built for large libraries — scoped SQL uses the hot `library_id` column with covering indexes and FTS-first matching.
* Identity matching that powers cross-library de-duplication now normalises names per shipped locale (folds German ß, Norwegian æ, French œ, Romanian ș/ț, and Cyrillic ё/й); CJK titles are matched as-is.
* The Genres page and album browse genre filter list the full catalog on large libraries when **All libraries** is selected.
### Theme contributors credited in Settings
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1248](https://github.com/Psychotoxical/psysonic/pull/1248)**
* **Settings → System → Contributors** now lists community theme authors in a **Themes** sub-section alongside the **App** contributors, pulled from the theme store so it stays current as new themes are published.
* The theme card **What's new** now shows just the latest version's notes instead of the full version history.
* Theme author names refresh quietly from the store in the background instead of staying stale for up to 12 hours.
### Fullscreen player — Minimal and Immersive styles
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1249](https://github.com/Psychotoxical/psysonic/pull/1249)**
* **Settings → Appearance → Fullscreen player style** lets you choose **Minimal** (the current view) or **Immersive** — the earlier fullscreen player, with the artist photo/backdrop, a cover-derived accent colour, and rail or Apple-style scrolling lyrics.
* In Immersive, **Show artist photo** and **Photo dimming** are configurable; Apple-style lyrics show the artist image as a dimmed full-screen backdrop.
### Italian translation
**By [@daquino94](https://github.com/daquino94), PR [#1250](https://github.com/Psychotoxical/psysonic/pull/1250)**
* Full Italian (Italiano) UI translation — selectable from the language picker on the Settings and Login screens.
### Fullscreen player — Prism style
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1251](https://github.com/Psychotoxical/psysonic/pull/1251)**
* A third **Fullscreen player style**, **Prism** — a full-bleed artist backdrop with a floating glass lyrics panel on the right and a single glass control bar at the bottom (transport, a centred now-playing pill with an integrated progress line, and utilities). The cover-derived accent colour drives the progress fill and the active lyric line, and upcoming lyric lines fade out with a progressive blur.
### Lyrics — word-by-word highlighting straight from your server
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1265](https://github.com/Psychotoxical/psysonic/pull/1265)**
* The **Server** lyrics source now highlights lyrics word by word, so karaoke sync no longer depends on the third-party YouLyPlus backend. Requires Navidrome 0.63 or newer and lyrics that carry word timing (TTML or Enhanced LRC); anything else keeps highlighting line by line.
* **Settings → Lyrics → Lyrics Sources** spells out those requirements, and the block now follows the standard settings sub-card layout.
* Embedded Enhanced LRC no longer prints raw word timing codes (`<00:12.34>`) in the lyric text — those codes drive word-by-word highlighting instead.
* FLAC, Ogg Vorbis, Opus and Speex files that store synced lyrics in the `SYNCEDLYRICS` tag show embedded lyrics again, with that tag taking priority over the plain `LYRICS` tag.
### Start minimized to tray
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1271](https://github.com/Psychotoxical/psysonic/pull/1271)**
* New **Start Minimized to Tray** toggle under **Settings → System → Behavior**. When enabled, the next cold start keeps the main window hidden and Psysonic runs from the system tray until you show it from the tray icon.
* Requires **Show Tray Icon** (turning this on enables the tray automatically; hiding the tray clears the setting). The choice applies on the next launch only — toggling it in Settings does not hide the window immediately.
* Opening the main window from the tray after a cold start renders the sidebar and main content immediately — including on Linux tiling WMs such as Hyprland — instead of leaving the sidebar or Mainstage invisible or blank until a restart.
### Navidrome public share links — open and play without logging in
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1275](https://github.com/Psychotoxical/psysonic/pull/1275)**
* Paste or search a Navidrome **public share** URL (`/share/{id}`) to preview the shared track list in a modal, then play the full queue with no server account — direct stream and cover URLs are resolved anonymously from the share page.
* Share playback uses a dedicated scope so an idle server play-queue pull cannot replace the share queue while you are also logged into Navidrome. Share sessions are not restored after an app restart — the server play queue applies as usual.
* While a share queue is active, **Save Playlist** is hidden in the queue toolbar (share tracks cannot be saved to the server); **Load Playlist** stays available. The queue **Share** button copies the original Navidrome `/share/{id}` page URL.
### Track lists — optional album cover thumbnails
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1280](https://github.com/Psychotoxical/psysonic/pull/1280)**
* Browse and queue track rows can show the track's **album** cover (per-disc art when the album has distinct disc covers). Covers load through the standard cover cache pipeline — library resolve, viewport ensure, Rust resize to disk tiers — not a separate warm path.
* **Settings → Appearance** adds separate toggles for queue vs browse tracklists. Favorites, playlist, and album-detail track grids gain a flex-resize handle on the title column when covers are shown.
* Album detail pages skip per-row cover thumbs when the album art is already shown above the list — no duplicate image on every line.
### Discord — server cover art source, without the credential leak
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1299](https://github.com/Psychotoxical/psysonic/pull/1299)**
* **Settings → Integrations → Discord → Cover art source** gets a **Server** option, alongside **None** and **Apple Music**. It resolves artwork through the standard Subsonic `getAlbumInfo2` endpoint's public image link — never an authenticated cover URL that could expose your login credentials (reported by lavioso on Discord). Needs a publicly reachable server; anyone viewing your Discord profile can see that server's public address, but nothing else.
### Playlist cards — play and queue from the right-click menu
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1307](https://github.com/Psychotoxical/psysonic/pull/1307)**
* Right-clicking a playlist card now offers **Play next** and **Add to queue** alongside **Play now**, matching the album card. All three honour offline mode and the active multi-library filter.
### Playlists browse — scoped header search
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1308](https://github.com/Psychotoxical/psysonic/pull/1308)**
* The header search field on the Playlists page now filters the list by playlist name (same scoped badge pattern as Artists / Albums), including in folder view.
### Artist page — add the whole discography to the queue
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1321](https://github.com/Psychotoxical/psysonic/pull/1321)**
* A new queue button on the artist page appends the artist's entire discography to the current queue in one click, next to Play all and Shuffle — matching what album pages already offer.
## Changed
### Frontend restructure — feature-folder architecture and hardening
**By [@Psychotoxical](https://github.com/Psychotoxical), with additional architecture by [@cucadmuh](https://github.com/cucadmuh), PR [#1225](https://github.com/Psychotoxical/psysonic/pull/1225)**
* Reorganised the frontend into a feature-folder architecture with a CI-enforced layering guard, added unit + behavior-scenario + boot-smoke test coverage, and introduced a compile-time frontend/backend IPC contract via tauri-specta. Internal only — no change to how the app looks or behaves.
### Typed-IPC contract — completed the tauri-specta cutover
**By [@Psychotoxical](https://github.com/Psychotoxical), with additional architecture by [@cucadmuh](https://github.com/cucadmuh), PR [#1230](https://github.com/Psychotoxical/psysonic/pull/1230)**
* Completed the frontend/backend typed-IPC contract: the frontend now calls the generated tauri-specta command surface, with CI guards keeping the bindings fresh and every command registered in the handler. Internal only — no change to how the app looks or behaves.
### Equalizer — per-device profiles follow the active system default
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1274](https://github.com/Psychotoxical/psysonic/pull/1274)**, suggested by [@JustBuddy](https://github.com/JustBuddy)
* With **Remember EQ per device** enabled and **System Default** selected, the equalizer now keys profiles to the active OS default output and switches when that default changes externally (Windows sound settings, Stream Deck, etc.), instead of using one shared profile for all system-default outputs.
* On Linux/PipeWire, the active default is resolved from WirePlumber (`wpctl`) first — including Hyprpanel, pavucontrol, and `wpctl set-default` — not cpal, which can keep a stale card name even after the default sink changes. When PipeWire has already moved the playback stream to the new default, the device watcher skips a redundant stream reopen (avoids a post-switch stutter).
* **Windows:** release builds no longer freeze on the loading splash; audio output devices on Windows and macOS use stable backend IDs with clearer labels, duplicate friendly names are disambiguated, device-change detection works again, and legacy pinned device / per-device EQ keys stored as plain names are matched after upgrade.
### Player bar — build your own
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1287](https://github.com/Psychotoxical/psysonic/pull/1287)**
* **Settings → Personalisation → Player bar** now also hides the **stop button** and shows the **album name** under the artist (off by default; clicking it opens the album). The right-hand buttons — star rating, favorite, love, playback speed, equalizer, mini player — can be **dragged into any order** you like.
* The section is no longer behind **Advanced**.
### Shuffle
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1288](https://github.com/Psychotoxical/psysonic/pull/1288)**
* A **shuffle toggle** in the player bar, next to the transport controls. While on, the queue is shuffled from the current track onwards — the playing track stays put — and turning it off restores the original order. It survives a restart, and the shuffled order is what your other devices and Orbit guests see, so playback stays in step everywhere. Hide the button under **Settings → Personalisation → Player bar** if you don't want it.
## Fixed
### Per-track covers when playing from a playlist
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1218](https://github.com/Psychotoxical/psysonic/pull/1218)**, reported by The Cup Slammer on Discord
* Playing a song from a playlist could show the song's own cover art in Now Playing instead of its album cover, while playing the same song from its album page showed the album cover. Now Playing now consistently uses the album cover, matching album-page playback. Albums with genuine per-disc artwork are unaffected.
### Playback — ReplayGain prefetch, gapless playbar sync, and library peak index
**By [@cucadmuh](https://github.com/cucadmuh), reported by Asra on the Psysonic Discord, PR [#1231](https://github.com/Psychotoxical/psysonic/pull/1231)**
* ReplayGain applies when stream or queue metadata resolves late — index-first prefetch before bind, reactive sync when resolver tags land, and live refresh from the library index after sync when tags differ on the playing track.
* Gapless auto-advance no longer leaves the playbar on the previous track; missed `audio:track_switched` is reconciled from engine position with seek guards so backward seek is not treated as a gapless switch.
* Local library index stores `replayGainPeak` (migration 015) so anti-clipping peak is available on index-first paths without an extra network round-trip.
### Connection — ignore spurious offline hint on desktop
**By [@cucadmuh](https://github.com/cucadmuh), reported by mikmik on the Psysonic Discord, PR [#1234](https://github.com/Psychotoxical/psysonic/pull/1234)**
* Desktop builds no longer get stuck showing "offline" when WebKitGTK leaves `navigator.onLine` stuck at `false` while the server is actually reachable — the app now confirms with a real server probe instead of trusting that hint, so browse and playback keep working. Web builds are unchanged.
* Pending favorite/rating sync now flushes when the server actually becomes reachable again, rather than relying on a browser `online` event that may never fire on desktop.
### Playlists — add more than ~341 tracks; faster large-playlist edits
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1235](https://github.com/Psychotoxical/psysonic/pull/1235)**
* Adding tracks to a playlist no longer fails past ~341 songs — writes are sent to the server in batches instead of one oversized request, so playlists of any size build correctly.
* Adding and merging into large playlists is faster: playlist membership is cached in memory for de-duplication instead of re-fetching the whole playlist on every add.
### Queue — rows no longer stuck showing "…"
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1236](https://github.com/Psychotoxical/psysonic/pull/1236)**
* Queue rows that were far from the currently playing track (e.g. after starting a large playlist from the middle, or scrolling the queue) no longer stay stuck on a "…" placeholder — the queue now loads track details for whatever you scroll to, in the desktop queue panel, the mobile queue drawer, and the fullscreen "up next" overlay.
### Offline browse — on-disk-only Artists, Albums, Tracks, and Genres
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1243](https://github.com/Psychotoxical/psysonic/pull/1243)**
* When browsing offline, Artists, Albums, Tracks, and Genres now list only content with on-disk bytes — library pins, favorites-auto saves, and hot-cache playback — instead of the full server or local index catalog.
* Sidebar and shell gates react when hot-cache rows appear; browse pages reload after hot-cache growth and library sync without leaving the page.
* Album vs track artist credit mode, starred artists, genre filters, and Tracks discovery rails respect the on-disk scope; album artist grouping follows indexed `album_artist` parity.
### All Albums — year filter keyboard entry
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1244](https://github.com/Psychotoxical/psysonic/pull/1244)**
* The year filter on All Albums no longer clamps on every keystroke while typing a four-digit year — drafts commit on blur, Enter, or outside click; incomplete input reverts to the last applied value. Wheel and spinner controls are unchanged.
### Album detail — favorite heart and album-level stars
**By [@cucadmuh](https://github.com/cucadmuh), reported by HiveMind on the Psysonic Discord, PR [#1247](https://github.com/Psychotoxical/psysonic/pull/1247)**
* Starring an album on the detail page now fills the heart immediately and keeps it filled after reload or returning from Favorites — the local index stores album favorites in `album.starred_at` instead of inferring from track stars.
* When only a track is starred, the album heart stays empty unless the album itself is in Favorites; unfavorite no longer requires a double click on the detail page.
* Album user rating on detail reconciles from the server in the background; multi-library browse and Favorites filters use album-level stars consistently.
### Library — renamed artists no longer linger as ghosts after resync
**By [@cucadmuh](https://github.com/cucadmuh), reported by Seraphim on the Psysonic Discord, PR [#1253](https://github.com/Psychotoxical/psysonic/pull/1253)**
* Renaming an artist on the server no longer leaves a stale entry in the local Artists list that opened to "Artist not found" — a sync now prunes artist rows the latest server listing no longer confirms that also have no remaining tracks. Cleanup runs on both full and delta syncs (only after a confirmed artist listing, so a transient empty response can't drop valid entries), plus a one-time pass at startup that clears ghosts already accumulated in existing libraries.
* Newly added or renamed entries now show up right after a resync instead of only after an app restart: the Artists and Albums pages refresh their cached catalog when a library sync finishes.
### Library — album artist links no longer dead-end at "Artist not found"
**By [@cucadmuh](https://github.com/cucadmuh), reported by tummydummy, PR [#1254](https://github.com/Psychotoxical/psysonic/pull/1254)**
* Clicking the artist beneath an album (most visibly in **Random Albums**) no longer shows "Artist not found" when the server's `getArtist` doesn't recognise that album-artist id — the artist page now falls back to the local library index, which shares the id the card was built from. Artist pages also stay reachable on a brief network hiccup when the library is indexed.
### Library — album tiles no longer miss cover art in Random Albums
**By [@cucadmuh](https://github.com/cucadmuh), reported by tummydummy, PR [#1254](https://github.com/Psychotoxical/psysonic/pull/1254)**
* Album tiles for rows that synced without a cover id (surfacing most in **Random Albums**) no longer show a blank cover while the detail page has one — local browse now falls back to the album's first track cover id, so tile and detail resolve the same artwork. The same fallback applies to the detail header's library cover resolution, so the two stay consistent instead of flickering between art and placeholder.
### Library — multi-library dedup sidecar no longer accumulates dead identity keys
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1255](https://github.com/Psychotoxical/psysonic/pull/1255)**
* The precomputed `library-cluster.db` identity keys used for cross-library dedup are now pruned on rebuild when their track no longer exists (removed, or dropped when a server mints a fresh id on rename). Previously the rebuild only refreshed live tracks and never deleted stale rows, so the sidecar grew with library churn until it was recreated wholesale (server switch / restore / import). The rows were inert (reads only ever join live tracks), so dedup and browse results are unchanged — this just stops the sidecar from bloating.
### Library — renamed album artist links now heal on resync
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1256](https://github.com/Psychotoxical/psysonic/pull/1256)**
* When an artist is renamed on the server (minting a new artist id), the album's stored artist link no longer stays stuck on the old id and dead-ending at "Artist not found". Album metadata now follows the server's `getAlbum` for the artist reference, so a resync updates it instead of keeping the pre-rename id indefinitely. Complements the earlier ghost-row prune (#1253) and the local-index fallback (#1254), which did not clear the stale reference itself.
### Sync — large play queues no longer revert after pausing
**By [@norperz](https://github.com/norperz), PR [#1262](https://github.com/Psychotoxical/psysonic/pull/1262)**
* Pausing a large queue behind a reverse proxy (e.g. Nginx) could snap the player back to an earlier track — the save was one long URL that hit the HTTP 414 limit, failed silently, and idle auto-pull restored the stale server queue.
* Servers advertising the OpenSubsonic `formPost` extension (Navidrome) now save via POST; others retry once as POST on 414. A failed save no longer lets auto-pull overwrite playback — it resumes only after a successful save.
### Servers — connecting to servers behind a header gate
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1273](https://github.com/Psychotoxical/psysonic/pull/1273)**
* Adding a server that needs a custom HTTP header (Cloudflare Access, Pangolin service tokens) failed with "Connection failed" even though streaming and covers would have worked. Root cause: the connection test ran in the WebView, which sends a header-less CORS preflight the gate rejects before the real request. The test now runs natively for header-carrying servers, so the header rides on the request and the server connects.
* A failed "Add server" used to close the form and leave only a tiny status dot, so it was unclear what went wrong. The form now stays open on failure and shows the reason — the server's own message (e.g. "Wrong username or password"), an HTTP status like `HTTP 403 Forbidden`, or a transport error — and empty server address / username are caught up front with a clear message.
* Even after a gated server connected, browse and detail views that run in the WebView — Main stage, New Releases, Random Albums, Statistics, search, genres, and more — stayed empty, because those `axios` requests still tripped the gate's CORS preflight. Every Subsonic REST call that would carry a gate header now runs natively (the same reqwest path streaming and covers use), so the whole app works behind a header gate, not just playback.
* Playback itself and background prefetch still returned `403` on a gated server: the audio/preload path attached the gate header only when its playback server id happened to match the header registry key, and silently sent nothing otherwise. Header lookup now falls back to matching the request URL against the server's own endpoints (the same fallback covers and Navidrome browse already used), so streaming, prefetch and artist-info fetches carry the header reliably. Endpoint matching only ever hits a configured gated server and still honours the *apply to LAN/public* rule, so non-gated servers are untouched.
* Under the extra native-proxy traffic a gated server now generates, browse calls opened a brand-new connection pool per request, which starved connections and made fast endpoints time out. Proxied requests now reuse a pooled HTTP client, keeping keep-alive across the burst of browse calls.
* On app startup the per-server gate headers were registered with the native layer only after a successful reachability probe and bind — so if that probe was slow or the server looked briefly offline, the registry stayed empty and every native request (streaming, covers, prefetch, artist art) 403'd behind the gate even though the header was configured. Headers are now registered up front, before any probe or bind, so the native layer always has them.
* Covers that hit the gate during the brief window before headers were registered got a `403`, which was treated as "cover missing" and written a 30-minute do-not-retry marker — so on a gated server most covers stayed blank long after the gate started answering. A gate-style `403`/`401` on cover art is now treated as a recoverable hiccup (retried) rather than a permanent miss, and reconnecting a gated server clears those stale markers and re-runs the cover fill so the artwork loads.
* For a server with both a LAN and a public address, whichever answered first after launch stuck for the whole session: if the app started off the LAN it pinned to the public address and never switched back to LAN once you got home (the public address kept answering, so the LAN address was never re-checked). The reachability tick now re-checks the LAN address first with a single attempt, so returning to the LAN upgrades the connection back to local automatically, while staying remote costs just that one probe rather than the full retry wait. The LAN/public badge also refreshes immediately when you switch the active server, instead of staying on the previous server's classification until the next poll.
### Windows — MSI bundle on dev and RC versions
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1278](https://github.com/Psychotoxical/psysonic/pull/1278)**
* Windows `.msi` builds no longer fail on channel versions like `1.50.0-dev` — WiX requires a numeric fourth version field, so the bundler maps `-dev` / `-rc.N` to numeric semver in `bundle.windows.wix.version` while Settings → About still shows the real package version.
* Release builds no longer warn that the album feature barrel defeats a lazy import in the new-albums easter egg (direct import of the export helper).
### Internet Radio — equalizer presets now apply to radio playback
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1284](https://github.com/Psychotoxical/psysonic/pull/1284)**
* Internet Radio playback stayed on HTML5 after v1.32, but EQ changes only reached the Rust engine used for library tracks — toggling EQ or switching presets had no effect on a live station. Radio now routes through a Web Audio 10-band graph on the same `<audio>` element when EQ is enabled; preset and slider changes update filters in place without restarting the stream.
### Music Network — connect errors now name their cause
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1285](https://github.com/Psychotoxical/psysonic/pull/1285)**
* Connecting a scrobble service could fail with only "Network error — check your connection or URL", which covers everything from a DNS failure to a blocked host, an interrupted TLS handshake or a rejected request. The underlying error is now shown alongside it, so a failing connect can be told apart from a reachability problem on your machine or network.
### Windows — Subsonic client id no longer `psysonic/undefined`
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1290](https://github.com/Psychotoxical/psysonic/pull/1290)**
* Windows release builds could send `psysonic/undefined` as the Subsonic client id (visible in **Who is listening?**) when `package.json` version was read during a circular authStore boot-chunk init — prebuild now emits a leaf `SUBSONIC_CLIENT_ID` literal and the boot-chunk guard rejects unresolved client-id templates.
### Albums — "Artist / Year" sorting and albums with featured guests
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1292](https://github.com/Psychotoxical/psysonic/pull/1292)**
* Sorting albums by artist ordered them by the track artist while showing the album artist. On a release with featured guests the two differ, so it was filed under a name that isn't on screen — the album dropped out of its artist's run of years, sometimes behind a different artist entirely. Album sorting now follows the artist the row actually shows.
### Playlist and radio custom covers blank
**By [@cucadmuh](https://github.com/cucadmuh), reported by VirtualWolf, PR [#1295](https://github.com/Psychotoxical/psysonic/pull/1295)**
* Custom playlist and internet radio covers uploaded in Navidrome stayed blank in Psysonic (cards and detail headers) while album and track art worked. The cover resolver rewrote Navidrome's `pl-*` and `ra-*` getCoverArt ids into invalid `al-pl-*_0` / `al-ra-*_0` forms; fetch-only prefixes are now preserved in TS and Rust.
### Themes — album rails no longer cut off card shadows
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by Asra on the Psysonic Discord, PR [#1300](https://github.com/Psychotoxical/psysonic/pull/1300)**
* Horizontal album rails clipped an outer card shadow at the edges, which only themes that use a real drop shadow ran into. Working around it meant overriding the rail's `overflow`, and that disabled the rail's `<` / `>` scroll arrows. Rails now reserve room for the shadow inside the rail itself, so the arrows keep working; a theme that needs more room can raise `--rail-shadow-room` instead of touching `overflow`.
### Accessibility — modal dialogs announce their title
**By [@AliMahmoudDev](https://github.com/AliMahmoudDev), PR [#1301](https://github.com/Psychotoxical/psysonic/pull/1301)**
* Modal dialogs carried no accessible name, so a screen reader announced them without saying which dialog had opened. The dialog is now linked to its title, and each instance gets its own id so several open dialogs cannot be confused for one another.
### Themes — smooth UI with many themes installed
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by Asra on the Psysonic Discord, PR [#1315](https://github.com/Psychotoxical/psysonic/pull/1315)**
* With a large number of community themes installed, every hover or playback-state change made the browser re-evaluate the CSS of every installed theme, which could slow the UI to a crawl. Only the active theme (plus the scheduler's day and night picks) participates now; the others stay dormant until applied — switching themes is unaffected.
### Settings — cover art toggles translated
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1319](https://github.com/Psychotoxical/psysonic/pull/1319)**
* The queue cover-art setting and the track-list setting's title showed English text in every language except Russian — both are translated in all languages now, and the German description states more precisely which pages show the thumbnails.
### Square corners — player bar and list thumbnails included
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by JU3RG on the Psysonic Discord, PR [#1320](https://github.com/Psychotoxical/psysonic/pull/1320)**
* The Square Corners toggle left the player bar cover and the small cover thumbnails in list rows rounded (queue, playlists, favorites, search, Random Mix). They now go square with everything else; the floating player bar's circular cover stays round by design.
### Duplicate server session on Navidrome
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by TheHomeGuy on the Psysonic Discord, PR [#1322](https://github.com/Psychotoxical/psysonic/pull/1322)**
* Native requests carried a separate User-Agent from the in-app view, so the server listed the app as two logged-in players at once. They now share one identity and show as a single session.
## [1.49.0] - 2026-06-29
## Added
@@ -50,6 +517,97 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* **Idle auto-pull** when paused/stopped for 30+ seconds on a single-server queue (active = playback): polls every 10s and applies server changes.
* **Push** now sends only tracks owned by the playback server (fixes mixed-server queues). Switching browse servers flushes the old server's queue slice without auto-pull.
### Japanese and Hungarian translations
**By [@Soli0222](https://github.com/Soli0222), PR [#1134](https://github.com/Psychotoxical/psysonic/pull/1134)**
* Full Japanese (日本語) UI translation — selectable from the language picker on the Settings and Login screens.
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1149](https://github.com/Psychotoxical/psysonic/pull/1149)**, a gift to [@falu](https://github.com/falu) for the first independent review of Psysonic
* Psysonic is now available in **Hungarian (Magyar)** — pick it from the language menu on the Settings and Login screens.
### Artist artwork from fanart.tv
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1137](https://github.com/Psychotoxical/psysonic/pull/1137) and PR [#1193](https://github.com/Psychotoxical/psysonic/pull/1193)**
* New opt-in **External Artwork Scraper** (Settings → Integrations, off by default): artist imagery from fanart.tv — a 16:9 background on the fullscreen player and a wide banner on the artist page — with Navidrome staying the canonical cover. Optional personal key; turning it off removes the fetched images again.
* The **mainstage hero** on the home screen now shows the album artist's backdrop too, matching the fullscreen player and artist page.
* Choose, per place (mainstage hero, artist page, fullscreen player), which images to use as the background and in what order — drag to reorder or switch a source off, under the same setting. The hero also preloads the upcoming backdrops so they appear without a long blank.
### Remember the equalizer per audio output device
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1146](https://github.com/Psychotoxical/psysonic/pull/1146)**, suggested by [@JustBuddy](https://github.com/JustBuddy)
* New opt-in **Remember EQ per device** toggle (Settings → Audio → Audio Output Device, off by default): the equalizer profile — bands, pre-gain, enabled state and active preset — is saved per audio output device and restored automatically when you switch devices.
### Custom HTTP headers for gated servers
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1156](https://github.com/Psychotoxical/psysonic/pull/1156)**, closes [#1095](https://github.com/Psychotoxical/psysonic/issues/1095)
* Per-server **custom HTTP headers** in Settings → Servers for reverse-proxy gates (Cloudflare Access, Pangolin, and similar): add name/value pairs, choose whether they apply to the local URL, public URL, or both on dual-address profiles.
* Headers attach to every user-server HTTP path — library sync, playback, covers, offline download, Navidrome admin, capability probes, and share-link preview — without putting secrets in invite links or magic strings.
* Gate header values are redacted from application logs.
### Orbit — shared crossfade, gapless and AutoDJ
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1158](https://github.com/Psychotoxical/psysonic/pull/1158)**
* In an Orbit session the host's track-transition settings — crossfade, gapless or AutoDJ, including the crossfade length and smooth-skip — now apply to everyone, so guests blend between tracks the same way the host does instead of each person using their own. Your own settings are restored when you leave.
* While you are a guest in a session, the transition controls in Settings → Audio and the queue toolbar are shown as host-controlled.
### Theme scheduler — follow the system theme
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1163](https://github.com/Psychotoxical/psysonic/pull/1163)**, suggested by [@mokazemi](https://github.com/mokazemi)
* The theme scheduler can now switch your day/night theme pair based on your operating system's light/dark setting, in addition to the existing time-of-day schedule. Pick the trigger with a new Time of Day / System Theme switch; in system mode the two pickers read as Light and Dark theme. On Linux setups where the OS does not signal the change live, a hint notes it applies after restarting the app.
### Hi-Res transition blend rate
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1171](https://github.com/Psychotoxical/psysonic/pull/1171)**
* **Settings → Audio → Native Hi-Res** gains a blend-rate picker (44.1 / 88.2 / 96 kHz, default 44.1 kHz) for transitions when adjacent tracks have different sample rates, with a note that resampling uses extra CPU and memory.
* **Crossfade / AutoDJ:** both sides resample to the chosen rate; the output stream reopens when needed and the outgoing track rebuilds from cache so mixed 88.2 ↔ 44.1 kHz transitions no longer tear mid-fade.
* **Gapless:** the next track chains at the blend rate and the current track realigns when the stream Hz differs, instead of falling back to a hard cut.
### AutoDJ — configurable overlap cap
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1173](https://github.com/Psychotoxical/psysonic/pull/1173)**
* **Settings → Audio → Track transitions → AutoDJ:** choose **Auto** (content-driven overlap, up to 12 s) or **Limit** (slider 230 s, default 15 s when enabled) to cap how long AutoDJ may overlap tracks.
* The cap applies to end-of-track planning, JS auto-advance, smooth skip, and Orbit transition sync; the audio engine accepts dynamic overlap overrides up to 30 s.
### Polish translation
**By [@Rextens](https://github.com/Rextens), PR [#1185](https://github.com/Psychotoxical/psysonic/pull/1185)**
* Full Polish (Polski) UI translation — selectable from the language picker on the Settings and Login screens.
### Multiple genres in album details
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1186](https://github.com/Psychotoxical/psysonic/pull/1186)**, suggested by [@Thraka](https://github.com/Thraka)
* Album details now surface every genre a release spans instead of just the first one: the main genre shows inline with a **+N** chip that opens the full, clickable list, each genre linking to its genre page.
* Genres combine album and track tags (matching the genre browser) and read from the local library index when it is ready, so they also work offline.
### Compact buttons — switch action and toolbar buttons to icon-only
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1189](https://github.com/Psychotoxical/psysonic/pull/1189)**
* New **Compact buttons** setting under Settings → Appearance. Switch the action and toolbar buttons between large labelled buttons and small icon-only ones — across album, artist and playlist headers, the shared browse toolbars (sort, filters, multi-select), and the Most Played sort/filter controls. Defaults to large, so nothing changes unless you turn it on. On phones the album header keeps its large touch targets.
### Playlists — sort by date added
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1191](https://github.com/Psychotoxical/psysonic/pull/1191)**, suggested by SinFist
* Sort a playlist by **Date added** (newest or oldest first), or by title, artist, album and the other columns, from a new sort dropdown in the playlist filter toolbar. The Subsonic API has no per-track "added on" date, so this follows the playlist's own order — servers add new tracks at the end, so newest-first puts your latest additions on top.
### WinGet update command in the update dialog (Windows)
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1202](https://github.com/Psychotoxical/psysonic/pull/1202)**
* The Windows update dialog now also shows the WinGet command (`winget upgrade Psysonic`) next to the installer download, so you can update whichever way you installed.
## Changed
@@ -64,6 +622,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* **Settings → Personalisation** gains a **Queue Settings** category that brings the queue display mode, the queue toolbar customizer, and the **Preserve "Play Next" order** toggle (moved here from Audio) together in one place.
* On macOS, the **Audio Output Device** category is now hidden rather than showing a notice — playback there always follows the system output device.
### Russian locale — missing strings and phrasing cleanup
**By [@kilyabin](https://github.com/kilyabin), PR [#1181](https://github.com/Psychotoxical/psysonic/pull/1181)**
* Fifty strings that still fell back to English in the Russian UI are now translated — macOS in-place updater, device sync file migration, fullscreen lyrics, and statistics share-image export.
* User-facing descriptions in Russian and English no longer mention WebKitGTK or FisherYates internals; several Russian labels and section titles read more naturally (settings casing, smart playlists, track transitions, and home rails).
### macOS — themed window title bar
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1199](https://github.com/Psychotoxical/psysonic/pull/1199)**, suggested by [@bcorporaal](https://github.com/bcorporaal)
* On macOS the window's title bar now follows the active theme instead of the grey system bar; the native macOS window buttons stay in place, floating over the themed bar.
## Fixed
@@ -110,12 +681,187 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Niri is now recognized as a tiling window manager (`NIRI_SOCKET`, `XDG_CURRENT_DESKTOP=niri`), so it gets the same custom title bar, window decorations, and mini-player behavior as Hyprland and Sway instead of being treated like a floating desktop.
### Play queue idle pull overwrote local edits
### Play queue sync — follow-up fixes
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1132](https://github.com/Psychotoxical/psysonic/pull/1132)**
* After cross-device idle pull while paused, a local queue change (e.g. enqueue) could be overwritten when auto-pull ran again. Idle auto-pull now stops on local mutations until manual sync from the header; the connection LED turns yellow while auto-sync is paused.
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1133](https://github.com/Psychotoxical/psysonic/pull/1133)**
* After editing the queue while paused (yellow sync LED), pressing Play only resumed audio and could leave the server on another device's queue until the debounced push fired. Resume and play-from-queue now flush the local play queue immediately and clear the yellow indicator when the push succeeds.
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1135](https://github.com/Psychotoxical/psysonic/pull/1135)**
* The header connection probe now retries a failed ping twice (2 s apart) before marking the server unreachable, so a single dropped packet on an otherwise fine link no longer flips the LED to disconnected.
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1136](https://github.com/Psychotoxical/psysonic/pull/1136)**
* Track-advance queue pushes no longer suspend idle auto-pull, so the connection LED does not flash yellow on every song change. Yellow sync still appears after a local queue edit while paused; it clears while audio is playing.
### Favorites — bulk add to playlist and play/enqueue selected
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#1140](https://github.com/Psychotoxical/psysonic/pull/1140)**
* Bulk **Add to playlist** no longer cleared the selection on `mousedown` before the click ran, so chosen tracks were not actually added.
* With rows selected, **Play all** / **Add all to queue** become **Play selected** / **Add selected to queue** and act on the checked tracks only.
* Bulk add now snapshots every checked row when the picker opens so all selected tracks land in the playlist, not just the last one.
### Update notification — clearer popup on Linux
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1142](https://github.com/Psychotoxical/psysonic/pull/1142)**, reported by zunoz on Discord
* The "new version available" popup no longer shows blurry, unfocused text on some Linux setups (the background blur could bleed onto the dialog). The version arrow now lines up with the heading, and the Skip / Remind me later buttons read clearly — Remind me later is the highlighted action when there's no in-app installer.
### Artists letter index — Navidrome ignored articles and library index
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1145](https://github.com/Psychotoxical/psysonic/pull/1145)**, closes [#1144](https://github.com/Psychotoxical/psysonic/issues/1144)
* On the **Artists** page (and **Composers**), the AZ filter now groups names like Navidrome: leading articles such as **The** are skipped before picking the letter — **The Beatles** lands under **B**, not **T**. The bucket follows the server's own `ignoredArticles` list when the local index knows it.
* The local library index stores `name_sort` and the server's `ignoredArticles` from `getArtists`, sorts browse SQL by the sort key (now indexed), and repairs stale keys once on upgrade.
* The local library database now opens, swaps and restores through one pipeline, so a swapped or restored file always picks up pending migrations and one-time repairs instead of serving a stale schema.
* A panic or a poisoned lock in one query no longer wedges the whole library index — connections recover and report the error instead, and the new sort-key migration applies idempotently so a half-applied upgrade self-heals on the next launch.
### Equalizer — the active AutoEQ profile name stays visible
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1147](https://github.com/Psychotoxical/psysonic/pull/1147)**
* After applying an AutoEQ headphone profile, the preset picker now shows the profile name under an AutoEQ group instead of going blank, and the delete button no longer appears for AutoEQ profiles (where it did nothing).
### All Albums — compilation and favorites filters
**By [@cucadmuh](https://github.com/cucadmuh), reported by [@bcorporaal](https://github.com/bcorporaal), PR [#1151](https://github.com/Psychotoxical/psysonic/pull/1151)**, closes [#1143](https://github.com/Psychotoxical/psysonic/issues/1143)
* **Only compilations** no longer shows a handful of albums after the local index already filtered them — slice mode skips the redundant client pass that dropped rows without `isCompilation` on the DTO.
* **Favorites** on All Albums uses the same `getStarred2` catalog path as the Favorites page instead of the empty sparse `album` table browse.
* Pre-index compilation filtering auto-paginates again in network page mode; offline library aggregates set `isCompilation` from track tags.
### Playlists header buttons clipped at narrow widths
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1153](https://github.com/Psychotoxical/psysonic/pull/1153)**
* The action buttons at the top of the Playlists page (New Playlist, New Smart Playlist, folder controls, Select) could run off-screen and get cut off when the window was narrow or the queue panel was open. They now wrap onto multiple rows, left-aligned.
### Orbit — session reliability fixes
**By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#1155](https://github.com/Psychotoxical/psysonic/pull/1155), [#1157](https://github.com/Psychotoxical/psysonic/pull/1157), [#1159](https://github.com/Psychotoxical/psysonic/pull/1159)**
* Opening Psysonic on a second device no longer deletes a session that is still live on another device.
* Long sessions keep updating for guests instead of silently stalling once the shared state grew too large.
* Radio no longer adds unrelated tracks to a guest's queue mid-session.
* Auto-shuffle and auto-approve are independent again — toggling one in an older session no longer flips the other.
* A session is kept within its guest limit even when several people join at once.
* Guest suggestions no longer get silently lost or stuck on "waiting on host": overlapping host updates are serialised, a lost suggestion is re-sent (with a notice if it still can't get through), and a flaky join no longer leaves a duplicate suggestion list on the server.
* Pasted invites are rejected unless they point at a normal http/https server address.
### macOS dock icon larger than native apps
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1169](https://github.com/Psychotoxical/psysonic/pull/1169)**, closes [#1166](https://github.com/Psychotoxical/psysonic/issues/1166)
* On macOS the dock icon was rendered edge-to-edge and looked larger than other apps; it is now padded to Apple's icon grid so it matches native sizing.
### Artist header showing the plain image instead of the external background
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1172](https://github.com/Psychotoxical/psysonic/pull/1172)**
* On the artist page, when an artist had an external background image (from fanart.tv) but no banner, the header showed the plain Navidrome artist image instead of the background — even though the fullscreen player used the background correctly. The header now falls back banner → background → Navidrome image as intended. The background also sits a little higher so band members' heads aren't cropped on wide screens.
### Context menu "Play Now" and resize behaviour
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1174](https://github.com/Psychotoxical/psysonic/pull/1174)**, reported by [@peri4ko](https://github.com/peri4ko)
* On the Playlists page, right-clicking a playlist and choosing "Play Now" only opened the playlist instead of playing it. It now starts playback.
* Resizing the window while a context menu was open could leave the menu stranded and drifting off-screen. The context menu now closes when the window is resized.
### Genres page kept empty genres after tag changes
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1176](https://github.com/Psychotoxical/psysonic/pull/1176)**, closes [#1162](https://github.com/Psychotoxical/psysonic/issues/1162)
* After retagging a track and resyncing the library, genres with no remaining albums could still appear on the Genres page until restart. The local genre catalog now counts only live indexed tracks, filters zero-count genres, and the Genres page refreshes when library sync finishes.
### AutoDJ — last track in the queue was cut short
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1183](https://github.com/Psychotoxical/psysonic/pull/1183)**
* With AutoDJ active and no next track to blend into, the engine could still fire the crossfade end timer and trim the final song. The last track now plays through to real source exhaustion.
### Play queue sync — idle pull rewound after the queue finished
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1183](https://github.com/Psychotoxical/psysonic/pull/1183)**
* After the last track ended (repeat off), idle auto-pull could restore an earlier server position from the last debounced push and seek backward. The client now flushes end-of-track position to the server and skips idle auto-pull until playback resumes, the queue is edited, or the user pulls manually.
### Sidebar — offline nav gating after manual reconnect Retry
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1190](https://github.com/Psychotoxical/psysonic/pull/1190)**, closes [#1160](https://github.com/Psychotoxical/psysonic/issues/1160)
* Strengthens the existing disconnect/recovery path: connection status is now shared across all `useConnectionStatus` hook instances, so a successful **Retry** on the offline banner clears offline-browse sidebar filtering in step with the header connection indicator (no app restart).
### Timeline play history disappeared on album/playlist play
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1204](https://github.com/Psychotoxical/psysonic/pull/1204)**, closes [#1096](https://github.com/Psychotoxical/psysonic/issues/1096)
* Timeline mode now keeps a session play-history strip (plus cold bootstrap of the last 50 plays from statistics) when Play album/playlist replaces the queue; canonical queue sync is unchanged.
* The current track stays pinned to the top of the list; clicking a history row inserts after the playing track instead of replacing the queue, and replayed tracks remain in the history strip.
* History rows from other servers resolve album/cover metadata per server so Now Playing artwork loads when replaying cross-server plays.
* Cross-server queue switches now send `playbackReport` **stopped** to the previous server so its Who is listening entry clears promptly.
### Album and artist covers — full resolution restored
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1205](https://github.com/Psychotoxical/psysonic/pull/1205)**
* Album and artist covers — and the full-size view when you click a cover — could appear small and low-quality even though the source image was large, depending on how you reached the album. Root cause: the cache built its larger sizes from a smaller already-saved size instead of the full-resolution download, so they were stored downscaled. Covers are now built from the full-resolution image, and the full-size view opens at full resolution. The cover cache refreshes once on update. Reported by users on Discord.
## Under the Hood
### WinGet — automated manifest updates on release
**By [@ImAsra](https://github.com/ImAsra), PR [#1077](https://github.com/Psychotoxical/psysonic/pull/1077)**
* New GitHub Actions workflow publishes Windows installer updates to `microsoft/winget-pkgs` on each release — scans the `_x64-setup.exe` asset, computes SHA-256, and opens the upstream PR via `winget-releaser`.
### ESLint setup and a strict lint pass over the frontend
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1165](https://github.com/Psychotoxical/psysonic/pull/1165)**
* Added an ESLint config and `npm run lint`, and brought `src/` to zero errors and warnings under the strict React-hooks ruleset. Developer-only — no user-facing behaviour change.
### CI — ESLint gate and path-aware ci-ok merge check
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1170](https://github.com/Psychotoxical/psysonic/pull/1170)**
* Strict `npm run lint` runs in CI on frontend path filters via a dedicated workflow parallel to the existing frontend test jobs.
* The `ci-ok` check waits for every applicable test and lint job on a PR (frontend and/or Rust, depending on changed paths) and blocks merge when any required job failed or did not finish in time.
### Settings — consistent design for the Audio sub-sections
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1175](https://github.com/Psychotoxical/psysonic/pull/1175)**
* The AutoDJ overlap-cap and the Native Hi-Res blend-rate options in Settings → Audio now sit in the same bordered sub-card the Normalization options use, and the Hi-Res section no longer shows a double border.
### App no longer blanks on an unexpected error
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1194](https://github.com/Psychotoxical/psysonic/pull/1194)**
* If a screen hit an unexpected rendering error, the whole window could go blank with no way back. The app now shows a small recoverable error card (Try again / Reload app) instead, and playback keeps going.
### Windows update notice waits out WinGet moderation
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1200](https://github.com/Psychotoxical/psysonic/pull/1200)**
* On Windows, the "update available" notice now waits until a release is a couple of days old, so it no longer points to a version that WinGet has not finished publishing yet. macOS and Linux are unaffected.
### Playlist no longer reloads when you press Play
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1201](https://github.com/Psychotoxical/psysonic/pull/1201)**
* Pressing Play, Shuffle or Add to queue on a playlist no longer reloads the whole page with a spinner — it just starts playback. Editing the playlist (adding or removing songs) still refreshes the list as before.
### Sidebar items jumped back when reordered
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1206](https://github.com/Psychotoxical/psysonic/pull/1206)**, reported by [@tummydummy](https://github.com/tummydummy)
* In Settings → Personalization → Sidebar, dragging an item to a new position could snap it back or land it one place off, depending on which items were hidden. Reordering now tracks each item directly, so it stays exactly where you release it — both in the customizer and when long-pressing items in the sidebar itself.
## [1.48.1] - 2026-06-15
+28 -3
View File
@@ -73,7 +73,7 @@ If you use **Nix**, `nix develop` (see [`flake.nix`](flake.nix)) provides the pi
| Topic | Location |
|--------|----------|
| Frontend test stack (Vitest, Tauri/Subsonic mocks, store resets, i18n in tests) | [`src/test/README.md`](src/test/README.md) |
| What CI runs for frontend / backend | [`frontend-tests.yml`](.github/workflows/frontend-tests.yml), [`rust-tests.yml`](.github/workflows/rust-tests.yml) |
| What CI runs for frontend / backend | [`frontend-tests.yml`](.github/workflows/frontend-tests.yml), [`eslint.yml`](.github/workflows/eslint.yml), [`rust-tests.yml`](.github/workflows/rust-tests.yml) |
| Frontend "hot path" files held to a coverage threshold | [`frontend-hot-path-files.txt`](.github/frontend-hot-path-files.txt), [`check-frontend-hot-path-coverage.sh`](scripts/check-frontend-hot-path-coverage.sh) |
| Rust hot-path gate | [`hot-path-files.txt`](.github/hot-path-files.txt), [`check-hot-path-coverage.sh`](scripts/check-hot-path-coverage.sh) |
| Nix packaging / release automation | [`flake.nix`](flake.nix), workflows under [`.github/workflows/`](.github/workflows/) |
@@ -84,11 +84,12 @@ If you use **Nix**, `nix develop` (see [`flake.nix`](flake.nix)) provides the pi
1. **One pull request, one coherent goal.** Easier review, easier revert, fewer merge conflicts.
2. **Match existing style** in touched files (naming, module layout, comment density). Avoid drive-by refactors unrelated to the task.
3. **Linting and formatting:** there is no enforced JS/TS formatter or ESLint config in the repo today — `tsc --noEmit` is the only frontend gate beyond tests. For Rust, `cargo clippy --workspace --all-targets -- -D warnings` is the lint gate; `cargo fmt` is not currently required but won't hurt.
3. **Linting and formatting:** ESLint (strict `eslint.config.mjs`) and **`npm run dep:check`** (dependency-cruiser layering/cycle guard) run in CI on frontend paths; run both locally before opening a frontend PR. `tsc --noEmit` is also required. For Rust, `cargo clippy --workspace --all-targets -- -D warnings` is the lint gate; `cargo fmt` is not currently required but won't hurt.
4. **Commit messages:** a short **human-readable** summary of what changed and why; Conventional Commits-style prefixes (`feat:`, `fix:`, ...) are fine if you prefer them. Do not include meta references (IDEs, assistants, or how the message was produced) — only what matters for project history.
5. **License:** new code must remain compatible with the project's GPLv3.
6. **Tests:** when you change behaviour users rely on, add or update tests next to the code (see [`src/test/README.md`](src/test/README.md)). Purely visual tweaks may not need tests, but behavioural regressions should be covered where the suite can catch them.
7. **i18n:** user-visible strings live in `src/locales/*.ts` (one TypeScript module per language) and are wired up in `src/i18n.ts`. English (`en.ts`) is the baseline — always add the key there. Other locales may be left for follow-up translation PRs if you don't speak the language, but keep the object shape consistent so missing keys are obvious.
- **Adding a new language (not just keys):** the Rust cluster-key normalizer (`src-tauri/crates/psysonic-library/src/identity/norm.rs`) folds diacritics/ligatures per shipped locale so library items match across servers. When a locale introduces a script or letters it does not yet cover, extend its decomposition table for that language and **bump `NORM_VERSION`** (existing `library-cluster.db` keys rebuild automatically). CJK locales are intentionally left verbatim. The module header carries the same checklist.
---
@@ -108,15 +109,36 @@ Align early: open an issue or chat thread before sending a PR that renames `invo
---
## Frontend architecture and layering
The frontend uses a feature-folder architecture (introduced in #1225) with a layering contract that CI enforces through **`npm run dep:check`** (dependency-cruiser). The rule is simple: **a lower layer must never import a higher one.**
```
lib → store / ui → cover / music-network → features/<x> → app
```
- **`lib/**` is the floor** — feature-free infrastructure (API clients, formatting, i18n, util, media, server, navigation). It must **not** import from `store`, `ui`, `features`, `cover`, `music-network`, or `app`. If a `lib` helper needs auth/server state, pass it in as an argument or keep the helper in the layer that owns that state (`store` or `features`) — do not reach into a store from `lib`.
- **`store` / `ui`** may import `lib` only.
- **`cover` / `music-network`** are top-level domains and may import `lib`, `store`, `ui`.
- **`features/<x>`** may import `lib`, `store`, `ui`, `cover`, `music-network`, and other features — but cross-feature access goes **only** through the `@/features/<x>` barrel, never a deep path.
- **`app`** (shell + bridges) may import anything.
- **No import cycles** anywhere under `src/`.
The authoritative rules and rationale live in [`.dependency-cruiser.cjs`](.dependency-cruiser.cjs) (its header documents the layers and the known-violation ratchet). Any **new** layering or cycle violation fails the `dependency-cruiser` job and blocks `ci-ok`, so run **`npm run dep:check`** locally before opening a frontend PR. The known-violations baseline in `.dependency-cruiser-known-violations.json` only ratchets **down** — don't regenerate it to silence a new violation; fix the import instead.
---
## CI on pull requests to `main`
PRs must target `main`. `next` and `release` are maintainer-driven promotion branches — don't target them directly.
Workflows are path-filtered (see the YAML for exact `paths` / `paths-ignore`):
- **Frontend** (`src/**`, lockfile, Vitest/Vite/tsconfig, etc.): `npm test` (Vitest), `npx tsc --noEmit`, then a coverage run.
- **Frontend** (`src/**`, lockfile, Vitest/Vite/tsconfig, ESLint config, dependency-cruiser config, etc.): `npm run lint`, **`npm run dep:check`** (layering + cycle guard), `npm test` (Vitest), `npx tsc --noEmit`, then a coverage run.
- **Rust** (`src-tauri/**`): `cargo test --workspace --all-targets`, `cargo clippy --workspace --all-targets -- -D warnings`, then coverage.
The **`ci-ok`** job in [`ci-main.yml`](.github/workflows/ci-main.yml) is the merge gate: it waits for every required job above whose path filter matched the PR, and fails if any of them failed or did not finish in time.
Hot-path coverage gates are **required** on pull requests: the `coverage` jobs in [`frontend-tests.yml`](.github/workflows/frontend-tests.yml) and [`rust-tests.yml`](.github/workflows/rust-tests.yml) fail when any listed file drops below the floor. See the headers in [`frontend-hot-path-files.txt`](.github/frontend-hot-path-files.txt) and [`hot-path-files.txt`](.github/hot-path-files.txt) for curation rules and thresholds.
---
@@ -129,7 +151,10 @@ Assume the repository root is `psysonic/` (for example after `git clone https://
```bash
npm ci
npm run lint
npm run dep:check
npm test
npm run prebuild:release-notes
npx tsc --noEmit
npm run test:coverage
bash scripts/check-frontend-hot-path-coverage.sh
+6 -6
View File
@@ -40,8 +40,9 @@ Click **Psy Orbit** in the top bar → **Create a session**. The start modal ope
- **Session name** — a random playful name is generated; edit it or reroll with the dice button.
- **Max guests** — cap on concurrent participants (132). You don't count.
- **Session server** — when your library scope includes several servers, choose the Navidrome instance that will host the session. Psysonic temporarily narrows the library scope and mixed queue to that server, then restores the previous scope when the session ends.
- **Invite link** — ready to copy and share the moment the modal opens. Pre-generated from a fresh session id + the slugified name.
- **Clear my queue first** — optional. Start with an empty queue (guest suggestions land fresh) vs. keep your current queue and share it with the guests.
- **Clear my queue first** — optional. Start with an empty queue (guest suggestions land fresh) vs. keep the chosen server's queued tracks and share them with the guests.
Click **Start Orbit**. The session bar appears at the top of the window (session name, participant count, shuffle countdown, settings / share / help / exit buttons). The link is now live — share it.
@@ -267,11 +268,10 @@ The state blob is size-bounded to 4 KB (serialised JSON). `serialiseOrbitState`
### Cleanup
Three layers of defense against orphaned playlists:
Two layers of defense against orphaned playlists:
1. **Explicit exit.** `endOrbitSession` (host) or `leaveOrbitSession` (guest) deletes the participant's own playlists synchronously. The happy path.
2. **Server-switch teardown.** Switching Navidrome servers tears the current session down first (up to 1.5 s), then switches. Prevents "in session against wrong server" states.
3. **App-start orphan sweep.** Every app launch runs `cleanupOrphanedOrbitPlaylists`: lists every `__psyorbit_*` playlist the current user owns, parses the heartbeat from the comment, deletes anything with a heartbeat older than 5 minutes (or `ended: true`, or an unparseable comment). The current local session is always protected.
2. **App-start orphan sweep.** Every app launch runs `cleanupOrphanedOrbitPlaylists`: lists every `__psyorbit_*` playlist the current user owns, parses the heartbeat from the comment, deletes anything with a heartbeat older than 5 minutes (or `ended: true`, or an unparseable comment). The current local session is always protected.
The 5-minute TTL is a conservative compromise: long enough to survive a brief app restart (and a session running on another device of yours), short enough that a dead session doesn't clutter the server indefinitely.
@@ -295,7 +295,7 @@ The 5-minute TTL is a conservative compromise: long enough to survive a brief ap
- **Bulk "Play All" in-session.** Dialog: "Add 14 tracks to the Orbit queue?" On confirm, appended. On cancel, no-op.
- **Single-click on song row in-session.** Swallowed; shows "Double-click to add" toast.
- **Multiple accounts on target server.** Paste flow opens an account picker modal. Keyboard-navigable.
- **Server switch while in session.** Teardown runs before switch. Any server-resident session playlists get cleaned up by their host's next app-start sweep.
- **Server switch while in session.** The session remains bound to its original server; changing the visible active server cannot redirect playlist reads, writes, suggestions or cleanup.
- **Initial sync race.** The guest's first tick retries on 500 ms cadence until the player state actually matches the host's last-known track (up to 2 s per attempt, then falls through with a best-effort mirror).
- **`positionAt` stale on join.** Seek fraction is clamped to [0, 0.99] — prevents `audio:ended` from firing at the very start of a join.
- **Outbox deletion mid-session** (cleanup race): host sees the guest drop out on the next sweep; guest's next heartbeat recreates the outbox if they're still connected.
@@ -312,7 +312,7 @@ The 5-minute TTL is a conservative compromise: long enough to survive a brief ap
### Lifecycle
- `src/utils/orbit.ts``startOrbitSession`, `joinOrbitSession`, `endOrbitSession`, `leaveOrbitSession`, `suggestOrbitTrack`, `approveOrbitSuggestion`, `declineOrbitSuggestion`, `hostEnqueueToOrbit`, `cleanupOrphanedOrbitPlaylists`, `effectiveShuffleIntervalMs`.
- `src/utils/orbitBulkGuard.ts` — standalone confirm-dialog helper invoked from `playerStore` when `>1` tracks land in the queue while a session is active.
- `src/utils/switchActiveServer.ts` — wires Orbit teardown into server-switch.
- `src/utils/server/switchActiveServer.ts`switches the active account without tearing down the source-bound Orbit session.
### Hooks
- `src/hooks/useOrbitHost.ts` — host state tick + outbox sweep + merge pipeline + heartbeat.
+119 -63
View File
@@ -14,11 +14,11 @@ Psysonic is built primarily for **Navidrome** and also works with **Gonic**, **A
<a href="https://discord.gg/AMnDRErm4u"><img src="https://img.shields.io/badge/Discord-Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord Community"></a> <a href="https://t.me/+GLBx1_xeH28xYTJi"><img src="https://img.shields.io/badge/Telegram-Community-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community"></a> <a href="https://ko-fi.com/psychotoxic"><img src="https://img.shields.io/badge/Ko--fi-Support%20Psysonic-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white" alt="Support Psysonic on Ko-fi"></a>
<a href="https://aur.archlinux.org/packages/psysonic"><img src="https://img.shields.io/badge/AUR-psysonic-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic"></a> <a href="https://aur.archlinux.org/packages/psysonic-bin"><img src="https://img.shields.io/badge/AUR-psysonic--bin-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic-bin"></a> <a href="https://psysonic.cachix.org"><img src="https://img.shields.io/badge/Cachix-psysonic.cachix.org-5277C3?style=for-the-badge&logo=nixos&logoColor=white" alt="Cachix"></a> <a href="https://github.com/microsoft/winget-pkgs/tree/master/manifests/p/Psychotoxical/Psysonic/1.47.0"><img src="https://img.shields.io/badge/WinGet-psysonic-blue?style=for-the-badge&logo=windows" alt="WinGet psysonic"></a>
<a href="https://aur.archlinux.org/packages/psysonic"><img src="https://img.shields.io/badge/AUR-psysonic-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic"></a> <a href="https://aur.archlinux.org/packages/psysonic-bin"><img src="https://img.shields.io/badge/AUR-psysonic--bin-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic-bin"></a> <a href="https://psysonic.cachix.org"><img src="https://img.shields.io/badge/Cachix-psysonic.cachix.org-5277C3?style=for-the-badge&logo=nixos&logoColor=white" alt="Cachix"></a> <a href="https://github.com/microsoft/winget-pkgs/tree/master/manifests/p/Psychotoxical/Psysonic"><img src="https://img.shields.io/badge/WinGet-psysonic-blue?style=for-the-badge&logo=windows" alt="WinGet psysonic"></a>
<br><br>
**Available languages:** English, German, Spanish, French, Norwegian Bokmål, Dutch, Romanian, Russian and Chinese.
**Available languages:** English, German, Spanish, French, Norwegian Bokmål, Dutch, Romanian, Russian, Chinese, Japanese, Hungarian and Polish.
More translations are added over time.
@@ -38,95 +38,142 @@ Psysonic is a desktop music client for self-hosted music libraries. It is design
It is built with **Rust**, **Tauri v2** and **React**, with a strong focus on responsiveness, customization, practical music-library workflows and a user interface that does not require a manual before you can press play.
Psysonic is **optimized first and foremost for Navidrome**. Other Subsonic-compatible servers can work well too, but advanced features may depend on server-side support.
Psysonic is **optimized first and foremost for Navidrome**, and it leans into that on purpose: instead of being one more generic Subsonic client, it is **the Navidrome-first desktop client that does things no other client does.** Other Subsonic-compatible servers can work well too, but advanced features may depend on server-side support.
---
# Highlights
# ⭐ Key Features
## Playback & Queue
These are the things that set Psysonic apart. To our knowledge, no comparable self-hosted desktop client ships them.
## 🪐 Orbit — Shared Listening
**Listen together, in sync, over your own server.**
Orbit brings real-time synchronized group listening into Psysonic. Start a session, invite people with a link, and everyone hears the same thing at the same time — with host-controlled playback, a shared queue and guest song suggestions.
The clever part: Orbit rides entirely on **your own Navidrome**. There is no external relay, no third-party service and no extra accounts. The session lives on your server, where it belongs. It is built for real-world music sharing without turning your self-hosted setup into a social-media circus.
<div align="left">
<img src="public/orbit.png" alt="Orbit shared listening" width="520"/>
</div>
## ⚡ Local Library — Instant, and Almost Offline
**A local index of your whole collection, so the app stays fast no matter what your connection does.**
Psysonic keeps a local library of your collection's metadata right on your machine. Because the app already knows your tracks inside out, browsing, searching and starting playback are instant — even a 500 MB FLAC starts the moment you hit play, because nothing has to be fetched or parsed first.
It also means the connection to your server stops being a bottleneck. Even on a slow, flaky or distant link, Psysonic stays responsive and **behaves almost like an offline player**, whatever the network is doing.
And it's the foundation everything else is built on: the local library is what makes on-device analysis, smart audio and snappy navigation possible in the first place.
## 🧠 On-Device Audio Analysis
**One of the most powerful things Psysonic does — entirely on your own machine.**
Built on top of the local library, Psysonic analyzes your tracks locally — **loudness, waveform and tempo** — with no cloud service and no required server-side plugin. That analysis is what powers content-aware AutoDJ transitions, LUFS-based loudness normalization and playback-speed control.
This is a deep, genuinely useful layer that most clients simply don't have, and because it runs locally, it works exactly the same whether you're fully online or barely connected.
## 🎧 AutoDJ — Content-Aware Crossfade
**A DJ that listens to the music, not a stopwatch.**
Most players do fixed-time crossfade: blend the last N seconds into the next N seconds, dead air and all. AutoDJ uses Psysonic's own audio analysis to **trim the silence at the edges of a track and blend out of the actual music** — for transitions that sound deliberate instead of mechanical. It is a standalone playback mode with smooth skip/interrupt handling and a configurable overlap.
## 🔗 Navidrome-Native, Deeply
**Not a generic Subsonic client wearing a Navidrome hat.**
Psysonic binds Navidrome's native capabilities directly: server-side smart-playlist create/edit, playback reporting and OpenSubsonic capability probing. Most clients in this space stay Subsonic-generic. Psysonic goes deeper, so Navidrome users get the features their server can actually deliver.
## 🎨 Community Theme Store
**A real marketplace for themes — installable and schedulable.**
Beyond a big set of built-in themes, Psysonic has a first-party theme registry: browse community themes, install them in-app, and let the **Theme Scheduler** switch looks automatically between day and night.
---
> ### Built to be trusted
>
> We take an enterprise-grade approach to development — continuously improving our automated testing and maintaining strict contracts between the backend and the frontend. Releases are cut from green CI, not vibes.
---
# ✨ More Highlights
Features that go well beyond the basics. Not all of these are unique to Psysonic, but few clients bring this many together.
## Audio & Loudness
* Gapless playback
* Crossfade
* ReplayGain support
* LUFS-based Smart Loudness Normalization
* [AudioMuse-AI](https://github.com/NeptuneHub/AudioMuse-AI) support
* Infinite Queue
* Smart Radio sessions
* Fast and responsive playback handling
* Low memory usage compared to heavy web-first clients
## Audio Tools
* 10-band Equalizer
* Equalizer presets
* ReplayGain support and loudness-aware playback
* 10-band Equalizer with presets
* AutoEQ headphone correction
* Per-device optimization
* Loudness-aware playback options
* Per-device EQ and output optimization
* Adjustable playback speed
## Library Management
## Lyrics & Listening
* Synced lyrics with seek support, from multiple providers ([YouLy+](https://github.com/ibratabian17/YouLyPlus), LRCLIB, NetEase)
* Auto-scrolling sidebar lyrics and a fullscreen lyric mode
* Last.fm scrobbling, similar artists, loved tracks and listening stats
* Smart Radio sessions and an Infinite Queue
* [AudioMuse-AI](https://github.com/NeptuneHub/AudioMuse-AI) support for sonic-similarity discovery (requires an AudioMuse-AI server)
## Artwork & Visuals
* Optional external artist imagery via **fanart.tv** — opt-in, shown on the artist page, fullscreen player and home hero (Navidrome stays the canonical cover-art source)
* Cover art surfaced across the app, OS media controls and Discord Rich Presence
## Library & Playlists
* Fast search across large libraries
* Albums, artists, tracks and genres
* Ratings support
* Multi-select bulk actions
* Drag & drop playlist management
* Smart Playlists
* Built for large self-hosted collections
* Drag & drop playlist management
* Multi-select bulk actions
## Lyrics & Discovery
## Sharing
* Synced lyrics with seek support
* Lyrics provider support: [YouLy+](https://github.com/ibratabian17/YouLyPlus), LRCLIB and NetEase
* Auto-scrolling sidebar lyrics
* Fullscreen lyric mode
* Last.fm scrobbling
* Similar artists
* Loved tracks and listening stats
* Magic Strings sharing for albums, artists and queues
* Navidrome user-management helpers for fast account sharing
## Sharing & Social Listening
## Offline, Sync & Deployment
* Magic Strings sharing:
* share albums, artists and queues
* Navidrome user management helpers
* fast account sharing
* Orbit shared listening sessions:
* host-controlled synchronized playback
* session invites via link
* guest song suggestions
* real-time queue interaction
* Offline playback and downloads
* USB / portable sync
* LAN / remote auto-switching
* Custom HTTP headers for reverse-proxy-gated servers (e.g. Cloudflare Access, Pangolin)
* Backup and restore settings
* In-app auto updater
## Personalization & Accessibility
* Large theme collection
* Catppuccin and Nord inspired styles
* Glassmorphism effects
* Font customization
* Zoom controls
* Font customization and zoom controls
* Keybind remapping
* Theme Scheduler for automatic day/night switching
* Colorblind-friendly theme options
* Keyboard-friendly navigation
## Power User Extras
## Power-User Extras
* CLI controls
* USB / portable sync
* Backup and restore settings
* In-app auto updater
* LAN / remote auto switching
---
<div align="left">
<img src="public/orbit.png" alt="Shared listening feature banner" width="520"/>
</div>
# ✅ The Basics, Done Right
Orbit brings synchronized shared listening sessions directly into Psysonic.
The things you simply expect from a serious music player — and Psysonic does them well.
Start a session, invite others with a link and listen together with host-controlled playback, shared queue interaction and guest song suggestions. It is built for real-world music sharing without turning your self-hosted setup into a social-media circus.
* Gapless playback and crossfade
* Fast search across large libraries
* Browse albums, artists, tracks and genres
* Ratings
* Queue management
* Keyboard navigation
* Media key support
* Low memory usage and native performance compared to heavy web-first clients
* Built for large self-hosted collections
---
@@ -160,6 +207,9 @@ install via Windows Package Manager (WinGet):
```powershell
winget install Psysonic
```
You can also browse and install it on [winstall.app](https://winstall.app/apps/Psychotoxical.Psysonic).
## macOS
Download the signed DMG from the [GitHub Releases](https://github.com/Psychotoxical/psysonic/releases/latest).
@@ -198,6 +248,12 @@ See [TELEMETRY.md](TELEMETRY.md) for the telemetry stance and [PRIVACY.md](PRIVA
---
# Reviews
* [An independent review at falu.github.io](https://falu.github.io/2026/06/19/psysonic.html)
---
# Community & Support
Join the community, report bugs, suggest features, share themes and help shape the future of Psysonic.
+2
View File
@@ -27,6 +27,8 @@ Version is authoritative in `package.json` and `package-lock.json`. Promotion wo
- `next` version format: `X.Y.Z-rc.N`
- `release` version format: `X.Y.Z`
WiX/MSI bundle version: alphabetic pre-releases (`-dev`, `-rc.N`) map to monotonic `major.minor.patch.build` in `bundle.windows.wix.version` via `scripts/sync-wix-bundle-version.mjs` — dev `.1`, rc `.10000+N`, stable `.65534` (in-place MSI upgrade across promotion). About/updater still show the real package version. NSIS accepts full semver without this mapping.
Rules:
1. Never edit versions manually in random commits.
+1
View File
@@ -36,6 +36,7 @@ Some Psysonic features can communicate with external services, such as:
- Last.fm
- Bandsintown
- Discord Rich Presence
- Fanart.tv
These integrations are optional and clearly presented as opt-in features. They are never required for using Psysonic.
+196 -12
View File
@@ -8,46 +8,230 @@ Within each section, order by **user impact** (most noticeable first) — not PR
`CHANGELOG.md` keeps strict PR order inside Added / Changed / Fixed.
## [1.50.0]
## Highlights
### Multi-library filter — browse across your libraries
- The sidebar library picker now supports **multi-select with priority ordering** — browse, search, genres, and album/artist detail views aggregate across the libraries you pick and de-duplicate shared items by priority.
- Cross-library matching normalises names per locale (German ß, Norwegian æ, French œ, Romanian ș/ț, Cyrillic ё/й); CJK titles are matched as-is.
- The Genres page and album browse genre filter list the full catalog on large libraries when **All libraries** is selected.
### Navidrome public share links — listen without logging in
- Paste or search a Navidrome **public share** URL (`/share/{id}`) to preview the shared track list, then play the full queue with no server account.
- Share playback stays isolated from your logged-in Navidrome queue — idle server play-queue pull cannot replace a share session while you are connected elsewhere.
- While a share queue is active, **Save Playlist** is hidden in the queue toolbar; **Share** copies the original Navidrome `/share/{id}` page URL.
### Fullscreen player — Minimal, Immersive, and Prism
- **Settings → Appearance → Fullscreen player style** now offers three looks: **Minimal** (the current sharp view), **Immersive** (artist photo/backdrop with rail or Apple-style scrolling lyrics), and **Prism** (full-bleed artist backdrop, glass lyrics panel, and a single glass control bar).
- In Immersive, **Show artist photo** and **Photo dimming** are configurable; Prism drives progress and the active lyric line from the cover-derived accent colour.
### Lyrics that follow the singer, word by word
- The **Server** lyrics source now highlights lyrics word by word as a track plays, so karaoke sync no longer needs the third-party YouLyPlus backend. It requires Navidrome 0.63 or newer and lyrics that carry word timing (TTML or Enhanced LRC files) — everything else keeps highlighting line by line. The requirements are spelled out under **Settings → Lyrics → Lyrics Sources**.
- Embedded Enhanced LRC no longer prints raw word timing codes in the lyric text; FLAC, Ogg Vorbis, Opus, and Speex files with synced lyrics in the `SYNCEDLYRICS` tag show embedded lyrics again.
### Player bar — build your own, plus shuffle
- **Settings → Personalisation → Player bar** now also hides the **stop button** and shows the **album name** under the artist (off by default; clicking it opens the album). Star rating, favourite, love, playback speed, equalizer, and mini player can be **dragged into any order** you like — the section is no longer behind **Advanced**.
- A **shuffle toggle** in the player bar shuffles the queue from the current track onwards while keeping the playing track in place; turning shuffle off restores the original order. It survives restarts and keeps Orbit guests in sync with the host. Hide the button from **Settings → Personalisation → Player bar** if you prefer.
### Track lists — optional album cover thumbnails
- Browse and queue track rows can show each track's **album** cover (per-disc art when the album has distinct disc covers).
- **Settings → Appearance** adds separate toggles for queue vs browse tracklists; playlist, Favorites, and album-detail grids gain a flex-resize handle on the title column when covers are shown.
- Album detail pages skip per-row cover thumbs when the album art is already shown above the list.
### Discord — Server cover art without exposing your login
- **Settings → Integrations → Discord → Cover art source** includes a **Server** option alongside **None** and **Apple Music**. It resolves artwork through the server's public album image link — never an authenticated URL that could expose your login credentials. Requires a publicly reachable server.
### Artists browse — album artists or track credits
- Toggle **Album artists** vs **Track artists** on the Artists page — album mode lists indexed album artists; track mode includes featured and guest performers from the local artist index. The choice persists across restarts like **Show artist images**.
- Artist name search no longer depends on query letter case for Cyrillic and other non-ASCII names when the local library index is enabled.
### Theme Store — what's new on each theme
- Each theme card has an expandable **What's new** with per-version release notes from the author — including non-visual fixes.
- Installed themes with an available update now appear at the top of the store list so you do not have to hunt for them.
- **Settings → System → Contributors** lists community theme authors in a **Themes** section alongside app contributors; author names refresh quietly from the store in the background.
### Italian and Bulgarian — now in your language
- Psysonic is now available in **Italian (Italiano)** and **Bulgarian (Български)** — pick either from the language menu on the **Settings** and **Login** screens.
### Start minimized to tray
- New **Start Minimized to Tray** toggle under **Settings → System → Behavior** — the next cold start keeps the main window hidden and Psysonic runs from the system tray until you show it from the tray icon. Requires **Show Tray Icon**; applies on the next launch only.
- Opening the main window from the tray after a cold start renders the sidebar and main content immediately — including on Linux tiling window managers — instead of leaving them invisible or blank until a restart.
### Square corners — a sharper, boxier look
- New **Square Corners** toggle under **Settings → Appearance → Visual Options → Display** strips the rounded corners off cards and cover art across the app — handy when a theme's rounding does not suit your album covers. Off by default; buttons, inputs, and dialogs keep the theme's corners.
## Improved
- With **Remember EQ per device** on and **System Default** selected, the equalizer now keys profiles to the active OS default output and switches when that default changes outside the app (Windows sound settings, Stream Deck, PipeWire / `wpctl`, and similar). **Linux:** when PipeWire has already moved the stream to the new default, the device watcher skips a redundant reopen to avoid a post-switch stutter. **Windows:** release builds no longer freeze on the loading splash; audio output devices use stable backend IDs with clearer labels, and device-change detection works again after upgrade.
## Fixed
### Playback and audio
- Playing a song from a playlist no longer shows the track's own cover in Now Playing when the album page would show album art — Now Playing consistently uses the album cover.
- ReplayGain applies when stream or queue metadata resolves late; gapless auto-advance no longer leaves the playbar on the previous track.
- Pausing a large queue behind a reverse proxy (e.g. Nginx) no longer snaps playback back to an earlier track — Navidrome saves via POST when supported, and a failed save no longer lets idle auto-pull overwrite your queue.
- Internet Radio equalizer presets and slider changes now apply to live stations — not just library tracks.
### Offline, Now Playing, and Navidrome
- Desktop builds no longer get stuck showing "offline" when WebKitGTK leaves `navigator.onLine` at `false` while the server is reachable — the app confirms with a real server probe instead.
- When browsing offline, Artists, Albums, Tracks, and Genres list only content with on-disk bytes — pins, favorites-auto saves, and hot-cache playback — instead of the full server or local index catalog.
### Themes and integrations
- Connecting a scrobble service in **Settings → Integrations → Music Network** now shows the underlying error alongside the generic network message, so a bad URL or rejected token is easier to tell apart from a reachability problem on your machine.
- Horizontal album rails in themes with drop shadows no longer clip card shadows at the edges — scroll arrows keep working without theme authors overriding rail overflow.
### Browse and library
- Adding tracks to a playlist no longer fails past ~341 songs — writes go to the server in batches, and large-playlist edits are faster.
- Queue rows far from the playing track no longer stay stuck on a "…" placeholder — the queue loads details for whatever you scroll to, in the desktop panel, mobile drawer, and fullscreen **Up next** overlay.
- The year filter on **All Albums** no longer clamps on every keystroke while you type a four-digit year — it commits on blur, Enter, or outside click.
- Starring an album on the detail page fills the heart immediately and keeps it filled after reload; album-level stars and ratings reconcile consistently across browse and Favorites.
- Renamed artists no longer linger as ghost entries that open to "Artist not found"; album artist links and cover tiles in **Random Albums** stay consistent after resync.
- Custom playlist and internet radio covers uploaded in Navidrome show again on cards and detail headers.
- Sorting albums by artist now follows the name shown on each row — featured-guest releases no longer land under the wrong artist in **Artist / Year** order.
### Other
- Servers behind a custom HTTP header gate (Cloudflare Access, Pangolin, and similar) now work for the full app — add-server errors stay on the form with a clear reason, browse and detail views load natively behind the gate, streaming and covers carry the header reliably, and returning to a LAN address upgrades the connection automatically when both LAN and public endpoints are configured.
- Modal dialogs now announce their title to screen readers when they open.
- **Windows:** **Who is listening?** no longer shows `psysonic/undefined` as the client id.
## [1.49.0]
## Highlights
### Play queue sync — pick up where you left off on another device
- Click the header connection indicator to **pull** the active server's play queue when it differs from yours; a yellow LED shows when browse and playback servers do not match.
- While paused or stopped, **idle auto-pull** checks every 10 seconds and applies server changes when you have been still for 30+ seconds.
- Queue **push** sends only tracks owned by the playback server, so mixed-server queues stay sane when you switch servers.
- Local queue edits while paused are no longer overwritten by auto-pull; pressing **Play** pushes your changes immediately, and the sync LED no longer flashes on every track during normal playback.
- After the last track ends with repeat off, idle pull no longer rewinds to an earlier server position — the queue stays where playback finished.
### AutoDJ — minimum pauses, maximum music
- New **AutoDJ** mode — a smart crossfade that blends tracks intelligently. Each transition is shaped to fit the music: awkward gaps fade away, handovers feel natural, and you spend less time in silence between songs. It's now its own choice in **Settings → Audio** and its own button in the queue toolbar, sitting alongside Crossfade and Gapless — pick one at a time. Off by default; classic **Crossfade** is unchanged.
- New **AutoDJ** mode — a smart crossfade that blends tracks intelligently: it trims dead air, rides natural fades, and keeps handovers musical instead of abrupt. Its own button in the queue toolbar and its own entry under **Settings → Audio**, alongside Crossfade and Gapless — only one at a time. Off by default; classic **Crossfade** is unchanged.
- **Smooth skip** (on by default with AutoDJ) crossfades manual Next/Previous and track picks from where you are listening instead of hard-cutting; the play/pause button pulses while a blend is active.
- Cap how long overlaps may last: **Auto** (content-driven, up to 12 s) or **Limit** (slider 230 s) under **Settings → Audio → Track transitions**.
- The last track in the queue plays through to the end instead of being trimmed when nothing follows.
### Playlist folders — your playlists, organised
- Folders on the **Playlists** page and in the sidebar keep long lists tidy — group by mood, occasion, or anything you like. Drag playlists in, rename and collapse folders, or choose **Move to folder** from the right-click menu. Switch back to a flat list whenever you prefer.
### Theme store — spot updates, pick your style
- Version numbers on store themes and ones you have installed make it obvious when an update is ready.
- Filter for **animated** or **static** themes only — less scrolling when you already know the look you want.
### Settings — tidier and easier to scan
- Settings are grouped into clear, labelled panels so related options sit together — less hunting around. The **Native Hi-Res Playback** option now explains in plain language what it actually does.
- **Normalization** and **Track transitions** are now their own sections under **Settings → Audio**, and the queue options (display mode, toolbar, and Play-Next order) are gathered into one **Queue Settings** group under **Personalisation**.
### Japanese, Hungarian, and Polish — now in your language
- Psysonic is now available in **Japanese (日本語)**, **Hungarian (Magyar)**, and **Polish (Polski)** — pick any of them from the language menu on the **Settings** and **Login** screens.
### Theme store — spot updates, pick your style
- Version numbers on store themes and ones you have installed make it obvious when an update is ready.
- Filter for **animated** or **static** themes only — less scrolling when you already know the look you want.
### Hi-Res playback — smoother transitions between sample rates
- Under **Settings → Audio → Native Hi-Res**, choose a **blend rate** (44.1 / 88.2 / 96 kHz) for crossfade, AutoDJ, and gapless when adjacent tracks differ in sample rate — mixed 88.2 ↔ 44.1 kHz handovers no longer tear mid-transition.
### Artist artwork — richer home, artist, and fullscreen views
- Switch on **External Artwork Scraper** under **Settings → Integrations** to pull artist imagery from fanart.tv: a wide backdrop on the fullscreen player, a banner across the top of the artist page, and now the artist's backdrop behind the home screen's **mainstage** too. Off by default, your Navidrome covers stay in charge, and turning it back off removes the fetched images again.
- Choose which images each place uses as its background, and in what order — drag to reorder or switch a source off — right under the same setting. The mainstage also loads the next backdrops ahead of time so they appear without a blank gap.
### Equalizer — a profile per output device
- Turn on **Remember EQ per device** under **Settings → Audio** and Psysonic keeps a separate equalizer setup for each output — speakers, headphones, a USB DAC — and switches to the right one automatically when you change devices, including when **System Default** is selected and the OS default output changes outside the app. Off by default.
### Orbit — everyone hears transitions the host chose
- In a shared **Orbit** session, the host's crossfade, gapless, or AutoDJ settings — including length and smooth skip — apply to all guests until you leave. Transition controls in **Settings → Audio** and the queue toolbar show as host-controlled while you are a guest.
### Themes — follow your system's light and dark mode
- The theme scheduler can now match your **system's light/dark setting** instead of a fixed clock: pick a light theme and a dark one, and Psysonic switches along with your OS. Choose **Time of Day** or **System Theme** under **Settings → Themes** — the existing time-based schedule is still there.
### Servers behind a reverse proxy — custom HTTP headers
- Per-server **custom HTTP headers** in **Settings → Servers** for Cloudflare Access, Pangolin, and similar gates — applied to library sync, playback, covers, offline download, and the rest without putting secrets in invite links.
### Album details — every genre, not just the first
- Album details now show **all** the genres a release spans: the main genre appears inline with a **+N** chip that opens the full, clickable list, each genre linking to its own page. Genres combine album and track tags and read from the local library index, so they work offline too.
### Compact buttons — switch to icon-only controls
- New **Compact buttons** option under **Settings → Appearance** switches the action and toolbar buttons between large labelled buttons and small icon-only ones — across album, artist and playlist headers, the shared browse toolbars, and the Most Played controls. Defaults to large; on phones the album header keeps its large touch targets.
### Playlists — sort by date added
- Sort a playlist by **Date added** (newest or oldest first), or by title, artist, album and the other columns, from a new sort dropdown in the playlist toolbar. The Subsonic API has no per-track "added on" date, so this follows the playlist's own order — servers add new tracks at the end, so newest-first puts your latest additions on top.
## Improved
- **macOS:** the window's title bar now follows the active theme instead of the grey system bar; the native window buttons stay in place, floating over the themed bar.
- Pressing **Play**, **Shuffle**, or **Add to queue** on a playlist starts playback without reloading the whole page with a spinner — editing the playlist still refreshes the list as before.
- Dragging sidebar items in **Settings → Personalisation → Sidebar** (or long-pressing in the sidebar itself) keeps each item exactly where you release it — no snap-back or off-by-one landing.
## Fixed
### Playback and audio
- **Timeline** mode keeps your session play-history strip when you **Play** an album or playlist; the current track stays pinned at the top, and replaying a history row inserts after the playing track instead of replacing the queue.
- **Opus/Ogg** tracks no longer fight the seekbar while they are still loading — scrub to where you want to be and keep listening.
- The equalizer preset picker shows the active **AutoEQ** profile name again instead of going blank.
### Offline, Now Playing, and Navidrome
- The **Live** listener count in the header stays up to date even when the "Who is listening?" popover is closed.
### Browse and library
- Album and artist covers — and the full-size view when you click a cover — open at full resolution again instead of looking soft or small.
- Albums sorted by artist now list each artist's work AZ by title — no more random order within a name.
- **Artist → Year** keeps artists grouped but walks through their albums chronologically, oldest first.
- Genres with no remaining tracks disappear after you retag and resync the library, without restarting the app.
- The **Artists** AZ index matches Navidrome ignored articles — **The Beatles** lands under **B**, not **T**.
- **All Albums → Only compilations** and **Favorites** return the albums you expect instead of an empty or partial list.
### Player and playlists
- **Add to playlist** from the player bar adds the song you are hearing, not the whole album.
- On **Favorites**, bulk **Add to playlist** and **Play selected** / **Add selected to queue** act on every checked row.
- **Play Now** on a playlist in the right-click menu starts playback instead of only opening the list.
- Playlists page header buttons wrap on narrow windows instead of clipping off-screen when the queue panel is open.
### Browse and library
- Albums sorted by artist now list each artist's work AZ by title — no more random order within a name.
- **Artist → Year** keeps artists grouped but walks through their albums chronologically, oldest first.
### Windows
### Other
- **Orbit** sessions stay reliable on long listens — guests keep receiving updates, radio no longer pollutes the shared queue, and opening Psysonic on a second device does not delete a live session elsewhere.
- On the artist page, the header uses the fanart.tv background when no banner is available — the same image the fullscreen player already showed.
- **Windows:** Previous, Play/Pause, and Next are back when you hover the taskbar icon — and Play/Pause shows whether music is playing or paused.
- **macOS:** the dock icon matches native app sizing instead of looking oversized.
- **Linux:** **Niri** is recognised as a tiling compositor and gets the same custom title bar behaviour as Hyprland and Sway; the "new version available" popup reads clearly on setups where the background blur used to bleed through.
## Under the hood
- If a screen hits an unexpected error, the app now shows a small recoverable card (**Try again** / **Reload app**) and keeps playing, instead of the whole window going blank.
## [1.48.1]
+1 -1
View File
@@ -149,7 +149,7 @@ case ${sub[1]} in
(( n == 1 )) && _message -e descriptions 'integer delta (seconds, e.g. -5)'
;;
volume)
(( n == 1 )) && _message -e descriptions 'percent 0100'
(( n == 1 )) && _message -e descriptions 'percent 0100, or ±N for relative change'
;;
play)
(( n == 1 )) && _message -e descriptions 'Subsonic id (song, album, or artist)'
+6 -1
View File
@@ -165,12 +165,17 @@ _psysonic_complete() {
rating)
(( n == 1 )) && _psysonic_compreply_from_compgen '0 1 2 3 4 5' "$cur"
;;
seek|volume)
seek)
if (( n == 1 )); then
_psysonic_compopt -o default
COMPREPLY=()
fi
;;
volume)
if (( n == 1 )); then
_psysonic_compreply_from_compgen '+5 +10 -5 -10 50' "$cur"
fi
;;
play)
if (( n == 1 )); then
_psysonic_compopt -o default
+45
View File
@@ -0,0 +1,45 @@
import eslint from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
// `scripts/` (Node CI helpers) is intentionally ignored — this config targets the browser `src/` tree.
{ ignores: ['dist', 'coverage', 'src-tauri', 'research', 'scripts'] },
// This gradual baseline deliberately omits the React Compiler rules (set-state-in-effect, refs,
// immutability, …) that the strict config enables. The per-line `eslint-disable-next-line` directives
// those rules require therefore read as "unused" here, so unused-directive reporting is turned off for
// this config only — the strict config keeps the default reporting and stays 0/0.
{ linterOptions: { reportUnusedDisableDirectives: 'off' } },
eslint.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-explicit-any': 'warn',
},
},
);
+45
View File
@@ -0,0 +1,45 @@
import eslint from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
// `scripts/` (Node CI helpers) is intentionally ignored — this config targets the
// browser `src/` tree. See `npm run lint:scripts` if scripts ever need a Node-globals lint pass.
// `src/generated` holds machine-generated output (release-notes bundle,
// tauri-specta `bindings.ts`) — not linted. `bindings.ts` is still type-checked
// by tsc (the FE↔BE contract); its generated runtime helper uses `any`.
{ ignores: ['dist', 'coverage', 'src-tauri', 'research', 'scripts', 'src/generated'] },
eslint.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-explicit-any': 'error',
// Promote deps to error at strict stage (wave 6+)
'react-hooks/exhaustive-deps': 'error',
},
},
);
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1779560665,
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"lastModified": 1783776592,
"narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3",
"type": "github"
},
"original": {
+4 -7
View File
@@ -13,6 +13,9 @@
min-height: 100%;
background: var(--startup-splash-bg, #1e1e2e);
}
#root.app-root--startup-pending {
visibility: hidden;
}
#app-startup-splash {
--splash-bg: var(--startup-splash-bg, #1e1e2e);
--splash-text: var(--startup-splash-text, #cdd6f4);
@@ -32,12 +35,6 @@
background: var(--splash-bg);
color: var(--splash-text);
font-family: system-ui, -apple-system, sans-serif;
opacity: 1;
transition: opacity 0.28s ease;
}
#app-startup-splash.app-startup-splash--hide {
opacity: 0;
pointer-events: none;
}
#app-startup-splash .app-startup-splash__logo {
height: 72px;
@@ -89,7 +86,7 @@
</div>
<span class="app-startup-splash__label">Loading</span>
</div>
<div id="root"></div>
<div id="root" class="app-root--startup-pending"></div>
<script src="/startup-splash-reveal.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
+1 -1
View File
@@ -1,3 +1,3 @@
{
"npmDepsHash": "sha256-oYSc00guRNrZhWTkRXYlqwSeVZ7tkz+/rA+XOF3NUvw="
"npmDepsHash": "sha256-r7Pe7YRJvRhigg4vmfLbnLdWr5EKGAeAC53vbs3V2uw="
}
+2063 -36
View File
File diff suppressed because it is too large Load Diff
+21 -5
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.49.0-dev",
"version": "1.51.0-dev",
"private": true,
"scripts": {
"check:css-imports": "node scripts/check-css-import-graph.mjs",
@@ -9,9 +9,15 @@
"build": "npm run prebuild:release-notes && tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "npm run prebuild:release-notes && tauri dev",
"tauri:build": "npm run prebuild:release-notes && tauri build",
"test": "npm run prebuild:release-notes && vitest run && node --test scripts/extract-release-section.test.mjs && npm run check:css-imports",
"tauri:dev": "npm run prebuild:release-notes && tauri dev --config src-tauri/tauri.dev.conf.json",
"tauri:build": "npm run prebuild:release-notes && node scripts/sync-wix-bundle-version.mjs && tauri build",
"lint": "eslint -c eslint.config.mjs src",
"lint:gradual": "eslint -c eslint.config.gradual.mjs src",
"dep:check": "depcruise src --config .dependency-cruiser.cjs --ignore-known",
"dep:check:barrels": "node scripts/check-feature-barrel-ui.mjs",
"check:boot-chunks": "node scripts/check-boot-chunk-lucide.mjs",
"build:verify": "npm run build && npm run check:boot-chunks",
"test": "npm run prebuild:release-notes && vitest run && node --test scripts/extract-release-section.test.mjs scripts/wix-bundle-version.test.mjs scripts/sync-version-pipeline.test.mjs && npm run check:css-imports && npm run dep:check:barrels",
"test:watch": "vitest",
"test:coverage": "npm run prebuild:release-notes && vitest run --coverage && npm run check:css-imports"
},
@@ -41,7 +47,7 @@
"@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/plugin-window-state": "^2.4.1",
"axios": "^1.16.0",
"axios": "^1.18.1",
"i18next": "^26.0.8",
"lucide-react": "^1.17.0",
"md5": "^2.3.0",
@@ -53,6 +59,7 @@
"zustand": "^5.0.14"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tauri-apps/cli": "^2.11.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -64,10 +71,19 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.8",
"dependency-cruiser": "^18.0.0",
"esbuild": "^0.28.1",
"eslint": "^10.5.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0",
"jsdom": "^29.1.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.62.0",
"vite": "^8.0.14",
"vitest": "^4.1.8"
},
"overrides": {
"undici": "^7.28.0"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.48.1
pkgver=1.50.0
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
+18
View File
@@ -155,7 +155,25 @@
}
var persisted = readThemeState();
function readStartMinimizedToTray() {
try {
var raw = localStorage.getItem('psysonic-auth');
if (!raw) return false;
var parsed = JSON.parse(raw);
var state = parsed && parsed.state;
if (!state || !state.startMinimizedToTray) return false;
return state.showTrayIcon !== false;
} catch (_err) {
return false;
}
}
var themeId = persisted ? resolveScheduledTheme(persisted) : 'mocha';
var palette = paletteForTheme(themeId, readInstalledThemes());
applyPalette(themeId, palette);
var trayHandled = false;
try {
trayHandled = sessionStorage.getItem('psy-startup-tray-handled') === '1';
} catch (_err) {}
window.__psyStartMinimizedToTray = readStartMinimizedToTray() && !trayHandled;
})();
+29
View File
@@ -1,5 +1,7 @@
/**
* Show the native window after the inline startup splash has painted.
* When starting minimized to tray, hide the main window as early as possible
* (visible:false may still map briefly on some Linux WMs before this script).
* __TAURI_INTERNALS__ may not exist yet when this script first runs.
*/
(function startupSplashReveal() {
@@ -12,7 +14,22 @@
return true;
}
function tryHideMainWindow() {
var internals = window.__TAURI_INTERNALS__;
if (!internals || typeof internals.invoke !== 'function') return false;
internals.invoke('plugin:window|hide', { label: 'main' }).catch(function () {});
return true;
}
function reveal(attempt) {
if (window.__psyStartMinimizedToTray) {
if (tryHideMainWindow()) return;
if (attempt >= MAX_ATTEMPTS) return;
window.setTimeout(function () {
reveal(attempt + 1);
}, 50);
return;
}
if (tryShowMainWindow()) return;
if (attempt >= MAX_ATTEMPTS) return;
window.setTimeout(function () {
@@ -20,6 +37,18 @@
}, 50);
}
if (window.__psyStartMinimizedToTray) {
// Mark this synchronously, before React mounts. This deliberately does
// not set the CSS animation-pause attribute: entrance animations may
// still mount while the native window is hidden.
window.__psyHidden = true;
try {
sessionStorage.setItem('psy-startup-tray-handled', '1');
} catch (_err) {}
reveal(0);
return;
}
requestAnimationFrame(function () {
requestAnimationFrame(function () {
reveal(0);
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env node
/**
* Post-build guard: boot-adjacent Vite chunks must not bundle lucide-react.
*
* When a store/utils barrel accidentally re-exports UI, Rollup may pull
* createLucideIcon into authStore/offline chunks and hit TDZ init-order bugs
* in production (Windows WebView2: "X is not a function" on splash).
*
* Run after `npm run build` — no Tauri compile needed.
*/
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
const DIST = join(new URL('..', import.meta.url).pathname, 'dist/assets');
/** Chunk filename prefixes that must stay lucide-free. */
const BOOT_CHUNK_PREFIXES = ['authStore-', 'offline-'];
/** Minified bundles still embed lucide module paths or icon factory calls. */
const LUCIDE_SIGNALS = [
'lucide-react',
'createLucideIcon',
// Common preset/offline icons pulled through bad barrels (minified arg strings):
'("globe"',
'("settings"',
'("wifi-off"',
'("download"',
];
/** Subsonic client id must be a compile-time literal, not a cyclic package.json import. */
const CLIENT_ID_SIGNALS = [
'psysonic/undefined',
'psysonic/${',
];
let files;
try {
files = readdirSync(DIST).filter(f => f.endsWith('.js'));
} catch {
console.error('check-boot-chunk-lucide: dist/assets not found — run `npm run build` first.');
process.exit(1);
}
const violations = [];
for (const file of files) {
if (!BOOT_CHUNK_PREFIXES.some(p => file.startsWith(p))) continue;
const text = readFileSync(join(DIST, file), 'utf8');
for (const signal of LUCIDE_SIGNALS) {
if (text.includes(signal)) {
violations.push({ file, signal });
}
}
for (const signal of CLIENT_ID_SIGNALS) {
if (text.includes(signal)) {
violations.push({ file, signal: `client-id: ${signal}` });
}
}
}
if (violations.length > 0) {
console.error('Lucide leaked into boot-critical chunks:\n');
for (const { file, signal } of violations) {
console.error(` • dist/assets/${file} — matched "${signal}"`);
}
console.error(
'\nFix: split UI from the feature root barrel; import UI from @/features/<x>/ui only in components.',
);
process.exit(1);
}
console.log('check-boot-chunk-lucide: ok');
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env node
/**
* Guard: boot-critical feature barrels must not re-export UI modules.
*
* Re-exporting components/hooks that pull lucide-react through a barrel while
* the same barrel (or its stores) is imported from boot paths creates production
* init-order failures (`createLucideIcon is not a function` in minified chunks).
*
* Not every feature barrel is checked — album/artist/etc. export UI for lazy
* routes and are safe. Only barrels that stores/utils on the boot path import
* from are listed in BOOT_CRITICAL_BARRELS.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
const ROOT = new URL('..', import.meta.url).pathname;
/** Barrels whose non-UI surface is imported before or during first paint. */
const BOOT_CRITICAL_BARRELS = [
{ file: join(ROOT, 'src/features/offline/index.ts'), label: 'src/features/offline/index.ts' },
{ file: join(ROOT, 'src/music-network/index.ts'), label: 'src/music-network/index.ts' },
];
const FORBIDDEN_EXPORT_PATTERNS = [
/from\s+['"]\.\/components\//,
/from\s+['"]\.\/ui\//,
/export\s+\*\s+from\s+['"]\.\/components\//,
/export\s+\*\s+from\s+['"]\.\/ui\//,
/export\s+\{[^}]*\}\s+from\s+['"]\.\/components\//,
/export\s+\{[^}]*\}\s+from\s+['"]\.\/ui\//,
];
/** @param {string} file @param {string} label */
function checkBarrel(file, label) {
const text = readFileSync(file, 'utf8');
const hits = [];
for (const re of FORBIDDEN_EXPORT_PATTERNS) {
for (const line of text.split('\n')) {
const trimmed = line.trim();
if (trimmed.startsWith('//') || trimmed.startsWith('*')) continue;
if (re.test(line)) hits.push(trimmed);
}
}
if (hits.length === 0) return [];
return hits.map(h => `${label}: ${h}`);
}
const errors = [];
for (const { file, label } of BOOT_CRITICAL_BARRELS) {
try {
statSync(file);
errors.push(...checkBarrel(file, label));
} catch {
errors.push(`${label}: missing barrel file`);
}
}
if (errors.length > 0) {
console.error('Boot-critical barrel UI re-export violations:\n');
for (const e of errors) console.error(`${e}`);
console.error(
'\nFix: remove UI exports from the root barrel; import from @/features/<x>/ui or deep paths.',
);
process.exit(1);
}
console.log('check-feature-barrel-ui: ok');
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
# Compare two environment dumps from dump-environment.sh
# Usage: ./scripts/compare-environment.sh FILE_A FILE_B
set -euo pipefail
if [[ $# -ne 2 ]]; then
echo "Usage: $0 FILE_A FILE_B" >&2
exit 1
fi
FILE_A="$1"
FILE_B="$2"
for f in "$FILE_A" "$FILE_B"; do
if [[ ! -f "$f" ]]; then
echo "Missing file: $f" >&2
exit 1
fi
done
parse_dump() {
local file="$1"
awk -F= '
/^\[/ {
section = substr($0, 2, length($0) - 2)
next
}
/^[[:space:]]*$/ { next }
/^#/ { next }
{
key = $1
$1 = ""
sub(/^=/, "", $0)
full = (section == "" ? key : section "." key)
print full "\t" $0
}
' "$file" | sort -u
}
TMP_A="$(mktemp)"
TMP_B="$(mktemp)"
TMP_JOIN="$(mktemp)"
trap 'rm -f "$TMP_A" "$TMP_B" "$TMP_JOIN"' EXIT
parse_dump "$FILE_A" >"$TMP_A"
parse_dump "$FILE_B" >"$TMP_B"
join -t $'\t' -a 1 -a 2 -e '—' -o '0,1.2,2.2' "$TMP_A" "$TMP_B" >"$TMP_JOIN"
ONLY_A=0
ONLY_B=0
DIFF=0
SAME=0
echo "Comparing:"
echo " A: $FILE_A"
echo " B: $FILE_B"
echo
while IFS=$'\t' read -r key val_a val_b; do
[[ -z "$key" ]] && continue
if [[ "$val_a" == "—" ]]; then
ONLY_B=$((ONLY_B + 1))
printf ' + %-40s B=%s\n' "$key" "$val_b"
elif [[ "$val_b" == "—" ]]; then
ONLY_A=$((ONLY_A + 1))
printf ' - %-40s A=%s\n' "$key" "$val_a"
elif [[ "$val_a" != "$val_b" ]]; then
DIFF=$((DIFF + 1))
printf ' ≠ %-40s\n A=%s\n B=%s\n' "$key" "$val_a" "$val_b"
else
SAME=$((SAME + 1))
fi
done <"$TMP_JOIN"
echo
echo "Summary: same=$SAME different=$DIFF only_in_A=$ONLY_A only_in_B=$ONLY_B"
# High-signal keys for the common "works on one NixOS box" case.
echo
echo "High-signal differences (if any):"
HIGH_SIGNAL=0
while IFS=$'\t' read -r key val_a val_b; do
[[ "$val_a" == "$val_b" ]] && continue
case "$key" in
meta.flake_lock_sha256|meta.nixpkgs_locked_rev|meta.npm_lock_sha256|meta.git_rev|\
installed_app.psysonic_realpath|installed_app.psysonic_closure_paths|installed_app.psysonic_drv|\
runtime_env.HTTP_PROXY|runtime_env.HTTPS_PROXY|runtime_env.http_proxy|runtime_env.https_proxy|\
runtime_env.NO_PROXY|runtime_env.no_proxy|runtime_env.SSL_CERT_FILE|runtime_env.NIX_SSL_CERT_FILE|\
toolchain_nix_develop.node_realpath|toolchain_nix_develop.rustc_realpath|\
host.nixos_version|host.uname|\
app_config_paths.data_dir|app_config_paths.localstorage_db_path|\
app_preferences.language|app_servers.active_server_id|app_servers.server_count|\
app_servers.server.*.url|app_servers.server.*.alternateUrl|\
app_servers.server.*.customHeaders_count|app_servers.server.*.customHeadersApplyTo|\
app_servers.server.*.password_sha256|app_servers.server.*.customHeaders.*.name|\
app_servers.server.*.customHeaders.*.value_sha256|\
app_network_probe.probe.*)
HIGH_SIGNAL=$((HIGH_SIGNAL + 1))
printf ' ! %s\n A=%s\n B=%s\n' "$key" "$val_a" "$val_b"
;;
esac
done <"$TMP_JOIN"
if [[ "$HIGH_SIGNAL" -eq 0 && "$DIFF" -eq 0 && "$ONLY_A" -eq 0 && "$ONLY_B" -eq 0 ]]; then
echo " (none — environments match on recorded keys)"
elif [[ "$HIGH_SIGNAL" -eq 0 ]]; then
echo " (no high-signal keys differ; see full list above — may be npm patch-level deps only)"
fi
echo
echo "Server / network config differences:"
SERVER_DIFF=0
while IFS=$'\t' read -r key val_a val_b; do
[[ "$val_a" == "$val_b" ]] && continue
case "$key" in
app_servers.*|app_network_probe.*|app_config_paths.*|app_preferences.*)
SERVER_DIFF=$((SERVER_DIFF + 1))
printf ' • %s\n A=%s\n B=%s\n' "$key" "$val_a" "$val_b"
;;
esac
done <"$TMP_JOIN"
if [[ "$SERVER_DIFF" -eq 0 ]]; then
echo " (none — server URLs, headers, and curl probes match)"
fi
echo
echo "Reminder: server offline is usually URL/network/headers, not Node patch versions."
echo "Next: curl the Navidrome URL from both hosts; compare Settings → Servers side by side."
if [[ "$DIFF" -gt 0 || "$ONLY_A" -gt 0 || "$ONLY_B" -gt 0 ]]; then
exit 1
fi
+254
View File
@@ -0,0 +1,254 @@
#!/usr/bin/env bash
# Dump Psysonic-related toolchain, Nix closure hints, app config, and env for cross-machine diff.
# Re-enters `nix develop` automatically when flake.nix is present (no manual dev shell needed).
# Usage: ./scripts/dump-environment.sh [-o FILE]
# Compare: ./scripts/compare-environment.sh a.txt b.txt
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SCRIPT="$REPO_ROOT/scripts/dump-environment.sh"
# Bootstrap: run the rest inside the flake dev shell so node/jq match the project.
if [[ -z "${PSYSONIC_ENV_DUMP_IN_NIX:-}" ]] && [[ -f "$REPO_ROOT/flake.nix" ]]; then
if ! command -v nix >/dev/null 2>&1; then
echo "$0: flake.nix found but nix is not on PATH — install Nix or run from a NixOS profile with flakes." >&2
exit 1
fi
export PSYSONIC_ENV_DUMP_IN_NIX=1
exec env REPO_ROOT="$REPO_ROOT" nix develop --command bash "$SCRIPT" "$@"
fi
cd "$REPO_ROOT"
if ! command -v node >/dev/null 2>&1; then
echo "$0: node not found after nix develop — check flake.nix devShell." >&2
exit 1
fi
OUTPUT_FILE=""
while getopts 'o:h' opt; do
case "$opt" in
o) OUTPUT_FILE="$OPTARG" ;;
h)
echo "Usage: $0 [-o FILE]" >&2
exit 0
;;
*)
echo "Usage: $0 [-o FILE]" >&2
exit 1
;;
esac
done
emit() {
if [[ -n "$OUTPUT_FILE" ]]; then
printf '%s\n' "$*" >>"$OUTPUT_FILE"
else
printf '%s\n' "$*"
fi
}
kv() {
local key="$1"
local value="${2-}"
value="${value//$'\n'/\\n}"
emit "${key}=${value}"
}
section() {
emit ""
emit "[$1]"
}
run_optional() {
"$@" 2>/dev/null || true
}
if [[ -n "$OUTPUT_FILE" ]]; then
: >"$OUTPUT_FILE"
fi
section meta
kv generated_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
kv in_nix_dev_shell "${PSYSONIC_ENV_DUMP_IN_NIX:-no}"
kv hostname "$(hostname 2>/dev/null || echo unknown)"
kv repo_root "$REPO_ROOT"
if command -v git >/dev/null 2>&1 && git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
kv git_rev "$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || echo unknown)"
kv git_branch "$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
kv git_dirty "$(git -C "$REPO_ROOT" status --porcelain 2>/dev/null | wc -l | tr -d ' ')"
else
kv git_rev "n/a"
kv git_branch "n/a"
kv git_dirty "n/a"
fi
if [[ -f "$REPO_ROOT/package.json" ]]; then
kv package_json_version "$(node -p "require('./package.json').version" 2>/dev/null || sed -n 's/.*\"version\": \"\\([^\"]*\\)\".*/\\1/p' "$REPO_ROOT/package.json" | head -1)"
fi
if [[ -f "$REPO_ROOT/flake.lock" ]]; then
kv flake_lock_sha256 "$(sha256sum "$REPO_ROOT/flake.lock" | awk '{print $1}')"
if command -v jq >/dev/null 2>&1; then
kv nixpkgs_locked_rev "$(jq -r '.nodes.nixpkgs.locked.rev // "unknown"' "$REPO_ROOT/flake.lock" 2>/dev/null)"
kv nixpkgs_locked_narHash "$(jq -r '.nodes.nixpkgs.locked.narHash // "unknown"' "$REPO_ROOT/flake.lock" 2>/dev/null)"
fi
fi
if [[ -f "$REPO_ROOT/package-lock.json" ]]; then
kv npm_lockfile_version "$(jq -r '.lockfileVersion // "unknown"' "$REPO_ROOT/package-lock.json" 2>/dev/null || echo unknown)"
kv npm_lock_sha256 "$(sha256sum "$REPO_ROOT/package-lock.json" | awk '{print $1}')"
fi
section host
kv uname "$(uname -a 2>/dev/null || echo unknown)"
if [[ -r /etc/os-release ]]; then
# shellcheck disable=SC1091
source /etc/os-release
kv os_id "${ID:-unknown}"
kv os_version "${VERSION_ID:-unknown}"
kv os_pretty "${PRETTY_NAME:-unknown}"
fi
if command -v nixos-version >/dev/null 2>&1; then
kv nixos_version "$(nixos-version 2>/dev/null || echo unknown)"
fi
section nix
if command -v nix >/dev/null 2>&1; then
kv nix_version "$(nix --version 2>/dev/null | head -1)"
kv nix_flake_present "$([[ -f "$REPO_ROOT/flake.nix" ]] && echo yes || echo no)"
else
kv nix_version "not_installed"
kv nix_flake_present "$([[ -f "$REPO_ROOT/flake.nix" ]] && echo yes || echo no)"
fi
dump_toolchain_block() {
local label="$1"
shift
section "$label"
for tool in node npm rustc cargo clippy jq cmake pkg-config; do
if command -v "$tool" >/dev/null 2>&1; then
case "$tool" in
node) kv node_version "$("$tool" -v 2>/dev/null)" ;;
npm) kv npm_version "$("$tool" -v 2>/dev/null)" ;;
rustc) kv rustc_version "$("$tool" -V 2>/dev/null | head -1)" ;;
cargo) kv cargo_version "$("$tool" -V 2>/dev/null | head -1)" ;;
clippy) kv clippy_version "$("$tool" -V 2>/dev/null | head -1)" ;;
jq) kv jq_version "$("$tool" --version 2>/dev/null | head -1)" ;;
cmake) kv cmake_version "$("$tool" --version 2>/dev/null | head -1)" ;;
pkg-config) kv pkg_config_version "$("$tool" --version 2>/dev/null | head -1)" ;;
esac
kv "${tool}_path" "$(command -v "$tool")"
if [[ -L "$(command -v "$tool")" ]] || [[ -e "$(command -v "$tool")" ]]; then
kv "${tool}_realpath" "$(readlink -f "$(command -v "$tool")" 2>/dev/null || echo unknown)"
fi
else
kv "${tool}_version" "missing"
fi
done
kv CARGO_TARGET_DIR "${CARGO_TARGET_DIR:-unset}"
kv LD_LIBRARY_PATH "${LD_LIBRARY_PATH:-unset}"
kv GST_PLUGIN_PATH "${GST_PLUGIN_PATH:-unset}"
kv GIO_EXTRA_MODULES "${GIO_EXTRA_MODULES:-unset}"
}
if [[ -n "${PSYSONIC_ENV_DUMP_IN_NIX:-}" ]]; then
dump_toolchain_block toolchain_nix_develop
elif command -v nix >/dev/null 2>&1 && [[ -f "$REPO_ROOT/flake.nix" ]]; then
# No bootstrap (should not happen when flake exists) — capture devShell separately.
NIX_DEV_DUMP="$(nix develop --command bash -lc '
set +e
cd "$REPO_ROOT" || exit 0
for tool in node npm rustc cargo clippy jq; do
if command -v "$tool" >/dev/null 2>&1; then
case "$tool" in
node) printf "node_version=%s\n" "$("$tool" -v)" ;;
npm) printf "npm_version=%s\n" "$("$tool" -v)" ;;
rustc) printf "rustc_version=%s\n" "$("$tool" -V | head -1)" ;;
cargo) printf "cargo_version=%s\n" "$("$tool" -V | head -1)" ;;
clippy) printf "clippy_version=%s\n" "$("$tool" -V | head -1)" ;;
jq) printf "jq_version=%s\n" "$("$tool" --version | head -1)" ;;
esac
printf "%s_path=%s\n" "$tool" "$(command -v "$tool")"
printf "%s_realpath=%s\n" "$tool" "$(readlink -f "$(command -v "$tool")" 2>/dev/null || echo unknown)"
else
printf "%s_version=missing\n" "$tool"
fi
done
printf "CARGO_TARGET_DIR=%s\n" "${CARGO_TARGET_DIR:-unset}"
printf "LD_LIBRARY_PATH=%s\n" "${LD_LIBRARY_PATH:-unset}"
printf "GST_PLUGIN_PATH=%s\n" "${GST_PLUGIN_PATH:-unset}"
printf "GIO_EXTRA_MODULES=%s\n" "${GIO_EXTRA_MODULES:-unset}"
' REPO_ROOT="$REPO_ROOT" 2>/dev/null | tr -d '\r' || true)"
if [[ -n "$NIX_DEV_DUMP" ]]; then
section toolchain_nix_develop
while IFS= read -r line; do
[[ -n "$line" && "$line" == *=* ]] && emit "$line"
done <<<"$NIX_DEV_DUMP"
else
section toolchain_nix_develop
kv status "nix develop failed or unavailable"
fi
else
dump_toolchain_block toolchain_ambient
fi
section runtime_env
for var in HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY http_proxy https_proxy all_proxy no_proxy GDK_BACKEND PSYSONIC_SKIP_WAYLAND_FONT_TUNING PSYSONIC_ALLOW_NATIVE_GDK SSL_CERT_FILE SSL_CERT_DIR NIX_SSL_CERT_FILE; do
kv "$var" "${!var-unset}"
done
kv navigator_online "n/a (browser-only)"
section installed_app
if command -v psysonic >/dev/null 2>&1; then
PSY_PATH="$(command -v psysonic)"
kv psysonic_path "$PSY_PATH"
kv psysonic_realpath "$(readlink -f "$PSY_PATH" 2>/dev/null || echo unknown)"
run_optional kv psysonic_version "$(psysonic --version 2>/dev/null | head -1)"
if command -v nix-store >/dev/null 2>&1; then
kv psysonic_closure_paths "$(nix-store -qR "$PSY_PATH" 2>/dev/null | wc -l | tr -d ' ')"
kv psysonic_drv "$(nix-store -q --deriver "$PSY_PATH" 2>/dev/null | sed 's/\.drv$//' | xargs -r basename 2>/dev/null || echo unknown)"
fi
else
kv psysonic_path "not_in_path"
fi
section npm_dependencies
if [[ -f "$REPO_ROOT/package-lock.json" ]] && command -v node >/dev/null 2>&1; then
REPO_ROOT="$REPO_ROOT" node <<'NODE' 2>/dev/null | while IFS= read -r line; do emit "$line"; done || true
const fs = require('fs');
const path = require('path');
const lockPath = path.join(process.env.REPO_ROOT || '.', 'package-lock.json');
let lock;
try { lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch { process.exit(0); }
const root = lock.packages?.['']?.dependencies || {};
const deps = Object.keys(root).sort();
for (const name of deps) {
const entry = lock.packages?.[`node_modules/${name}`] || lock.packages?.[name];
const version = entry?.version || 'unknown';
console.log(`dep.${name}=${version}`);
}
NODE
else
kv status "node or package-lock.json unavailable"
fi
run_app_config_dump() {
local extractor="$REPO_ROOT/scripts/lib/extract-app-config.mjs"
[[ -f "$extractor" ]] || return 0
if node "$extractor" --repo-root "$REPO_ROOT" >>"${OUTPUT_FILE:-/dev/stdout}" 2>/dev/null; then
:
else
section app_config
kv status "extract-app-config failed (is Psysonic installed / has it been run once?)"
fi
}
run_app_config_dump
section network_probe_hint
kv note_1 "App server profiles and curl probes are in app_servers / app_network_probe sections above."
kv note_2 "Passwords and custom header values are redacted; compare password_sha256 and value_sha256 only."
kv note_3 "Compare two dumps with: scripts/compare-environment.sh a.txt b.txt"
if [[ -n "$OUTPUT_FILE" ]]; then
echo "Wrote $OUTPUT_FILE" >&2
fi
+12
View File
@@ -43,3 +43,15 @@ export const CHANGELOG_RAW: string = ${JSON.stringify(changelogRaw)};
writeFileSync(join(outDir, 'releaseNotesBundle.ts'), ts, 'utf8');
console.log(`wrote src/generated/releaseNotesBundle.ts (sliced for ${version})`);
// Leaf module for boot-critical client id — must not import package.json at runtime
// in the authStore chunk (circular init → psysonic/undefined on Windows WebView2).
const appVersionTs = `/** @generated — run: node scripts/generate-release-notes-bundle.mjs */
export const APP_VERSION = ${JSON.stringify(version)};
/** Subsonic REST \`c\` param and OpenSubsonic client id (\`psysonic/<version>\`). */
export const SUBSONIC_CLIENT_ID = ${JSON.stringify(`psysonic/${version}`)};
`;
writeFileSync(join(outDir, 'appVersion.ts'), appVersionTs, 'utf8');
console.log(`wrote src/generated/appVersion.ts (${version})`);
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env node
/**
* Read Psysonic app config from WebKit localStorage + XDG dirs.
* Emits key=value lines (passwords/secrets redacted; header values hashed).
*
* Usage: node scripts/lib/extract-app-config.mjs [--app-id ID] [--repo-root PATH]
*/
import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { DatabaseSync } from 'node:sqlite';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
function parseArgs(argv) {
const out = { appId: process.env.PSYSONIC_APP_ID || '', repoRoot: '' };
for (let i = 2; i < argv.length; i++) {
if (argv[i] === '--app-id' && argv[i + 1]) {
out.appId = argv[++i];
} else if (argv[i] === '--repo-root' && argv[i + 1]) {
out.repoRoot = argv[++i];
}
}
return out;
}
function sha256(text) {
return createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 16);
}
function emitSection(name) {
process.stdout.write(`\n[${name}]\n`);
}
function emit(key, value) {
const v = String(value ?? '').replace(/\n/g, '\\n');
process.stdout.write(`${key}=${v}\n`);
}
function readAppIdFromRepo(repoRoot) {
const conf = path.join(repoRoot, 'src-tauri', 'tauri.conf.json');
if (!fs.existsSync(conf)) return '';
try {
const j = JSON.parse(fs.readFileSync(conf, 'utf8'));
return typeof j.identifier === 'string' ? j.identifier : '';
} catch {
return '';
}
}
function xdgDataHome() {
return process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
}
function xdgConfigHome() {
return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
}
function decodeWebKitValue(raw) {
if (raw == null) return null;
const buf = Buffer.isBuffer(raw) ? raw : raw instanceof Uint8Array ? Buffer.from(raw) : null;
if (buf) {
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
return new TextDecoder('utf-16le').decode(buf.subarray(2));
}
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
return new TextDecoder('utf-16be').decode(buf.subarray(2));
}
// WebKit often stores UTF-16LE without BOM
if (buf.length >= 4 && buf[1] === 0 && buf[3] === 0) {
return new TextDecoder('utf-16le').decode(buf);
}
return buf.toString('utf8');
}
if (typeof raw === 'string') return raw;
return String(raw);
}
function readLocalStorageRaw(dbPath, storageKey) {
try {
const db = new DatabaseSync(dbPath, { readOnly: true });
const row = db.prepare('SELECT value FROM ItemTable WHERE key = ?').get(storageKey);
db.close();
if (!row?.value) return null;
return decodeWebKitValue(row.value);
} catch {
return null;
}
}
function readLocalStorageKey(dbPath, storageKey) {
const text = readLocalStorageRaw(dbPath, storageKey);
if (text == null) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
function pickLocalStorageFile(dataDir) {
const dir = path.join(dataDir, 'localstorage');
if (!fs.existsSync(dir)) return null;
const files = fs
.readdirSync(dir)
.filter(f => f.endsWith('.localstorage') && !f.includes('-wal') && !f.includes('-shm'))
.map(f => path.join(dir, f));
if (files.length === 0) return null;
// Prefer packaged app origin over vite dev (1420) when both exist.
const ranked = files.sort((a, b) => {
const score = p => {
const base = path.basename(p);
if (base.includes('tauri_localhost')) return 0;
if (base.includes('1420')) return 2;
return 1;
};
return score(a) - score(b);
});
for (const file of ranked) {
const auth = readLocalStorageKey(file, 'psysonic-auth');
if (auth?.state?.servers?.length) return file;
}
return ranked[0];
}
function probeHttpReachability(rawUrl) {
if (!rawUrl) return 'empty';
const url = rawUrl.startsWith('http') ? rawUrl : `http://${rawUrl}`;
const r = spawnSync(
'curl',
['-sS', '-o', '/dev/null', '-w', '%{http_code}', '--connect-timeout', '5', '--max-time', '10', url],
{ encoding: 'utf8', timeout: 15000 },
);
if (r.error) return `error:${r.error.code ?? 'unknown'}`;
if (r.status !== 0) return `curl_exit_${r.status}`;
return `http_${r.stdout.trim() || '000'}`;
}
function dumpNetworkProbes(servers) {
emitSection('app_network_probe');
const seen = new Set();
servers.forEach((srv, i) => {
for (const [kind, raw] of [
['url', srv.url],
['alternateUrl', srv.alternateUrl],
]) {
if (!raw || seen.has(raw)) continue;
seen.add(raw);
emit(`probe.${i}.${kind}`, probeHttpReachability(raw));
emit(`probe.${i}.${kind}_target`, raw);
}
});
if (seen.size === 0) emit('probe_status', 'no server URLs configured');
}
function dumpServerProfiles(state) {
const servers = state?.servers ?? [];
emit('active_server_id', state?.activeServerId ?? '');
emit('server_count', servers.length);
servers.forEach((srv, i) => {
const p = `server.${i}`;
emit(`${p}.id`, srv.id ?? '');
emit(`${p}.name`, srv.name ?? '');
emit(`${p}.url`, srv.url ?? '');
emit(`${p}.alternateUrl`, srv.alternateUrl ?? '');
emit(`${p}.shareUsesLocalUrl`, srv.shareUsesLocalUrl === true ? 'true' : 'false');
emit(`${p}.username`, srv.username ?? '');
emit(`${p}.password_set`, srv.password ? 'yes' : 'no');
emit(`${p}.password_sha256`, srv.password ? sha256(srv.password) : 'none');
emit(`${p}.customHeadersApplyTo`, srv.customHeadersApplyTo ?? 'public');
const headers = srv.customHeaders ?? [];
emit(`${p}.customHeaders_count`, headers.length);
headers.forEach((h, hi) => {
emit(`${p}.customHeaders.${hi}.name`, h.name ?? '');
emit(`${p}.customHeaders.${hi}.value_sha256`, h.value ? sha256(h.value) : 'empty');
});
});
dumpNetworkProbes(servers);
}
function fileMeta(label, filePath) {
if (!filePath || !fs.existsSync(filePath)) {
emit(`${label}_exists`, 'no');
return;
}
const st = fs.statSync(filePath);
emit(`${label}_exists`, 'yes');
emit(`${label}_path`, filePath);
emit(`${label}_size`, st.size);
emit(`${label}_mtime`, st.mtime.toISOString());
}
function listDir(label, dirPath, max = 12) {
if (!fs.existsSync(dirPath)) {
emit(`${label}_exists`, 'no');
return;
}
emit(`${label}_exists`, 'yes');
emit(`${label}_path`, dirPath);
const entries = fs.readdirSync(dirPath).sort();
emit(`${label}_entry_count`, entries.length);
entries.slice(0, max).forEach((name, i) => emit(`${label}.entry.${i}`, name));
}
const { appId: appIdArg, repoRoot } = parseArgs(process.argv);
let appId = appIdArg || (repoRoot ? readAppIdFromRepo(repoRoot) : '');
if (!appId) appId = readAppIdFromRepo(process.cwd()) || 'dev.psysonic.player';
const dataDir = path.join(xdgDataHome(), appId);
const configDir = path.join(xdgConfigHome(), appId);
emitSection('app_config_paths');
emit('app_id', appId);
emit('data_dir', dataDir);
emit('config_dir', configDir);
emit('data_dir_exists', fs.existsSync(dataDir) ? 'yes' : 'no');
emit('config_dir_exists', fs.existsSync(configDir) ? 'yes' : 'no');
const lsFile = pickLocalStorageFile(dataDir);
fileMeta('localstorage_db', lsFile);
emitSection('app_preferences');
if (lsFile) {
const lang = readLocalStorageRaw(lsFile, 'psysonic_language');
emit('language', lang ?? 'unknown');
const authWrap = readLocalStorageKey(lsFile, 'psysonic-auth');
if (authWrap?.state) {
emitSection('app_servers');
dumpServerProfiles(authWrap.state);
} else {
emit('app_servers_status', 'psysonic-auth not found or empty');
}
} else {
emit('app_servers_status', 'no localstorage database found');
}
emitSection('app_config_files');
for (const rel of ['linux_wayland_text_profile', 'mini_player_pos.json', '.window-state.json']) {
fileMeta(rel.replace(/\./g, '_'), path.join(configDir, rel));
}
emitSection('app_data_artifacts');
fileMeta('hsts_storage', path.join(dataDir, 'hsts-storage.sqlite'));
fileMeta('library_db', path.join(dataDir, 'databases', 'library', 'library.sqlite'));
listDir('localstorage_dir', path.join(dataDir, 'localstorage'));
@@ -47,3 +47,8 @@ if (updatedLock !== lock) {
} else {
console.log(`Cargo.lock workspace crates already at ${version}`);
}
require('child_process').execSync('node scripts/sync-wix-bundle-version.mjs', {
cwd: root,
stdio: 'inherit',
});
+84
View File
@@ -0,0 +1,84 @@
/**
* Integration tests for promotion/version sync: npm version → sync-tauri → sync-wix.
* Simulates tauri.conf.json state after the full pipeline (no file I/O).
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
wixMappedBuildNumber,
wixVersionOverrideForPackageVersion,
} from './wix-bundle-version.mjs';
/** Mirrors sync-tauri + sync-wix effects on tauri.conf.json. */
function confAfterVersionSync(packageVersion, priorConf) {
const conf = structuredClone(priorConf);
conf.version = packageVersion;
conf.bundle ??= {};
conf.bundle.windows ??= {};
const override = wixVersionOverrideForPackageVersion(packageVersion);
conf.bundle.windows.wix = { ...(conf.bundle.windows.wix ?? {}), version: override };
return conf;
}
const baseConf = {
version: '1.49.0-dev',
bundle: {
windows: {
nsis: { installMode: 'currentUser' },
wix: { version: '1.49.0.1' },
},
},
};
describe('version promotion pipeline (sync-tauri + sync-wix)', () => {
it('main → next: dev to first RC increases WiX build', () => {
const conf = confAfterVersionSync('1.50.0-rc.1', baseConf);
assert.equal(conf.version, '1.50.0-rc.1');
assert.equal(conf.bundle.windows.wix.version, '1.50.0.10001');
assert.ok(
wixMappedBuildNumber('1.50.0-rc.1') > wixMappedBuildNumber('1.50.0-dev'),
);
});
it('next RC bump: rc.1 to rc.2', () => {
const from = confAfterVersionSync('1.50.0-rc.1', baseConf);
const conf = confAfterVersionSync('1.50.0-rc.2', from);
assert.equal(conf.version, '1.50.0-rc.2');
assert.equal(conf.bundle.windows.wix.version, '1.50.0.10002');
});
it('next → release: RC to stable increases WiX build', () => {
const from = confAfterVersionSync('1.50.0-rc.3', baseConf);
const conf = confAfterVersionSync('1.50.0', from);
assert.equal(conf.version, '1.50.0');
assert.equal(conf.bundle.windows.wix.version, '1.50.0.65534');
assert.ok(wixMappedBuildNumber('1.50.0') > wixMappedBuildNumber('1.50.0-rc.3'));
});
it('post-release: next minor dev resets build band on new line', () => {
const from = confAfterVersionSync('1.50.0', baseConf);
const conf = confAfterVersionSync('1.51.0-dev', from);
assert.equal(conf.version, '1.51.0-dev');
assert.equal(conf.bundle.windows.wix.version, '1.51.0.1');
});
it('stable release sets highest build in line', () => {
const conf = confAfterVersionSync('2.0.0', {
version: '2.0.0-rc.1',
bundle: { windows: { wix: { version: '2.0.0.10001' } } },
});
assert.equal(conf.version, '2.0.0');
assert.equal(conf.bundle.windows.wix.version, '2.0.0.65534');
});
});
describe('sync-tauri-version-from-package.js invokes sync-wix', () => {
it('calls sync-wix-bundle-version.mjs after updating conf.version', () => {
const source = readFileSync(new URL('./sync-tauri-version-from-package.js', import.meta.url), 'utf8');
assert.match(source, /sync-wix-bundle-version\.mjs/);
const wixCall = source.indexOf('sync-wix-bundle-version');
const versionWrite = source.indexOf('conf.version = version');
assert.ok(wixCall > versionWrite, 'sync-wix must run after conf.version is set');
});
});
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env node
/**
* Write bundle.windows.wix.version in tauri.conf.json from package.json.
* Keeps the top-level app version unchanged (About, updater metadata, filenames).
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { wixVersionOverrideForPackageVersion } from './wix-bundle-version.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
if (!version || typeof version !== 'string') {
console.error('package.json version missing');
process.exit(1);
}
const confPath = path.join(root, 'src-tauri', 'tauri.conf.json');
const conf = JSON.parse(fs.readFileSync(confPath, 'utf8'));
conf.bundle ??= {};
conf.bundle.windows ??= {};
conf.bundle.windows.wix ??= {};
const wixVersion = wixVersionOverrideForPackageVersion(version);
conf.bundle.windows.wix.version = wixVersion;
console.log(`tauri.conf wix.version -> ${wixVersion} (package ${version})`);
fs.writeFileSync(confPath, `${JSON.stringify(conf, null, 2)}\n`);
+96
View File
@@ -0,0 +1,96 @@
/**
* Map package.json semver to a monotonic WiX/MSI ProductVersion for Tauri.
*
* `bundle.windows.wix.version` must be `major.minor.patch.build` (four integers
* ≤ 65535). Alphabetic pre-releases cannot be used directly. NSIS accepts full
* semver without this mapping.
*
* Build bands (monotonic within X.Y.Z so in-place MSI upgrades work across
* dev → rc → stable):
* dev → .1
* rc.N → .10000 + N
* stable → .65534
*
* Display / About still use the real package.json version.
*/
/** @type {const} */
export const WIX_BUILD = {
DEV: 1,
RC_BASE: 10_000,
STABLE: 65_534,
};
const MAX_WIX_FIELD = 65_535;
/** @param {number} build */
function assertBuildField(build, label) {
if (!Number.isInteger(build) || build < 0 || build > MAX_WIX_FIELD) {
throw new Error(`WiX build field out of range for ${label}: ${build}`);
}
}
/**
* WiX dot version for bundle.windows.wix.version (always four parts for channels
* we ship).
* @param {string} version
*/
export function wixVersionOverrideForPackageVersion(version) {
const trimmed = version.trim();
const match = trimmed.match(/^(\d+)\.(\d+)\.(\d+)(?:-([^+]+))?(?:\+(\d+))?$/);
if (!match) {
throw new Error(`Invalid semver for WiX mapping: ${trimmed}`);
}
const major = match[1];
const minor = match[2];
const patch = match[3];
const pre = match[4];
const buildPart = match[5];
const base = `${major}.${minor}.${patch}`;
if (buildPart !== undefined) {
throw new Error(
`Version "${trimmed}" has +build metadata — map manually or drop build for WiX`,
);
}
if (pre === undefined) {
return `${base}.${WIX_BUILD.STABLE}`;
}
if (pre === 'dev') {
return `${base}.${WIX_BUILD.DEV}`;
}
const rc = pre.match(/^rc\.(\d+)$/);
if (rc) {
const n = Number(rc[1]);
if (!Number.isInteger(n) || n < 1) {
throw new Error(`WiX rc index must be ≥ 1 (got rc.${rc[1]})`);
}
const build = WIX_BUILD.RC_BASE + n;
assertBuildField(build, `rc.${n}`);
return `${base}.${build}`;
}
if (/^\d+$/.test(pre)) {
const n = Number(pre);
const build = WIX_BUILD.RC_BASE + n;
assertBuildField(build, `pre ${pre}`);
return `${base}.${build}`;
}
throw new Error(
`Version "${trimmed}" has non-numeric pre-release "${pre}" — MSI/WiX cannot bundle it. ` +
'Use NSIS (`--bundles nsis`) or extend wix-bundle-version.mjs.',
);
}
/** Numeric build field from mapped WiX version (for monotonicity tests). */
export function wixMappedBuildNumber(packageVersion) {
const wix = wixVersionOverrideForPackageVersion(packageVersion);
const build = Number(wix.split('.')[3]);
assertBuildField(build, packageVersion);
return build;
}
+38
View File
@@ -0,0 +1,38 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
WIX_BUILD,
wixMappedBuildNumber,
wixVersionOverrideForPackageVersion,
} from './wix-bundle-version.mjs';
describe('wixVersionOverrideForPackageVersion', () => {
it('maps -dev to lowest build band', () => {
assert.equal(wixVersionOverrideForPackageVersion('1.50.0-dev'), '1.50.0.1');
});
it('maps -rc.N to RC base + N', () => {
assert.equal(wixVersionOverrideForPackageVersion('1.50.0-rc.3'), '1.50.0.10003');
});
it('maps stable to highest build band', () => {
assert.equal(wixVersionOverrideForPackageVersion('1.50.0'), '1.50.0.65534');
});
it('maps numeric pre-release to RC band', () => {
assert.equal(wixVersionOverrideForPackageVersion('1.50.0-42'), '1.50.0.10042');
});
});
describe('monotonic promotion builds within X.Y.Z', () => {
it('dev < rc.1 < rc.2 < stable', () => {
const chain = ['1.50.0-dev', '1.50.0-rc.1', '1.50.0-rc.2', '1.50.0'];
let prev = -1;
for (const v of chain) {
const build = wixMappedBuildNumber(v);
assert.ok(build > prev, `${v} build ${build} must exceed ${prev}`);
prev = build;
}
assert.equal(prev, WIX_BUILD.STABLE);
});
});
+122 -12
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "Inflector"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
[[package]]
name = "adler2"
version = "2.0.1"
@@ -529,6 +535,15 @@ dependencies = [
"alloc-stdlib",
]
[[package]]
name = "bs58"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
dependencies = [
"tinyvec",
]
[[package]]
name = "bumpalo"
version = "3.20.2"
@@ -2368,7 +2383,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.62.2",
"windows-core 0.61.2",
]
[[package]]
@@ -4114,7 +4129,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.49.0-dev"
version = "1.51.0-dev"
dependencies = [
"biquad",
"dasp_sample",
@@ -4140,6 +4155,8 @@ dependencies = [
"serde",
"serde_json",
"souvlaki",
"specta",
"specta-typescript",
"symphonia",
"symphonia-adapter-libopus",
"sysinfo",
@@ -4154,6 +4171,7 @@ dependencies = [
"tauri-plugin-store",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"tauri-specta",
"thread-priority",
"tokio",
"url",
@@ -4167,7 +4185,7 @@ dependencies = [
[[package]]
name = "psysonic-analysis"
version = "1.49.0-dev"
version = "1.51.0-dev"
dependencies = [
"ebur128",
"futures-util",
@@ -4178,6 +4196,7 @@ dependencies = [
"rusqlite",
"serde",
"serde_json",
"specta",
"symphonia",
"symphonia-adapter-libopus",
"tauri",
@@ -4186,7 +4205,7 @@ dependencies = [
[[package]]
name = "psysonic-audio"
version = "1.49.0-dev"
version = "1.51.0-dev"
dependencies = [
"biquad",
"dasp_sample",
@@ -4203,6 +4222,7 @@ dependencies = [
"rodio",
"serde",
"serde_json",
"specta",
"symphonia",
"symphonia-adapter-libopus",
"tauri",
@@ -4216,16 +4236,19 @@ dependencies = [
[[package]]
name = "psysonic-core"
version = "1.49.0-dev"
version = "1.51.0-dev"
dependencies = [
"libc",
"reqwest",
"serde",
"specta",
"tauri",
"url",
]
[[package]]
name = "psysonic-integration"
version = "1.49.0-dev"
version = "1.51.0-dev"
dependencies = [
"discord-rich-presence",
"futures-util",
@@ -4234,6 +4257,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"specta",
"tauri",
"tokio",
"url",
@@ -4242,7 +4266,7 @@ dependencies = [
[[package]]
name = "psysonic-library"
version = "1.49.0-dev"
version = "1.51.0-dev"
dependencies = [
"psysonic-core",
"psysonic-integration",
@@ -4250,6 +4274,7 @@ dependencies = [
"rusqlite",
"serde",
"serde_json",
"specta",
"tauri",
"tokio",
"wiremock",
@@ -4257,7 +4282,7 @@ dependencies = [
[[package]]
name = "psysonic-syncfs"
version = "1.49.0-dev"
version = "1.51.0-dev"
dependencies = [
"futures-util",
"id3",
@@ -4270,6 +4295,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"specta",
"sysinfo",
"tauri",
"tauri-plugin-process",
@@ -5100,11 +5126,12 @@ dependencies = [
[[package]]
name = "serde_with"
version = "3.19.0"
version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19"
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
dependencies = [
"base64 0.22.1",
"bs58",
"chrono",
"hex",
"indexmap 1.9.3",
@@ -5119,9 +5146,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
version = "3.19.0"
version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334"
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
dependencies = [
"darling",
"proc-macro2",
@@ -5379,6 +5406,57 @@ dependencies = [
"zvariant 3.15.2",
]
[[package]]
name = "specta"
version = "2.0.0-rc.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38f9a30cbcbb7011f1da7d73483983bf838af123883e45f2b36ed76328df9c50"
dependencies = [
"paste",
"rustc_version",
"specta-macros",
]
[[package]]
name = "specta-macros"
version = "2.0.0-rc.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ce14957ecc2897f1f848b8255b6531d13ddf49cbcf506b7c2c9fb1d005593bb"
dependencies = [
"Inflector",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "specta-serde"
version = "0.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8a72b755ddb8949fd8f17c5db43f0e8a806ea587d9bc602ee3f73240c00029"
dependencies = [
"specta",
"specta-macros",
]
[[package]]
name = "specta-typescript"
version = "0.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "639404ee95557f2f8b7e4cb773ffefd45304c7ab8ba21ac83b69051595e083c0"
dependencies = [
"specta",
]
[[package]]
name = "specta-util"
version = "0.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29b1fc02b446f7244a92924fe68c0555921209f1d342990cd1539e9138e69502"
dependencies = [
"specta",
]
[[package]]
name = "spin"
version = "0.12.0"
@@ -5819,6 +5897,7 @@ dependencies = [
"serde_json",
"serde_repr",
"serialize-to-javascript",
"specta",
"swift-rs",
"tauri-build",
"tauri-macros",
@@ -6131,6 +6210,37 @@ dependencies = [
"wry",
]
[[package]]
name = "tauri-specta"
version = "2.0.0-rc.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee080f36d2ac17ce2f3a82fb53f02d664e8345457de51b56dad3c394dacc41a2"
dependencies = [
"heck 0.5.0",
"serde",
"serde_json",
"specta",
"specta-serde",
"specta-typescript",
"specta-util",
"tauri",
"tauri-specta-macros",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-specta-macros"
version = "2.0.0-rc.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a59dfdce06c98d8d211619bea5fdb39486d8a8c558e12b2d2ce255972320012"
dependencies = [
"darling",
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tauri-utils"
version = "2.9.2"
+4 -1
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "1.49.0-dev"
version = "1.51.0-dev"
edition = "2021"
rust-version = "1.95"
license = "GPL-3.0-or-later"
@@ -44,6 +44,9 @@ psysonic-library = { path = "crates/psysonic-library" }
psysonic-syncfs = { path = "crates/psysonic-syncfs" }
psysonic-integration = { path = "crates/psysonic-integration" }
tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] }
specta = "=2.0.0-rc.25"
specta-typescript = "=0.0.12"
tauri-specta = { version = "=2.0.0-rc.25", features = ["derive", "typescript"] }
tauri-plugin-single-instance = "2"
tauri-plugin-shell = "2"
tauri-plugin-global-shortcut = "2"
+17
View File
@@ -1,3 +1,20 @@
fn main() {
// Windows/MSVC test binaries only: bind to Common-Controls v6.
//
// The library test harness (`--lib` unittests) links the wry/tao windowing
// runtime and statically imports `TaskDialogIndirect` from comctl32, which
// exists only in Common-Controls v6 (WinSxS). The real app binary gets that
// manifest from `tauri_build`, but the separate test executable does not, so
// it aborts at startup with STATUS_ENTRYPOINT_NOT_FOUND (0xC0000139). Add the
// dependency to test targets only — never the app binary (which already has a
// manifest via tauri_build) nor non-Windows/CI builds.
let is_windows_msvc = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
&& std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
if is_windows_msvc {
println!(
"cargo::rustc-link-arg=/MANIFESTDEPENDENCY:type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'"
);
}
tauri_build::build()
}
@@ -12,6 +12,7 @@ psysonic-core = { path = "../psysonic-core" }
tauri = { version = "2" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
tokio = { version = "1", features = ["rt", "time", "sync"] }
reqwest = { version = "0.13", default-features = false, features = ["stream", "rustls", "gzip", "brotli"] }
futures-util = "0.3"
@@ -0,0 +1,19 @@
fn main() {
// Windows/MSVC test binaries only: bind to Common-Controls v6.
//
// The `tauri` dev-dependency pulls the wry/tao windowing runtime into this
// crate's *test* executables, which statically import `TaskDialogIndirect`
// from comctl32. That symbol lives only in Common-Controls v6 (WinSxS); the
// bare System32 comctl32.dll is v5.82 and does not export 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 its
// embedded manifest — mirror that here, scoped to test targets only (never
// the app, the rlib, or non-Windows/CI builds).
let is_windows_msvc = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
&& std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
if is_windows_msvc {
println!(
"cargo::rustc-link-arg=/MANIFESTDEPENDENCY:type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'"
);
}
}
@@ -3,10 +3,10 @@ mod store;
pub use compute::{
analysis_pcm_window, audio_duration_from_bytes, decode_mono_pcm_limited,
decode_mono_pcm_window, md5_first_16kb, recommended_gain_for_target,
seed_from_bytes_execute, seed_from_bytes_into_cache, PcmAnalysisWindow, SeedFromBytesOutcome,
decode_mono_pcm_window, md5_first_16kb, recommended_gain_for_target, seed_from_bytes_execute,
seed_from_bytes_into_cache, PcmAnalysisWindow, SeedFromBytesOutcome,
};
pub use store::{
AnalysisCache, AnalysisDeleteServerReport, FailedTrackEntry, LoudnessEntry, TrackKey,
WaveformEntry,
WaveformEntry, ANALYSIS_DB_SCHEMA_VERSION,
};
@@ -19,10 +19,68 @@ const MIGRATION_002_SERVER_ID: &str = include_str!("../../migrations/002_server_
/// Embedded migrations, ascending by version. The runner sorts defensively and
/// applies each missing one in its own transaction (schema change + version
/// marker commit together — see [`run_migrations_with`]).
const MIGRATIONS: &[(i64, &str)] = &[
(1, MIGRATION_001_BASELINE),
(2, MIGRATION_002_SERVER_ID),
const MIGRATIONS: &[(i64, &str)] = &[(1, MIGRATION_001_BASELINE), (2, MIGRATION_002_SERVER_ID)];
struct OperationalTable {
name: &'static str,
columns: &'static [&'static str],
primary_key: &'static [&'static str],
}
const OPERATIONAL_TABLES: [OperationalTable; 3] = [
OperationalTable {
name: "analysis_track",
columns: &[
"server_id",
"track_id",
"md5_16kb",
"status",
"waveform_algo_version",
"loudness_algo_version",
"updated_at",
],
primary_key: &["server_id", "track_id", "md5_16kb"],
},
OperationalTable {
name: "waveform_cache",
columns: &[
"server_id",
"track_id",
"md5_16kb",
"bins",
"bin_count",
"is_partial",
"known_until_sec",
"duration_sec",
"updated_at",
],
primary_key: &["server_id", "track_id", "md5_16kb"],
},
OperationalTable {
name: "loudness_cache",
columns: &[
"server_id",
"track_id",
"md5_16kb",
"integrated_lufs",
"true_peak",
"recommended_gain_db",
"target_lufs",
"updated_at",
],
primary_key: &["server_id", "track_id", "md5_16kb", "target_lufs"],
},
];
const OPERATIONAL_INDEXES: [&str; 1] = ["idx_analysis_track_status"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SwapDatabaseStage {
BackupActive,
ActivateDestination,
Open,
Configure,
Migrate,
}
/// Bins in waveform BLOB: `2 * bin_count` bytes (peak u8, then mean-abs u8 per time bin).
fn waveform_cache_blob_len_ok(bins: &[u8], bin_count: i64) -> bool {
@@ -138,8 +196,11 @@ impl AnalysisCache {
let mut conn = Connection::open(&db_path).map_err(|e| e.to_string())?;
configure_connection(&conn).map_err(|e| e.to_string())?;
run_migrations(&mut conn).map_err(|e| e.to_string())?;
verify_operational_schema_conn(&conn)?;
checkpoint_wal_conn(&conn, "open").map_err(|e| e.to_string())?;
Ok(Self { conn: Mutex::new(conn) })
Ok(Self {
conn: Mutex::new(conn),
})
}
/// Builds an in-memory SQLite database with the production schema applied.
@@ -153,16 +214,24 @@ impl AnalysisCache {
/// use it.
pub fn open_in_memory() -> Self {
let mut conn = Connection::open_in_memory().expect("in-memory connection");
conn.pragma_update(None, "foreign_keys", "ON").expect("pragma foreign_keys");
conn.pragma_update(None, "foreign_keys", "ON")
.expect("pragma foreign_keys");
run_migrations(&mut conn).expect("schema migration");
verify_operational_schema_conn(&conn).expect("operational schema");
let _ = checkpoint_wal_conn(&conn, "open");
Self { conn: Mutex::new(conn) }
Self {
conn: Mutex::new(conn),
}
}
/// Remove `loudness_cache` rows for this logical track (bare id and `stream:`
/// variant) scoped to one server. A reseed on server A must not delete
/// server B's analysis for the same bare `track_id`.
pub fn delete_loudness_for_track_id(&self, server_id: &str, track_id: &str) -> Result<u64, String> {
pub fn delete_loudness_for_track_id(
&self,
server_id: &str,
track_id: &str,
) -> Result<u64, String> {
if track_id.trim().is_empty() {
return Ok(0);
}
@@ -186,7 +255,11 @@ impl AnalysisCache {
/// Remove `waveform_cache` rows for this logical track (bare id and `stream:`
/// variant) scoped to one server. See [`Self::delete_loudness_for_track_id`]
/// for the scoping rationale.
pub fn delete_waveform_for_track_id(&self, server_id: &str, track_id: &str) -> Result<u64, String> {
pub fn delete_waveform_for_track_id(
&self,
server_id: &str,
track_id: &str,
) -> Result<u64, String> {
if track_id.trim().is_empty() {
return Ok(0);
}
@@ -230,13 +303,22 @@ impl AnalysisCache {
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
let tx = conn.transaction().map_err(|e| e.to_string())?;
let waveforms = tx
.execute("DELETE FROM waveform_cache WHERE server_id = ?1", params![server_id])
.execute(
"DELETE FROM waveform_cache WHERE server_id = ?1",
params![server_id],
)
.map_err(|e| e.to_string())?;
let loudness = tx
.execute("DELETE FROM loudness_cache WHERE server_id = ?1", params![server_id])
.execute(
"DELETE FROM loudness_cache WHERE server_id = ?1",
params![server_id],
)
.map_err(|e| e.to_string())?;
let analysis_tracks = tx
.execute("DELETE FROM analysis_track WHERE server_id = ?1", params![server_id])
.execute(
"DELETE FROM analysis_track WHERE server_id = ?1",
params![server_id],
)
.map_err(|e| e.to_string())?;
tx.commit().map_err(|e| e.to_string())?;
Ok(AnalysisDeleteServerReport {
@@ -254,12 +336,30 @@ impl AnalysisCache {
checkpoint_wal_conn(&conn, op).map_err(|e| e.to_string())
}
/// Verify the migration head and objects required by runtime reads/writes.
pub fn verify_operational_schema(&self) -> Result<(), String> {
let conn = self
.conn
.lock()
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
verify_operational_schema_conn(&conn)
}
/// Atomically switch analysis sqlite file while replacing the held
/// connection so runtime writers cannot continue on the old inode.
pub fn swap_database_file(
&self,
active_path: &Path,
destination_path: &Path,
) -> Result<Option<PathBuf>, String> {
self.swap_database_file_with(active_path, destination_path, |_| Ok(()))
}
fn swap_database_file_with(
&self,
active_path: &Path,
destination_path: &Path,
mut before_stage: impl FnMut(SwapDatabaseStage) -> Result<(), String>,
) -> Result<Option<PathBuf>, String> {
if !destination_path.exists() {
return Ok(None);
@@ -269,6 +369,7 @@ impl AnalysisCache {
.lock()
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
let tmp = Connection::open_in_memory().map_err(|e| e.to_string())?;
checkpoint_wal_conn(&conn, "pre-swap").map_err(|e| e.to_string())?;
let old_conn = std::mem::replace(&mut *conn, tmp);
drop(old_conn);
@@ -279,28 +380,62 @@ impl AnalysisCache {
.and_then(|n| n.to_str())
.unwrap_or("audio-analysis.sqlite")
));
remove_db_with_sidecars(&backup).ok();
if active_path.exists() {
fs::rename(active_path, &backup).map_err(|e| e.to_string())?;
move_sidecar(active_path, &backup, "-wal")?;
move_sidecar(active_path, &backup, "-shm")?;
}
if let Err(err) = fs::rename(destination_path, active_path) {
if backup.exists() {
let _ = fs::rename(&backup, active_path);
let _ = move_sidecar(&backup, active_path, "-wal");
let _ = move_sidecar(&backup, active_path, "-shm");
let mut active_backed_up = false;
let mut destination_activated = false;
let swap_result = (|| {
remove_db_with_sidecars(&backup)?;
if active_path.exists() {
before_stage(SwapDatabaseStage::BackupActive)?;
fs::rename(active_path, &backup).map_err(|e| e.to_string())?;
active_backed_up = true;
move_sidecar(active_path, &backup, "-wal")?;
move_sidecar(active_path, &backup, "-shm")?;
}
return Err(err.to_string());
before_stage(SwapDatabaseStage::ActivateDestination)?;
fs::rename(destination_path, active_path).map_err(|e| e.to_string())?;
destination_activated = true;
move_sidecar(destination_path, active_path, "-wal")?;
move_sidecar(destination_path, active_path, "-shm")?;
before_stage(SwapDatabaseStage::Open)?;
let mut reopened = Connection::open(active_path).map_err(|e| e.to_string())?;
before_stage(SwapDatabaseStage::Configure)?;
configure_connection(&reopened).map_err(|e| e.to_string())?;
before_stage(SwapDatabaseStage::Migrate)?;
run_migrations(&mut reopened).map_err(|e| e.to_string())?;
verify_operational_schema_conn(&reopened)?;
checkpoint_wal_conn(&reopened, "swap").map_err(|e| e.to_string())?;
Ok(reopened)
})();
match swap_result {
Ok(reopened) => {
*conn = reopened;
Ok(Some(backup))
}
Err(error) => match recover_failed_swap(
active_path,
destination_path,
&backup,
active_backed_up,
destination_activated,
) {
Ok(reopened) => {
*conn = reopened;
Err(error)
}
Err(rollback_error) => Err(format!(
"analysis database swap failed: {error}; rollback failed: {rollback_error}"
)),
},
}
let mut reopened = Connection::open(active_path).map_err(|e| e.to_string())?;
configure_connection(&reopened).map_err(|e| e.to_string())?;
run_migrations(&mut reopened).map_err(|e| e.to_string())?;
*conn = reopened;
Ok(Some(backup))
}
pub fn restore_database_backup(&self, backup_path: &Path, active_path: &Path) -> Result<(), String> {
pub fn restore_database_backup(
&self,
backup_path: &Path,
active_path: &Path,
) -> Result<(), String> {
let mut conn = self
.conn
.lock()
@@ -320,15 +455,13 @@ impl AnalysisCache {
let mut reopened = Connection::open(active_path).map_err(|e| e.to_string())?;
configure_connection(&reopened).map_err(|e| e.to_string())?;
run_migrations(&mut reopened).map_err(|e| e.to_string())?;
verify_operational_schema_conn(&reopened)?;
*conn = reopened;
Ok(())
}
/// Drop analysis rows written under legacy server ids (profile UUIDs).
pub fn migrate_server_keys(
&self,
mappings: &[(String, String)],
) -> Result<(), String> {
pub fn migrate_server_keys(&self, mappings: &[(String, String)]) -> Result<(), String> {
let mut conn = self
.conn
.lock()
@@ -340,12 +473,21 @@ impl AnalysisCache {
if legacy.is_empty() || key.is_empty() || legacy == key {
continue;
}
tx.execute("DELETE FROM waveform_cache WHERE server_id = ?1", params![legacy])
.map_err(|e| e.to_string())?;
tx.execute("DELETE FROM loudness_cache WHERE server_id = ?1", params![legacy])
.map_err(|e| e.to_string())?;
tx.execute("DELETE FROM analysis_track WHERE server_id = ?1", params![legacy])
.map_err(|e| e.to_string())?;
tx.execute(
"DELETE FROM waveform_cache WHERE server_id = ?1",
params![legacy],
)
.map_err(|e| e.to_string())?;
tx.execute(
"DELETE FROM loudness_cache WHERE server_id = ?1",
params![legacy],
)
.map_err(|e| e.to_string())?;
tx.execute(
"DELETE FROM analysis_track WHERE server_id = ?1",
params![legacy],
)
.map_err(|e| e.to_string())?;
}
tx.commit().map_err(|e| e.to_string())?;
Ok(())
@@ -353,7 +495,10 @@ impl AnalysisCache {
pub fn touch_track_status(&self, key: &TrackKey, status: &str) -> Result<(), String> {
let now = now_unix_ts();
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
let conn = self
.conn
.lock()
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
conn.execute(
r#"
INSERT INTO analysis_track (
@@ -380,7 +525,10 @@ impl AnalysisCache {
}
pub fn upsert_waveform(&self, key: &TrackKey, entry: &WaveformEntry) -> Result<(), String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
let conn = self
.conn
.lock()
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
conn.execute(
r#"
INSERT INTO waveform_cache (
@@ -411,7 +559,10 @@ impl AnalysisCache {
}
pub fn upsert_loudness(&self, key: &TrackKey, entry: &LoudnessEntry) -> Result<(), String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
let conn = self
.conn
.lock()
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
conn.execute(
r#"
INSERT INTO loudness_cache (
@@ -439,7 +590,10 @@ impl AnalysisCache {
}
pub fn get_waveform(&self, key: &TrackKey) -> Result<Option<WaveformEntry>, String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
let conn = self
.conn
.lock()
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
let row = conn
.query_row(
r#"
@@ -505,7 +659,10 @@ impl AnalysisCache {
/// True when this exact `(track_id, md5_16kb)` has a loudness row for the current algo version.
/// Used after `delete_loudness_for_track_id`: waveform may still be cached, but EBU data was removed.
pub fn loudness_row_exists_for_key(&self, key: &TrackKey) -> Result<bool, String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
let conn = self
.conn
.lock()
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
let exists: i64 = conn
.query_row(
r#"
@@ -522,7 +679,12 @@ impl AnalysisCache {
AND a.loudness_algo_version = ?4
)
"#,
params![key.server_id, key.track_id, key.md5_16kb, LOUDNESS_ALGO_VERSION],
params![
key.server_id,
key.track_id,
key.md5_16kb,
LOUDNESS_ALGO_VERSION
],
|row| row.get(0),
)
.map_err(|e| e.to_string())?;
@@ -535,7 +697,10 @@ impl AnalysisCache {
server_id: &str,
track_id: &str,
) -> Result<Option<WaveformEntry>, String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
let conn = self
.conn
.lock()
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
query_latest_waveform_scoped(&conn, server_id, track_id)
}
@@ -545,7 +710,10 @@ impl AnalysisCache {
server_id: &str,
track_id: &str,
) -> Result<Option<String>, String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
let conn = self
.conn
.lock()
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
query_latest_md5_16kb_scoped(&conn, server_id, track_id)
}
@@ -557,12 +725,18 @@ impl AnalysisCache {
server_id: &str,
track_id: &str,
) -> Result<Option<(String, i64)>, String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
let conn = self
.conn
.lock()
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
query_latest_status_scoped(&conn, server_id, track_id)
}
pub fn count_failed_tracks(&self, server_id: &str) -> Result<i64, String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
let conn = self
.conn
.lock()
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
query_failed_tracks_count_scoped(&conn, server_id)
}
@@ -571,7 +745,10 @@ impl AnalysisCache {
server_id: &str,
limit: Option<usize>,
) -> Result<Vec<FailedTrackEntry>, String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
let conn = self
.conn
.lock()
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
query_failed_tracks_scoped(&conn, server_id, limit)
}
@@ -580,7 +757,10 @@ impl AnalysisCache {
server_id: &str,
track_ids: &[String],
) -> Result<u64, String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
let conn = self
.conn
.lock()
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
if track_ids.is_empty() {
let deleted = conn
.execute(
@@ -612,11 +792,17 @@ impl AnalysisCache {
/// Both waveform and loudness rows exist for this `(server_id, track_id)` —
/// a CPU seed from bytes/file would only decode the file to immediately skip
/// with `SkippedWaveformCacheHit`.
pub fn cpu_seed_redundant_for_track(&self, server_id: &str, track_id: &str) -> Result<bool, String> {
Ok(
self.get_latest_waveform_for_track(server_id, track_id)?.is_some()
&& self.get_latest_loudness_for_track(server_id, track_id)?.is_some(),
)
pub fn cpu_seed_redundant_for_track(
&self,
server_id: &str,
track_id: &str,
) -> Result<bool, String> {
Ok(self
.get_latest_waveform_for_track(server_id, track_id)?
.is_some()
&& self
.get_latest_loudness_for_track(server_id, track_id)?
.is_some())
}
/// Latest loudness for `(server_id, track_id)`.
@@ -625,10 +811,12 @@ impl AnalysisCache {
server_id: &str,
track_id: &str,
) -> Result<Option<LoudnessSnapshot>, String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
let conn = self
.conn
.lock()
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
query_latest_loudness_scoped(&conn, server_id, track_id)
}
}
/// Server-scoped variant of the "latest waveform for this track" lookup: filters
@@ -730,9 +918,7 @@ fn query_latest_status_scoped(
.optional()
.map_err(|e| e.to_string())?;
if let Some(candidate) = row {
let take = latest
.as_ref()
.is_none_or(|(_, ts)| candidate.1 > *ts);
let take = latest.as_ref().is_none_or(|(_, ts)| candidate.1 > *ts);
if take {
latest = Some(candidate);
}
@@ -846,10 +1032,7 @@ fn query_latest_loudness_scoped(
}
fn analysis_db_path<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> Result<PathBuf, String> {
let base = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?;
let base = app.path().app_data_dir().map_err(|e| e.to_string())?;
let db_dir = base.join("databases").join("analysis");
let db_path = db_dir.join("audio-analysis.sqlite");
let legacy_data = base.join("audio-analysis.sqlite");
@@ -948,6 +1131,37 @@ fn move_sidecar(from_base: &Path, to_base: &Path, suffix: &str) -> Result<(), St
fs::rename(from, to).map_err(|e| e.to_string())
}
fn recover_failed_swap(
active_path: &Path,
destination_path: &Path,
backup_path: &Path,
active_backed_up: bool,
destination_activated: bool,
) -> Result<Connection, String> {
if destination_activated && active_path.exists() {
let _ = remove_db_with_sidecars(destination_path);
if fs::rename(active_path, destination_path).is_ok() {
let _ = move_sidecar(active_path, destination_path, "-wal");
let _ = move_sidecar(active_path, destination_path, "-shm");
} else {
let _ = remove_db_with_sidecars(active_path);
}
}
if active_backed_up {
remove_db_with_sidecars(active_path)?;
fs::rename(backup_path, active_path).map_err(|e| e.to_string())?;
move_sidecar(backup_path, active_path, "-wal")?;
move_sidecar(backup_path, active_path, "-shm")?;
}
let mut reopened = Connection::open(active_path).map_err(|e| e.to_string())?;
configure_connection(&reopened).map_err(|e| e.to_string())?;
run_migrations(&mut reopened).map_err(|e| e.to_string())?;
verify_operational_schema_conn(&reopened)?;
checkpoint_wal_conn(&reopened, "swap-rollback").map_err(|e| e.to_string())?;
Ok(reopened)
}
fn remove_db_with_sidecars(path: &Path) -> Result<(), String> {
if path.exists() {
fs::remove_file(path).map_err(|e| e.to_string())?;
@@ -969,6 +1183,95 @@ fn configure_connection(conn: &Connection) -> rusqlite::Result<()> {
Ok(())
}
fn verify_operational_schema_conn(conn: &Connection) -> Result<(), String> {
let migration_head = conn
.query_row("SELECT MAX(version) FROM schema_migrations", [], |row| {
row.get::<_, Option<i64>>(0)
})
.map_err(|error| format!("analysis migration history unavailable: {error}"))?;
if migration_head != Some(ANALYSIS_DB_SCHEMA_VERSION) {
return Err(format!(
"analysis schema migration head mismatch: expected {}, found {}",
ANALYSIS_DB_SCHEMA_VERSION,
migration_head
.map(|version| version.to_string())
.unwrap_or_else(|| "none".to_string())
));
}
let mut missing = Vec::new();
for table in &OPERATIONAL_TABLES {
let present = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
[table.name],
|row| row.get::<_, bool>(0),
)
.map_err(|error| error.to_string())?;
if !present {
missing.push(format!("table {}", table.name));
continue;
}
let mut statement = conn
.prepare("SELECT name, pk FROM pragma_table_info(?1)")
.map_err(|error| error.to_string())?;
let columns: Vec<(String, i64)> = statement
.query_map([table.name], |row| Ok((row.get(0)?, row.get(1)?)))
.map_err(|error| error.to_string())?
.collect::<Result<_, _>>()
.map_err(|error| error.to_string())?;
for required in table.columns {
if !columns.iter().any(|(name, _)| name == *required) {
missing.push(format!("column {}.{required}", table.name));
}
}
let mut actual_primary_key: Vec<(i64, String)> = columns
.iter()
.filter(|(_, ordinal)| *ordinal > 0)
.map(|(name, ordinal)| (*ordinal, name.clone()))
.collect();
actual_primary_key.sort_by_key(|(ordinal, _)| *ordinal);
let actual_primary_key: Vec<String> = actual_primary_key
.into_iter()
.map(|(_, name)| name)
.collect();
if actual_primary_key
!= table
.primary_key
.iter()
.map(|column| (*column).to_string())
.collect::<Vec<_>>()
{
missing.push(format!(
"primary key {} expected ({}) found ({})",
table.name,
table.primary_key.join(", "),
actual_primary_key.join(", ")
));
}
}
for index in OPERATIONAL_INDEXES {
let present = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?1)",
[index],
|row| row.get::<_, bool>(0),
)
.map_err(|error| error.to_string())?;
if !present {
missing.push(format!("index {index}"));
}
}
if !missing.is_empty() {
return Err(format!(
"analysis schema missing operational objects: {}",
missing.join(", ")
));
}
Ok(())
}
/// One-shot safety net before the first table-rewriting migration (002
/// `server_id`). Snapshots the existing DB via `VACUUM INTO` — a transactionally
/// consistent copy even with WAL — to `<db>.pre-v<N>.bak`, so a catastrophic
@@ -1156,6 +1459,49 @@ mod tests {
"waveform_cache"
]
);
drop(conn);
cache.verify_operational_schema().unwrap();
}
#[test]
fn operational_schema_rejects_current_head_with_missing_table() {
let cache = AnalysisCache::open_in_memory();
{
let conn = cache.conn.lock().unwrap();
let head: i64 = conn
.query_row("SELECT MAX(version) FROM schema_migrations", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(head, ANALYSIS_DB_SCHEMA_VERSION);
conn.execute_batch("DROP TABLE waveform_cache").unwrap();
}
let error = cache.verify_operational_schema().unwrap_err();
assert!(error.contains("table waveform_cache"));
}
#[test]
fn operational_schema_rejects_current_head_with_v1_table_shapes() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(MIGRATION_001_BASELINE).unwrap();
conn.execute_batch(
"CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL);
INSERT INTO schema_migrations(version, applied_at) VALUES (1, 0), (2, 0);",
)
.unwrap();
let error = verify_operational_schema_conn(&conn).unwrap_err();
for table in ["analysis_track", "waveform_cache", "loudness_cache"] {
assert!(
error.contains(&format!("column {table}.server_id")),
"missing v2 column was not reported for {table}: {error}"
);
assert!(
error.contains(&format!("primary key {table}")),
"v1 primary key was not rejected for {table}: {error}"
);
}
}
// ── waveform roundtrip ────────────────────────────────────────────────────
@@ -1273,8 +1619,13 @@ mod tests {
cache.touch_track_status(&k, "ok").unwrap();
cache.upsert_waveform(&k, &waveform(4, false)).unwrap();
// Insert under stream:abc, look up with bare abc.
let got = cache.get_latest_waveform_for_track("server-a", "abc").unwrap();
assert!(got.is_some(), "bare-id lookup must find stream-prefixed row");
let got = cache
.get_latest_waveform_for_track("server-a", "abc")
.unwrap();
assert!(
got.is_some(),
"bare-id lookup must find stream-prefixed row"
);
}
#[test]
@@ -1297,7 +1648,9 @@ mod tests {
let k = key("abc");
cache.touch_track_status(&k, "ok").unwrap();
assert!(!cache.cpu_seed_redundant_for_track("server-a", "abc").unwrap());
assert!(!cache
.cpu_seed_redundant_for_track("server-a", "abc")
.unwrap());
cache.upsert_waveform(&k, &waveform(4, false)).unwrap();
assert!(
@@ -1328,7 +1681,10 @@ mod tests {
let deleted = cache
.delete_loudness_for_track_id("server-a", "abc")
.unwrap();
assert_eq!(deleted, 2, "delete must remove both bare and stream:abc rows");
assert_eq!(
deleted, 2,
"delete must remove both bare and stream:abc rows"
);
assert!(!cache.loudness_row_exists_for_key(&bare).unwrap());
assert!(!cache.loudness_row_exists_for_key(&prefixed).unwrap());
}
@@ -1341,9 +1697,13 @@ mod tests {
cache.touch_track_status(&bare, "ok").unwrap();
cache.touch_track_status(&prefixed, "ok").unwrap();
cache.upsert_waveform(&bare, &waveform(4, false)).unwrap();
cache.upsert_waveform(&prefixed, &waveform(4, false)).unwrap();
cache
.upsert_waveform(&prefixed, &waveform(4, false))
.unwrap();
let deleted = cache.delete_waveform_for_track_id("server-a", "abc").unwrap();
let deleted = cache
.delete_waveform_for_track_id("server-a", "abc")
.unwrap();
assert_eq!(deleted, 2);
assert!(cache.get_waveform(&bare).unwrap().is_none());
assert!(cache.get_waveform(&prefixed).unwrap().is_none());
@@ -1431,7 +1791,10 @@ mod tests {
.unwrap()
.map(|r| r.unwrap())
.collect();
assert_eq!(versions, (1..=ANALYSIS_DB_SCHEMA_VERSION).collect::<Vec<_>>());
assert_eq!(
versions,
(1..=ANALYSIS_DB_SCHEMA_VERSION).collect::<Vec<_>>()
);
}
#[test]
@@ -1453,7 +1816,11 @@ mod tests {
// Same (track_id, md5_16kb) under two server ids are independent rows.
let conn = cache.conn.lock().unwrap();
let rows: i64 = conn
.query_row("SELECT COUNT(*) FROM analysis_track WHERE track_id='t'", [], |r| r.get(0))
.query_row(
"SELECT COUNT(*) FROM analysis_track WHERE track_id='t'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(rows, 2);
}
@@ -1474,7 +1841,9 @@ mod tests {
}
fn backup_file(dir: &Path) -> PathBuf {
dir.join(format!("audio-analysis.sqlite.pre-v{ANALYSIS_DB_SCHEMA_VERSION}.bak"))
dir.join(format!(
"audio-analysis.sqlite.pre-v{ANALYSIS_DB_SCHEMA_VERSION}.bak"
))
}
fn sqlite_sidecar(path: &Path, suffix: &str) -> PathBuf {
@@ -1488,6 +1857,7 @@ mod tests {
let mut conn = Connection::open(db_path).unwrap();
configure_connection(&conn).unwrap();
run_migrations(&mut conn).unwrap();
verify_operational_schema_conn(&conn).unwrap();
AnalysisCache {
conn: Mutex::new(conn),
}
@@ -1543,7 +1913,10 @@ mod tests {
let dir = unique_temp_dir("bkp-absent");
let db_path = dir.join("audio-analysis.sqlite");
backup_before_pending_migration(&db_path).unwrap();
assert!(!backup_file(&dir).exists(), "no backup for a fresh (absent) DB");
assert!(
!backup_file(&dir).exists(),
"no backup for a fresh (absent) DB"
);
let _ = std::fs::remove_dir_all(&dir);
}
@@ -1569,7 +1942,9 @@ mod tests {
let k = key("abc");
cache.touch_track_status(&k, "queued").unwrap();
let none = cache.content_cache_coverage("server-a", "abc", "deadbeef").unwrap();
let none = cache
.content_cache_coverage("server-a", "abc", "deadbeef")
.unwrap();
assert!(!none.has_waveform);
assert!(!none.has_loudness);
assert!(!none.complete());
@@ -1583,7 +1958,9 @@ mod tests {
assert!(!only_waveform.complete());
cache.upsert_loudness(&k, &loudness(-14.0)).unwrap();
let full = cache.content_cache_coverage("server-a", "abc", "deadbeef").unwrap();
let full = cache
.content_cache_coverage("server-a", "abc", "deadbeef")
.unwrap();
assert!(full.complete());
}
@@ -1608,7 +1985,9 @@ mod tests {
md5_16kb: "".to_string(),
};
cache.touch_track_status(&empty_md5, "ready").unwrap();
cache.upsert_waveform(&empty_md5, &waveform(4, false)).unwrap();
cache
.upsert_waveform(&empty_md5, &waveform(4, false))
.unwrap();
assert!(
cache
.get_latest_md5_16kb_for_track("server-a", "t2")
@@ -1676,7 +2055,9 @@ mod tests {
let cache = open_file_cache(&active_path);
let old_key = key_on("server-a", "old");
cache.touch_track_status(&old_key, "ready").unwrap();
cache.upsert_waveform(&old_key, &waveform(4, false)).unwrap();
cache
.upsert_waveform(&old_key, &waveform(4, false))
.unwrap();
{
let dst = open_file_cache(&destination_path);
@@ -1698,8 +2079,14 @@ mod tests {
.is_some(),
"cache must reopen on swapped destination DB"
);
assert!(!destination_path.exists(), "destination DB must be moved into active path");
assert!(backup_path.exists(), "previous active DB must be moved to backup path");
assert!(
!destination_path.exists(),
"destination DB must be moved into active path"
);
assert!(
backup_path.exists(),
"previous active DB must be moved to backup path"
);
cache
.restore_database_backup(&backup_path, &active_path)
@@ -1735,6 +2122,95 @@ mod tests {
let _ = remove_db_with_sidecars(&active_path);
}
#[test]
fn swap_database_file_restores_active_database_for_every_failure_stage() {
for stage in [
SwapDatabaseStage::BackupActive,
SwapDatabaseStage::ActivateDestination,
SwapDatabaseStage::Open,
SwapDatabaseStage::Configure,
SwapDatabaseStage::Migrate,
] {
let active_path = unique_temp_file("swap-fault-active");
let destination_path = unique_temp_file("swap-fault-destination");
let cache = open_file_cache(&active_path);
let old_key = key_on("server-a", "old");
cache.touch_track_status(&old_key, "ready").unwrap();
cache.upsert_waveform(&old_key, &waveform(4, false)).unwrap();
{
let destination = open_file_cache(&destination_path);
let new_key = key_on("server-a", "new");
destination.touch_track_status(&new_key, "ready").unwrap();
destination
.upsert_waveform(&new_key, &waveform(4, false))
.unwrap();
destination.checkpoint_wal("fault-destination").unwrap();
}
let error = cache
.swap_database_file_with(&active_path, &destination_path, |current| {
if current == stage {
Err(format!("injected {stage:?} failure"))
} else {
Ok(())
}
})
.unwrap_err();
assert!(error.contains("injected"));
assert!(
cache.get_waveform(&old_key).unwrap().is_some(),
"live connection was not restored after {stage:?}"
);
assert!(
cache
.get_waveform(&key_on("server-a", "new"))
.unwrap()
.is_none(),
"failed destination stayed live after {stage:?}"
);
let reopened = open_file_cache(&active_path);
assert!(
reopened.get_waveform(&old_key).unwrap().is_some(),
"active file was not restored after {stage:?}"
);
let _ = remove_db_with_sidecars(&active_path);
let _ = remove_db_with_sidecars(&destination_path);
}
}
#[test]
fn swap_database_file_rejects_current_head_v1_shape_and_restores_active() {
let active_path = unique_temp_file("swap-invalid-active");
let destination_path = unique_temp_file("swap-invalid-destination");
let cache = open_file_cache(&active_path);
let old_key = key_on("server-a", "old");
cache.touch_track_status(&old_key, "ready").unwrap();
cache.upsert_waveform(&old_key, &waveform(4, false)).unwrap();
{
let destination = Connection::open(&destination_path).unwrap();
destination.execute_batch(MIGRATION_001_BASELINE).unwrap();
destination
.execute_batch(
"CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL);
INSERT INTO schema_migrations(version, applied_at) VALUES (2, 0);",
)
.unwrap();
}
let error = cache
.swap_database_file(&active_path, &destination_path)
.unwrap_err();
assert!(error.contains("column analysis_track.server_id"));
assert!(cache.get_waveform(&old_key).unwrap().is_some());
open_file_cache(&active_path)
.verify_operational_schema()
.unwrap();
let _ = remove_db_with_sidecars(&active_path);
let _ = remove_db_with_sidecars(&destination_path);
}
#[test]
fn migrate_db_helpers_move_and_cleanup_sidecars() {
let from = unique_temp_file("migrate-from");
@@ -1770,18 +2246,9 @@ mod tests {
fn run_migrations_with_applies_unsorted_versions_once() {
let mut conn = Connection::open_in_memory().unwrap();
let migrations = [
(
3,
"CREATE TABLE IF NOT EXISTS m3 (id INTEGER PRIMARY KEY);",
),
(
1,
"CREATE TABLE IF NOT EXISTS m1 (id INTEGER PRIMARY KEY);",
),
(
2,
"CREATE TABLE IF NOT EXISTS m2 (id INTEGER PRIMARY KEY);",
),
(3, "CREATE TABLE IF NOT EXISTS m3 (id INTEGER PRIMARY KEY);"),
(1, "CREATE TABLE IF NOT EXISTS m1 (id INTEGER PRIMARY KEY);"),
(2, "CREATE TABLE IF NOT EXISTS m2 (id INTEGER PRIMARY KEY);"),
];
run_migrations_with(&mut conn, &migrations).unwrap();
run_migrations_with(&mut conn, &migrations).unwrap();
@@ -5,6 +5,7 @@ use std::sync::{Arc, Mutex, OnceLock};
use tauri::{Emitter, Manager};
use psysonic_core::ports::PlaybackQueryHandle;
use psysonic_core::server_http::{apply_optional_registry_headers, ServerHttpRegistry};
use psysonic_core::user_agent::subsonic_wire_user_agent;
use psysonic_core::track_enrichment::TrackEnrichmentOutcome;
@@ -16,6 +17,7 @@ use crate::analysis_perf::{emit_analysis_track_perf, AnalysisSeedTimings};
#[serde(rename_all = "camelCase")]
pub struct WaveformUpdatedPayload {
pub track_id: String,
pub server_index_key: String,
pub is_partial: bool,
}
@@ -36,7 +38,7 @@ impl AnalysisTierCounts {
}
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisPipelineQueueStatsDto {
pub pipeline_workers: u32,
@@ -679,10 +681,22 @@ fn analysis_http_client() -> &'static reqwest::Client {
})
}
async fn analysis_backfill_download_bytes(url: &str) -> Result<(Vec<u8>, u64), String> {
async fn analysis_backfill_download_bytes(
app: &tauri::AppHandle,
server_id: &str,
url: &str,
) -> Result<(Vec<u8>, u64), String> {
let fetch_started = std::time::Instant::now();
let response = analysis_http_client()
.get(url)
let registry = app
.try_state::<Arc<ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
let request = apply_optional_registry_headers(
registry.as_deref(),
Some(server_id),
url,
analysis_http_client().get(url),
);
let response = request
.send()
.await
.map_err(|e| e.to_string())?;
@@ -781,7 +795,7 @@ async fn spawn_backfill_slots(app: &tauri::AppHandle, shared: &Arc<AnalysisBackf
let app = app.clone();
let shared = shared.clone();
tauri::async_runtime::spawn(async move {
let download_result = analysis_backfill_download_bytes(&url).await;
let download_result = analysis_backfill_download_bytes(&app, &server_id, &url).await;
{
let mut st = shared
.state
@@ -1407,6 +1421,7 @@ async fn spawn_cpu_seed_slots(app: &tauri::AppHandle, shared: &Arc<AnalysisCpuSe
let notify_ui = analysis_emits_ui_events(job.priority);
tauri::async_runtime::spawn(async move {
let sid = job.server_id.clone();
let sid_for_event = sid.clone();
let tid = job.track_id.clone();
let bytes = job.bytes;
let format_hint = job.format_hint;
@@ -1468,6 +1483,7 @@ async fn spawn_cpu_seed_slots(app: &tauri::AppHandle, shared: &Arc<AnalysisCpuSe
"analysis:waveform-updated",
WaveformUpdatedPayload {
track_id: tid_log.clone(),
server_index_key: sid_for_event,
is_partial: false,
},
);
@@ -11,7 +11,7 @@ use crate::analysis_runtime::{
prune_analysis_queues, AnalysisBackfillPriority, PlaybackPriorityHints,
};
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct WaveformCachePayload {
pub bins: Vec<u8>,
@@ -35,7 +35,7 @@ impl From<analysis_cache::WaveformEntry> for WaveformCachePayload {
}
}
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LoudnessCachePayload {
pub integrated_lufs: f64,
@@ -45,7 +45,7 @@ pub struct LoudnessCachePayload {
pub updated_at: i64,
}
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisDeleteServerReportDto {
pub analysis_tracks: u64,
@@ -53,7 +53,7 @@ pub struct AnalysisDeleteServerReportDto {
pub loudness: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisFailedTrackDto {
pub track_id: String,
@@ -81,7 +81,7 @@ impl From<analysis_cache::FailedTrackEntry> for AnalysisFailedTrackDto {
}
}
#[derive(serde::Deserialize)]
#[derive(serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisServerKeyMigrationDto {
pub legacy_id: String,
@@ -148,6 +148,7 @@ pub fn get_loudness_payload_for_track(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_get_waveform(
track_id: String,
md5_16kb: String,
@@ -172,6 +173,7 @@ pub fn analysis_get_waveform(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_get_waveform_for_track(
track_id: String,
server_id: Option<String>,
@@ -192,6 +194,7 @@ pub fn analysis_get_waveform_for_track(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_get_loudness_for_track(
track_id: String,
target_lufs: Option<f64>,
@@ -203,6 +206,7 @@ pub fn analysis_get_loudness_for_track(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_delete_loudness_for_track(
track_id: String,
server_id: Option<String>,
@@ -212,6 +216,7 @@ pub fn analysis_delete_loudness_for_track(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_delete_waveform_for_track(
track_id: String,
server_id: Option<String>,
@@ -221,6 +226,7 @@ pub fn analysis_delete_waveform_for_track(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_delete_all_waveforms(
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<u64, String> {
@@ -228,6 +234,7 @@ pub fn analysis_delete_all_waveforms(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_delete_all_for_server(
server_id: String,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
@@ -240,6 +247,7 @@ pub fn analysis_delete_all_for_server(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_get_failed_track_count(
server_id: String,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
@@ -252,6 +260,7 @@ pub fn analysis_get_failed_track_count(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_list_failed_tracks(
server_id: String,
limit: Option<u32>,
@@ -269,6 +278,7 @@ pub fn analysis_list_failed_tracks(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_clear_failed_tracks(
server_id: String,
track_ids: Option<Vec<String>>,
@@ -288,6 +298,7 @@ pub fn analysis_clear_failed_tracks(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_migrate_server_index_keys(
mappings: Vec<AnalysisServerKeyMigrationDto>,
_cache: tauri::State<'_, analysis_cache::AnalysisCache>,
@@ -299,6 +310,7 @@ pub fn analysis_migrate_server_index_keys(
}
#[tauri::command]
#[specta::specta]
pub fn analysis_enqueue_seed_from_url(
track_id: String,
url: String,
@@ -318,7 +330,7 @@ pub fn analysis_enqueue_seed_from_url(
)
}
#[derive(Debug, Clone, serde::Deserialize)]
#[derive(Debug, Clone, serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisPriorityHintDto {
pub server_id: String,
@@ -326,6 +338,7 @@ pub struct AnalysisPriorityHintDto {
}
#[tauri::command]
#[specta::specta]
pub fn analysis_set_playback_priority_hints(
middle_track_refs: Vec<AnalysisPriorityHintDto>,
hints: tauri::State<'_, PlaybackPriorityHints>,
@@ -337,7 +350,7 @@ pub fn analysis_set_playback_priority_hints(
Ok(())
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisBackfillQueueStatsDto {
pub queued: usize,
@@ -346,17 +359,20 @@ pub struct AnalysisBackfillQueueStatsDto {
}
#[tauri::command]
#[specta::specta]
pub fn analysis_set_pipeline_parallelism(workers: u32) -> Result<(), String> {
crate::analysis_runtime::analysis_set_pipeline_parallelism(workers as usize);
Ok(())
}
#[tauri::command]
#[specta::specta]
pub fn analysis_get_pipeline_queue_stats() -> Result<crate::analysis_runtime::AnalysisPipelineQueueStatsDto, String> {
Ok(analysis_pipeline_queue_stats())
}
#[tauri::command]
#[specta::specta]
pub fn analysis_get_backfill_queue_stats() -> Result<AnalysisBackfillQueueStatsDto, String> {
let (queued, in_progress_count, in_progress_track_id) =
analysis_backfill_queue_stats();
@@ -367,7 +383,7 @@ pub fn analysis_get_backfill_queue_stats() -> Result<AnalysisBackfillQueueStatsD
})
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisPrunePendingResult {
pub keep_count: usize,
@@ -380,6 +396,7 @@ pub struct AnalysisPrunePendingResult {
///
/// Keeps currently-running jobs untouched; only queued (not-yet-started) jobs are removed.
#[tauri::command]
#[specta::specta]
pub fn analysis_prune_pending_to_track_ids(
track_ids: Vec<String>,
server_id: String,
@@ -11,6 +11,7 @@ psysonic-core = { path = "../psysonic-core" }
psysonic-analysis = { path = "../psysonic-analysis" }
tauri = { version = "2" }
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt", "time", "sync"] }
+19
View File
@@ -0,0 +1,19 @@
fn main() {
// Windows/MSVC test binaries only: bind to Common-Controls v6.
//
// The `tauri` dev-dependency pulls the wry/tao windowing runtime into this
// crate's *test* executables, which statically import `TaskDialogIndirect`
// from comctl32. That symbol lives only in Common-Controls v6 (WinSxS); the
// bare System32 comctl32.dll is v5.82 and does not export 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 its
// embedded manifest — mirror that here, scoped to test targets only (never
// the app, the rlib, or non-Windows/CI builds).
let is_windows_msvc = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
&& std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
if is_windows_msvc {
println!(
"cargo::rustc-link-arg=/MANIFESTDEPENDENCY:type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'"
);
}
}
@@ -40,6 +40,7 @@ pub(crate) fn autoeq_profile_url_candidates(
/// Proxy: fetches https://autoeq.app/entries via Rust to bypass WebView CORS restrictions.
#[tauri::command]
#[specta::specta]
pub async fn autoeq_entries(state: State<'_, AudioEngine>) -> Result<String, String> {
audio_http_client(&state)
.get("https://autoeq.app/entries")
@@ -49,6 +50,7 @@ pub async fn autoeq_entries(state: State<'_, AudioEngine>) -> Result<String, Str
/// Fetches the AutoEQ FixedBandEQ profile for a specific headphone from GitHub raw content.
#[tauri::command]
#[specta::specta]
pub async fn autoeq_fetch_profile(
name: String,
source: String,
+142 -28
View File
@@ -11,8 +11,9 @@ use rodio::Source;
use tauri::{AppHandle, Emitter, State};
use super::decode::build_source;
use super::engine::{audio_http_client, AudioEngine};
use super::engine::AudioEngine;
use super::helpers::*;
use super::hi_res_blend::{self, OutgoingBlendSnapshot};
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
use super::play_input::{select_play_input, url_format_hint, PlayInputContext};
use super::source_build::{build_playback_source_with_probe_fallback, BuildSourceArgs};
@@ -39,6 +40,10 @@ use super::state::{ChainedInfo, PreloadedTrack};
/// `stream_format_suffix`: Subsonic `song.suffix` (e.g. m4a); `stream.view` URLs have no
/// file extension, so this helps pick a Symphonia `format_hint` for ranged HTTP.
#[tauri::command]
// NOTE: excluded from tauri-specta collect_commands! — specta's SpectaFn is only
// implemented up to 10 args and this has 24. Typing it needs the args bundled into
// a struct (a behaviour/contract change), tracked for the D4 flip; stays on
// generate_handler! for now.
#[allow(clippy::too_many_arguments)]
pub async fn audio_play(
url: String,
@@ -51,6 +56,7 @@ pub async fn audio_play(
fallback_db: f32,
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
hi_res_enabled: bool, // false = safe 44.1 kHz mode; true = native rate (alpha)
hi_res_crossfade_resample_hz: Option<u32>, // 44100 / 88200 / 96000 when hi-res + crossfade
analysis_track_id: Option<String>,
server_id: Option<String>,
stream_format_suffix: Option<String>,
@@ -245,9 +251,12 @@ pub async fn audio_play(
state.crossfade_enabled.load(Ordering::Relaxed) && (!manual || manual_blend);
// Per-transition override (dynamic crossfade) caps the fade for this swap;
// otherwise fall back to the global crossfade length. Both clamped the same.
let crossfade_secs_val = crossfade_secs_override
.unwrap_or_else(|| f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)))
.clamp(0.5, 12.0);
let crossfade_secs_val = if let Some(override_secs) = crossfade_secs_override {
override_secs.clamp(0.5, 30.0)
} else {
f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed))
.clamp(0.5, 12.0)
};
// Measure how much audio Track A actually has left right now.
// By the time audio_play is called, near_end_ticks (2×500ms) + IPC latency
@@ -285,6 +294,13 @@ pub async fn audio_play(
0.0
};
let blend_rate = hi_res_blend::blend_rate_hz(
hi_res_enabled,
crossfade_enabled || (gapless && !manual),
hi_res_crossfade_resample_hz,
);
let resample_target_hz = blend_rate.unwrap_or(0);
// Build source: decode → trim → resample → EQ → fade-in → fade-out → notify → count.
let done_flag = Arc::new(AtomicBool::new(false));
// Reset sample counter for the new track.
@@ -301,6 +317,7 @@ pub async fn audio_play(
done_flag: done_flag.clone(),
fade_in_dur,
hi_res_enabled,
resample_target_hz,
duration_hint,
},
&state,
@@ -339,6 +356,26 @@ pub async fn audio_play(
return Ok(());
}
let current_stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
let outgoing_blend: Option<OutgoingBlendSnapshot> =
if let Some(blend) = blend_rate {
if crossfade_enabled && current_stream_rate > 0 && current_stream_rate != blend {
hi_res_blend::capture_outgoing_blend_snapshot(
&state,
outgoing_fade_secs,
actual_fade_secs,
)
} else {
None
}
} else {
None
};
if outgoing_blend.is_some() {
hi_res_blend::detach_current_sink_for_blend_reopen(&state);
}
// ── Stream rate management ────────────────────────────────────────────────
// Hi-Res ON: open device at file's native rate (bit-perfect, no resampler).
// Hi-Res OFF: if the stream was previously opened at a hi-res rate (e.g. the
@@ -347,11 +384,12 @@ pub async fn audio_play(
// If already at the device default — skip entirely (no IPC, no
// PipeWire reconfigure, no scheduler cost).
{
let current_stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
let target_rate = if hi_res_enabled {
output_rate // native file rate
let target_rate = if let Some(blend) = blend_rate {
blend
} else if hi_res_enabled {
output_rate // native file rate
} else {
state.device_default_rate // restore device default
state.device_default_rate // restore device default
};
let needs_switch = target_rate > 0 && target_rate != current_stream_rate;
if needs_switch {
@@ -383,6 +421,23 @@ pub async fn audio_play(
}
}
if let (Some(snap), Some(blend)) = (&outgoing_blend, blend_rate) {
if let Err(e) = hi_res_blend::spawn_outgoing_blend_resample(
&app,
&state,
snap,
blend,
gen,
)
.await
{
crate::app_eprintln!("{e}");
}
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
}
}
let stream = super::engine::ensure_output_stream_open(&state)?;
let sink = Arc::new(Player::connect_new(stream.mixer()));
sink.set_volume(effective_volume);
@@ -399,7 +454,7 @@ pub async fn audio_play(
// ring buffer time to build headroom before the cpal callback drains it.
let needs_preserve_prefill = preserve_pitch_will_run(&state.playback_rate);
let needs_prefill =
(hi_res_enabled && output_rate > 48_000) || needs_preserve_prefill;
(hi_res_enabled && blend_rate.unwrap_or(output_rate) > 48_000) || needs_preserve_prefill;
let defer_playback_start = !state.stream_playback_armed.load(Ordering::Relaxed);
if needs_prefill || defer_playback_start {
sink.pause();
@@ -551,6 +606,9 @@ pub async fn audio_play(
/// audio_play() checks chained_info.url on arrival: if it matches, it returns
/// immediately without touching the Sink (pure no-op on the audio path).
#[tauri::command]
// NOTE: excluded from tauri-specta collect_commands! — 13 args exceed specta's
// 10-arg SpectaFn limit; needs arg-bundling for the D4 flip. Stays on
// generate_handler! for now.
#[allow(clippy::too_many_arguments)]
pub async fn audio_chain_preload(
url: String,
@@ -562,6 +620,7 @@ pub async fn audio_chain_preload(
pre_gain_db: f32,
fallback_db: f32,
hi_res_enabled: bool,
hi_res_crossfade_resample_hz: Option<u32>,
analysis_track_id: Option<String>,
server_id: Option<String>,
app: AppHandle,
@@ -597,7 +656,14 @@ pub async fn audio_chain_preload(
} else if let Some(path) = url.strip_prefix("psysonic-local://") {
tokio::fs::read(path).await.map_err(|e| e.to_string())?
} else {
let resp = audio_http_client(&state).get(&url).send().await
let resp = crate::engine::playback_scoped_get(
&state,
&app,
&url,
server_id.as_deref(),
)
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Ok(()); // silently fail — audio_play will retry
@@ -668,8 +734,9 @@ pub async fn audio_chain_preload(
// Use a dedicated counter for the chained source — it will be swapped into
// samples_played when the chained track becomes active.
let chain_counter = Arc::new(AtomicU64::new(0));
// Always 0 — no application-level resampling (same as audio_play).
let target_rate: u32 = 0;
// Always 0 unless hi-res gapless blend resampling is active.
let blend_rate = hi_res_blend::blend_rate_hz(hi_res_enabled, hi_res_enabled, hi_res_crossfade_resample_hz);
let target_rate: u32 = blend_rate.unwrap_or(0);
let format_hint = url.rsplit('.').next()
.and_then(|ext| ext.split('?').next())
.map(|s| s.to_lowercase());
@@ -695,23 +762,70 @@ pub async fn audio_chain_preload(
return Ok(());
}
// In hi-res mode: if the next track's native rate differs from the current
// output stream, we cannot chain gaplessly — audio_play will do a hard cut
// with a stream re-open. Store raw bytes to avoid re-downloading.
// In safe mode (44.1 kHz locked): the stream rate is always 44100, so the
// chain proceeds and rodio resamples internally — no bail needed.
let next_rate = if hi_res_enabled { built.output_rate } else { 44_100 };
// Hi-res gapless: resample the chained track to the blend rate and realign
// the output stream when its Hz differs from the current track.
let stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
if hi_res_enabled && stream_rate > 0 && next_rate != stream_rate {
crate::app_eprintln!(
"[psysonic] gapless chain skipped: next track rate {} Hz ≠ stream {} Hz",
next_rate, stream_rate
);
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
url,
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
});
return Ok(());
if let Some(br) = blend_rate {
if stream_rate > 0 && stream_rate != br {
if let Some(snap) = hi_res_blend::capture_outgoing_blend_snapshot(&state, 0.0, 0.0) {
hi_res_blend::detach_current_sink_for_blend_reopen(&state);
let dev = state.selected_device.lock().unwrap().clone();
if super::engine::open_output_stream_blocking(&state, br, true, dev).is_ok() {
if hi_res_enabled && br > 48_000 {
tokio::time::sleep(Duration::from_millis(150)).await;
}
if state.generation.load(Ordering::SeqCst) == snapshot_gen {
if let Err(e) = hi_res_blend::rebuild_current_track_at_blend_rate(
&app,
&state,
&snap,
br,
snapshot_gen,
)
.await
{
crate::app_eprintln!("{e}");
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
url: url.clone(),
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
});
return Ok(());
}
}
} else {
crate::app_eprintln!(
"[psysonic] gapless blend stream reopen failed (wanted {br} Hz, had {stream_rate} Hz)"
);
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
url,
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
});
return Ok(());
}
} else {
crate::app_eprintln!(
"[psysonic] gapless blend skipped: current track not cached for realign"
);
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
url,
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
});
return Ok(());
}
}
} else {
let next_rate = if hi_res_enabled { built.output_rate } else { 44_100 };
if hi_res_enabled && stream_rate > 0 && next_rate != stream_rate {
crate::app_eprintln!(
"[psysonic] gapless chain skipped: next track rate {} Hz ≠ stream {} Hz",
next_rate, stream_rate
);
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
url,
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
});
return Ok(());
}
}
// Append to the existing Sink. The audio hardware stream never stalls.
+729 -4
View File
@@ -27,18 +27,540 @@ pub(crate) fn with_suppressed_alsa_stderr<R>(f: impl FnOnce() -> R) -> R {
}
pub(crate) fn enumerate_output_device_names() -> Vec<String> {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
with_suppressed_alsa_stderr(|| {
enumerate_output_device_entries()
.into_iter()
.map(|e| e.key)
.collect()
}
/// Stable key + human label for the settings dropdown.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct OutputDeviceEntry {
pub key: String,
pub label: String,
}
pub(crate) fn enumerate_output_device_entries() -> Vec<OutputDeviceEntry> {
use rodio::cpal::traits::HostTrait;
let mut out = with_suppressed_alsa_stderr(|| {
let host = rodio::cpal::default_host();
host.output_devices()
.map(|iter| {
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
.collect()
iter.filter_map(|d| {
let key = output_device_stable_key(&d);
if key.is_empty() {
return None;
}
Some(OutputDeviceEntry {
label: output_device_display_label(&d),
key,
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default()
});
dedupe_output_device_entries(&mut out);
out
}
fn dedupe_output_device_entries(entries: &mut Vec<OutputDeviceEntry>) {
let mut seen = std::collections::HashSet::new();
entries.retain(|e| seen.insert(e.key.clone()));
}
/// Stable per-device key for Settings / EQ maps. Linux keeps ALSA-style description
/// names; Windows/macOS use cpal [`DeviceId`] so same-named endpoints stay distinct
/// and default-device changes are observable by the watcher.
pub(crate) fn output_device_stable_key(device: &impl rodio::cpal::traits::DeviceTrait) -> String {
#[cfg(not(target_os = "linux"))]
{
if let Ok(id) = device.id() {
return id.to_string();
}
}
device
.description()
.ok()
.map(|d| d.name().to_string())
.unwrap_or_else(|| device.id().map(|i| i.to_string()).unwrap_or_default())
}
/// Human-readable label for the settings dropdown (not the stored key).
pub(crate) fn output_device_display_label(
device: &impl rodio::cpal::traits::DeviceTrait,
) -> String {
match device.description() {
Ok(desc) => format_output_device_label(&desc),
Err(_) => output_device_stable_key(device),
}
}
pub(crate) fn format_output_device_label(desc: &rodio::cpal::DeviceDescription) -> String {
use rodio::cpal::{DeviceType, InterfaceType};
let name = desc.name();
let mut parts: Vec<String> = vec![name.to_string()];
if let Some(mfr) = desc.manufacturer() {
if mfr != name && !name.contains(mfr) {
parts.push(mfr.to_string());
}
}
if let Some(driver) = desc.driver() {
if driver != name && !parts.iter().any(|p| p.contains(driver)) {
parts.push(driver.to_string());
}
}
if parts.len() == 1 {
let iface = desc.interface_type();
if iface != InterfaceType::Unknown && iface != InterfaceType::BuiltIn {
parts.push(iface.to_string());
} else {
let dtype = desc.device_type();
if dtype != DeviceType::Unknown && dtype != DeviceType::Speaker {
parts.push(dtype.to_string());
}
}
}
parts.join(" · ")
}
/// Best-effort label when a legacy plain-name pin is kept off the current list.
pub(crate) fn legacy_output_device_display_label(key: &str) -> String {
#[cfg(not(target_os = "linux"))]
{
use rodio::cpal::traits::HostTrait;
if let Ok(id) = key.parse::<rodio::cpal::DeviceId>() {
if let Some(device) = rodio::cpal::default_host().device_by_id(&id) {
return output_device_display_label(&device);
}
}
}
key.to_string()
}
/// Upgrade a preDeviceId persisted pin to the current stable key when unambiguous.
pub(crate) fn resolve_legacy_pinned_key(
pinned: &str,
entries: &[OutputDeviceEntry],
) -> Option<String> {
if entries.iter().any(|e| e.key == pinned) {
return Some(pinned.to_string());
}
let logic_matches: Vec<_> = entries
.iter()
.filter(|e| output_devices_logically_same(&e.key, pinned))
.collect();
if logic_matches.len() == 1 {
return Some(logic_matches[0].key.clone());
}
#[cfg(not(target_os = "linux"))]
{
let label_matches: Vec<_> = entries
.iter()
.filter(|e| e.label == pinned || e.label.starts_with(&format!("{pinned} · ")))
.collect();
if label_matches.len() == 1 {
return Some(label_matches[0].key.clone());
}
}
None
}
/// Resolve a stored device key to a cpal device (DeviceId on Windows/macOS, name on Linux).
pub(crate) fn resolve_output_device(
device_key: &str,
) -> Option<rodio::cpal::Device> {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
use std::str::FromStr;
let host = rodio::cpal::default_host();
if let Ok(id) = rodio::cpal::DeviceId::from_str(device_key) {
if let Some(device) = host.device_by_id(&id) {
return Some(device);
}
}
host.output_devices().ok()?.find(|d| {
output_device_stable_key(d) == device_key
|| d.description()
.ok()
.map(|desc| desc.name().to_string())
.as_deref()
== Some(device_key)
})
}
/// cpal/rodio aliases for "follow the OS default" — not a stable per-device key.
#[cfg(target_os = "linux")]
pub(crate) fn is_generic_default_output_alias(name: &str) -> bool {
matches!(
name,
"default"
| "Default Audio Device"
| "PipeWire Sound Server"
| "Default ALSA Output (currently PipeWire Media Server)"
)
}
fn raw_cpal_default_output_device_key() -> Option<String> {
use rodio::cpal::traits::HostTrait;
with_suppressed_alsa_stderr(|| {
rodio::cpal::default_host()
.default_output_device()
.map(|d| output_device_stable_key(&d))
})
}
#[cfg(target_os = "linux")]
fn pick_listed_device_name(candidate: &str, list: &[String]) -> Option<String> {
list.iter()
.find(|d| d.as_str() == candidate || output_devices_logically_same(d, candidate))
.cloned()
}
#[cfg(target_os = "linux")]
fn equivalent_list_entries(name: &str, list: &[String]) -> Vec<String> {
let mut out: Vec<String> = list
.iter()
.filter(|d| d.as_str() == name || output_devices_logically_same(d, name))
.cloned()
.collect();
if let Some(picked) = pick_listed_device_name(name, list) {
if !out.iter().any(|d| d == &picked) {
out.push(picked);
}
}
if out.is_empty() && !name.is_empty() {
out.push(name.to_string());
}
out
}
/// True when two device keys refer to the same sink (exact, ALSA logical, or via list canon).
#[cfg(target_os = "linux")]
pub(crate) fn output_device_keys_equivalent(a: &str, b: &str, list: &[String]) -> bool {
if a == b || output_devices_logically_same(a, b) {
return true;
}
if comma_and_alsa_device_equivalent(a, b) {
return true;
}
let ea = equivalent_list_entries(a, list);
let eb = equivalent_list_entries(b, list);
ea.iter()
.any(|x| eb.iter().any(|y| x == y || output_devices_logically_same(x, y)))
}
/// Match wpctl/cpal `"CARD, PCM"` labels to ALSA `iface:CARD=…` picker ids.
#[cfg(target_os = "linux")]
fn comma_and_alsa_device_equivalent(a: &str, b: &str) -> bool {
let (comma, alsa) = if linux_alsa_sink_fingerprint(a).is_some() {
(b, a)
} else if linux_alsa_sink_fingerprint(b).is_some() {
(a, b)
} else {
return false;
};
if comma.contains(':') {
return false;
}
let mut parts = comma.splitn(2, ',');
let Some(comma_card) = parts.next() else {
return false;
};
let comma_card = comma_card.trim();
let comma_pcm = parts.next().map(|s| s.trim()).unwrap_or("");
if comma_pcm.is_empty() {
return false;
}
let Some((_, alsa_card, _)) = linux_alsa_sink_fingerprint(alsa) else {
return false;
};
let pcm = comma_pcm.to_ascii_lowercase();
let alsa_lower = alsa.to_ascii_lowercase();
let cc = comma_card.to_ascii_lowercase();
let ac = alsa_card.to_ascii_lowercase();
let card_ok = cc.contains(&ac) || ac.contains(&cc);
if !card_ok {
return false;
}
if alsa_lower.starts_with("hdmi:") {
return !pcm.contains("analog");
}
if pcm.contains("analog") {
return alsa_lower.starts_with("hw:") || alsa_lower.starts_with("plughw:");
}
alsa_lower.contains(&pcm) || pcm.contains(&alsa_lower)
}
/// Build the cpal-style `"CARD, PCM name"` label PipeWire exposes for ALSA sinks.
#[cfg(target_os = "linux")]
pub(crate) fn cpal_name_from_pipewire_alsa(card: &str, alsa_name: &str) -> String {
format!("{card}, {alsa_name}")
}
/// Read `node.driver-id` from `wpctl inspect` output (PipeWire stream → sink link).
#[cfg(target_os = "linux")]
pub(crate) fn parse_wpctl_inspect_driver_id(inspect: &str) -> Option<u32> {
for line in inspect.lines() {
let line = line.trim().trim_start_matches('*').trim();
if let Some(v) = line.strip_prefix("node.driver-id = ") {
return v.trim_matches('"').parse().ok();
}
}
None
}
/// Collect PipeWire ALSA `[psysonic]` stream node ids that have at least one
/// active playback link in `wpctl status` (ignores stale / idle nodes).
#[cfg(target_os = "linux")]
pub(crate) fn parse_wpctl_status_psysonic_stream_ids(status: &str) -> Vec<u32> {
let mut in_audio_streams = false;
let mut ids = Vec::new();
let mut current_id: Option<u32> = None;
for line in status.lines() {
if line.contains("Streams:") && line.contains('─') {
in_audio_streams = true;
continue;
}
if !in_audio_streams {
continue;
}
let trimmed = line.trim();
if trimmed.starts_with("Video") || trimmed.starts_with("Settings") {
break;
}
if trimmed.contains("PipeWire ALSA [psysonic]") && !trimmed.contains("(deleted)") {
current_id = trimmed
.split('.')
.next()
.and_then(|s| s.trim().parse().ok());
continue;
}
if trimmed.contains('>')
&& (trimmed.contains("[active]") || trimmed.contains("[init]"))
{
if let Some(id) = current_id {
if !ids.contains(&id) {
ids.push(id);
}
}
} else if trimmed.contains('.') {
let prefix = trimmed.split('.').next().unwrap_or("").trim();
if prefix.chars().all(|c| c.is_ascii_digit()) && !trimmed.contains('>') {
current_id = None;
}
}
}
ids
}
#[cfg(target_os = "linux")]
fn linux_wpctl_inspect_driver_id(node_id: u32) -> Option<u32> {
use std::process::Command;
let inspect = Command::new("wpctl")
.args(["inspect", &node_id.to_string()])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())?;
parse_wpctl_inspect_driver_id(&inspect)
}
/// True when a live psysonic PipeWire stream is already routed to the default sink.
/// Hyprpanel / WirePlumber often migrate streams on `set-default` before our poll
/// sees the change — reopening CPAL in that case only causes an audible glitch.
#[cfg(target_os = "linux")]
pub(crate) fn linux_psysonic_stream_routes_to_default_sink() -> bool {
use std::process::Command;
let Some(default_id) = linux_wpctl_default_sink_id() else {
return false;
};
let Some(status) = Command::new("wpctl")
.args(["status"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
else {
return false;
};
let stream_ids = parse_wpctl_status_psysonic_stream_ids(&status);
stream_ids.iter().any(|&id| linux_wpctl_inspect_driver_id(id) == Some(default_id))
}
/// Parse `wpctl list audio sinks` and return the id of the default sink (trailing `*`).
#[cfg(target_os = "linux")]
pub(crate) fn parse_wpctl_list_default_sink_id(listing: &str) -> Option<u32> {
for line in listing.lines() {
let line = line.trim_end();
if !line.ends_with('*') {
continue;
}
let id_str = line.split('\t').next()?.trim();
return id_str.parse().ok();
}
None
}
/// Parse `wpctl status` and return the id of the default sink (line marked with `*`).
#[cfg(target_os = "linux")]
pub(crate) fn parse_wpctl_default_sink_id(status: &str) -> Option<u32> {
let mut in_sinks = false;
for line in status.lines() {
if line.contains("Sinks:") {
in_sinks = true;
continue;
}
if !in_sinks {
continue;
}
if line.contains("Sources:") {
break;
}
if !line.contains('*') {
continue;
}
let after_star = line.split('*').nth(1)?.trim();
let id_str = after_star.split('.').next()?.trim();
return id_str.parse().ok();
}
None
}
/// Read `api.alsa.card.name` + `alsa.name` from `wpctl inspect` output.
#[cfg(target_os = "linux")]
pub(crate) fn parse_wpctl_inspect_alsa_names(inspect: &str) -> Option<(String, String)> {
let mut card: Option<String> = None;
let mut pcm: Option<String> = None;
for line in inspect.lines() {
let line = line.trim();
if let Some(v) = line.strip_prefix("api.alsa.card.name = ") {
card = Some(v.trim_matches('"').to_string());
} else if card.is_none() {
if let Some(v) = line.strip_prefix("alsa.card_name = ") {
card = Some(v.trim_matches('"').to_string());
}
}
if let Some(v) = line.strip_prefix("alsa.name = ") {
pcm = Some(v.trim_matches('"').to_string());
}
}
match (card, pcm) {
(Some(c), Some(n)) if !c.is_empty() && !n.is_empty() => Some((c, n)),
_ => None,
}
}
#[cfg(target_os = "linux")]
fn linux_wpctl_default_sink_id() -> Option<u32> {
use std::process::Command;
let listing = Command::new("wpctl")
.args(["list", "audio", "sinks"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned());
if let Some(ref text) = listing {
if let Some(id) = parse_wpctl_list_default_sink_id(text) {
return Some(id);
}
}
let status = Command::new("wpctl")
.args(["status"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())?;
parse_wpctl_default_sink_id(&status)
}
/// Read `node.description` from `wpctl inspect` (Bluetooth and other non-ALSA sinks).
#[cfg(target_os = "linux")]
pub(crate) fn parse_wpctl_inspect_node_description(inspect: &str) -> Option<String> {
for line in inspect.lines() {
let line = line.trim().trim_start_matches('*').trim();
if let Some(v) = line.strip_prefix("node.description = ") {
let desc = v.trim_matches('"').to_string();
if !desc.is_empty() {
return Some(desc);
}
}
}
None
}
#[cfg(target_os = "linux")]
fn linux_resolve_default_via_pipewire(list: &[String]) -> Option<String> {
use std::process::Command;
let sink_id = linux_wpctl_default_sink_id()?;
let inspect = Command::new("wpctl")
.args(["inspect", &sink_id.to_string()])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())?;
let candidate = if let Some((card, pcm)) = parse_wpctl_inspect_alsa_names(&inspect) {
cpal_name_from_pipewire_alsa(&card, &pcm)
} else {
parse_wpctl_inspect_node_description(&inspect)?
};
pick_listed_device_name(&candidate, list).or(Some(candidate))
}
/// Resolve the active default output to a device key that matches `audio_list_devices`
/// when possible. On Linux/PipeWire, cpal's default is often a generic alias or a
/// stale card name that does not track WirePlumber default changes (Hyprpanel,
/// pavucontrol, `wpctl set-default`, etc.) — prefer `wpctl` when available.
pub fn effective_default_output_device_name() -> Option<String> {
resolve_effective_default_output_device_name(true)
}
/// Same as [`effective_default_output_device_name`] but skips the full
/// `output_devices()` scan — for the device-watcher poll path (#996).
pub(crate) fn effective_default_output_device_name_for_poll() -> Option<String> {
resolve_effective_default_output_device_name(false)
}
fn resolve_effective_default_output_device_name(enumerate_devices: bool) -> Option<String> {
// Windows/macOS: single cpal default query (pre-#1274). Full `output_devices()`
// enumeration contends with WASAPI/CoreAudio and is only needed for Linux/PipeWire
// default resolution + ALSA logical key matching.
#[cfg(not(target_os = "linux"))]
{
let _ = enumerate_devices;
return raw_cpal_default_output_device_key();
}
#[cfg(target_os = "linux")]
{
let list = if enumerate_devices {
enumerate_output_device_names()
} else {
Vec::new()
};
if let Some(resolved) = linux_resolve_default_via_pipewire(&list) {
return Some(resolved);
}
if !enumerate_devices {
// wpctl unavailable — last-resort cpal (skip generic/stale placeholder names).
if linux_wpctl_default_sink_id().is_none() {
if let Some(raw) = raw_cpal_default_output_device_key() {
if !is_generic_default_output_alias(&raw) {
return Some(raw);
}
}
}
return None;
}
let raw = raw_cpal_default_output_device_key();
if let Some(ref name) = raw {
if !is_generic_default_output_alias(name) {
return pick_listed_device_name(name, &list).or_else(|| Some(name.clone()));
}
}
raw
}
}
/// Linux ALSA-style cpal names: same physical sink can appear with different suffixes;
/// busy devices are sometimes omitted from `output_devices()` while playback works.
#[cfg(target_os = "linux")]
@@ -72,6 +594,20 @@ pub(crate) fn output_devices_logically_same(a: &str, b: &str) -> bool {
if a == b {
return true;
}
#[cfg(not(target_os = "linux"))]
{
if let (Ok(ida), Ok(idb)) = (
a.parse::<rodio::cpal::DeviceId>(),
b.parse::<rodio::cpal::DeviceId>(),
) {
return ida.1 == idb.1;
}
if legacy_description_key_matches_device_id(a, b)
|| legacy_description_key_matches_device_id(b, a)
{
return true;
}
}
match (
linux_alsa_sink_fingerprint(a),
linux_alsa_sink_fingerprint(b),
@@ -81,7 +617,32 @@ pub(crate) fn output_devices_logically_same(a: &str, b: &str) -> bool {
}
}
/// PreDeviceId persisted pins (description names) vs cpal `DeviceId` enumeration keys.
#[cfg(not(target_os = "linux"))]
fn legacy_description_key_matches_device_id(legacy: &str, device_id_key: &str) -> bool {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
use std::str::FromStr;
if legacy.parse::<rodio::cpal::DeviceId>().is_ok() {
return false;
}
let Ok(id) = rodio::cpal::DeviceId::from_str(device_id_key) else {
return legacy == device_id_key;
};
let Some(device) = rodio::cpal::default_host().device_by_id(&id) else {
return false;
};
let Ok(desc) = device.description() else {
return false;
};
if desc.name() == legacy {
return true;
}
let label = output_device_display_label(&device);
label == legacy || label.starts_with(&format!("{legacy} · "))
}
/// True if `pinned` is the same sink as some entry (exact or Linux ALSA logical match).
#[cfg(not(target_os = "linux"))]
pub(crate) fn output_enumeration_includes_pinned(available: &[String], pinned: &str) -> bool {
available
.iter()
@@ -110,18 +671,21 @@ mod tests {
// ── output_enumeration_includes_pinned ────────────────────────────────────
#[test]
#[cfg(not(target_os = "linux"))]
fn includes_pinned_finds_exact_match() {
let avail = vec!["A".to_string(), "B".to_string(), "C".to_string()];
assert!(output_enumeration_includes_pinned(&avail, "B"));
}
#[test]
#[cfg(not(target_os = "linux"))]
fn includes_pinned_returns_false_when_absent() {
let avail = vec!["A".to_string(), "B".to_string()];
assert!(!output_enumeration_includes_pinned(&avail, "Z"));
}
#[test]
#[cfg(not(target_os = "linux"))]
fn includes_pinned_returns_false_for_empty_list() {
let avail: Vec<String> = vec![];
assert!(!output_enumeration_includes_pinned(&avail, "anything"));
@@ -188,4 +752,165 @@ mod tests {
assert!(linux_alsa_sink_fingerprint("hdmi:CARD=X,DEV=0").is_none());
assert!(linux_alsa_sink_fingerprint("anything").is_none());
}
// ── generic default alias / PipeWire wpctl parsing ────────────────────────
#[test]
#[cfg(target_os = "linux")]
fn generic_default_alias_detects_cpal_pipewire_placeholders() {
assert!(is_generic_default_output_alias("Default Audio Device"));
assert!(is_generic_default_output_alias("PipeWire Sound Server"));
assert!(!is_generic_default_output_alias("HDA NVidia, Gigabyte M32U"));
}
#[test]
#[cfg(target_os = "linux")]
fn parse_wpctl_status_psysonic_stream_ids_accepts_init_links_when_paused() {
let status = r#"
Audio
Streams:
84. PipeWire ALSA [psysonic]
90. output_FL > ALC897 Analog:playback_FL [init]
"#;
assert_eq!(parse_wpctl_status_psysonic_stream_ids(status), vec![84]);
}
#[test]
#[cfg(target_os = "linux")]
fn parse_wpctl_status_psysonic_stream_ids_ignores_streams_without_links() {
let status = r#"
Audio
Streams:
84. PipeWire ALSA [psysonic]
87. PipeWire ALSA [psysonic]
106. output_FL > HDMI:playback_FL [active]
"#;
assert_eq!(parse_wpctl_status_psysonic_stream_ids(status), vec![87]);
}
#[test]
#[cfg(target_os = "linux")]
fn parse_wpctl_status_psysonic_stream_ids_finds_active_streams() {
let status = r#"
Audio
Streams:
84. PipeWire ALSA [psysonic]
90. output_FL > ALC897 Analog:playback_FL [active]
119. PipeWire ALSA [psysonic (deleted)]
Video
"#;
assert_eq!(
parse_wpctl_status_psysonic_stream_ids(status),
vec![84]
);
}
#[test]
#[cfg(target_os = "linux")]
fn parse_wpctl_inspect_driver_id_reads_node_driver() {
let inspect = r#"
* node.driver-id = "58"
node.name = "alsa_playback.psysonic"
"#;
assert_eq!(parse_wpctl_inspect_driver_id(inspect), Some(58));
}
#[test]
#[cfg(target_os = "linux")]
fn parse_wpctl_list_default_sink_id_finds_starred_sink() {
let listing = "56\talsa_output.pci-hdmi\taudio/sink\t\n58\talsa_output.pci-analog\taudio/sink\t*";
assert_eq!(parse_wpctl_list_default_sink_id(listing), Some(58));
}
#[test]
#[cfg(target_os = "linux")]
fn parse_wpctl_default_sink_id_finds_starred_sink() {
let status = r#"
Audio
Devices:
Sinks:
56. HDMI out
* 58. Analog out
Sources:
"#;
assert_eq!(parse_wpctl_default_sink_id(status), Some(58));
}
#[test]
#[cfg(target_os = "linux")]
fn parse_wpctl_inspect_alsa_names_reads_card_and_pcm() {
let inspect = r#"
api.alsa.card.name = "HD-Audio Generic"
alsa.name = "ALC897 Analog"
"#;
assert_eq!(
parse_wpctl_inspect_alsa_names(inspect),
Some(("HD-Audio Generic".into(), "ALC897 Analog".into()))
);
assert_eq!(
cpal_name_from_pipewire_alsa("HD-Audio Generic", "ALC897 Analog"),
"HD-Audio Generic, ALC897 Analog"
);
}
#[test]
#[cfg(target_os = "linux")]
fn parse_wpctl_inspect_node_description_reads_bluetooth_sink() {
let inspect = r#"
* node.description = "BlueZ Audio Device"
node.name = "bluez_output.xxx"
"#;
assert_eq!(
parse_wpctl_inspect_node_description(inspect),
Some("BlueZ Audio Device".into())
);
}
#[test]
#[cfg(target_os = "linux")]
fn output_device_keys_equivalent_links_hdmi_comma_and_alsa_id() {
assert!(output_device_keys_equivalent(
"HDA NVidia, Gigabyte M32U",
"hdmi:CARD=NVidia,DEV=3",
&[],
));
}
#[test]
#[cfg(target_os = "linux")]
fn output_device_keys_equivalent_distinguishes_analog_and_hdmi() {
assert!(!output_device_keys_equivalent(
"HD-Audio Generic, ALC897 Analog",
"hdmi:CARD=HD-Audio Generic,DEV=3",
&[],
));
}
#[test]
#[cfg(target_os = "linux")]
fn pick_listed_device_name_prefers_enumerated_entry() {
let list = vec![
"Default Audio Device".to_string(),
"HDA NVidia, Gigabyte M32U".to_string(),
];
assert_eq!(
pick_listed_device_name("HDA NVidia, Gigabyte M32U", &list),
Some("HDA NVidia, Gigabyte M32U".to_string())
);
}
#[test]
#[cfg(target_os = "linux")]
fn pick_listed_device_name_matches_linux_alsa_logical_alias() {
let list = vec!["hdmi:CARD=NVidia,DEV=3".to_string()];
assert_eq!(
pick_listed_device_name("hw:CARD=NVidia,DEV=3", &list),
None,
"different ALSA ifaces are not logically the same"
);
assert_eq!(
pick_listed_device_name("hdmi:CARD=NVidia,DEV=3", &list),
Some("hdmi:CARD=NVidia,DEV=3".to_string())
);
}
}
@@ -7,67 +7,135 @@ use std::sync::atomic::Ordering;
use tauri::{Emitter, State};
use super::dev_io::{
enumerate_output_device_names, output_devices_logically_same,
output_enumeration_includes_pinned, with_suppressed_alsa_stderr,
enumerate_output_device_entries, legacy_output_device_display_label,
output_devices_logically_same, resolve_legacy_pinned_key, OutputDeviceEntry,
};
#[cfg(target_os = "linux")]
use super::dev_io::output_device_keys_equivalent;
use super::engine::AudioEngine;
/// One row in the audio output device picker (`key` is persisted; `label` is display-only).
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
pub struct AudioOutputDeviceEntry {
pub key: String,
pub label: String,
}
impl From<OutputDeviceEntry> for AudioOutputDeviceEntry {
fn from(e: OutputDeviceEntry) -> Self {
Self {
key: e.key,
label: e.label,
}
}
}
fn list_output_device_entries(engine: &AudioEngine) -> Vec<AudioOutputDeviceEntry> {
let mut list: Vec<AudioOutputDeviceEntry> = enumerate_output_device_entries()
.into_iter()
.map(Into::into)
.collect();
if let Some(ref name) = *engine.selected_device.lock().unwrap() {
if !name.is_empty()
&& !list
.iter()
.any(|e| output_devices_logically_same(&e.key, name))
{
list.push(AudioOutputDeviceEntry {
key: name.clone(),
label: legacy_output_device_display_label(name),
});
}
}
list
}
/// When the saved `selected_device` no longer literally matches any listed
/// physical sink (e.g. suffix drift), rewrite `selected_device` to the listed form.
#[tauri::command]
#[specta::specta]
pub fn audio_canonicalize_selected_device(state: State<'_, AudioEngine>) -> Option<String> {
let pinned = state.selected_device.lock().unwrap().clone()?;
if pinned.is_empty() {
return None;
}
let list = enumerate_output_device_names();
if list.iter().any(|d| d == &pinned) {
let entries = list_output_device_entries(&state);
if entries.iter().any(|e| e.key == pinned) {
return None;
}
let canon = list
if let Some(upgraded) = resolve_legacy_pinned_key(&pinned, &enumerate_output_device_entries()) {
if upgraded != pinned {
*state.selected_device.lock().unwrap() = Some(upgraded.clone());
return Some(upgraded);
}
}
let canon = entries
.iter()
.find(|d| output_devices_logically_same(d, &pinned))?
.find(|e| output_devices_logically_same(&e.key, &pinned))?
.key
.clone();
*state.selected_device.lock().unwrap() = Some(canon.clone());
Some(canon)
}
/// Same device list as [`audio_list_devices`] without the Tauri `State` wrapper (CLI / single-instance).
pub fn audio_list_devices_for_engine(engine: &AudioEngine) -> Vec<String> {
let mut list = enumerate_output_device_names();
if let Some(ref name) = *engine.selected_device.lock().unwrap() {
if !name.is_empty() && !output_enumeration_includes_pinned(&list, name) {
list.push(name.clone());
}
}
list
pub fn audio_list_devices_for_engine(engine: &AudioEngine) -> Vec<AudioOutputDeviceEntry> {
list_output_device_entries(engine)
}
/// Returns the names of all available audio output devices on the current host.
/// Returns the keys of all available audio output devices on the current host.
/// On Linux, ALSA probes unavailable backends (JACK, OSS, dmix) and prints errors to
/// stderr. We suppress fd 2 for the duration of enumeration to keep the terminal clean.
///
/// The user-pinned device name is appended when cpal omits it (e.g. HDMI busy while
/// The user-pinned device is appended when cpal omits it (e.g. HDMI busy while
/// streaming) so the Settings dropdown still matches `audioOutputDevice`.
#[tauri::command]
pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec<String> {
#[specta::specta]
pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec<AudioOutputDeviceEntry> {
audio_list_devices_for_engine(&state)
}
/// Device id string for the host default output (matches an entry from `audio_list_devices` when present).
#[tauri::command]
#[specta::specta]
pub fn audio_default_output_device_name() -> Option<String> {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
with_suppressed_alsa_stderr(|| {
let host = rodio::cpal::default_host();
host.default_output_device()
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()))
})
super::dev_io::effective_default_output_device_name()
}
/// Lightweight default query for EQ poll — skips full `output_devices()` scan (#996).
#[tauri::command]
#[specta::specta]
pub fn audio_default_output_device_name_for_poll() -> Option<String> {
super::dev_io::effective_default_output_device_name_for_poll()
}
/// Find a stored per-device EQ key that denotes the same sink as `candidate`
/// (exact or Linux ALSA logical match).
#[tauri::command]
#[specta::specta]
pub fn audio_match_stored_output_device_key(
candidate: String,
stored_keys: Vec<String>,
) -> Option<String> {
#[cfg(not(target_os = "linux"))]
{
return stored_keys
.into_iter()
.find(|k| output_devices_logically_same(k, &candidate));
}
#[cfg(target_os = "linux")]
{
let list = super::dev_io::enumerate_output_device_names();
stored_keys
.into_iter()
.find(|k| output_device_keys_equivalent(k, &candidate, &list))
}
}
/// Switch the audio output device. `device_name = null` → follow system default.
/// Reopens the stream immediately; frontend must restart playback via audio:device-changed.
#[tauri::command]
#[specta::specta]
pub async fn audio_set_device(
device_name: Option<String>,
state: State<'_, AudioEngine>,
@@ -161,6 +161,7 @@ pub(crate) async fn try_resume_after_device_change(
done_flag: done_flag.clone(),
fade_in_dur: std::time::Duration::from_millis(5),
hi_res_enabled,
resample_target_hz: 0,
duration_hint: snap.duration_secs,
},
&engine,
@@ -132,10 +132,7 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
tauri::async_runtime::spawn(async move {
let mut last_default: Option<String> = tauri::async_runtime::spawn_blocking(|| {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
rodio::cpal::default_host()
.default_output_device()
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()))
super::dev_io::effective_default_output_device_name_for_poll()
}).await.unwrap_or(None);
// macOS/Windows: consecutive polls where a pinned device is absent from cpal's list.
@@ -246,7 +243,6 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
// Suppress stderr on Unix to avoid ALSA probing noise (JACK, OSS, dmix).
let (current_default, available) = tauri::async_runtime::spawn_blocking(move || {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
#[cfg(unix)]
let _guard = unsafe {
struct StderrGuard(i32);
@@ -259,17 +255,9 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
libc::close(devnull);
StderrGuard(saved)
};
let host = rodio::cpal::default_host();
let default = host
.default_output_device()
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()));
let default = super::dev_io::effective_default_output_device_name_for_poll();
let available: Vec<String> = if need_full_enum {
host.output_devices()
.map(|iter| {
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
.collect()
})
.unwrap_or_default()
super::dev_io::enumerate_output_device_names()
} else {
Vec::new()
};
@@ -326,13 +314,57 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
continue;
}
last_default = current_default.clone();
let Some(new_name) = current_default else {
// Transient wpctl/cpal miss — keep last known default.
continue;
};
let Some(_new_name) = current_default else { continue };
if last_default.is_none() {
last_default = Some(new_name.clone());
continue;
}
// Linux/PipeWire: cpal default labels can drift while the physical sink
// is unchanged — compare via ALSA logical keys before reopening.
#[cfg(target_os = "linux")]
if let Some(ref prev) = last_default {
let prev_name = prev.clone();
let new_name_for_eq = new_name.clone();
let same_sink = tauri::async_runtime::spawn_blocking(move || {
let list = super::dev_io::enumerate_output_device_names();
super::dev_io::output_device_keys_equivalent(
&prev_name,
&new_name_for_eq,
&list,
)
})
.await
.unwrap_or(false);
if same_sink {
last_default = Some(new_name);
continue;
}
}
last_default = Some(new_name.clone());
// Debounce: give the OS time to finish configuring the new device.
tokio::time::sleep(Duration::from_millis(500)).await;
#[cfg(target_os = "linux")]
{
let stream_on_default = tauri::async_runtime::spawn_blocking(|| {
super::dev_io::linux_psysonic_stream_routes_to_default_sink()
})
.await
.unwrap_or(false);
if stream_on_default {
// PipeWire already moved playback — notify frontend (EQ sync) only.
app.emit("audio:device-changed", Option::<f64>::None).ok();
continue;
}
}
if !reopen_output_stream(&app, None, ReopenNotify::DeviceChanged).await {
crate::app_eprintln!("[psysonic] device-watcher: stream reopen timed out");
}
+84 -9
View File
@@ -4,6 +4,7 @@ use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant};
use rodio::Player;
use tauri::Manager;
use super::state::{ChainedInfo, PreloadedTrack, StreamCompletedSpill};
@@ -205,21 +206,23 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
// On systems where neither alias exists (pure ALSA, macOS, Windows),
// `find_by_name` returns None and we drop through to `default_output_device`
// unchanged — no regression.
let find_by_name = |name: &str| -> Option<_> {
host.output_devices().ok()?.find(|d| {
d.description()
.ok()
.map(|desc| desc.name().to_string())
.as_deref()
== Some(name)
let find_by_key = |key: &str| -> Option<_> {
super::dev_io::resolve_output_device(key).or_else(|| {
host.output_devices().ok()?.find(|d| {
d.description()
.ok()
.map(|desc| desc.name().to_string())
.as_deref()
== Some(key)
})
})
};
let device = device_name
.and_then(find_by_name)
.and_then(find_by_key)
.or_else(|| {
#[cfg(target_os = "linux")]
{ find_by_name("pipewire").or_else(|| find_by_name("pulse")) }
{ find_by_key("pipewire").or_else(|| find_by_key("pulse")) }
#[cfg(not(target_os = "linux"))]
{ None }
})
@@ -570,3 +573,75 @@ pub fn refresh_http_user_agent(state: &AudioEngine, ua: &str) {
*slot = client;
}
}
pub(crate) fn apply_playback_request_headers(
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_id: Option<&str>,
url: &str,
req: reqwest::RequestBuilder,
) -> reqwest::RequestBuilder {
psysonic_core::server_http::apply_optional_registry_headers(registry, server_id, url, req)
}
/// Custom HTTP headers for reverse-proxy gates — cloned into background download tasks.
#[derive(Clone, Default)]
pub(crate) struct PlaybackHttpHeaders {
registry: Option<Arc<psysonic_core::server_http::ServerHttpRegistry>>,
server_id: Option<String>,
}
impl PlaybackHttpHeaders {
pub fn from_app(app: &tauri::AppHandle, server_id: Option<&str>) -> Self {
Self {
registry: app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s)),
server_id: server_id.filter(|s| !s.is_empty()).map(str::to_string),
}
}
pub fn apply(&self, url: &str, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
apply_playback_request_headers(
self.registry.as_deref(),
self.server_id.as_deref(),
url,
req,
)
}
}
pub(crate) fn scoped_http_get(
state: &AudioEngine,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_id: Option<&str>,
url: &str,
) -> reqwest::RequestBuilder {
apply_playback_request_headers(
registry,
server_id,
url,
audio_http_client(state).get(url),
)
}
/// Resolve registry + server id for playback/preload HTTP GETs.
pub(crate) fn playback_scoped_get(
state: &AudioEngine,
app: &tauri::AppHandle,
url: &str,
server_id: Option<&str>,
) -> reqwest::RequestBuilder {
let registry = app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
let sid = server_id
.filter(|s| !s.is_empty())
.map(str::to_string)
.or_else(|| state.current_playback_server_id.lock().unwrap().clone());
scoped_http_get(
state,
registry.as_deref(),
sid.as_deref(),
url,
)
}
@@ -16,6 +16,7 @@ use crate::ipc::{
pub(crate) fn emit_partial_loudness_from_bytes(
app: &AppHandle,
url: &str,
server_id: Option<&str>,
bytes: &[u8],
target_lufs: f32,
pre_analysis_attenuation_db: f32,
@@ -63,6 +64,10 @@ pub(crate) fn emit_partial_loudness_from_bytes(
"analysis:loudness-partial",
PartialLoudnessPayload {
track_id: playback_identity(url),
server_index_key: {
let sid = crate::analysis_dispatch::resolve_server_id_for_app(app, server_id);
(!sid.is_empty()).then_some(sid)
},
gain_db,
target_lufs,
is_partial: true,
@@ -708,7 +713,10 @@ pub(crate) async fn fetch_data(
return Ok(Some(data));
}
let response = crate::engine::audio_http_client(state).get(url).send().await.map_err(|e| e.to_string())?;
let response = crate::engine::playback_scoped_get(state, app, url, None)
.send()
.await
.map_err(|e| e.to_string())?;
let status = response.status();
let ct = response.headers()
.get(reqwest::header::CONTENT_TYPE)
@@ -0,0 +1,349 @@
//! Hi-Res transition blend: resample to a user-chosen rate when crossfade,
//! AutoDJ, or gapless must cross a sample-rate boundary.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use rodio::Player;
use tauri::{AppHandle, State};
use super::engine::AudioEngine;
use super::playback_rate::raw_counter_samples_for_content_position;
use super::play_input::{url_format_hint, PlayInput};
use super::source_build::{build_playback_source_with_probe_fallback, BuildSourceArgs, PlaybackSource};
use super::stream::LocalFileSource;
const BLEND_44100: u32 = 44_100;
const BLEND_88200: u32 = 88_200;
const BLEND_96000: u32 = 96_000;
/// User-selected blend rate for hi-res transitions; `None` when inactive.
pub(crate) fn blend_rate_hz(
hi_res_enabled: bool,
transition_blend_active: bool,
hz: Option<u32>,
) -> Option<u32> {
if !hi_res_enabled || !transition_blend_active {
return None;
}
let raw = hz.unwrap_or(BLEND_44100);
match raw {
BLEND_44100 | BLEND_88200 | BLEND_96000 => Some(raw),
_ => Some(BLEND_44100),
}
}
pub(crate) struct OutgoingBlendSnapshot {
pub(crate) url: String,
pub(crate) position_secs: f64,
pub(crate) duration_secs: f64,
pub(crate) base_volume: f32,
pub(crate) gain_linear: f32,
pub(crate) outgoing_fade_secs: f32,
pub(crate) actual_fade_secs: f32,
pub(crate) analysis_track_id: Option<String>,
}
/// Capture the currently playing track before a hi-res blend stream reopen.
pub(crate) fn capture_outgoing_blend_snapshot(
state: &AudioEngine,
outgoing_fade_secs: f32,
actual_fade_secs: f32,
) -> Option<OutgoingBlendSnapshot> {
let url = state.current_playback_url.lock().unwrap().clone()?;
if url.is_empty() {
return None;
}
let (position_secs, duration_secs, base_volume, gain_linear, playing) = {
let cur = state.current.lock().unwrap();
let playing = cur.sink.is_some() && cur.paused_at.is_none();
(
cur.position(),
cur.duration_secs,
cur.base_volume,
cur.replay_gain_linear,
playing,
)
};
if !playing {
return None;
}
let analysis_track_id = state.current_analysis_track_id.lock().unwrap().clone();
Some(OutgoingBlendSnapshot {
url,
position_secs,
duration_secs,
base_volume,
gain_linear,
outgoing_fade_secs,
actual_fade_secs,
analysis_track_id,
})
}
/// Drop the live main sink so a stream reopen does not leave dangling players.
pub(crate) fn detach_current_sink_for_blend_reopen(state: &AudioEngine) {
let mut cur = state.current.lock().unwrap();
if let Some(old) = cur.sink.take() {
old.stop();
}
cur.fadeout_trigger = None;
cur.fadeout_samples = None;
}
fn resolve_cached_play_input(engine: &AudioEngine, url: &str) -> Option<PlayInput> {
if url.starts_with("psysonic-local://") {
let path = url.strip_prefix("psysonic-local://").unwrap_or(url);
let file = std::fs::File::open(path).ok()?;
let len = file.metadata().map(|m| m.len()).unwrap_or(0);
return Some(PlayInput::SeekableMedia {
reader: Box::new(LocalFileSource { file, len }),
format_hint: url_format_hint(url),
tag: "LocalFile[hi-res-blend]",
random_access: true,
mp4_probe_gate: None,
});
}
let ram_bytes = {
let guard = engine.stream_completed_cache.lock().unwrap();
guard
.as_ref()
.filter(|t| t.url == url)
.map(|t| t.data.clone())
};
let bytes = if let Some(b) = ram_bytes {
b
} else {
let spill_path = {
let guard = engine.stream_completed_spill.lock().unwrap();
guard
.as_ref()
.filter(|s| s.url == url)
.map(|s| s.path.clone())
};
let path = spill_path?;
std::fs::read(&path).ok()?
};
Some(PlayInput::Bytes(bytes))
}
/// Rebuild the outgoing track on `fading_out_sink` at `blend_rate` after reopen.
pub(crate) async fn spawn_outgoing_blend_resample(
app: &AppHandle,
state: &State<'_, AudioEngine>,
snap: &OutgoingBlendSnapshot,
blend_rate: u32,
gen: u64,
) -> Result<(), String> {
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
}
let play_input = resolve_cached_play_input(state, &snap.url).ok_or_else(|| {
format!(
"[hi-res-blend] outgoing track not cached for blend reopen: {}",
snap.url
)
})?;
let done_flag = Arc::new(AtomicBool::new(false));
let format_hint = url_format_hint(&snap.url);
let stream_format_suffix: Option<String> = snap
.url
.rsplit('.')
.next()
.and_then(|e| e.split('?').next())
.map(|s| s.to_lowercase());
let resume_server = super::helpers::current_playback_server_id_str(state);
let ps: PlaybackSource = build_playback_source_with_probe_fallback(
play_input,
BuildSourceArgs {
url: &snap.url,
gen,
cache_id_for_tasks: snap.analysis_track_id.as_deref(),
server_id: Some(resume_server.as_str()),
url_format_hint: format_hint.as_deref(),
stream_format_suffix: stream_format_suffix.as_deref(),
done_flag: done_flag.clone(),
fade_in_dur: Duration::from_millis(5),
hi_res_enabled: true,
resample_target_hz: blend_rate,
duration_hint: snap.duration_secs,
},
state,
app,
)
.await?;
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
}
let stream = super::engine::ensure_output_stream_open(state)?;
let sink = Arc::new(Player::connect_new(stream.mixer()));
let effective_volume = (snap.base_volume * snap.gain_linear).clamp(0.0, 1.0);
sink.set_volume(effective_volume);
sink.append(ps.built.source);
if ps.is_seekable && snap.position_secs > 0.05 {
let target = Duration::from_secs_f64(snap.position_secs.max(0.0));
sink.try_seek(target)
.map_err(|e| format!("[hi-res-blend] outgoing seek failed: {e}"))?;
}
let fade_secs = snap.outgoing_fade_secs;
if fade_secs > 0.0 {
let rate = blend_rate;
let ch = state.current_channels.load(Ordering::Relaxed).max(2);
let fade_total = (fade_secs as f64 * rate as f64 * ch as f64) as u64;
ps.built
.fadeout_samples
.store(fade_total.max(1), Ordering::SeqCst);
ps.built.fadeout_trigger.store(true, Ordering::SeqCst);
}
sink.play();
*state.fading_out_sink.lock().unwrap() = Some(sink);
let fo_arc = state.fading_out_sink.clone();
let cleanup_secs = snap.actual_fade_secs.max(snap.outgoing_fade_secs) + 0.5;
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs_f32(cleanup_secs)).await;
if let Some(s) = fo_arc.lock().unwrap().take() {
s.stop();
}
});
crate::app_deprintln!(
"[hi-res-blend] outgoing rebuilt at {blend_rate} Hz from {:.2}s (fade {:.2}s)",
snap.position_secs,
fade_secs
);
Ok(())
}
/// Rebuild the **current** track on a freshly opened blend-rate stream (gapless
/// chain realign) so the next source can append to the same sink.
pub(crate) async fn rebuild_current_track_at_blend_rate(
app: &AppHandle,
state: &State<'_, AudioEngine>,
snap: &OutgoingBlendSnapshot,
blend_rate: u32,
gen: u64,
) -> Result<(), String> {
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
}
let play_input = resolve_cached_play_input(state, &snap.url).ok_or_else(|| {
format!(
"[hi-res-blend] current track not cached for gapless realign: {}",
snap.url
)
})?;
let done_flag = Arc::new(AtomicBool::new(false));
let format_hint = url_format_hint(&snap.url);
let stream_format_suffix: Option<String> = snap
.url
.rsplit('.')
.next()
.and_then(|e| e.split('?').next())
.map(|s| s.to_lowercase());
let resume_server = super::helpers::current_playback_server_id_str(state);
let ps: PlaybackSource = build_playback_source_with_probe_fallback(
play_input,
BuildSourceArgs {
url: &snap.url,
gen,
cache_id_for_tasks: snap.analysis_track_id.as_deref(),
server_id: Some(resume_server.as_str()),
url_format_hint: format_hint.as_deref(),
stream_format_suffix: stream_format_suffix.as_deref(),
done_flag: done_flag.clone(),
fade_in_dur: Duration::from_millis(5),
hi_res_enabled: true,
resample_target_hz: blend_rate,
duration_hint: snap.duration_secs,
},
state,
app,
)
.await?;
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
}
state
.current_sample_rate
.store(ps.built.output_rate, Ordering::Relaxed);
state
.current_channels
.store(ps.built.output_channels as u32, Ordering::Relaxed);
let stream = super::engine::ensure_output_stream_open(state)?;
let sink = Arc::new(Player::connect_new(stream.mixer()));
let effective_volume = (snap.base_volume * snap.gain_linear).clamp(0.0, 1.0);
sink.set_volume(effective_volume);
sink.append(ps.built.source);
if ps.is_seekable && snap.position_secs > 0.05 {
let target = Duration::from_secs_f64(snap.position_secs.max(0.0));
sink.try_seek(target)
.map_err(|e| format!("[hi-res-blend] gapless realign seek failed: {e}"))?;
}
sink.play();
{
let mut cur = state.current.lock().unwrap();
cur.sink = Some(sink);
cur.duration_secs = ps.built.duration_secs;
cur.seek_offset = snap.position_secs;
cur.play_started = Some(Instant::now());
cur.paused_at = None;
cur.replay_gain_linear = snap.gain_linear;
cur.base_volume = snap.base_volume;
cur.fadeout_trigger = Some(ps.built.fadeout_trigger);
cur.fadeout_samples = Some(ps.built.fadeout_samples);
}
state.samples_played.store(
raw_counter_samples_for_content_position(
snap.position_secs,
ps.built.output_rate,
ps.built.output_channels as u32,
&state.playback_rate,
),
Ordering::Relaxed,
);
crate::app_deprintln!(
"[hi-res-blend] gapless realigned current track at {blend_rate} Hz from {:.2}s",
snap.position_secs
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blend_rate_inactive_without_hi_res_or_transition() {
assert_eq!(blend_rate_hz(false, true, Some(96_000)), None);
assert_eq!(blend_rate_hz(true, false, Some(96_000)), None);
}
#[test]
fn blend_rate_sanitizes_hz() {
assert_eq!(blend_rate_hz(true, true, None), Some(44_100));
assert_eq!(blend_rate_hz(true, true, Some(88_200)), Some(88_200));
assert_eq!(blend_rate_hz(true, true, Some(48_000)), Some(44_100));
}
}
@@ -7,6 +7,7 @@ use tauri::{AppHandle, Emitter};
#[serde(rename_all = "camelCase")]
pub(crate) struct PartialLoudnessPayload {
pub(crate) track_id: Option<String>,
pub(crate) server_index_key: Option<String>,
pub(crate) gain_db: f32,
pub(crate) target_lufs: f32,
pub(crate) is_partial: bool,
@@ -34,6 +34,7 @@ mod power_notify_win;
#[cfg(target_os = "linux")]
mod power_notify_linux;
mod helpers;
mod hi_res_blend;
mod ipc;
pub mod preview;
mod sources;
@@ -11,6 +11,7 @@ use super::helpers::*;
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
#[tauri::command]
#[specta::specta]
pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
let mut cur = state.current.lock().unwrap();
cur.base_volume = volume.clamp(0.0, 1.0);
@@ -22,6 +23,7 @@ pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
}
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)]
pub fn audio_update_replay_gain(
volume: f32,
@@ -132,6 +134,7 @@ pub fn audio_update_replay_gain(
}
#[tauri::command]
#[specta::specta]
pub fn audio_set_eq(gains: [f32; 10], enabled: bool, pre_gain: f32, state: State<'_, AudioEngine>) {
state.eq_enabled.store(enabled, Ordering::Relaxed);
state.eq_pre_gain.store(pre_gain.clamp(-30.0, 6.0).to_bits(), Ordering::Relaxed);
@@ -141,12 +144,14 @@ pub fn audio_set_eq(gains: [f32; 10], enabled: bool, pre_gain: f32, state: State
}
#[tauri::command]
#[specta::specta]
pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngine>) {
state.crossfade_enabled.store(enabled, Ordering::Relaxed);
state.crossfade_secs.store(secs.clamp(0.1, 12.0).to_bits(), Ordering::Relaxed);
}
#[tauri::command]
#[specta::specta]
pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
state.gapless_enabled.store(enabled, Ordering::Relaxed);
}
@@ -154,6 +159,7 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
/// Duck the current sink over `fade_secs` without exhausting its source (which
/// would spuriously emit `audio:ended` before the interrupt handoff).
#[tauri::command]
#[specta::specta]
pub fn audio_begin_outgoing_fade(fade_secs: f32, state: State<'_, AudioEngine>) {
let fade_secs = fade_secs.clamp(0.1, 12.0);
let cur = state.current.lock().unwrap();
@@ -173,6 +179,7 @@ pub fn audio_begin_outgoing_fade(fade_secs: f32, state: State<'_, AudioEngine>)
/// (only when the next track is actually playable). When `false`, the engine's
/// normal early crossfade trigger is restored (plain crossfade / loud→loud).
#[tauri::command]
#[specta::specta]
pub fn audio_set_autodj_suppress(enabled: bool, state: State<'_, AudioEngine>) {
state
.autodj_suppress_autocrossfade
@@ -180,6 +187,7 @@ pub fn audio_set_autodj_suppress(enabled: bool, state: State<'_, AudioEngine>) {
}
#[tauri::command]
#[specta::specta]
pub fn audio_set_playback_rate(
enabled: bool,
strategy: String,
@@ -263,6 +271,7 @@ pub fn audio_set_playback_rate(
}
#[tauri::command]
#[specta::specta]
pub fn audio_set_normalization(
engine: String,
target_lufs: f32,
@@ -15,7 +15,7 @@ use super::analysis_dispatch::{
prepare_playback_analysis, spawn_track_analysis_bytes, spawn_track_analysis_file,
TrackAnalysisOrigin,
};
use super::engine::{audio_http_client, AudioEngine};
use super::engine::{audio_http_client, AudioEngine, PlaybackHttpHeaders};
use super::helpers::{
content_type_to_hint, fetch_data, format_hint_from_content_disposition,
normalize_stream_suffix_for_hint, sniff_stream_format_extension,
@@ -217,7 +217,12 @@ async fn open_ranged_or_streaming_input(
state: &State<'_, AudioEngine>,
app: &AppHandle,
) -> Result<Option<PlayInput>, String> {
let response = audio_http_client(state).get(ctx.url).send().await.map_err(|e| e.to_string())?;
let http_headers = PlaybackHttpHeaders::from_app(app, ctx.server_id);
let response = http_headers
.apply(ctx.url, audio_http_client(state).get(ctx.url))
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
if state.generation.load(Ordering::SeqCst) != ctx.gen {
return Ok(None); // superseded
@@ -256,8 +261,8 @@ async fn open_ranged_or_streaming_input(
let last = total_u64
.saturating_sub(1)
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
if let Ok(pr) = audio_http_client(state)
.get(ctx.url)
if let Ok(pr) = http_headers
.apply(ctx.url, audio_http_client(state).get(ctx.url))
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
.send()
.await
@@ -328,6 +333,7 @@ async fn open_ranged_or_streaming_input(
state.loudness_pre_analysis_attenuation_db.clone(),
ctx.cache_id_for_tasks.map(|s| s.to_string()),
ctx.server_id.map(|s| s.to_string()),
http_headers.clone(),
loudness_hold_for_defer,
playback_armed,
stream_hint.clone(),
@@ -347,6 +353,7 @@ async fn open_ranged_or_streaming_input(
total,
state.generation.clone(),
ctx.gen,
http_headers.clone(),
)));
let reader = RangedHttpSource {
buf,
@@ -401,6 +408,7 @@ async fn open_ranged_or_streaming_input(
state.loudness_pre_analysis_attenuation_db.clone(),
ctx.cache_id_for_tasks.map(|s| s.to_string()),
ctx.server_id.map(|s| s.to_string()),
http_headers,
playback_armed,
));
@@ -16,7 +16,7 @@ use super::analysis_dispatch::{
dispatch_track_analysis_bytes, prepare_playback_analysis, spawn_track_analysis_file,
TrackAnalysisOrigin,
};
use super::engine::{audio_http_client, AudioEngine};
use super::engine::AudioEngine;
use super::helpers::{analysis_cache_track_id, same_playback_target};
use super::state::PreloadedTrack;
@@ -114,6 +114,7 @@ fn emit_preload_cancelled(app: &AppHandle, url: String, track_id: Option<String>
}
#[tauri::command]
#[specta::specta]
pub async fn audio_preload(
url: String,
duration_hint: f64,
@@ -197,7 +198,15 @@ pub async fn audio_preload(
}
}
let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?;
let response = crate::engine::playback_scoped_get(
&state,
&app,
&url,
server_id.as_deref(),
)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
emit_preload_cancelled(&app, url, track_id_for_events);
return Ok(());
+11 -5
View File
@@ -8,7 +8,7 @@ use rodio::Source;
use tauri::{AppHandle, Emitter, State};
use super::decode::SizedDecoder;
use super::engine::{audio_http_client, AudioEngine};
use super::engine::{audio_http_client, AudioEngine, PlaybackHttpHeaders};
use super::helpers::{
content_type_to_hint, format_hint_from_content_disposition, normalize_stream_suffix_for_hint,
resolve_playback_format_hint, sniff_stream_format_extension, STREAM_FORMAT_SNIFF_PROBE_BYTES,
@@ -155,9 +155,10 @@ async fn open_preview_decoder(
state: &AudioEngine,
app: &AppHandle,
) -> Result<Option<SizedDecoder>, String> {
let http_headers = PlaybackHttpHeaders::from_app(app, None);
let preview_http = preview_http_client(state);
let response = preview_http
.get(url)
let response = http_headers
.apply(url, preview_http.get(url))
.send()
.await
.map_err(|e| format!("preview: connection failed: {e}"))?
@@ -194,8 +195,8 @@ async fn open_preview_decoder(
let last = total_u64
.saturating_sub(1)
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
if let Ok(pr) = preview_http
.get(url)
if let Ok(pr) = http_headers
.apply(url, preview_http.get(url))
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
.send()
.await
@@ -257,6 +258,7 @@ async fn open_preview_decoder(
state.loudness_pre_analysis_attenuation_db.clone(),
None,
None,
http_headers.clone(),
None,
playback_armed,
stream_hint.clone(),
@@ -334,6 +336,7 @@ async fn open_preview_decoder(
}
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)] // Tauri IPC — args map 1:1 to the JS invoke payload.
pub async fn audio_preview_play(
id: String,
@@ -530,6 +533,7 @@ mod tests {
}
#[tauri::command]
#[specta::specta]
pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) {
preview_stop_inner(&app, &state, true);
}
@@ -540,6 +544,7 @@ pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) {
/// auto-resume main playback the moment the preview ends and the user perceives
/// the click as having no effect.
#[tauri::command]
#[specta::specta]
pub fn audio_preview_stop_silent(app: AppHandle, state: State<'_, AudioEngine>) {
preview_stop_inner(&app, &state, false);
}
@@ -550,6 +555,7 @@ pub fn audio_preview_stop_silent(app: AppHandle, state: State<'_, AudioEngine>)
/// start, so the engine just clamps and applies the master headroom. No-op
/// when no preview is active.
#[tauri::command]
#[specta::specta]
pub fn audio_preview_set_volume(volume: f32, state: State<'_, AudioEngine>) {
if let Some(sink) = state.preview_sink.lock().unwrap().as_ref() {
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
@@ -31,6 +31,7 @@ use super::stream::{
/// Emits `audio:playing` with `duration = 0.0` (sentinel for live stream)
/// and `radio:metadata` whenever the StreamTitle changes.
#[tauri::command]
#[specta::specta]
pub async fn audio_play_radio(
url: String,
volume: f32,
@@ -33,9 +33,20 @@ pub(crate) struct BuildSourceArgs<'a> {
pub done_flag: Arc<AtomicBool>,
pub fade_in_dur: Duration,
pub hi_res_enabled: bool,
/// When > 0, resample decoded audio to this Hz (hi-res crossfade / AutoDJ blend).
pub resample_target_hz: u32,
pub duration_hint: f64,
}
/// Decoder/output-shaping inputs shared by [`build_source_from_play_input`].
struct PlaybackSourceShape {
done_flag: Arc<AtomicBool>,
fade_in_dur: Duration,
hi_res_enabled: bool,
resample_target_hz: u32,
duration_hint: f64,
}
/// Output of `build_source_from_play_input`: the wrapped rodio source plus
/// whether the chosen source path is seekable (only the Streaming variant
/// is not).
@@ -183,6 +194,7 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
done_flag,
fade_in_dur,
hi_res_enabled,
resample_target_hz,
duration_hint,
} = args;
let media_hint = play_media_format_hint(&play_input);
@@ -196,15 +208,15 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
crate::app_deprintln!("[stream] playback format hint: {h}");
}
match build_source_from_play_input(
play_input,
state,
effective_hint.as_deref(),
done_flag.clone(),
let shape = PlaybackSourceShape {
done_flag: done_flag.clone(),
fade_in_dur,
hi_res_enabled,
resample_target_hz,
duration_hint,
)
};
match build_source_from_play_input(play_input, state, effective_hint.as_deref(), &shape)
.await
{
Ok(p) => Ok(p),
@@ -261,10 +273,7 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
PlayInput::Bytes(data.clone()),
state,
bytes_hint.as_deref(),
done_flag.clone(),
fade_in_dur,
hi_res_enabled,
duration_hint,
&shape,
)
.await
{
@@ -296,10 +305,13 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
PlayInput::Bytes(fresh),
state,
bytes_hint.as_deref(),
done_flag,
fade_in_dur,
hi_res_enabled,
duration_hint,
&PlaybackSourceShape {
done_flag,
fade_in_dur,
hi_res_enabled,
resample_target_hz,
duration_hint,
},
)
.await
}
@@ -317,29 +329,32 @@ async fn build_source_from_play_input(
play_input: PlayInput,
state: &State<'_, AudioEngine>,
format_hint: Option<&str>,
done_flag: Arc<AtomicBool>,
fade_in_dur: Duration,
hi_res_enabled: bool,
duration_hint: f64,
shape: &PlaybackSourceShape,
) -> Result<PlaybackSource, String> {
// Always 0 — no application-level resampling. Rodio handles conversion to
// the output device rate internally; we let every track play at its native rate.
let target_rate: u32 = 0;
let PlaybackSourceShape {
done_flag,
fade_in_dur,
hi_res_enabled,
resample_target_hz,
duration_hint,
} = shape;
// 0 = native rate; hi-res crossfade blend passes an explicit Hz.
let target_rate: u32 = *resample_target_hz;
let mut is_seekable = true;
let built = match play_input {
PlayInput::Bytes(data) => build_source(
data,
duration_hint,
*duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
state.eq_pre_gain.clone(),
state.playback_rate.clone(),
done_flag,
fade_in_dur,
done_flag.clone(),
*fade_in_dur,
state.samples_played.clone(),
target_rate,
format_hint,
hi_res_enabled,
*hi_res_enabled,
),
PlayInput::SeekableMedia {
reader,
@@ -361,13 +376,13 @@ async fn build_source_from_play_input(
.map_err(|e| e.to_string())??;
build_streaming_source(
decoder,
duration_hint,
*duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
state.eq_pre_gain.clone(),
state.playback_rate.clone(),
done_flag,
fade_in_dur,
done_flag.clone(),
*fade_in_dur,
state.samples_played.clone(),
target_rate,
None,
@@ -387,13 +402,13 @@ async fn build_source_from_play_input(
.map_err(|e| e.to_string())??;
build_streaming_source(
decoder,
duration_hint,
*duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
state.eq_pre_gain.clone(),
state.playback_rate.clone(),
done_flag,
fade_in_dur,
done_flag.clone(),
*fade_in_dur,
state.samples_played.clone(),
target_rate,
Some(state.stream_playback_armed.clone()),
@@ -21,6 +21,7 @@ use futures_util::StreamExt;
use symphonia::core::io::MediaSource;
use tauri::{AppHandle, Emitter};
use super::super::engine::PlaybackHttpHeaders;
use super::super::state::PreloadedTrack;
use super::{
RADIO_YIELD_MS, TRACK_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_RECONNECTS,
@@ -88,6 +89,7 @@ pub(crate) struct OnDemand {
/// Bumped after every completed (success or failure) fetch so the read loop
/// can reset its stall deadline while on-demand fetches make progress.
progress: AtomicU64,
http_headers: PlaybackHttpHeaders,
}
impl OnDemand {
@@ -100,6 +102,7 @@ impl OnDemand {
total_size: u64,
gen_arc: Arc<AtomicU64>,
gen: u64,
http_headers: PlaybackHttpHeaders,
) -> Self {
OnDemand {
http,
@@ -112,6 +115,7 @@ impl OnDemand {
filled: Mutex::new(Vec::new()),
inflight: Mutex::new(Vec::new()),
progress: AtomicU64::new(0),
http_headers,
}
}
@@ -154,6 +158,7 @@ impl OnDemand {
end_inclusive,
me.gen,
&me.gen_arc,
&me.http_headers,
)
.await;
if let Ok(written) = res {
@@ -367,6 +372,7 @@ pub(crate) async fn ranged_http_download_loop<F>(
downloaded_to: &Arc<AtomicUsize>,
gen: u64,
gen_arc: &Arc<AtomicU64>,
http_headers: &PlaybackHttpHeaders,
mut on_partial: F,
playback_armed: Option<&AtomicBool>,
) -> (usize, RangedHttpLoopOutcome)
@@ -387,6 +393,7 @@ where
if downloaded > 0 {
req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
}
req = http_headers.apply(url, req);
match req.send().await {
Ok(r) => r,
Err(err) => {
@@ -487,6 +494,7 @@ where
}
/// Fetch `bytes=start-end` into `buf[start..=end]` (inclusive HTTP Range).
#[allow(clippy::too_many_arguments)]
async fn ranged_write_http_range(
http_client: &reqwest::Client,
url: &str,
@@ -495,13 +503,18 @@ async fn ranged_write_http_range(
end_inclusive: u64,
gen: u64,
gen_arc: &Arc<AtomicU64>,
http_headers: &PlaybackHttpHeaders,
) -> Result<usize, ()> {
if gen_arc.load(Ordering::SeqCst) != gen {
return Err(());
}
let response = http_client
.get(url)
.header(reqwest::header::RANGE, format!("bytes={start}-{end_inclusive}"))
let response = http_headers
.apply(
url,
http_client
.get(url)
.header(reqwest::header::RANGE, format!("bytes={start}-{end_inclusive}")),
)
.send()
.await
.map_err(|_| ())?;
@@ -555,6 +568,7 @@ async fn ranged_prefetch_mp4_tail(
playback_armed: Arc<AtomicBool>,
gen: u64,
gen_arc: Arc<AtomicU64>,
http_headers: PlaybackHttpHeaders,
) {
const MIN_TAIL: u64 = 256 * 1024;
const MAX_TAIL: u64 = 8 * 1024 * 1024;
@@ -573,6 +587,7 @@ async fn ranged_prefetch_mp4_tail(
end_inclusive,
gen,
&gen_arc,
&http_headers,
)
.await
{
@@ -622,6 +637,7 @@ pub(crate) async fn ranged_download_task(
cache_track_id: Option<String>,
// Playback server scope for the analysis-cache write key (empty/`None` → legacy '').
server_id: Option<String>,
http_headers: PlaybackHttpHeaders,
// When `Some`, ranged playback seeds on completion — defer HTTP backfill for that
// track; `None` for large files where ranged skips seed (needs backfill).
loudness_seed_hold: Option<LoudnessSeedHold>,
@@ -650,6 +666,7 @@ pub(crate) async fn ranged_download_task(
let mut last_partial_loudness_emit = Instant::now() - Duration::from_secs(5);
let url_for_emit = url.clone();
let app_for_emit = app.clone();
let server_id_for_emit = resolve_server_id_for_app(&app, server_id.as_deref());
crate::app_deprintln!(
"[stream] ranged dl start: total={} KiB (~{:.2} MiB)",
@@ -689,6 +706,7 @@ pub(crate) async fn ranged_download_task(
"analysis:loudness-partial",
crate::ipc::PartialLoudnessPayload {
track_id: crate::helpers::playback_identity(&url_for_emit),
server_index_key: (!server_id_for_emit.is_empty()).then_some(server_id_for_emit.clone()),
gain_db: provisional_db,
target_lufs,
is_partial: true,
@@ -705,6 +723,7 @@ pub(crate) async fn ranged_download_task(
let tail_from_bg = tail_filled_from.clone();
let armed_bg = playback_armed.clone();
let gen_bg = gen_arc.clone();
let headers_bg = http_headers.clone();
Some(tokio::spawn(async move {
ranged_prefetch_mp4_tail(
client,
@@ -716,6 +735,7 @@ pub(crate) async fn ranged_download_task(
armed_bg,
gen,
gen_bg,
headers_bg,
)
.await;
}))
@@ -736,6 +756,7 @@ pub(crate) async fn ranged_download_task(
&downloaded_to,
gen,
&gen_arc,
&http_headers,
on_partial,
linear_arm,
)
@@ -1127,6 +1148,7 @@ mod tests {
&dl,
1,
&gen_arc,
&PlaybackHttpHeaders::default(),
|_, _| {},
None,
)
@@ -1162,6 +1184,7 @@ mod tests {
&dl,
1,
&gen_arc,
&PlaybackHttpHeaders::default(),
|downloaded, total| calls.lock().unwrap().push((downloaded, total)),
None,
)
@@ -1190,7 +1213,7 @@ mod tests {
let (buf, dl, gen_arc) = loop_state(1024);
let (downloaded, outcome) =
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
.await;
assert_eq!(outcome, RangedHttpLoopOutcome::Aborted);
@@ -1221,7 +1244,7 @@ mod tests {
gen_arc.store(99, Ordering::SeqCst);
let (downloaded, outcome) =
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
.await;
assert_eq!(outcome, RangedHttpLoopOutcome::Superseded);
@@ -1280,7 +1303,7 @@ mod tests {
let (buf, dl, gen_arc) = loop_state(body.len());
let (downloaded, outcome) =
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
.await;
// Stream finishes via a Range-resumed second request.
@@ -1354,6 +1377,7 @@ mod tests {
total as u64,
gen_arc.clone(),
1,
PlaybackHttpHeaders::default(),
)));
let mut src = RangedHttpSource {
buf,
@@ -1406,6 +1430,7 @@ mod tests {
2047,
1,
&gen_arc,
&PlaybackHttpHeaders::default(),
)
.await;
@@ -1440,7 +1465,7 @@ mod tests {
let (buf, dl, gen_arc) = loop_state(body.len());
let (downloaded, outcome) =
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
.await;
// Reconnect server returned 200 instead of 206 → Aborted, downloaded
@@ -15,6 +15,7 @@ use ringbuf::HeapProd;
use ringbuf::traits::Producer;
use tauri::AppHandle;
use super::super::engine::PlaybackHttpHeaders;
use super::super::state::PreloadedTrack;
use super::{
maybe_arm_stream_playback, TRACK_STREAM_MAX_RECONNECTS, TRACK_STREAM_PROMOTE_MAX_BYTES,
@@ -37,6 +38,7 @@ pub(crate) async fn track_download_task(
cache_track_id: Option<String>,
// Playback server scope for the analysis-cache write key (empty/`None` → legacy '').
server_id: Option<String>,
http_headers: PlaybackHttpHeaders,
playback_armed: Arc<AtomicBool>,
) {
let mut downloaded: u64 = 0;
@@ -53,6 +55,7 @@ pub(crate) async fn track_download_task(
if downloaded > 0 {
req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
}
req = http_headers.apply(&url, req);
match req.send().await {
Ok(r) => r,
Err(err) => {
@@ -147,7 +150,14 @@ pub(crate) async fn track_download_task(
loudness_pre_analysis_attenuation_db.load(Ordering::Relaxed),
)
.clamp(-24.0, 0.0);
crate::helpers::emit_partial_loudness_from_bytes(&app, &url, &capture, target_lufs, pre_db);
crate::helpers::emit_partial_loudness_from_bytes(
&app,
&url,
server_id.as_deref(),
&capture,
target_lufs,
pre_db,
);
}
}
offset += pushed;
@@ -18,6 +18,7 @@ use super::preview::preview_clear_for_new_main_playback;
use super::stream::{radio_download_task, RADIO_BUF_CAPACITY};
#[tauri::command]
#[specta::specta]
pub fn audio_pause(state: State<'_, AudioEngine>) {
let mut cur = state.current.lock().unwrap();
if let Some(sink) = &cur.sink {
@@ -49,6 +50,7 @@ pub fn audio_pause(state: State<'_, AudioEngine>) {
/// ring buffer is created, its consumer is sent to `AudioStreamReader` (which
/// swaps it in on the next `read()`), and a new download task is spawned.
#[tauri::command]
#[specta::specta]
pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Result<(), String> {
// If a preview is running, cancel it first — otherwise sink.play() on the
// main sink would mix on top of the preview sink.
@@ -112,6 +114,7 @@ pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Resu
}
#[tauri::command]
#[specta::specta]
pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) {
preview_clear_for_new_main_playback(&state, &app);
state.generation.fetch_add(1, Ordering::SeqCst);
@@ -135,6 +138,7 @@ pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) {
}
#[tauri::command]
#[specta::specta]
pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> {
let state = state.inner();
const AUDIO_SEEK_TIMEOUT_MS: u64 = 700;
@@ -8,7 +8,10 @@ publish = false
[dependencies]
tauri = { version = "2" }
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
reqwest = { version = "0.13", default-features = false, features = ["rustls"] }
url = "2"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
@@ -17,9 +17,10 @@
use std::path::{Path, PathBuf};
/// Written to `{cover_root}/.storage-layout` — mismatch triggers cache reset.
pub const LAYOUT_STAMP: &str = "canonical-segment-v4";
pub const LAYOUT_STAMP: &str = "canonical-segment-v5";
/// True for ids that are only valid as `getCoverArt` targets, not library entity keys.
/// Prefixes mirror Navidrome `model.Kind` (`pl`, `ra`, `dc`, `mf`, …) — not bare album hashes.
pub fn is_fetch_only_cover_id(id: &str) -> bool {
let id = id.trim();
id.starts_with("mf-")
@@ -110,6 +111,20 @@ pub fn resolve_album_cover(
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(album);
// Navidrome track-only libraries: keep consensus `mf-*` fetch (library picks the
// first track per album) while the disk slot stays album-scoped.
if !distinct_disc_covers && fetch.starts_with("mf-") && fetch != album {
return Some(CoverEntry {
cache_kind: "album",
cache_entity_id: album.to_string(),
fetch_cover_art_id: fetch.to_string(),
});
}
let fetch_id = if !distinct_disc_covers && fetch == album && !is_fetch_only_cover_id(fetch) {
format!("al-{album}_0")
} else {
fetch.to_string()
};
let cache_entity_id = if distinct_disc_covers && fetch != album {
fetch.to_string()
} else {
@@ -118,7 +133,7 @@ pub fn resolve_album_cover(
Some(CoverEntry {
cache_kind: "album",
cache_entity_id,
fetch_cover_art_id: fetch.to_string(),
fetch_cover_art_id: fetch_id,
})
}
@@ -271,6 +286,41 @@ mod tests {
assert_eq!(e.cache_entity_id, "mf-d2");
}
#[test]
fn resolve_album_keeps_mf_fetch_on_album_bucket() {
let e = resolve_album_cover("al-box", Some("mf-track"), false).unwrap();
assert_eq!(e.cache_entity_id, "al-box");
assert_eq!(e.fetch_cover_art_id, "mf-track");
}
#[test]
fn resolve_album_navidrome_bare_id() {
let e = resolve_album_cover("2lsdR1ogDKiFcAD6Pcvk4f", None, false).unwrap();
assert_eq!(e.fetch_cover_art_id, "al-2lsdR1ogDKiFcAD6Pcvk4f_0");
}
#[test]
fn resolve_album_playlist_cover_keeps_pl_prefix() {
let e = resolve_album_cover("pl-abc123", Some("pl-abc123"), false).unwrap();
assert_eq!(e.cache_entity_id, "pl-abc123");
assert_eq!(e.fetch_cover_art_id, "pl-abc123");
}
#[test]
fn resolve_album_playlist_cover_keeps_navidrome_pl_suffix() {
let id = "pl-18690de0-151b-4d86-81cb-f418a907315a_0";
let e = resolve_album_cover(id, Some(id), false).unwrap();
assert_eq!(e.cache_entity_id, id);
assert_eq!(e.fetch_cover_art_id, id);
}
#[test]
fn resolve_album_radio_cover_keeps_ra_prefix() {
let e = resolve_album_cover("ra-rd-1_0", Some("ra-rd-1_0"), false).unwrap();
assert_eq!(e.cache_entity_id, "ra-rd-1_0");
assert_eq!(e.fetch_cover_art_id, "ra-rd-1_0");
}
fn test_server_dir(label: &str) -> std::path::PathBuf {
let base = std::env::temp_dir().join(format!("psysonic-cover-layout-{label}"));
let _ = std::fs::remove_dir_all(&base);
@@ -4,6 +4,7 @@
//! macros) and the cross-crate port traits used to break dependency cycles
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
pub mod server_http;
pub mod cover_cache_layout;
pub mod log_sanitize;
pub mod media_layout;
@@ -8,12 +8,14 @@ const SENSITIVE_QUERY_KEYS: &[&str] = &[
const SENSITIVE_KV_KEYS: &[&str] = &[
"password", "passwd", "token", "secret", "api_key", "apikey", "access_token",
"refresh_token", "authorization", "auth",
"refresh_token", "authorization", "auth", "cookie", "x-api-key",
"cf-access-client-secret", "cf-access-client-id", "x-auth-token",
];
/// Sanitize one runtime log line for display and export.
pub fn sanitize_log_line(line: &str) -> String {
let mut out = redact_bearer_tokens(line);
out = redact_pangolin_headers(&out);
out = redact_sensitive_key_values(&out);
out = redact_urls_in_text(&out);
out
@@ -43,6 +45,37 @@ fn redact_bearer_tokens(line: &str) -> String {
s
}
fn redact_pangolin_headers(line: &str) -> String {
let lower = line.to_ascii_lowercase();
let mut out = line.to_string();
let mut search_from = 0;
while let Some(rel) = lower[search_from..].find("x-pangolin-") {
let idx = search_from + rel;
let after_prefix = &lower[idx..];
let Some(sep_rel) = after_prefix.find([':', '=']) else {
search_from = idx + 1;
continue;
};
let sep_idx = idx + sep_rel;
let val_start = sep_idx + 1;
let slice = &out[val_start..];
let trimmed = slice.trim_start();
let ws = slice.len().saturating_sub(trimmed.len());
let val_start = val_start + ws;
let end = trimmed
.find(|c: char| c.is_whitespace() || c == '&' || c == ',' || c == ';' || c == ')')
.unwrap_or(trimmed.len());
if end > 0 {
out.replace_range(val_start..val_start + end, "REDACTED");
}
search_from = val_start + "REDACTED".len();
if search_from >= out.len() {
break;
}
}
out
}
fn redact_sensitive_key_values(line: &str) -> String {
let mut out = line.to_string();
for key in SENSITIVE_KV_KEYS {
@@ -260,7 +293,10 @@ fn is_lan_ipv6(host: &str) -> bool {
false
}
fn is_lan_host(host: &str) -> bool {
/// Public: reused by other crates (e.g. `psysonic-integration`'s Discord
/// publish gate) wherever "is this host LAN/loopback, not safe to expose
/// externally" needs the same answer this log-redaction module already uses.
pub fn is_lan_host(host: &str) -> bool {
let stripped = host.trim().trim_matches(|c| c == '[' || c == ']');
let lower = stripped.to_ascii_lowercase();
if lower.is_empty() || lower == "localhost" || lower.ends_with(".local") {
@@ -368,6 +404,17 @@ mod tests {
assert!(!out.contains("user:pass"));
}
#[test]
fn redacts_reverse_proxy_gate_headers() {
let line = "req CF-Access-Client-Secret: gate-secret Authorization: Bearer tok123 x-pangolin-auth: pangolin-key";
let out = sanitize_log_line(line);
assert!(out.contains("CF-Access-Client-Secret: REDACTED"));
assert!(!out.contains("gate-secret"));
assert!(!out.contains("tok123"));
assert!(out.contains("x-pangolin-auth: REDACTED"));
assert!(!out.contains("pangolin-key"));
}
#[test]
fn stream_log_with_em_dash_does_not_panic() {
let line = "[stream] RangedHttpSource selected — total=15666KB, hint=Some(\"mp3\")";
+21 -1
View File
@@ -8,7 +8,7 @@
use std::collections::VecDeque;
use std::io::Write;
use std::sync::{Mutex, OnceLock};
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
@@ -19,6 +19,8 @@ pub enum LoggingMode {
}
static LOGGING_MODE: AtomicU8 = AtomicU8::new(LoggingMode::Normal as u8);
static PSYLAB_ALBUMS_BROWSE_TRACE: AtomicBool = AtomicBool::new(false);
static PSYLAB_ARTISTS_BROWSE_TRACE: AtomicBool = AtomicBool::new(false);
const LOG_BUFFER_MAX_LINES: usize = 20_000;
/// Monotonic sequence assigned to each appended line; lets the UI tail
@@ -100,6 +102,24 @@ pub fn should_log_debug() -> bool {
matches!(current_mode(), LoggingMode::Debug)
}
/// PsyLab → Toggles → Albums → Browse perf trace (frontend syncs via IPC).
pub fn set_psylab_albums_browse_trace(enabled: bool) {
PSYLAB_ALBUMS_BROWSE_TRACE.store(enabled, Ordering::Relaxed);
}
pub fn should_log_albums_browse_trace() -> bool {
should_log_debug() && PSYLAB_ALBUMS_BROWSE_TRACE.load(Ordering::Relaxed)
}
/// PsyLab → Toggles → Artists → Browse perf trace (frontend syncs via IPC).
pub fn set_psylab_artists_browse_trace(enabled: bool) {
PSYLAB_ARTISTS_BROWSE_TRACE.store(enabled, Ordering::Relaxed);
}
pub fn should_log_artists_browse_trace() -> bool {
should_log_debug() && PSYLAB_ARTISTS_BROWSE_TRACE.load(Ordering::Relaxed)
}
pub fn append_log_line(line: String) {
let line = crate::log_sanitize::sanitize_log_line_infallible(&line);
let seq = LOG_SEQ.fetch_add(1, Ordering::Relaxed) + 1;
@@ -0,0 +1,399 @@
//! Per-server custom HTTP headers for reverse-proxy gates (Pangolin, Cloudflare Access).
//! Registry is keyed by index key; app server UUID aliases resolve via `ref_to_key`.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use reqwest::RequestBuilder;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "lowercase")]
pub enum EndpointKind {
Local,
Public,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "lowercase")]
pub enum CustomHeadersApplyTo {
Local,
#[default]
Public,
Both,
}
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
pub struct ServerHttpEndpointWire {
pub url: String,
pub kind: EndpointKind,
}
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
pub struct CustomHeaderEntryWire {
pub name: String,
pub value: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
pub struct ServerHttpContextSyncWire {
#[serde(rename = "serverId")]
pub server_id: String,
#[serde(rename = "appServerId")]
pub app_server_id: String,
pub endpoints: Vec<ServerHttpEndpointWire>,
#[serde(rename = "customHeaders", default)]
pub custom_headers: Vec<CustomHeaderEntryWire>,
#[serde(rename = "customHeadersApplyTo", default)]
pub custom_headers_apply_to: Option<CustomHeadersApplyTo>,
}
#[derive(Clone, Debug)]
pub struct ServerHttpContext {
pub endpoints: Vec<(String, EndpointKind)>,
pub headers: Vec<(String, String)>,
pub apply_to: CustomHeadersApplyTo,
}
impl From<ServerHttpContextSyncWire> for ServerHttpContext {
fn from(w: ServerHttpContextSyncWire) -> Self {
Self {
endpoints: w
.endpoints
.into_iter()
.map(|e| (normalize_server_base_url(&e.url), e.kind))
.collect(),
headers: w
.custom_headers
.into_iter()
.map(|h| (h.name.trim().to_string(), h.value))
.filter(|(n, _)| !n.is_empty())
.collect(),
apply_to: w.custom_headers_apply_to.unwrap_or_default(),
}
}
}
fn normalize_server_base_url(raw: &str) -> String {
let trimmed = raw.trim().trim_end_matches('/');
if trimmed.is_empty() {
return String::new();
}
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
trimmed.to_string()
} else {
format!("http://{trimmed}")
}
}
/// Strip `/rest/…`, `/api/…`, `/auth/…`, and query from a full HTTP URL to match TS `requestBaseUrlFromHttpUrl`.
pub fn request_base_url_from_http_url(raw_url: &str) -> String {
let trimmed = raw_url.trim();
if trimmed.is_empty() {
return String::new();
}
let with_scheme = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
trimmed.to_string()
} else {
format!("http://{trimmed}")
};
let Ok(mut parsed) = url::Url::parse(&with_scheme) else {
return normalize_server_base_url(trimmed);
};
parsed.set_query(None);
parsed.set_fragment(None);
let mut path = parsed.path().to_string();
if let Some(idx) = path.find("/rest/") {
path.truncate(idx);
} else if path.ends_with("/rest") {
path.truncate(path.len().saturating_sub("/rest".len()));
} else {
for seg in ["/api/", "/auth/"] {
if let Some(idx) = path.find(seg) {
path.truncate(idx);
break;
}
}
}
while path.ends_with('/') && path.len() > 1 {
path.pop();
}
parsed.set_path(if path.is_empty() { "/" } else { &path });
let host = parsed.host_str().unwrap_or_default();
if host.is_empty() {
return normalize_server_base_url(trimmed);
}
let mut out = format!("{}://{}", parsed.scheme(), host);
if let Some(port) = parsed.port() {
out.push(':');
out.push_str(&port.to_string());
}
if !path.is_empty() && path != "/" {
out.push_str(&path);
}
normalize_server_base_url(&out)
}
pub fn headers_for_request_base_url(ctx: &ServerHttpContext, request_base_url: &str) -> HeaderMap {
let mut map = HeaderMap::new();
if ctx.headers.is_empty() {
return map;
}
let normalized = normalize_server_base_url(request_base_url);
let Some((_, kind)) = ctx.endpoints.iter().find(|(u, _)| *u == normalized) else {
return map;
};
let apply = match ctx.apply_to {
CustomHeadersApplyTo::Both => true,
CustomHeadersApplyTo::Public => *kind == EndpointKind::Public,
CustomHeadersApplyTo::Local => *kind == EndpointKind::Local,
};
if !apply {
return map;
}
for (name, value) in &ctx.headers {
let Ok(header_name) = HeaderName::from_bytes(name.as_bytes()) else {
continue;
};
let Ok(header_value) = HeaderValue::from_str(value) else {
continue;
};
map.insert(header_name, header_value);
}
map
}
pub fn apply_server_headers(
builder: RequestBuilder,
ctx: &ServerHttpContext,
request_base_url: &str,
) -> RequestBuilder {
let map = headers_for_request_base_url(ctx, request_base_url);
if map.is_empty() {
return builder;
}
builder.headers(map)
}
pub fn apply_server_headers_for_http_url(
builder: RequestBuilder,
ctx: &ServerHttpContext,
full_http_url: &str,
) -> RequestBuilder {
let base = request_base_url_from_http_url(full_http_url);
apply_server_headers(builder, ctx, &base)
}
#[derive(Default)]
pub struct ServerHttpRegistry {
contexts: Mutex<HashMap<String, Arc<ServerHttpContext>>>,
ref_to_key: Mutex<HashMap<String, String>>,
}
impl ServerHttpRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn sync(&self, wire: ServerHttpContextSyncWire) {
let index_key = wire.server_id.clone();
let app_id = wire.app_server_id.clone();
let ctx = Arc::new(ServerHttpContext::from(wire));
if ctx.headers.is_empty() {
self.remove(&index_key, &app_id);
return;
}
{
let mut contexts = self.contexts.lock().unwrap();
contexts.insert(index_key.clone(), Arc::clone(&ctx));
}
let mut refs = self.ref_to_key.lock().unwrap();
refs.insert(index_key.clone(), index_key.clone());
refs.insert(app_id, index_key);
}
pub fn sync_all(&self, entries: Vec<ServerHttpContextSyncWire>) {
let mut new_contexts = HashMap::new();
let mut new_refs = HashMap::new();
for wire in entries {
let index_key = wire.server_id.clone();
let app_id = wire.app_server_id.clone();
let ctx = Arc::new(ServerHttpContext::from(wire));
if ctx.headers.is_empty() {
continue;
}
new_contexts.insert(index_key.clone(), Arc::clone(&ctx));
new_refs.insert(index_key.clone(), index_key.clone());
new_refs.insert(app_id, index_key);
}
*self.contexts.lock().unwrap() = new_contexts;
*self.ref_to_key.lock().unwrap() = new_refs;
}
pub fn remove(&self, index_key: &str, app_server_id: &str) {
self.contexts.lock().unwrap().remove(index_key);
let mut refs = self.ref_to_key.lock().unwrap();
refs.remove(index_key);
refs.remove(app_server_id);
}
pub fn get(&self, index_key: &str) -> Option<Arc<ServerHttpContext>> {
self.contexts.lock().unwrap().get(index_key).cloned()
}
pub fn get_for_server_ref(&self, server_ref: &str) -> Option<Arc<ServerHttpContext>> {
if server_ref.is_empty() {
return None;
}
let key = {
let refs = self.ref_to_key.lock().unwrap();
refs.get(server_ref).cloned()
};
if let Some(k) = key {
return self.get(&k);
}
self.get(server_ref)
}
/// Fallback when only a server base URL is known (Navidrome invoke paths).
pub fn get_for_server_url(&self, server_url: &str) -> Option<Arc<ServerHttpContext>> {
let base = request_base_url_from_http_url(server_url);
if base.is_empty() {
return None;
}
let contexts = self.contexts.lock().unwrap();
for ctx in contexts.values() {
if ctx.endpoints.iter().any(|(u, _)| *u == base) {
return Some(Arc::clone(ctx));
}
}
None
}
/// Resolve a context by `server_ref` first, then fall back to matching the
/// request URL against the registered endpoints. The URL fallback is what
/// keeps gated servers working when a caller passes a stale/foreign ref
/// (e.g. the audio engine's playback server id vs. the index key): endpoint
/// matching only ever hits a registered gated server, and `apply_to` is
/// still enforced downstream, so non-gated servers are never touched.
pub fn resolve_context(
&self,
server_ref: Option<&str>,
full_http_url: &str,
) -> Option<Arc<ServerHttpContext>> {
if let Some(sid) = server_ref.filter(|s| !s.is_empty()) {
if let Some(ctx) = self.get_for_server_ref(sid) {
return Some(ctx);
}
}
self.get_for_server_url(full_http_url)
}
}
/// The single entry point for attaching a gated server's custom headers to any
/// native request. Resolves the context by `server_ref` first, then falls back
/// to matching the request URL against a registered gated endpoint; a non-gated
/// server (no match) leaves the builder untouched. Every raw-download call site
/// (streaming, cover art, analysis prefetch, Navidrome auth, offline transfer)
/// and `SubsonicClient::with_registry` funnel through this / `resolve_context`,
/// so gate-header behaviour lives in exactly one place.
pub fn apply_optional_registry_headers(
registry: Option<&ServerHttpRegistry>,
server_ref: Option<&str>,
full_http_url: &str,
builder: RequestBuilder,
) -> RequestBuilder {
if let Some(reg) = registry {
if let Some(ctx) = reg.resolve_context(server_ref, full_http_url) {
return apply_server_headers_for_http_url(builder, &ctx, full_http_url);
}
}
builder
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_base_url_strips_rest_and_query() {
let url = "https://music.example/rest/stream.view?id=1&u=x";
assert_eq!(
request_base_url_from_http_url(url),
"https://music.example"
);
}
#[test]
fn headers_apply_public_only_on_public_endpoint() {
let ctx = ServerHttpContext {
endpoints: vec![
("http://192.168.0.10".into(), EndpointKind::Local),
("https://music.example".into(), EndpointKind::Public),
],
headers: vec![("X-Gate".into(), "secret".into())],
apply_to: CustomHeadersApplyTo::Public,
};
let lan = headers_for_request_base_url(&ctx, "http://192.168.0.10");
assert!(lan.is_empty());
let pub_ = headers_for_request_base_url(&ctx, "https://music.example");
assert_eq!(pub_.get("X-Gate").map(|v| v.to_str().ok()), Some(Some("secret")));
}
#[test]
fn resolve_context_falls_back_to_url_when_ref_is_stale() {
// Registry keyed by index key with an app-id alias; endpoint is the gate.
let reg = ServerHttpRegistry::new();
reg.sync(ServerHttpContextSyncWire {
server_id: "127.0.0.1".into(),
app_server_id: "uuid-1".into(),
endpoints: vec![ServerHttpEndpointWire {
url: "http://127.0.0.1:8899".into(),
kind: EndpointKind::Local,
}],
custom_headers: vec![CustomHeaderEntryWire {
name: "X-Gate".into(),
value: "tok".into(),
}],
custom_headers_apply_to: Some(CustomHeadersApplyTo::Both),
});
let stream_url = "http://127.0.0.1:8899/rest/stream.view?id=42&u=x&t=y";
// The audio engine passes a playback server id that is neither the index
// key nor the app-id alias — it must still resolve via the request URL.
let ctx = reg
.resolve_context(Some("some-stale-playback-id"), stream_url)
.expect("stale ref must fall back to URL endpoint match");
let headers = headers_for_request_base_url(&ctx, "http://127.0.0.1:8899");
assert_eq!(headers.get("X-Gate").map(|v| v.to_str().ok()), Some(Some("tok")));
// A non-gated server URL never resolves — foreign servers stay untouched.
assert!(reg
.resolve_context(Some("some-stale-playback-id"), "https://other.example/rest/stream.view?id=1")
.is_none());
}
#[test]
fn registry_resolves_app_id_alias() {
let reg = ServerHttpRegistry::new();
reg.sync(ServerHttpContextSyncWire {
server_id: "music.example".into(),
app_server_id: "uuid-1".into(),
endpoints: vec![ServerHttpEndpointWire {
url: "https://music.example".into(),
kind: EndpointKind::Public,
}],
custom_headers: vec![CustomHeaderEntryWire {
name: "X-Gate".into(),
value: "tok".into(),
}],
custom_headers_apply_to: Some(CustomHeadersApplyTo::Public),
});
assert!(reg.get("music.example").is_some());
assert!(reg.get_for_server_ref("uuid-1").is_some());
assert!(reg.get("uuid-1").is_none());
}
}
@@ -10,6 +10,7 @@ publish = false
psysonic-core = { path = "../psysonic-core" }
tauri = { version = "2" }
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt", "time", "sync"] }
@@ -4,7 +4,7 @@
// `js_app_id` is the ID their own embeddable widget uses and is broadly accepted.
pub const BANDSINTOWN_APP_ID: &str = "js_app_id";
#[derive(serde::Serialize, Default)]
#[derive(serde::Serialize, Default, specta::Type)]
pub struct BandsintownEvent {
datetime: String, // ISO 8601 (e.g. "2026-04-23T20:30:00")
venue_name: String,
@@ -20,6 +20,7 @@ pub struct BandsintownEvent {
/// Returns an empty list on any failure (404, network, parse) — the UI
/// just hides the section in that case.
#[tauri::command]
#[specta::specta]
pub async fn fetch_bandsintown_events(artist_name: String) -> Result<Vec<BandsintownEvent>, String> {
let trimmed = artist_name.trim();
if trimmed.is_empty() {
@@ -19,6 +19,42 @@ use std::time::Instant;
const DISCORD_APP_ID: &str = "1489544859718258779";
/// Query-param keys that carry a replayable auth secret. Checked
/// case-insensitively; Subsonic's own keys (`u`/`t`/`s`) are lower-case but
/// the defensive variants guard against other backends / auth schemes.
const CREDENTIAL_PARAM_KEYS: &[&str] = &["u", "t", "s", "p", "apikey", "jwt", "token", "auth"];
/// Backstop gate: true when `url` is safe to publish to Discord as a
/// `large_image`. Discord's external image proxy re-exposes the source URL
/// to anyone viewing the presence, so this must reject anything credentialed
/// or LAN-scoped before it ever reaches `Assets::large_image` — regardless of
/// which frontend code path produced the URL (mirrors the sanitizer in
/// `src/cover/integrations/discord.ts`, but this is the layer a frontend
/// regression cannot bypass). The LAN/loopback check reuses
/// `psysonic_core::log_sanitize::is_lan_host`, the same host classification
/// already relied on for local-log redaction, rather than a second
/// hand-written copy.
fn is_publishable_image_url(url: &str) -> bool {
let Ok(parsed) = url::Url::parse(url) else {
return false;
};
if parsed.scheme() != "https" {
return false;
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return false;
}
if psysonic_core::log_sanitize::is_lan_host(parsed.host_str().unwrap_or("")) {
return false;
}
for (key, _) in parsed.query_pairs() {
if CREDENTIAL_PARAM_KEYS.contains(&key.to_lowercase().as_str()) {
return false;
}
}
true
}
/// Cache entry for iTunes artwork lookup (avoids repeated API calls for same album).
pub struct ArtworkCacheEntry {
pub url: String,
@@ -326,6 +362,7 @@ pub(crate) fn compute_discord_start_timestamp(elapsed_secs: f64, now_unix_secs:
/// user list (e.g. "🎵 Bohemian Rhapsody" instead of "🎵 Psysonic"). Default: "{title}".
/// Empty string falls back to the registered Discord application name.
/// Supported placeholders: {title}, {artist}, {album}
// NOT specta-collected: >10 total params exceed specta's SpectaFn arg cap. Stays hand-written on generate_handler!.
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn discord_update_presence(
@@ -367,6 +404,17 @@ pub async fn discord_update_presence(
None
};
// Backstop: reject any URL that isn't safe to publish, no matter which
// path above produced it. Falls back to the app icon on rejection.
let artwork_url = artwork_url.filter(|url| {
let ok = is_publishable_image_url(url);
if !ok {
#[cfg(debug_assertions)]
crate::app_eprintln!("[discord] rejected non-publishable artwork_url");
}
ok
});
let mut guard = state.client.lock().unwrap();
// (Re)connect lazily — handles the case where Discord starts after the app.
@@ -448,6 +496,7 @@ pub async fn discord_update_presence(
/// Clear the Discord Rich Presence activity (e.g. playback stopped).
#[tauri::command]
#[specta::specta]
pub fn discord_clear_presence(state: tauri::State<DiscordState>) -> Result<(), String> {
let mut guard = state.client.lock().unwrap();
if let Some(client) = guard.as_mut() {
@@ -608,6 +657,68 @@ mod tests {
assert_eq!(f.details, "Queen Bohemian Rhapsody");
}
// ── is_publishable_image_url ─────────────────────────────────────────────
#[test]
fn publishable_url_accepts_public_share_image_link() {
assert!(is_publishable_image_url(
"https://music.example.com/share/img/eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjEifQ.abc?size=600"
));
}
#[test]
fn publishable_url_accepts_itunes_artwork_link() {
assert!(is_publishable_image_url(
"https://is1-ssl.mzstatic.com/image/thumb/Music/600x600bb.jpg"
));
}
#[test]
fn publishable_url_rejects_credentialed_subsonic_cover_url() {
assert!(!is_publishable_image_url(
"https://music.example.com/rest/getCoverArt.view?id=al-1&u=alice&t=deadbeef&s=abc123"
));
}
#[test]
fn publishable_url_rejects_credentialed_url_regardless_of_key_case() {
assert!(!is_publishable_image_url(
"https://music.example.com/rest/getCoverArt.view?id=al-1&U=alice&T=deadbeef&S=abc123"
));
}
#[test]
fn publishable_url_rejects_non_https_scheme() {
assert!(!is_publishable_image_url(
"http://music.example.com/share/img/eyJhbGciOiJIUzI1NiJ9.abc"
));
}
#[test]
fn publishable_url_rejects_embedded_userinfo() {
assert!(!is_publishable_image_url(
"https://alice:secret@music.example.com/share/img/eyJhbGciOiJIUzI1NiJ9.abc"
));
}
#[test]
fn publishable_url_rejects_malformed_url() {
assert!(!is_publishable_image_url("not a url"));
}
#[test]
fn publishable_url_rejects_lan_host() {
assert!(!is_publishable_image_url(
"https://192.168.1.5/share/img/eyJhbGciOiJIUzI1NiJ9.abc"
));
}
#[test]
fn publishable_url_rejects_loopback_and_local_hosts() {
assert!(!is_publishable_image_url("https://localhost/share/img/abc"));
assert!(!is_publishable_image_url("https://music.local/share/img/abc"));
}
// ── compute_discord_start_timestamp ──────────────────────────────────────
#[test]
@@ -1,15 +1,31 @@
//! Auth + retry + HTTP client for Navidrome's native REST API.
//! Used by every other navidrome submodule for `/auth/*` and `/api/*` calls.
use psysonic_core::server_http::{apply_optional_registry_headers, ServerHttpRegistry};
/// Authenticate with Navidrome's own REST API and return a Bearer token.
pub async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> {
navidrome_token_with_registry(None, server_url, username, password).await
}
pub async fn navidrome_token_with_registry(
registry: Option<&ServerHttpRegistry>,
server_url: &str,
username: &str,
password: &str,
) -> Result<String, String> {
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/auth/login", server_url))
.json(&serde_json::json!({ "username": username, "password": password }))
.send()
.await
.map_err(|e| e.to_string())?;
let base = server_url.trim_end_matches('/');
let login_url = format!("{base}/auth/login");
let req = apply_optional_registry_headers(
registry,
None,
&login_url,
client
.post(&login_url)
.json(&serde_json::json!({ "username": username, "password": password })),
);
let resp = req.send().await.map_err(|e| e.to_string())?;
let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
data["token"]
.as_str()
@@ -17,8 +33,18 @@ pub async fn navidrome_token(server_url: &str, username: &str, password: &str) -
.ok_or_else(|| "Navidrome auth: no token in response".to_string())
}
/// Attach gate headers for Navidrome `/auth/*` and `/api/*` requests.
pub fn nd_apply_request(
registry: Option<&ServerHttpRegistry>,
server_ref: Option<&str>,
full_url: &str,
builder: reqwest::RequestBuilder,
) -> reqwest::RequestBuilder {
apply_optional_registry_headers(registry, server_ref, full_url, builder)
}
/// Payload returned by Navidrome's `/auth/login`.
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
pub struct NdLoginResult {
pub(super) token: String,
#[serde(rename = "userId")]
@@ -97,7 +123,10 @@ pub fn nd_http_client() -> reqwest::Client {
// the WebKit-side Subsonic calls end up negotiating most of the time
// on these setups.
reqwest::Client::builder()
.user_agent(format!("Psysonic/{} (Tauri)", env!("CARGO_PKG_VERSION")))
// Shared wire UA (the main WebView's User-Agent once the frontend reports
// it at startup) so Navidrome logs these native calls under the same
// client as the WebView instead of a second `[Psysonic]` session.
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.http1_only()
.pool_max_idle_per_host(0)
.max_tls_version(reqwest::tls::Version::TLS_1_2)
@@ -2,10 +2,17 @@
//! login (via `navidrome_token`) and then a multipart POST to the relevant
//! `/api/{playlist|radio|artist}/{id}/image` endpoint.
use super::client::navidrome_token;
use std::sync::Arc;
use psysonic_core::server_http::ServerHttpRegistry;
use tauri::State;
use super::client::{navidrome_token_with_registry, nd_apply_request, nd_http_client};
#[tauri::command]
#[specta::specta]
pub async fn upload_playlist_cover(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
playlist_id: String,
username: String,
@@ -13,26 +20,35 @@ pub async fn upload_playlist_cover(
file_bytes: Vec<u8>,
mime_type: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let reg = http_registry.as_ref();
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg")
.mime_str(&mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new()
.post(format!("{}/api/playlist/{}/image", server_url, playlist_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let url = format!("{}/api/playlist/{}/image", server_url, playlist_id);
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.post(&url)
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form),
)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
#[specta::specta]
pub async fn upload_radio_cover(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
radio_id: String,
username: String,
@@ -40,26 +56,35 @@ pub async fn upload_radio_cover(
file_bytes: Vec<u8>,
mime_type: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let reg = http_registry.as_ref();
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg")
.mime_str(&mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new()
.post(format!("{}/api/radio/{}/image", server_url, radio_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let url = format!("{}/api/radio/{}/image", server_url, radio_id);
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.post(&url)
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form),
)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
#[specta::specta]
pub async fn upload_artist_image(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
artist_id: String,
username: String,
@@ -67,40 +92,59 @@ pub async fn upload_artist_image(
file_bytes: Vec<u8>,
mime_type: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let reg = http_registry.as_ref();
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg")
.mime_str(&mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new()
.post(format!("{}/api/artist/{}/image", server_url, artist_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let url = format!("{}/api/artist/{}/image", server_url, artist_id);
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.post(&url)
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form),
)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
#[specta::specta]
pub async fn delete_radio_cover(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
radio_id: String,
username: String,
password: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let resp = reqwest::Client::new()
.delete(format!("{}/api/radio/{}/image", server_url, radio_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| e.to_string())?;
let reg = http_registry.as_ref();
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
let url = format!("{}/api/radio/{}/image", server_url, radio_id);
let resp = nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.delete(&url)
.header("X-ND-Authorization", format!("Bearer {}", token)),
)
.send()
.await
.map_err(|e| e.to_string())?;
// 404/503 = no image existed — treat as success
if !resp.status().is_success() && resp.status() != reqwest::StatusCode::NOT_FOUND && resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE {
if !resp.status().is_success()
&& resp.status() != reqwest::StatusCode::NOT_FOUND
&& resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE
{
resp.error_for_status().map_err(|e| e.to_string())?;
}
Ok(())
@@ -11,4 +11,4 @@ pub mod probe;
pub mod queries;
pub mod users;
pub use client::navidrome_token;
pub use client::{navidrome_token, navidrome_token_with_registry, nd_apply_request};
@@ -2,24 +2,42 @@
//! payload is forwarded as-is so the frontend can compose any rule the
//! Navidrome version supports without backend changes.
use super::client::{nd_err, nd_http_client, nd_retry};
use std::sync::Arc;
use psysonic_core::server_http::ServerHttpRegistry;
use tauri::State;
use super::client::{nd_apply_request, nd_err, nd_http_client, nd_retry};
/// GET `/api/playlist` — list playlists; pass `smart=true` to filter smart playlists.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn nd_list_playlists(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
smart: Option<bool>,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let base = format!("{}/api/playlist", server_url);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
let client = nd_http_client();
let mut req = client
.get(format!("{}/api/playlist", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token));
if let Some(s) = smart {
req = req.query(&[("smart", s)]);
let base = base.clone();
let auth = auth.clone();
async move {
let mut req = nd_apply_request(
Some(reg),
None,
&base,
nd_http_client()
.get(&base)
.header("X-ND-Authorization", auth),
);
if let Some(s) = smart {
req = req.query(&[("smart", s)]);
}
req.send().await
}
req.send()
})
.await?;
if !resp.status().is_success() {
@@ -29,18 +47,34 @@ pub async fn nd_list_playlists(
}
/// POST `/api/playlist` — create playlist (supports smart rules payload).
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn nd_create_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
body: serde_json::Value,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/playlist", server_url);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.post(format!("{}/api/playlist", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body)
let url = url.clone();
let auth = auth.clone();
let body = body.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.post(&url)
.header("X-ND-Authorization", auth)
.json(&body),
)
.send()
.await
}
})
.await?;
let status = resp.status();
@@ -52,19 +86,35 @@ pub async fn nd_create_playlist(
}
/// PUT `/api/playlist/{id}` — update playlist (supports smart rules payload).
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn nd_update_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
id: String,
body: serde_json::Value,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/playlist/{}", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.put(format!("{}/api/playlist/{}", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body)
let url = url.clone();
let auth = auth.clone();
let body = body.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.put(&url)
.header("X-ND-Authorization", auth)
.json(&body),
)
.send()
.await
}
})
.await?;
let status = resp.status();
@@ -76,17 +126,32 @@ pub async fn nd_update_playlist(
}
/// GET `/api/playlist/{id}` — get a single playlist (includes smart rules if available).
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn nd_get_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
id: String,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/playlist/{}", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.get(format!("{}/api/playlist/{}", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.get(&url)
.header("X-ND-Authorization", auth),
)
.send()
.await
}
})
.await?;
let status = resp.status();
@@ -99,16 +164,31 @@ pub async fn nd_get_playlist(
/// DELETE `/api/playlist/{id}` — delete playlist.
#[tauri::command]
#[specta::specta]
pub async fn nd_delete_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
id: String,
) -> Result<(), String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/playlist/{}", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.delete(format!("{}/api/playlist/{}", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.delete(&url)
.header("X-ND-Authorization", auth),
)
.send()
.await
}
})
.await?;
let status = resp.status();
@@ -6,7 +6,7 @@
//! endpoint? — so this stays a free function rather than a client
//! struct. The full `nd_list_songs`-style ingest loop lands with PR-3b.
use super::client::{nd_err, nd_http_client};
use super::client::{nd_apply_request, nd_err, nd_http_client};
/// Returns `Ok(true)` when `GET /api/song?_start=0&_end=1` answers with
/// a 2xx status, `Ok(false)` for 4xx (auth ok but endpoint missing or
@@ -16,15 +16,25 @@ use super::client::{nd_err, nd_http_client};
/// Spec §6.1 ties the result to the `NavidromeNativeBulk` capability
/// flag. Wider call into the actual ingest path (`nd_list_songs` port)
/// is PR-3b's job.
pub async fn native_bulk_available(server_url: &str, token: &str) -> Result<bool, String> {
pub async fn native_bulk_available(
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: Option<&str>,
server_url: &str,
token: &str,
) -> Result<bool, String> {
let client = nd_http_client();
let url = format!("{}/api/song?_start=0&_end=1", server_url.trim_end_matches('/'));
let resp = client
.get(url)
.header("X-ND-Authorization", format!("Bearer {token}"))
.send()
.await
.map_err(nd_err)?;
let resp = nd_apply_request(
registry,
server_ref,
&url,
client
.get(&url)
.header("X-ND-Authorization", format!("Bearer {token}")),
)
.send()
.await
.map_err(nd_err)?;
let status = resp.status();
if status.is_success() {
@@ -56,7 +66,7 @@ mod tests {
.mount(&server)
.await;
let ok = native_bulk_available(&server.uri(), "tok-123").await.unwrap();
let ok = native_bulk_available(None, None, &server.uri(), "tok-123").await.unwrap();
assert!(ok);
}
@@ -71,7 +81,7 @@ mod tests {
.mount(&server)
.await;
let ok = native_bulk_available(&server.uri(), "tok").await.unwrap();
let ok = native_bulk_available(None, None, &server.uri(), "tok").await.unwrap();
assert!(!ok);
}
@@ -84,7 +94,7 @@ mod tests {
.mount(&server)
.await;
let ok = native_bulk_available(&server.uri(), "bad").await.unwrap();
let ok = native_bulk_available(None, None, &server.uri(), "bad").await.unwrap();
assert!(!ok, "401 reads as `endpoint not available for this caller`");
}
@@ -97,7 +107,7 @@ mod tests {
.mount(&server)
.await;
let err = native_bulk_available(&server.uri(), "tok").await.unwrap_err();
let err = native_bulk_available(None, None, &server.uri(), "tok").await.unwrap_err();
assert!(err.contains("503"));
}
@@ -111,6 +121,6 @@ mod tests {
.await;
let with_slash = format!("{}/", server.uri());
assert!(native_bulk_available(&with_slash, "tok").await.unwrap());
assert!(native_bulk_available(None, None, &with_slash, "tok").await.unwrap());
}
}
@@ -2,13 +2,21 @@
//! incompletely: songs, role-filtered artist/album lists, libraries,
//! per-user library assignment, and absolute song path resolution.
use super::client::{navidrome_token, nd_err, nd_http_client, nd_retry};
use std::sync::Arc;
use psysonic_core::server_http::ServerHttpRegistry;
use tauri::State;
use super::client::{navidrome_token_with_registry, nd_apply_request, nd_err, nd_http_client, nd_retry};
/// GET `/api/song?_sort=...&_order=...&_start=...&_end=...` — paginated
/// song list. Pure async helper used by the library-side N1 ingest
/// loop (spec §6.3, PR-3*); also wrapped by the `#[tauri::command]`
/// variant below for existing frontend callers.
#[allow(clippy::too_many_arguments)]
pub async fn nd_list_songs_internal(
registry: Option<&ServerHttpRegistry>,
server_ref: Option<&str>,
server_url: &str,
token: &str,
sort: &str,
@@ -20,12 +28,24 @@ pub async fn nd_list_songs_internal(
"{}/api/song?_sort={}&_order={}&_start={}&_end={}",
server_url, sort, order, start, end
);
let auth = format!("Bearer {token}");
let resp = nd_retry(|| {
nd_http_client()
.get(&url)
.header("X-ND-Authorization", format!("Bearer {token}"))
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
registry,
server_ref,
&url,
nd_http_client()
.get(&url)
.header("X-ND-Authorization", auth),
)
.send()
}).await?;
.await
}
})
.await?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
@@ -34,8 +54,10 @@ pub async fn nd_list_songs_internal(
/// Tauri-visible variant — owned-String arguments to keep the IPC
/// surface unchanged for existing call sites in the WebView.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn nd_list_songs(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
sort: String,
@@ -43,7 +65,17 @@ pub async fn nd_list_songs(
start: u32,
end: u32,
) -> Result<serde_json::Value, String> {
nd_list_songs_internal(&server_url, &token, &sort, &order, start, end).await
nd_list_songs_internal(
Some(http_registry.as_ref()),
None,
&server_url,
&token,
&sort,
&order,
start,
end,
)
.await
}
/// Build the `_filters` JSON for native-API list calls. Optionally narrows the
@@ -67,9 +99,11 @@ fn nd_build_filters(seed: serde_json::Map<String, serde_json::Value>, library_id
/// — paginated list of artists that have at least one credit in the given role.
/// Navidrome 0.55.0+ (uses `library_artist.stats` JSON aggregate). Available to any
/// authenticated user. Returns raw JSON array.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_list_artists_by_role(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
role: String,
@@ -79,24 +113,42 @@ pub async fn nd_list_artists_by_role(
end: u32,
library_id: Option<String>,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let mut seed = serde_json::Map::new();
seed.insert("role".to_string(), serde_json::Value::String(role.clone()));
let filters = nd_build_filters(seed, library_id.as_deref());
let start_s = start.to_string();
let end_s = end.to_string();
let base = format!("{}/api/artist", server_url);
let resp = nd_retry(|| {
nd_http_client()
.get(format!("{}/api/artist", server_url))
.query(&[
("_filters", filters.as_str()),
("_sort", sort.as_str()),
("_order", order.as_str()),
("_start", start_s.as_str()),
("_end", end_s.as_str()),
])
.header("X-ND-Authorization", format!("Bearer {}", token))
let base = base.clone();
let filters = filters.clone();
let sort = sort.clone();
let order = order.clone();
let start_s = start_s.clone();
let end_s = end_s.clone();
let auth = format!("Bearer {}", token);
async move {
nd_apply_request(
Some(reg),
None,
&base,
nd_http_client()
.get(&base)
.query(&[
("_filters", filters.as_str()),
("_sort", sort.as_str()),
("_order", order.as_str()),
("_start", start_s.as_str()),
("_end", end_s.as_str()),
])
.header("X-ND-Authorization", auth),
)
.send()
}).await?;
.await
}
})
.await?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
@@ -108,9 +160,11 @@ pub async fn nd_list_artists_by_role(
/// Subsonic `getArtist.view` only walks AlbumArtist relations, so composer-only
/// (or conductor-only, lyricist-only, …) credits are unreachable there. Navidrome
/// generates `role_<role>_id` filters dynamically from `model.AllRoles`.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_list_albums_by_artist_role(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
artist_id: String,
@@ -121,25 +175,43 @@ pub async fn nd_list_albums_by_artist_role(
end: u32,
library_id: Option<String>,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let filter_key = format!("role_{}_id", role);
let mut seed = serde_json::Map::new();
seed.insert(filter_key, serde_json::Value::String(artist_id.clone()));
let filters = nd_build_filters(seed, library_id.as_deref());
let start_s = start.to_string();
let end_s = end.to_string();
let base = format!("{}/api/album", server_url);
let resp = nd_retry(|| {
nd_http_client()
.get(format!("{}/api/album", server_url))
.query(&[
("_filters", filters.as_str()),
("_sort", sort.as_str()),
("_order", order.as_str()),
("_start", start_s.as_str()),
("_end", end_s.as_str()),
])
.header("X-ND-Authorization", format!("Bearer {}", token))
let base = base.clone();
let filters = filters.clone();
let sort = sort.clone();
let order = order.clone();
let start_s = start_s.clone();
let end_s = end_s.clone();
let auth = format!("Bearer {}", token);
async move {
nd_apply_request(
Some(reg),
None,
&base,
nd_http_client()
.get(&base)
.query(&[
("_filters", filters.as_str()),
("_sort", sort.as_str()),
("_order", order.as_str()),
("_start", start_s.as_str()),
("_end", end_s.as_str()),
])
.header("X-ND-Authorization", auth),
)
.send()
}).await?;
.await
}
})
.await?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
@@ -147,17 +219,31 @@ pub async fn nd_list_albums_by_artist_role(
}
/// GET `/api/library` — list all libraries (admin only). Returns the raw JSON array.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn nd_list_libraries(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/library", server_url);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.get(format!("{}/api/library", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token))
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client().get(&url).header("X-ND-Authorization", auth),
)
.send()
}).await?;
.await
}
})
.await?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
@@ -167,20 +253,37 @@ pub async fn nd_list_libraries(
/// PUT `/api/user/{id}/library` — assign libraries to a non-admin user.
/// Admin users auto-receive all libraries; calling this for an admin returns HTTP 400.
#[tauri::command]
#[specta::specta]
pub async fn nd_set_user_libraries(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
id: String,
library_ids: Vec<i64>,
) -> Result<(), String> {
let reg = http_registry.as_ref();
let body = serde_json::json!({ "libraryIds": library_ids });
let url = format!("{}/api/user/{}/library", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.put(format!("{}/api/user/{}/library", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body)
let url = url.clone();
let auth = auth.clone();
let body = body.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.put(&url)
.header("X-ND-Authorization", auth)
.json(&body),
)
.send()
}).await?;
.await
}
})
.await?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
@@ -201,19 +304,33 @@ pub async fn nd_set_user_libraries(
/// Returns `Ok(None)` when the response has no `path` field — Navidrome can omit
/// it for non-admin users on some configurations.
#[tauri::command]
#[specta::specta]
pub async fn nd_get_song_path(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
username: String,
password: String,
id: String,
) -> Result<Option<String>, String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let reg = http_registry.as_ref();
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
let url = format!("{}/api/song/{}", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.get(&url)
.header("X-ND-Authorization", format!("Bearer {}", token))
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.get(&url)
.header("X-ND-Authorization", auth),
)
.send()
.await
}
})
.await?;
if !resp.status().is_success() {
@@ -2,22 +2,40 @@
//! `token` (obtained via `navidrome_login`); admin-only ones return 401/403
//! when the caller is not an admin.
use super::client::{nd_err, nd_http_client, nd_retry, NdLoginResult};
use std::sync::Arc;
use psysonic_core::server_http::ServerHttpRegistry;
use tauri::State;
use super::client::{nd_apply_request, nd_err, nd_http_client, nd_retry, NdLoginResult};
/// Log in to Navidrome's native REST API. Returns a Bearer token and whether the user is admin.
#[tauri::command]
#[specta::specta]
pub async fn navidrome_login(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
username: String,
password: String,
) -> Result<NdLoginResult, String> {
let reg = http_registry.as_ref();
let body = serde_json::json!({ "username": username, "password": password });
let login_url = format!("{}/auth/login", server_url.trim_end_matches('/'));
let resp = nd_retry(|| {
nd_http_client()
.post(format!("{}/auth/login", server_url))
.json(&body)
let login_url = login_url.clone();
let body = body.clone();
async move {
nd_apply_request(
Some(reg),
None,
&login_url,
nd_http_client().post(&login_url).json(&body),
)
.send()
}).await?;
.await
}
})
.await?;
if !resp.status().is_success() {
return Err(format!("Navidrome login failed: HTTP {}", resp.status()));
}
@@ -25,21 +43,41 @@ pub async fn navidrome_login(
let token = data["token"].as_str().ok_or("no token in response")?.to_string();
let user_id = data["id"].as_str().unwrap_or("").to_string();
let is_admin = data["isAdmin"].as_bool().unwrap_or(false);
Ok(NdLoginResult { token, user_id, is_admin })
Ok(NdLoginResult {
token,
user_id,
is_admin,
})
}
/// GET `/api/user` — admin only. Returns the raw JSON array verbatim so the frontend can pick fields.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn nd_list_users(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/user", server_url);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.get(format!("{}/api/user", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token))
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.get(&url)
.header("X-ND-Authorization", auth),
)
.send()
}).await?;
.await
}
})
.await?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
@@ -47,8 +85,11 @@ pub async fn nd_list_users(
}
/// POST `/api/user` — create a user.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_create_user(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
user_name: String,
@@ -57,6 +98,7 @@ pub async fn nd_create_user(
password: String,
is_admin: bool,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let body = serde_json::json!({
"userName": user_name,
"name": name,
@@ -64,13 +106,27 @@ pub async fn nd_create_user(
"password": password,
"isAdmin": is_admin,
});
let url = format!("{}/api/user", server_url);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.post(format!("{}/api/user", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body)
let url = url.clone();
let auth = auth.clone();
let body = body.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.post(&url)
.header("X-ND-Authorization", auth)
.json(&body),
)
.send()
}).await?;
.await
}
})
.await?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
@@ -80,9 +136,11 @@ pub async fn nd_create_user(
}
/// PUT `/api/user/{id}` — update a user. Pass an empty `password` to leave it unchanged.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_update_user(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
id: String,
@@ -92,6 +150,7 @@ pub async fn nd_update_user(
password: String,
is_admin: bool,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let mut body = serde_json::json!({
"id": id,
"userName": user_name,
@@ -102,13 +161,27 @@ pub async fn nd_update_user(
if !password.is_empty() {
body["password"] = serde_json::Value::String(password);
}
let url = format!("{}/api/user/{}", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.put(format!("{}/api/user/{}", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body)
let url = url.clone();
let auth = auth.clone();
let body = body.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.put(&url)
.header("X-ND-Authorization", auth)
.json(&body),
)
.send()
}).await?;
.await
}
})
.await?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
@@ -119,17 +192,33 @@ pub async fn nd_update_user(
/// DELETE `/api/user/{id}`.
#[tauri::command]
#[specta::specta]
pub async fn nd_delete_user(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
id: String,
) -> Result<(), String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/user/{}", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.delete(format!("{}/api/user/{}", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.delete(&url)
.header("X-ND-Authorization", auth),
)
.send()
}).await?;
.await
}
})
.await?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
@@ -3,6 +3,7 @@ use psysonic_core::user_agent::subsonic_wire_user_agent;
pub const RADIO_PAGE_SIZE: u32 = 25;
/// Search the radio-browser.info directory (needs User-Agent header — CORS would block WebView).
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn search_radio_browser(query: String, offset: u32) -> Result<Vec<serde_json::Value>, String> {
let client = reqwest::Client::new();
@@ -26,6 +27,7 @@ pub async fn search_radio_browser(query: String, offset: u32) -> Result<Vec<serd
}
/// Fetch top-voted stations from radio-browser.info for initial suggestions.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn get_top_radio_stations(offset: u32) -> Result<Vec<serde_json::Value>, String> {
let client = reqwest::Client::new();
@@ -46,6 +48,7 @@ pub async fn get_top_radio_stations(offset: u32) -> Result<Vec<serde_json::Value
/// Fetch arbitrary URL bytes (e.g. radio station favicon) through Rust to bypass CORS.
/// Returns (bytes, content_type).
#[tauri::command]
#[specta::specta]
pub async fn fetch_url_bytes(url: String) -> Result<(Vec<u8>, String), String> {
let client = reqwest::Client::builder()
.user_agent(subsonic_wire_user_agent())
@@ -75,6 +78,7 @@ pub async fn fetch_url_bytes(url: String) -> Result<(Vec<u8>, String), String> {
/// Fetch a JSON API endpoint through Rust to bypass CORS/WebView networking restrictions.
/// Returns the response body as a UTF-8 string for parsing on the JS side.
// NOT specta-collected: raw-JSON passthrough (FE JSON.parses the string) — kept hand-written on generate_handler! with the scrobbler/fetch family.
#[tauri::command]
pub async fn fetch_json_url(url: String) -> Result<String, String> {
let client = reqwest::Client::builder()
@@ -95,7 +99,7 @@ pub async fn fetch_json_url(url: String) -> Result<String, String> {
}
/// ICY metadata response returned to the frontend.
#[derive(serde::Serialize)]
#[derive(serde::Serialize, specta::Type)]
pub struct IcyMetadata {
/// The `StreamTitle` from the inline ICY metadata block in the stream (e.g. `"Artist - Title"`).
stream_title: Option<String>,
@@ -173,6 +177,7 @@ pub async fn resolve_playlist_url(client: &reqwest::Client, url: &str) -> Option
/// If `url` is a PLS or M3U playlist file it is resolved to the first direct
/// stream URL before the ICY request is made.
#[tauri::command]
#[specta::specta]
pub async fn fetch_icy_metadata(url: String) -> Result<IcyMetadata, String> {
use futures_util::StreamExt;
@@ -277,6 +282,7 @@ pub async fn fetch_icy_metadata(url: String) -> Result<IcyMetadata, String> {
/// Returns the original URL unchanged if it is not a recognised playlist format
/// or if the playlist cannot be fetched/parsed.
#[tauri::command]
#[specta::specta]
pub async fn resolve_stream_url(url: String) -> String {
let Ok(client) = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
@@ -309,6 +315,7 @@ fn provider_http_client() -> Result<reqwest::Client, String> {
/// `params` is a list of [key, value] pairs (method must be included). If `sign`
/// is true an `api_sig` is computed (MD5 of sorted params + secret). If `get` is
/// true a GET request is made, otherwise a form POST.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn audioscrobbler_request(
base_url: String,
@@ -371,6 +378,7 @@ pub async fn audioscrobbler_request(
///
/// `path` is appended to `base_url` (e.g. `/1/submit-listens`). When `json_body`
/// is present the request is a POST with that body; otherwise a GET.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn listenbrainz_request(
base_url: String,
@@ -410,6 +418,7 @@ pub async fn listenbrainz_request(
///
/// `path` is appended to `base_url`. When `json_body` is present the request is a
/// POST with that body; otherwise a GET with `query` pairs.
// NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn maloja_request(
base_url: String,
@@ -11,7 +11,8 @@ use serde::Deserialize;
use super::auth::SubsonicCredentials;
use super::error::{flatten_reqwest_error, SubsonicError};
use super::types::{Album, AlbumSummary, ArtistIndex, ScanStatus, SearchResult, ServerInfo, Song};
use super::types::{Album, AlbumSummary, ArtistIndex, MusicFolder, ScanStatus, SearchResult, ServerInfo, Song};
use psysonic_core::server_http::{apply_server_headers, ServerHttpContext};
/// Protocol level we advertise — pre-OpenSubsonic Subsonic baseline that
/// Navidrome and other servers in the wild support. OpenSubsonic
@@ -42,6 +43,7 @@ pub struct SubsonicClient {
base_url: String,
credentials: CredentialsMode,
http: reqwest::Client,
http_context: Option<ServerHttpContext>,
}
impl SubsonicClient {
@@ -75,9 +77,28 @@ impl SubsonicClient {
password: password.into(),
},
http,
http_context: None,
}
}
pub fn with_http_context(mut self, ctx: ServerHttpContext) -> Self {
self.http_context = Some(ctx);
self
}
/// Production helper — attach registry context when present for `server_ref`
/// (app server id or index key).
pub fn with_registry(
self,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: &str,
) -> Self {
registry
.and_then(|r| r.get_for_server_ref(server_ref))
.map(|ctx| self.clone().with_http_context((*ctx).clone()))
.unwrap_or(self)
}
/// Test-/cache-friendly constructor — re-uses the same
/// `SubsonicCredentials` triple on every call. Wiremock tests rely on
/// this for deterministic `s=` and `t=` query params; production code
@@ -95,6 +116,7 @@ impl SubsonicClient {
base_url: url,
credentials: CredentialsMode::Static(credentials),
http,
http_context: None,
}
}
@@ -165,6 +187,15 @@ impl SubsonicClient {
self.fetch("getArtists", &params, "artists").await
}
/// B2 — `getMusicFolders()`. Returns the server's music libraries /
/// folders. Used by the library-tagging pass to scope `getAlbumList2`
/// without re-ingesting tracks.
pub async fn get_music_folders(&self) -> Result<Vec<MusicFolder>, SubsonicError> {
let wrapped: MusicFoldersWrapper =
self.fetch("getMusicFolders", &[], "musicFolders").await?;
Ok(wrapped.music_folder)
}
/// B3a — `getAlbumList2(type, size, offset, musicFolderId?)`. Returns
/// just the album summaries; the caller follows up with `get_album`
/// per id to enumerate songs.
@@ -301,10 +332,56 @@ impl SubsonicClient {
let mut query: Vec<(&str, &str)> = auth.to_vec();
query.extend_from_slice(extra);
let resp = self
let mut req = self
.http
.get(format!("{}/rest/{method}.view", self.base_url))
.query(&query)
.query(&query);
if let Some(ctx) = &self.http_context {
req = apply_server_headers(req, ctx, &self.base_url);
}
let resp = req
.send()
.await
.map_err(|e| SubsonicError::Transport(flatten_reqwest_error(e)))?;
if !resp.status().is_success() {
return Err(SubsonicError::HttpStatus(resp.status()));
}
resp.text()
.await
.map_err(|e| SubsonicError::Transport(flatten_reqwest_error(e)))
}
/// Raw WebView-transport bridge: the caller (TypeScript) has already built
/// the *full* query — auth params (`u`/`t`/`s`/`v`/`c`/`f`) plus the
/// endpoint's own args — so this only attaches gate headers + UA and hands
/// the untouched response body back for the frontend to parse. It lets
/// gated servers (Cloudflare Access, Pangolin, …) reach every Subsonic
/// endpoint the WebView would otherwise call over `axios`, where a
/// non-safelisted header trips a CORS preflight the gate rejects.
///
/// `endpoint` is the REST path segment *including* `.view`
/// (e.g. `getAlbumList2.view`). `post_form` sends the params as an
/// `application/x-www-form-urlencoded` body (OpenSubsonic `formPost`, for
/// large multi-`id` calls) instead of a query string.
pub async fn send_raw(
&self,
endpoint: &str,
params: &[(String, String)],
post_form: bool,
) -> Result<String, SubsonicError> {
let url = format!("{}/rest/{endpoint}", self.base_url);
let mut req = if post_form {
self.http.post(&url).form(params)
} else {
self.http.get(&url).query(params)
};
if let Some(ctx) = &self.http_context {
req = apply_server_headers(req, ctx, &self.base_url);
}
let resp = req
.send()
.await
.map_err(|e| SubsonicError::Transport(flatten_reqwest_error(e)))?;
@@ -324,13 +401,36 @@ struct AlbumListWrapper {
album: Vec<AlbumSummary>,
}
#[derive(Deserialize)]
struct MusicFoldersWrapper {
#[serde(
rename = "musicFolder",
default,
deserialize_with = "crate::subsonic::types::de_music_folder_one_or_many"
)]
music_folder: Vec<MusicFolder>,
}
fn default_http_client() -> reqwest::Client {
reqwest::Client::builder()
.user_agent(format!("Psysonic/{} (Tauri)", env!("CARGO_PKG_VERSION")))
// Shared wire UA (aligned with the main WebView at startup) so native
// Subsonic calls share the WebView's client identity on the server
// instead of registering a separate `[Psysonic]` session.
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}
pub fn subsonic_client_with_registry(
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: &str,
base_url: impl Into<String>,
username: impl Into<String>,
password: impl Into<String>,
) -> SubsonicClient {
SubsonicClient::new(base_url, username, password).with_registry(registry, server_ref)
}
/// Validate the Subsonic envelope and return the raw `serde_json::Value`
/// at `body_key`. Maps `error.code = 70` to the dedicated `NotFound`
/// variant; surfaces every other failed status as `Api { code, message }`.
@@ -605,6 +705,126 @@ mod tests {
test_client(&server.uri()).ping().await.expect("ping must succeed");
}
#[tokio::test(flavor = "multi_thread")]
async fn ping_sends_custom_gate_header_via_http_context() {
// Backs the connect-probe fix (#1216): a per-server gate header
// (Cloudflare Access / Pangolin) must ride on the ping itself. The mock
// only answers when the header is present, so a passing ping proves the
// header was sent on the native request (no WebView CORS preflight).
use psysonic_core::server_http::{CustomHeadersApplyTo, EndpointKind};
use wiremock::matchers::header;
let server = MockServer::start().await;
Mock::given(wm_method("GET"))
.and(wm_path("/rest/ping.view"))
.and(header("CF-Access-Client-Secret", "gate-secret"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subsonic-response": { "status": "ok", "version": "1.16.1" }
})))
.mount(&server)
.await;
let ctx = ServerHttpContext {
endpoints: vec![(server.uri(), EndpointKind::Public)],
headers: vec![("CF-Access-Client-Secret".into(), "gate-secret".into())],
apply_to: CustomHeadersApplyTo::Public,
};
test_client(&server.uri())
.with_http_context(ctx)
.ping()
.await
.expect("ping must carry the gate header to the mounted matcher");
}
#[tokio::test(flavor = "multi_thread")]
async fn ping_without_context_misses_gate_matcher() {
// Same gated mock, but no header context: the request must NOT match, so
// the probe fails — confirming the header (not something else) is what
// unlocks the gated endpoint.
use wiremock::matchers::header;
let server = MockServer::start().await;
Mock::given(wm_method("GET"))
.and(wm_path("/rest/ping.view"))
.and(header("CF-Access-Client-Secret", "gate-secret"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subsonic-response": { "status": "ok" }
})))
.mount(&server)
.await;
let err = test_client(&server.uri()).ping().await.unwrap_err();
assert!(
matches!(err, SubsonicError::HttpStatus(_)),
"gated endpoint without the header should not match the mock (got {err:?})"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn send_raw_get_forwards_query_and_gate_header_and_returns_body() {
// WebView-transport bridge: the frontend passes the full query (auth +
// endpoint args) and the gate header rides via the http context. The
// mock only answers when both the caller's `type` param and the gate
// header are present, and the untouched JSON body is returned verbatim.
use psysonic_core::server_http::{CustomHeadersApplyTo, EndpointKind};
use wiremock::matchers::header;
let server = MockServer::start().await;
Mock::given(wm_method("GET"))
.and(wm_path("/rest/getAlbumList2.view"))
.and(query_param("type", "newest"))
.and(query_param("u", "user"))
.and(header("CF-Access-Client-Secret", "gate-secret"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subsonic-response": { "status": "ok", "albumList2": { "album": [] } }
})))
.mount(&server)
.await;
let ctx = ServerHttpContext {
endpoints: vec![(server.uri(), EndpointKind::Public)],
headers: vec![("CF-Access-Client-Secret".into(), "gate-secret".into())],
apply_to: CustomHeadersApplyTo::Public,
};
let params = vec![
("u".to_string(), "user".to_string()),
("type".to_string(), "newest".to_string()),
];
let body = test_client(&server.uri())
.with_http_context(ctx)
.send_raw("getAlbumList2.view", &params, false)
.await
.expect("gated raw GET must reach the mounted matcher");
assert!(body.contains("albumList2"), "raw body returned verbatim: {body}");
}
#[tokio::test(flavor = "multi_thread")]
async fn send_raw_post_form_sends_params_in_body() {
// OpenSubsonic `formPost` path for large multi-`id` calls: params ride in
// the urlencoded body, not the query string.
use wiremock::matchers::body_string_contains;
let server = MockServer::start().await;
Mock::given(wm_method("POST"))
.and(wm_path("/rest/savePlayQueue.view"))
.and(body_string_contains("id=track-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subsonic-response": { "status": "ok" }
})))
.mount(&server)
.await;
let params = vec![
("u".to_string(), "user".to_string()),
("id".to_string(), "track-1".to_string()),
];
let body = test_client(&server.uri())
.send_raw("savePlayQueue.view", &params, true)
.await
.expect("form-post raw request must match the body matcher");
assert!(body.contains("\"status\": \"ok\"") || body.contains("\"status\":\"ok\""));
}
#[tokio::test(flavor = "multi_thread")]
async fn ping_surfaces_wrong_credentials_as_code_40() {
let server = MockServer::start().await;
@@ -777,6 +997,54 @@ mod tests {
assert_eq!(albums[1].id, "al_2");
}
#[tokio::test(flavor = "multi_thread")]
async fn get_music_folders_handles_array_and_numeric_ids() {
let server = MockServer::start().await;
Mock::given(wm_method("GET"))
.and(wm_path("/rest/getMusicFolders.view"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subsonic-response": {
"status": "ok",
"musicFolders": {
"musicFolder": [
{ "id": 1, "name": "Music Library" },
{ "id": "2", "name": "Podcasts" }
]
}
}
})))
.mount(&server)
.await;
let folders = test_client(&server.uri()).get_music_folders().await.unwrap();
assert_eq!(folders.len(), 2);
assert_eq!(folders[0].id, "1");
assert_eq!(folders[0].name, "Music Library");
assert_eq!(folders[1].id, "2");
}
#[tokio::test(flavor = "multi_thread")]
async fn get_music_folders_handles_single_object() {
let server = MockServer::start().await;
Mock::given(wm_method("GET"))
.and(wm_path("/rest/getMusicFolders.view"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"subsonic-response": {
"status": "ok",
"musicFolders": {
"musicFolder": { "id": 3, "name": "Only" }
}
}
})))
.mount(&server)
.await;
let folders = test_client(&server.uri()).get_music_folders().await.unwrap();
assert_eq!(folders.len(), 1);
assert_eq!(folders[0].id, "3");
assert_eq!(folders[0].name, "Only");
}
#[tokio::test(flavor = "multi_thread")]
async fn get_album_includes_song_list() {
let server = MockServer::start().await;
@@ -10,11 +10,12 @@ pub mod types;
pub use auth::SubsonicCredentials;
pub use client::{
fingerprint_sample, SubsonicClient, SUBSONIC_API_VERSION, SUBSONIC_CLIENT_ID,
fingerprint_sample, subsonic_client_with_registry, SubsonicClient, SUBSONIC_API_VERSION,
SUBSONIC_CLIENT_ID,
};
pub use stream_url::{build_stream_view_url, rest_base_from_url};
pub use error::SubsonicError;
pub use types::{
Album, AlbumSummary, ArtistIndex, ArtistRef, IndexBucket, ScanStatus, SearchResult, ServerInfo,
Song,
Album, AlbumSummary, ArtistIndex, ArtistRef, IndexBucket, MusicFolder, ScanStatus,
SearchResult, ServerInfo, Song,
};
@@ -37,6 +37,41 @@ where
})
}
/// Required id field — Subsonic/Navidrome may send string or number.
pub(crate) fn de_id_string_or_number<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
match value {
serde_json::Value::String(s) => Ok(s),
serde_json::Value::Number(n) => Ok(n.to_string()),
other => Err(serde::de::Error::custom(format!(
"expected string or number for id, got {other}"
))),
}
}
/// `musicFolder` may be a single object or an array on the wire.
pub(crate) fn de_music_folder_one_or_many<'de, D>(
deserializer: D,
) -> Result<Vec<MusicFolder>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
match value {
None => Ok(Vec::new()),
Some(serde_json::Value::Array(arr)) => arr
.into_iter()
.map(|v| serde_json::from_value(v).map_err(serde::de::Error::custom))
.collect(),
Some(obj) => serde_json::from_value(obj)
.map(|one: MusicFolder| vec![one])
.map_err(serde::de::Error::custom),
}
}
/// First usable value in a multi-valued array: a string element, or an
/// object element's `name` (the OpenSubsonic `[{ "name": … }]` shape).
fn first_tag_value(arr: &[serde_json::Value]) -> Option<String> {
@@ -116,6 +151,15 @@ pub struct ArtistRef {
pub cover_art: Option<String>,
}
/// `#getMusicFolders` — top-level music libraries / folders on the server.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct MusicFolder {
#[serde(deserialize_with = "de_id_string_or_number")]
pub id: String,
#[serde(default)]
pub name: String,
}
/// `#getAlbumList2` — page of album summaries (no song list).
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct AlbumSummary {
+2 -1
View File
@@ -11,9 +11,10 @@ psysonic-core = { path = "../psysonic-core" }
psysonic-integration = { path = "../psysonic-integration" }
tauri = { version = "2" }
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.40", features = ["bundled"] }
rusqlite = { version = "0.40", features = ["bundled", "functions"] }
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls", "gzip", "brotli"] }
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "macros", "time"] }
@@ -0,0 +1,16 @@
-- External artist artwork lookup (fanart.tv etc.) — image-scraper design-review §12.
-- Render NEVER reads this table; only the on-demand cover ensure path + the
-- negative cache (`mbid_ambiguous` 24h backoff) use it. `server_id` is the
-- serverIndexKey (same key as coverStorageKey / the on-disk cover path, §27),
-- NOT the auth-profile UUID.
CREATE TABLE IF NOT EXISTS artist_artwork_lookup (
server_id TEXT NOT NULL,
artist_id TEXT NOT NULL,
surface_kind TEXT NOT NULL, -- 'fanart' (| 'thumb' later)
mbid TEXT, -- nullable; from tag or MusicBrainz
mbid_source TEXT, -- 'tag' | 'musicbrainz' | NULL
status TEXT NOT NULL, -- pending|hit|miss|skipped|no_mbid|mbid_ambiguous|error
provider TEXT, -- hit source (e.g. 'fanart'); NULL for miss/skipped
updated_at INTEGER NOT NULL, -- unix ms
PRIMARY KEY (server_id, artist_id, surface_kind)
);
@@ -0,0 +1,6 @@
-- Artist browse sort key (Navidrome OrderArtistName parity) + server ignoredArticles watermark.
-- Applied idempotently from store.rs `apply_migration_14` (per-column guard) so a
-- partial apply recovers; keep this file in sync as the canonical DDL.
ALTER TABLE artist ADD COLUMN name_sort TEXT;
ALTER TABLE sync_state ADD COLUMN ignored_articles TEXT;
CREATE INDEX IF NOT EXISTS idx_artist_name_sort ON artist(server_id, name_sort);
@@ -0,0 +1,2 @@
-- ReplayGain track peak for anti-clipping bind (OpenSubsonic replayGain.trackPeak).
ALTER TABLE track ADD COLUMN replay_gain_peak REAL;
@@ -0,0 +1,16 @@
-- Layer-1 scoped browse indexes: (server_id, library_id, …) for sargable IN filters.
CREATE INDEX IF NOT EXISTS idx_track_library_album
ON track(server_id, library_id, album_id)
WHERE deleted = 0;
CREATE INDEX IF NOT EXISTS idx_track_library_artist
ON track(server_id, library_id, artist_id)
WHERE deleted = 0;
CREATE INDEX IF NOT EXISTS idx_track_library_title
ON track(server_id, library_id, title COLLATE NOCASE)
WHERE deleted = 0;
CREATE INDEX IF NOT EXISTS idx_track_library_genre
ON track(server_id, library_id, genre)
WHERE deleted = 0;
@@ -0,0 +1,7 @@
-- Per-server library tagging pass state (post-sync album-membership tagging).
CREATE TABLE IF NOT EXISTS library_tag_state (
server_id TEXT PRIMARY KEY,
folders_hash TEXT NOT NULL,
last_untagged_count INTEGER NOT NULL DEFAULT 0,
completed_at INTEGER NOT NULL
);
@@ -0,0 +1,5 @@
-- Speeds up the orphan-artist prune's freshness check: the prune keeps the
-- freshest getArtists pass per server (MAX(synced_at)) and deletes stale rows
-- below it. Without this index that lookup scans every artist row for the
-- server on each sync; the composite index turns it into a seek + range.
CREATE INDEX IF NOT EXISTS idx_artist_synced ON artist(server_id, synced_at);
@@ -0,0 +1,27 @@
-- Candidate-first New Releases: seek one selected library in descending
-- server creation order, with album/track ids available for stable ties.
CREATE INDEX IF NOT EXISTS idx_track_library_created_album
ON track(server_id, library_id, server_created_at DESC, album_id, id)
WHERE deleted = 0
AND server_created_at IS NOT NULL
AND album_id IS NOT NULL
AND album_id != '';
-- Owner-scoped ratings for tracks, albums, and artists. The composite primary
-- key covers the batch cache lookups, so a secondary index is unnecessary.
CREATE TABLE IF NOT EXISTS entity_user_rating (
server_id TEXT NOT NULL,
entity_kind TEXT NOT NULL CHECK (entity_kind IN ('track', 'album', 'artist')),
entity_id TEXT NOT NULL,
rating INTEGER NOT NULL,
fetched_at INTEGER NOT NULL,
PRIMARY KEY (server_id, entity_kind, entity_id)
);
-- Suffix-selective candidate index for local Lossless Albums browse.
CREATE INDEX IF NOT EXISTS idx_track_lossless_album_browse
ON track(server_id, suffix COLLATE NOCASE, library_id, album_id)
WHERE deleted = 0
AND album_id IS NOT NULL
AND album_id != ''
AND suffix IS NOT NULL;
@@ -0,0 +1,33 @@
-- Materialized per-library album rows for candidate-first scoped browse. The
-- track table remains authoritative; this table avoids GROUP BY track on every
-- All Albums page request.
CREATE TABLE IF NOT EXISTS album_browse_projection (
server_id TEXT NOT NULL,
library_id TEXT NOT NULL,
album_id TEXT NOT NULL,
identity_key TEXT,
name TEXT NOT NULL,
artist TEXT,
artist_id TEXT,
song_count INTEGER NOT NULL,
duration_sec INTEGER NOT NULL,
year INTEGER,
genre TEXT,
cover_art_id TEXT,
starred_at INTEGER,
synced_at INTEGER NOT NULL,
representative_track_id TEXT NOT NULL,
PRIMARY KEY (server_id, library_id, album_id)
);
CREATE INDEX IF NOT EXISTS idx_album_browse_projection_name
ON album_browse_projection(server_id, library_id, name COLLATE NOCASE, album_id);
CREATE INDEX IF NOT EXISTS idx_album_browse_projection_artist
ON album_browse_projection(server_id, library_id, artist COLLATE NOCASE, name COLLATE NOCASE, album_id);
CREATE INDEX IF NOT EXISTS idx_album_browse_projection_artist_year
ON album_browse_projection(server_id, library_id, artist COLLATE NOCASE, year, name COLLATE NOCASE, album_id);
CREATE INDEX IF NOT EXISTS idx_album_browse_projection_identity
ON album_browse_projection(server_id, library_id, identity_key);

Some files were not shown because too many files have changed in this diff Show More