Compare commits

..

41 Commits

Author SHA1 Message Date
Psychotoxical f133492e5c fix(settings): surface a specific reason when adding a server fails
Adding a server could fail silently: the form closed before the connect-ping
ran, and a failed ping only set an invisible temp status — no toast, no log. A
reachable-but-rejected server (wrong credentials, protocol version, …) or a
network/TLS problem both vanished with no feedback.

pingWithCredentials now returns a structured failure (auth / version / network
/ server, with the server's own error code + message). The add flow keeps the
form open on failure and shows a specific toast: the server's Subsonic message
for a rejection, or an actionable 'couldn't reach the server — check address,
network, and TLS certificate' for a network/TLS error. Both paths also log.
2026-06-10 00:01:40 +02:00
Psychotoxical a8bed6cc91 refactor(subsonic): centralize the REST protocol version in one constant
The Subsonic protocol version (v=1.16.1) was hardcoded across six call sites.
Hoist it into a single SUBSONIC_API_VERSION constant next to SUBSONIC_CLIENT so
it can be reasoned about and adjusted in one place. No behaviour change.
2026-06-10 00:01:40 +02:00
Psychotoxical 36a0615dcb fix(home): stop Most Played load-more from snapping the page upward (#1053)
* fix(home): stop Most Played load-more from snapping the page upward

Loading more Most Played albums rebuilds the Because-You-Like anchor pool
(it is seeded from mostPlayed), and that rail's fetch effect keyed on the
pool array ref — so it re-ran on every append, swapping the row's cards and
producing a height blip that scroll anchoring turned into an upward viewport
jump. Gate the effect on poolKey (the stable top-anchor identity) instead,
matching the sibling reserve effect, so it only re-runs on a real seed change.

* docs(changelog): add #1053 Most Played load-more scroll fix
2026-06-09 22:39:12 +02:00
Psychotoxical 98bdf310d6 fix(themes): unify input & dropdown focus borders across themes (#1052)
* chore(themes): drop the dead installs field from RegistryTheme

The store no longer reads install counts (the themes registry stopped
emitting them), so this optional field on RegistryTheme was unused.

* fix(themes): give dropdown border/shadow tokens a cascade default

--border-dropdown and --shadow-dropdown were set by only two themes and had
no base fallback, so dropdown/popover borders rendered without a themed value
in every other theme. Default them in the semantic cascade next to the other
menu tokens; themes that set them explicitly still override.

* fix(themes): unify input focus rings, drop the double border

Text inputs draw their own border + box-shadow focus ring, but the global
:focus-visible outline stacked a second ring outside it — a double border on
every field. Suppress the outline for text inputs centrally; the specificity
(0,1,1) beats the global ring but loses to the colour-blind-safe themes'
[data-theme] *:focus-visible (0,2,0), so those keep their stronger AAA ring on
every field by design — the header search now carries that ring on its cluster
there too. Align the few input classes that had a weaker or missing ring to the
shared border + 3px accent-dim standard.

* docs(changelog): add #1052 input/dropdown focus border fixes
2026-06-09 22:23:41 +02:00
Psychotoxical 155ef88cc0 fix(themes): serve the Theme Store from GitHub raw, not jsDelivr (#1051)
Theme assets (registry.json, theme CSS, thumbnails) were served from the
jsDelivr CDN's mutable @main edge, which caches up to 12h. The registry is
purged on every themes push so updates were offered promptly — but the theme
CSS could still be served stale, so an update stored pre-update CSS under the
new version label and no further update was offered to correct it (a freshly
animated theme would install without its @keyframes).

Fetch everything from GitHub raw (permissive CORS, ~5-min server cache), which
is always current. jsDelivr is dropped entirely: our request volume does not
need a CDN, and its only real upside here (high-traffic edge serving) does not
apply, while its staleness actively caused the bug.

Rename cdnUrl -> assetUrl to reflect the source.
2026-06-09 20:24:43 +02:00
Psychotoxical 316c99ba07 refactor(themes): drop unreliable popularity/download stats from the store (#1050)
The install/download numbers came from jsDelivr CDN hits, which are
structurally unreliable: the stats API caps at the top 100 files, so most
themes report 0 and the figures jump from day to day. Showing them — and a
popularity bar and a most-popular sort built on them — only misled users.

Remove the stats meta box (popularity bar + download count), move the author
back under the theme name, and keep the reliable git-based last-changed date
inline. Sort is now Newest (default) or Alphabetical.
2026-06-09 20:04:25 +02:00
Psychotoxical c33d1e64c5 fix(now-playing): index-first metadata fetchers (#1046) (#1049)
* feat(now-playing): index-first metadata resolvers (#1046)

Add nowPlayingMetadataResolve.ts: four index-first resolvers (album,
discography, top songs, song meta) that read the local library index when
it has the row and fall back to Subsonic only on index miss / off / not
ready. Reuses loadAlbumFromLibraryIndex, loadArtistFromLibraryIndex,
libraryGetTrack, libraryAdvancedSearch, trackToSong, and libraryIsReady —
the same index-first family as queueTrackResolver, not a new network path.

getSongForServer keeps its byte-style trackId guard; the index arm runs
first so an index hit avoids the guarded call. artistInfo has no index
source and stays network-only (not handled here).

* feat(now-playing): wire index-first resolvers into fetchers + prewarm (#1046)

Replace the four direct Subsonic calls in useNowPlayingFetchers and
prewarmNowPlayingFetchers (album, discography, top songs, song meta) with
the resolveNp* resolvers — index-first, network fallback. artistInfo
stays on getArtistInfoForServer (no index source). Caches, id-gating
tuples, and the subsonicFetchAllowed guard are unchanged; the top-songs
effect gains artistId in its deps (index arm filters by it).

Net: on a populated, ready index those four cards render from SQLite with
no Subsonic call; index miss/off/not-ready falls back to the network path
exactly as before.

* docs: add CHANGELOG entry for PR #1049

* fix(now-playing): widen resolveNpTopSongs artistId to optional (tsc)

The top-songs fetch isn't guard-narrowed on artistId, so callers pass
string | undefined. The resolver already handles a missing artistId (falls
straight to the network arm); widening the param type fixes the CI tsc
failure that the wiring commit introduced.

* fix(now-playing): run index reads offline + deterministic top songs (#1049 review)

Address cucadmuh's two in-PR review points:

1. Index-first reads now run whenever there's a playback server id, including
   when the server is unreachable — the offline win of index-first. Split the
   hook/prewarm gate: resolvers run on server-id presence; the reachability
   guard moved into each resolver's network fallback arm. artistInfo (no index)
   stays network-only.
2. resolveNpTopSongs derives top songs from the artist's own discography albums
   (sorted by play_count) instead of an FTS-on-name query, so name collisions
   can't surface the wrong tracks. getTopSongsForServer remains the fallback.

Resolver tests extended with the unreachable (index-only) cases.
2026-06-09 19:22:43 +02:00
Psychotoxical 276903d0af i18n(settings): translate Cover art cache section into the remaining locales (#1047)
The Cover art cache settings section shipped with English (and Russian)
strings only. Add the 23 keys (scope/strategy labels, Lazy/Aggressive
descriptions, per-server clear dialog) for de, fr, es, nl, nb, ro, and zh.
Placeholders and the technical progress-value string are left verbatim.
2026-06-09 12:14:29 +02:00
Psychotoxical a151cf5deb fix(now-playing): keep metadata cards when the track plays from local cache (#1042)
* fix(now-playing): keep metadata cards when the track plays from local cache

The Now Playing metadata fetches (album, artist info, discography, top songs)
gated on shouldAttemptSubsonicForServer(serverId, trackId). That guard returns
false when the current track's audio resolves to a psysonic-local:// URL (hot
cache / offline bytes), which is correct for playback-byte calls but wrongly
suppressed the metadata calls. On track change the prefetched next track plays
from the hot cache, so every Subsonic-backed card blanked and only the Last.fm
bio fallback and Bandsintown tour (both non-Subsonic) remained.

Drop the trackId from the four Now Playing metadata gates so metadata is fetched
whenever the server is reachable, regardless of where the audio bytes come from.
True-offline is still handled by the online / reachability checks in the guard.
The guard and all other call sites are unchanged.

* docs: add CHANGELOG entry for PR #1042

* refactor(now-playing): single Subsonic network gate owned by the fetchers hook

The metadata reachability guard (shouldAttemptSubsonicForServer) ran 2-3
times per Now Playing render/effect cycle: callers folded it into
fetchEnabled in NowPlaying.tsx and useNowPlayingPrewarm on top of the
checks already inside useNowPlayingFetchers and prewarmNowPlayingFetchers.

Callers now pass fetchEnabled as intent only ("we have a playback server
id"); the single reachability decision lives in the hook / prewarm
function. Behaviour is unchanged — only the duplicated guard calls go.

* test(now-playing): cover local-playback metadata behaviour, not only call shape

Add a guard test proving the metadata gate (no trackId) bypasses the
psysonic-local:// skip while a byte-style call stays blocked, and a hook
test that fetches album/discography/top songs with a trackId-sensitive
guard — so a reintroduced trackId at the gate fails the suite instead of
silently blanking the cards.
2026-06-09 11:31:01 +02:00
Psychotoxical ad8e376c9c feat(settings): show server software and version on server cards (#1045)
* feat(settings): show server software and version on server cards

Render the OpenSubsonic-reported software and version (e.g. "Navidrome
0.62.0") under each server name on Settings -> Servers. The value comes
from the existing ping identity (subsonicServerIdentityByServer), so no
extra request is made; the line is omitted when the server reports no
type (e.g. plain Subsonic without OpenSubsonic).

Extract the formatting into a shared formatServerSoftware() helper and
reuse it in the PsyLab perf-probe server section in place of its local
copy.

* docs: add CHANGELOG entry for PR #1045
2026-06-09 10:25:28 +02:00
cucadmuh b2a5baa48d fix(library-db): name slow-write ops for macOS stall diagnosis (#1043)
* fix(library-db): name slow-write ops for macOS stall diagnosis (#1040)

Replace generic op=misc labels on production library-db write paths with
stable module.action names (sync_state.*, track.*, tombstone.*, cmd.*, …)
so SLOW write logs pinpoint the call site. Document the naming convention
on LibraryStore::with_conn.

Diagnostic step for #1040 — no behaviour change.

* docs: add CHANGELOG and credits for PR #1043

Library-db slow-write op naming diagnostic for issue #1040.
2026-06-09 11:03:22 +03:00
Psychotoxical cfc9419de7 feat(themes): sidebar notice when an installed theme has an update (#1041)
* feat(themes): sidebar notice when an installed theme has an update

Adds a dismissible sidebar pill (sibling of the What's New banner) shown
while an installed community theme has a newer version in the store. Clicking
opens Settings -> Themes; dismiss hides it until a new update changes the set.

The theme registry is now refreshed from source once per app launch instead
of only when the Theme Store tab is opened, so newly published themes and
updates surface without a manual refresh -- and feed this notice.

* docs: add CHANGELOG entry for PR #1041

* feat(themes): in-place update control on installed theme cards

Themes with a newer version in the store now show a centered update icon on
their card in Settings -> Themes; clicking it fetches and reinstalls in place.
Extracts the shared installThemeFromRegistry helper (fetch -> validate ->
install) used by both the store list and the card control, and surfaces the
full registry entry from useThemeUpdates so the card can update directly.
2026-06-09 01:02:32 +02:00
Psychotoxical c6298d8c25 fix(audio): poll only the default device when none is pinned (#996) (#1039)
* fix(audio): poll only the default device when none is pinned (#996)

The device watcher ran a full output_devices() + per-device description()
CoreAudio enumeration every 3s, even with no pinned device. On some macOS
setups this contends with the audio render thread and causes a brief dropout
once per poll — a stutter whose cadence tracks the poll interval exactly.

The full enumeration is only needed to detect a pinned device disappearing.
With no pin (system default, the common case) only the current default is
needed, so the enumeration is skipped entirely in that case; the cheap
single default_output_device() query still detects default-device changes.

Confirmed with a diagnostic build: throttling the enumeration to ~60s moved
the reporter's stutter cadence from ~3s to ~60s, isolating the enumeration
as the cause.

* docs: add CHANGELOG entry for PR #1039
2026-06-09 00:16:29 +02:00
Psychotoxical 37089ea0f1 refactor(themes): make the Now Playing page fully themeable (#1038)
* refactor(themes): drive the Now Playing page from semantic tokens

Replace the hardcoded white/black colours in the Now Playing dashboard,
the info-card glass panels, the queue sidebar, track lists, tags and the
Last.fm stats with the semantic token surface (text / surface / border /
glass). Light themes now render the page legibly instead of washed-out
white-on-light; dark themes keep their look because the tokens' dark values
match the previous hardcoded ones. Cover-art content (fullscreen player,
hero) is intentionally left fixed and out of scope.

* docs: CHANGELOG for themeable Now Playing page (#1038)
2026-06-08 23:19:35 +02:00
cucadmuh 6118b3940f feat(server): capability framework, AudioMuse sonic routing & PsyLab Connections (#1033)
* feat(server): probe AudioMuse via OpenSubsonic and add PsyLab Connections tab

Navidrome ≥0.62: detect sonicSimilarity extension for reliable plugin signal;
older servers keep the legacy getSimilarSongs probe. PsyLab gets a Connections
tab with session, endpoint, and active-server capability details.

* feat(psylab): polish Connections tab, admin role probe, and tab bar layout

Status badges and Navidrome admin/user validation in Connections; prevent
PsyLab tab row from vertically collapsing under the Logs flex layout.

* docs: add CHANGELOG and credits for PR #1032

* feat(settings): auto-enable AudioMuse on Navidrome 0.62+ with status indicator

Replace the per-server manual toggle with a probe-driven badge when
sonicSimilarity is available; pre-0.62 Navidrome keeps the legacy toggle.

* feat(server): capability framework with AudioMuse sonic routing

Add a declarative server-capability catalog (src/serverCapabilities/) that
picks a feature strategy per server generation, runs only the needed probes,
and routes API calls. AudioMuse Instant Mix now prefers the OpenSubsonic
sonicSimilarity endpoint (getSonicSimilarTracks) on Navidrome 0.62+ and
falls back to legacy getSimilarSongs.

- catalog/context/resolve: eligibility, detection, activation, call routing
- storeView: read facade over the existing per-server probe maps
- getSonicSimilarTracks API client + fetchSimilarTracksRouted router
- route Instant Mix and Lucky Mix through the resolver
- ServersTab + PsyLab Connections read the resolver (auto status vs toggle)
- tests: resolve, storeView, router

* docs: update CHANGELOG and credits for PR #1033

Renamed branch supersedes PR #1032: point changelog/credits at #1033 and
document the server-capability framework, auto-managed AudioMuse indicator,
and sonic Instant Mix routing.

* refactor(server): address PR #1033 review — idempotent probe, drop dead code

- Make scheduleInstantMixProbeForServer idempotent: skip when a definitive
  result is cached; re-probe only on force (add/edit/test server), a prior
  error, or a server version/type change (invalidated in setSubsonicServerIdentity).
  Removes the steady-state 120 s re-probe, the present→probing→present flicker,
  and the momentary legacy-fallback routing window.
- Remove now-dead identity helpers (showAudiomuseNavidromeServerSetting,
  isAudiomusePluginAutoManaged, isNavidromeSonicSimilarityEligible,
  resolveAudiomusePluginProbeUiStatus + type) and the superseded
  probeAudiomusePluginWithCredentials; the catalog is the single source of truth.
- Drop the never-emitted 'unsupported' AudiomusePluginProbeResult variant.
- Fill audiomuseStatus* keys in all 8 non-en locales.
- Tests: probe idempotency + version-change invalidation; retarget
  OpenSubsonic test to fetchOpenSubsonicExtensionsWithCredentials.
2026-06-08 23:16:29 +02:00
Psychotoxical 0878cbf308 feat(themes): Theme Store install counts, downloads & sort (#1036)
* feat(themes): install counts, downloads, popularity & sort in the Theme Store

Each registry theme now carries an install count and a last-changed date. Store
rows show them in a dedicated meta panel (author, popularity bar, total
downloads, last changed), plus a sort dropdown (most popular / newest / name),
zebra-striped rows, a numbered pager, and a note that the stats refresh daily.
Adds a shared formatRelativeTime helper (formatLastSeen now delegates to it).

* test(themes): cover sort modes, pager jump and relative-time formatting

* fix(build): gate nvidia_quirk_active to Linux

Its only caller is the Linux branch of theme_animation_risk, so on Windows and
macOS the function was dead code and tripped clippy's -D warnings.

* docs: CHANGELOG + credits for theme store download stats & sort (#1036)
2026-06-08 22:05:41 +02:00
Psychotoxical 68f0f09aae fix(themes): restyle the animated-theme indicator (#1035)
Replace the raw hazard-triangle icon with a small amber motion chip
(shared AnimatedThemeBadge): an Activity glyph on a tinted rounded
surface. In the Theme Store it stays inline after the name; on installed
theme cards it moves from bottom-right to top-centre, clear of the active
indicator (top-right) and the uninstall control (top-left). Tooltip and
aria-label are unchanged.
2026-06-08 19:51:16 +02:00
cucadmuh 1b4fb9e9b3 fix(artist): play top tracks when album list is empty (#1031)
* fix(artist): play top tracks when album list is empty on page

Top-track rows silently no-op'd when albums.length was 0 (common in
lossless artist view). Play the selected top songs immediately and only
append catalog tracks when albums are available.

* docs: note artist top tracks play fix in CHANGELOG for PR #1031
2026-06-08 13:43:49 +03:00
cucadmuh 5167d8f49e feat(ui): themed startup splash with deferred window show (#1030)
* feat(ui): add themed startup splash with deferred window show

Show a theme-aware loading splash before the Vite bundle mounts, hide the
native window until it paints, and use per-theme logo gradient colors.

* docs: note startup splash in CHANGELOG and credits for PR #1030

* docs: attribute PR #1030 startup splash to cucadmuh
2026-06-08 12:59:07 +03:00
cucadmuh 086c7e43b4 feat(psylab): rename probe, Tuning tab, log tools, and safe log sanitization (#1027)
* feat(psylab): rename probe UI, add Tuning tab and log copy/export

PsyLab (Ctrl+Shift+D): cover backfill threads move to Tuning; logs gain
selectable text, toolbar copy/export, and a selection-only context menu.

* fix(logging): redact secrets and mask remote hosts in runtime logs

Sanitize lines at append time (buffer, CLI tail, export) and in PsyLab:
Subsonic/auth query params, bearer tokens, password fields, URL userinfo;
remote hostnames partially starred, LAN/localhost left readable.

* fix(logging): UTF-8-safe log sanitization — unbreak playback on em dash

Byte-indexed URL scanning panicked on multi-byte chars (e.g. "—" in stream
logs), killing tokio workers and aborting playback. Iterate by char boundary;
add infallible wrapper on the append hot path.

* docs(changelog): PsyLab UI and safe log sanitization (PR #1027)
2026-06-08 02:29:23 +03:00
cucadmuh 32832246c0 fix(library): All Albums compilation filter matches VA album artist (#1026)
* fix(library): All Albums compilation filter matches VA album artist

Local index SQL and track-grouped browse missed compilations tagged via
Various Artists on album_artist; genre-only browse also ignored combined
filters. Extend predicates on both sides and route genre+filter to advanced search.

* docs(changelog): All Albums compilation filter fix (PR #1026)
2026-06-08 01:19:24 +03:00
cucadmuh e527cfe67f fix(fullscreen): respect Settings clock format on wall clock (#1025)
* fix(fullscreen): respect Settings clock format on wall clock

The fullscreen player corner clock always used the browser locale default
(12-hour). Route it through formatClockTime and authStore.clockFormat so
24-hour matches queue ETA and sleep-timer preview.

* docs(changelog): fullscreen clock format fix (PR #1025)
2026-06-08 01:02:29 +03:00
cucadmuh 30e9db1a2b fix(artists): per-artist links on song rails and shared OpenSubsonic refs (#1023)
* fix(artists): per-artist links on song rails and shared OpenSubsonic refs

Song cards in Random Picks and Discover Songs showed joined artist
credits but navigated to a single artistId. Route track surfaces through
resolveTrackArtistRefs and coerce single-object Subsonic JSON payloads.

* docs(changelog): note song-rail multi-artist link fix (PR #1023)
2026-06-08 00:45:32 +03:00
Psychotoxical 49ad3618a8 fix(themes): show animated-theme warning on the thumbnail in My Themes (#1022) 2026-06-07 21:26:19 +02:00
cucadmuh 4148e063dd fix(home): sync Mainstage hero backdrop with album on fast nav (#1021)
* fix(home): sync Mainstage hero backdrop with album on fast nav

Bind hero background URL to the current slide and drop stale crossfade
layers so rapid carousel clicks no longer show the previous album's art.

* docs(changelog): note Mainstage hero backdrop sync fix (PR #1021)
2026-06-07 21:49:59 +03:00
Psychotoxical 88f7a7bc90 feat(themes): warn about animated themes on high-CPU setups (#1020)
* feat(themes): warn about animated themes on high-CPU setups

Show a warning icon + tooltip on animated themes (those defining
@keyframes) in the store and in Your Themes, on Linux setups where
animation is costly — the Nvidia WebKit quirk is active or compositing
is forced off. The Nvidia detection already runs once at startup; its
result is recorded (theme_animation.rs) and read via a new
theme_animation_risk command — no GPU re-probe. Display-only; never
shown off Linux. The animated flag comes from the registry (store) or
the theme CSS (Your Themes).

* docs(themes): note the animated-theme warning PR in the Theme Store entry
2026-06-07 20:38:36 +02:00
Psychotoxical daa6fbbfd7 feat(dev): --theme-watch flag for live theme authoring (#1019)
Debug builds only: `--theme-watch <theme.css>` polls a local theme file
and pushes it into the running app on save (a lightweight Rust watcher
thread emits a Tauri event). The frontend installs the CSS under its
[data-theme='<id>'] selector and applies it, so the existing
syncInjectedThemes effect re-injects live — no zip re-import, no reload.
Double-gated (debug_assertions + import.meta.env.DEV); never wired in
production.
2026-06-07 19:54:12 +02:00
Psychotoxical aabd342a64 fix(themes): show store thumbnails in 16:9 (#1018)
* fix(themes): show store thumbnails in 16:9

Thumbnails are now 16:9 WebP. Render the store-row preview at 200x112
(16:9) so the screenshot isn't cropped in the grid; the lightbox already
shows it full size, now larger and crisper from the 1280x720 source.

* docs(themes): note the 16:9 thumbnail PR in the Theme Store entry
2026-06-07 19:30:09 +02:00
cucadmuh fc34a0ec59 feat(offline): local-bytes browse when server is unreachable (#1017)
* feat(offline): local-bytes browse for artists and albums

Make Artists, All Albums, and artist/album detail pages work offline
from the library index limited to on-disk library and favorite-auto
tracks. Add a DEV header toggle to simulate offline browse for testing.

* feat(offline): reactive DEV offline toggle with full disconnect simulation

Subscribe nav and browse/detail hooks to useOfflineBrowseActive so UI
refreshes on toggle. DEV force-offline now blocks server probes, reports
disconnected status, and gates Subsonic like real offline for player parity.

* feat(offline): bytes-first favorites when offline browse is active

Load Favorites from local playback bytes, filter starred tracks client-side,
and restrict album-level star queries to local album ids. Drop interim perf
attempts (lean SQL, progressive load, connection singleton, prefetch UX).

* feat(offline): tracks, help, player stats; suspend library picker offline

- Offline browse for Tracks hub from local bytes; sidebar nav for tracks/help/statistics
- Statistics redirects to player-stats offline; server/Last.fm tabs skip network fetches
- Hide music-library picker offline; save filter and restore on reconnect (all libraries while disconnected)
- Unified isOfflineSidebarNavAllowed for library + system entries

* feat(offline): fork disconnect navigation by offline browse capability

When the server drops: stay on the page if nothing is browsable offline;
reload in place on offline-capable routes; otherwise redirect to All Albums
instead of the old /offline or /favorites bounce.

* feat(offline): browse cached playlists when the server is down

List and open manually pinned regular playlists from local library-tier
bytes offline, with sidebar/nav routing and read-only playlist UI.

* feat(offline): read-only artist detail and local play-all paths

Hide favorites and discography offline actions when browse is offline;
load Play All, Shuffle, and top-track continuation from local album bytes.

* feat(offline): read-only album detail and enqueue from local bytes

Hide favorites, download, and cache-offline actions on album pages when
offline browse is active. Favorites album cards enqueue via the same
resolveAlbumForServer path as play, including local playback bytes.

* chore: remove unused import in AlbumCard after enqueue refactor

* feat(offline): unify browse integration contract across the app

Add useOfflineBrowseContext, offlineMediaResolve, and offlineActionPolicy;
wire shell nav to a single capability source; migrate play/enqueue and
context-menu paths off raw getAlbum; replace readOnly with action policy
on detail surfaces. Tests updated for the media-resolve facade.

* feat(offline): close browse contract gaps and fix offline Home feed

Split offline browse modules, align favorites capability across servers,
wire action policy on context menus, migrate hooks to useOfflineBrowseContext,
and preserve stale Home feed cache when offline so the UI does not empty.

* fix(offline): block playbar stars, close audit gaps, trim dead exports

Hide star rating and favorite in PlayerBar when offline browse is active via
offlineActionPolicy playerBar surface. Wire stay-reload token into browse
hooks, migrate hooks to context.active, guard rating prefetch network calls,
and route playlist load through resolvePlaylist.

* docs: add CHANGELOG and credits for offline browse PR #1017

* fix(offline): stop DEV connection probe regression in tests

React to devForceOffline transitions only in useConnectionStatus so mount
does not double-fire check() or ignore disableBackgroundPolling. Add
pingWithCredentials to PlayerBar test mock and DEV-toggle unit tests.
2026-06-07 15:59:41 +03:00
Psychotoxical f4e1086131 feat(themes): fresher store refresh + external-services notice (#1016)
* feat(themes): fresher store refresh + external-services notice

Manual refresh now fetches the registry from GitHub raw first (~5 min
cache) and falls back to the jsDelivr CDN, so a freshly merged theme shows
up without waiting on — or purging — the shared CDN @main edge (up to
12 h). Normal loads still use the CDN.

Adds a notice on the Theme Store that the catalogue and previews load from
external services (jsDelivr / GitHub), in line with the app's network
transparency. i18n ×9.

* docs(themes): note the store-refresh PR in the Theme Store entry

Now that #1015 is merged, fold PR #1016 into the still-unreleased Theme
Store changelog and credits entry.
2026-06-07 14:34:28 +02:00
Psychotoxical 23f032b274 feat(themes): free-form community themes with a security floor (#1015)
* feat(themes): free-form community themes with a security floor

Community themes are no longer token-only. The in-app guard
(validateThemeCss) now enforces only a security floor — no network
(@import / non-data url()), no scripts (<style>/<script>/expression()/
javascript:/-moz-binding), no @property, @keyframes namespaced as <id>-,
and a 256 KB cap — and otherwise allows any selectors, structure and
animations. validateThemePackage checks the manifest plus that floor.

Themes can react to app state via same-element attributes set on the theme
root: data-playing, data-fullscreen, data-sidebar-collapsed, data-lyrics-open.

The local-import confirm dialog now notes that imported themes aren't
reviewed and are installed at the user's own risk. Removes the now-unused
bundled token whitelist.

* docs(themes): note free-form themes in the Theme Store entry

Add PR #1015 and a free-form bullet to the still-unreleased Theme Store
changelog and credits entry.
2026-06-07 14:04:38 +02:00
Psychotoxical aad1a6c3f0 fix(themes): route remaining UI colours through theme tokens (#1014)
* fix(themes): route remaining UI colours through theme tokens

An audit found several surfaces still wired to the fixed Catppuccin
palette or hardcoded hex, so community themes could not recolour them:
the 5-star rating, the global search field, the gradient-text flourish,
badges and category-avatar text, and the What's New page + sidebar
banner. Rewire them to existing contract tokens — no new tokens, and the
built-in themes look identical (the values match). Community themes now
control these areas.

Also fixes device-sync rows referencing an undefined --bg-secondary
(they rendered with no background); they now use --bg-card.

* docs(themes): note the theme-coverage PR in the Theme Store entry

Add PR #1014 to the still-unreleased Theme Store changelog and credits
entry, alongside the other follow-ups.
2026-06-07 12:22:54 +02:00
Psychotoxical 10fc489ce9 fix(themes): offline banner for the Theme Store (#1013)
* fix(themes): show an offline banner when the Theme Store is unavailable

When the registry can't be fetched and no catalogue is cached, the store
now shows a clear offline banner (icon, message, retry) in place of the
list, and hides the search/filter toolbar — there is nothing to browse.
The cached-catalogue fallback with its offline indicator is unchanged, so
the store still works offline when a catalogue was previously cached.

* docs(themes): note the offline-banner PR in the Theme Store entry

Add PR #1013 to the still-unreleased Theme Store changelog and credits
entry, alongside the other follow-ups.
2026-06-07 11:48:23 +02:00
Psychotoxical b3573e7107 feat(themes): import a theme from a local .zip (#1012)
* feat(themes): validate and extract locally imported theme packages

Add the backend + validation half of local theme import: users will be
able to load a theme packaged as a .zip (manifest.json + theme.css).

- `import_theme_zip` Tauri command unpacks only manifest.json + theme.css
  from the archive, outside the webview, with an archive-size cap,
  per-entry uncompressed caps, and path-traversal rejection.
- `validateThemePackage` runs the full theme-store contract in-app: the
  manifest schema (field patterns copied from the repo schema), the CSS
  token whitelist with all core tokens required, color-scheme matching
  the declared mode, data-URI restricted to the arrow token, and a guard
  against ids that collide with built-in themes. The existing
  `validateThemeCss` containment guard is reused and runs again at
  injection time. The contract token list is a byte-identical copy of the
  themes repo's allowed-tokens.json.

Covered by validateThemePackage tests for every rejection class.

* feat(themes): add the Import a theme section to the Themes tab

A dedicated section between the theme scheduler and the Theme Store lets
users import a theme from a local .zip. After the package validates, a
confirmation dialog names the theme and its author before it is installed.

A rejected import shows a plain-language explanation aimed at end users;
the raw contract diagnostics (token names, missing fields) are tucked
into a collapsible "Technical details" block for theme authors. Fully
localised across all nine languages.

* docs(themes): changelog + credits for local theme import

Fold the local .zip import (and the store pagination / refresh-scroll
polish) into the still-unreleased Theme Store entry rather than a new
section, and extend the credits line. PRs #1011 and #1012.
2026-06-07 11:35:52 +02:00
Psychotoxical 9fb0d5638b feat(themes): paginate theme store and keep scroll on refresh (#1011)
The community Theme Store grew large enough that browsing it meant
scrolling through the whole catalogue. Paginate it (12 per page) with a
Prev / "Page X of Y" / Next pager that resets to page 1 on filter changes
and scrolls back to the top of the list when paging.

Also fix the refresh button resetting the scroll position. It set the
top-level loading state, which unmounted the list and collapsed the
scroll viewport so it clamped back to the top. Refreshing now keeps the
existing list mounted and only spins the refresh icon; the full-page
loading and error placeholders are reserved for the initial load.

Adds themeStorePagePrev / PageNext / PageStatus across all nine locales
and a ThemeStoreSection test covering pagination, filtering, page reset,
and the refresh-keeps-scroll behaviour.
2026-06-07 10:14:46 +02:00
Psychotoxical f9df918c72 feat(themes): community Theme Store + semantic-token refactor (#1009)
* feat(themes): add semantic tokens for the theme-store contract (B0 P1)

Additive: define --highlight, --accent-2, --bg-deep, --bg-elevated and
--text-on-accent on the :root base as --ctp-* mappings. They resolve
per-theme automatically and nothing consumes them yet (zero behaviour
change) — groundwork for replacing direct --ctp-* use in components.

* refactor(themes): components consume semantic tokens, not --ctp-* (B0 P2)

Replace every direct --ctp-* reference in component/layout/track CSS and
TSX inline styles with the readable semantic token (--bg-app, --accent,
--highlight, --text-on-accent, …). --ctp-* now survives only as the
Catppuccin palette layer the base maps from, and as the deliberate
categorical rainbow in Composers/Genres/artistsHelpers (left untouched).

This is the readable contract surface for the community theme store.
Divergences (theme set a semantic var != its --ctp- source) are
corrections — the element now uses the theme's real semantic colour.

* feat(themes): add player-bar title/artist color tokens

New optional --player-title / --player-artist, defaulting to --text-primary / --text-secondary so nothing changes unless a theme overrides them. Lets a theme give the now-playing readout its own colour as a plain token.

* refactor(themes): token-only theme library (flatten, whitelist, one file per theme)

Turn every built-in theme into a single self-contained [data-theme] var block of semantic whitelist tokens (plus the internal --ctp-* palette layer):

- Flatten all themes: drop structural override rules, @keyframes, and global-token overrides (radius / shadow-elevation / transition / spacing / font / focus-ring). Signature player-bar readout colours are preserved via the new --player-title / --player-artist tokens.

- Normalize the var blocks to the semantic whitelist: drop the alternate token vocabulary (nav-active / scrollbar / bg-input / success / border-default / ...); rename --success -> --positive and --border-default -> --border where no whitelist equivalent was set. Migrate the few components that read those tokens to the whitelist equivalents.

- Split multi-theme files so each theme ships as its own file, making the built-in set 1:1 with the per-theme store packaging.

Kept as-is (built-in, not flattened): the two colour-blind-safe accessibility themes, plus the two curated core skins.

* chore(themes): remove seven themes retired after the token refactor

These themes leaned on heavy structural overrides and were dropped rather than flattened. Full removal each: the CSS file(s), the index.css import, the Theme type union, and the ThemePicker entry.

* feat(themes): granular tokens — track lists

Wire track rows to per-region tokens: row hover (--row-hover), the now-playing
row + indicator (--row-playing-bg / --row-playing-text), track title/artist/
number/duration text, column-header text, row dividers, and the resize-handle
active colour. Covers the desktop tracklist, the shared song-row (Tracks hub /
search), and the mobile tracklist. Drop a baked border fallback. Visual no-op.

* feat(themes): granular tokens — cards

Wire album and artist cards to per-region tokens (--card-hover-border,
--card-title, --card-subtitle, --card-placeholder-bg). Visual no-op.

* fix(themes): drop undefined/baked colour aliases

Replace the undefined --bg-surface (resolved to nothing — broken placeholder
backgrounds and filter input) with --card-placeholder-bg / --input-bg, and the
baked-hex aliases --color-error / --color-warning with --danger / --warning so
themes can actually recolour them.

* feat(themes): Spectrum demo theme + trim unused cascade tokens

Add a loud built-in demo theme that gives each region its own hue (sidebar
green, player pink, lists cyan, cards gold, menus red, controls blue) so the
per-region granularity is obvious when you switch to it. Drop two unused
cascade tokens (--sidebar-text-active, --row-active-bg) and the unused
on-media block (those media surfaces stay static by design).

* style(themes): make Spectrum demo brutally loud

Full-saturation neon per region (toxic green sidebar, magenta player, cyan
lists, acid-yellow cards, blood-red menus, electric-blue controls, purple
scrollbar) so the per-region separation is unmistakable. The earlier soft
tints were too subtle to read.

* feat(themes): name the granular demo theme Braindead

* feat(themes): granular per-region tokens — cascade layer + sidebar

Add an optional per-region token layer (semantic-cascade.css) so a theme can
recolour individual regions — sidebar hover, player controls, list rows,
menus, inputs, on-media surfaces — independently of the global tokens. Every
token defaults to its base token (or a media-safe literal), so this is a
visual no-op until a theme overrides one; it only adds control points.

Wire the sidebar region as the first consumer and drop the baked grey
fallbacks (--bg-tertiary, etc.) that no theme could reach.

* feat(themes): granular tokens — controls, menus, scrollbar

Wire inputs, buttons, sliders, the custom-select, context menus, submenus and
modals to per-region tokens, and tokenise the scrollbar. Complete B0 by
dropping the last direct --ctp-* references in the input/button/progress/
scrollbar utility CSS.

Fix three undefined-token bugs that fell back to nothing (so no theme could
reach them): --surface-2 (context-menu hover had no highlight), --bg-surface
(submenu create-input had no background), and the baked grey fallbacks in the
custom-select. Every other new token defaults to today's value — a visual
no-op that only adds override points.

* feat(themes): granular tokens — player bar

Wire the desktop player bar's transport controls, time toggle, and overflow
menu to per-region tokens (--player-control, --player-time-toggle-*, etc.).
Fix the undefined --surface-hover/--surface-active grey fallbacks on the time
toggle. Visual no-op; defaults match today's values.

* chore(themes): remove empty theme stub files

Six theme CSS files were reduced to comment-only stubs by the flatten
sweep but their files and @import lines remained. Five are empty
structural companions of now-flattened themes (morpheus, p-dvd,
aero-glass, luna-teal) and two are orphans of cut themes
(order-of-the-phoenix, pandora). Removed the files and their imports.

* feat(themes): runtime injection foundation for the theme store

Plumbing for installed community themes ahead of the in-app store UI,
nothing user-visible yet:

- installedThemesStore: persisted (localStorage) record of installed
  community themes incl. their CSS text, so an active community theme is
  available synchronously at startup (no flash, fully offline).
- themeInjection: reconcile <head> <style data-installed-theme> elements
  with the store; lightweight defense-in-depth sanitize on top of CI.
- themeRegistry: jsDelivr registry client with a 12h localStorage cache
  and stale-on-error fallback.
- App: inject installed themes before applying data-theme, in both webviews.
- themeStore: widen the Theme type to accept dynamic installed ids.

* feat(themes): dedicated Themes settings tab

Move theme selection and the day/night scheduler out of Appearance into
a new dedicated Themes tab — the future home of the community Theme Store.
Appearance keeps grid columns, visual options, UI scale, font and seekbar.

- ThemesTab: theme picker + scheduler (relocated verbatim).
- AppearanceTab: drop the two relocated sections + now-unused imports.
- Register the tab in settingsTabs (Tab union, resolveTab, search index)
  and Settings (tab bar, render, label map).
- i18n: settings.tabThemes in all 9 locales.

* feat(themes): community Theme Store browse + install

Add the Theme Store section to the Themes tab:

- Fetch the jsDelivr registry (12h cache, stale-on-error fallback).
- Search by name/author/description + filter by light/dark + refresh.
- Per-row CDN thumbnail, name, author, description and actions:
  Install / Apply / Update / Uninstall. Installing fetches the CSS,
  persists it (localStorage) and the runtime injection applies it;
  uninstalling the active theme falls back to the matching core.
- Rating slot left reserved (deferred).
- i18n: themeStore* keys in all 9 locales.

* feat(themes): slim bundle to fixed cores + flat Themes tab

Remove the 86 store palettes (incl. braindead) from the app bundle — the
CSS files, their index.css imports, the Theme union and the picker data —
leaving only the six fixed cores (Catppuccin Mocha/Latte, Kanagawa Wave,
Stark HUD, Vision Dark/Navy). Everything else installs from the store.

Themes tab is rebuilt flat (no collapsible accordions):
- "Your Themes": one card grid of the fixed cores + installed community
  themes; click to apply, uninstall on community ones (active theme falls
  back to the matching core). Catppuccin prefix on Mocha/Latte; a CVD-safe
  pill on the colour-blind-safe Vision themes.
- Scheduler day/night options include installed themes.
- Theme Store: alphabetical order, thumbnail lightbox, and a submit hint
  above the search linking to the themes repository.
- Nav order: Servers, Library, Audio, Themes, Appearance, Lyrics, …
- ThemePicker accordion removed; fixed-theme data moved to fixedThemes.ts.
- i18n for all new strings across 9 locales.

* feat(themes): reset removed-from-bundle themes to a bundled fallback

After slimming the bundle to the six fixed core themes, a profile upgraded
from an older build may have an active or scheduler theme that is now
store-only and not installed — it has no [data-theme] block and would render
as unstyled :root. Reset any theme/themeDay/themeNight that is neither bundled
nor installed to a bundled fallback: Mocha for the main + night slots, Latte
for the day slot. Runs synchronously in runPreReactBootstrap, rewriting the
persisted selection in localStorage before React mounts (no flash; Zustand
rehydrates after first paint). No auto-install and no network — the fallback
is always a bundled theme, so it works offline.

* feat(themes): floating back-to-top button on the Themes tab

The Themes tab can get long (theme grid + scheduler + full store list), so
add a floating back-to-top affordance that appears once the page is scrolled
and smooth-scrolls to the top. It is portalled into the route host and
positioned absolute against it — the main scroll viewport sets contain: paint,
which would otherwise make position: fixed resolve against the scrolling box
and drift with the content. Reusable component (scroll viewport id + threshold
props); i18n common.backToTop added in all nine locales.

* feat(themes): accessibility + state polish for the Theme Store

- Reuse the shared CoverLightbox for the thumbnail preview instead of a
  second inline dialog — gains a visible close button and a focus-managed,
  portalled dialog, and drops duplicated markup.
- Theme cards expose aria-pressed so assistive tech announces the selected
  theme, not just the visual check.
- Transient store messages get live-region roles (loading/empty/install
  failure = status, fetch error = alert).
- Thumbnails degrade gracefully when offline/missing (hide the broken-image
  glyph; the thumbnail button no longer stretches with the row, so its
  background can't show as letterbox bars).

* feat(themes): larger store-row thumbnails (120x75 -> 200x125)

The list previews were too small to make a theme out; bump the display size
(same 1.6 aspect). Thumbnails are now served at 720x450, so the larger
display stays crisp.

* fix(themes): bust thumbnail cache on registry change

jsDelivr serves theme thumbnails with a 7-day max-age, so when a thumbnail
is updated the webview keeps showing its cached old image (the path is
unchanged). Append the registry's generatedAt as a cache-busting query to
the thumbnail URLs (list + lightbox); it changes on every themes push, so a
registry refresh makes the webview re-fetch and reflect the current CDN
image instead of a stale one.

* docs(themes): changelog + credits for the Theme Store

Add the 1.48.0 "Themes — community Theme Store" changelog entry (PR #1009)
and the matching line in the Psychotoxical credits.

* fix(themes): address PR review (uninstall hygiene, validation, polish)

Uninstall/scheduler & validation:
- uninstallTheme() repairs every selection slot (active + day + night), not
  just the manual one, and is shared by both uninstall buttons (dedup).
- Validate theme CSS at install time and skip persisting CSS that won't inject
  (no more "installed/active but renders nothing" with no feedback).
- Harden the runtime validator: exactly one rule, scoped exactly to the
  theme's [data-theme='<id>'] selector (no unscoped/foreign selectors), no
  at-rules, url() only data:, no expression()/javascript:, size-capped.

Tokens & polish:
- Fix three dangling undefined tokens (--surface-2 x2, --bg-surface).
- Finish the warning/success token sweep (--warning / --positive, themeable).
- Apply the active theme synchronously before React mounts (no first-frame
  flash) and inject installed themes up front.
- One-time, dismissible notice when the slim-bundle migration reset a theme.
- Update badge uses semver, not string inequality.
- Offline/stale indicator in the store; cross-window theme sync; drop the now
  dead REMOVED_THEME_REMAP and Card.mode field.

Tests: themeInjection (validator + sync), themeRegistry (cache/force/stale/
malformed), uninstallTheme (slot repair), migration notice. i18n in all nine
locales. Full suite green (1755 tests).

* test(bootstrap): cover startup theme apply + cross-window sync

The review fixes added applyThemeAtStartup / installCrossWindowThemeSync to
bootstrap.ts (a hot-path file) without tests, dropping its coverage to 68.3%
and failing the frontend hot-path coverage gate (>=70%). Add unit tests for
both (and the no-op / malformed-storage paths); bootstrap.ts is back to ~98%.
2026-06-07 02:47:33 +02:00
Psychotoxical 03a1ba9582 Update README.md (#1010) 2026-06-07 01:57:31 +02:00
cucadmuh 2d3c723a6e feat(offline): unify local playback, offline library, and favorites sync (#1008)
* feat(local-playback): LP-1 media layout and download_track_local

Add library-index-backed path builder in psysonic-core and a unified
Tauri download command that writes under media/{cache|library}/ with
layout fingerprints; legacy hot/offline commands unchanged for now.

* feat(local-playback): LP-2 localPlaybackStore and media tier Rust helpers

Add unified Zustand index with legacy offline/hot-cache import, media_layout
TS mirror, and Rust commands for tier size/purge/delete/promote.

* feat(local-playback): LP-3 wire prefetch and playback to unified index

Route downloads through download_track_local, delegate hot/offline shims
to localPlaybackStore, and update resolve/promote/prefetch plus key rewrite.

* feat(local-playback): LP-4–LP-6 offline UI, invalidation, and mediaDir

Offline Library loads pinned groups via library index; sync-idle invalidates
stale paths; Settings uses a single mediaDir with cache/library tier sizes.

* feat(local-playback): migrate legacy offline files to media/library layout

Move flat psysonic-offline downloads into nested media/library paths using
library index metadata, with retry on sync-idle when tracks are not yet indexed.

* feat(local-playback): simplify offline disk migration and restore Offline Library UI

Scan psysonic-offline on disk and relocate by library track id; restore pinSource
and cover art for migrated pins; add find_live_by_id for segment/key resolution.

* feat(local-playback): disk-first offline reconcile and fast library tier discovery

Reconcile library-tier index against on-disk files using candidate track IDs
instead of scanning the full catalog. Refresh Offline Library from disk on
open and focus so deleted folders drop out of the UI. Add Rust discover/prune
helpers and wire album/server reconcile through the unified path.

* fix(offline): resolve local playback URLs across server index-key variants

Offline Library play failed when library-tier files were indexed under a
host key while playback looked up only the active profile UUID. Use
findLocalPlaybackEntry for URL resolution, pin queueServerId to the card
server, and build play queues from tracks that still have on-disk bytes.

* fix(offline): playlist cards, playback from Offline Library, and local URL routing

Show playlists with name and quad/custom cover instead of the first track's
album artist. Build play queues with library-batch fallback and offline-only
server switch. Prefer library-tier URLs in playTrack; add playback-unavailable
toast and missing trackToSong import.

* fix(cache): ephemeral disk reconcile, empty-dir prune, and Storage UI

Sweep media/cache after eviction (orphan files, stale index, empty folders).
Settings: split media folder from cover cache; in-browser image cache lives
under Cover art cache with aligned columns; clear only IndexedDB images.

* feat(offline): show library disk usage in Offline Library header

Query media/library tier size on reconcile and display it in a right-aligned
stat block beside the page title and album count.

* fix(cache): defer unindexed hot-cache eviction; drop legacy offline size cap

Reconcile ephemeral cache without deleting files from other app instances;
evict unindexed hot-cache files oldest-first only when over hotCacheMaxMb.
Remove the hidden maxCacheMb gate and offline-full banner on album pages.

* feat(offline): play-all cache card and stable Offline Library grid rows

Add a shuffle-and-play card for all on-disk library pins plus hot-cache
tracks when buffering is enabled. Fix virtual row height for offline cards
and reserve the year line so grid rows no longer overlap.

* feat(offline): queue-cache grid card limited to media/cache

Replace the full-width play-all banner with a playlist-style grid tile.
Shuffle/enqueue only ephemeral hot-cache tracks when buffering is enabled,
not offline library pins.

* chore(licenses): regenerate bundled OSS list for 1.48.0-dev

Set GPL-3.0-or-later on workspace crates and extend cargo-about accepted
licenses so generate-licenses.mjs runs; refresh src/data/licenses.json.

* feat(favorites): auto-sync starred tracks into separate media/favorites tier

Keep manual Offline Library in media/library/ and favorites offline in
favorite-auto/index + media/favorites/ so toggling sync cannot purge
user-pinned bytes; playback resolves library before favorites.

* feat(favorites): compact offline toggle with disk icon and sync semaphore

Move control to the page header (disk + switch, tooltips); show red/yellow/green
LED when enabled instead of the full-width save-offline card.

* fix(favorites): trigger offline sync on star/unstar from anywhere

Hook star/unstar API so favorites offline reconcile runs globally (songs,
albums, artists); optimistic unstar removes local bytes; drop Favorites-page-only sync.

* fix(cache): skip hot-cache prefetch when favorites or library bytes exist

Treat favorite-auto tier like offline library for prefetch, stream promote,
and same-track replay so synced favorites are not duplicated in media/cache.

* fix(favorites): reconcile offline files on merged track union only

Dedupe artist/album/song stars into one target set per track id; drop eager
unstar deletes so overlapping favorites do not remove bytes still needed.

* feat(offline): add Favorites card to Offline Library

Mirror queue-cache card for favorite-auto tier with play, enqueue, and
navigation to Favorites on card click.

* feat(offline): show library+favorites disk total with icon breakdown

Sum media/library and media/favorites in the On disk widget and open an
icon popover on hover with per-tier sizes for screen readers and sighted users.

* feat(favorites): enable offline Favorites tab when auto-save is on

Keep Favorites in the sidebar when disconnected, land on /favorites without
manual pins, and load starred rows from the local library index.

* feat(favorites): cross-server offline browse with per-server covers

When auto-save is on, Favorites merges starred items from every indexed
server and syncs each server independently. Detail links carry ?server=
for offline album/artist pages; cover art resolves disk cache by entity
serverId instead of the active server only.

* feat(playback): mixed-server queue scope and cross-server favorites sync

Per-ref server identity for playback (URL index key in queue refs, profile
UUID for API): trackServerScope, playbackServer helpers, gapless/scrobble/covers
by playing ref. Remove cross-server enqueue block; remap queueItems on URL
remigration.

Favorites: star/unstar and favorite-auto sync target the owning serverId
(not only active); queueSongStar passes server through pending sync.

* fix(offline): suppress Subsonic calls during favorites and local playback

Add reachability guards so offline favorites browse, album detail, queue
sync, scrobble, and Now Playing metadata skip network when the server is
down or the track plays from psysonic-local. Load starred albums/tracks
from the library index only (not the full artist table), refresh favorites
from index first, and pass server scope in favorites navigation.

* fix(queue): export share and playlist save for active server only

Mixed-server queues now filter queue refs by the browsed server profile
before copying a share link or saving/updating a playlist from the toolbar.

* fix(offline): complete album pins and resume interrupted downloads

Prefer full getAlbum track lists when online so partial library index
does not truncate offline pins; refresh songs before pin from album
detail. Resume incomplete persisted pins after reconcile and reconnect,
cancel in-flight work on delete, and chunk library batch fetches past
100 refs.

* fix(offline): pin queue, queued UI, and re-pin after remove

Serialize album and playlist offline pins so parallel enqueue no longer
drops in-flight work. Show an explicit queued state on album and playlist
actions with dequeue on repeat click, sidebar tooltips for long labels,
and clear stale cancel flags so Make available offline works after remove.

* fix(offline): remove Offline Library cards without full page reload

Optimistically drop the deleted card from local grid state, show the
loading spinner only on first visit, and ignore stale disk refreshes so
pin updates no longer flash the whole library view.

* fix(offline): artist discography pin state and queue handling

Detect cached/queued/downloading from persisted album pins instead of
ephemeral bulkProgress, skip already offline or in-flight albums when
enqueueing discography, and show the correct hero button after revisit.

* fix(local-playback): address LP-1 review handoff (B1, M1–M7)

Harden media path sanitization and tier containment, align Rust/TS layout
fingerprints, serialize per-track downloads, and fix favorites re-enable,
multi-server debounce, prev-track promote key, now-playing reachability,
and ephemeral prefetch soft-skip for unindexed tracks.

* fix(offline): cancel in-flight favorites downloads on unstar

Abort Rust streams with the real favorites downloadId when sync is
rescheduled or disabled, and drop completed bytes that no longer belong
in the starred set so unstar does not leave orphan files on disk.

* fix(build): resolve TypeScript errors blocking prod nix build

Align mediaLayout with LibraryTrackDto camelCase, extend analysis-sync
reasons, fix OfflineLibrary grid cover typing, and tighten vitest mocks
so `tsc && vite build` passes under the flake beforeBuildCommand.

* fix(rust): satisfy clippy too_many_arguments for CI

Bundle offline-library analysis and local path/migration helpers into
parameter structs so `cargo clippy -D warnings` passes on the branch.

* fix(settings): show correct hot-cache track count in Buffering section

Count ephemeral localPlayback rows instead of prefix-matching index keys,
which always missed host:port server segments and showed zero tracks.

* fix(media-layout): align truncation threshold on code points (M1)

Rust sanitize_and_truncate_segment now uses char count like TS so long
non-ASCII metadata does not diverge layout fingerprints; add Cyrillic
parity tests and clarify ephemeral cold-miss doc on download_track_local.

* fix(test): use numeric cachedAt in hotCacheStore count test

Align test fixture with LocalPlaybackEntry type so tsc passes in CI.

* docs: add CHANGELOG and credits for offline experience PR #1008

* feat(offline): auto-sync manually cached playlists when track list changes

Re-download new tracks and prune removed ones for playlist pins only, triggered
from updatePlaylist, playlist detail load, smart-playlist polling, and reconnect.

* docs: note cached-playlist sync in CHANGELOG and credits for PR #1008

* fix(offline): exclude smart playlists from manual offline cache and sync

Hide cache-offline for psy-smart-* playlists, block download/sync paths, and
document the distinction in CHANGELOG.

* feat(offline): auto-sync cached albums and artist discographies

Generalize pinned playlist reconcile into pinnedOfflineSync so manually
pinned albums and artist discographies re-download added tracks and prune
removed ones on reopen, reconnect, and catalog changes.

* feat(offline): split pinned sync triggers by pin kind

Album and artist pins reconcile after library index sync and reconnect;
regular playlists reconcile hourly and on in-app playlist edits only.
Remove reconcile-on-open for album, artist, and playlist detail views.

* fix(offline): address PR #1008 review (N1, tests, pin queue)

Scope playlist reconcile to the owning server via getPlaylistForServer.
Add artist discography and mixed-server playlist tests; dedupe pending
sync jobs; skip pinTasks overwrite during active downloads.
2026-06-06 22:43:03 +03:00
cucadmuh 40db6b08d2 chore(playback): remove Preload Next Track setting (#1007)
* chore(playback): remove Preload Next Track setting

The configurable next-track RAM preload duplicated hot cache and is
obsolete now that ranged streaming starts playback sooner. Gapless and
crossfade still use the internal audio_preload backup when hot cache is off.

* docs: add CHANGELOG entry for Preload Next Track removal (PR #1007)
2026-06-06 01:01:08 +03:00
cucadmuh a66d932afe fix(preview): Symphonia format sniff and ranged stream startup (#1006)
* fix(preview): Symphonia format sniff and cluster member stream URLs

Resolve preview container hints from HTTP headers, Subsonic suffix, and
magic-byte sniff after Symphonia 0.6. Route preview streams through
clusterBrowseServerId like main playback; guard CoverArtImage when the
preview cover ref is still loading.

* fix(preview): adapt Symphonia sniff branch for main without cluster routing

Keep formatSuffix and cover-ref guards from the cluster work; use
buildStreamUrl on the active server instead of clusterBrowseServerId.

* fix(preview): use ranged HTTP so preview starts without full-file download

Open preview via RangedHttpSource when the server supports byte ranges;
fall back to buffered download otherwise. Gate in-memory probe with
ProbeSeekGate so Symphonia 0.6 does not scan the entire file before audio.

* docs: CHANGELOG and credits for preview fix PR #1006

* chore: drop PR #1006 from settings credits (minor fix)

* fix(preview): allow clippy too_many_arguments on audio_preview_play

formatSuffix pushed the Tauri command to 8 args; matches other IPC commands.
2026-06-05 22:59:20 +03:00
Frank Stellmacher f706336e58 fix(waveform): repaint on Vite HMR so theme CSS var edits show live (#1005)
* fix(waveform): repaint on Vite HMR so theme CSS var edits show live

The seekbar caches its colours and repaints the canvas on a data-theme
MutationObserver, so a manual theme switch picks up new waveform colours.
Editing a theme's CSS variables via Vite HMR changes the stylesheet but
not data-theme, so the observer never fires and the canvas keeps the old
palette until a theme switch — annoying during theme dev.

Add a dev-only effect that drops the colour cache and repaints on every
HMR update (vite:afterUpdate). import.meta.hot is undefined in production
builds, so the effect is stripped there — no runtime cost.

* fix(waveform): guard HMR effect against partial import.meta.hot in tests

vitest's SSR env exposes a partial import.meta.hot (has .on, no .off), so
the cleanup threw and failed all WaveformSeek/PlayerBar tests. Feature-detect
both .on and .off before wiring the vite:afterUpdate listener.
2026-06-05 18:44:14 +02:00
635 changed files with 24567 additions and 18720 deletions
+203
View File
@@ -40,6 +40,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Offline — unified local playback, library index join, and favorites sync
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1008](https://github.com/Psychotoxical/psysonic/pull/1008)**
* All local audio bytes live under one **`media/`** tree: `cache/` (ephemeral hot-cache), `library/` (user-pinned offline), and `favorites/` (auto-synced stars). Paths use library-index metadata and the URL-derived server index key so two profiles on the same server share one bucket.
* **`localPlaybackStore`** replaces the split hot-cache / offline metadata stores — one index drives prefetch, promotion, eviction, and `psysonic-local://` playback resolution.
* **Offline Library** lists pinned and favorites-tier tracks by joining that index with the SQLite library catalog (no duplicate offline album cards). Pin album, playlist, or artist from browse; disk usage shown in the Offline Library header.
* **Favorites auto-sync** keeps starred tracks on disk in `media/favorites/` with a compact toggle, cross-server reconcile, and cancel-on-unstar so orphaned files are not left behind.
* **Cached offline pins stay in sync** — manually pinned **albums** and **artist discographies** reconcile after a library index sync (delta/full); **regular playlists** reconcile hourly and when edited in-app. Added tracks download and removed ones are pruned. **Smart playlists** (`psy-smart-…`) are excluded — their contents refresh from server rules automatically.
* Mixed-server queues play offline with correct per-track server scope; network guards skip Subsonic when local bytes exist.
* Startup migration from legacy `psysonic-offline/` layout; Settings → Storage uses a single **media directory** picker and a live hot-cache track count.
### Themes — community Theme Store
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1009](https://github.com/Psychotoxical/psysonic/pull/1009), [#1011](https://github.com/Psychotoxical/psysonic/pull/1011), [#1012](https://github.com/Psychotoxical/psysonic/pull/1012), [#1013](https://github.com/Psychotoxical/psysonic/pull/1013), [#1014](https://github.com/Psychotoxical/psysonic/pull/1014), [#1015](https://github.com/Psychotoxical/psysonic/pull/1015), [#1016](https://github.com/Psychotoxical/psysonic/pull/1016), [#1018](https://github.com/Psychotoxical/psysonic/pull/1018), [#1020](https://github.com/Psychotoxical/psysonic/pull/1020), [#1036](https://github.com/Psychotoxical/psysonic/pull/1036), [#1038](https://github.com/Psychotoxical/psysonic/pull/1038), [#1041](https://github.com/Psychotoxical/psysonic/pull/1041)**
* New **Settings → Themes** tab: pick a theme, set the day/night scheduler, and browse a built-in **Theme Store** to install, update and uninstall community themes — with search, a dark/light filter, and full-size thumbnail previews.
* The app now bundles six core themes (Catppuccin Mocha & Latte, Kanagawa Wave, Stark HUD, and the colour-blind-safe Vision Dark / Vision Navy); every other palette installs on demand from the [psysonic-themes](https://github.com/Psysonic/psysonic-themes) repo. Installed themes are saved locally and apply instantly at startup, even offline.
* **Import a theme from a local `.zip`** (manifest.json + theme.css): the package is validated, you confirm its name and author, then it installs like any other community theme.
* Themes are **free-form** — beyond recolouring, they can add their own styling and animations and react to playback / fullscreen / sidebar / lyrics state. A safety floor (no network, no scripts) is always enforced; store themes are reviewed, and imported themes install at your own risk.
* The store paginates large catalogues, and refreshing the list no longer jumps back to the top.
* Each store theme shows its **total downloads** and a **last-changed** date, and can be sorted by most popular, newest or name; the catalogue now has numbered page navigation. These stats refresh once a day.
* The **Now Playing** page now follows the active theme end to end — light themes render it legibly instead of washed-out, with no per-theme tweaks needed.
* The sidebar now shows a small notice when one of your installed themes has an update available — click it to jump straight to the Theme Store, or dismiss it until the next update. Themes with an update also show an update control right on their card under **Settings → Themes** to update them in place with one click, and the store refreshes once on startup so new themes and updates show up without hitting refresh.
* Upgrading from an older build: an active or scheduled theme that has moved to the store and isn't installed falls back to Mocha (dark) / Latte (light).
### Offline — local-bytes browse when the server is down
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1017](https://github.com/Psychotoxical/psysonic/pull/1017)**
* When the active server is unreachable, browse and detail pages read from **local playback bytes** and the **library index** instead of Subsonic — albums, artists, tracks, cached playlists, and cross-server favorites.
* Single integration contract: `offlineBrowseContext`, `offlineActionPolicy`, and `resolveAlbum` / `resolveArtist` / `resolvePlaylist` resolvers; context menus and detail toolbars block server mutations offline.
* Disconnect navigation forks by offline capability (stay on page, stay-reload, or redirect); Home reuses the last cached feed snapshot; DEV offline toggle simulates full disconnect for testing.
* PlayerBar hides star rating and favorite controls while offline browse is active.
### Startup — themed loading splash before the app bundle loads
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1030](https://github.com/Psychotoxical/psysonic/pull/1030)**
* Inline splash in `index.html` (progress bar + P logo) shows while the Vite bundle loads in dev and production — no empty or black window on launch.
* Splash colours follow the persisted theme (built-in palettes, day/night scheduler, and installed community themes); the logo uses each theme's accent gradient instead of a hardcoded white asset.
* The native window stays hidden until the splash has painted (`visible: false` + deferred `show` from Rust/JS); window-state restore no longer overrides startup visibility.
### Servers — software and version on each server card
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1045](https://github.com/Psychotoxical/psysonic/pull/1045)**
* Each server card under **Settings → Servers** now shows the server software and version (e.g. `Navidrome 0.62.0`) under the server name. The value comes from the existing connection ping, so no extra request is made; it is hidden for servers that don't report it (plain Subsonic without OpenSubsonic).
## Changed
### Dependencies — npm and Rust refresh
@@ -60,8 +119,68 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Playback — Preload Next Track setting removed
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1007](https://github.com/Psychotoxical/psysonic/pull/1007)**
* The **Preload Next Track** toggle and timing modes under **Settings → Storage → Buffering** are gone — ranged streaming now starts playback without that extra RAM prefetch.
* Gapless and crossfade still prefetch the next track internally when Hot Cache is off; Hot Cache is unchanged.
### PsyLab — Performance Probe rename, Tuning tab, and log tools
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1027](https://github.com/Psychotoxical/psysonic/pull/1027)**
* **Ctrl+Shift+D** opens **PsyLab** (formerly Performance Probe). Cover backfill thread tuning moved to a new **Tuning** tab.
* **Logs** tab: selectable text, toolbar copy/export, and a context-menu **Copy** for the current selection.
* Runtime log lines are sanitized before they enter the buffer — Subsonic/auth tokens and remote hostnames are redacted or partially masked; LAN and localhost addresses stay readable.
### PsyLab — Connections tab and Navidrome admin role
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1033](https://github.com/Psychotoxical/psysonic/pull/1033)**
* New **Connections** tab: session/endpoint status, active-server capability readout (OpenSubsonic, AudioMuse detection, provider/strategy, detection trust, resolved call route, and AudioMuse mode), and queue-playback server when it differs from the active profile.
* Navidrome **admin vs standard user** badge via native login probe — useful when diagnosing plugin/settings visibility.
### Servers — capability framework with AudioMuse sonic routing (Navidrome ≥ 0.62)
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1033](https://github.com/Psychotoxical/psysonic/pull/1033)**
* New declarative **server-capability framework** (`src/serverCapabilities/`): a catalog picks a feature strategy per server generation, runs only the needed probes, and routes API calls — replacing scattered version checks in the UI and call sites.
* Navidrome **0.62+**: detect the AudioMuse-AI plugin from `getOpenSubsonicExtensions` when `sonicSimilarity` is advertised — the first reliable signal. Settings shows an **auto-managed status indicator** (no manual toggle); older Navidrome keeps the manual toggle and the legacy `getSimilarSongs` Instant Mix probe.
* **Path routing**: Instant Mix and Lucky Mix prefer the OpenSubsonic `getSonicSimilarTracks` endpoint when the plugin is present, falling back to legacy `getSimilarSongs`.
## Fixed
### Now Playing — cards no longer blank out on track change
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1042](https://github.com/Psychotoxical/psysonic/pull/1042)**
* The "from this album", "discography" and "most played" sections on the Now Playing page disappeared after a track change once the next track started playing from the local cache, and didn't come back. The page now keeps loading that info whenever the server is reachable, regardless of whether the audio plays from cached or offline bytes.
### Now Playing — metadata reads from the local library index first
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1049](https://github.com/Psychotoxical/psysonic/pull/1049)**
* The "from this album", "discography", "most played" and song details on the Now Playing page now come from the local library index when it has them, only falling back to the server when the index can't serve a row. Cards and fields (genre, play count, contributors) stay populated during cached and offline playback, with fewer server requests.
### Library DB — named slow-write ops for stall diagnosis
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1043](https://github.com/Psychotoxical/psysonic/pull/1043)**
* Production `library-db` write paths now log stable `module.action` op names instead of the generic `misc`, so the next `[library-db] SLOW write` line on macOS (or elsewhere) identifies the call site — diagnostic step for playback stalls under long write-lock holds ([#1040](https://github.com/Psychotoxical/psysonic/issues/1040)).
### Servers — complete border on the active server card
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#998](https://github.com/Psychotoxical/psysonic/pull/998)**
@@ -79,6 +198,90 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Playback — macOS stutter from background device checks
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1039](https://github.com/Psychotoxical/psysonic/pull/1039)**
* On some macOS setups playback stuttered at a steady ~3-second cadence: a background check that scans every audio output device ran on each poll and briefly contended with playback. It now runs only when a specific output device is pinned; with the system default (the common case) a single lightweight check runs instead.
### Track preview — Symphonia 0.6 format hints and fast stream start
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1006](https://github.com/Psychotoxical/psysonic/pull/1006)**
* Preview resolves container format from HTTP headers, Subsonic `suffix`, and magic-byte sniff so Symphonia 0.6 no longer fails with `.unknown` demuxer errors.
* Preview opens via ranged HTTP when the server supports byte ranges — audio starts after ~384 KiB buffered instead of waiting for a full-file download; buffered fallback uses the same probe seek-gate as main playback.
* Player bar cover guard while preview metadata loads; progress ring leaves the loading spinner once the engine emits `audio:preview-start`.
### Mainstage — hero backdrop stays in sync when skipping albums quickly
**By [@cucadmuh](https://github.com/cucadmuh), reported by Asra on the Psysonic Discord, PR [#1021](https://github.com/Psychotoxical/psysonic/pull/1021)**
* Rapid prev/next clicks on the Mainstage hero no longer leave the blurred cover-art background on the previous album while the foreground cover and metadata already show the next one.
### Song rails — multi-artist credits link to each artist
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on Discord, PR [#1023](https://github.com/Psychotoxical/psysonic/pull/1023)**
* **Random Picks**, **Discover Songs**, and other song cards now split OpenSubsonic `artists[]` into individually clickable names — the same behaviour as album track rows and the player bar, instead of one link for the whole joined credit string.
* Album cards and the rest of the app share the same artist-ref helper, including when Subsonic returns a single ref object instead of a one-element array.
### Fullscreen player — corner clock follows Clock format setting
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on Discord, PR [#1025](https://github.com/Psychotoxical/psysonic/pull/1025)**
* The wall clock in the fullscreen player now honours **Settings → System → Clock format** (24-hour vs 12-hour), matching the queue ETA and sleep-timer preview instead of always showing AM/PM.
### All Albums — Only compilations filter returns results
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on Discord, PR [#1026](https://github.com/Psychotoxical/psysonic/pull/1026)**
* The **Only compilations** toggle on **All Albums** no longer returns an empty list when compilations are tagged via **Various Artists** as album artist or when genre is combined with other browse filters — local index SQL, track-grouped browse, and client-side detection now agree on the same compilation signals.
### Artist page — Top Tracks play button
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1031](https://github.com/Psychotoxical/psysonic/pull/1031)**
* Play on **Top Tracks** rows no longer silently does nothing when the artist page has top songs but no albums loaded (e.g. lossless artist view); playback starts from the clicked track and continues into the catalog when albums are available.
### PsyLab — tab bar no longer collapses on the Logs tab
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1033](https://github.com/Psychotoxical/psysonic/pull/1033)**
* The PsyLab tab row keeps its height when the Logs flex layout fills the modal — tabs were previously squashed to a thin strip.
### Themes — consistent focus borders on inputs and dropdowns
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1052](https://github.com/Psychotoxical/psysonic/pull/1052)**
* Text fields no longer draw a double border when focused — they now show a single clean ring across every theme. The colour-blind-safe themes keep their stronger high-contrast focus ring on every field, including the header search.
* Dropdown and popover borders now follow the active theme in all themes (a couple of themes previously rendered them with an unthemed colour).
### Home — Most Played no longer jumps the page when loading more
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#1053](https://github.com/Psychotoxical/psysonic/pull/1053)**
* Clicking the arrow to load more albums in the **Most Played** rail sometimes snapped the page up to an earlier section. Loading more now keeps the viewport in place.
## [1.47.0]
> **🙏 Thank you to our amazing Discord community.** This release would not have been possible without your tireless support, quality checks, bug reports and all-round collaboration. Every report, every repro and every bit of feedback shaped what shipped here — thank you. Come join us: [discord.gg/AMnDRErm4u](https://discord.gg/AMnDRErm4u)
-3
View File
@@ -32,9 +32,6 @@ More translations are added over time.
---
> [!WARNING]
> Psysonic is under active development. Bugs and rough edges can happen, and features may change as the project evolves.
## What is Psysonic?
Psysonic is a desktop music client for self-hosted music libraries. It is designed for people who want the freedom of their own server without giving up the comfort, polish and speed of a modern music app.
+82
View File
@@ -6,9 +6,91 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Psysonic Dein Navidrome Desktop Player" />
<title>Psysonic</title>
<script src="/startup-splash-preflight.js"></script>
<style>
html, body {
margin: 0;
min-height: 100%;
background: var(--startup-splash-bg, #1e1e2e);
}
#app-startup-splash {
--splash-bg: var(--startup-splash-bg, #1e1e2e);
--splash-text: var(--startup-splash-text, #cdd6f4);
--splash-muted: var(--startup-splash-muted, #a6adc8);
--splash-accent: var(--startup-splash-accent, #cba6f7);
--splash-track: var(--startup-splash-track, #313244);
--splash-logo-start: var(--startup-splash-logo-start, var(--splash-accent));
--splash-logo-end: var(--startup-splash-logo-end, var(--splash-accent));
position: fixed;
inset: 0;
z-index: 2147483646;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.75rem;
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;
width: auto;
opacity: 0.95;
}
#app-startup-splash .app-startup-splash__label {
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--splash-muted);
}
#app-startup-splash .app-startup-splash__bar {
width: min(240px, 72vw);
height: 4px;
border-radius: 999px;
background: var(--splash-track);
overflow: hidden;
}
#app-startup-splash .app-startup-splash__bar-fill {
width: 42%;
height: 100%;
border-radius: inherit;
background: var(--splash-accent);
animation: app-startup-splash-indeterminate 1.15s ease-in-out infinite;
}
@keyframes app-startup-splash-indeterminate {
0% { transform: translateX(-120%); }
100% { transform: translateX(320%); }
}
</style>
</head>
<body>
<div id="app-startup-splash" role="status" aria-live="polite" aria-label="Loading Psysonic">
<svg class="app-startup-splash__logo" viewBox="0 0 115.549 130.30972" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<defs>
<linearGradient id="startupSplashLogoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="var(--splash-logo-start)" />
<stop offset="100%" stop-color="var(--splash-logo-end)" />
</linearGradient>
</defs>
<g transform="translate(220.53237,27.789086)">
<path fill="url(#startupSplashLogoGrad)" d="m -191.83501,87.581279 v -14.93937 l 1.01946,-0.029 c 1.8496,-0.0526 5.09881,-2.007 6.98453,-4.20123 2.13731,-2.48697 3.28384,-4.43657 4.52545,-7.69521 0.51751,-1.35819 1.078,-2.78694 1.24554,-3.175 0.16755,-0.38805 0.88173,-2.7693 1.58707,-5.29166 0.70533,-2.52236 1.41605,-4.90361 1.57937,-5.29167 0.16441,-0.39067 0.30759,11.85061 0.32081,27.42847 l 0.0239,28.134031 h -8.64306 -8.64305 z m -3.42317,-19.65031 c -0.81559,-0.16111 -1.84746,-0.48272 -2.29306,-0.71468 -1.09242,-0.5687 -2.72853,-2.16884 -2.74064,-2.68038 -0.005,-0.22765 -0.38465,-0.86265 -0.84281,-1.41111 -0.8626,-1.03264 -2.38323,-4.66133 -4.63113,-11.05137 -1.72997,-4.91772 -1.63358,-4.68451 -3.35352,-8.11389 -0.82714,-1.64924 -1.91998,-3.45186 -2.42853,-4.00582 -1.28805,-1.40307 -4.41406,-2.7715 -6.89485,-3.01827 l -2.08965,-0.20785 1.43221,-0.99035 c 1.5468,-1.06957 5.31147,-2.35399 6.9124,-2.35835 1.72563,-0.005 4.25283,0.7809 5.71247,1.77575 1.63175,1.11217 3.92377,3.83335 3.77488,4.48172 -0.0559,0.24344 0.11427,0.44261 0.37817,0.44261 0.53171,0 3.78445,6.24176 3.78445,7.26208 0,0.15195 0.30609,0.92171 0.6802,1.71057 0.37412,0.78887 1.08633,2.44854 1.5827,3.68817 1.00279,2.50434 2.57055,5.33152 2.95544,5.32962 0.85183,-0.004 3.83204,-7.97894 5.40479,-14.46266 1.9193,-7.91232 5.01161,-18.44694 6.10967,-20.81389 2.30114,-4.96024 4.60601,-7.03734 8.12223,-7.31959 1.95377,-0.15683 2.44243,-0.0601 4.01261,0.79453 2.49546,1.35819 3.31044,2.35029 5.40102,6.57479 0.93741,1.89425 3.29625,9.1126 4.36446,13.35583 0.51289,2.03729 1.21262,4.57729 1.55498,5.64444 0.34236,1.06716 0.83543,2.65466 1.09573,3.52778 0.96371,3.23267 3.75139,8.2344 5.51689,9.89856 2.09506,1.9748 4.10606,3.2977 5.85136,3.84922 0.72761,0.22993 1.32292,0.49404 1.32292,0.58692 0,0.0929 -0.71641,0.48577 -1.59202,0.87309 -2.29705,1.01609 -6.48839,1.02714 -8.75823,0.0231 -3.42674,-1.51581 -6.17101,-4.45149 -8.36088,-8.94406 -0.59782,-1.22642 -1.23412,-2.50231 -1.41401,-2.8353 -0.17988,-0.333 -0.47718,-1.20612 -0.66066,-1.94028 -0.74987,-3.00045 -6.42415,-19.25706 -6.99617,-20.04376 -0.79895,-1.09881 -0.87818,-1.08476 -1.55823,0.27628 -1.1693,2.3402 -2.07427,5.18987 -3.61302,11.37709 -3.03871,12.21839 -6.36478,22.38234 -8.0081,24.47148 -0.36655,0.466 -0.66646,0.99153 -0.66646,1.16785 0,0.86017 -2.61454,3.05174 -4.28395,3.59089 -1.94625,0.62857 -2.53141,0.65417 -4.78366,0.20926 z m 49.82815,-13.29265 c -2.77991,-0.70614 -6.29714,-6.05076 -8.15323,-12.38927 -0.30389,-1.03778 -0.47868,-1.96073 -0.38841,-2.051 0.0903,-0.0903 1.5695,-0.22877 3.28719,-0.30779 8.47079,-0.38969 9.78292,-0.63406 14.05919,-2.61837 3.78653,-1.75706 9.09259,-6.79386 10.56941,-10.03304 3.78708,-8.30644 4.33485,-14.20262 2.08448,-22.4376404 -1.15336,-4.22063002 -3.6401,-8.21361 -6.73205,-10.80969 -1.12271,-0.94265 -2.12066,-1.8146 -2.21767,-1.93765 -0.3794,-0.48123 -4.30858,-2.4333296 -6.41876,-3.1889796 -2.16778,-0.77628 -2.64336,-0.79956 -18.71666,-0.91597 l -16.49236,-0.11945 V -0.68605142 10.798429 h -0.8256 c -1.53109,0 -5.09758,2.09614 -6.79456,3.99338 -1.65639,1.85186 -4.54446,7.43871 -5.41264,10.47051 -0.25002,0.87312 -0.58222,1.98437 -0.73823,2.46944 -0.39136,1.2169 -2.0765,7.30176 -3.12634,11.28889 -0.2052,0.7793 -0.33685,-11.27627 -0.35693,-32.6846104 l -0.0318,-33.9193396 1.55319,-0.12371 c 0.85426,-0.068 12.32395,-0.10028 25.4882,-0.0716 20.69377,0.045 24.2694,0.12953 26.40444,0.62402 3.9887,0.92382 7.58472,2.04932 7.58472,2.3739 0,0.16576 0.52886,0.30139 1.17524,0.30139 2.09331,0 10.76432,4.87704 10.22435,5.75072 -0.12186,0.19718 -0.0447,0.24734 0.17328,0.11263 0.60692,-0.3751 4.21691,3.0333 6.9953,6.60467 2.06429,2.6534496 4.63504,8.4775396 5.94174,13.4611396 1.7681,6.7433 1.74625,15.8657704 -0.0549,22.9305504 -2.11084,8.27937 -4.97852,13.41407 -10.75456,19.25647 -2.59968,2.62955 -8.78375,7.02548 -9.88326,7.02548 -0.27557,0 -0.68644,0.1854 -0.91304,0.412 -0.39593,0.39593 -0.78905,0.56749 -4.31522,1.88319 -3.68968,1.37672 -10.83412,2.28545 -13.21446,1.68081 z m 7.57002,-15.26489 c 0,-0.19403 -0.07,-0.35278 -0.15557,-0.35278 -0.0856,0 -0.25368,0.15875 -0.3736,0.35278 -0.11992,0.19403 -0.0499,0.35278 0.15557,0.35278 0.20548,0 0.3736,-0.15875 0.3736,-0.35278 z" />
</g>
</svg>
<div class="app-startup-splash__bar" aria-hidden="true">
<div class="app-startup-splash__bar-fill"></div>
</div>
<span class="app-startup-splash__label">Loading</span>
</div>
<div id="root"></div>
<script src="/startup-splash-reveal.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+161
View File
@@ -0,0 +1,161 @@
/**
* Synchronous startup splash theme (before the Vite bundle loads).
* Keep palette ids/hex in sync with `src/config/startupSplashPalettes.ts`.
*/
(function startupSplashPreflight() {
var THEME_KEY = 'psysonic_theme';
var INSTALLED_KEY = 'psysonic_installed_themes';
var DEFAULT = {
bg: '#1e1e2e',
text: '#cdd6f4',
muted: '#a6adc8',
accent: '#cba6f7',
track: '#313244',
logoStart: '#cba6f7',
logoEnd: '#89b4fa',
};
var BUILTIN = {
mocha: DEFAULT,
latte: {
bg: '#eff1f5',
text: '#4c4f69',
muted: '#6c6f85',
accent: '#8839ef',
track: '#ccd0da',
logoStart: '#8839ef',
logoEnd: '#1e66f5',
},
'kanagawa-wave': {
bg: '#1F1F28',
text: '#DCD7BA',
muted: '#727169',
accent: '#7E9CD8',
track: '#2A2A37',
logoStart: '#7E9CD8',
logoEnd: '#957FB8',
},
'stark-hud': {
bg: '#0b0f15',
text: '#e0f7fa',
muted: '#7da5aa',
accent: '#00f2ff',
track: '#141b24',
logoStart: '#00f2ff',
logoEnd: '#7df9ff',
},
'vision-dark': {
bg: '#0d0b12',
text: '#f2eef8',
muted: '#a6a2b8',
accent: '#ffd700',
track: '#16131e',
logoStart: '#ffd700',
logoEnd: '#a07af8',
},
'vision-navy': {
bg: '#0a1628',
text: '#e8eef8',
muted: '#9caac2',
accent: '#ffd700',
track: '#12213a',
logoStart: '#ffd700',
logoEnd: '#a07af8',
},
};
function readCssVar(css, name) {
var match = css.match(new RegExp(name + '\\s*:\\s*([^;]+);'));
var value = match && match[1] ? match[1].trim() : '';
return value || null;
}
function resolveScheduledTheme(state) {
if (!state.enableThemeScheduler) return state.theme;
var now = new Date();
var nowMins = now.getHours() * 60 + now.getMinutes();
var dayParts = state.timeDayStart.split(':').map(Number);
var nightParts = state.timeNightStart.split(':').map(Number);
var dayMins = dayParts[0] * 60 + dayParts[1];
var nightMins = nightParts[0] * 60 + nightParts[1];
var isDay = dayMins < nightMins
? nowMins >= dayMins && nowMins < nightMins
: nowMins >= dayMins || nowMins < nightMins;
return isDay ? state.themeDay : state.themeNight;
}
function readThemeState() {
try {
var raw = localStorage.getItem(THEME_KEY);
if (!raw) return null;
var parsed = JSON.parse(raw);
var s = parsed && parsed.state;
if (!s) return null;
return {
enableThemeScheduler: !!s.enableThemeScheduler,
theme: String(s.theme || 'mocha'),
themeDay: String(s.themeDay || 'latte'),
themeNight: String(s.themeNight || 'mocha'),
timeDayStart: String(s.timeDayStart || '07:00'),
timeNightStart: String(s.timeNightStart || '19:00'),
};
} catch (_err) {
return null;
}
}
function readInstalledThemes() {
try {
var raw = localStorage.getItem(INSTALLED_KEY);
if (!raw) return [];
var parsed = JSON.parse(raw);
var themes = parsed && parsed.state && parsed.state.themes;
return Array.isArray(themes) ? themes : [];
} catch (_err) {
return [];
}
}
function paletteForTheme(themeId, installedThemes) {
if (BUILTIN[themeId]) return BUILTIN[themeId];
for (var i = 0; i < installedThemes.length; i += 1) {
var theme = installedThemes[i];
if (!theme || theme.id !== themeId || !theme.css) continue;
var bg = readCssVar(theme.css, '--bg-app');
var accent = readCssVar(theme.css, '--accent');
if (!bg || !accent) break;
var logoStart = readCssVar(theme.css, '--logo-color-start') || accent;
var logoEnd = readCssVar(theme.css, '--logo-color-end')
|| readCssVar(theme.css, '--accent-2')
|| accent;
return {
bg: bg,
text: readCssVar(theme.css, '--text-primary') || readCssVar(theme.css, '--ctp-text') || DEFAULT.text,
muted: readCssVar(theme.css, '--text-muted') || readCssVar(theme.css, '--ctp-subtext0') || DEFAULT.muted,
accent: accent,
track: readCssVar(theme.css, '--bg-card') || readCssVar(theme.css, '--border-subtle') || DEFAULT.track,
logoStart: logoStart,
logoEnd: logoEnd,
};
}
return DEFAULT;
}
function applyPalette(themeId, palette) {
var root = document.documentElement;
root.setAttribute('data-theme', themeId);
root.style.setProperty('--startup-splash-bg', palette.bg);
root.style.setProperty('--startup-splash-text', palette.text);
root.style.setProperty('--startup-splash-muted', palette.muted);
root.style.setProperty('--startup-splash-accent', palette.accent);
root.style.setProperty('--startup-splash-track', palette.track);
root.style.setProperty('--startup-splash-logo-start', palette.logoStart);
root.style.setProperty('--startup-splash-logo-end', palette.logoEnd);
root.style.background = palette.bg;
if (document.body) document.body.style.background = palette.bg;
}
var persisted = readThemeState();
var themeId = persisted ? resolveScheduledTheme(persisted) : 'mocha';
var palette = paletteForTheme(themeId, readInstalledThemes());
applyPalette(themeId, palette);
})();
+28
View File
@@ -0,0 +1,28 @@
/**
* Show the native window after the inline startup splash has painted.
* __TAURI_INTERNALS__ may not exist yet when this script first runs.
*/
(function startupSplashReveal() {
var MAX_ATTEMPTS = 60;
function tryShowMainWindow() {
var internals = window.__TAURI_INTERNALS__;
if (!internals || typeof internals.invoke !== 'function') return false;
internals.invoke('plugin:window|show', { label: 'main' }).catch(function () {});
return true;
}
function reveal(attempt) {
if (tryShowMainWindow()) return;
if (attempt >= MAX_ATTEMPTS) return;
window.setTimeout(function () {
reveal(attempt + 1);
}, 50);
}
requestAnimationFrame(function () {
requestAnimationFrame(function () {
reveal(0);
});
});
})();
+1
View File
@@ -4266,6 +4266,7 @@ dependencies = [
"psysonic-analysis",
"psysonic-audio",
"psysonic-core",
"psysonic-library",
"reqwest",
"serde",
"serde_json",
+2 -1
View File
@@ -6,6 +6,7 @@ resolver = "2"
version = "1.48.0-dev"
edition = "2021"
rust-version = "1.95"
license = "GPL-3.0-or-later"
[workspace.dependencies]
tempfile = "3"
@@ -18,7 +19,7 @@ name = "psysonic"
version.workspace = true
description = "Psysonic Desktop Music Player"
authors = []
license = ""
license.workspace = true
repository = ""
default-run = "psysonic"
edition.workspace = true
+2 -1
View File
@@ -21,6 +21,8 @@ accepted = [
"OpenSSL",
"BSL-1.0",
"CDLA-Permissive-2.0",
"GPL-3.0-or-later",
"bzip2-1.0.6",
]
# Skip the build host's own platform-pinning; we want a list across all targets
@@ -38,5 +40,4 @@ targets = [
ignore-build-dependencies = false
ignore-dev-dependencies = true
ignore-transitive-dependencies = false
filter-noassertion = false
workarounds = ["ring"]
@@ -3,6 +3,7 @@ name = "psysonic-analysis"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -291,7 +291,7 @@ impl AnalysisBackfillQueueState {
}
}
/// Frontend-maintained set of queue-neighbour track ids (next ~5 + preload next).
/// Frontend-maintained set of queue-neighbour track ids (next ~5 in queue).
#[derive(Default)]
pub struct PlaybackPriorityHints {
middle_track_ids: Mutex<HashSet<String>>,
@@ -516,6 +516,145 @@ pub async fn enqueue_track_analysis_from_file(
enqueue_track_analysis(app, server_id, track_id, &bytes, format_hint.as_deref(), priority).await
}
/// Library-tier offline pin: reuse waveform/LUFS cached under the playback index key,
/// plan enrichment under the library UUID, and skip work when both scopes are complete.
pub async fn enqueue_offline_library_analysis_from_file(
app: &tauri::AppHandle,
server_index_key: &str,
library_server_id: &str,
track_id: &str,
file_path: &std::path::Path,
explicit_priority: Option<AnalysisBackfillPriority>,
) -> Result<(), String> {
use tokio::io::AsyncReadExt;
use crate::track_analysis_plan::plan_track_analysis_offline_library;
let mut file = tokio::fs::File::open(file_path)
.await
.map_err(|e| e.to_string())?;
let mut prefix = vec![0u8; 16384];
let n = file.read(&mut prefix).await.map_err(|e| e.to_string())?;
prefix.truncate(n);
if prefix.is_empty() {
return Ok(());
}
let content_hash = analysis_cache::md5_first_16kb(&prefix);
let plan = plan_track_analysis_offline_library(
app,
&[server_index_key, library_server_id],
library_server_id,
track_id,
&content_hash,
);
if !plan.any() {
crate::app_deprintln!(
"[analysis] offline library seed skip (complete) track_id={} index={} library={}",
track_id,
server_index_key,
library_server_id,
);
return Ok(());
}
let bytes = tokio::fs::read(file_path)
.await
.map_err(|e| e.to_string())?;
let format_hint = file_path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.filter(|e| !e.is_empty());
let priority = explicit_priority.unwrap_or_else(|| {
analysis_backfill_resolve_priority(app, server_index_key, track_id, None)
});
enqueue_track_analysis_offline_library_with_plan(OfflineLibraryAnalysisEnqueue {
app,
cache_server_id: server_index_key,
enrichment_server_id: library_server_id,
track_id,
bytes: &bytes,
format_hint: format_hint.as_deref(),
priority,
plan,
fetch_ms: 0,
})
.await?;
Ok(())
}
struct OfflineLibraryAnalysisEnqueue<'a> {
app: &'a tauri::AppHandle,
cache_server_id: &'a str,
enrichment_server_id: &'a str,
track_id: &'a str,
bytes: &'a [u8],
format_hint: Option<&'a str>,
priority: AnalysisBackfillPriority,
plan: psysonic_core::track_analysis::TrackAnalysisPlan,
fetch_ms: u64,
}
async fn enqueue_track_analysis_offline_library_with_plan(
args: OfflineLibraryAnalysisEnqueue<'_>,
) -> Result<EnqueueTrackAnalysisOutcome, String> {
if args.bytes.is_empty() || !args.plan.any() {
return Ok(EnqueueTrackAnalysisOutcome::Complete);
}
let content_hash = analysis_cache::md5_first_16kb(args.bytes);
if args.plan.needs_full_cpu_seed() {
crate::app_deprintln!(
"[analysis] queue full seed track_id={} hash={} need_waveform={} need_loudness={} need_enrichment={}",
args.track_id,
content_hash,
args.plan.need_waveform,
args.plan.need_loudness,
args.plan.enrichment.any()
);
submit_analysis_cpu_seed(
args.app.clone(),
args.cache_server_id.to_string(),
args.track_id.to_string(),
args.bytes.to_vec(),
args.format_hint.map(str::to_string),
args.priority,
args.fetch_ms,
)
.await?;
return Ok(EnqueueTrackAnalysisOutcome::QueuedFullSeed);
}
if args.plan.needs_enrichment_only() {
crate::app_deprintln!(
"[analysis] enrichment-only track_id={} hash={}",
args.track_id,
content_hash
);
let bpm_started = std::time::Instant::now();
let outcome = run_track_enrichment_from_bytes(
args.app,
args.enrichment_server_id,
args.track_id,
args.bytes,
analysis_emits_ui_events(args.priority),
)
.await;
if matches!(outcome, TrackEnrichmentOutcome::Failed) {
if let Some(cache) = args.app.try_state::<analysis_cache::AnalysisCache>() {
let key = analysis_cache::TrackKey {
server_id: args.cache_server_id.to_string(),
track_id: args.track_id.to_string(),
md5_16kb: content_hash.clone(),
};
let _ = cache.touch_track_status(&key, "failed");
}
return Err("track enrichment failed".to_string());
}
let bpm_ms = bpm_started.elapsed().as_millis() as u64;
emit_analysis_track_perf(args.app, args.track_id, args.fetch_ms, 0, bpm_ms);
return Ok(EnqueueTrackAnalysisOutcome::RanEnrichmentOnly);
}
Ok(EnqueueTrackAnalysisOutcome::Complete)
}
/// Decode `bytes` for `track_id` via the cpu-seed queue. Prefer [`enqueue_track_analysis`].
pub async fn enqueue_analysis_seed(
app: &tauri::AppHandle,
@@ -15,8 +15,21 @@ pub fn plan_track_analysis(
track_id: &str,
content_hash: &str,
) -> TrackAnalysisPlan {
let (need_waveform, need_loudness) = cache_gaps(app, server_id, track_id, content_hash);
let enrichment = enrichment_plan(app, server_id, track_id, content_hash);
plan_track_analysis_offline_library(app, &[server_id], server_id, track_id, content_hash)
}
/// Offline/library download: waveform cache and enrichment facts may live under the
/// playback index key while library rows use the UUID — try every scope before seeding.
pub fn plan_track_analysis_offline_library(
app: &AppHandle,
cache_server_ids: &[&str],
_enrichment_server_id: &str,
track_id: &str,
content_hash: &str,
) -> TrackAnalysisPlan {
let (need_waveform, need_loudness) =
cache_gaps_multi(app, cache_server_ids, track_id, content_hash);
let enrichment = enrichment_plan_multi(app, cache_server_ids, track_id, content_hash);
TrackAnalysisPlan {
need_waveform,
need_loudness,
@@ -103,6 +116,32 @@ fn cache_gaps(
)
}
fn cache_gaps_multi(
app: &AppHandle,
server_ids: &[&str],
track_id: &str,
content_hash: &str,
) -> (bool, bool) {
let mut need_waveform = true;
let mut need_loudness = true;
for &server_id in server_ids {
if server_id.is_empty() {
continue;
}
let (nw, nl) = cache_gaps(app, server_id, track_id, content_hash);
if !nw {
need_waveform = false;
}
if !nl {
need_loudness = false;
}
if !need_waveform && !need_loudness {
break;
}
}
(need_waveform, need_loudness)
}
fn enrichment_plan(
app: &AppHandle,
server_id: &str,
@@ -117,6 +156,45 @@ fn enrichment_plan(
.unwrap_or_default()
}
fn enrichment_plan_multi(
app: &AppHandle,
server_ids: &[&str],
track_id: &str,
content_hash: &str,
) -> psysonic_core::track_enrichment::TrackEnrichmentPlan {
let mut need_bpm = true;
let mut need_valence = true;
let mut need_arousal = true;
let mut need_moods = true;
for &server_id in server_ids {
if server_id.is_empty() {
continue;
}
let plan = enrichment_plan(app, server_id, track_id, content_hash);
if !plan.need_bpm {
need_bpm = false;
}
if !plan.need_valence {
need_valence = false;
}
if !plan.need_arousal {
need_arousal = false;
}
if !plan.need_moods {
need_moods = false;
}
if !need_bpm && !need_valence && !need_arousal && !need_moods {
break;
}
}
psysonic_core::track_enrichment::TrackEnrichmentPlan {
need_bpm,
need_valence,
need_arousal,
need_moods,
}
}
fn cache_gaps_for_content(
cache: Option<&AnalysisCache>,
server_id: &str,
@@ -194,4 +272,14 @@ mod tests {
assert!(!wf && !ld, "bare id should resolve stream: cached fingerprint");
}
#[test]
fn playback_index_cache_row_not_visible_under_library_uuid_only() {
let cache = AnalysisCache::open_in_memory();
seed_waveform_loudness(&cache, "navidrome.test:4533", "t1", "abc");
let (wf, ld) = cache_gaps_for_content(Some(&cache), "library-uuid", "t1", "abc");
assert!(wf && ld, "library uuid alone should miss playback-scoped cache");
let (wf2, ld2) = cache_gaps_for_content(Some(&cache), "navidrome.test:4533", "t1", "abc");
assert!(!wf2 && !ld2, "playback index key should hit the cached row");
}
}
@@ -3,6 +3,7 @@ name = "psysonic-audio"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
+17 -4
View File
@@ -166,15 +166,24 @@ impl SizedDecoder {
inner: Cursor::new(data),
len: data_len,
};
// Symphonia 0.6 scans trailing metadata on seekable sources — hide
// seekability during probe (same as `new_streaming`) so preview does not
// read the entire in-memory file before the first sample.
let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint))
.then(|| Arc::new(AtomicBool::new(false)));
let media: Box<dyn MediaSource> = match &probe_seek_gate {
Some(gate) => Box::new(ProbeSeekGate {
inner: Box::new(source),
seekable: gate.clone(),
}),
None => Box::new(source),
};
// Hi-Res: 4 MB read-ahead so Symphonia demuxes fewer Read calls for
// high-bitrate files (88.2 kHz/24-bit FLAC ≈ 1800 kbps).
// Standard: 512 KB is plenty for MP3/AAC — larger buffers waste allocation
// and compete with the playback thread at track start.
let buf_len = if hi_res { 4 * 1024 * 1024 } else { 512 * 1024 };
let mss = MediaSourceStream::new(
Box::new(source) as Box<dyn MediaSource>,
MediaSourceStreamOptions { buffer_len: buf_len },
);
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: buf_len });
let mut hint = Hint::new();
if let Some(ext) = format_hint {
@@ -200,6 +209,10 @@ impl SizedDecoder {
}
})?;
if let Some(gate) = &probe_seek_gate {
gate.store(true, Ordering::Relaxed);
}
let track = format
.tracks()
.iter()
@@ -140,12 +140,6 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
let mut last_stall_recover_at: Option<Instant> = None;
let mut last_poll_at = Instant::now();
let mut watchdog_armed_until: Option<Instant> = None;
// DIAGNOSTIC (issue #996, EXPERIMENT BUILD ONLY — revert before release):
// counts watcher cycles so the heavy CoreAudio enumeration below runs only
// ~every 60s (20 × 3s) instead of every cycle. Tests whether that periodic
// enumeration is the source of the ~3s macOS playback stutter. The 3s loop,
// watchdog and stall detector are left untouched on purpose.
let mut diag_enum_skip: u32 = 0;
loop {
tokio::time::sleep(Duration::from_secs(3)).await;
@@ -230,20 +224,18 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
}
}
// DIAGNOSTIC (issue #996, EXPERIMENT BUILD ONLY — revert before release):
// run the heavy CoreAudio enumeration + device-change detection only
// ~every 60s. The stall detector above still runs every 3s, so playback
// recovery is unaffected. If the stutter cadence shifts to ~60s (or
// stops), the periodic enumeration is the culprit.
diag_enum_skip += 1;
if diag_enum_skip < 20 {
continue;
}
diag_enum_skip = 0;
// The full `output_devices()` + per-device `description()` scan is the
// CoreAudio HAL call that contends with the audio render thread and
// produces a brief dropout once per poll interval (issue #996: stutter
// every ~3s, cadence tracking the poll exactly). It is only needed to
// detect a *pinned* output device disappearing. With no pin — system
// default, the common case — only the current default is needed, a
// single cheap query, so the full enumeration is skipped entirely.
let pinned = selected_device.lock().unwrap().clone();
let need_full_enum = pinned.is_some();
// Enumerate all available output devices and the current default.
// Suppress stderr on Unix to avoid ALSA probing noise (JACK, OSS, dmix).
let (current_default, available) = tauri::async_runtime::spawn_blocking(|| {
let (current_default, available) = tauri::async_runtime::spawn_blocking(move || {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
#[cfg(unix)]
let _guard = unsafe {
@@ -261,26 +253,28 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
let default = host
.default_output_device()
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()));
let available: Vec<String> = host
.output_devices()
.map(|iter| {
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
.collect()
})
.unwrap_or_default();
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()
} else {
Vec::new()
};
(default, available)
}).await.unwrap_or((None, vec![]));
// Empty list almost always means a transient enumeration failure, not
// that every output device vanished. Treating it as "pinned missing"
// caused false audio:device-reset (UI jumped back to system default)
// when switching to external USB / class-compliant interfaces.
if available.is_empty() {
// Empty list (only when we actually enumerated for a pinned device)
// almost always means a transient enumeration failure, not that every
// output device vanished. Treating it as "pinned missing" caused false
// audio:device-reset (UI jumped back to system default) when switching
// to external USB / class-compliant interfaces.
if need_full_enum && available.is_empty() {
continue;
}
let pinned = selected_device.lock().unwrap().clone();
#[cfg(target_os = "linux")]
if pinned.is_some() {
// Do not infer "unplugged" from `output_devices()` when a device is pinned.
+298 -44
View File
@@ -1,6 +1,6 @@
//! Short preview playback on a secondary sink (same output stream).
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use rodio::Player;
@@ -9,8 +9,17 @@ use tauri::{AppHandle, Emitter, State};
use super::decode::SizedDecoder;
use super::engine::{audio_http_client, AudioEngine};
use super::helpers::MASTER_HEADROOM;
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,
MASTER_HEADROOM,
};
use super::play_input::url_format_hint;
use super::sources::PriorityBoostSource;
use super::stream::{
mp4_needs_tail_prefetch, ranged_download_task, wait_for_ranged_mp4_probe_ready,
RangedHttpSource, RangedMp4ProbeGate,
};
// ────────────────────────────────────────────────────────────────────────────
// Preview engine — secondary Sink on the same OutputStream, fed by Symphonia.
@@ -90,26 +99,246 @@ pub(crate) fn preview_resume_main(state: &AudioEngine) {
}
}
/// Format hint inferred from a Subsonic stream URL. The frontend always passes
/// a `format=flac` query param for `.opus` files (server transcodes); for
/// everything else we guess from the URL's `format=` value or fall back to None.
/// `format=` query param on Subsonic stream URLs (transcode targets).
pub(crate) fn preview_format_hint_from_url(url: &str) -> Option<String> {
url.split('?')
.nth(1)?
.split('&')
.find_map(|kv| {
let (k, v) = kv.split_once('=')?;
if k.eq_ignore_ascii_case("format") { Some(v.to_string()) } else { None }
if k.eq_ignore_ascii_case("format") {
Some(v.to_string())
} else {
None
}
})
}
/// Symphonia container hint for preview downloads — mirrors main playback:
/// Content-Type / Content-Disposition, URL tail, Subsonic suffix, magic-byte sniff.
pub(crate) fn resolve_preview_format_hint(
url: &str,
content_type: Option<&str>,
content_disposition: Option<&str>,
stream_suffix: Option<&str>,
bytes: &[u8],
) -> Option<String> {
let media_hint = content_type
.and_then(content_type_to_hint)
.or_else(|| {
content_disposition.and_then(format_hint_from_content_disposition)
});
let url_hint = preview_format_hint_from_url(url).or_else(|| url_format_hint(url));
resolve_playback_format_hint(
url_hint.as_deref(),
stream_suffix,
media_hint.as_deref(),
Some(bytes),
)
}
fn preview_http_client(state: &AudioEngine) -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.use_rustls_tls()
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.build()
.unwrap_or_else(|_| audio_http_client(state))
}
/// Open a preview decoder — ranged HTTP when the server supports it (starts
/// after ~384 KiB buffered), otherwise falls back to a full in-memory download.
async fn open_preview_decoder(
url: &str,
format_suffix: Option<&str>,
gen: u64,
state: &AudioEngine,
app: &AppHandle,
) -> Result<Option<SizedDecoder>, String> {
let preview_http = preview_http_client(state);
let response = preview_http
.get(url)
.send()
.await
.map_err(|e| format!("preview: connection failed: {e}"))?
.error_for_status()
.map_err(|e| format!("preview: HTTP {e}"))?;
let mut stream_hint = content_type_to_hint(
response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or(""),
)
.or_else(|| {
response
.headers()
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.and_then(format_hint_from_content_disposition)
})
.or_else(|| normalize_stream_suffix_for_hint(format_suffix))
.or_else(|| preview_format_hint_from_url(url))
.or_else(|| url_format_hint(url));
let supports_range = response
.headers()
.get(reqwest::header::ACCEPT_RANGES)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.to_ascii_lowercase().contains("bytes"));
let total_size = response.content_length();
if stream_hint.is_none() && supports_range {
if let Some(total_u64) = total_size.filter(|&t| t > 0) {
let last = total_u64
.saturating_sub(1)
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
if let Ok(pr) = preview_http
.get(url)
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
.send()
.await
{
let stat = pr.status();
let ok = stat == reqwest::StatusCode::PARTIAL_CONTENT
|| stat == reqwest::StatusCode::OK;
if ok {
if let Ok(bytes) = pr.bytes().await {
if !bytes.is_empty() {
stream_hint = sniff_stream_format_extension(&bytes).or(stream_hint);
}
}
}
}
}
}
if let (true, Some(total), true) = (supports_range, total_size, stream_hint.is_some()) {
if state.preview_gen.load(Ordering::SeqCst) != gen {
return Ok(None);
}
let total_usize = total as usize;
crate::app_deprintln!(
"[preview] ranged open — total={} KB, hint={:?}",
total_usize / 1024,
stream_hint
);
let buf = Arc::new(Mutex::new(vec![0u8; total_usize]));
let downloaded_to = Arc::new(AtomicUsize::new(0));
let done = Arc::new(AtomicBool::new(false));
let playback_armed = Arc::new(AtomicBool::new(false));
let tail_ready = Arc::new(AtomicBool::new(false));
let tail_filled_from = Arc::new(AtomicU64::new(0));
let tail_prefetch = mp4_needs_tail_prefetch(&[], stream_hint.as_deref());
let mp4_probe_gate = tail_prefetch.then(|| RangedMp4ProbeGate {
tail_ready: tail_ready.clone(),
buf: buf.clone(),
downloaded_to: downloaded_to.clone(),
gen_arc: state.preview_gen.clone(),
gen,
format_hint: stream_hint.clone(),
});
tokio::spawn(ranged_download_task(
gen,
state.preview_gen.clone(),
preview_http,
app.clone(),
0.0,
url.to_string(),
response,
buf.clone(),
downloaded_to.clone(),
done.clone(),
state.stream_completed_cache.clone(),
state.stream_completed_spill.clone(),
state.normalization_engine.clone(),
state.normalization_target_lufs.clone(),
state.loudness_pre_analysis_attenuation_db.clone(),
None,
None,
None,
playback_armed,
stream_hint.clone(),
tail_ready.clone(),
tail_filled_from.clone(),
));
if let Some(ref gate) = mp4_probe_gate {
wait_for_ranged_mp4_probe_ready(gate).await?;
if state.preview_gen.load(Ordering::SeqCst) != gen {
return Ok(None);
}
}
let reader = RangedHttpSource {
buf,
downloaded_to,
tail_ready,
tail_filled_from,
total_size: total,
pos: 0,
done,
gen_arc: state.preview_gen.clone(),
gen,
};
let hint = stream_hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream")
})
.await
.map_err(|e| format!("preview: decoder thread: {e}"))??;
return Ok(Some(decoder));
}
crate::app_deprintln!(
"[preview] buffered download — accept-ranges={}, content-length={:?}, hint={:?}",
supports_range,
total_size,
stream_hint
);
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let content_disposition = response
.headers()
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let bytes = response
.bytes()
.await
.map_err(|e| format!("preview: read body: {e}"))?
.to_vec();
if state.preview_gen.load(Ordering::SeqCst) != gen {
return Ok(None);
}
let hint = resolve_preview_format_hint(
url,
content_type.as_deref(),
content_disposition.as_deref(),
format_suffix,
&bytes,
);
let bytes_for_blocking = bytes;
let hint_for_blocking = hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false)
})
.await
.map_err(|e| format!("preview: decoder thread: {e}"))??;
Ok(Some(decoder))
}
#[tauri::command]
#[allow(clippy::too_many_arguments)] // Tauri IPC — args map 1:1 to the JS invoke payload.
pub async fn audio_preview_play(
id: String,
url: String,
start_sec: f64,
duration_sec: f64,
volume: f32,
format_suffix: Option<String>,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
@@ -134,48 +363,24 @@ pub async fn audio_preview_play(
preview_pause_main(&state);
}
// ── Download ─────────────────────────────────────────────────────────────
// Dedicated client with a generous timeout. The shared `audio_http_client`
// caps at 30 s, which aborts mid-download on multi-hundred-megabyte
// uncompressed files (e.g. 18-min Hi-Res WAV ~600 MB) — those need
// ~60120 s on a typical home LAN. The watchdog (30 s wall-clock) still
// bounds how long the preview plays once the bytes are in memory, so a
// long download just means a longer "loading" spinner before audio starts.
let preview_http = reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.use_rustls_tls()
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.build()
.unwrap_or_else(|_| audio_http_client(&state));
let bytes = preview_http
.get(&url)
.send()
.await
.map_err(|e| format!("preview: connection failed: {e}"))?
.error_for_status()
.map_err(|e| format!("preview: HTTP {e}"))?
.bytes()
.await
.map_err(|e| format!("preview: read body: {e}"))?
.to_vec();
// ── Open decoder (ranged stream when possible) ───────────────────────────
let decoder = match open_preview_decoder(
&url,
format_suffix.as_deref(),
gen,
&state,
&app,
)
.await?
{
Some(d) => d,
None => return Ok(()),
};
if state.preview_gen.load(Ordering::SeqCst) != gen {
// A newer preview started while we were downloading — bail.
return Ok(());
}
// ── Decode ───────────────────────────────────────────────────────────────
let hint = preview_format_hint_from_url(&url);
let bytes_for_blocking = bytes;
let hint_for_blocking = hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false)
})
.await
.map_err(|e| format!("preview: decoder thread: {e}"))??;
if state.preview_gen.load(Ordering::SeqCst) != gen { return Ok(()); }
// ── Build source pipeline ────────────────────────────────────────────────
// Seek FIRST on the bare decoder, THEN cap with take_duration. Capping
// before the seek made take_duration's wall-clock counter tick from
@@ -271,6 +476,55 @@ pub async fn audio_preview_play(
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_preview_format_hint_sniffs_flac_from_bytes() {
let hint = resolve_preview_format_hint(
"https://host/rest/stream.view?id=1",
None,
None,
None,
b"fLaC\x00\x00\x00\x22",
);
assert_eq!(hint.as_deref(), Some("flac"));
}
#[test]
fn resolve_preview_format_hint_prefers_content_type_over_sniff() {
let hint = resolve_preview_format_hint(
"https://host/rest/stream.view?id=1",
Some("audio/mpeg"),
None,
None,
b"fLaC\x00\x00\x00\x22",
);
assert_eq!(hint.as_deref(), Some("mp3"));
}
#[test]
fn resolve_preview_format_hint_uses_subsonic_suffix() {
let hint = resolve_preview_format_hint(
"https://host/rest/stream.view?id=1",
None,
None,
Some("flac"),
&[0x00, 0x01, 0x02, 0x03],
);
assert_eq!(hint.as_deref(), Some("flac"));
}
#[test]
fn preview_format_hint_from_url_reads_format_query_param() {
assert_eq!(
preview_format_hint_from_url("https://h/stream.view?format=opus&id=x"),
Some("opus".into())
);
}
}
#[tauri::command]
pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) {
preview_stop_inner(&app, &state, true);
@@ -3,6 +3,7 @@ name = "psysonic-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -29,17 +29,38 @@ pub fn is_fetch_only_cover_id(id: &str) -> bool {
|| id.starts_with("ra-")
}
/// Windows reserved device names (case-insensitive) — invalid as path components.
const WINDOWS_RESERVED_NAMES: &[&str] = &[
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
/// Sanitize a single path segment for Windows / Unix (Navidrome ids are usually already safe).
/// Also used for media layout artist/album/title segments from server metadata.
pub fn sanitize_path_segment(segment: &str) -> String {
const FORBIDDEN: &[char] = &['\\', '/', ':', '*', '?', '"', '<', '>', '|'];
let trimmed = segment.trim();
let trimmed = segment.trim().trim_end_matches(['.', ' ']).to_string();
if trimmed.is_empty() {
return "_".to_string();
}
trimmed
let cleaned: String = trimmed
.chars()
.map(|c| if FORBIDDEN.contains(&c) { '_' } else { c })
.collect()
.map(|c| {
if c.is_control() || FORBIDDEN.contains(&c) {
'_'
} else {
c
}
})
.collect();
if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
return "_".to_string();
}
let upper = cleaned.to_ascii_uppercase();
if WINDOWS_RESERVED_NAMES.contains(&upper.as_str()) {
return format!("_{cleaned}");
}
cleaned
}
/// Relative path under `{root}/{server_segment}/` — change format here only.
@@ -256,6 +277,13 @@ mod tests {
base
}
#[test]
fn sanitize_rejects_dot_dot_and_reserved_names() {
assert_eq!(sanitize_path_segment(".."), "_");
assert_eq!(sanitize_path_segment("CON"), "_CON");
assert_eq!(sanitize_path_segment(" trailing. "), "trailing");
}
#[test]
fn segment_disk_usage_counts_canonical_only() {
let server = test_server_dir("usage");
@@ -5,6 +5,8 @@
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
pub mod cover_cache_layout;
pub mod log_sanitize;
pub mod media_layout;
pub mod logging;
pub mod ports;
pub mod track_analysis;
@@ -0,0 +1,378 @@
//! Redact secrets and partially mask remote server hostnames before log lines
//! are stored or exported (PsyLab / Settings log export).
const SENSITIVE_QUERY_KEYS: &[&str] = &[
"t", "s", "p", "token", "password", "passwd", "secret", "api_key", "apikey",
"access_token", "refresh_token", "auth",
];
const SENSITIVE_KV_KEYS: &[&str] = &[
"password", "passwd", "token", "secret", "api_key", "apikey", "access_token",
"refresh_token", "authorization", "auth",
];
/// 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_sensitive_key_values(&out);
out = redact_urls_in_text(&out);
out
}
/// Never panic on the logging hot path — fall back to the raw line if needed.
pub fn sanitize_log_line_infallible(line: &str) -> String {
std::panic::catch_unwind(|| sanitize_log_line(line)).unwrap_or_else(|_| line.to_string())
}
fn redact_bearer_tokens(line: &str) -> String {
let marker = "Bearer ";
let mut s = line.to_string();
let mut search_from = 0;
while let Some(rel) = s[search_from..].find(marker) {
let idx = search_from + rel;
let start = idx + marker.len();
let end = s[start..]
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == ')' || c == ']')
.map(|i| start + i)
.unwrap_or(s.len());
if end > start {
s.replace_range(start..end, "REDACTED");
}
search_from = start + "REDACTED".len();
}
s
}
fn redact_sensitive_key_values(line: &str) -> String {
let mut out = line.to_string();
for key in SENSITIVE_KV_KEYS {
for sep in [':', '='] {
let needle = format!("{key}{sep}");
let lower = out.to_ascii_lowercase();
let mut search_from = 0;
while let Some(rel) = lower[search_from..].find(&needle) {
let idx = search_from + rel;
let val_start = idx + needle.len();
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 url_char_ends_url(ch: char, s: &str, byte_off: usize) -> bool {
if ch.is_whitespace() || ch == '"' || ch == '\'' || ch == '>' {
return true;
}
if ch == ')' || ch == ']' || ch == ',' {
if let Some(next) = s[byte_off..].chars().nth(1) {
return next.is_whitespace() || next == '"' || next == '\'';
}
}
false
}
fn redact_urls_in_text(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut cursor = 0;
while cursor < line.len() {
let slice = &line[cursor..];
let rel = match (slice.find("http://"), slice.find("https://")) {
(Some(h), Some(s)) => Some(h.min(s)),
(Some(h), None) => Some(h),
(None, Some(s)) => Some(s),
(None, None) => None,
};
let Some(rel) = rel else {
out.push_str(slice);
break;
};
out.push_str(&slice[..rel]);
let url_start = cursor + rel;
let url_slice = &line[url_start..];
let scheme_len = if url_slice.starts_with("https://") { 8 } else { 7 };
let mut url_end = scheme_len;
for (off, ch) in url_slice[scheme_len..].char_indices() {
let abs = scheme_len + off;
if url_char_ends_url(ch, url_slice, abs) {
break;
}
url_end = abs + ch.len_utf8();
}
out.push_str(&redact_url(&line[url_start..url_start + url_end]));
cursor = url_start + url_end;
}
out
}
fn redact_url(raw: &str) -> String {
let (url, suffix) = split_trailing_punct(raw);
let mut out = String::new();
let scheme_end = url.find("://").map(|i| i + 3).unwrap_or(0);
out.push_str(&url[..scheme_end]);
let mut rest = &url[scheme_end..];
if let Some(at) = rest.rfind('@') {
// Drop userinfo entirely.
out.push_str("***@");
rest = &rest[at + 1..];
}
let (hostport, path) = split_host_path(rest);
let (host, port) = split_host_port(&hostport);
let masked_host = mask_hostname(&host);
out.push_str(&masked_host);
if let Some(p) = port {
out.push(':');
out.push_str(&p);
}
if let Some((path_only, query)) = path.split_once('?') {
out.push_str(path_only);
out.push('?');
out.push_str(&redact_query_string(query));
} else {
out.push_str(&path);
}
format!("{out}{suffix}")
}
fn split_trailing_punct(raw: &str) -> (&str, &str) {
let mut end = raw.len();
while end > 0 {
let ch = raw.as_bytes()[end - 1] as char;
if ch == ')' || ch == ']' || ch == ',' {
end -= 1;
continue;
}
break;
}
(&raw[..end], &raw[end..])
}
fn split_host_path(rest: &str) -> (String, String) {
if rest.starts_with('[') {
if let Some(end) = rest.find(']') {
let hostport = &rest[..=end];
return (hostport.to_string(), rest[end + 1..].to_string());
}
}
if let Some(slash) = rest.find('/') {
(rest[..slash].to_string(), rest[slash..].to_string())
} else {
(rest.to_string(), String::new())
}
}
fn split_host_port(hostport: &str) -> (String, Option<String>) {
if hostport.starts_with('[') {
if let Some(end) = hostport.find("]:") {
return (
hostport[..=end].to_string(),
Some(hostport[end + 2..].to_string()),
);
}
return (hostport.to_string(), None);
}
if let Some((h, p)) = hostport.rsplit_once(':') {
if !h.is_empty() && p.chars().all(|c| c.is_ascii_digit()) && !h.contains(':') {
return (h.to_string(), Some(p.to_string()));
}
}
(hostport.to_string(), None)
}
fn redact_query_string(query: &str) -> String {
query
.split('&')
.map(|pair| {
let (k, _v) = pair.split_once('=').unwrap_or((pair, ""));
if is_sensitive_query_key(k) {
format!("{k}=REDACTED")
} else {
pair.to_string()
}
})
.collect::<Vec<_>>()
.join("&")
}
fn is_sensitive_query_key(key: &str) -> bool {
let k = key.trim().to_ascii_lowercase();
SENSITIVE_QUERY_KEYS.iter().any(|needle| *needle == k)
}
fn is_lan_ipv4(ip: &str) -> bool {
let parts: Vec<&str> = ip.split('.').collect();
if parts.len() != 4 {
return false;
}
let Ok(a) = parts[0].parse::<u8>() else { return false };
let Ok(b) = parts[1].parse::<u8>() else { return false };
a == 127
|| a == 10
|| (a == 172 && (16..=31).contains(&b))
|| (a == 192 && b == 168)
}
fn is_lan_ipv6(host: &str) -> bool {
let h = host.to_ascii_lowercase();
if h == "::1" {
return true;
}
if h.starts_with("fe8") || h.starts_with("fe9") || h.starts_with("fea") || h.starts_with("feb") {
return true;
}
if h.starts_with("fc") || h.starts_with("fd") {
return true;
}
if let Some(rest) = h.strip_prefix("::ffff:") {
if rest.contains('.') {
return is_lan_ipv4(rest);
}
if let Some((a, b)) = rest.split_once(':') {
if let (Ok(v1), Ok(v2)) = (u16::from_str_radix(a, 16), u16::from_str_radix(b, 16)) {
let ip = format!(
"{}.{}.{}.{}",
(v1 >> 8) & 0xff,
v1 & 0xff,
(v2 >> 8) & 0xff,
v2 & 0xff
);
return is_lan_ipv4(&ip);
}
}
}
false
}
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") {
return true;
}
if stripped.contains(':') {
return is_lan_ipv6(stripped);
}
if stripped.chars().all(|c| c.is_ascii_digit() || c == '.') && stripped.matches('.').count() == 3 {
return is_lan_ipv4(stripped);
}
false
}
fn mask_label_prefix(label: &str) -> String {
let mut chars = label.chars();
let c1 = chars.next();
let c2 = chars.next();
match (c1, c2) {
(None, _) => "*".to_string(),
(Some(a), None) => a.to_string(),
(Some(a), Some(b)) => {
let rest = label.chars().count().saturating_sub(2);
let stars = rest.clamp(1, 4);
format!("{a}{b}{}", "*".repeat(stars))
}
}
}
fn mask_public_ipv4(ip: &str) -> String {
let parts: Vec<&str> = ip.split('.').collect();
if parts.len() != 4 {
return "***".to_string();
}
format!("{}.*.*.{}", parts[0], parts[3])
}
fn mask_hostname(host: &str) -> String {
let stripped = host.trim().trim_matches(|c| c == '[' || c == ']');
if is_lan_host(stripped) {
return host.to_string();
}
if stripped.chars().all(|c| c.is_ascii_digit() || c == '.') && stripped.matches('.').count() == 3 {
return mask_public_ipv4(stripped);
}
if stripped.contains(':') {
return "[ipv6-redacted]".to_string();
}
let parts: Vec<&str> = stripped.split('.').collect();
if parts.is_empty() {
return "***".to_string();
}
let masked_first = mask_label_prefix(parts[0]);
if parts.len() == 1 {
masked_first
} else {
format!("{}.{}", masked_first, parts[1..].join("."))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redacts_subsonic_wire_auth_params() {
let line = "GET https://music.example.com/rest/stream.view?id=1&t=abc&s=def&p=ghi";
let out = sanitize_log_line(line);
assert!(out.contains("t=REDACTED"));
assert!(out.contains("s=REDACTED"));
assert!(out.contains("p=REDACTED"));
assert!(!out.contains("abc"));
}
#[test]
fn masks_remote_hostname_keeps_lan_ip() {
let remote = sanitize_log_line("connect https://my-server.example.com:4533/rest/ping");
assert!(remote.contains("my****.example.com"));
assert!(!remote.contains("my-server.example.com"));
let lan = sanitize_log_line("connect http://192.168.1.42:4533/rest/ping");
assert!(lan.contains("192.168.1.42"));
}
#[test]
fn redacts_bearer_and_password_kv() {
let line = "auth header Bearer eyJhbGciOiJIUzI1NiJ9.xyz password=sekrit";
let out = sanitize_log_line(line);
assert!(out.contains("Bearer REDACTED"));
assert!(!out.contains("eyJhbGci"));
assert!(out.contains("password=REDACTED"));
assert!(!out.contains("sekrit"));
}
#[test]
fn strips_url_userinfo() {
let line = "fetch https://user:pass@10.0.0.5:4533/rest/ping";
let out = sanitize_log_line(line);
assert!(out.contains("***@10.0.0.5"));
assert!(!out.contains("user:pass"));
}
#[test]
fn stream_log_with_em_dash_does_not_panic() {
let line = "[stream] RangedHttpSource selected — total=15666KB, hint=Some(\"mp3\")";
let out = sanitize_log_line(line);
assert!(out.contains('—'));
assert!(out.contains("RangedHttpSource"));
}
}
@@ -101,6 +101,7 @@ pub fn should_log_debug() -> bool {
}
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;
{
let mut buf = log_buffer().lock().unwrap();
@@ -0,0 +1,364 @@
//! Local playback disk layout — artist/album/track paths from library-index fields.
//!
//! Mirrors the contract in `implementation-spec.md` (local playback unification).
//! `server_segment` uses [`cover_cache_layout::sanitize_path_segment`] on the URL
//! index key; artist/album/filename segments are derived from track metadata only.
use std::path::{Component, Path, PathBuf};
use crate::cover_cache_layout::sanitize_path_segment;
/// Max length for a single path component after sanitization (Windows budget).
pub const MAX_SEGMENT_LEN: usize = 120;
/// Inputs required to build hierarchical media paths (library index row projection).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrackPathInput {
pub artist: Option<String>,
pub album_artist: Option<String>,
pub album: String,
pub title: String,
pub track_number: Option<i64>,
pub disc_number: Option<i64>,
pub suffix: Option<String>,
/// When set, used to detect compilation albums from `raw_json` (OpenSubsonic).
pub raw_json: Option<String>,
}
/// Tier subdirectory under the media root (`cache/`, `library/`, or `favorites/`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocalTier {
Ephemeral,
Library,
/// Auto-synced starred favorites — separate from user-pinned `library/`.
Favorites,
}
impl LocalTier {
pub fn subdir(self) -> &'static str {
match self {
Self::Ephemeral => "cache",
Self::Library => "library",
Self::Favorites => "favorites",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"ephemeral" | "cache" => Some(Self::Ephemeral),
"library" => Some(Self::Library),
"favorites" | "favorite-auto" | "favorite_auto" => Some(Self::Favorites),
_ => None,
}
}
}
/// Stable fingerprint for invalidation when library metadata changes (§8 spec).
pub fn layout_fingerprint(input: &TrackPathInput) -> String {
let artist_seg = artist_folder_segment(input);
let album_seg = album_folder_segment(&input.album);
let stem = track_filename_stem(input);
let suffix = input
.suffix
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("");
let track_n = input.track_number.unwrap_or(0);
let disc_n = input.disc_number.unwrap_or(0);
format!(
"artist={artist_seg}|album_artist={}|album={album_seg}|title={}|track={track_n}|disc={disc_n}|stem={stem}|suffix={suffix}",
input
.album_artist
.as_deref()
.map(str::trim)
.unwrap_or(""),
input.title.trim(),
)
}
/// Relative path under `{tier}/{server_segment}/`: `{artist}/{album}/{file}.{suffix}`.
pub fn relative_path_for_track(
server_index_key: &str,
input: &TrackPathInput,
suffix: &str,
) -> PathBuf {
let server_segment = sanitize_path_segment(server_index_key);
let artist = artist_folder_segment(input);
let album = album_folder_segment(&input.album);
let stem = track_filename_stem(input);
let ext = suffix.trim().trim_start_matches('.');
let filename = if ext.is_empty() {
sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN)
} else {
format!(
"{}.{}",
sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN),
sanitize_path_segment(ext)
)
};
PathBuf::from(server_segment)
.join(artist)
.join(album)
.join(filename)
}
/// Absolute file path: `{media_root}/{tier}/…relative_path…`.
pub fn absolute_track_path(
media_root: &Path,
tier: LocalTier,
server_index_key: &str,
input: &TrackPathInput,
suffix: &str,
) -> PathBuf {
media_root
.join(tier.subdir())
.join(relative_path_for_track(server_index_key, input, suffix))
}
/// Defense-in-depth: resolved paths must stay under `{media_root}/{tier}/`.
pub fn ensure_track_path_within_tier(
media_root: &Path,
tier: LocalTier,
absolute: &Path,
) -> Result<(), String> {
let tier_root = media_root.join(tier.subdir());
let Ok(rel) = absolute.strip_prefix(&tier_root) else {
return Err(format!(
"path `{}` escapes tier root `{}`",
absolute.display(),
tier_root.display()
));
};
for comp in rel.components() {
if matches!(comp, Component::ParentDir | Component::RootDir | Component::Prefix(_)) {
return Err(format!(
"path `{}` contains forbidden component `{comp:?}`",
absolute.display()
));
}
}
Ok(())
}
fn artist_folder_segment(input: &TrackPathInput) -> String {
let artist = input.artist.as_deref().map(str::trim).unwrap_or("");
let album_artist = input.album_artist.as_deref().map(str::trim).unwrap_or("");
let chosen = if artist.is_empty() || track_is_compilation(input) {
if !album_artist.is_empty() {
album_artist
} else {
"Various Artists"
}
} else {
artist
};
sanitize_and_truncate_segment(chosen, MAX_SEGMENT_LEN)
}
fn album_folder_segment(album: &str) -> String {
let trimmed = album.trim();
let fallback = if trimmed.is_empty() { "Unknown Album" } else { trimmed };
sanitize_and_truncate_segment(fallback, MAX_SEGMENT_LEN)
}
fn track_filename_stem(input: &TrackPathInput) -> String {
let title = input.title.trim();
let title = if title.is_empty() { "Unknown Title" } else { title };
let track_n = input.track_number.unwrap_or(0).max(0) as u32;
let disc_n = input.disc_number.unwrap_or(1).max(0) as u32;
if disc_n > 1 {
format!("{disc_n:02}-{track_n:02} - {title}")
} else {
format!("{track_n:02} - {title}")
}
}
fn track_is_compilation(input: &TrackPathInput) -> bool {
if various_artists_label(input.artist.as_deref().unwrap_or("")) {
return true;
}
let Some(raw) = input.raw_json.as_deref().filter(|s| !s.is_empty()) else {
return false;
};
raw_json_marks_compilation(raw)
}
/// Best-effort probe aligned with `album_compilation_filter::compilation_raw_json_sql`.
fn raw_json_marks_compilation(raw: &str) -> bool {
let lower = raw.to_ascii_lowercase();
lower.contains("\"iscompilation\":true")
|| lower.contains("\"iscompilation\": true")
|| lower.contains("\"compilation\":true")
|| lower.contains("\"compilation\": true")
|| lower.contains("\"compilation\":1")
|| lower.contains("\"releaseTypes\"") && lower.contains("compilation")
}
fn various_artists_label(s: &str) -> bool {
let lower = s.trim().to_ascii_lowercase();
lower.contains("various artists")
}
fn sanitize_and_truncate_segment(segment: &str, max_len: usize) -> String {
let sanitized = sanitize_path_segment(segment);
// Code points — keep in sync with `[...sanitized].length` in `mediaLayout.ts`.
if sanitized.chars().count() <= max_len {
return sanitized;
}
let hash = short_hash(segment);
let keep = max_len.saturating_sub(1 + hash.len());
let mut out = sanitized.chars().take(keep).collect::<String>();
out.push('_');
out.push_str(&hash);
out
}
/// Keep in sync with `shortHash` in `src/utils/media/mediaLayout.ts` (UTF-16 code units).
fn short_hash(s: &str) -> String {
let mut h: u32 = 0;
for unit in s.encode_utf16() {
h = h.wrapping_mul(31).wrapping_add(unit as u32);
}
format!("{:08x}", h)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_input() -> TrackPathInput {
TrackPathInput {
artist: Some("Radiohead".to_string()),
album_artist: None,
album: "OK Computer".to_string(),
title: "Paranoid Android".to_string(),
track_number: Some(6),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
}
}
#[test]
fn relative_path_uses_library_segments() {
let rel = relative_path_for_track("host:4533", &sample_input(), "mp3");
assert_eq!(
rel,
PathBuf::from("host_4533")
.join("Radiohead")
.join("OK Computer")
.join("06 - Paranoid Android.mp3")
);
}
#[test]
fn multi_disc_adds_disc_prefix() {
let mut input = sample_input();
input.disc_number = Some(2);
let rel = relative_path_for_track("srv", &input, "flac");
assert!(rel
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("02-06 - Paranoid Android.flac")));
}
#[test]
fn compilation_uses_album_artist_folder() {
let input = TrackPathInput {
artist: Some("Various Artists".to_string()),
album_artist: Some("Original Soundtrack".to_string()),
album: "Film Score".to_string(),
title: "Main Theme".to_string(),
track_number: Some(1),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
};
let rel = relative_path_for_track("srv", &input, "mp3");
assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Original Soundtrack"));
}
#[test]
fn empty_artist_falls_back_to_various_artists() {
let input = TrackPathInput {
artist: None,
album_artist: None,
album: "Comp".to_string(),
title: "Song".to_string(),
track_number: Some(1),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
};
let rel = relative_path_for_track("srv", &input, "mp3");
assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Various Artists"));
}
#[test]
fn layout_fingerprint_is_stable() {
let a = layout_fingerprint(&sample_input());
let b = layout_fingerprint(&sample_input());
assert_eq!(a, b);
assert!(a.contains("Radiohead"));
assert!(a.contains("OK Computer"));
}
#[test]
fn tier_subdirs_are_fixed() {
assert_eq!(LocalTier::Ephemeral.subdir(), "cache");
assert_eq!(LocalTier::Library.subdir(), "library");
assert_eq!(LocalTier::Favorites.subdir(), "favorites");
assert_eq!(LocalTier::parse("ephemeral"), Some(LocalTier::Ephemeral));
assert_eq!(LocalTier::parse("library"), Some(LocalTier::Library));
assert_eq!(LocalTier::parse("favorite-auto"), Some(LocalTier::Favorites));
}
#[test]
fn absolute_path_includes_tier() {
let root = Path::new("/media");
let path = absolute_track_path(root, LocalTier::Library, "srv", &sample_input(), "mp3");
assert!(path.starts_with(root.join("library")));
}
#[test]
fn dot_dot_metadata_does_not_escape_tier_root() {
let input = TrackPathInput {
artist: Some("..".to_string()),
album_artist: None,
album: "..".to_string(),
title: "Song".to_string(),
track_number: Some(1),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
};
let root = Path::new("/media");
let path = absolute_track_path(root, LocalTier::Library, "srv", &input, "mp3");
assert!(path.starts_with(root.join("library")));
ensure_track_path_within_tier(root, LocalTier::Library, &path).unwrap();
}
#[test]
fn short_hash_matches_ts_imul31_utf16() {
// "Radiohead" — same as mediaLayout.test parity anchor.
assert_eq!(short_hash("Radiohead"), "3da68c3b");
}
#[test]
fn sanitize_and_truncate_uses_code_point_threshold() {
let cyrillic_a = '\u{0430}';
let hundred: String = std::iter::repeat_n(cyrillic_a, 100).collect();
assert!(hundred.len() > MAX_SEGMENT_LEN);
assert_eq!(hundred.chars().count(), 100);
assert_eq!(
sanitize_and_truncate_segment(&hundred, MAX_SEGMENT_LEN),
hundred
);
let long: String = std::iter::repeat_n(cyrillic_a, 130).collect();
let truncated = sanitize_and_truncate_segment(&long, MAX_SEGMENT_LEN);
assert!(truncated.ends_with("_eef20600"));
assert_eq!(truncated.chars().count(), MAX_SEGMENT_LEN);
}
}
@@ -3,6 +3,7 @@ name = "psysonic-integration"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -3,6 +3,7 @@ name = "psysonic-library"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -460,8 +460,8 @@ fn build_album_from_tracks(
);
let select = "t.server_id, t.album_id, MAX(t.album), MAX(t.artist), MAX(t.artist_id), \
COUNT(*), SUM(t.duration_sec), MAX(t.year), MAX(t.genre), MAX(t.cover_art_id), \
MAX(t.starred_at), MAX(t.synced_at)";
MAX(t.album_artist), COUNT(*), SUM(t.duration_sec), MAX(t.year), MAX(t.genre), \
MAX(t.cover_art_id), MAX(t.starred_at), MAX(t.synced_at)";
let order = album_order_from_track_groups(&req.sort).unwrap_or_else(|| {
"ORDER BY MAX(t.album) COLLATE NOCASE ASC, t.album_id ASC".to_string()
});
@@ -868,10 +868,10 @@ fn resolve_clause(
}));
}
("compilation", EntityKind::Album) => {
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), "a");
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), EntityKind::Album);
}
("compilation", EntityKind::Track) => {
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), "t");
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), EntityKind::Track);
}
("compilation", _) => return Ok(None),
// `text` is handled by the entity builder (FTS / LIKE), never here.
@@ -1156,19 +1156,21 @@ fn map_artist(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryArtistDto> {
}
fn map_album_from_tracks(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryAlbumDto> {
let track_artist: Option<String> = r.get(3)?;
let album_artist: Option<String> = r.get(5)?;
Ok(LibraryAlbumDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
artist: r.get(3)?,
artist: crate::album_compilation_filter::pick_album_group_artist(track_artist, album_artist),
artist_id: r.get(4)?,
song_count: Some(r.get(5)?),
duration_sec: Some(r.get(6)?),
year: r.get(7)?,
genre: r.get(8)?,
cover_art_id: r.get(9)?,
starred_at: r.get(10)?,
synced_at: r.get(11)?,
song_count: Some(r.get(6)?),
duration_sec: Some(r.get(7)?),
year: r.get(8)?,
genre: r.get(9)?,
cover_art_id: r.get(10)?,
starred_at: r.get(11)?,
synced_at: r.get(12)?,
raw_json: Value::Null,
})
}
@@ -1264,9 +1266,21 @@ fn compilation_filter_fragment(
field: &str,
op: FilterOp,
value: Option<&Value>,
table_alias: &str,
kind: EntityKind,
) -> Result<Option<SqlFragment>, String> {
let comp_sql = crate::album_compilation_filter::compilation_raw_json_sql(table_alias);
let comp_sql = match kind {
EntityKind::Album => crate::album_compilation_filter::compilation_predicate_sql(
"a",
Some("a.artist"),
None,
),
EntityKind::Track => crate::album_compilation_filter::compilation_predicate_sql(
"t",
Some("t.artist"),
Some("t.album_artist"),
),
_ => crate::album_compilation_filter::compilation_raw_json_sql("t"),
};
match op {
FilterOp::IsTrue => Ok(Some(SqlFragment {
sql: comp_sql,
@@ -2032,6 +2046,27 @@ mod tests {
assert_eq!(resp.albums[0].id, "al_comp");
}
#[test]
fn compilation_filter_matches_va_album_artist_on_track_groups() {
let store = LibraryStore::open_in_memory();
let mut comp = track("s1", "t_comp", "Hit", "Alice", "Comp Album");
comp.album_id = Some("al_comp".into());
comp.album_artist = Some("Various Artists".into());
comp.raw_json = "{}".into();
let mut reg = track("s1", "t_reg", "Song", "Band", "Studio");
reg.album_id = Some("al_reg".into());
reg.raw_json = "{}".into();
TrackRepository::new(&store)
.upsert_batch(&[comp, reg])
.unwrap();
let mut r = req("s1", &[EntityKind::Album]);
r.filters = vec![clause("compilation", FilterOp::IsTrue, None, None)];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_comp");
assert_eq!(resp.albums[0].artist.as_deref(), Some("Various Artists"));
}
#[test]
fn compilation_filter_on_track_grouped_album_browse() {
let store = LibraryStore::open_in_memory();
@@ -1,5 +1,6 @@
//! OpenSubsonic compilation flag in entity `raw_json` (Navidrome: `compilation`,
//! `isCompilation`, or `releaseTypes` containing `Compilation`).
//! `isCompilation`, or `releaseTypes` containing `Compilation`), plus the same
//! "Various Artists" heuristics the web UI uses when structured flags are absent.
/// SQL predicate on any row with a `raw_json` column (album or track).
pub fn compilation_raw_json_sql(table_alias: &str) -> String {
@@ -17,6 +18,49 @@ pub fn compilation_raw_json_sql(table_alias: &str) -> String {
)
}
fn various_artists_like_sql(column: &str) -> String {
format!(
"lower(trim(coalesce({column}, ''))) LIKE '%various artists%'",
column = column
)
}
/// Full compilation predicate for browse filters — JSON flags plus VA artist labels.
pub fn compilation_predicate_sql(
table_alias: &str,
artist_column: Option<&str>,
album_artist_column: Option<&str>,
) -> String {
let mut parts = vec![compilation_raw_json_sql(table_alias)];
parts.push(format!(
"lower(trim(coalesce(json_extract({a}.raw_json, '$.displayArtist'), ''))) LIKE '%various artists%'",
a = table_alias
));
if let Some(col) = artist_column {
parts.push(various_artists_like_sql(col));
}
if let Some(col) = album_artist_column {
parts.push(various_artists_like_sql(col));
}
format!("({})", parts.join(" OR "))
}
pub fn various_artists_label(s: &str) -> bool {
s.trim().to_ascii_lowercase().contains("various artists")
}
/// Track-grouped album rows: prefer album artist when it marks a VA compilation.
pub fn pick_album_group_artist(
track_artist: Option<String>,
album_artist: Option<String>,
) -> Option<String> {
let aa = album_artist.as_deref().unwrap_or("").trim();
if various_artists_label(aa) {
return Some(aa.to_string());
}
track_artist
}
#[cfg(test)]
mod tests {
use super::*;
@@ -27,4 +71,24 @@ mod tests {
assert!(sql.contains("$.compilation"));
assert!(sql.contains("$.releaseTypes"));
}
#[test]
fn predicate_includes_artist_columns() {
let sql = compilation_predicate_sql("t", Some("t.artist"), Some("t.album_artist"));
assert!(sql.contains("t.artist"));
assert!(sql.contains("t.album_artist"));
assert!(sql.contains("$.displayArtist"));
}
#[test]
fn pick_album_group_artist_prefers_va_album_artist() {
assert_eq!(
pick_album_group_artist(Some("Alice".into()), Some("Various Artists".into())),
Some("Various Artists".to_string())
);
assert_eq!(
pick_album_group_artist(Some("Alice".into()), Some("Bob".into())),
Some("Alice".to_string())
);
}
}
@@ -34,7 +34,7 @@ pub(crate) fn reconcile_album_stars(
) -> Result<(), String> {
runtime
.store
.with_conn("misc", |conn| {
.with_conn("browse.reconcile_album_stars", |conn| {
if starred.is_empty() {
conn.execute(
"UPDATE album SET starred_at = NULL \
@@ -81,7 +81,7 @@ pub fn link_all_tracks_for_server(
server_id: &str,
now: i64,
) -> Result<u32, String> {
store.with_conn_mut("misc", |conn| {
store.with_conn_mut("canonical.link_all_tracks", |conn| {
let tx = conn.transaction()?;
let mut stmt = tx.prepare(
"SELECT id, isrc, mbid_recording FROM track \
@@ -396,6 +396,40 @@ pub async fn library_get_tracks_by_album(
Ok(rows.iter().map(LibraryTrackDto::from_row).collect())
}
/// Upsert Subsonic API song payloads into the library index so pin/download can
/// build `media/library/…` paths before a full sync has ingested the rows.
#[tauri::command]
pub fn library_upsert_songs_from_api(
runtime: State<'_, LibraryRuntime>,
server_id: String,
songs: Vec<serde_json::Value>,
) -> Result<u32, String> {
use crate::sync::subsonic_song_to_track_row;
use psysonic_integration::subsonic::Song;
if songs.is_empty() {
return Ok(0);
}
let synced_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| e.to_string())?
.as_secs() as i64;
let repo = TrackRepository::new(&runtime.store);
let mut rows = Vec::with_capacity(songs.len());
for raw in songs {
let song: Song = serde_json::from_value(raw.clone()).map_err(|e| e.to_string())?;
rows.push(subsonic_song_to_track_row(
&server_id,
&song,
&raw,
synced_at,
None,
));
}
repo.upsert_batch(&rows)?;
Ok(rows.len() as u32)
}
#[tauri::command]
pub async fn library_get_artifact(
runtime: State<'_, LibraryRuntime>,
@@ -442,7 +476,7 @@ pub async fn library_get_offline_path(
) -> Result<OfflinePathDto, String> {
let path = runtime
.store
.with_conn("misc", |conn| {
.with_conn("cmd.get_offline_path", |conn| {
conn.query_row(
"SELECT local_path FROM track_offline \
WHERE server_id = ?1 AND track_id = ?2",
@@ -994,7 +1028,7 @@ pub fn patch_content_hash(
}
runtime
.store
.with_conn("misc", |conn| {
.with_conn("cmd.patch_content_hash", |conn| {
conn.execute(
"UPDATE track SET content_hash = ?3 \
WHERE server_id = ?1 AND id = ?2",
@@ -1041,7 +1075,7 @@ pub(crate) fn apply_track_patch(
runtime
.store
.with_conn("misc", |conn| {
.with_conn("cmd.patch_track", |conn| {
// One UPDATE per field present — keeps SQL simple and
// matches the spec's per-field patch semantics.
if let Some(v) = starred_at {
@@ -1177,7 +1211,7 @@ pub fn library_purge_server(
let mut report = PurgeReportDto::default();
runtime
.store
.with_conn_mut("misc", |conn| {
.with_conn_mut("cmd.purge_server", |conn| {
let tx = conn.transaction()?;
let track_count: i64 =
tx.query_row("SELECT COUNT(*) FROM track WHERE server_id = ?1", params![server_id], |r| r.get(0))?;
@@ -29,7 +29,7 @@ impl<'a> SyncStateRepository<'a> {
/// if none exists. All non-PK columns fall back to their schema DEFAULTs
/// (`sync_phase='idle'`, `initial_sync_cursor_json='{}'`, …).
pub fn ensure(&self, server_id: &str, library_scope: &str) -> Result<(), String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.ensure", |conn| {
conn.execute(
"INSERT OR IGNORE INTO sync_state (server_id, library_scope) VALUES (?1, ?2)",
params![server_id, library_scope],
@@ -73,7 +73,7 @@ impl<'a> SyncStateRepository<'a> {
cursor: &Value,
) -> Result<(), String> {
let json = serde_json::to_string(cursor).map_err(|e| e.to_string())?;
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_initial_sync_cursor", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, initial_sync_cursor_json) \
VALUES (?1, ?2, ?3) \
@@ -134,7 +134,7 @@ impl<'a> SyncStateRepository<'a> {
library_scope: &str,
flags: u32,
) -> Result<(), String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_capability_flags", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, capability_flags) \
VALUES (?1, ?2, ?3) \
@@ -192,7 +192,7 @@ impl<'a> SyncStateRepository<'a> {
library_scope: &str,
phase: &str,
) -> Result<(), String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_sync_phase", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, sync_phase) \
VALUES (?1, ?2, ?3) \
@@ -212,7 +212,7 @@ impl<'a> SyncStateRepository<'a> {
library_scope: &str,
last_scan_iso: Option<&str>,
) -> Result<(), String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_server_last_scan_iso", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, server_last_scan_iso) \
VALUES (?1, ?2, ?3) \
@@ -232,7 +232,7 @@ impl<'a> SyncStateRepository<'a> {
library_scope: &str,
last_modified_ms: i64,
) -> Result<(), String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_indexes_last_modified_ms", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, indexes_last_modified_ms) \
VALUES (?1, ?2, ?3) \
@@ -330,7 +330,7 @@ impl<'a> SyncStateRepository<'a> {
library_scope: &str,
epoch_ms: i64,
) -> Result<(), String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_next_poll_at", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, next_poll_at) \
VALUES (?1, ?2, ?3) \
@@ -375,7 +375,7 @@ impl<'a> SyncStateRepository<'a> {
stats: &Value,
) -> Result<(), String> {
let json = serde_json::to_string(stats).map_err(|e| e.to_string())?;
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_poll_stats_json", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, poll_stats_json) \
VALUES (?1, ?2, ?3) \
@@ -412,7 +412,7 @@ impl<'a> SyncStateRepository<'a> {
library_scope: &str,
count: i64,
) -> Result<(), String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_local_track_count", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, local_track_count) \
VALUES (?1, ?2, ?3) \
@@ -447,7 +447,7 @@ impl<'a> SyncStateRepository<'a> {
library_scope: &str,
count: i64,
) -> Result<(), String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_server_track_count", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, server_track_count) \
VALUES (?1, ?2, ?3) \
@@ -488,7 +488,7 @@ impl<'a> SyncStateRepository<'a> {
library_scope: &str,
unreliable: bool,
) -> Result<(), String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_n1_bulk_unreliable", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, n1_bulk_unreliable) \
VALUES (?1, ?2, ?3) \
@@ -508,7 +508,7 @@ impl<'a> SyncStateRepository<'a> {
library_scope: &str,
epoch_ms: i64,
) -> Result<(), String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_last_full_sync_at", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, last_full_sync_at) \
VALUES (?1, ?2, ?3) \
@@ -528,7 +528,7 @@ impl<'a> SyncStateRepository<'a> {
library_scope: &str,
epoch_ms: i64,
) -> Result<(), String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_last_delta_sync_at", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, last_delta_sync_at) \
VALUES (?1, ?2, ?3) \
@@ -549,7 +549,7 @@ impl<'a> SyncStateRepository<'a> {
library_scope: &str,
last_modified_ms: i64,
) -> Result<(), String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_artists_last_modified_ms", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, artists_last_modified_ms) \
VALUES (?1, ?2, ?3) \
@@ -570,7 +570,7 @@ impl<'a> SyncStateRepository<'a> {
library_scope: &str,
tier: &str,
) -> Result<(), String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_library_tier", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, library_tier) \
VALUES (?1, ?2, ?3) \
@@ -191,7 +191,7 @@ impl<'a> TrackRepository<'a> {
/// Next generation stamp for a full-resync orphan sweep on this server.
pub fn next_resync_gen(&self, server_id: &str) -> Result<i64, String> {
self.store.with_conn("misc", |c| {
self.store.with_conn("track.next_resync_gen", |c| {
c.query_row(
"SELECT COALESCE(MAX(resync_gen), 0) + 1 FROM track WHERE server_id = ?1",
params![server_id],
@@ -203,7 +203,7 @@ impl<'a> TrackRepository<'a> {
/// IS-7 — soft-delete live rows not re-stamped during the active resync.
pub fn sweep_resync_orphans(&self, server_id: &str, resync_gen: i64) -> Result<u32, String> {
let now = now_unix_ms();
let changed = self.store.with_conn_mut("misc", |c| {
let changed = self.store.with_conn_mut("track.sweep_resync_orphans", |c| {
c.execute(
"UPDATE track SET deleted = 1, synced_at = ?3 \
WHERE server_id = ?1 AND deleted = 0 AND resync_gen != ?2",
@@ -228,6 +228,18 @@ impl<'a> TrackRepository<'a> {
})
}
/// All live rows for a Subsonic track id (any server). Used when legacy offline
/// folders name the server by URL index key rather than profile UUID.
pub fn find_live_by_id(&self, track_id: &str) -> Result<Vec<TrackRow>, String> {
self.store.with_read_conn(|conn| {
let mut stmt = conn.prepare(SELECT_TRACK_BY_ID_ONLY)?;
let rows = stmt
.query_map(params![track_id], row_to_track_row)?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
})
}
/// Batch SELECT — `library_get_tracks_batch`. Caller-supplied refs
/// preserve their order in the result; unknown / deleted refs
/// are silently dropped (frontend reads `tracks.length` against
@@ -292,6 +304,26 @@ impl<'a> TrackRepository<'a> {
})
}
/// Legacy offline rows keyed by library `server_id` (index key scope).
pub fn list_offline_local_paths(
&self,
server_id: &str,
) -> Result<Vec<(String, String, Option<String>)>, String> {
self.store.with_read_conn(|conn| {
let mut stmt = conn.prepare(
"SELECT track_id, local_path, suffix FROM track_offline WHERE server_id = ?1",
)?;
let rows = stmt.query_map(params![server_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
))
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
}
/// Tracks with `content_hash` and an analysis BPM fact — may still lack waveform/LUFS.
/// Confirmed per id via [`TrackAnalysisNeedsWorkQuery`].
pub fn list_analysis_hash_bpm_ids_after(
@@ -386,7 +418,7 @@ impl<'a> TrackRepository<'a> {
if rows.is_empty() {
return Ok(RemapStats::default());
}
self.store.with_conn_mut("misc", |conn| {
self.store.with_conn_mut("track.upsert_batch_remap", |conn| {
let tx = conn.transaction()?;
let mut remapped: Vec<RemapEntry> = Vec::new();
let mut upsert = tx.prepare_cached(UPSERT_SQL)?;
@@ -583,6 +615,13 @@ const SELECT_TRACK_BY_ID: &str = "SELECT server_id, id, title, title_sort, artis
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
FROM track WHERE server_id = ?1 AND id = ?2 AND deleted = 0";
const SELECT_TRACK_BY_ID_ONLY: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
FROM track WHERE id = ?1 AND deleted = 0";
const SELECT_TRACKS_BY_ALBUM: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
@@ -26,7 +26,7 @@ impl<'a> TrackIdHistoryRepository<'a> {
server_id: &str,
old_id: &str,
) -> Result<Option<String>, String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("track_id_history.lookup", |conn| {
conn.query_row(
"SELECT new_id FROM track_id_history \
WHERE server_id = ?1 AND old_id = ?2",
@@ -40,7 +40,7 @@ impl<'a> TrackIdHistoryRepository<'a> {
/// Count the rows recorded for this server — used by tests and by
/// post-sync diagnostics (Settings „Library index" panel later).
pub fn count_for_server(&self, server_id: &str) -> Result<i64, String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("track_id_history.count", |conn| {
conn.query_row(
"SELECT COUNT(*) FROM track_id_history WHERE server_id = ?1",
params![server_id],
@@ -103,6 +103,11 @@ impl LibraryStore {
}
/// Writer connection — sync ingest, migrations, mutations.
///
/// `op` is logged on slow writes (`[library-db] SLOW write op=…`) — use a
/// stable `module.action` label (e.g. `sync_state.set_sync_phase`,
/// `track.upsert_batch_remap`), not the generic `"misc"`, so production
/// stalls can be attributed to a specific call site.
pub(crate) fn with_conn<R>(
&self,
op: &'static str,
@@ -284,7 +284,7 @@ impl<'a> DeltaSyncRunner<'a> {
fn local_track_updated_watermark(&self) -> Result<Option<i64>, SyncError> {
self.store
.with_conn("misc", |c| {
.with_conn("delta.local_track_watermark", |c| {
c.query_row(
"SELECT MAX(server_updated_at) FROM track \
WHERE server_id = ?1 AND deleted = 0",
@@ -297,7 +297,7 @@ impl<'a> DeltaSyncRunner<'a> {
fn local_album_ids(&self) -> Result<HashSet<String>, SyncError> {
self.store
.with_conn("misc", |c| {
.with_conn("delta.local_album_ids", |c| {
let mut stmt = c.prepare(
"SELECT DISTINCT album_id FROM track \
WHERE server_id = ?1 AND deleted = 0 AND album_id IS NOT NULL",
@@ -115,7 +115,7 @@ impl<'a> TombstoneReconciler<'a> {
fn next_candidates(&self, budget: u32) -> Result<Vec<String>, SyncError> {
self.store
.with_conn("misc", |c| {
.with_conn("tombstone.next_candidates", |c| {
let mut stmt = c.prepare(
"SELECT id FROM track \
WHERE server_id = ?1 AND deleted = 0 \
@@ -133,7 +133,7 @@ impl<'a> TombstoneReconciler<'a> {
fn mark_deleted(&self, id: &str) -> Result<(), SyncError> {
self.store
.with_conn("misc", |c| {
.with_conn("tombstone.mark_deleted", |c| {
c.execute(
"UPDATE track SET deleted = 1, synced_at = ?3 \
WHERE server_id = ?1 AND id = ?2",
@@ -146,7 +146,7 @@ impl<'a> TombstoneReconciler<'a> {
fn mark_synced(&self, id: &str) -> Result<(), SyncError> {
self.store
.with_conn("misc", |c| {
.with_conn("tombstone.mark_synced", |c| {
c.execute(
"UPDATE track SET synced_at = ?3 \
WHERE server_id = ?1 AND id = ?2",
@@ -3,10 +3,12 @@ name = "psysonic-syncfs"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
psysonic-core = { path = "../psysonic-core" }
psysonic-library = { path = "../psysonic-library" }
psysonic-analysis = { path = "../psysonic-analysis" }
psysonic-audio = { path = "../psysonic-audio" }
+100 -2
View File
@@ -1,4 +1,4 @@
use std::path::Path;
use std::path::{Path, PathBuf};
/// Recursively sums the size of all files under `root`.
/// Missing roots, unreadable directories, and unreadable files are silently skipped.
@@ -25,6 +25,47 @@ pub fn dir_size_recursive(root: &Path) -> u64 {
total
}
/// All regular files under `root` (recursive). Missing or unreadable roots yield an empty list.
pub fn collect_regular_files_under(root: &Path) -> Vec<std::path::PathBuf> {
if !root.is_dir() {
return Vec::new();
}
let mut files = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let rd = match std::fs::read_dir(&dir) {
Ok(r) => r,
Err(_) => continue,
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.is_file() {
files.push(path);
}
}
}
files
}
fn normalize_path_for_prefix(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
/// Returns the `{…}/cache`, `{…}/library`, or `{…}/favorites` ancestor of a media file path.
pub fn local_tier_boundary_from_path(path: &Path) -> Option<PathBuf> {
let mut current = path.parent()?;
loop {
match current.file_name().and_then(|s| s.to_str()) {
Some("cache") | Some("library") | Some("favorites") => {
return Some(current.to_path_buf());
}
_ => current = current.parent()?,
}
}
}
/// Walks upward from `start_dir`, removing each empty directory using `remove_dir`
/// (never `remove_dir_all`). Stops as soon as a non-empty directory is hit, the
/// boundary is reached, or removal fails.
@@ -32,9 +73,11 @@ pub fn dir_size_recursive(root: &Path) -> u64 {
/// `boundary` is never removed and is treated as a hard stop. If `start_dir` is
/// not under `boundary`, the function is a no-op.
pub fn prune_empty_dirs_up_to(start_dir: &Path, boundary: &Path) {
let boundary_norm = normalize_path_for_prefix(boundary);
let mut current = Some(start_dir.to_path_buf());
while let Some(dir) = current {
if dir == boundary || !dir.starts_with(boundary) {
let dir_norm = normalize_path_for_prefix(&dir);
if dir_norm == boundary_norm || !dir_norm.starts_with(&boundary_norm) {
break;
}
match std::fs::read_dir(&dir) {
@@ -52,6 +95,29 @@ pub fn prune_empty_dirs_up_to(start_dir: &Path, boundary: &Path) {
}
}
/// Post-order sweep: removes empty child directories under `root` (never `root` itself).
pub fn prune_empty_subdirs_under(root: &Path) {
if !root.is_dir() {
return;
}
let children: Vec<PathBuf> = std::fs::read_dir(root)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
for child in children {
prune_empty_subdirs_under(&child);
let is_empty = std::fs::read_dir(&child)
.map(|mut rd| rd.next().is_none())
.unwrap_or(false);
if is_empty {
let _ = std::fs::remove_dir(&child);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -87,6 +153,17 @@ mod tests {
assert!(path.exists(), "boundary dir must never be removed");
}
#[test]
fn collect_regular_files_under_lists_nested_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.mp3"), b"x").unwrap();
let sub = dir.path().join("Artist/Album");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("b.flac"), b"yy").unwrap();
let files = collect_regular_files_under(dir.path());
assert_eq!(files.len(), 2);
}
#[test]
fn prune_empty_dirs_up_to_stops_at_non_empty_parent() {
let root = tempfile::tempdir().unwrap();
@@ -98,4 +175,25 @@ mod tests {
assert!(!child.exists(), "empty leaf should be pruned");
assert!(parent.exists(), "non-empty parent must stay");
}
#[test]
fn local_tier_boundary_from_path_finds_cache_root() {
let root = tempfile::tempdir().unwrap();
let track = root
.path()
.join("vol/media/cache/my.server/Artist/Album/track.flac");
std::fs::create_dir_all(track.parent().unwrap()).unwrap();
let boundary = local_tier_boundary_from_path(&track).unwrap();
assert_eq!(boundary, root.path().join("vol/media/cache"));
}
#[test]
fn prune_empty_subdirs_under_removes_nested_empty_tree() {
let root = tempfile::tempdir().unwrap();
let cache = root.path().join("cache").join("srv").join("Artist").join("Album");
std::fs::create_dir_all(&cache).unwrap();
prune_empty_subdirs_under(&root.path().join("cache"));
assert!(!cache.exists());
assert!(root.path().join("cache").exists(), "tier root preserved");
}
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -2,3 +2,4 @@ mod fs_utils;
pub mod offline;
pub mod downloads;
pub mod hot;
pub mod local;
+72
View File
@@ -5,6 +5,8 @@ pub mod cli;
mod cover_cache;
mod library_analysis_backfill;
mod lib_commands;
mod theme_import;
pub mod theme_animation;
pub use psysonic_integration::discord;
@@ -95,6 +97,10 @@ pub fn run() {
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(
tauri_plugin_window_state::Builder::default()
.with_state_flags(
tauri_plugin_window_state::StateFlags::all()
& !tauri_plugin_window_state::StateFlags::VISIBLE,
)
.with_denylist(&["mini"])
.build()
)
@@ -114,6 +120,38 @@ pub fn run() {
let _ = window.set_title("Psysonic (Dev)");
}
// ── Dev: `--theme-watch <theme.css>` live theme reload ─────────
// Poll a local theme.css and push it into the running app on save,
// so theme authors get a live loop without re-importing a zip. The
// frontend (dev only) installs it under the id in its
// `[data-theme='<id>']` selector and applies it. Dev-builds only.
#[cfg(debug_assertions)]
{
let args: Vec<String> = std::env::args().collect();
if let Some(i) = args.iter().position(|a| a == "--theme-watch") {
match args.get(i + 1).cloned() {
Some(path) => {
eprintln!("[theme-watch] watching {path}");
let handle = app.handle().clone();
std::thread::spawn(move || {
let p = std::path::PathBuf::from(&path);
let mut last_css = String::new();
loop {
if let Ok(css) = std::fs::read_to_string(&p) {
if css != last_css {
last_css = css.clone();
let _ = handle.emit("theme-watch:css", css);
}
}
std::thread::sleep(std::time::Duration::from_millis(300));
}
});
}
None => eprintln!("[theme-watch] usage: --theme-watch <path/to/theme.css>"),
}
}
}
// ── Analysis cache (SQLite) ───────────────────────────────────
{
let cache = analysis_cache::AnalysisCache::init(app.handle())
@@ -591,8 +629,27 @@ pub fn run() {
}
}
})
.on_page_load(|webview, payload| {
if webview.label() != "main" {
return;
}
match payload.event() {
tauri::webview::PageLoadEvent::Started => {
let window = webview.window().clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(48));
let _ = window.show();
});
}
tauri::webview::PageLoadEvent::Finished => {
let _ = webview.window().show();
}
}
})
.invoke_handler(tauri::generate_handler![
greet,
theme_import::import_theme_zip,
backup_export_library_db,
backup_import_library_db,
backup_export_full,
@@ -619,6 +676,7 @@ pub fn run() {
performance_cpu_snapshot,
set_subsonic_wire_user_agent,
no_compositing_mode,
theme_animation_risk,
linux_xdg_session_type,
is_tiling_wm_cmd,
open_mini_player,
@@ -715,6 +773,7 @@ pub fn run() {
psysonic_library::commands::library_get_track,
psysonic_library::commands::library_get_tracks_batch,
psysonic_library::commands::library_get_tracks_by_album,
psysonic_library::commands::library_upsert_songs_from_api,
psysonic_library::commands::library_get_artifact,
psysonic_library::commands::library_get_facts,
psysonic_library::commands::library_get_offline_path,
@@ -775,6 +834,19 @@ pub fn run() {
psysonic_syncfs::cache::offline::clear_offline_cancel,
psysonic_syncfs::cache::offline::delete_offline_track,
psysonic_syncfs::cache::offline::get_offline_cache_size,
psysonic_syncfs::cache::local::download_track_local,
psysonic_syncfs::cache::local::probe_library_track_local,
psysonic_syncfs::cache::local::discover_library_tier_on_disk,
psysonic_syncfs::cache::local::prune_orphan_library_tier_files,
psysonic_syncfs::cache::local::prune_orphan_ephemeral_cache_files,
psysonic_syncfs::cache::local::evict_ephemeral_cache_orphans_to_fit,
psysonic_syncfs::cache::local::probe_media_files,
psysonic_syncfs::cache::local::get_media_tier_size,
psysonic_syncfs::cache::local::purge_media_tier,
psysonic_syncfs::cache::local::delete_media_file,
psysonic_syncfs::cache::local::prune_empty_media_tier_dirs,
psysonic_syncfs::cache::local::promote_stream_cache_to_local,
psysonic_syncfs::cache::local::migrate_legacy_offline_disk,
psysonic_syncfs::cache::hot::download_track_hot_cache,
psysonic_syncfs::cache::hot::promote_stream_cache_to_hot_cache,
psysonic_syncfs::cache::hot::get_hot_cache_size,
+2 -2
View File
@@ -49,8 +49,8 @@ pub(crate) struct LogTailDto {
pub dropped: bool,
}
/// Incremental tail of the in-memory runtime log buffer for the Performance
/// Probe Logs tab. `after_seq` is the highest seq the UI already has (omit for
/// Incremental tail of the in-memory runtime log buffer for the PsyLab Logs tab.
/// `after_seq` is the highest seq the UI already has (omit for
/// the initial fetch of the most recent `max` lines).
#[tauri::command]
pub(crate) fn tail_runtime_logs(after_seq: Option<u64>, max: Option<usize>) -> LogTailDto {
@@ -23,6 +23,7 @@ pub(crate) use perf::performance_cpu_snapshot;
pub(crate) use platform::{
linux_wayland_gpu_font_tuning_active, linux_wayland_text_render_settings_available,
set_linux_wayland_text_render_profile, set_linux_webkit_smooth_scrolling, set_window_decorations,
theme_animation_risk,
};
#[cfg(target_os = "linux")]
pub(crate) use platform::{
@@ -137,6 +137,29 @@ pub(crate) fn linux_webkit_apply_wayland_gpu_font_tuning(win: &tauri::WebviewWin
}
/// Toggle native window decorations at runtime (Linux custom title bar opt-out).
/// Tauri command: true when theme animations may be costly on this setup —
/// Linux with the Nvidia WebKit quirk active (recorded once at startup) or
/// compositing forced off. The frontend warns on animated themes when true.
/// Always false off Linux.
#[tauri::command]
pub(crate) fn theme_animation_risk() -> bool {
#[cfg(target_os = "linux")]
{
// Compositing forced off → GPU-accelerated effects/animation are costly.
if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE")
.map(|v| v == "1")
.unwrap_or(false)
{
return true;
}
crate::theme_animation::nvidia_quirk_active()
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
#[tauri::command]
pub(crate) fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) {
if let Some(win) = app_handle.get_webview_window("main") {
+6 -1
View File
@@ -17,11 +17,16 @@ fn apply_linux_webkit_nvidia_quirk() {
// may still be `XDG_SESSION_TYPE=wayland`. The quirk maps that to `__NV_DISABLE_EXPLICIT_SYNC`,
// which mismatches a real X11 EGL stack and can leave the webview gray — mirror the native-X11
// branch (`WEBKIT_DISABLE_DMABUF_RENDERER` only) whenever GDK is pinned to x11 first in the list.
// Detect once and record it for the UI's animated-theme CPU-load warning,
// so the theme_animation_risk command never has to re-probe the GPU.
let kind = needs_workaround();
psysonic_lib::theme_animation::set_nvidia_quirk_active(!matches!(kind, WorkaroundKind::None));
let forced_x11_gdk = std::env::var("GDK_BACKEND").ok().is_some_and(|s| {
matches!(s.split(',').next().map(str::trim), Some("x11"))
});
if forced_x11_gdk {
match needs_workaround() {
match kind {
WorkaroundKind::None => {}
WorkaroundKind::DisableWebkitDmabufRenderer | WorkaroundKind::DisableNvExplicitSync => {
set_webkit_disable_dmabuf_renderer();
+25
View File
@@ -0,0 +1,25 @@
//! Startup-recorded display hint for the theme system.
//!
//! The Nvidia/WebKit GPU quirk is detected once at process start (in `main()`,
//! before the webview/GTK init). We record whether it was needed so the UI can
//! warn that animated themes may raise CPU load on this setup — without
//! re-probing the GPU later. Read via the `theme_animation_risk` command
//! (`lib_commands::app_api::platform`).
use std::sync::OnceLock;
static NVIDIA_QUIRK_ACTIVE: OnceLock<bool> = OnceLock::new();
/// Record the startup Nvidia-WebKit-quirk detection. Called once from `main()`.
pub fn set_nvidia_quirk_active(active: bool) {
let _ = NVIDIA_QUIRK_ACTIVE.set(active);
}
/// Whether the Nvidia WebKit quirk was needed at startup. Linux-only: read
/// solely by the Linux branch of `theme_animation_risk`. False when unrecorded
/// (GPU acceleration opted in via `PSYSONIC_WEBKIT_GPU_ACCEL`). Gated so it is
/// not dead code on Windows/macOS, where the quirk never applies.
#[cfg(target_os = "linux")]
pub(crate) fn nvidia_quirk_active() -> bool {
NVIDIA_QUIRK_ACTIVE.get().copied().unwrap_or(false)
}
+99
View File
@@ -0,0 +1,99 @@
//! Local theme-package import.
//!
//! Reads a user-picked `.zip` and returns its `manifest.json` + `theme.css`
//! to the frontend, which runs the full theme-store contract validation
//! (`src/utils/themes/validateThemePackage.ts`) before installing.
//!
//! Only those two entries are pulled out — the thumbnail is not needed (the UI
//! derives a swatch from the CSS). Parsing the untrusted archive happens here
//! in Rust, outside the webview, and every read is size-capped so a malformed
//! or hostile archive (lying header, zip-bomb, path traversal) cannot exhaust
//! memory or escape the archive.
use std::io::Read;
use serde::Serialize;
/// On-disk archive cap. A real token-only theme zip is a few KB.
const MAX_ARCHIVE_BYTES: u64 = 4 * 1024 * 1024;
/// Per-entry uncompressed caps — mirror the frontend/CI limits
/// (`validateThemeCss` caps CSS at 64 KB; the manifest is tiny).
const MAX_MANIFEST_BYTES: usize = 64 * 1024;
const MAX_CSS_BYTES: usize = 256 * 1024;
#[derive(Serialize)]
pub struct ImportedThemeFiles {
pub manifest: String,
pub css: String,
}
#[tauri::command]
pub fn import_theme_zip(path: String) -> Result<ImportedThemeFiles, String> {
let file = std::fs::File::open(&path).map_err(|e| format!("cannot open file: {e}"))?;
let len = file
.metadata()
.map_err(|e| format!("cannot read file info: {e}"))?
.len();
if len > MAX_ARCHIVE_BYTES {
return Err(format!(
"archive is too large (> {} KB)",
MAX_ARCHIVE_BYTES / 1024
));
}
let mut archive =
zip::ZipArchive::new(file).map_err(|_| "not a valid .zip archive".to_string())?;
let manifest = read_capped_entry(&mut archive, "manifest.json", MAX_MANIFEST_BYTES)?
.ok_or_else(|| "manifest.json was not found in the archive".to_string())?;
let css = read_capped_entry(&mut archive, "theme.css", MAX_CSS_BYTES)?
.ok_or_else(|| "theme.css was not found in the archive".to_string())?;
Ok(ImportedThemeFiles { manifest, css })
}
/// Find the first non-directory entry whose file name equals `wanted` (at the
/// archive root or under a single wrapping folder), reject path traversal, and
/// read it as UTF-8 text under `cap` bytes. Both the declared size and the
/// actual read are bounded, so a lying header cannot allocate past the cap.
fn read_capped_entry<R: Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
wanted: &str,
cap: usize,
) -> Result<Option<String>, String> {
for i in 0..archive.len() {
let mut entry = archive
.by_index(i)
.map_err(|e| format!("corrupt archive entry: {e}"))?;
if entry.is_dir() {
continue;
}
// `enclosed_name()` is `None` for absolute paths or `..` traversal.
let base = match entry.enclosed_name() {
Some(p) => match p.file_name().and_then(|s| s.to_str()) {
Some(s) => s.to_string(),
None => continue,
},
None => return Err("archive contains an unsafe path".to_string()),
};
if base != wanted {
continue;
}
if entry.size() > cap as u64 {
return Err(format!("{wanted} is too large (> {} KB)", cap / 1024));
}
let mut buf = Vec::new();
entry
.by_ref()
.take(cap as u64 + 1)
.read_to_end(&mut buf)
.map_err(|e| format!("cannot read {wanted}: {e}"))?;
if buf.len() > cap {
return Err(format!("{wanted} is too large (> {} KB)", cap / 1024));
}
return String::from_utf8(buf)
.map(Some)
.map_err(|_| format!("{wanted} is not valid UTF-8"));
}
Ok(None)
}
+2 -1
View File
@@ -22,7 +22,8 @@
"fullscreen": false,
"decorations": true,
"transparent": false,
"visible": true,
"visible": false,
"backgroundColor": "#1e1e2e",
"dragDropEnabled": false
}
],
+56
View File
@@ -1,6 +1,10 @@
import { useEffect } from 'react';
import { useAuthStore } from './store/authStore';
import { usePlayerStore } from './store/playerStore';
import { useLyricsStore } from './store/lyricsStore';
import { useThemeStore } from './store/themeStore';
import { useInstalledThemesStore } from './store/installedThemesStore';
import { syncInjectedThemes } from './utils/themes/themeInjection';
import { useThemeScheduler } from './hooks/useThemeScheduler';
import { useFontStore } from './store/fontStore';
import { getWindowKind } from './app/windowKind';
@@ -13,14 +17,66 @@ export default function App() {
useThemeStore(s => s.theme);
const effectiveTheme = useThemeScheduler();
const font = useFontStore(s => s.font);
const installedThemes = useInstalledThemesStore(s => s.themes);
// Document-attribute hooks are shared between both window kinds — each
// webview has its own `document`, and theme / font / track-preview tokens
// are read by CSS in both trees.
// Installed community themes have no build-time CSS — inject their
// `[data-theme='<id>']` blocks into <head> from the persisted (localStorage)
// store. Runs before the data-theme effect below so the matching style exists
// when the attribute is applied. The store hydrates synchronously, so an
// active community theme is painted without a network round-trip.
useEffect(() => {
syncInjectedThemes(installedThemes);
}, [installedThemes]);
// Dev only: `--theme-watch <theme.css>` (debug builds) pushes a local theme's
// CSS in on every save. Install it under the id in its `[data-theme='<id>']`
// selector and apply it — the syncInjectedThemes effect above re-injects, so
// authoring is live without re-importing a zip. Never wired in production.
useEffect(() => {
if (!import.meta.env.DEV) return;
let unlisten: (() => void) | undefined;
void import('@tauri-apps/api/event').then(({ listen }) => {
const sub = listen<string>('theme-watch:css', ({ payload }) => {
const id = payload.match(/\[data-theme=['"]([^'"]+)['"]\]/)?.[1];
if (!id) return;
useInstalledThemesStore.getState().install({
id, name: id, author: 'dev', version: '0.0.0', description: '', mode: 'dark', css: payload, installedAt: Date.now(),
});
useThemeStore.getState().setTheme(id);
});
// Guard the mocked-in-tests case where listen() isn't a promise.
if (sub && typeof sub.then === 'function') sub.then(u => { unlisten = u; });
}).catch(() => {});
return () => unlisten?.();
}, []);
useEffect(() => {
document.documentElement.setAttribute('data-theme', effectiveTheme);
}, [effectiveTheme]);
// Expose app state on the theme root so themes can react to it with a
// same-element compound selector, e.g. `[data-theme='x'][data-playing='true']`.
// The set of allowed state attributes is the contract's `stateSelectors`.
// (Sidebar-collapsed is set in AppShell, where that state lives.)
const isPlaying = usePlayerStore(s => s.isPlaying);
useEffect(() => {
document.documentElement.setAttribute('data-playing', isPlaying ? 'true' : 'false');
}, [isPlaying]);
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
useEffect(() => {
document.documentElement.setAttribute('data-fullscreen', isFullscreenOpen ? 'true' : 'false');
}, [isFullscreenOpen]);
const lyricsOpen = useLyricsStore(s => s.activeTab === 'lyrics');
useEffect(() => {
document.documentElement.setAttribute('data-lyrics-open', lyricsOpen ? 'true' : 'false');
}, [lyricsOpen]);
useEffect(() => {
document.documentElement.setAttribute('data-font', font);
}, [font]);
+24
View File
@@ -473,6 +473,18 @@ export function libraryGetTrack(
.then(track => (track ? { ...track, serverId } : track));
}
/** Seed library index rows from live Subsonic song payloads (pin/download cold miss). */
export function libraryUpsertSongsFromApi(
serverId: string,
songs: unknown[],
): Promise<number> {
const indexKey = serverIndexKeyForId(serverId);
return invoke<number>('library_upsert_songs_from_api', { serverId: indexKey, songs });
}
/** `library_get_tracks_batch` cap (spec §7.1). */
export const LIBRARY_TRACKS_BATCH_LIMIT = 100;
export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise<LibraryTrackDto[]> {
const indexKeyMap = new Map<string, string>();
const remapped = refs.map(ref => {
@@ -487,6 +499,18 @@ export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise<LibraryTrack
})));
}
/** Chunked batch fetch — safe when `refs.length` exceeds {@link LIBRARY_TRACKS_BATCH_LIMIT}. */
export async function libraryGetTracksBatchChunked(refs: TrackRefDto[]): Promise<LibraryTrackDto[]> {
if (refs.length === 0) return [];
const out: LibraryTrackDto[] = [];
for (let i = 0; i < refs.length; i += LIBRARY_TRACKS_BATCH_LIMIT) {
const chunk = refs.slice(i, i + LIBRARY_TRACKS_BATCH_LIMIT);
const batch = await libraryGetTracksBatch(chunk).catch(() => []);
out.push(...batch);
}
return out;
}
export function libraryGetTracksByAlbum(
serverId: string,
albumId: string,
+5
View File
@@ -20,6 +20,11 @@ vi.mock('axios', () => ({
default: { get: vi.fn() },
}));
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
shouldAttemptSubsonicForActiveServer: () => true,
shouldAttemptSubsonicForServer: () => true,
}));
import axios from 'axios';
import { pingWithCredentials, ping } from './subsonic';
import { getAlbumInfo2 } from './subsonicAlbumInfo';
+89
View File
@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./subsonicOpenSubsonic', () => ({
fetchOpenSubsonicExtensionsWithCredentials: vi.fn(),
}));
import { fetchOpenSubsonicExtensionsWithCredentials } from './subsonicOpenSubsonic';
import { scheduleInstantMixProbeForServer } from './subsonic';
import { useAuthStore } from '../store/authStore';
import type { SubsonicServerIdentity } from '../utils/server/subsonicServerIdentity';
const fetchMock = vi.mocked(fetchOpenSubsonicExtensionsWithCredentials);
const SID = 'srv-probe';
const id062: SubsonicServerIdentity = { type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true };
const flush = () => new Promise(resolve => setTimeout(resolve, 0));
function reset() {
useAuthStore.setState({
subsonicServerIdentityByServer: {},
audiomusePluginProbeByServer: {},
instantMixProbeByServer: {},
audiomuseNavidromeByServer: {},
audiomuseNavidromeIssueByServer: {},
} as never);
}
describe('scheduleInstantMixProbeForServer (idempotency)', () => {
beforeEach(() => {
fetchMock.mockReset();
fetchMock.mockResolvedValue(['sonicSimilarity']);
reset();
});
it('probes once, caches the result, then skips on the next poll', async () => {
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
expect(fetchMock).toHaveBeenCalledTimes(1);
await flush();
expect(useAuthStore.getState().audiomusePluginProbeByServer[SID]).toBe('present');
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('re-probes when forced (user-initiated refresh)', async () => {
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
await flush();
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062, true);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('re-probes after a prior error', async () => {
fetchMock.mockResolvedValueOnce(null);
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
await flush();
expect(useAuthStore.getState().audiomusePluginProbeByServer[SID]).toBe('error');
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
describe('setSubsonicServerIdentity (version-change cache invalidation)', () => {
beforeEach(reset);
it('clears cached probes on a version change but keeps the opt-in', () => {
useAuthStore.setState({
subsonicServerIdentityByServer: { [SID]: id062 },
audiomusePluginProbeByServer: { [SID]: 'present' },
audiomuseNavidromeByServer: { [SID]: true },
} as never);
useAuthStore.getState().setSubsonicServerIdentity(SID, { type: 'navidrome', serverVersion: '0.63.0', openSubsonic: true });
const s = useAuthStore.getState();
expect(s.audiomusePluginProbeByServer[SID]).toBeUndefined();
expect(s.audiomuseNavidromeByServer[SID]).toBe(true);
});
it('keeps cached probes when the identity is unchanged', () => {
useAuthStore.setState({
subsonicServerIdentityByServer: { [SID]: id062 },
audiomusePluginProbeByServer: { [SID]: 'present' },
} as never);
useAuthStore.getState().setSubsonicServerIdentity(SID, { ...id062 });
expect(useAuthStore.getState().audiomusePluginProbeByServer[SID]).toBe('present');
});
});
+78 -11
View File
@@ -2,17 +2,34 @@ import axios from 'axios';
import md5 from 'md5';
import { useAuthStore } from '../store/authStore';
import {
isNavidromeAudiomuseSoftwareEligible,
type InstantMixProbeResult,
type SubsonicServerIdentity,
} from '../utils/server/subsonicServerIdentity';
import { fetchOpenSubsonicExtensionsWithCredentials } from './subsonicOpenSubsonic';
import { buildCapabilityContext } from '../serverCapabilities/context';
import {
PROBE_LEGACY_INSTANT_MIX,
PROBE_OPENSUBSONIC_EXTENSIONS,
SERVER_CAPABILITY_CATALOG,
SONIC_SIMILARITY_EXTENSION,
} from '../serverCapabilities/catalog';
import { neededProbeIds } from '../serverCapabilities/resolve';
import {
SUBSONIC_CLIENT,
SUBSONIC_API_VERSION,
api,
apiWithCredentials,
secureRandomSalt,
} from './subsonicClient';
import type { PingWithCredentialsResult, SubsonicSong } from './subsonicTypes';
import type { PingFailure, PingWithCredentialsResult, SubsonicSong } from './subsonicTypes';
/** Map a Subsonic error code to a coarse failure category for the UI. */
function classifyPingError(code: number | undefined, message: string | undefined): PingFailure {
let reason: PingFailure['reason'] = 'server';
if (code === 40 || code === 41 || code === 50) reason = 'auth';
else if (code === 20 || code === 30) reason = 'version';
return { reason, code, message };
}
export async function ping(): Promise<boolean> {
try {
@@ -35,21 +52,33 @@ export async function pingWithCredentials(
const salt = secureRandomSalt();
const token = md5(password + salt);
const resp = await axios.get(`${base}/rest/ping.view`, {
params: { u: username, t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json' },
params: { u: username, t: token, s: salt, v: SUBSONIC_API_VERSION, c: SUBSONIC_CLIENT, f: 'json' },
paramsSerializer: { indexes: null },
timeout: 15000,
});
const data = resp.data?.['subsonic-response'];
const ok = data?.status === 'ok';
return {
ok,
const identity = {
type: typeof data?.type === 'string' ? data.type : undefined,
serverVersion: typeof data?.serverVersion === 'string' ? data.serverVersion : undefined,
openSubsonic: data?.openSubsonic === true,
};
if (ok) return { ok: true, ...identity };
// Reachable server that rejected the ping — keep the Subsonic reason so the
// UI can show a specific message (code 30 = protocol too high, 40 = bad
// credentials, …) instead of an opaque failure.
const code = typeof data?.error?.code === 'number' ? data.error.code : undefined;
const message = typeof data?.error?.message === 'string' ? data.error.message : undefined;
console.warn('[psysonic] ping rejected by server:', serverUrl, 'sentVersion=', SUBSONIC_API_VERSION, data?.error ?? data);
return { ok: false, failure: classifyPingError(code, message), ...identity };
} catch (err) {
// Never reached the server (DNS, refused, timeout, TLS-cert not trusted, or
// a blocked cross-origin request). The WebView hides the exact cause, so
// pass the raw detail through for the toast + log.
const detail =
(err as { message?: string })?.message || (err as { code?: string })?.code || 'network error';
console.warn('[psysonic] pingWithCredentials failed:', serverUrl, err);
return { ok: false };
return { ok: false, failure: { reason: 'network', message: String(detail) } };
}
}
@@ -104,16 +133,54 @@ export async function probeInstantMixWithCredentials(
}
}
/** After a successful ping, probe Instant Mix in the background (Navidrome ≥ 0.60 only). */
/**
* After a successful ping, run the server-capability probes needed by the catalog
* (`serverCapabilities/`). Which probes run is decided by the strategies eligible
* for this server generation not by inline version checks here.
*
* Currently: Navidrome 0.62 `getOpenSubsonicExtensions` (sonicSimilarity);
* Navidrome 0.600.61 legacy `getSimilarSongs` Instant Mix probe.
*
* Idempotent: a server's advertised capabilities are static within a session, so
* once a definitive result is cached the probe is skipped. This is called on every
* 120 s connection poll, so re-fetching each time would be wasteful and would flip
* the resolved status (and the routed endpoint) through a `probing` window. Pass
* `force` for user-initiated refreshes (add/edit/test server); a server version or
* type change clears the cache (see `setSubsonicServerIdentity`), forcing a re-probe.
*/
export function scheduleInstantMixProbeForServer(
serverId: string,
serverUrl: string,
username: string,
password: string,
identity: SubsonicServerIdentity,
force = false,
): void {
if (!isNavidromeAudiomuseSoftwareEligible(identity)) return;
void probeInstantMixWithCredentials(serverUrl, username, password).then(result =>
useAuthStore.getState().setInstantMixProbe(serverId, result),
);
const ctx = buildCapabilityContext(identity);
const probeIds = neededProbeIds(SERVER_CAPABILITY_CATALOG, ctx);
const store = useAuthStore.getState();
if (probeIds.has(PROBE_OPENSUBSONIC_EXTENSIONS)) {
const cached = store.audiomusePluginProbeByServer[serverId];
// Re-probe only without a definitive cached result (or on force / prior error).
// `probing` means an in-flight fetch — skip to avoid a duplicate request.
if (force || cached === undefined || cached === 'error') {
store.setAudiomusePluginProbe(serverId, 'probing');
void fetchOpenSubsonicExtensionsWithCredentials(serverUrl, username, password).then(extensions => {
const result = extensions === null
? 'error'
: extensions.includes(SONIC_SIMILARITY_EXTENSION) ? 'present' : 'absent';
useAuthStore.getState().setAudiomusePluginProbe(serverId, result);
});
}
}
if (probeIds.has(PROBE_LEGACY_INSTANT_MIX)) {
const cached = store.instantMixProbeByServer[serverId];
if (force || cached === undefined || cached === 'error') {
void probeInstantMixWithCredentials(serverUrl, username, password).then(result =>
useAuthStore.getState().setInstantMixProbe(serverId, result),
);
}
}
}
+79
View File
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./subsonicClient', () => ({
api: vi.fn(),
apiForServer: vi.fn(),
libraryFilterParams: () => ({}),
libraryFilterParamsForServer: () => ({}),
}));
vi.mock('./subsonicLibrary', () => ({
filterSongsToActiveLibrary: async (songs: unknown[]) => songs,
filterSongsToServerLibrary: async (songs: unknown[]) => songs,
similarSongsRequestCount: (count: number) => count,
}));
import { api } from './subsonicClient';
import { fetchSimilarTracksRouted } from './subsonicArtists';
import { useAuthStore } from '../store/authStore';
const SID = 'srv-router';
const apiMock = vi.mocked(api);
function seedServer(identity: Record<string, unknown>, probes: Record<string, unknown>) {
useAuthStore.setState({
activeServerId: SID,
subsonicServerIdentityByServer: { [SID]: identity as never },
audiomusePluginProbeByServer: {},
instantMixProbeByServer: {},
audiomuseNavidromeByServer: {},
...probes,
} as never);
}
const SONIC_RESPONSE = { sonicMatch: [{ entry: { id: 'sonic-1', title: 'Sonic' } }] };
const SIMILAR_RESPONSE = { similarSongs: { song: [{ id: 'legacy-1', title: 'Legacy' }] } };
describe('fetchSimilarTracksRouted', () => {
beforeEach(() => {
apiMock.mockReset();
});
it('prefers sonicSimilarity on Navidrome 0.62 with plugin', async () => {
seedServer({ type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true }, {
audiomusePluginProbeByServer: { [SID]: 'present' },
});
apiMock.mockImplementation(async (endpoint: string) =>
(endpoint === 'getSonicSimilarTracks.view' ? SONIC_RESPONSE : SIMILAR_RESPONSE) as never);
const result = await fetchSimilarTracksRouted('seed', 10);
expect(result.map(s => s.id)).toEqual(['sonic-1']);
expect(apiMock).toHaveBeenCalledWith('getSonicSimilarTracks.view', expect.anything());
expect(apiMock).not.toHaveBeenCalledWith('getSimilarSongs.view', expect.anything());
});
it('falls back to legacy when sonic returns empty', async () => {
seedServer({ type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true }, {
audiomusePluginProbeByServer: { [SID]: 'present' },
});
apiMock.mockImplementation(async (endpoint: string) =>
(endpoint === 'getSonicSimilarTracks.view' ? { sonicMatch: [] } : SIMILAR_RESPONSE) as never);
const result = await fetchSimilarTracksRouted('seed', 10);
expect(result.map(s => s.id)).toEqual(['legacy-1']);
expect(apiMock).toHaveBeenCalledWith('getSonicSimilarTracks.view', expect.anything());
expect(apiMock).toHaveBeenCalledWith('getSimilarSongs.view', expect.anything());
});
it('uses legacy only on Navidrome 0.62 without plugin', async () => {
seedServer({ type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true }, {
audiomusePluginProbeByServer: { [SID]: 'absent' },
});
apiMock.mockImplementation(async () => SIMILAR_RESPONSE as never);
const result = await fetchSimilarTracksRouted('seed', 10);
expect(result.map(s => s.id)).toEqual(['legacy-1']);
expect(apiMock).not.toHaveBeenCalledWith('getSonicSimilarTracks.view', expect.anything());
expect(apiMock).toHaveBeenCalledWith('getSimilarSongs.view', expect.anything());
});
});
+47
View File
@@ -2,6 +2,11 @@ import { useAuthStore } from '../store/authStore';
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
import { filterSongsToServerLibrary } from './subsonicLibrary';
import { filterSongsToActiveLibrary, similarSongsRequestCount } from './subsonicLibrary';
import {
FEATURE_AUDIOMUSE_SIMILAR_TRACKS,
OP_SIMILAR_TRACKS,
} from '../serverCapabilities/catalog';
import { resolveCallRoutesForServer } from '../serverCapabilities/storeView';
import type {
SubsonicAlbum,
SubsonicArtist,
@@ -109,3 +114,45 @@ export async function getSimilarSongs(id: string, count = 50): Promise<SubsonicS
return [];
}
}
/**
* Sonic (audio-analysis) similar tracks via the OpenSubsonic `sonicSimilarity`
* extension (Navidrome 0.62 + AudioMuse plugin). Returns `[]` when the server
* has no provider (HTTP 404) so callers can fall back.
*/
export async function getSonicSimilarTracks(id: string, count = 50): Promise<SubsonicSong[]> {
try {
const requestCount = similarSongsRequestCount(count);
const data = await api<{ sonicMatch: Array<{ entry?: SubsonicSong }> | { entry?: SubsonicSong } }>(
'getSonicSimilarTracks.view',
{ id, count: requestCount, ...libraryFilterParams() },
);
const raw = data.sonicMatch;
const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
const songs = list.map(m => m.entry).filter((e): e is SubsonicSong => !!e);
if (songs.length === 0) return [];
const filtered = await filterSongsToActiveLibrary(songs);
return filtered.slice(0, count);
} catch {
return [];
}
}
/**
* Capability-routed similar tracks for the active server. Prefers the sonic
* similarity endpoint when the AudioMuse plugin is detected (Navidrome 0.62),
* falling back to legacy `getSimilarSongs` on empty/unavailable.
*/
export async function fetchSimilarTracksRouted(songId: string, count = 50): Promise<SubsonicSong[]> {
const { activeServerId } = useAuthStore.getState();
if (!activeServerId) return getSimilarSongs(songId, count);
const routes = resolveCallRoutesForServer(activeServerId, FEATURE_AUDIOMUSE_SIMILAR_TRACKS, OP_SIMILAR_TRACKS);
if (routes.length === 0) return getSimilarSongs(songId, count);
for (const route of routes) {
const songs = route.transport === 'opensubsonic'
? await getSonicSimilarTracks(songId, count)
: await getSimilarSongs(songId, count);
if (songs.length > 0) return songs;
}
return [];
}
+10 -1
View File
@@ -8,6 +8,15 @@ import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../utils/s
export const SUBSONIC_CLIENT = `psysonic/${version}`;
/**
* Subsonic REST protocol version sent on every request. A server returns
* error 30 ("Incompatible REST protocol version") if the client asks for a
* HIGHER version than it supports surfaced to the user via the connection
* error toast. OpenSubsonic extensions are negotiated via the `openSubsonic`
* response flag, not this number.
*/
export const SUBSONIC_API_VERSION = '1.16.1';
export function secureRandomSalt(): string {
const buf = new Uint8Array(8);
crypto.getRandomValues(buf);
@@ -17,7 +26,7 @@ export function secureRandomSalt(): string {
export function getAuthParams(username: string, password: string) {
const salt = secureRandomSalt();
const token = md5(password + salt);
return { u: username, t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json' };
return { u: username, t: token, s: salt, v: SUBSONIC_API_VERSION, c: SUBSONIC_CLIENT, f: 'json' };
}
export function restBaseFromUrl(serverUrl: string): string {
+15
View File
@@ -1,4 +1,8 @@
import { useAuthStore } from '../store/authStore';
import {
shouldAttemptSubsonicForActiveServer,
shouldAttemptSubsonicForServer,
} from '../utils/network/subsonicNetworkGuard';
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
import type {
RandomSongsFilters,
@@ -57,6 +61,7 @@ export async function getMusicFolders(): Promise<SubsonicMusicFolder[]> {
}
export async function getRandomAlbums(size = 6): Promise<SubsonicAlbum[]> {
if (!shouldAttemptSubsonicForActiveServer()) return [];
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
type: 'random',
size,
@@ -71,6 +76,7 @@ export async function getAlbumList(
offset = 0,
extra: Record<string, unknown> = {}
): Promise<SubsonicAlbum[]> {
if (!shouldAttemptSubsonicForActiveServer()) return [];
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
type,
size,
@@ -212,6 +218,7 @@ export async function getAlbumListForServer(
offset = 0,
extra: Record<string, unknown> = {},
): Promise<SubsonicAlbum[]> {
if (!shouldAttemptSubsonicForServer(serverId)) return [];
const data = await apiForServer<{ albumList2: { album: SubsonicAlbum[] } }>(serverId, 'getAlbumList2.view', {
type,
size,
@@ -224,6 +231,7 @@ export async function getAlbumListForServer(
}
export async function getSong(id: string): Promise<SubsonicSong | null> {
if (!shouldAttemptSubsonicForActiveServer()) return null;
try {
const data = await api<{ song: SubsonicSong }>('getSong.view', { id });
return data.song ?? null;
@@ -233,6 +241,7 @@ export async function getSong(id: string): Promise<SubsonicSong | null> {
}
export async function getSongForServer(serverId: string, id: string): Promise<SubsonicSong | null> {
if (!shouldAttemptSubsonicForServer(serverId, id)) return null;
try {
const data = await apiForServer<{ song: SubsonicSong }>(serverId, 'getSong.view', { id });
return data.song ?? null;
@@ -242,6 +251,9 @@ export async function getSongForServer(serverId: string, id: string): Promise<Su
}
export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
if (!shouldAttemptSubsonicForActiveServer()) {
throw new Error('Subsonic unavailable');
}
const data = await api<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>('getAlbum.view', { id });
const { song, ...album } = data.album;
return { album, songs: song ?? [] };
@@ -251,6 +263,9 @@ export async function getAlbumForServer(
serverId: string,
id: string,
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
if (!shouldAttemptSubsonicForServer(serverId)) {
throw new Error('Subsonic unavailable');
}
const data = await apiForServer<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>(serverId, 'getAlbum.view', { id });
const { song, ...album } = data.album;
return { album, songs: song ?? [] };
+72
View File
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import axios from 'axios';
import {
fetchOpenSubsonicExtensionsWithCredentials,
hasOpenSubsonicExtension,
parseOpenSubsonicExtensions,
} from './subsonicOpenSubsonic';
vi.mock('axios');
function okExtensions(extensions: unknown[]) {
return {
data: {
'subsonic-response': {
status: 'ok',
openSubsonic: true,
openSubsonicExtensions: extensions,
},
},
};
}
describe('parseOpenSubsonicExtensions', () => {
it('parses extension names and versions', () => {
const parsed = parseOpenSubsonicExtensions([
{ name: 'sonicSimilarity', versions: [1] },
{ name: 'playbackReport', versions: [1, 2] },
{ bad: true },
]);
expect(parsed).toEqual([
{ name: 'sonicSimilarity', versions: [1] },
{ name: 'playbackReport', versions: [1, 2] },
]);
});
});
describe('hasOpenSubsonicExtension', () => {
it('detects sonicSimilarity', () => {
const extensions = parseOpenSubsonicExtensions([{ name: 'sonicSimilarity', versions: [1] }]);
expect(hasOpenSubsonicExtension(extensions, 'sonicSimilarity')).toBe(true);
expect(hasOpenSubsonicExtension(extensions, 'other')).toBe(false);
});
});
describe('fetchOpenSubsonicExtensionsWithCredentials', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns the advertised extension names', async () => {
vi.mocked(axios.get).mockResolvedValue(
okExtensions([{ name: 'sonicSimilarity', versions: [1] }, { name: 'playbackReport', versions: [1] }]),
);
await expect(
fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p'),
).resolves.toEqual(['sonicSimilarity', 'playbackReport']);
});
it('returns an empty list when none are advertised', async () => {
vi.mocked(axios.get).mockResolvedValue(okExtensions([]));
await expect(
fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p'),
).resolves.toEqual([]);
});
it('returns null on request failure', async () => {
vi.mocked(axios.get).mockRejectedValue(new Error('boom'));
await expect(
fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p'),
).resolves.toBeNull();
});
});
+47
View File
@@ -0,0 +1,47 @@
import { apiWithCredentials } from './subsonicClient';
export interface OpenSubsonicExtension {
name: string;
versions: number[];
}
export function parseOpenSubsonicExtensions(raw: unknown): OpenSubsonicExtension[] {
if (!Array.isArray(raw)) return [];
return raw
.filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object' && !Array.isArray(entry))
.map(entry => ({
name: typeof entry.name === 'string' ? entry.name : '',
versions: Array.isArray(entry.versions)
? entry.versions.filter((v): v is number => typeof v === 'number')
: [],
}))
.filter(entry => entry.name.length > 0);
}
export function hasOpenSubsonicExtension(extensions: readonly OpenSubsonicExtension[], name: string): boolean {
return extensions.some(ext => ext.name === name);
}
/**
* Fetch the list of OpenSubsonic extension names advertised by the server, or
* `null` when the request fails. Shared probe for any extension-gated feature.
*/
export async function fetchOpenSubsonicExtensionsWithCredentials(
serverUrl: string,
username: string,
password: string,
): Promise<string[] | null> {
try {
const data = await apiWithCredentials<{ openSubsonicExtensions?: unknown }>(
serverUrl,
username,
password,
'getOpenSubsonicExtensions.view',
{},
12000,
);
return parseOpenSubsonicExtensions(data.openSubsonicExtensions).map(ext => ext.name);
} catch {
return null;
}
}
+21 -1
View File
@@ -1,6 +1,7 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { api } from './subsonicClient';
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
import { api, apiForServer } from './subsonicClient';
import type { SubsonicPlaylist, SubsonicSong } from './subsonicTypes';
export async function getPlaylists(includeOrbit = false): Promise<SubsonicPlaylist[]> {
@@ -19,6 +20,22 @@ export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlayl
return { playlist, songs: entry ?? [] };
}
export async function getPlaylistForServer(
serverId: string,
id: string,
): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] }> {
if (!shouldAttemptSubsonicForServer(serverId)) {
throw new Error('Subsonic unavailable');
}
const data = await apiForServer<{ playlist: SubsonicPlaylist & { entry: SubsonicSong[] } }>(
serverId,
'getPlaylist.view',
{ id },
);
const { entry, ...playlist } = data.playlist;
return { playlist, songs: entry ?? [] };
}
export async function createPlaylist(name: string, songIds?: string[]): Promise<SubsonicPlaylist> {
const params: Record<string, unknown> = { name };
if (songIds && songIds.length > 0) {
@@ -40,6 +57,9 @@ export async function updatePlaylist(id: string, songIds: string[], prevCount =
songIndexToRemove: Array.from({ length: prevCount }, (_, i) => i),
});
}
void import('../utils/offline/pinnedOfflineSync')
.then(m => m.schedulePinnedPlaylistSync(id))
.catch(() => {});
}
export async function updatePlaylistMeta(
+3
View File
@@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./subsonicArtists', () => ({ getArtist: vi.fn() }));
vi.mock('./subsonicLibrary', () => ({ getAlbum: vi.fn() }));
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
shouldAttemptSubsonicForActiveServer: vi.fn(() => true),
}));
import { getArtist } from './subsonicArtists';
import { invalidateEntityUserRatingCaches, prefetchArtistUserRatings } from './subsonicRatings';
+3
View File
@@ -1,5 +1,6 @@
import { getArtist } from './subsonicArtists';
import { getAlbum } from './subsonicLibrary';
import { shouldAttemptSubsonicForActiveServer } from '../utils/network/subsonicNetworkGuard';
const MIX_RATING_PREFETCH_CONCURRENCY = 8;
const RATING_CACHE_TTL = 7 * 60 * 1000; // 7 minutes
@@ -55,6 +56,7 @@ export async function prefetchArtistUserRatings(
else uncached.push(id);
}
if (!uncached.length) return out;
if (!shouldAttemptSubsonicForActiveServer()) return out;
let next = 0;
async function worker() {
for (;;) {
@@ -93,6 +95,7 @@ export async function prefetchAlbumUserRatings(
else uncached.push(id);
}
if (!uncached.length) return out;
if (!shouldAttemptSubsonicForActiveServer()) return out;
let next = 0;
async function worker() {
for (;;) {
+3
View File
@@ -11,6 +11,9 @@ vi.mock('./subsonicClient', () => ({
api: vi.fn(),
apiForServer: apiForServerMock,
}));
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
shouldAttemptSubsonicForServer: () => true,
}));
describe('subsonicScrobble', () => {
beforeEach(() => {
+2
View File
@@ -1,6 +1,7 @@
import { api, apiForServer } from './subsonicClient';
import type { SubsonicNowPlaying } from './subsonicTypes';
import { patchLibraryTrackOnUse } from '../utils/library/patchOnUse';
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
async function scrobbleOnServer(
serverId: string,
@@ -8,6 +9,7 @@ async function scrobbleOnServer(
submission: boolean,
time?: number,
): Promise<void> {
if (!shouldAttemptSubsonicForServer(serverId, id)) return;
const params: Record<string, unknown> = { id, submission };
if (time !== undefined) params.time = time;
await apiForServer(serverId, 'scrobble.view', params);
+55 -9
View File
@@ -1,4 +1,4 @@
import { api, libraryFilterParams } from './subsonicClient';
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
import { invalidateEntityUserRatingCaches } from './subsonicRatings';
import { useAuthStore } from '../store/authStore';
import { patchLibraryTrackOnUse, type StarPatchMeta } from '../utils/library/patchOnUse';
@@ -15,6 +15,17 @@ import type {
SubsonicSong,
} from './subsonicTypes';
function parseStarred2Response(data: {
starred2?: {
artist?: SubsonicArtist[];
album?: SubsonicAlbum[];
song?: SubsonicSong[];
};
}): StarredResults {
const r = data.starred2 ?? {};
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
}
export async function getStarred(): Promise<StarredResults> {
const data = await api<{
starred2: {
@@ -23,21 +34,50 @@ export async function getStarred(): Promise<StarredResults> {
song?: SubsonicSong[];
}
}>('getStarred2.view', { ...libraryFilterParams() });
const r = data.starred2 ?? {};
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
return parseStarred2Response(data);
}
/** Starred entities for an explicit saved server (not necessarily the active one). */
export async function getStarredForServer(serverId: string): Promise<StarredResults> {
const data = await apiForServer<{
starred2: {
artist?: SubsonicArtist[];
album?: SubsonicAlbum[];
song?: SubsonicSong[];
};
}>(serverId, 'getStarred2.view', { ...libraryFilterParamsForServer(serverId) });
return parseStarred2Response(data);
}
function resolveStarServerId(meta?: StarPatchMeta): string | null {
return meta?.serverId ?? useAuthStore.getState().activeServerId;
}
async function starApi(
serverId: string | null | undefined,
endpoint: string,
params: Record<string, string>,
): Promise<void> {
const sid = serverId ?? useAuthStore.getState().activeServerId;
if (!sid) throw new Error('No server for star API');
if (sid === useAuthStore.getState().activeServerId) {
await api(endpoint, params);
} else {
await apiForServer(sid, endpoint, params);
}
}
export async function star(
id: string,
type: 'song' | 'album' | 'artist' = 'album',
_meta?: StarPatchMeta,
meta?: StarPatchMeta,
): Promise<void> {
const params: Record<string, string> = {};
if (type === 'song') params.id = id;
if (type === 'album') params.albumId = id;
if (type === 'artist') params.artistId = id;
await api('star.view', params);
const serverId = useAuthStore.getState().activeServerId;
const serverId = resolveStarServerId(meta);
await starApi(serverId, 'star.view', params);
if (type === 'song') {
patchLibraryTrackOnUse(serverId, id, { starredAt: Date.now() });
} else if (type === 'album' && serverId) {
@@ -45,19 +85,22 @@ export async function star(
const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId);
void refreshStarredAlbumIndexFromServer(serverId, indexEnabled).catch(() => {});
}
void import('../utils/offline/favoritesOfflineSync')
.then(m => m.onFavoritesOfflineStarChange(id, type, true, serverId ?? undefined))
.catch(() => {});
}
export async function unstar(
id: string,
type: 'song' | 'album' | 'artist' = 'album',
_meta?: StarPatchMeta,
meta?: StarPatchMeta,
): Promise<void> {
const params: Record<string, string> = {};
if (type === 'song') params.id = id;
if (type === 'album') params.albumId = id;
if (type === 'artist') params.artistId = id;
await api('unstar.view', params);
const serverId = useAuthStore.getState().activeServerId;
const serverId = resolveStarServerId(meta);
await starApi(serverId, 'unstar.view', params);
if (type === 'song') {
patchLibraryTrackOnUse(serverId, id, { starredAt: null });
} else if (type === 'album' && serverId) {
@@ -65,6 +108,9 @@ export async function unstar(
const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId);
void refreshStarredAlbumIndexFromServer(serverId, indexEnabled).catch(() => {});
}
void import('../utils/offline/favoritesOfflineSync')
.then(m => m.onFavoritesOfflineStarChange(id, type, false, serverId ?? undefined))
.catch(() => {});
}
export async function setRating(id: string, rating: number): Promise<void> {
+4 -4
View File
@@ -5,7 +5,7 @@ import type { CoverArtTier } from '../cover/types';
import { useAuthStore } from '../store/authStore';
import { connectBaseUrlForServer } from '../utils/server/serverEndpoint';
import { findServerByIdOrIndexKey } from '../utils/server/serverLookup';
import { restBaseFromUrl, SUBSONIC_CLIENT, secureRandomSalt } from './subsonicClient';
import { restBaseFromUrl, SUBSONIC_CLIENT, SUBSONIC_API_VERSION, secureRandomSalt } from './subsonicClient';
function coverArtQueryParams(username: string, password: string, id: string, size: number): URLSearchParams {
const salt = secureRandomSalt();
@@ -16,7 +16,7 @@ function coverArtQueryParams(username: string, password: string, id: string, siz
u: username,
t: token,
s: salt,
v: '1.16.1',
v: SUBSONIC_API_VERSION,
c: SUBSONIC_CLIENT,
f: 'json',
});
@@ -36,7 +36,7 @@ function streamUrlFromProfile(
u: username,
t: token,
s: salt,
v: '1.16.1',
v: SUBSONIC_API_VERSION,
c: SUBSONIC_CLIENT,
f: 'json',
});
@@ -116,7 +116,7 @@ export function buildDownloadUrl(id: string): string {
const p = new URLSearchParams({
id,
u: server?.username ?? '',
t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json',
t: token, s: salt, v: SUBSONIC_API_VERSION, c: SUBSONIC_CLIENT, f: 'json',
});
return `${baseUrl}/rest/download.view?${p.toString()}`;
}
+20 -1
View File
@@ -26,6 +26,8 @@ export interface SubsonicAlbum {
displayArtist?: string;
/** OpenSubsonic: per-disc subtitles (e.g. "Sessions" on CD 3 of a deluxe edition). */
discTitles?: SubsonicDiscTitle[];
/** Set when favorites are merged across servers (offline favorites tier). */
serverId?: string;
}
export interface SubsonicDiscTitle {
@@ -90,6 +92,8 @@ export interface SubsonicSong {
trackPeak?: number;
albumPeak?: number;
};
/** Set when favorites are merged across servers (offline favorites tier). */
serverId?: string;
/** OpenSubsonic: structured composer credit (string for back-compat). */
displayComposer?: string;
/** OpenSubsonic: structured contributors list — Navidrome ≥ 0.55. */
@@ -144,6 +148,8 @@ export interface SubsonicArtist {
starred?: string;
/** Present on some servers (e.g. OpenSubsonic) for artist-level rating. */
userRating?: number;
/** Set when favorites are merged across servers (offline favorites tier). */
serverId?: string;
}
export interface SubsonicGenre {
@@ -195,7 +201,20 @@ export interface SubsonicDirectory {
child: SubsonicDirectoryEntry[];
}
export type PingWithCredentialsResult = SubsonicServerIdentity & { ok: boolean };
export interface PingFailure {
/** Coarse category that drives the user-facing message. */
reason: 'auth' | 'version' | 'network' | 'server';
/** Subsonic error code, when the server returned a structured error. */
code?: number;
/** Server's error message, or the network/TLS error detail. */
message?: string;
}
export type PingWithCredentialsResult = SubsonicServerIdentity & {
ok: boolean;
/** Populated when `ok` is false — why the ping was rejected. */
failure?: PingFailure;
};
export interface RandomSongsFilters {
size?: number;
+21 -6
View File
@@ -11,6 +11,7 @@ import PlayerBar from '../components/PlayerBar';
import BottomNav from '../components/BottomNav';
import { useIsMobile } from '../hooks/useIsMobile';
import LiveSearch from '../components/LiveSearch';
import DevNetworkModeToggle from '../components/DevNetworkModeToggle';
import NowPlayingDropdown from '../components/NowPlayingDropdown';
import QueuePanel from '../components/QueuePanel';
import AppRoutes from './AppRoutes';
@@ -19,6 +20,7 @@ import ContextMenu from '../components/ContextMenu';
import SongInfoModal from '../components/SongInfoModal';
import DownloadFolderModal from '../components/DownloadFolderModal';
import GlobalConfirmModal from '../components/GlobalConfirmModal';
import ThemeMigrationNotice from '../components/ThemeMigrationNotice';
import OrbitAccountPicker from '../components/OrbitAccountPicker';
import OrbitHelpModal from '../components/OrbitHelpModal';
import TooltipPortal from '../components/TooltipPortal';
@@ -38,7 +40,8 @@ import { useOrbitHost } from '../hooks/useOrbitHost';
import { useOrbitGuest } from '../hooks/useOrbitGuest';
import { useOrbitBodyAttrs } from '../hooks/useOrbitBodyAttrs';
import { usePlatformShellSetup } from '../hooks/usePlatformShellSetup';
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
import { useWindowFullscreenState } from '../hooks/useWindowFullscreenState';
import { useNowPlayingTrayTitle } from '../hooks/useNowPlayingTrayTitle';
import { useTrayMenuI18n } from '../hooks/useTrayMenuI18n';
@@ -51,11 +54,11 @@ import { useCoverNavigationPriority } from '../hooks/useCoverNavigationPriority'
import { useLiveSearchRouteScope } from '../hooks/useLiveSearchRouteScope';
import { useNowPlayingPrewarm } from '../hooks/useNowPlayingPrewarm';
import { useOfflineAutoNav } from '../hooks/useOfflineAutoNav';
import { useOfflineLibraryFilterSuspend } from '../hooks/useOfflineLibraryFilterSuspend';
import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam';
import { IS_LINUX } from '../utils/platform';
import { useConnectionStatus } from '../hooks/useConnectionStatus';
import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore';
import { usePlayerStore } from '../store/playerStore';
import '../store/previewPlayerVolumeSync';
import '../store/queueResolverBridge';
@@ -105,8 +108,10 @@ export function AppShell() {
useLiveSearchRouteScope();
useNowPlayingPrewarm();
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
const offlineCtx = useOfflineBrowseContext();
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
const hasOfflineContent = offlineCtx.hasBrowsingContent;
const hasOfflineBrowse = offlineCtx.hasBrowseCapability;
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
const perfFlags = usePerfProbeFlags();
@@ -134,7 +139,8 @@ export function AppShell() {
document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTo({ top: 0 });
}, [location.pathname, location.state]);
useOfflineAutoNav(connStatus, hasOfflineContent, location.pathname, navigate);
useOfflineAutoNav(connStatus, offlineNav, location, navigate);
useOfflineLibraryFilterSuspend();
useEffect(() => {
initializeFromServerQueue();
@@ -173,6 +179,13 @@ export function AppShell() {
return () => window.removeEventListener('psy:toggle-sidebar', onToggleSidebar);
}, [isSidebarCollapsed, setSidebarCollapsed]);
// Expose sidebar state on the theme root so themes can react with a
// `[data-theme='x'][data-sidebar-collapsed='true']` compound (contract
// `stateSelectors`). Other state attributes are set in App.tsx.
useEffect(() => {
document.documentElement.setAttribute('data-sidebar-collapsed', isSidebarCollapsed ? 'true' : 'false');
}, [isSidebarCollapsed]);
// Workaround for WebKitGTK 2.50.x text-input repaint hang on
// Linux Mint / Ubuntu 24.04 (issues #342, #782). When opted in,
// nudge WebKit awake on every input/textarea focus via a sync
@@ -241,6 +254,7 @@ export function AppShell() {
<div className="main-content-zoom">
<header className="content-header">
<LiveSearch />
{import.meta.env.DEV && <DevNetworkModeToggle />}
<div className="spacer" />
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
<LastfmIndicator />
@@ -259,7 +273,7 @@ export function AppShell() {
</header>
<OrbitSessionBar />
{connStatus === 'disconnected' && (
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineContent} serverName={serverName} />
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineBrowse} serverName={serverName} />
)}
<div className="content-body app-shell-route-host">
<OverlayScrollArea
@@ -305,6 +319,7 @@ export function AppShell() {
<SongInfoModal />
<DownloadFolderModal />
<GlobalConfirmModal />
<ThemeMigrationNotice />
<OrbitAccountPicker />
<OrbitHelpModal />
{!perfFlags.disableTooltipPortal && <TooltipPortal />}
+28
View File
@@ -13,6 +13,12 @@ import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { useGlobalShortcutsStore } from '../store/globalShortcutsStore';
import { initHotCachePrefetch } from '../hotCachePrefetch';
import { initLocalPlaybackInvalidation } from '../localPlaybackInvalidation';
import { initFavoritesOfflineSync } from '../utils/offline/favoritesOfflineSync';
import { initPinnedOfflineSync } from '../utils/offline/pinnedOfflineSync';
import { initResumeIncompleteOfflinePins, scheduleResumeIncompleteOfflinePins } from '../utils/offline/resumeIncompleteOfflinePins';
import { runLegacyOfflineFileMigration } from '../utils/migrations/legacyOfflineFileMigration';
import { reconcileLibraryTierForServer } from '../utils/offline/libraryTierReconcile';
import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge';
import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigration';
import { bootstrapAllIndexedServers } from '../utils/library/librarySession';
@@ -98,6 +104,28 @@ export default function MainApp() {
return initHotCachePrefetch();
}, [migrationReady]);
useEffect(() => {
if (!migrationReady) return undefined;
void (async () => {
await runLegacyOfflineFileMigration();
const servers = useAuthStore.getState().servers;
for (const server of servers) {
await reconcileLibraryTierForServer(server.id);
}
scheduleResumeIncompleteOfflinePins();
})();
const stopInvalidation = initLocalPlaybackInvalidation();
const stopFavoritesSync = initFavoritesOfflineSync();
const stopPinnedOfflineSync = initPinnedOfflineSync();
const stopOfflineResume = initResumeIncompleteOfflinePins();
return () => {
stopInvalidation();
stopFavoritesSync();
stopPinnedOfflineSync();
stopOfflineResume();
};
}, [migrationReady, serverIdsKey]);
useEffect(() => {
if (!migrationReady) return;
useGlobalShortcutsStore.getState().registerAll();
+59
View File
@@ -24,10 +24,14 @@ vi.mock('./windowKind', () => ({
import { invoke } from '@tauri-apps/api/core';
import { getWindowKind } from './windowKind';
import {
applyThemeAtStartup,
installCrossWindowThemeSync,
pushLoggingModeToBackend,
pushUserAgentToBackend,
runPreReactBootstrap,
} from './bootstrap';
import { useThemeStore } from '../store/themeStore';
import { useInstalledThemesStore } from '../store/installedThemesStore';
const ORIGINAL_USER_AGENT = window.navigator.userAgent;
@@ -48,6 +52,8 @@ beforeEach(() => {
afterEach(() => {
setUserAgent(ORIGINAL_USER_AGENT);
document.documentElement.removeAttribute('data-theme');
document.head.querySelectorAll('style[data-installed-theme]').forEach((el) => el.remove());
});
describe('pushUserAgentToBackend', () => {
@@ -143,3 +149,56 @@ describe('runPreReactBootstrap', () => {
// the bootstrap doesn't second-guess that decision.
});
});
describe('applyThemeAtStartup', () => {
const setPersistedTheme = (state: Record<string, unknown>) =>
localStorage.setItem('psysonic_theme', JSON.stringify({ state, version: 1 }));
it('does nothing when there is no persisted theme', () => {
applyThemeAtStartup();
expect(document.documentElement.getAttribute('data-theme')).toBeNull();
});
it('sets data-theme to the active theme (scheduler off)', () => {
setPersistedTheme({ theme: 'kanagawa-wave', enableThemeScheduler: false });
applyThemeAtStartup();
expect(document.documentElement.getAttribute('data-theme')).toBe('kanagawa-wave');
});
it('injects installed community themes up front', () => {
setPersistedTheme({ theme: 'dracula', enableThemeScheduler: false });
localStorage.setItem('psysonic_installed_themes', JSON.stringify({
state: { themes: [{ id: 'dracula', name: 'Dracula', author: 'a', version: '1.0.0', description: '', mode: 'dark', css: "[data-theme='dracula']{--accent:#bd93f9;}", installedAt: 0 }] },
version: 1,
}));
applyThemeAtStartup();
expect(document.head.querySelector('style[data-installed-theme="dracula"]')).not.toBeNull();
expect(document.documentElement.getAttribute('data-theme')).toBe('dracula');
});
it('does not throw on malformed storage', () => {
localStorage.setItem('psysonic_theme', '{not json');
expect(() => applyThemeAtStartup()).not.toThrow();
expect(document.documentElement.getAttribute('data-theme')).toBeNull();
});
});
describe('installCrossWindowThemeSync', () => {
it('rehydrates the matching store on a cross-window storage event', () => {
const themeRehydrate = vi.spyOn(useThemeStore.persist, 'rehydrate').mockResolvedValue(undefined);
const installedRehydrate = vi.spyOn(useInstalledThemesStore.persist, 'rehydrate').mockResolvedValue(undefined);
installCrossWindowThemeSync();
window.dispatchEvent(new StorageEvent('storage', { key: 'psysonic_theme' }));
expect(themeRehydrate).toHaveBeenCalled();
window.dispatchEvent(new StorageEvent('storage', { key: 'psysonic_installed_themes' }));
expect(installedRehydrate).toHaveBeenCalled();
themeRehydrate.mockClear();
installedRehydrate.mockClear();
window.dispatchEvent(new StorageEvent('storage', { key: 'unrelated-key' }));
expect(themeRehydrate).not.toHaveBeenCalled();
expect(installedRehydrate).not.toHaveBeenCalled();
});
});
+69
View File
@@ -1,6 +1,11 @@
import { installQueueUndoHotkey } from '../store/queueUndoHotkey';
import { configureStartupSplash } from './startupSplash';
import { invoke } from '@tauri-apps/api/core';
import { getWindowKind } from './windowKind';
import { migrateThemeSelection } from '../utils/themes/themeMigration';
import { getScheduledTheme, useThemeStore } from '../store/themeStore';
import { syncInjectedThemes } from '../utils/themes/themeInjection';
import { useInstalledThemesStore, type InstalledTheme } from '../store/installedThemesStore';
/** Sync backend HTTP User-Agent from the main webview once at startup. */
export function pushUserAgentToBackend(): void {
@@ -35,6 +40,63 @@ export function pushLoggingModeToBackend(): void {
}
}
function readInstalledThemes(): InstalledTheme[] {
try {
const raw = localStorage.getItem('psysonic_installed_themes');
if (!raw) return [];
const parsed = JSON.parse(raw) as { state?: { themes?: InstalledTheme[] } };
return Array.isArray(parsed.state?.themes) ? (parsed.state!.themes as InstalledTheme[]) : [];
} catch {
return [];
}
}
/**
* Apply the active theme synchronously, before React mounts, so the first paint
* is already correct. Zustand rehydrate + the `data-theme` effect run after the
* first paint, so without this a non-Mocha active theme (every light theme and
* every installed community theme) flashes the `:root` Mocha default for a
* frame. We set `data-theme` to the effective (scheduler-resolved) theme and
* inject installed community themes' CSS up front. Runs after the migration so
* the persisted ids are already resolved.
*/
export function applyThemeAtStartup(): void {
try {
const raw = localStorage.getItem('psysonic_theme');
if (!raw) return; // fresh profile — the :root default is correct
const parsed = JSON.parse(raw) as { state?: Record<string, unknown> };
const s = parsed.state;
if (!s) return;
syncInjectedThemes(readInstalledThemes());
const effective = getScheduledTheme({
enableThemeScheduler: !!s.enableThemeScheduler,
theme: String(s.theme ?? 'mocha'),
themeDay: String(s.themeDay ?? 'latte'),
themeNight: String(s.themeNight ?? 'mocha'),
timeDayStart: String(s.timeDayStart ?? '07:00'),
timeNightStart: String(s.timeNightStart ?? '19:00'),
});
if (effective) document.documentElement.setAttribute('data-theme', effective);
} catch {
// Non-fatal — App's effects apply the theme after mount.
}
}
/**
* Keep theme state in sync across webviews (main mini player). Zustand
* persist does not sync across windows on its own; the `storage` event fires in
* *other* windows when localStorage changes, so rehydrate the relevant store
* there. Installing/applying/uninstalling in one window then live-updates the
* other (App's effects re-run and re-apply `data-theme` / injected styles).
*/
export function installCrossWindowThemeSync(): void {
if (typeof window === 'undefined') return;
window.addEventListener('storage', (e) => {
if (e.key === 'psysonic_theme') void useThemeStore.persist?.rehydrate?.();
else if (e.key === 'psysonic_installed_themes') void useInstalledThemesStore.persist?.rehydrate?.();
});
}
/** Mark the document in Vite dev so CSS can show dev-only chrome. */
export function markDevBuildDocument(): void {
if (import.meta.env.DEV) {
@@ -46,6 +108,13 @@ export function markDevBuildDocument(): void {
export function runPreReactBootstrap(): void {
// Pre-warm the window-kind cache so subsequent reads are sync + safe.
getWindowKind();
// Reset any persisted theme that is no longer bundled and not installed, so
// the store hydrates onto a paintable theme (no unstyled-:root flash).
migrateThemeSelection();
// Paint the correct theme on the very first frame (no Mocha flash).
applyThemeAtStartup();
configureStartupSplash();
installCrossWindowThemeSync();
markDevBuildDocument();
pushUserAgentToBackend();
pushLoggingModeToBackend();
+53
View File
@@ -0,0 +1,53 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
STARTUP_SPLASH_ID,
configureStartupSplash,
dismissStartupSplash,
} from './startupSplash';
vi.mock('./windowKind', () => ({
getWindowKind: vi.fn(() => 'main'),
}));
vi.mock('../utils/themes/startupThemeAppearance', () => ({
applyStartupSplashThemeFromStorage: vi.fn(() => 'mocha'),
}));
vi.mock('@tauri-apps/api/webviewWindow', () => ({
getCurrentWebviewWindow: vi.fn(() => ({ show: vi.fn(() => Promise.resolve()) })),
}));
import { getWindowKind } from './windowKind';
import { applyStartupSplashThemeFromStorage } from '../utils/themes/startupThemeAppearance';
describe('startupSplash', () => {
beforeEach(() => {
document.body.innerHTML = `<div id="${STARTUP_SPLASH_ID}"></div><div id="root"></div>`;
vi.mocked(getWindowKind).mockReturnValue('main');
vi.mocked(applyStartupSplashThemeFromStorage).mockClear();
});
afterEach(() => {
document.body.innerHTML = '';
});
it('removes splash on mini player webview', () => {
vi.mocked(getWindowKind).mockReturnValue('mini');
configureStartupSplash();
expect(document.getElementById(STARTUP_SPLASH_ID)).toBeNull();
});
it('re-applies theme from storage on main window', () => {
configureStartupSplash();
expect(applyStartupSplashThemeFromStorage).toHaveBeenCalled();
});
it('fades out and removes splash', () => {
vi.useFakeTimers();
dismissStartupSplash();
expect(document.getElementById(STARTUP_SPLASH_ID)?.classList.contains('app-startup-splash--hide')).toBe(true);
vi.runAllTimers();
expect(document.getElementById(STARTUP_SPLASH_ID)).toBeNull();
vi.useRealTimers();
});
});
+43
View File
@@ -0,0 +1,43 @@
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { applyStartupSplashThemeFromStorage } from '../utils/themes/startupThemeAppearance';
import { getWindowKind } from './windowKind';
export const STARTUP_SPLASH_ID = 'app-startup-splash';
/** Ensure the native shell is visible once the webview bundle is alive. */
export function revealStartupWindow(): void {
if (getWindowKind() === 'mini') return;
void getCurrentWebviewWindow().show().catch(() => {});
}
/** Re-apply splash colors after bootstrap theme migration/injection. */
export function configureStartupSplash(): void {
const splash = document.getElementById(STARTUP_SPLASH_ID);
if (!splash) return;
if (getWindowKind() === 'mini') {
splash.remove();
return;
}
applyStartupSplashThemeFromStorage();
revealStartupWindow();
}
/** Fade out the splash after the first React commit. */
export function dismissStartupSplash(): void {
const splash = document.getElementById(STARTUP_SPLASH_ID);
if (!splash || splash.classList.contains('app-startup-splash--hide')) return;
splash.classList.add('app-startup-splash--hide');
const remove = () => splash.remove();
splash.addEventListener('transitionend', remove, { once: true });
window.setTimeout(remove, 500);
}
/** Schedule dismiss on the frame after the first paint. */
export function scheduleStartupSplashDismiss(): void {
requestAnimationFrame(() => {
requestAnimationFrame(dismissStartupSplash);
});
}
+28 -17
View File
@@ -1,14 +1,13 @@
import { getAlbum } from '../api/subsonicLibrary';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import React, { memo, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore';
import { useLocalPlaybackStore } from '../store/localPlaybackStore';
import { isOfflinePinComplete } from '../utils/offline/offlineLibraryHelpers';
import { CoverArtImage } from '../cover/CoverArtImage';
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
import { coverStorageKeyFromRef } from '../cover/storageKeys';
@@ -17,12 +16,14 @@ import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
import { resolveCoverDisplayTier } from '../cover/tiers';
import { acquireUrl } from '../utils/imageCache/urlPool';
import { OpenArtistRefInline } from './OpenArtistRefInline';
import { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
import { fetchAlbumTracks, playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
import { useLongPressAction } from '../hooks/useLongPressAction';
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
import { useDragDrop } from '../contexts/DragDropContext';
import { isAlbumRecentlyAdded } from '../utils/albumRecency';
import { deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
import { coverServerScopeForServerId } from '../cover/serverScope';
import { appendServerQuery } from '../utils/navigation/detailServerScope';
interface AlbumCardProps {
album: SubsonicAlbum;
@@ -63,21 +64,28 @@ function AlbumCard({
}: AlbumCardProps) {
const { t } = useTranslation();
const { isHolding, pressBind } = useLongPressAction({
onShortPress: () => playAlbum(album.id),
onLongPress: () => playAlbumShuffled(album.id),
onShortPress: () => playAlbum(album.id, album.serverId ? { serverId: album.serverId } : undefined),
onLongPress: () => playAlbumShuffled(album.id, album.serverId ? { serverId: album.serverId } : undefined),
});
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const enqueue = usePlayerStore(s => s.enqueue);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const isOffline = useOfflineStore(s => {
const meta = s.albums[`${serverId}:${album.id}`];
if (!meta || meta.trackIds.length === 0) return false;
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
});
const activeServerId = useAuthStore(s => s.activeServerId ?? '');
const offlineServerId = album.serverId ?? activeServerId;
const localEntries = useLocalPlaybackStore(s => s.entries);
const isOffline = isOfflinePinComplete(album.id, offlineServerId);
const albumLinkQuery = useMemo(
() => appendServerQuery(linkQuery, album.serverId),
[linkQuery, album.serverId],
);
void localEntries;
const psyDrag = useDragDrop();
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve });
const coverServerScope = useMemo(
() => coverServerScopeForServerId(album.serverId),
[album.serverId],
);
const coverRef = useAlbumCoverRef(album.id, album.coverArt, coverServerScope, { libraryResolve });
const dragCoverKey = useMemo(() => {
if (!coverRef) return '';
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' });
@@ -88,7 +96,7 @@ function AlbumCard({
const handleClick = (opts?: { shiftKey?: boolean }) => {
if (selectionMode) { onToggleSelect?.(album.id, opts); return; }
navigateToAlbum(album.id, { search: linkQuery });
navigateToAlbum(album.id, { search: albumLinkQuery });
};
return (
@@ -182,10 +190,13 @@ function AlbumCard({
onClick={async e => {
e.stopPropagation();
try {
const data = await getAlbum(album.id);
enqueue(data.songs.map(songToTrack));
const tracks = await fetchAlbumTracks(
album.id,
offlineServerId || undefined,
);
if (tracks.length > 0) enqueue(tracks);
} catch {
// Network failure — silent (toast would be too noisy for a hover action)
// Unavailable offline or network failure — silent on hover action
}
}}
aria-label={t('contextMenu.enqueueAlbum')}
+129 -94
View File
@@ -19,6 +19,7 @@ import { formatMb } from '../utils/format/formatBytes';
import { sanitizeHtml } from '../utils/sanitizeHtml';
import { OpenArtistRefInline } from './OpenArtistRefInline';
import { tooltipAttrs } from './tooltipAttrs';
import { offlineActionPolicy, type OfflineActionPolicy } from '../utils/offline/offlineActionPolicy';
/** True when the album artist label means "no single artist" `getArtistInfo`
* has nothing meaningful to return for these, so the Artist Bio entry is hidden.
@@ -74,7 +75,7 @@ interface AlbumHeaderProps {
resolvedCoverUrl: string | null;
isStarred: boolean;
downloadProgress: number | null;
offlineStatus: 'none' | 'downloading' | 'cached';
offlineStatus: 'none' | 'queued' | 'downloading' | 'cached';
offlineProgress: { done: number; total: number } | null;
bio: string | null;
bioOpen: boolean;
@@ -91,6 +92,8 @@ interface AlbumHeaderProps {
onEntityRatingChange: (rating: number) => void;
/** `unknown` = probe pending or not run; from `entityRatingSupportByServer`. */
entityRatingSupport: EntityRatingSupportLevel | 'unknown';
/** Offline browse action gates (favorites, download, cache, bio, ratings). */
actionPolicy?: OfflineActionPolicy;
}
export default function AlbumHeader({
@@ -117,7 +120,9 @@ export default function AlbumHeader({
entityRatingValue,
onEntityRatingChange,
entityRatingSupport,
actionPolicy,
}: AlbumHeaderProps) {
const policy = actionPolicy ?? offlineActionPolicy('albumDetail', false);
const { t } = useTranslation();
const navigate = useNavigate();
const goBack = useAlbumDetailBack();
@@ -222,7 +227,7 @@ export default function AlbumHeader({
<StarRating
value={entityRatingValue}
onChange={onEntityRatingChange}
disabled={entityRatingSupport === 'track_only'}
disabled={!policy.canRate || entityRatingSupport === 'track_only'}
labelKey="entityRating.albumAriaLabel"
/>
</div>
@@ -250,14 +255,16 @@ export default function AlbumHeader({
{/* Row 2 — Secondary actions */}
<div className="album-actions-row album-actions-row--secondary">
<button
className={`album-icon-btn album-icon-btn--sm${isStarred ? ' is-starred' : ''}`}
onClick={onToggleStar}
aria-label={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
</button>
{policy.canFavorite && (
<button
className={`album-icon-btn album-icon-btn--sm${isStarred ? ' is-starred' : ''}`}
onClick={onToggleStar}
aria-label={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
</button>
)}
<button
className="album-icon-btn album-icon-btn--sm"
@@ -269,7 +276,7 @@ export default function AlbumHeader({
<Share2 size={16} />
</button>
{showBioButton && (
{showBioButton && policy.canShowBio && (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onBio}
@@ -280,44 +287,57 @@ export default function AlbumHeader({
</button>
)}
{downloadProgress !== null ? (
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
<Download size={14} />
<span className="album-icon-btn-pct">{downloadProgress}%</span>
</div>
) : (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onDownload}
aria-label={t('albumDetail.download')}
data-tooltip={t('albumDetail.download')}
>
<Download size={16} />
</button>
{policy.canDownload && (
downloadProgress !== null ? (
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
<Download size={14} />
<span className="album-icon-btn-pct">{downloadProgress}%</span>
</div>
) : (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onDownload}
aria-label={t('albumDetail.download')}
data-tooltip={t('albumDetail.download')}
>
<Download size={16} />
</button>
)
)}
{offlineStatus === 'downloading' ? (
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
<Loader2 size={14} className="spin" />
</div>
) : offlineStatus === 'cached' ? (
<button
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
onClick={onRemoveOffline}
aria-label={t('albumDetail.offlineCached')}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
</button>
) : (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onCacheOffline}
aria-label={t('albumDetail.cacheOffline')}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
</button>
{policy.canPinOffline && (
offlineStatus === 'downloading' ? (
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
<Loader2 size={14} className="spin" />
</div>
) : offlineStatus === 'queued' ? (
<button
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
onClick={onCacheOffline}
aria-label={t('albumDetail.offlineQueued')}
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
>
<HardDriveDownload size={16} />
</button>
) : offlineStatus === 'cached' ? (
<button
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
onClick={onRemoveOffline}
aria-label={t('albumDetail.offlineCached')}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
</button>
) : (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onCacheOffline}
aria-label={t('albumDetail.cacheOffline')}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
</button>
)
)}
</div>
</div>
@@ -348,13 +368,15 @@ export default function AlbumHeader({
>
<ListPlus size={16} />
</button>
<button
className={`btn btn-surface${isStarred ? ' is-starred' : ''}`}
onClick={onToggleStar}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
</button>
{policy.canFavorite && (
<button
className={`btn btn-surface${isStarred ? ' is-starred' : ''}`}
onClick={onToggleStar}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
</button>
)}
<button
type="button"
className="btn btn-surface"
@@ -366,7 +388,7 @@ export default function AlbumHeader({
</button>
</div>
{showBioButton && (
{showBioButton && policy.canShowBio && (
<button
className="btn btn-surface"
id="album-bio-btn"
@@ -377,47 +399,60 @@ export default function AlbumHeader({
</button>
)}
{downloadProgress !== null ? (
<div className="download-progress-wrap">
<Download size={14} />
<div className="download-progress-bar">
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
{policy.canDownload && (
downloadProgress !== null ? (
<div className="download-progress-wrap">
<Download size={14} />
<div className="download-progress-bar">
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
</div>
<span className="download-progress-pct">{downloadProgress}%</span>
</div>
<span className="download-progress-pct">{downloadProgress}%</span>
</div>
) : (
<button
className="btn btn-surface"
id="album-download-btn"
onClick={onDownload}
{...tooltipAttrs(t('albumDetail.downloadTooltip'))}
>
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}
</button>
) : (
<button
className="btn btn-surface"
id="album-download-btn"
onClick={onDownload}
{...tooltipAttrs(t('albumDetail.downloadTooltip'))}
>
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}
</button>
)
)}
{offlineStatus === 'downloading' && offlineProgress ? (
<div className="offline-cache-btn offline-cache-btn--progress">
<Loader2 size={14} className="spin" />
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
</div>
) : offlineStatus === 'cached' ? (
<button
className="btn btn-surface offline-cache-btn offline-cache-btn--cached"
onClick={onRemoveOffline}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.offlineCached')}
</button>
) : (
<button
className="btn btn-surface offline-cache-btn"
onClick={onCacheOffline}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.cacheOffline')}
</button>
{policy.canPinOffline && (
offlineStatus === 'downloading' && offlineProgress ? (
<div className="offline-cache-btn offline-cache-btn--progress">
<Loader2 size={14} className="spin" />
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
</div>
) : offlineStatus === 'queued' ? (
<button
className="btn btn-surface offline-cache-btn offline-cache-btn--queued"
onClick={onCacheOffline}
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
>
<HardDriveDownload size={16} />
{t('albumDetail.offlineQueued')}
</button>
) : offlineStatus === 'cached' ? (
<button
className="btn btn-surface offline-cache-btn offline-cache-btn--cached"
onClick={onRemoveOffline}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.offlineCached')}
</button>
) : (
<button
className="btn btn-surface offline-cache-btn"
onClick={onCacheOffline}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.cacheOffline')}
</button>
)
)}
</div>
)}
+1 -1
View File
@@ -251,7 +251,7 @@ export default function AlbumRow({
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{uniqueAlbums.map((a, idx) => (
<AlbumCard
key={a.id}
key={a.serverId ? `${a.serverId}:${a.id}` : a.id}
album={a}
showRating={showRating}
linkQuery={albumLinkQuery}
+12 -3
View File
@@ -1,6 +1,6 @@
import type { SubsonicSong } from '../api/subsonicTypes';
import type { Track } from '../store/playerStoreTypes';
import React, { useState, useEffect } from 'react';
import React, { useMemo, useState, useEffect } from 'react';
import { useTracklistColumns } from '../utils/useTracklistColumns';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
@@ -15,6 +15,7 @@ import { TrackRow } from './albumTrackList/TrackRow';
import { AlbumTrackListMobile } from './albumTrackList/AlbumTrackListMobile';
import { TracklistColumnPicker } from './albumTrackList/TracklistColumnPicker';
import { TracklistHeaderRow } from './albumTrackList/TracklistHeaderRow';
import { offlineActionPolicy, type OfflineActionPolicy } from '../utils/offline/offlineActionPolicy';
export type { SortKey } from '../utils/componentHelpers/albumTrackListHelpers';
@@ -38,6 +39,7 @@ interface AlbumTrackListProps {
sortKey?: SortKey;
sortDir?: 'asc' | 'desc';
onSort?: (key: SortKey) => void;
actionPolicy?: OfflineActionPolicy;
}
// ── AlbumTrackList ────────────────────────────────────────────────────────────
@@ -60,7 +62,9 @@ export default function AlbumTrackList({
sortKey,
sortDir,
onSort,
actionPolicy,
}: AlbumTrackListProps) {
const policy = actionPolicy ?? offlineActionPolicy('trackRow', false);
const { t } = useTranslation();
const isMobile = useIsMobile();
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
@@ -98,6 +102,10 @@ export default function AlbumTrackList({
);
const currentTrackId = currentTrack?.id ?? null;
const displayCols = useMemo(
() => (policy.canFavorite ? visibleCols : visibleCols.filter(c => c.key !== 'favorite')),
[policy.canFavorite, visibleCols],
);
if (isMobile) {
return (
@@ -139,7 +147,7 @@ export default function AlbumTrackList({
>
<TracklistHeaderRow
visibleCols={visibleCols}
visibleCols={displayCols}
gridStyle={gridStyle}
sortKey={sortKey}
sortDir={sortDir}
@@ -170,7 +178,7 @@ export default function AlbumTrackList({
key={song.id}
song={song}
globalIdx={globalIdx}
visibleCols={visibleCols}
visibleCols={displayCols}
gridStyle={gridStyle}
currentTrackId={currentTrackId}
isPlaying={isPlaying}
@@ -186,6 +194,7 @@ export default function AlbumTrackList({
onToggleSelect={onToggleSelect}
onDragStart={onDragStart}
setContextMenuSongId={setContextMenuSongId}
actionPolicy={policy}
/>
);
})}
+10 -3
View File
@@ -1,11 +1,13 @@
import type { SubsonicArtist } from '../api/subsonicTypes';
import React from 'react';
import React, { useMemo } from 'react';
import { Users } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { CoverArtImage } from '../cover/CoverArtImage';
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
import { coverServerScopeForServerId } from '../cover/serverScope';
import { appendServerQuery } from '../utils/navigation/detailServerScope';
interface Props {
artist: SubsonicArtist;
@@ -18,12 +20,17 @@ interface Props {
export default function ArtistCardLocal({ artist, linkQuery, libraryResolve = false }: Props) {
const { t } = useTranslation();
const navigateToArtist = useNavigateToArtist();
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, undefined, { libraryResolve });
const coverServerScope = useMemo(
() => coverServerScopeForServerId(artist.serverId),
[artist.serverId],
);
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, coverServerScope, { libraryResolve });
const artistLinkQuery = appendServerQuery(linkQuery, artist.serverId);
return (
<div
className="artist-card"
onClick={() => navigateToArtist(artist.id, linkQuery ? { search: linkQuery } : undefined)}
onClick={() => navigateToArtist(artist.id, artistLinkQuery ? { search: artistLinkQuery } : undefined)}
>
<div className="artist-card-avatar">
{coverRef ? (
+1 -1
View File
@@ -117,7 +117,7 @@ export default function ArtistRow({
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{artists.map(a => (
<ArtistCardLocal
key={a.id}
key={a.serverId ? `${a.serverId}:${a.id}` : a.id}
artist={a}
linkQuery={artistLinkQuery}
libraryResolve={libraryResolve}
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { ArrowUp } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
interface BackToTopButtonProps {
/** Id of the scroll viewport to watch/scroll. Defaults to the main route scroller. */
viewportId?: string;
/** Show the button once the viewport is scrolled past this many pixels. */
threshold?: number;
}
/**
* A floating "back to top" affordance for long pages. Watches a scroll viewport
* (the overlay-scroll element wrapping the routes by default) and, once it is
* scrolled past `threshold`, shows a button that smooth-scrolls it back to the
* top reusing the same `getElementById(...).scrollTo` pattern AppShell uses
* for its route-change scroll reset.
*
* The button is portalled into `.app-shell-route-host` and positioned
* `absolute` against it: the scroll viewport itself sets `contain: paint`, which
* would otherwise make a `position: fixed` child resolve against the *scrolling*
* box (so it would drift with the content). The route host is a non-contained,
* `position: relative` ancestor that spans exactly the content area (between the
* sidebar and queue, above the player bar), so the button stays pinned to the
* visible viewport corner regardless of scroll.
*/
export default function BackToTopButton({
viewportId = APP_MAIN_SCROLL_VIEWPORT_ID,
threshold = 400,
}: BackToTopButtonProps) {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [host, setHost] = useState<HTMLElement | null>(null);
useEffect(() => {
setHost(document.querySelector<HTMLElement>('.app-shell-route-host'));
const el = document.getElementById(viewportId);
if (!el) return;
const onScroll = () => setVisible(el.scrollTop > threshold);
onScroll(); // sync immediately (e.g. switching back to an already-scrolled tab)
el.addEventListener('scroll', onScroll, { passive: true });
return () => el.removeEventListener('scroll', onScroll);
}, [viewportId, threshold]);
if (!visible || !host) return null;
return createPortal(
<button
type="button"
className="back-to-top-btn"
onClick={() =>
document.getElementById(viewportId)?.scrollTo({ top: 0, behavior: 'smooth' })
}
aria-label={t('common.backToTop')}
data-tooltip={t('common.backToTop')}
data-tooltip-pos="left"
>
<ArrowUp size={18} aria-hidden="true" />
</button>,
host,
);
}
+13 -3
View File
@@ -1,5 +1,6 @@
import { getArtist, getArtistInfo } from '../api/subsonicArtists';
import { filterAlbumsToActiveLibrary, getAlbum } from '../api/subsonicLibrary';
import { filterAlbumsToActiveLibrary } from '../api/subsonicLibrary';
import { resolveAlbum, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import { shuffleArray } from '../utils/playback/shuffleArray';
@@ -518,7 +519,13 @@ export default function BecauseYouLikeRail({
})();
return () => { cancelled = true; };
}, [pool, activeServerId, musicLibraryFilterVersion, disableArtwork, poolKey]);
// Gate on poolKey (the stable top-anchor identity), not the `pool` array ref.
// `pool` is rebuilt whenever Home's mostPlayed changes, so loading more Most
// Played albums (which feeds this pool) would otherwise re-run this effect and
// swap the cards — a height blip above the row that scroll anchoring turns into
// an upward viewport jump. The sibling reserve effect already keys on poolKey.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [poolKey, activeServerId, musicLibraryFilterVersion, disableArtwork]);
useLibraryCoverPrefetch(
disableArtwork || recs.length === 0 ? [] : [{ albums: recs, priority: 'high' }],
@@ -596,7 +603,10 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e
const handleEnqueue = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
const data = await getAlbum(album.id);
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
const data = await resolveAlbum(serverId, album.id);
if (!data) return;
enqueue(data.songs.map(songToTrack));
} catch {
/* silent — toast would be too noisy for a hover action */
+1 -1
View File
@@ -247,7 +247,7 @@ export default function CachedImage({
};
const fallbackStyle: React.CSSProperties = isFallback
? { objectFit: 'contain', background: 'var(--bg-card, var(--ctp-surface0, #313244))', padding: '15%' }
? { objectFit: 'contain', background: 'var(--bg-card, var(--bg-card, #313244))', padding: '15%' }
: {};
return (
+17
View File
@@ -43,6 +43,23 @@ vi.mock('@/utils/orbitBulkGuard', () => ({
orbitBulkGuard: vi.fn(async () => true),
}));
vi.mock('@/hooks/useOfflineBrowseContext', () => ({
useOfflineBrowseContext: () => ({
active: false,
serverId: 'srv-1',
capabilities: {
localLibrary: false,
favorites: false,
playlists: false,
manualPins: false,
playerStats: false,
},
hasBrowseCapability: false,
hasBrowsingContent: false,
connStatus: 'connected' as const,
}),
}));
import ContextMenu from './ContextMenu';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { usePlayerStore } from '@/store/playerStore';
+28
View File
@@ -17,8 +17,29 @@ import { useContextMenuKeyboardNav } from '../hooks/useContextMenuKeyboardNav';
import { useContextMenuRating } from '../hooks/useContextMenuRating';
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
import { useNavigate } from 'react-router-dom';
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
import {
offlineActionPolicy,
type OfflineSurface,
} from '../utils/offline/offlineActionPolicy';
import ContextMenuItems from './contextMenu/ContextMenuItems';
function contextMenuSurfaceForType(type: string | null): OfflineSurface {
switch (type) {
case 'album':
case 'multi-album':
return 'contextMenuAlbum';
case 'artist':
case 'multi-artist':
return 'contextMenuArtist';
case 'playlist':
case 'multi-playlist':
return 'contextMenuPlaylist';
default:
return 'contextMenuSong';
}
}
export { AddToPlaylistSubmenu };
@@ -180,6 +201,12 @@ export default function ContextMenu() {
const downloadAlbum = downloadAlbumAction;
const { active: offlineBrowseActive } = useOfflineBrowseContext();
const offlinePolicy = offlineActionPolicy(
contextMenuSurfaceForType(type),
offlineBrowseActive,
);
if (!contextMenu.isOpen || !contextMenu.item) return null;
return (
@@ -233,6 +260,7 @@ export default function ContextMenu() {
isStarred={isStarred}
pinToPlaybackServer={pinToPlaybackServer}
navigateLibrary={navigateLibrary}
offlinePolicy={offlinePolicy}
/>
</div>
</>
+23
View File
@@ -0,0 +1,23 @@
import { Cloud, CloudOff } from 'lucide-react';
import { useDevOfflineBrowseStore } from '../store/devOfflineBrowseStore';
/** DEV-only: simulate full offline (disconnect UI, block Subsonic, local playback only). */
export default function DevNetworkModeToggle() {
const forceOffline = useDevOfflineBrowseStore(s => s.forceOffline);
const toggle = useDevOfflineBrowseStore(s => s.toggleForceOffline);
if (!import.meta.env.DEV) return null;
return (
<button
type="button"
className={`dev-network-mode-toggle${forceOffline ? ' dev-network-mode-toggle--offline' : ''}`}
onClick={toggle}
title={forceOffline ? 'DEV: forced offline (click for online)' : 'DEV: online (click to simulate offline)'}
aria-pressed={forceOffline}
>
{forceOffline ? <CloudOff size={16} aria-hidden /> : <Cloud size={16} aria-hidden />}
<span>{forceOffline ? 'Offline' : 'Online'}</span>
</button>
);
}
+1 -1
View File
@@ -59,7 +59,7 @@ function LiveOverlayPinnedMetric({
);
}
/** FPS + pipeline + pinned live metrics overlay (Performance Probe). */
/** FPS + pipeline + pinned live metrics overlay (PsyLab). */
export default function FpsOverlay() {
const overlayMode = usePerfOverlayMode();
const perfFlags = usePerfProbeFlags();
+42 -15
View File
@@ -1,4 +1,5 @@
import { getRandomAlbums, getAlbum } from '../api/subsonicLibrary';
import { getRandomAlbums } from '../api/subsonicLibrary';
import { resolveAlbum, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
@@ -33,14 +34,25 @@ function HeroBg({ url }: { url: string }) {
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
url ? [{ url, id: 0, visible: true }] : []
);
const counter = useRef(1);
const counter = useRef(url ? 1 : 0);
const latestUrlRef = useRef(url);
latestUrlRef.current = url;
useEffect(() => {
if (!url) return;
if (!url) {
setLayers([]);
return;
}
const id = counter.current++;
setLayers(prev => [...prev, { url, id, visible: false }]);
const t1 = setTimeout(() => setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id }))), 20);
const t2 = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 900);
const t1 = setTimeout(() => {
if (latestUrlRef.current !== url) return;
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
}, 20);
const t2 = setTimeout(() => {
if (latestUrlRef.current !== url) return;
setLayers(prev => prev.filter(l => l.id === id));
}, 900);
return () => { clearTimeout(t1); clearTimeout(t2); };
}, [url]);
@@ -259,7 +271,13 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const [albumFormats, setAlbumFormats] = useState<Record<string, string>>({});
useEffect(() => {
if (!album || albumFormats[album.id] !== undefined) return;
getAlbum(album.id).then(data => {
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
resolveAlbum(serverId, album.id).then(data => {
if (!data) {
setAlbumFormats(prev => ({ ...prev, [album.id]: '' }));
return;
}
const fmts = [...new Set(data.songs.map(s => s.suffix).filter((f): f is string => !!f))];
setAlbumFormats(prev => ({ ...prev, [album.id]: fmts.map(f => f.toUpperCase()).join(' / ') }));
}).catch(() => {
@@ -273,17 +291,20 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
ensurePriority: 'high',
});
// Keep the last known good URL so HeroBg never receives '' during a cache-miss
// transition (which would cause the background to flash empty before fading in).
const stableBgUrl = useRef('');
// Per-album fallback so a cache miss on the current slide does not flash empty,
// but never reuse another album's art (that caused bg/foreground desync on fast nav).
const stableBgByAlbum = useRef<Record<string, string>>({});
const albumId = album?.id;
useEffect(() => {
if (bgHandle.src && albumId) {
stableBgByAlbum.current[albumId] = bgHandle.src;
}
}, [bgHandle.src, albumId]);
const heroBgUrl = bgHandle.src || (albumId ? stableBgByAlbum.current[albumId] ?? '' : '');
const { isHolding, pressBind } = useLongPressAction({
onShortPress: () => { if (albumId) playAlbum(albumId); },
onLongPress: () => { if (albumId) playAlbumShuffled(albumId); },
});
useEffect(() => {
if (bgHandle.src) stableBgUrl.current = bgHandle.src;
}, [bgHandle.src, albumId]);
if (!album) return <div className="hero-placeholder" />;
@@ -296,7 +317,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
onClick={() => navigateToAlbum(album.id)}
style={{ cursor: 'pointer' }}
>
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={stableBgUrl.current} />}
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={heroBgUrl} />}
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <div className="hero-overlay" aria-hidden="true" />}
{/* key causes re-mount → animate-fade-in triggers on each album change */}
@@ -339,7 +360,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
onClick={async e => {
e.stopPropagation();
try {
const albumData = await getAlbum(album.id);
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
const albumData = await resolveAlbum(serverId, album.id);
if (!albumData) return;
usePlayerStore.getState().enqueue(albumData.songs.map(songToTrack));
} catch (_) {}
}}
@@ -369,7 +393,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
onClick={async (e) => {
e.stopPropagation();
try {
const albumData = await getAlbum(album.id);
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
const albumData = await resolveAlbum(serverId, album.id);
if (!albumData) return;
const tracks = albumData.songs.map(songToTrack);
usePlayerStore.getState().enqueue(tracks);
} catch (_) {}
+1 -1
View File
@@ -10,7 +10,7 @@ interface Props {
export default function LosslessFilterButton({ active, onChange }: Props) {
const { t } = useTranslation();
const tooltip = active ? t('albums.losslessTooltipOn') : t('albums.losslessTooltipOff');
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {};
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {};
return (
<button
+16 -4
View File
@@ -4,10 +4,11 @@ import { useTranslation } from 'react-i18next';
import { Settings, HardDriveDownload } from 'lucide-react';
import { useSidebarStore } from '../store/sidebarStore';
import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore';
import { ALL_NAV_ITEMS } from '../config/navItems';
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
import { isOfflineSidebarNavAllowed } from '../utils/offline/offlineNavPolicy';
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']);
@@ -15,8 +16,10 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
const { t } = useTranslation();
const sidebarItems = useSidebarStore(s => s.items);
const randomNavMode = useAuthStore(s => s.randomNavMode);
const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
const offlineCtx = useOfflineBrowseContext();
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
const isServerOffline = offlineCtx.active;
const hasOfflineContent = offlineCtx.capabilities.manualPins;
const luckyMixBase = useLuckyMixAvailable();
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
@@ -29,6 +32,15 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false;
if (isServerOffline && !isOfflineSidebarNavAllowed(
cfg.id,
offlineNav.favoritesOfflineBrowse,
offlineNav.localLibraryBrowse,
offlineNav.playerStatsBrowse,
offlineNav.playlistsOfflineBrowse,
)) {
return false;
}
return true;
})
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
+1 -1
View File
@@ -238,7 +238,7 @@ export default function MobilePlayerView() {
const toggleStar = useCallback(() => {
if (!currentTrack) return;
queueSongStar(currentTrack.id, !isStarred);
queueSongStar(currentTrack.id, !isStarred, currentTrack.serverId);
}, [currentTrack, isStarred]);
// Scrubber touch/mouse drag
+2 -2
View File
@@ -108,7 +108,7 @@ export default function NowPlayingDropdown() {
{visible.length > 0 && (
<span style={{
background: 'var(--accent)',
color: 'var(--ctp-crust)',
color: 'var(--text-on-accent)',
fontSize: '10px',
fontWeight: 'bold',
padding: '2px 6px',
@@ -161,7 +161,7 @@ export default function NowPlayingDropdown() {
onClick={() => { if (stream.albumId) { setIsOpen(false); navigate(`/album/${stream.albumId}`); } }}
style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', background: 'var(--bg-hover)', padding: '0.5rem', borderRadius: '8px', cursor: stream.albumId ? 'pointer' : 'default' }}
>
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--bg-surface)' }}>
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--card-placeholder-bg)' }}>
{stream.albumId && stream.coverArt ? (
<TrackCoverArtImage
song={{
+135
View File
@@ -0,0 +1,135 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { HardDriveDownload, Heart } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { formatBytes } from '../utils/format/formatBytes';
const OPEN_DELAY_MS = 450;
interface Props {
label: string;
totalBytes: number | null;
libraryBytes: number | null;
favoritesBytes: number | null;
}
export function OfflineLibraryDiskStat({
label,
totalBytes,
libraryBytes,
favoritesBytes,
}: Props) {
const { t } = useTranslation();
const anchorRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [style, setStyle] = useState<React.CSSProperties>({ opacity: 0 });
const openTimerRef = useRef<number | null>(null);
const breakdownReady = libraryBytes !== null && favoritesBytes !== null;
const clearOpenTimer = () => {
if (openTimerRef.current !== null) {
clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
};
const scheduleOpen = () => {
if (!breakdownReady) return;
clearOpenTimer();
openTimerRef.current = window.setTimeout(() => setOpen(true), OPEN_DELAY_MS);
};
const close = () => {
clearOpenTimer();
setOpen(false);
};
const breakdownAriaLabel = breakdownReady
? [
t('connection.offlineLibraryDiskTierLibrary', { size: formatBytes(libraryBytes) }),
t('connection.offlineLibraryDiskTierFavorites', { size: formatBytes(favoritesBytes) }),
].join('. ')
: undefined;
useLayoutEffect(() => {
if (!open || !anchorRef.current || !popoverRef.current) {
setStyle({ opacity: 0 });
return;
}
const anchor = anchorRef.current.getBoundingClientRect();
const box = popoverRef.current.getBoundingClientRect();
const GAP = 8;
const MARGIN = 8;
let top = anchor.bottom + GAP;
let left = anchor.left + anchor.width / 2 - box.width / 2;
left = Math.max(MARGIN, Math.min(left, window.innerWidth - box.width - MARGIN));
top = Math.max(MARGIN, Math.min(top, window.innerHeight - box.height - MARGIN));
setStyle({ opacity: 1, top, left });
}, [open, libraryBytes, favoritesBytes]);
useEffect(() => () => clearOpenTimer(), []);
return (
<>
<div
ref={anchorRef}
className={`offline-library-header-stat${breakdownReady ? ' offline-library-header-stat--interactive' : ''}`}
aria-live="polite"
aria-label={breakdownAriaLabel}
onMouseEnter={scheduleOpen}
onMouseLeave={close}
onFocus={scheduleOpen}
onBlur={close}
tabIndex={breakdownReady ? 0 : undefined}
>
<span className="offline-library-disk-label">{label}</span>
<span className="offline-library-disk-value">
{totalBytes !== null ? formatBytes(totalBytes) : '…'}
</span>
</div>
{open && breakdownReady && createPortal(
<div
ref={popoverRef}
className="offline-library-disk-breakdown"
style={{ position: 'fixed', pointerEvents: 'auto', ...style }}
role="tooltip"
onMouseEnter={() => {
clearOpenTimer();
setOpen(true);
}}
onMouseLeave={close}
>
<div className="offline-library-disk-breakdown-row">
<span
className="offline-library-disk-breakdown-icon offline-library-disk-breakdown-icon--library"
aria-hidden
>
<HardDriveDownload size={15} strokeWidth={2} />
</span>
<span className="offline-library-disk-breakdown-size">
{formatBytes(libraryBytes)}
</span>
</div>
<div className="offline-library-disk-breakdown-divider" aria-hidden />
<div className="offline-library-disk-breakdown-row">
<span
className="offline-library-disk-breakdown-icon offline-library-disk-breakdown-icon--favorites"
aria-hidden
>
<Heart size={15} strokeWidth={2} />
</span>
<span className="offline-library-disk-breakdown-size">
{formatBytes(favoritesBytes)}
</span>
</div>
</div>,
document.body,
)}
</>
);
}
+1 -1
View File
@@ -10,7 +10,7 @@ export default function PSmallLogo({ className, style }: Props) {
<svg viewBox="0 0 115.549 130.30972" xmlns="http://www.w3.org/2000/svg" style={style} className={className}><defs id="defs1">
<linearGradient id="psmallGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="var(--accent)" />
<stop offset="100%" stopColor="var(--ctp-blue)" />
<stop offset="100%" stopColor="var(--accent-2)" />
</linearGradient>
</defs><g
id="layer1"
+7
View File
@@ -10,6 +10,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/api/subsonic', () => ({
savePlayQueue: vi.fn(async () => undefined),
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
pingWithCredentials: vi.fn(async () => ({
ok: true,
type: 'navidrome',
serverVersion: '0.55.0',
openSubsonic: true,
})),
scheduleInstantMixProbeForServer: vi.fn(),
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
+6 -5
View File
@@ -15,7 +15,6 @@ import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
import WaveformSeek from './WaveformSeek';
import Equalizer from './Equalizer';
import StarRating from './StarRating';
import { useTranslation } from 'react-i18next';
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
import { useLyricsStore } from '../store/lyricsStore';
@@ -29,6 +28,8 @@ import PlaybackScheduleBadge from './PlaybackScheduleBadge';
import { usePlaybackScheduleRemaining } from '../utils/format/playbackScheduleFormat';
import { usePreviewStore } from '../store/previewStore';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { coerceOpenArtistRefs } from '../utils/openArtistRefs';
import { resolveTrackArtistRefs } from '../utils/playback/trackArtistRefs';
import { formatTrackTime } from '../utils/format/formatDuration';
import { PlayerTrackInfo } from './playerBar/PlayerTrackInfo';
import { PlayerTransportControls } from './playerBar/PlayerTransportControls';
@@ -136,7 +137,7 @@ export default function PlayerBar() {
const toggleStar = useCallback(() => {
if (!currentTrack) return;
queueSongStar(currentTrack.id, !isStarred);
queueSongStar(currentTrack.id, !isStarred, currentTrack.serverId);
}, [currentTrack, isStarred]);
const duration = currentTrack?.duration ?? 0;
@@ -147,8 +148,8 @@ export default function PlayerBar() {
const showPreviewMeta = isPreviewing && !isRadio && previewingTrack !== null;
const displayTitle = showPreviewMeta ? previewingTrack!.title : (currentTrack?.title ?? t('player.noTitle'));
const displayArtist = showPreviewMeta ? previewingTrack!.artist : (currentTrack?.artist ?? '—');
const displayArtistRefs = !showPreviewMeta && currentTrack?.artists && currentTrack.artists.length > 0
? currentTrack.artists
const displayArtistRefs = !showPreviewMeta && currentTrack && coerceOpenArtistRefs(currentTrack.artists).length > 0
? resolveTrackArtistRefs(currentTrack)
: undefined;
const coverArtId = showPreviewMeta
@@ -180,7 +181,7 @@ export default function PlayerBar() {
}, [volume, setVolume, utilityOverflow]);
const volumeStyle = {
background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--bg-elevated) ${volume * 100}%)`,
};
const playerBarContent = (
+1 -1
View File
@@ -19,7 +19,7 @@ export default function PsysonicLogo({ className, style, gradientIdSuffix }: Pro
<svg viewBox="0 0 594.45007 134.93138" xmlns="http://www.w3.org/2000/svg" style={style} className={className}><defs id="defs1">
<linearGradient id={gradId} x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="var(--logo-color-start, var(--accent))" />
<stop offset="100%" stopColor="var(--logo-color-end, var(--ctp-blue))" />
<stop offset="100%" stopColor="var(--logo-color-end, var(--accent-2))" />
</linearGradient>
</defs><g
id="layer1"
+13 -7
View File
@@ -1,5 +1,6 @@
import { Play } from 'lucide-react';
import { getPlaylist, updatePlaylist } from '../api/subsonicPlaylists';
import { updatePlaylist } from '../api/subsonicPlaylists';
import { resolvePlaylist, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
import { songToTrack } from '../utils/playback/songToTrack';
import type { Track } from '../store/playerStoreTypes';
import { useState, useRef, useMemo } from 'react';
@@ -33,6 +34,7 @@ import { QueueToolbar } from './queuePanel/QueueToolbar';
import { QueueList } from './queuePanel/QueueList';
import { QueueTabBar } from './queuePanel/QueueTabBar';
import { useQueueAutoScroll } from '../hooks/useQueueAutoScroll';
import { activeServerQueueTrackIds } from '../utils/playback/trackServerScope';
export default function QueuePanel() {
const orbitRole = useOrbitStore(s => s.role);
@@ -170,11 +172,12 @@ function QueuePanelHostOrSolo() {
const [loadModalOpen, setLoadModalOpen] = useState(false);
const handleSave = async () => {
if (queueItems.length === 0) return;
const exportTrackIds = activeServerQueueTrackIds(queueItems);
if (exportTrackIds.length === 0) return;
if (activePlaylist) {
setSaveState('saving');
try {
await updatePlaylist(activePlaylist.id, queueItems.map(r => r.trackId));
await updatePlaylist(activePlaylist.id, exportTrackIds);
setSaveState('saved');
setTimeout(() => setSaveState('idle'), 1500);
} catch (e) {
@@ -196,7 +199,8 @@ function QueuePanelHostOrSolo() {
};
const handleCopyQueueShare = async () => {
if (queueItems.length === 0) {
const ids = activeServerQueueTrackIds(queueItems);
if (ids.length === 0) {
showToast(t('queue.shareQueueEmpty'), 3000, 'info');
return;
}
@@ -207,7 +211,6 @@ function QueuePanelHostOrSolo() {
if (!active) return;
const srv = serverShareBaseUrl(active);
if (!srv) return;
const ids = queueItems.map(r => r.trackId);
const ok = await copyTextToClipboard(encodeSharePayload({ srv, k: 'queue', ids }));
if (ok) showToast(t('contextMenu.shareCopied'));
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
@@ -381,7 +384,7 @@ function QueuePanelHostOrSolo() {
onSave={async (name) => {
try {
const createPlaylist = usePlaylistStore.getState().createPlaylist;
const pl = await createPlaylist(name, queueItems.map(r => r.trackId));
const pl = await createPlaylist(name, activeServerQueueTrackIds(queueItems));
if (pl) setActivePlaylist({ id: pl.id, name: pl.name });
setSaveModalOpen(false);
} catch (e) {
@@ -396,7 +399,10 @@ function QueuePanelHostOrSolo() {
onClose={() => setLoadModalOpen(false)}
onLoad={async (id, name, mode) => {
try {
const data = await getPlaylist(id);
const serverId = resolveMediaServerId();
if (!serverId) return;
const data = await resolvePlaylist(serverId, id);
if (!data) return;
const tracks: Track[] = data.songs.map(songToTrack);
if (tracks.length > 0) {
if (mode === 'append') {
+49 -9
View File
@@ -1,8 +1,8 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
import { useOfflineJobStore } from '../store/offlineJobStore';
import { clearOfflinePinTasks } from '../utils/offline/offlinePinQueue';
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
import { useAuthStore } from '../store/authStore';
import { useSidebarStore } from '../store/sidebarStore';
@@ -23,7 +23,9 @@ import { useSidebarNewReleasesUnread } from '../hooks/useSidebarNewReleasesUnrea
import { useSidebarNavDnd } from '../hooks/useSidebarNavDnd';
import { useSidebarLibraryDropdown } from '../hooks/useSidebarLibraryDropdown';
import { useSidebarScrollVisible } from '../hooks/useSidebarScrollVisible';
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
import { isOfflineSidebarNavAllowed } from '../utils/offline/offlineNavPolicy';
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
import { useSidebarPerfProbe } from '../hooks/useSidebarPerfProbe';
import SidebarPerfProbeModal from './sidebar/SidebarPerfProbeModal';
import SidebarNavBody from './sidebar/SidebarNavBody';
@@ -41,15 +43,24 @@ export default function Sidebar({
const isPlaying = usePlayerStore(s => s.isPlaying);
const currentTrack = usePlayerStore(s => s.currentTrack);
const offlineJobs = useOfflineJobStore(s => s.jobs);
const cancelAllDownloads = useOfflineJobStore(s => s.cancelAllDownloads);
const pinQueue = useOfflineJobStore(s => s.pinQueue);
const cancelAllDownloadsStore = useOfflineJobStore(s => s.cancelAllDownloads);
const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading');
const activePin = pinQueue.find(p => p.status === 'downloading')
?? pinQueue.find(p => p.status === 'queued');
const queuedPinCount = pinQueue.filter(p => p.status === 'queued').length;
const cancelAllDownloads = () => {
clearOfflinePinTasks();
cancelAllDownloadsStore();
};
const syncJobStatus = useDeviceSyncJobStore(s => s.status);
const syncJobDone = useDeviceSyncJobStore(s => s.done);
const syncJobSkip = useDeviceSyncJobStore(s => s.skipped);
const syncJobFail = useDeviceSyncJobStore(s => s.failed);
const syncJobTotal = useDeviceSyncJobStore(s => s.total);
const isSyncing = syncJobStatus === 'running';
const offlineAlbums = useOfflineStore(s => s.albums);
const offlineCtx = useOfflineBrowseContext();
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
const musicFolders = useAuthStore(s => s.musicFolders);
@@ -61,7 +72,8 @@ export default function Sidebar({
const setNormalizationEngine = useAuthStore(s => s.setNormalizationEngine);
const loggingMode = useAuthStore(s => s.loggingMode);
const setLoggingMode = useAuthStore(s => s.setLoggingMode);
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
const hasOfflineContent = offlineCtx.capabilities.manualPins;
const isServerOffline = offlineCtx.active;
const sidebarItems = useSidebarStore(s => s.items);
const setSidebarItems = useSidebarStore(s => s.setItems);
const randomNavMode = useAuthStore(s => s.randomNavMode);
@@ -82,7 +94,7 @@ export default function Sidebar({
}, [playlistsRaw]);
const [sidebarViewportEl, setSidebarViewportEl] = useState<HTMLDivElement | null>(null);
const isSidebarScrolling = useSidebarScrollVisible(sidebarViewportEl);
const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1;
const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1 && !isServerOffline;
const filterId = serverId ? (musicLibraryFilterByServer[serverId] ?? 'all') : 'all';
const selectedFolderName =
@@ -101,13 +113,34 @@ export default function Sidebar({
libraryItemsForReorder.filter(c => {
if (!c.visible) return false;
if (c.id === 'luckyMix' && !luckyMixAvailable) return false;
if (isServerOffline && !isOfflineSidebarNavAllowed(
c.id,
offlineNav.favoritesOfflineBrowse,
offlineNav.localLibraryBrowse,
offlineNav.playerStatsBrowse,
offlineNav.playlistsOfflineBrowse,
)) {
return false;
}
return true;
}),
[libraryItemsForReorder, luckyMixAvailable],
[libraryItemsForReorder, luckyMixAvailable, isServerOffline, offlineNav],
);
const visibleSystemConfigs = useMemo(
() => systemItemsForReorder.filter(c => c.visible),
[systemItemsForReorder],
() => systemItemsForReorder.filter(c => {
if (!c.visible) return false;
if (isServerOffline && !isOfflineSidebarNavAllowed(
c.id,
offlineNav.favoritesOfflineBrowse,
offlineNav.localLibraryBrowse,
offlineNav.playerStatsBrowse,
offlineNav.playlistsOfflineBrowse,
)) {
return false;
}
return true;
}),
[systemItemsForReorder, isServerOffline, offlineNav],
);
const sidebarItemsRef = useRef(sidebarItems);
@@ -140,10 +173,15 @@ export default function Sidebar({
const pickLibrary = (id: 'all' | string) => {
if (isServerOffline) return;
setMusicLibraryFilter(id);
setLibraryDropdownOpen(false);
};
useEffect(() => {
if (isServerOffline) setLibraryDropdownOpen(false);
}, [isServerOffline, setLibraryDropdownOpen]);
// Fetch playlists when expanded
useEffect(() => {
if (!playlistsExpanded || !isLoggedIn) return;
@@ -231,6 +269,8 @@ export default function Sidebar({
nowPlayingAtTop={nowPlayingAtTop}
hasOfflineContent={hasOfflineContent}
activeJobsCount={activeJobs.length}
activePinName={activePin?.albumName ?? null}
queuedPinCount={queuedPinCount}
cancelAllDownloads={cancelAllDownloads}
isSyncing={isSyncing}
syncJobDone={syncJobDone}
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../test/helpers/renderWithProviders';
import SongCard from './SongCard';
import type { SubsonicSong } from '../api/subsonicTypes';
const navigateToArtist = vi.fn();
vi.mock('../hooks/useNavigateToArtist', () => ({
useNavigateToArtist: () => navigateToArtist,
}));
vi.mock('../cover/useLibraryCoverRef', () => ({
useTrackCoverRef: () => undefined,
}));
function song(overrides: Partial<SubsonicSong>): SubsonicSong {
return {
id: 's1', title: 'Track', artist: 'A', album: 'Alb', albumId: 'al1', duration: 100,
...overrides,
} as SubsonicSong;
}
describe('SongCard', () => {
it('splits OpenSubsonic artists into individual links', async () => {
navigateToArtist.mockClear();
const user = userEvent.setup();
renderWithProviders(
<SongCard
disableArtwork
song={song({
artist: 'Apocalyptica', artistId: 'a1',
artists: [{ id: 'a1', name: 'Apocalyptica' }, { id: 'a2', name: 'Joe Duplantier' }],
})}
/>,
);
expect(screen.getByText('Apocalyptica')).toHaveClass('track-artist-link');
expect(screen.getByText('Joe Duplantier')).toHaveClass('track-artist-link');
await user.click(screen.getByText('Joe Duplantier'));
expect(navigateToArtist).toHaveBeenCalledWith('a2');
});
});
+16 -15
View File
@@ -1,7 +1,6 @@
import type { SubsonicSong } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import React, { memo } from 'react';
import { useNavigate } from 'react-router-dom';
import React, { memo, useMemo } from 'react';
import { Play, ListPlus, Star, Disc3 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '../store/playerStore';
@@ -13,6 +12,9 @@ import { enqueueAndPlay } from '../utils/playback/playSong';
import { useDragDrop } from '../contexts/DragDropContext';
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
import { OpenArtistRefInline } from './OpenArtistRefInline';
import { resolveTrackArtistRefs } from '../utils/playback/trackArtistRefs';
interface SongCardProps {
song: SubsonicSong;
@@ -31,7 +33,7 @@ function SongCard({
}: SongCardProps) {
const layoutPx = artworkSize ?? displayCssPx;
const { t } = useTranslation();
const navigate = useNavigate();
const navigateToArtist = useNavigateToArtist();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const enqueue = usePlayerStore(s => s.enqueue);
const coverRef = useTrackCoverRef(song, undefined, { libraryResolve: false });
@@ -55,12 +57,7 @@ function SongCard({
};
const handleClick = handlePlay;
const handleArtistClick = (e: React.MouseEvent) => {
if (!song.artistId) return;
e.stopPropagation();
navigate(`/artist/${song.artistId}`);
};
const artistRefs = useMemo(() => resolveTrackArtistRefs(song), [song]);
const handleAlbumClick = (e: React.MouseEvent) => {
if (!song.albumId) return;
@@ -142,12 +139,16 @@ function SongCard({
</div>
<div className="song-card-info">
<p className="song-card-title truncate" title={song.title}>{song.title}</p>
<p
className={`song-card-artist truncate${song.artistId ? ' track-artist-link' : ''}`}
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
onClick={handleArtistClick}
title={song.artist}
>{song.artist}</p>
<p className="song-card-artist truncate" title={song.artist}>
<OpenArtistRefInline
refs={artistRefs}
fallbackName={song.artist}
onGoArtist={id => navigateToArtist(id)}
as="none"
linkTag="span"
linkClassName="track-artist-link"
/>
</p>
{song.albumId && (
<button
type="button"
+2 -6
View File
@@ -10,6 +10,7 @@ import { enqueueAndPlay } from '../utils/playback/playSong';
import { useDragDrop } from '../contexts/DragDropContext';
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
import { formatTrackTime } from '../utils/format/formatDuration';
import { resolveTrackArtistRefs } from '../utils/playback/trackArtistRefs';
import { tooltipAttrs } from './tooltipAttrs';
interface Props {
@@ -39,12 +40,7 @@ function SongRow({ song, showBpm }: Props) {
enqueue([songToTrack(song)]);
};
// Split multi-artist tracks into individually clickable links (OpenSubsonic
// `artists[]`), falling back to the single flat artist when absent — mirrors
// the album tracklist so a "A · B" credit isn't one link to a single artist.
const artistRefs = song.artists && song.artists.length > 0
? song.artists
: [{ id: song.artistId, name: song.artist }];
const artistRefs = resolveTrackArtistRefs(song);
const bpmTooltip =
song.localBpmSource === 'analysis'
+1 -1
View File
@@ -14,7 +14,7 @@ interface Props {
export default function StarFilterButton({ active, onChange, size = 'default' }: Props) {
const { t } = useTranslation();
const tooltip = active ? t('common.favoritesTooltipOn') : t('common.favoritesTooltipOff');
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {};
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {};
if (size === 'compact') {
return (

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