Compare commits

..

29 Commits

Author SHA1 Message Date
github-actions[bot] de1924d5e3 chore(nix): refresh lock + npmDepsHash for v1.48.0 2026-06-14 20:19:27 +00:00
github-actions[bot] 85e91f5375 chore(release): finalize release version 1.48.0 2026-06-14 20:01:50 +00:00
github-actions[bot] 20cbdd123d chore(nix): refresh lock + npmDepsHash for v1.48.0-rc.4 2026-06-14 19:49:07 +00:00
github-actions[bot] 098788b56f chore(release): bump next channel to 1.48.0-rc.4 2026-06-14 19:32:01 +00:00
Psychotoxical 07232cea9a fix(fullscreen-player): honour the Lyrics scroll style setting (#1089)
The rebuilt fullscreen player hardcoded Apple-style lyrics scrolling (active
line ~35% from the top) and ignored Settings -> Lyrics -> Lyrics scroll style,
which still drives the sidebar/mobile LyricsPane. FsLyricsApple now reads
sidebarLyricsStyle: 'classic' centres the active line, 'apple' keeps the 35%
anchor, matching the sidebar lyrics.
2026-06-14 13:37:25 +02:00
cucadmuh 6f555bdc96 docs: sync WHATS_NEW with 1.48 changelog, fix Fixed PR order (#1087)
Add highlights for Live status dots, playback Semitones, Linux title bar
styles, Music Network self-hosted scrobble fix, and Navidrome radio admin
gating. Remove meta What's New page section. Re-sort CHANGELOG Fixed
entries (#1085/#1086 were at the top).
2026-06-14 03:16:31 +03:00
Psychotoxical c1403f8bd6 feat: now-playing liveness dot + admin-gated radio management (#1086)
* feat(now-playing): liveness indicator dot in the listening popover

Replace the raw "Nm ago" line in the "Who is listening?" popover with a
derived presence dot (green playing / amber paused / dim idle). The presence
is computed in one tested helper that unifies the playbackReport transport
state with the legacy getNowPlaying recency, instead of formatting a raw
timestamp inline. The dot carries the localized status as an aria-label and
tooltip so it is not conveyed by colour alone.

* feat(radio): gate station create/edit/delete behind Navidrome admin role

Navidrome >= 0.62 restricts internet-radio management to admins
(GHSA-jw24-qqrj-633c); non-admin requests fail. Hide Add Station, Search
Directory, the per-card edit chip and delete button for confirmed standard
Navidrome users via a canManageNavidromeRadio() helper on the existing
useNavidromeAdminRole framework. Admins, non-Navidrome servers and transient
states stay unrestricted; playback and favourites remain available to all.

* docs(changelog): now-playing status dot + admin-gated radio (#1086)
2026-06-14 01:30:25 +02:00
cucadmuh 0b7d9eae2d feat(playback): Semitones strategy, 2-decimal speed label, advanced fine steps (#1084)
* feat(playback): varispeed-by-semitones strategy and 2-decimal speed label

Adds a fourth playback-rate strategy "Varispeed (semitones)" — a frontend
lens over varispeed where the user dials the pitch change directly in
semitones (±12 st, 0.1 step) and speed = 2^(st/12). Engine contract is
unchanged: the store maps it to the existing "varispeed" Rust strategy via
engineStrategy(), and switching between the two varispeed lenses at the same
speed no longer restarts the track.

Also widens the speed readout to two decimals (formatSpeedLabel → toFixed(2))
so every 0.05 slider step is visible (1.05×, 1.10×, 1.15×), addressing the
feedback on issue #531 that the label looked stuck between steps.

Includes new i18n keys and updated hint across all 9 locales, plus Vitest
coverage for the new helper, label formatting, and the no-restart lens switch.

* refactor(playback): shorten Semitones strategy label, add per-strategy tooltips

Renames the fourth strategy to a concise "Semitones" (was "Varispeed
(semitones)") across all 9 locales, and lets the four strategy buttons share
the row width so they fit on one line in Settings while still wrapping in the
narrow player popover. Each strategy button now has a short hover tooltip
explaining how it is built (incl. the 2^(st/12) and 12 × log2(speed) maths).

* feat(playback): advanced fine-step precision for speed/pitch sliders

Adds an opt-in "Fine adjustment" toggle in Settings → Audio (visible only in
Advanced mode) that shrinks the playback-rate slider steps to 0.01× for speed
and 0.01 st for pitch/semitones, with the pitch readout widening to two
decimals to match. Default behaviour (0.05× / 0.1 st) is unchanged. The
preference is UI-only (persisted, not sent to the engine) and also applies to
the player-bar popover and wheel. Addresses the finer-precision request on
issue #531 without cluttering the default UX.

* docs: changelog and credits for playback speed follow-up (PR #1084)
2026-06-14 01:32:20 +03:00
Psychotoxical 41c8187186 fix(music-network): keep API suffix for self-hosted paste-token providers (#1085)
* fix(music-network): keep API suffix for self-hosted paste-token providers

The api_key_only connect strategy persisted the raw origin from the baseUrl
field instead of the resolved API base, dropping the preset's
selfHostedApiSuffix (e.g. /apis/listenbrainz). Scrobbles and now-playing then
hit <origin>/1/submit-listens (404/405, silently unlogged) instead of
<origin>/apis/listenbrainz/1/submit-listens, so nothing was recorded.

Return the runtime-resolved ctx.baseUrl (origin + suffix) and fall back to the
field only when it is absent. Fixes Koito and both Maloja compat surfaces
(ListenBrainz and Audioscrobbler). Existing accounts must reconnect to
re-persist the corrected base.

* docs(changelog): self-hosted scrobble URL fix (#1085)
2026-06-14 00:27:30 +02:00
Psychotoxical 028eb65f7d feat(titlebar): selectable window button styles + minimize toggle (#1083)
* feat(titlebar): selectable window button styles + minimize toggle

Custom title bar (Linux) gains a window-button style picker, mirroring
the seekbar style picker pattern. Six form-named styles: dots, dotsGlyph,
flat, pill, outline, glyph. All buttons now carry minimize/maximize/close
glyphs for clear, colour-blind-friendly iconography; dots reveal glyphs on
hover, dotsGlyph always shows them.

- New authStore state windowButtonStyle (default dots) + showMinimizeButton,
  with rehydrate validation falling back to dots on unknown values.
- WindowButtonPreview reuses the real .titlebar-btn classes for WYSIWYG tiles.
- Picker + minimize toggle render under the Custom title bar setting, gated
  on the toggle being on.
- Monochrome styles use --text-primary glyphs and stronger borders for
  contrast on dark themes.
- Dev-build grey marker scoped to the real title bar so previews show true
  colours.
- i18n keys in all 9 locales; setter and rehydrate tests.

* docs(changelog): window button styles (#1083)
2026-06-13 23:52:56 +02:00
cucadmuh be3f1dc299 docs(whats-new): refresh 1.48.0 release highlights (#1082)
* docs(whats-new): refresh 1.48.0 highlights for release screen

Reorder and expand user-facing copy to match CHANGELOG Added order,
add Music Network and Live, drop stale About/licenses line, replace
the vague Fixed placeholder with grouped fixes, and add a short Under
the hood section for significant non-UI work.

* docs(whats-new): order 1.48 sections by user impact

Sort Highlights, Fixed groups, and bullets within each block so the
most noticeable changes for everyday use appear first.

* docs: CHANGELOG 1.48 strict PR order; clarify WHATS_NEW sorting

Reorder Added/Changed/Fixed entries in CHANGELOG [1.48.0] by ascending PR
number. WHATS_NEW keeps impact-based order; add a maintainer note that
the two files intentionally differ.
2026-06-13 06:03:21 +03:00
cucadmuh 52fbb33b00 chore(deps): bump esbuild to 0.28.1 (Dependabot #17) (#1081)
* chore(deps): bump esbuild to 0.28.1 for GHSA-g7r4-m6w7-qqqr

Closes Dependabot alert #17 — path traversal in esbuild dev server
(--servedir) on Windows; patched in 0.28.1.

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

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-13 05:51:19 +03:00
Kris Bennett 947711a98d fix(installer): stderr logging, /dev/tty reinstall prompt, hardened download (#1079)
* fix(installer): write logs to stderr so the download URL isn't polluted

* fix(installer): read the reinstall prompt from /dev/tty

* fix(installer): add --fail --globoff to the package download

* fix(installer): degrade gracefully when no controlling terminal is available
2026-06-13 02:36:20 +00:00
cucadmuh 891ab0dd5b feat(now-playing): OpenSubsonic playbackReport for live now-playing (#1080)
* feat(now-playing): adopt OpenSubsonic playbackReport for live now-playing

Drive a small playback state machine (starting → playing ↔ paused → stopped)
on the Subsonic-server channel when the server advertises the OpenSubsonic
`playbackReport` extension (Navidrome ≥ 0.62), giving `getNowPlaying` a real
transport state and an extrapolated position. Reports send `ignoreScrobble=true`
so play counts stay on the existing `scrobble.view` 50% path (no double count),
and the effective playback speed is included so the server extrapolates position
correctly with the speed feature on.

- New `playbackReportSession` FSM mirrors the existing `playListenSession`
  lifecycle hooks and is wired at the same player call sites (start / gapless
  switch / queue restore / resume / 15s heartbeat / pause / seek / stop / ended /
  error / app quit). Servers without the extension degrade to the unchanged
  legacy `scrobble.view?submission=false` presence call.
- Gated through the existing serverCapabilities framework: a new
  `FEATURE_PLAYBACK_REPORT` (auto, extension-detected). The OpenSubsonic
  extensions probe now stores the full advertised list once and serves both
  AudioMuse `sonicSimilarity` and `playbackReport` from it, without disturbing
  the legacy Instant Mix opt-in on pre-0.62 servers.
- Now Playing dropdown shows a live position bar and a paused indicator.
- reportPlayback uses the real request params (mediaId / mediaType / positionMs).

Tests: FSM transitions + gating + legacy fallback, capability resolution,
extension-list probe storage/decoupling. Full suite green; no Tauri-boundary
changes.

* feat(now-playing): glide the Live position bar between polls

Extrapolate the position of `playing` entries locally (elapsed × reported
playbackRate from the last 10 s poll) and re-render once a second, so the Live
progress bar advances smoothly instead of jumping on each refresh. Paused and
position-less entries stay frozen. A linear width transition matched to the tick
keeps the fill gliding. Only applies to clients that report a position via the
playbackReport extension.

* fix(now-playing): tighten Live timer layout and report resume immediately

Keep the progress-bar width stable without a wide empty gap before the
timer: reserve ~2ch inside the current-time span only (right-aligned), not
on the whole clock block. Report `playing` to the server as soon as
resume() runs, matching the immediate `paused` report on pause instead of
waiting for the Rust `audio:playing` event.

* docs: CHANGELOG and credits for playbackReport live now-playing (PR #1080)
2026-06-13 05:31:26 +03:00
cucadmuh abc2c0b579 refactor(audio): split source-build pipeline into source_build module (#1074)
Move the source-building pipeline out of play_input.rs into a focused
source_build.rs: BuildSourceArgs/PlaybackSource, the ranged-stream probe
fallback (build_playback_source_with_probe_fallback) and its private helpers
(build_source_from_play_input, wait_or_fetch_bytes_for_stream_fallback, etc.).
play_input.rs now only handles source *selection* and drops from 799 to 420
lines; helpers that became module-internal lose their pub(super) visibility.
No behavior change.
2026-06-12 19:41:17 +03:00
cucadmuh 184e87a469 fix(audio): release idle output stream after 60s (#1071) (#1073)
* fix(audio): release idle output stream after 60s (#1071)

Lazy-open CPAL on first playback and close the device handle after one
minute without active audio so Windows can sleep; emit output-released
for cold resume and skip post-wake reopen when idle.

* docs: CHANGELOG and credits for idle audio stream fix (PR #1073)

* fix(audio): satisfy clippy if-same-then-else in idle watcher

* fix(audio): silence rodio DeviceSink drop unless logging is debug

Gate log_on_drop(false) on runtime should_log_debug() so normal/off
logging modes avoid stderr noise from intentional idle stream release.

* feat(audio): cold-start paused restore and silent engine prepare

After getPlayQueue on startup, apply saved seek position to the UI,
prefetch the current track to hot cache, and load the engine paused via
new audio_play startPaused so playback does not audibly start before
pause. Shared engineLoadTrackAtPosition with queue-undo restore.

* fix(audio): satisfy clippy too_many_arguments on stream arm helper

Bundle spawn_legacy_stream_start_when_armed parameters into
LegacyStreamStartWhenArmed so workspace clippy passes.

* fix(audio): release output stream immediately on stop (#1071)

Stop and natural queue end call audio_stop; close the CPAL device right
away instead of waiting for the 60s idle timer. Pause keeps the grace
period for warm resume.

* fix(audio): keep waveform mounted after stop (#1071)

Stop preserves currentTrack, so its cached analysis waveform stays valid.
Stop no longer nulls waveformBins for the still-shown track and re-hydrates
them from the analysis DB, instead of dropping to flat placeholder bars.

* test(audio): cover output_stream_is_needed branches; harden audio_play arg (#1071)

- Add unit tests for the idle-keepalive decision: empty/playing/paused main
  sink, preview and fading-out sinks, and radio playing/paused. Players are
  built device-less via rodio's Player::new + a Zero source, so empty()/state
  are exercised without an audio device.
- Make audio_play's `start_paused` an Option<bool> defaulting to false, so the
  new field is strictly additive (omitting startPaused no longer fails serde).
- Drop the unused `_engine` parameter from start_stream_idle_watcher; it
  resolves the engine from the AppHandle each poll.

* refactor(audio): extract sink-swap lifecycle into sink_swap module (#1071)

Move SinkSwapInputs/swap_in_new_sink and the legacy stream-arm helper
(LegacyStreamStartWhenArmed/spawn_legacy_stream_start_when_armed) out of
play_input.rs into a focused sink_swap.rs, so source selection and source
building stay separate from sink lifecycle. play_input.rs drops from 953 to
799 lines. No behavior change.
2026-06-12 17:13:51 +03:00
cucadmuh 80822fd742 fix(themes): apply Theme Store themes in production release builds (#1070)
Release CSP blocked runtime <style> injection for community themes; add
explicit style-src/style-src-elem and font-src. Derive the effective theme
synchronously in useThemeScheduler so data-theme updates on the same commit.
2026-06-12 03:13:18 +03:00
Psychotoxical 4902c0e25b Fix MPRIS player duplication during internet radio (#1048) (#1069)
* fix(mpris): disable WebKit media session so radio doesn't duplicate the player

Internet radio plays through an HTML <audio> element, for which WebKitGTK
auto-registers its own MPRIS player (org.webkit.*) alongside the app's
souvlaki one. On Linux desktops that list every player, now-playing then
showed twice during radio (issue #1048: one "psysonic", one "Psysonic").

Disable the WebKit media session at main-window setup (enable-media-session,
set by GObject property name since the pinned binding has no typed setter,
guarded by find_property) so souvlaki stays the single now-playing source;
radio metadata still reaches it via mpris_set_metadata. The navigator.media
Session push in useRadioMprisSync is kept as a fallback in case a WebKitGTK
version still registers the player, so issue #816 cannot regress.

* docs(changelog): MPRIS radio duplication fix (PR #1069)
2026-06-11 23:58:01 +02:00
Psychotoxical 3de7b57cc5 Fullscreen player polish + Discord Rich Presence fixes (#1068)
* fix(fullscreen-player): drop track-number prefix from title, stop clipping descenders

The big title showed a zero-padded queue position ("11. ") before the
song name; remove it (the position still lives in the top bar). Also bump
line-height 1.05 -> 1.25 so overflow:hidden no longer crops descenders
(g, j, p, q, y) at 46px.

* feat(settings): clarify built-in Discord RP vs official Navidrome plugin

Add a bold notice atop the Discord Rich Presence block explaining it is
the built-in integration, and that users wanting the official Navidrome
Discord RP plugin should leave it off and enable "Show in Now Playing"
instead. Add a matching bold note to the Now Playing setting. New i18n
keys discordRichPresenceNotice / nowPlayingPluginNote across all 5 locales.

* fix(discord-rp): use HTTPS cover URL instead of local file:// path

Discord Rich Presence images are fetched by Discord's servers, so
large_image must be a key or an https:// URL they can reach. Since the
cover pipeline moved to an on-disk webp cache, coverArtUrlForDiscord
returned a file://.../800.webp path on cache hit, which Discord cannot
load and silently falls back to the app icon. Always hand it the
getCoverArt URL; MPRIS keeps the local file:// path unchanged.

* i18n(settings): fill discordRichPresenceNotice + nowPlayingPluginNote for es, nb, ro, ru

The two new Discord-RP notice keys had only landed in 5 of the 9 locales;
complete the set so no locale falls back to English.

* docs(changelog): fullscreen title + Discord RP fixes (PR #1068)
2026-06-11 23:34:58 +02:00
Psychotoxical 1a82376f8c feat(music-network): unified scrobble & enrichment framework (replaces hard-wired Last.fm) (#1066)
* feat(music-network): core domain types and wire contracts

Foundation for the Music Network framework: provider-agnostic domain
types, capability model, typed errors, and account shapes under
src/music-network/core, plus the ScrobbleWire / EnrichmentWire /
PresetManifest / AuthStrategy contracts. No runtime wiring yet.

* feat(music-network): generic audioscrobbler/listenbrainz/maloja transports

Generalize the Rust remote layer for the Music Network framework. Add
provider-agnostic transports parameterized by base_url:

- audioscrobbler_request: Audioscrobbler v2 with caller-supplied endpoint
  (Last.fm, Libre.fm, Rocksky, custom GNU FM, Maloja compat share it)
- listenbrainz_request: Token-header JSON (direct + Maloja LB compat)
- maloja_request: native /apis/mlj_1 JSON

lastfm_request stays as a thin transition delegate against the fixed
host; it is removed once the framework owns all call sites. Wiremock
tests cover audioscrobbler_request with a custom base_url and API-error
mapping.

* feat(music-network): audioscrobbler wire with last.fm + libre.fm presets

Add the Audioscrobbler v2 wire, the behavioural successor to the legacy
src/api/lastfm.ts, implementing the full EnrichmentWire surface (scrobble,
now playing, love/unlove, loved sync, similar artists, track/artist stats,
top lists, recent tracks, user profile, urls).

- client.ts: transport wrapper over audioscrobbler_request, classifying
  failures into MusicNetworkError without touching any store
- sign.ts: TS mirror of the api_sig base-string ordering rule (unit-tested)
- auth/tokenPoll.ts: browser token-poll connect flow as a reusable strategy
- presets/lastfm.ts, presets/librefm.ts: bundled, enrichment-capable,
  token-poll presets (both endpoints verified live)

Extends WireContext with profileBase and ConnectContext with authBase so
URL builders and connect flows need no preset lookup.

* feat(music-network): listenbrainz + maloja-native wires, paste-auth presets

Add the scrobble-destination wires and presets:

- ListenBrainz wire (scrobble + now playing via playing_now), backing both
  the direct api.listenbrainz.org preset and the Maloja /apis/listenbrainz
  compat preset (one wire, two presets, differing only by base URL)
- Maloja native wire (/apis/mlj_1/newscrobble, scrobble only — Maloja has
  no now-playing endpoint)
- Shared api_key_only paste-auth strategy (token/key/session-key paste);
  the Audioscrobbler wire now dispatches token-poll vs paste by preset
- Presets: listenbrainz, maloja_listenbrainz, maloja_native, rocksky
  (scrobble-only, session-key paste, bundled keys — verified live), and
  custom_gnufm (token-poll, user-supplied url/key/secret)
- Contracts: ConnectContext.authStrategy, PresetManifest.selfHostedApiSuffix

maloja_compat (the {url}/apis/audioscrobbler mode) is intentionally omitted:
its protocol cannot be verified and is almost certainly the legacy handshake,
not the 2.0 web API; Maloja is covered by the native and ListenBrainz modes.

* feat(music-network): registry, orchestrator, enrichment router + runtime facade

Wire the framework together behind a single facade:

- registry: wireRegistry (WireId -> wire), presetRegistry (the 7 built-in
  presets), registerBuiltinWires (one-time side-effect registration)
- CapabilityProbe: wire probe overlaid by manifest staticCapabilities as the
  final authority (lets two presets on one wire diverge, e.g. Rocksky's
  nowPlaying:false over the Audioscrobbler wire's optimistic yes)
- ScrobbleOrchestrator: best-effort fan-out; flips the per-account
  session-error flag on AUTH_SESSION_INVALID and clears it on next success
- EnrichmentRouter: resolves the single primary to its EnrichmentWire; the
  type guard rejects non-enrichment wires (Maloja/ListenBrainz)
- MusicNetworkRuntime: the only app entry point — accounts, roles, fan-out,
  enrichment, urls, probe. Reads/writes state through the MusicNetworkStore
  port (Phase 5 backs it with the auth store) and a RuntimeHost for side effects
- getMusicNetworkRuntime singleton + index.ts public barrel

Tests cover fan-out, master toggle, capability gating, session-error
flip/clear, primary eligibility, and enrichment routing.

* feat(music-network): auth-store state + lossless legacy migration + runtime bridge

Add the persisted Music Network state to the auth store and wire the runtime,
all additively — nothing existing breaks yet.

- authStoreTypes: musicNetworkAccounts / enrichmentPrimaryId /
  scrobblingMasterEnabled + actions; legacy lastfm* fields kept until Phase 6
- authMusicNetworkActions + defaults/wiring (synchronous localStorage)
- accountPersistence: migrateLegacyLastfm (lossless — preserves session key,
  username and scrobbling preference; fills bundled Last.fm key from the preset;
  sets the migrated account as enrichment primary) + sanitizeAccounts
- authStoreRehydrate: one-shot migration guarded by a sentinel so a later
  disconnect cannot resurrect the account from still-present legacy fields
- musicNetworkBridge: backs the MusicNetworkStore port with the auth store and
  the RuntimeHost with the Tauri shell; initialized in pre-React bootstrap

nowPlayingEnabled stays a global toggle (not a lastfm* field); the Phase 6
playback call-site will gate dispatchNowPlaying on it, preserving behaviour.

* feat(music-network): route playback, enrichment and love through the runtime

Migrate every Last.fm call-site onto the Music Network runtime, preserving
behaviour:

- playback (audioEventHandlers, playTrackAction): scrobble@50% and now-playing
  via dispatchScrobble/dispatchNowPlaying; loved-fetch via isTrackLoved. Now-
  playing follows scrobbling (as Last.fm did), Navidrome now-playing keeps the
  nowPlayingEnabled gate
- enrichment: useArtistSimilarArtists, useNowPlayingFetchers, Statistics, and
  the ArtistDetail similar-artists gate now use the runtime, gated on an
  enrichment primary
- love: PlayerBar, PlayerTrackInfo, all context menus, useNowPlayingStarLove and
  the startup loved-sync route through setTrackLoved / toggleNetworkLove
- player store: lastfmLoved/lastfmLovedCache -> networkLoved/networkLovedCache;
  lastfmActions -> networkLoveActions; loved cache storage renamed with a
  lossless legacy-key fallback
- getMusicNetworkRuntimeOrNull() for best-effort callers so they no-op (not
  throw) before the runtime is initialized

src/api/lastfm.ts and the Integrations UI still use the legacy path; they are
migrated and removed in the next phase.

* feat(music-network): manifest-driven Integrations UI + scrobble batch format

Replace the Last.fm Integrations card with a manifest-driven Music Network
section, and fix Audioscrobbler scrobbling to the batch/array shape.

- settings/musicNetwork/: MusicNetworkSection (master toggle, destination
  cards, enrichment-primary picker, Maloja proxy warning, add-a-service list)
  driven entirely off the preset registry; icon map from PresetManifest.icon
- IntegrationsTab delegates to MusicNetworkSection (Discord/Bandsintown/
  Navidrome now-playing unchanged)
- i18n: musicNetwork.* across all 9 locales, incl. a per-field help hint for
  Rocksky's CLI session-key flow (rocksky login)
- scrobble now uses the documented array form (artist[0]/track[0]/…); the bare
  single form is only tolerated by Last.fm, Rocksky requires the indexed form
- auth-error detection keys off the response message (not the ambiguous numeric
  code) so a Rocksky server-500 no longer flips the account to a reconnect state
- PresetManifest.PresetField gains an optional helpKey

Rocksky's server rejects some non-ASCII track metadata with a 500 — a Rocksky
backend bug; the client call is correct (verified).

* fix(music-network): clearer Integrations layout + scrobble batch fix

Address UI feedback on the Music Network section:
- per-account scrobble toggle moves inside its account block (was a loose
  row between cards — unclear which account it belonged to)
- master toggle and the primary-service picker are now boxed blocks at the top,
  not bare rows
- primary-service copy reworked: 'Primary service' + a line spelling out that
  liked tracks/similar artists/stats come from it while scrobbling still goes
  to all enabled services
- distinct zones separated by dividers (master/primary · connected · add)

Also folds in the verified scrobble fixes: documented array form
(artist[0]/track[0]/…) so Rocksky accepts scrobbles, and auth-error detection
by message rather than the ambiguous numeric code (a Rocksky server-500 no
longer flips the account to a reconnect state). Rocksky session-key field gains
a CLI help hint (rocksky login), all 9 locales.

* feat(music-network): indicator + remove legacy lastfm path

Phase 7b/7c — finish the cutover and delete the old Last.fm path.

- LastfmIndicator -> MusicNetworkIndicator (shows the enrichment primary's
  status, click -> Integrations)
- delete src/api/lastfm.ts; remove Rust lastfm_request (remote.rs + lib.rs)
- remove legacy authStore lastfm* fields, actions and types; delete
  authLastfmActions.ts; rehydrate migration reads the legacy blob via a cast
- migrate the remaining NowPlaying call-sites (NowPlaying.tsx + the now-playing
  fetchers/prewarm/star-love hooks) off lastfmSessionKey/lastfmUsername onto the
  enrichment primary (gate + cache key)
- type imports LastfmTrackInfo/LastfmArtistStats -> music-network TrackStats/
  ArtistStats; drop 8 stale api/lastfm test mocks and the obsolete Last.fm auth
  tests; update settingsTabs + src/CLAUDE.md

No lastfm imports remain outside src/music-network/; lastfm_request removed
(acceptance §12). tsc clean, 1947 frontend tests + remote rust tests green.

* test(music-network): cover scrobble shape + error classification, drop dead i18n keys

Remove the 14 unused legacy scrobble/connection i18n keys across all 9
locales (settings.lfm*/scrobble*, connection.lastfm*); the live love,
profile-link and now-playing keys stay.

Add regression tests for the parity-critical transport logic: the indexed
batch/array scrobble body, the auth-vs-network error classification
(numeric codes collide across providers), and the manifest-overrides-probe
capability merge.

* feat(music-network): provider-agnostic UI + Maloja Audioscrobbler & Koito presets

- de-hardcode the single provider name across every enrichment surface
  (love labels, now-playing badge, stats title); derive it from the
  enrichment primary and interpolate via {{provider}} i18n params
- surface a toast when a paste-auth connect probe fails — a static
  'supported' capability flag no longer masks a runtime probe error
- add the Maloja Audioscrobbler (GNU FM) preset (the third Maloja wire
  mode) and a Koito preset (ListenBrainz-compatible), both data-only
- generalise PRIVACY.md and the scrobbling help entry to the framework
- rename residual lfm* identifiers to network*; strip legacy flat
  lastfm* fields from the persisted blob; neutral transport error prefix
- tests: error classification, scrobble body, capability probe, registry

* fix(music-network): validate paste-auth keys on connect + UI polish

- AudioscrobblerWire.probe now validates an api_key_only session with a
  signed call and reports scrobble:'error' only on a genuine auth failure
  (a scrobble-only service that rejects user.getInfo is not a bad key), so
  an invalid Maloja Audioscrobbler / Rocksky key surfaces a connect toast
  instead of failing silently; WireContext carries the preset authStrategy
- drop the unreachable Statistics empty-state branch and its dead
  lfmNotConnected i18n key; use the useEnrichmentPrimaryLabel hook there
- drive the love-button glyph from the enrichment primary's manifest icon;
  neutral Music Network section icon; remove a dead LastfmIcon import
- tests: paste-auth vs token-poll probe behaviour

* chore(music-network): rename showLastfmSimilar → showNetworkSimilar, refresh stale comments

Post-parity polish: the similar-artists toggle now sources from the generic
enrichment runtime, so rename the lingering lastfm-flavoured identifier; drop
stale 'Mirrors today's LastfmX' doc comments referencing the removed legacy
types, and generalise the scrobble-point comment.

* docs(changelog): Music Network entry (PR #1066)

Co-Authored-By: cucadmuh <49571317+cucadmuh@users.noreply.github.com>

* fix(music-network): bound request timeout on the provider transports

audioscrobbler_request / listenbrainz_request / maloja_request built a
reqwest client with no timeout, so a hung provider left scrobble / probe /
loved-sync promises unresolved. Add a shared provider_http_client() with a
15s timeout, matching the sibling fetch_* commands. Addresses review C3.

* refactor(music-network): dedupe wire transport + no-enrichment helpers

The three provider clients repeated the same invoke -> classify-error ->
MusicNetworkError boilerplate, and the three probe() bodies repeated the
"mark every enrichment capability no" loop. Extract
wires/shared/invokeTransport() (each wire keeps its own arg shape + auth
rule) and markNoEnrichment() in core/capabilities.ts. Addresses review C4.

* refactor(music-network): drop write-only malojaWireMode dead state

malojaWireMode was written on connect but never read — the wire is resolved
by wireId and the Maloja base URL by the preset's selfHostedApiSuffix.
Remove the field, the MalojaWireMode type, the malojaWireModeFor helper,
the AccountPatch entry, the PresetField union member, and the export.
Addresses review C1.

* refactor(music-network): one useEnrichmentPrimary hook, drop lastfm fallback

The enrichment-primary lookup (accounts.find by enrichmentPrimaryId) was
duplicated across two hooks and inlined in the indicator and both
context-menu builders, two of them with a hardcoded 'lastfm' icon fallback.
Add one music-network/ui/useEnrichmentPrimary() returning
{account,label,icon}|null; useEnrichmentPrimaryLabel/Icon delegate to it and
the indicator + context menus consume it directly. Icon fallback is the
neutral 'custom' glyph, never a provider (provider-agnostic, §7.3).
Addresses review C2.

---------

Co-authored-by: cucadmuh <49571317+cucadmuh@users.noreply.github.com>
2026-06-11 22:56:30 +02:00
‮Artem ea304357ca fix(favorites): reflect player-bar song star in track lists (#1063)
* fix(favorites): reflect player-bar song star in track lists

Liking a song from the player bar / fullscreen / shortcuts wrote only to
the session `starredOverrides` map, which onStarSuccess deleted once the
server sync resolved. List views (AlbumDetail, Favorites, RandomMix,
playlists) seed their starred state from a one-shot fetch and reflect
later changes only by merging that override, so the row reverted the
instant the sync completed — the like never stuck in the list. Toggling
from a list row also updated the row's own local state, which is why that
direction already worked.

Keep the star override as the durable session source of truth (stop
deleting it on success); the in-memory Track / queue-cache patches stay.
The override is unpersisted and superseded by the next toggle, so it never
diverges from the server. Ratings keep their existing clear-on-success
behavior.
2026-06-11 16:07:02 +03:00
cucadmuh 90452a8f8c fix(whats-new): allow plugin-fs write into AppData cache dir (#1062)
* fix(whats-new): allow plugin-fs write into AppData cache dir

mkdir for release-notes/ succeeded but write_text_file to nested
paths was denied without fs:allow-app-write-recursive — RC/stable
re-fetched whats-new.md on every launch with an empty cache folder.

* docs: CHANGELOG PR #1062 for release-notes cache write
2026-06-11 11:02:51 +03:00
cucadmuh c503226b0a fix(library): stop genre-tags migration gate flashing on every launch (#1061)
runGenreTagsPhase entered the blocking 'inspecting' phase before awaiting
the inspect IPC, so the migration modal briefly appeared on every startup
once the one-time genre backfill was already complete. Inspect first without
a blocking phase and only switch to 'running' when work is actually needed.
2026-06-11 10:43:58 +03:00
cucadmuh 5cd01c90ac fix(library): multi-genre local index with track_genre and backfill (#1059)
* fix(library): multi-genre local index with track_genre and backfill

Restore atomic genre browse, filters, and counts via track_genre:
OpenSubsonic genres[] first with Navidrome-default split fallback, sync
write path, read-path query switches, blocking startup backfill with
progress, and v12 repair migration for DBs that recorded legacy 002–011.
TS fallback adds genreTagsFor and migration gate i18n across locales.

* fix(library): address multi-genre review — robust TS genres and scope join

genreTagsFor routes raw genres through parseItemGenres (single-object
Subsonic quirk and bare strings). Library-scoped genre browse/counts join
track for raw_json library_id fallback. Statistics keeps empty-genre bucket.

* docs: CHANGELOG and credits for multi-genre local index (PR #1059)

* docs(changelog): credit HiveMind on Discord for multi-genre report (PR #1059)
2026-06-11 01:02:35 +03:00
cucadmuh 8593858f3a fix(whats-new): generate release-notes bundle before dev startup (#1060)
* fix(whats-new): generate release-notes bundle before dev startup

Fresh clones failed Vite import analysis because releaseNotesBundle.ts
was gitignored and dev/tauri:dev did not run prebuild. Commit a sliced
stub and hook prebuild into dev scripts.

* docs: CHANGELOG PR #1060 for dev startup bundle fix

* fix(whats-new): keep release-notes bundle gitignored

Drop the committed generated slice — it would churn on every
CHANGELOG/WHATS_NEW edit. Prebuild before dev/tauri:dev is enough.
2026-06-11 00:55:35 +03:00
cucadmuh c7d71ea57c feat(whats-new): remote release notes with dev workspace mode (#1058)
* feat(whats-new): remote release notes with dev workspace mode

Add WHATS_NEW.md, CI whats-new.md asset upload, and client fetch/cache
with embedded fallbacks. Dev and -dev builds read the full file from the
repo for debugging; RC/stable download the release asset on first use.

* fix(whats-new): render ## headings and add changelog tab

Parse h2 sections in release-notes markdown; load changelog alongside
highlights and let users switch views on the What's New page.

* fix(whats-new): prefetch on startup and fix CI typecheck prebuild

Prefetch whats-new asset when the shell loads on RC/stable builds.
Run prebuild:release-notes before tsc and coverage jobs so the
gitignored generated bundle exists in CI.

* docs: CHANGELOG and credits for What's New remote notes (PR #1058)

* fix(whats-new): always slice embedded release notes to current line

Drop full CHANGELOG embed for -dev bundles; tauri:dev still reads live
markdown from the repo. Ignore all of src/generated/ in git.

* fix(whats-new): fetch release asset via Rust to bypass CORS

Route whats-new.md download through fetch_url_bytes; rename the
technical tab label; add fetch unit tests (PR #1058 review).
2026-06-10 23:35:23 +03:00
cucadmuh fb5a257735 fix(library): show album artist correctly in album grids (#1056) (#1057)
* fix(library): show album artist in album grids (#1056)

Prefer album-artist tags over track artist when building album rows from
the local index, and align grid cards with OpenSubsonic displayArtist.

* fix(library): align album artist in FTS, search, and offline paths (#1056)

Apply album-artist preference in FTS album dedupe and live search, fix
offline pin hydration order, and use albumArtistDisplayName in remaining
cheap UI/export/download call sites.

* docs(changelog): album artist grid fix for compilations (PR #1057)

* fix(library): parity guard and live-search album artist helper (#1056)

Align SQL ELSE branch with pick_album_group_artist trimming, add parity
test, and use albumArtistDisplayName in LiveSearch and MobileSearchOverlay.
2026-06-10 15:54:46 +03:00
cucadmuh 707a41f615 fix(scrobble): Navidrome Now Playing with local playback and mixed-server queue (#1055)
* fix(scrobble): report Now Playing on playback server with local bytes

Navidrome presence and play-count scrobbles no longer skip when audio plays
from hot cache, offline pins, or favorites-auto, and reachability follows the
queue/playback server instead of the browsed active server.

* docs: changelog and credits for PR #1055
2026-06-10 04:25:22 +03:00
cucadmuh ae9be74719 feat(settings): compact server cards with capability badges (#1054)
* feat(settings): redesign server cards with identity line and capability badges

Compact two-line server headers (entry name + user@host), HTTPS lock, and a
clickable version info tooltip. Navidrome ≥0.62 shows a green AudioMuse inline
badge; older Navidrome keeps the manual toggle row. Adds click-pinned tooltips
via data-tooltip-click on TooltipPortal.

* feat(settings): unify use/active slot and move delete into edit form

Merge Active badge and Use button into one rightmost action; Active uses
green styling. Reorder actions to edit, test, use/active. Remove the card
delete icon — deletion lives in the server edit form footer.

* docs: note compact server cards in CHANGELOG and credits (PR #1054)

* fix(credits): move PR #1054 server cards line to cucadmuh block

Was appended to Psychotoxical's contributions array by mistake; CHANGELOG
already credited cucadmuh (gh pr view author).
2026-06-10 00:25:30 +03:00
390 changed files with 11696 additions and 3197 deletions
+2
View File
@@ -56,6 +56,7 @@ jobs:
node-version: 'lts/*'
cache: 'npm'
- run: npm ci
- run: npm run prebuild:release-notes
- name: tsc
run: npx tsc --noEmit
@@ -71,6 +72,7 @@ jobs:
- name: install jq
run: sudo apt-get update && sudo apt-get install -y jq
- run: npm ci
- run: npm run prebuild:release-notes
- name: vitest run --coverage
run: npx vitest run --coverage
- name: hot-path file coverage gate
+28 -6
View File
@@ -176,18 +176,32 @@ jobs:
- name: extract changelog
id: changelog
run: |
set -euo pipefail
VERSION="${{ steps.get-version.outputs.version }}"
BODY=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
if [ -z "$BODY" ]; then
BASE_VERSION="$(node -e 'const v=process.argv[1]; const m=v.match(/^(\d+\.\d+\.\d+)/); if(m){process.stdout.write(m[1]);}' "$VERSION")"
if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "$VERSION" ]; then
BODY=$(awk "/^## \[$BASE_VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
fi
BODY=""
if node scripts/extract-release-section.mjs CHANGELOG.md "$VERSION" --allow-empty > /tmp/changelog-body.md; then
BODY="$(cat /tmp/changelog-body.md)"
fi
EOF_MARKER=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
echo "body<<$EOF_MARKER" >> "$GITHUB_OUTPUT"
echo "$BODY" >> "$GITHUB_OUTPUT"
echo "$EOF_MARKER" >> "$GITHUB_OUTPUT"
- name: extract what's new for release asset
id: whats-new
run: |
set -euo pipefail
VERSION="${{ steps.get-version.outputs.version }}"
CHANNEL="${{ inputs.channel }}"
if ! node scripts/extract-release-section.mjs WHATS_NEW.md "$VERSION" > /tmp/whats-new.md; then
if [ "$CHANNEL" = "release" ]; then
echo "::error::WHATS_NEW.md has no section for version $VERSION (required for stable release)"
exit 1
fi
echo "::warning::WHATS_NEW.md has no section for $VERSION — skipping whats-new.md asset"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: create or update release
id: create-release
uses: actions/github-script@v9
@@ -237,6 +251,14 @@ jobs:
prerelease,
});
return data.id;
- name: upload whats-new.md release asset
if: steps.whats-new.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
RELEASE_TAG="${{ steps.tag.outputs.value }}"
gh release upload "$RELEASE_TAG" /tmp/whats-new.md --clobber
build-macos-windows:
if: ${{ inputs.build_platform_artifacts }}
+3
View File
@@ -37,6 +37,9 @@ src-tauri/lcov.info
# Frontend test coverage
coverage/
# Generated at build/test/dev time (scripts/generate-release-notes-bundle.mjs)
src/generated/
# Documentation
CLAUDE.md
+213 -30
View File
@@ -99,6 +99,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### What's New — remote release notes
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1058](https://github.com/Psychotoxical/psysonic/pull/1058)**
* The **What's New** page shows user-friendly highlights from `WHATS_NEW.md` instead of embedding the full technical changelog in the app bundle.
* RC and stable builds prefetch `whats-new.md` from the GitHub release on startup and cache it locally; offline users see a thin embedded fallback.
* **`tauri:dev`** reads markdown straight from the repo for easy editing; shipped bundles embed only the current release-line slice. A **Full changelog** tab on the page shows the technical list for the same version.
* CI uploads `whats-new.md` on each `next` / `release` tag alongside platform artifacts.
* Remote download uses the existing Rust `fetch_url_bytes` proxy so GitHub release assets work without browser CORS.
### Music Network — scrobble to more than just Last.fm
**By [@Psychotoxical](https://github.com/Psychotoxical) and [@cucadmuh](https://github.com/cucadmuh), PR [#1066](https://github.com/Psychotoxical/psysonic/pull/1066)**
* **Settings → Integrations** now hosts a **Music Network** that scrobbles your plays to one or more services at once: **Last.fm**, **Libre.fm**, **Rocksky**, **ListenBrainz** (the public service or any compatible server), **Maloja** (native, Audioscrobbler or ListenBrainz API), **Koito**, and any **custom GNU FM** instance.
* Choose a **primary** service — your loved tracks, similar artists and listening stats come from it — while scrobbles fan out to every connected service. A master switch turns the whole thing on or off.
* **Last.fm works exactly as before**, including love, similar artists and the Statistics page; your existing connection is migrated automatically on first launch.
* *Known limitation:* Rocksky currently rejects scrobbles whose title or artist contains non-standard (non-ASCII) characters — a Rocksky-side issue, not a Psysonic bug.
### Live — rich now-playing on Navidrome 0.62+
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1080](https://github.com/Psychotoxical/psysonic/pull/1080)**
* On servers that advertise the OpenSubsonic `playbackReport` extension (Navidrome ≥ 0.62), Psysonic reports live transport state and position so **Live** shows who is playing or paused and where in the track — including playback speed when the other client sends it.
* The position bar glides between refreshes; pause and resume update the server immediately instead of waiting for the audio engine.
* Play counts are unchanged — still driven by the existing scrobble path. Servers without the extension keep the previous now-playing behaviour.
### Title bar — selectable window button styles
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1083](https://github.com/Psychotoxical/psysonic/pull/1083), suggested by [@PHLAK](https://github.com/PHLAK)**
* The Linux custom title bar gets a **window button style** picker in **Settings → Appearance → Custom title bar** — choose between dots, dots with icons, flat, pill, outline, and minimal looks.
* All styles now carry minimize/maximize/close icons for clear, colour-blind-friendly buttons, and an optional toggle hides the minimize button (maximize and close only).
### Playback speed — Semitones strategy, finer labels, and advanced fine steps
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1084](https://github.com/Psychotoxical/psysonic/pull/1084)**
* New **Semitones** strategy sets varispeed directly in semitones (±12 st, 0.1 step) instead of coarse speed steps; the speed readout now shows two decimals so every slider notch is visible.
* Each strategy button has a short tooltip; **Advanced** mode adds an optional **Fine adjustment** toggle (0.01× / 0.01 st steps) in **Settings → Audio**.
### Now Playing — live status dot in "Who is listening?"
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1086](https://github.com/Psychotoxical/psysonic/pull/1086)**
* Each listener in the **Who is listening?** popover now shows a small status dot — playing, paused, or idle — derived from the live playback report, replacing the previous "minutes ago" line. The status is also read out for screen readers and on hover, so it is never conveyed by colour alone.
## Changed
### Dependencies — npm and Rust refresh
@@ -157,30 +215,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Settings → Servers — compact server cards
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1054](https://github.com/Psychotoxical/psysonic/pull/1054)**
* Two-line header: custom entry name plus `user@host`, HTTPS lock, and a clickable version info tooltip (hover or tap).
* Navidrome **0.62+**: green **AudioMuse-AI** inline badge when the plugin is detected; older Navidrome keeps the manual toggle row below the card.
* **Use** and **Active** share one rightmost slot (green badge vs primary button); card actions are edit → test → use/active. Delete lives in the edit form footer.
* **TooltipPortal**: `data-tooltip-click` for click-pinned tooltips (touch and explicit open without the 1s hover delay).
## 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)**
@@ -198,14 +244,6 @@ 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)**
@@ -265,6 +303,38 @@ 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.
### 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.
### 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)).
### 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.
### Themes — consistent focus borders on inputs and dropdowns
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1052](https://github.com/Psychotoxical/psysonic/pull/1052)**
@@ -282,6 +352,119 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Navidrome Now Playing and scrobble with local playback
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1055](https://github.com/Psychotoxical/psysonic/pull/1055)**
* **Show in Now Playing** and Navidrome play-count scrobbles no longer silently skip when audio plays from hot cache, offline library pins, or favorites-auto bytes.
* Presence and queue sync target the **playback server** reachability gate, so a queue on server A still reports to Navidrome while browsing server B.
### Album grids — album artist on compilation cards
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1057](https://github.com/Psychotoxical/psysonic/pull/1057), reported in [#1056](https://github.com/Psychotoxical/psysonic/issues/1056)**
* Random Albums, New Releases, All Albums, and other album grids no longer show a track artist on compilation albums when the tags set a single album artist (e.g. **Underworld** on a various-artists mix); the card matches the album page.
* Local index browse, live search, and FTS album dedupe prefer `album_artist` over per-track `artist`; Hero, Most Played, and offline pin labels use the same display helper.
### Local index — multi-genre browse, filters, and counts
**By [@cucadmuh](https://github.com/cucadmuh), reported by HiveMind on the Psysonic Discord, PR [#1059](https://github.com/Psychotoxical/psysonic/pull/1059)**
* Tracks tagged with several genres in one metadata field (e.g. `Noise Metal/Dark Ambient/Experimental Black Metal`) again match **each atomic genre** in Genres browse, All Albums filters, genre detail, and Advanced Search — not only the first segment.
* New `track_genre` index (OpenSubsonic `genres[]` when present, Navidrome-default split fallback), maintained on sync; one-time blocking startup backfill for existing libraries with progress.
* Migration v12 repairs databases that recorded legacy schema versions 211; TS network fallback uses robust `genreTagsFor` parsing.
### Dev startup — missing generated release-notes bundle
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1060](https://github.com/Psychotoxical/psysonic/pull/1060)**
* Fresh clones no longer crash Vite on `tauri:dev` when `src/generated/releaseNotesBundle.ts` is missing — `dev` and `tauri:dev` now run `prebuild:release-notes` before launch (file stays gitignored).
### What's New — release-notes cache file on disk (RC/stable)
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1062](https://github.com/Psychotoxical/psysonic/pull/1062)**
* RC and stable builds now persist the downloaded `whats-new.md` slice under AppData — `plugin-fs` had mkdir but lacked recursive write scope, so the `release-notes/` folder appeared empty and every launch re-fetched from GitHub.
### Favorites — player-bar star stays synced in track lists
**By [@artplan1](https://github.com/artplan1), PR [#1063](https://github.com/Psychotoxical/psysonic/pull/1063)**
* Liking a song from the **player bar**, fullscreen player, or global shortcuts now updates the star in album tracklists, playlists, Random Mix, and Favorites — the row no longer reverts the instant the server sync completes.
* List views seed starred state from a one-shot fetch and merge session `starredOverrides`; clearing those overrides on sync success had only patched `currentTrack` and the queue cache, so rows fell back to stale fetched values.
### Fullscreen player — title cleanup
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1068](https://github.com/Psychotoxical/psysonic/pull/1068)**
* The song title no longer shows a leading track number, and letters with descenders (g, j, p, q, y) are no longer clipped along the bottom edge.
### Discord Rich Presence — album art and clearer settings
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1068](https://github.com/Psychotoxical/psysonic/pull/1068)**
* Album art shows again when the cover source is "Server (via album info)" — Discord was handed a local file path it cannot fetch and fell back to the app icon; it now receives a reachable image URL.
* **Settings → Integrations:** added notices clarifying that this is the built-in Discord Rich Presence, and that the official Navidrome Discord RP plugin needs "Show in Now Playing" enabled instead.
### Internet radio — no more duplicate now-playing on Linux
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by agriffit79, PR [#1069](https://github.com/Psychotoxical/psysonic/pull/1069)**
* On Linux, playing an internet radio station showed the track twice in the desktop "now playing" overlay — once from the app's own media controls and once from a second player the web engine registered for the stream. The extra player is now suppressed, so radio shows a single entry like regular tracks.
### Windows — idle app no longer blocks system sleep
**By [@cucadmuh](https://github.com/cucadmuh), reported by [@Thraka](https://github.com/Thraka), PR [#1073](https://github.com/Psychotoxical/psysonic/pull/1073)**
* Psysonic no longer keeps the audio output device open while the app is idle — the CPAL stream opens on first playback and closes after **60 seconds** without active audio (pause), or **immediately** on Stop / natural queue end, so Windows `powercfg` no longer reports an in-use audio stream when music is not playing ([#1071](https://github.com/Psychotoxical/psysonic/issues/1071)).
* Resume after a long pause uses the existing cold path (`audio:output-released` resets the warm-pause flag); post-sleep recovery skips reopening the stream when nothing is playing.
* **Cold start while paused:** after `getPlayQueue` restores position, the seekbar shows the saved time immediately; the current track is hot-cache prefetched and the engine loads silently (`audio_play` with `startPaused`) at that position so the next Play is a warm `audio_resume` without an audible blip at the start of the track.
* Rodio `Dropping DeviceSink` warnings on stream release are suppressed unless logging is in debug mode.
* **Stop keeps the waveform:** stopping no longer wipes the seekbar waveform for the still-shown track — the cached analysis bins are kept and re-hydrated from the database, so the real waveform stays mounted instead of falling back to flat bars.
### Auto-install script — `curl | sudo bash` works again
**By [@kbennett2000](https://github.com/kbennett2000), PR [#1079](https://github.com/Psychotoxical/psysonic/pull/1079)**
* The Linux auto-installer (`install.sh`) failed before any download because `[INFO]` log lines were captured into the package URL and curl rejected the mangled string; logging now goes to stderr, the reinstall prompt reads from the terminal, and package downloads use `--fail --globoff`.
### Music Network — self-hosted scrobbling reaches the server
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1085](https://github.com/Psychotoxical/psysonic/pull/1085)**
* Self-hosted scrobble targets that use a pasted token (Koito, Maloja's ListenBrainz and Audioscrobbler compatibility surfaces) silently recorded nothing: the saved server address dropped the API path, so listens were sent to a route that does not exist while the account still showed as connected.
* The correct API path is now kept when connecting. Reconnect an affected account once so it picks up the corrected address.
### Internet Radio — station management limited to server admins
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1086](https://github.com/Psychotoxical/psysonic/pull/1086)**
* Navidrome 0.62 made creating, editing and deleting radio stations admin-only, so those actions failed for standard accounts. Add, edit and delete controls are now hidden for non-admin Navidrome users; playback and favourites stay available to everyone. Other server types are unaffected.
## [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)
+13 -7
View File
@@ -9,13 +9,19 @@ All third-party integrations listed below are **opt-in**. Nothing is sent until
### Your Subsonic / Navidrome server
Your server URL, username, and password are stored locally in the app's data directory. All playback and library requests go directly to your own server. Psysonic has no access to this data.
### Last.fm
If you connect a Last.fm account in Settings, Psysonic sends:
- **Scrobbles** — track title, artist, album, and timestamp when a song reaches 50% playback
- **Now Playing** — the currently playing track (title, artist, album)
- **Love / Unlove** — when you mark a track as loved or unloved
### Music Network (scrobble & enrichment services)
Psysonic can connect to one or more scrobble services in Settings → Integrations. Each service you connect is opt-in and independent; nothing is sent to a service you have not connected. Supported service classes:
All requests go to the [Last.fm API](https://www.last.fm/api). Your Last.fm credentials are stored locally and never leave your device. You can disconnect your account at any time in Settings.
- **Audioscrobbler / GNU FM services** — Last.fm, Libre.fm, Rocksky (AT Protocol), and any self-hosted GNU FM-compatible instance
- **ListenBrainz** — the public ListenBrainz.org service, or a self-hosted instance (e.g. Koito) via its ListenBrainz-compatible API
- **Maloja** — your own self-hosted Maloja server (native API or its ListenBrainz-compatible API)
To each connected service, Psysonic may send:
- **Scrobbles** — track title, artist, album, and timestamp when a song reaches 50% playback
- **Now Playing** — the currently playing track (title, artist, album), where the service supports it
- **Love / Unlove** — when you mark a track as loved, on services that support it
Additionally, the one service you choose as your **primary** is queried to enrich the UI (your loved tracks, similar artists, and listening stats). All requests go directly from your device to the service's own host — the public service's host (e.g. the [Last.fm API](https://www.last.fm/api), [ListenBrainz](https://listenbrainz.org)) or, for self-hosted services, the server URL you entered. Credentials (session keys / API tokens) are stored locally and never leave your device. You can disconnect any service at any time in Settings.
### LRCLIB (Lyrics)
When lyrics are fetched from LRCLIB, Psysonic sends the track title, artist, album, and duration to [lrclib.net](https://lrclib.net) as a search query. No account is required. This feature can be disabled in Settings → Lyrics.
@@ -37,7 +43,7 @@ If Discord is running and Rich Presence is not disabled, Psysonic connects to th
The following data is stored exclusively on your device in the app's local storage directory and is never transmitted:
- Server profiles (URL, username, password)
- Last.fm session key
- Scrobble service credentials (session keys / API tokens)
- Playback preferences, themes, keybindings, and all other settings
- Synced device manifests
+1
View File
@@ -49,6 +49,7 @@ Rules:
### Step B: Promote to RC (`next`)
0. Confirm `WHATS_NEW.md` has a `## [X.Y.Z]` section for the release line about to ship (user-facing copy for the in-app What's New screen; CI uploads it as `whats-new.md` on the release tag).
1. Run workflow: **Promote main to next**.
2. Workflow behavior:
- validates required `main` checks before promotion (default: `ci-ok`, or UI-style `ci-main / ci-ok`; either satisfies the gate)
+109
View File
@@ -0,0 +1,109 @@
# What's New
User-facing release highlights for the in-app **What's New** screen. Maintainers refresh the
current line before promoting to `next` / `release`. Technical details and PR credits stay in
`CHANGELOG.md`.
Within each section, order by **user impact** (most noticeable first) — not PR merge order.
`CHANGELOG.md` keeps strict PR order inside Added / Changed / Fixed.
## [1.48.0]
## Highlights
### Offline listening
- When the server is unreachable, browse and detail pages show what you already have locally instead of empty errors — albums, artists, playlists, and cross-server favorites.
- Starred tracks, pinned albums, and playlists live under one **media** folder; browse them in **Offline Library** and see disk usage at a glance.
- **Favorites auto-sync** keeps loved songs on disk; pinned albums and playlists refresh when the library index updates.
### Music Network — scrobble beyond Last.fm
- **Settings → Integrations** now hosts a **Music Network**: connect **Last.fm**, **Libre.fm**, **ListenBrainz**, **Maloja**, **Rocksky**, **Koito**, or your own **GNU FM** instance — and scrobble to several at once.
- Pick a **primary** service for loved tracks, similar artists, and stats; other connections still receive scrobbles. Your existing Last.fm setup migrates automatically.
- A master switch turns the whole network on or off.
### Theme Store
- Browse and install community themes from **Settings → Themes** — search, dark/light filter, full-size previews, and sort by popularity or date.
- Six palettes ship with the app; everything else installs on demand and works offline after the first download.
- **Now Playing** follows every theme cleanly, including light palettes.
- Import a theme from a local `.zip` when you have a package from a friend or your own design.
- The sidebar nudges you when an installed theme has an update; one-click update from the theme card.
### Fullscreen player
- Rebuilt for much lower CPU and memory use: a calm, sharp fullscreen view with album art, waveform seekbar, up-next queue, synced lyrics, ratings, and a clock that follows your **Clock format** setting.
- The song title no longer shows a leading track number, and descenders (g, j, p, q, y) are no longer clipped.
### Live — richer now playing on Navidrome 0.62+
- On servers with OpenSubsonic **playbackReport** (Navidrome ≥ 0.62), **Live** shows who is playing or paused, how far into the track they are, and playback speed when another client sends it — with smooth position updates between refreshes.
- In **Who is listening?**, each listener shows a small status dot (playing, paused, or idle) instead of a vague “minutes ago” line.
### Queue — Timeline mode
- A third queue layout keeps the current track in the middle with history above and up next below — great for long listening sessions. Cycle the header control or pick it in **Settings → Personalisation → Queue display**.
### Settings → Servers
- Each card shows the server software and version (e.g. **Navidrome 0.62.0**) under the name, with a cleaner two-line layout and compact actions.
- Navidrome **0.62+** shows a green **AudioMuse-AI** badge when the plugin is detected — no manual toggle on current Navidrome.
### Sidebar — pin Now Playing to the top
- New **Settings → Sidebar** toggle moves **Now Playing** to the top of the sidebar instead of the bottom (off by default).
### Startup
- A themed loading splash appears while the app starts — colours follow your active theme, including community palettes.
## Improved
- Audio decoding runs on **Symphonia 0.6**; streams start sooner and recover from stalls without restarting the player.
- The **Preload Next Track** toggle under **Settings → Storage → Buffering** is gone — playback no longer waits on that extra RAM prefetch. Gapless, crossfade, and Hot Cache behave as before.
- New **Semitones** playback-speed strategy (±12 st, 0.1 step) with two-decimal speed readout; optional fine steps in **Settings → Audio → Advanced**.
## Fixed
### Playback and audio
- **Windows:** the app no longer keeps the audio device open while idle, so the system can sleep when music is not playing.
- **macOS:** steady playback stutter from background device polling is gone on the default output path.
- After a long pause, the seekbar shows the saved position immediately and the next **Play** resumes without an audible blip at track start.
- **Stop** keeps the real waveform on the seekbar instead of falling back to flat bars.
### Offline, Now Playing, and Navidrome
- Now Playing cards (**from this album**, discography, most played) stay populated during cached and offline playback instead of blanking out on track change.
- Navidrome **Show in Now Playing** and play-count scrobbles work when audio plays from hot cache, offline pins, or auto-synced favorites.
- Mixed-server queues still report to the correct Navidrome server.
### Themes and integrations
- Self-hosted Music Network targets (Koito, Maloja, custom GNU FM with a pasted token) scrobble again — reconnect once if you connected before this fix.
- Favoriting from the player bar, fullscreen player, or shortcuts updates the star in track lists and playlists immediately.
- Discord Rich Presence shows album art again when covers come from the server.
- Focus rings and dropdown borders follow the active theme consistently.
### Browse and library
- Tracks tagged with several genres in one field (e.g. `Metal/Ambient/Experimental`) match **each genre** again in browse, filters, and search.
- **All Albums → Only compilations** returns results for common tagging patterns.
- Album grids show the album artist on compilations instead of a random track artist.
- Song rails (**Random Picks**, **Discover Songs**, etc.) link each name in multi-artist credits separately.
- **Artist → Top Tracks** play works even when the artist page has no albums loaded yet.
- **Home → Most Played** no longer jumps the page when you load more albums.
- **Mainstage** hero backdrop stays in sync when you skip albums quickly.
### Other
- **Linux:** the `curl | bash` auto-installer works again.
- **Linux:** internet radio no longer appears twice in the desktop now-playing overlay.
- On Navidrome **0.62+**, add/edit/delete radio stations is shown only to admin accounts; everyone can still play and favourite stations.
- **Linux custom title bar:** pick window button styles (dots, flat, pill, and more) and optionally hide minimize in **Settings → Appearance**.
- The active server card under **Settings → Servers** draws a complete border on all sides.
## Under the hood
- Navidrome **0.62+** auto-detects **AudioMuse-AI** and routes Instant Mix / Lucky Mix through the smarter API when the plugin is present — older Navidrome keeps the manual toggle you already know.
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1779560665,
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"lastModified": 1781074563,
"narHash": "sha256-md8WlXOlfnIeHeOScMTTHFyf2d6iaTwPl2apR5EQ3P4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"rev": "9ae611a455b90cf061d8f332b977e387bda8e1ca",
"type": "github"
},
"original": {
+1 -1
View File
@@ -1,3 +1,3 @@
{
"npmDepsHash": "sha256-7BvKeTkZzAQoBVm2vw2oZRsRKWN/Du1pn89U1rtc47k="
"npmDepsHash": "sha256-ndXqYgws77qokAXznbQ6BXhXUo3VIaiF1AVs5jCkNCo="
}
+110 -110
View File
@@ -1,12 +1,12 @@
{
"name": "psysonic",
"version": "1.48.0-dev",
"version": "1.48.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "psysonic",
"version": "1.48.0-dev",
"version": "1.48.0",
"dependencies": {
"@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/figtree": "^5.2.10",
@@ -56,7 +56,7 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.8",
"esbuild": "^0.28.0",
"esbuild": "^0.28.1",
"jsdom": "^29.1.1",
"typescript": "^6.0.3",
"vite": "^8.0.14",
@@ -394,9 +394,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
@@ -411,9 +411,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
@@ -428,9 +428,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
@@ -445,9 +445,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
@@ -462,9 +462,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
@@ -479,9 +479,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
@@ -496,9 +496,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
@@ -513,9 +513,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
@@ -530,9 +530,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
@@ -547,9 +547,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
@@ -564,9 +564,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
@@ -581,9 +581,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
@@ -598,9 +598,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
@@ -615,9 +615,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
@@ -632,9 +632,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
@@ -649,9 +649,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
@@ -666,9 +666,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
@@ -683,9 +683,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
@@ -700,9 +700,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
@@ -717,9 +717,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
@@ -734,9 +734,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
@@ -751,9 +751,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
@@ -768,9 +768,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
@@ -785,9 +785,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
@@ -802,9 +802,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
@@ -819,9 +819,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
@@ -2366,9 +2366,9 @@
}
},
"node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -2379,32 +2379,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/estree-walker": {
+9 -8
View File
@@ -1,18 +1,19 @@
{
"name": "psysonic",
"version": "1.48.0-dev",
"version": "1.48.0",
"private": true,
"scripts": {
"check:css-imports": "node scripts/check-css-import-graph.mjs",
"dev": "vite",
"build": "tsc && vite build",
"prebuild:release-notes": "node scripts/generate-release-notes-bundle.mjs",
"dev": "npm run prebuild:release-notes && vite",
"build": "npm run prebuild:release-notes && tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"test": "vitest run && npm run check:css-imports",
"tauri:dev": "npm run prebuild:release-notes && tauri dev",
"tauri:build": "npm run prebuild:release-notes && tauri build",
"test": "npm run prebuild:release-notes && vitest run && node --test scripts/extract-release-section.test.mjs && npm run check:css-imports",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage && npm run check:css-imports"
"test:coverage": "npm run prebuild:release-notes && vitest run --coverage && npm run check:css-imports"
},
"dependencies": {
"@fontsource-variable/dm-sans": "^5.2.8",
@@ -63,7 +64,7 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.8",
"esbuild": "^0.28.0",
"esbuild": "^0.28.1",
"jsdom": "^29.1.1",
"typescript": "^6.0.3",
"vite": "^8.0.14",
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* Extract the body of a ## [version] section from a Keep-a-Changelog-style file.
* Resolution matches src/utils/releaseNotes/releaseNotesMatch.ts.
*
* Usage: node scripts/extract-release-section.mjs <file> <version> [--allow-empty]
* Stdout: section body (no ## header). Exit 1 if empty unless --allow-empty.
*/
import { readFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
const SEMVER_CORE = /^v?(\d+\.\d+\.\d+)/i;
function versionCore(version) {
const m = version.trim().match(SEMVER_CORE);
return m ? m[1] : null;
}
function isPlainTriple(header) {
return /^\d+\.\d+\.\d+$/.test(header.trim());
}
function splitBlocks(raw) {
return raw.split(/\n(?=## \[)/).filter((b) => b.startsWith('## ['));
}
function headerVersion(block) {
const m = block.match(/^## \[([^\]]+)\]/);
return m ? m[1] : null;
}
function parseBlock(block) {
const lines = block.split('\n');
const m = lines[0].match(/## \[([^\]]+)\](?:\s*-\s*(.+))?/);
if (!m) return null;
return {
headerVersion: m[1],
date: (m[2] ?? '').trim(),
body: lines.slice(1).join('\n').trim(),
};
}
export function findReleaseSection(raw, appVersion) {
const blocks = splitBlocks(raw);
const exact = blocks.find((b) => b.startsWith(`## [${appVersion}]`));
if (exact) return parseBlock(exact);
const appCore = versionCore(appVersion);
if (!appCore) return null;
const candidates = blocks.filter((b) => {
const hv = headerVersion(b);
return hv !== null && versionCore(hv) === appCore;
});
if (candidates.length === 0) return null;
const plain = candidates.find((b) => {
const hv = headerVersion(b);
return hv !== null && isPlainTriple(hv);
});
return parseBlock(plain ?? candidates[0]);
}
function main() {
const args = process.argv.slice(2);
const allowEmpty = args.includes('--allow-empty');
const positional = args.filter((a) => a !== '--allow-empty');
const [file, version] = positional;
if (!file || !version) {
console.error('Usage: node scripts/extract-release-section.mjs <file> <version> [--allow-empty]');
process.exit(2);
}
const raw = readFileSync(file, 'utf8');
const entry = findReleaseSection(raw, version);
const body = entry?.body?.trim() ?? '';
if (!body) {
if (allowEmpty) process.exit(0);
console.error(`No release section found in ${file} for version ${version}`);
process.exit(1);
}
process.stdout.write(`${body}\n`);
}
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMain) main();
+26
View File
@@ -0,0 +1,26 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { findReleaseSection } from './extract-release-section.mjs';
const FIXTURE = `
## [1.48.0] - 2026-06-10
## Highlights
- One
## [1.47.0]
- Old
`;
describe('findReleaseSection', () => {
it('matches base line for -rc versions', () => {
const entry = findReleaseSection(FIXTURE, '1.48.0-rc.3');
assert.equal(entry.headerVersion, '1.48.0');
assert.match(entry.body, /Highlights/);
});
it('matches base line for -dev versions', () => {
const entry = findReleaseSection(FIXTURE, '1.48.0-dev');
assert.equal(entry.headerVersion, '1.48.0');
});
});
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env node
/**
* Build src/generated/releaseNotesBundle.ts for production bundles.
* Embeds only the ## [X.Y.Z] slice for package.json version (dev, RC, and stable).
* tauri:dev reads live markdown from the repo via Vite ?raw imports instead.
*/
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { findReleaseSection } from './extract-release-section.mjs';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
const version = pkg.version;
const whatsNewPath = join(root, 'WHATS_NEW.md');
const changelogPath = join(root, 'CHANGELOG.md');
const whatsNewFull = readFileSync(whatsNewPath, 'utf8');
const changelogFull = readFileSync(changelogPath, 'utf8');
function sliceForVersion(full, fileLabel) {
const entry = findReleaseSection(full, version);
if (!entry?.body) {
console.warn(`warn: no section in ${fileLabel} for ${version} — embedding empty slice`);
return '';
}
const dateSuffix = entry.date ? ` - ${entry.date}` : '';
return `## [${entry.headerVersion}]${dateSuffix}\n\n${entry.body}`;
}
const whatsNewRaw = sliceForVersion(whatsNewFull, 'WHATS_NEW.md');
const changelogRaw = sliceForVersion(changelogFull, 'CHANGELOG.md');
const outDir = join(root, 'src/generated');
mkdirSync(outDir, { recursive: true });
const ts = `/** @generated — run: node scripts/generate-release-notes-bundle.mjs */
export const WHATS_NEW_RAW: string = ${JSON.stringify(whatsNewRaw)};
export const CHANGELOG_RAW: string = ${JSON.stringify(changelogRaw)};
`;
writeFileSync(join(outDir, 'releaseNotesBundle.ts'), ts, 'utf8');
console.log(`wrote src/generated/releaseNotesBundle.ts (sliced for ${version})`);
+19 -8
View File
@@ -14,20 +14,22 @@ YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Log helpers write to stderr so functions that return values via stdout
# (e.g. get_download_url) stay clean when called in command substitution.
info() {
echo -e "${BLUE}[INFO]${NC} $1"
echo -e "${BLUE}[INFO]${NC} $1" >&2
}
success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
echo -e "${GREEN}[SUCCESS]${NC} $1" >&2
}
warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
echo -e "${YELLOW}[WARN]${NC} $1" >&2
}
error() {
echo -e "${RED}[ERROR]${NC} $1"
echo -e "${RED}[ERROR]${NC} $1" >&2
exit 1
}
@@ -105,7 +107,7 @@ install_package() {
if [ "$OS_TYPE" = "debian" ]; then
package_file="${package_file}.deb"
curl -L -o "$package_file" "$download_url"
curl --fail --globoff -L -o "$package_file" "$download_url"
info "Installing package..."
$PACKAGE_MANAGER install -y "$package_file" || {
@@ -114,7 +116,7 @@ install_package() {
}
elif [ "$OS_TYPE" = "rhel" ]; then
package_file="${package_file}.rpm"
curl -L -o "$package_file" "$download_url"
curl --fail --globoff -L -o "$package_file" "$download_url"
info "Installing package..."
$PACKAGE_MANAGER install -y "$package_file"
@@ -128,8 +130,17 @@ install_package() {
check_installed() {
if command -v $APP_NAME &> /dev/null || command -v ${APP_NAME^} &> /dev/null; then
warn "${APP_NAME} appears to be already installed."
read -p "Do you want to reinstall? (y/N): " -n 1 -r
echo
# Under `curl ... | bash`, stdin is the script stream itself, so
# read the answer from the controlling terminal instead. Probe by
# opening: `[ -r /dev/tty ]` passes on the 0666 device node even
# without a controlling terminal; only open() reports the failure.
if { : < /dev/tty; } 2>/dev/null; then
read -p "Do you want to reinstall? (y/N): " -n 1 -r < /dev/tty
echo
else
warn "No terminal available for prompt; skipping reinstall."
exit 0
fi
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
info "Installation cancelled."
exit 0
+7 -7
View File
@@ -4114,7 +4114,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.48.0-dev"
version = "1.48.0"
dependencies = [
"biquad",
"dasp_sample",
@@ -4167,7 +4167,7 @@ dependencies = [
[[package]]
name = "psysonic-analysis"
version = "1.48.0-dev"
version = "1.48.0"
dependencies = [
"ebur128",
"futures-util",
@@ -4186,7 +4186,7 @@ dependencies = [
[[package]]
name = "psysonic-audio"
version = "1.48.0-dev"
version = "1.48.0"
dependencies = [
"biquad",
"dasp_sample",
@@ -4216,7 +4216,7 @@ dependencies = [
[[package]]
name = "psysonic-core"
version = "1.48.0-dev"
version = "1.48.0"
dependencies = [
"libc",
"serde",
@@ -4225,7 +4225,7 @@ dependencies = [
[[package]]
name = "psysonic-integration"
version = "1.48.0-dev"
version = "1.48.0"
dependencies = [
"discord-rich-presence",
"futures-util",
@@ -4242,7 +4242,7 @@ dependencies = [
[[package]]
name = "psysonic-library"
version = "1.48.0-dev"
version = "1.48.0"
dependencies = [
"psysonic-core",
"psysonic-integration",
@@ -4257,7 +4257,7 @@ dependencies = [
[[package]]
name = "psysonic-syncfs"
version = "1.48.0-dev"
version = "1.48.0"
dependencies = [
"futures-util",
"id3",
+1 -1
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "1.48.0-dev"
version = "1.48.0"
edition = "2021"
rust-version = "1.95"
license = "GPL-3.0-or-later"
+1
View File
@@ -22,6 +22,7 @@
"fs:allow-write-file",
"fs:allow-read-file",
"fs:allow-mkdir",
"fs:allow-app-write-recursive",
"fs:scope-download-recursive",
"fs:scope-home-recursive",
"window-state:allow-save-window-state",
+39 -29
View File
@@ -14,10 +14,11 @@ use super::decode::build_source;
use super::engine::{audio_http_client, AudioEngine};
use super::helpers::*;
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
use super::play_input::{
build_playback_source_with_probe_fallback, select_play_input,
spawn_legacy_stream_start_when_armed, swap_in_new_sink, url_format_hint, BuildSourceArgs,
PlayInputContext, SinkSwapInputs,
use super::play_input::{select_play_input, url_format_hint, PlayInputContext};
use super::source_build::{build_playback_source_with_probe_fallback, BuildSourceArgs};
use super::sink_swap::{
spawn_legacy_stream_start_when_armed, swap_in_new_sink, LegacyStreamStartWhenArmed,
SinkSwapInputs,
};
use super::playback_rate::preserve_pitch_will_run;
use super::preview::preview_clear_for_new_main_playback;
@@ -53,9 +54,13 @@ pub async fn audio_play(
analysis_track_id: Option<String>,
server_id: Option<String>,
stream_format_suffix: Option<String>,
// Silent load: no `audio:playing`, sink stays paused. Optional + defaults to
// `false` so older/external `audio_play` callers that omit it still work.
start_paused: Option<bool>,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
let start_paused = start_paused.unwrap_or(false);
let gapless = state.gapless_enabled.load(Ordering::Relaxed);
// ── Ghost-command guard ───────────────────────────────────────────────────
@@ -311,23 +316,25 @@ pub async fn audio_play(
};
let needs_switch = target_rate > 0 && target_rate != current_stream_rate;
if needs_switch {
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
let dev = state.selected_device.lock().unwrap().clone();
if state.stream_reopen_tx.send((target_rate, hi_res_enabled, dev, reply_tx)).is_ok() {
match reply_rx.recv_timeout(std::time::Duration::from_secs(5)) {
Ok(new_handle) => {
*state.stream_handle.lock().unwrap() = new_handle;
state.stream_sample_rate.store(target_rate, Ordering::Relaxed);
// Give PipeWire time to reconfigure at the new rate before
// we open a Sink — only needed for large hi-res quanta.
if hi_res_enabled && target_rate > 48_000 {
tokio::time::sleep(Duration::from_millis(150)).await;
}
}
Err(_) => {
crate::app_eprintln!("[psysonic] stream rate switch timed out, keeping {current_stream_rate} Hz");
match super::engine::open_output_stream_blocking(
&state,
target_rate,
hi_res_enabled,
dev,
) {
Ok(_) => {
// Give PipeWire time to reconfigure at the new rate before
// we open a Sink — only needed for large hi-res quanta.
if hi_res_enabled && target_rate > 48_000 {
tokio::time::sleep(Duration::from_millis(150)).await;
}
}
Err(_) => {
crate::app_eprintln!(
"[psysonic] stream rate switch timed out, keeping {current_stream_rate} Hz"
);
}
}
}
@@ -337,7 +344,8 @@ pub async fn audio_play(
}
}
let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer()));
let stream = super::engine::ensure_output_stream_open(&state)?;
let sink = Arc::new(Player::connect_new(stream.mixer()));
sink.set_volume(effective_volume);
// ── Sink pre-fill for hi-res tracks ──────────────────────────────────────
@@ -401,7 +409,7 @@ pub async fn audio_play(
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(()); // skipped during pre-fill — abort silently
}
if !defer_playback_start {
if !defer_playback_start && !start_paused {
sink.play();
}
}
@@ -415,24 +423,26 @@ pub async fn audio_play(
fadeout_samples: built.fadeout_samples,
crossfade_enabled,
actual_fade_secs,
start_paused,
});
if defer_playback_start {
{
if !start_paused {
let mut cur = state.current.lock().unwrap();
cur.play_started = None;
cur.paused_at = Some(0.0);
}
spawn_legacy_stream_start_when_armed(
spawn_legacy_stream_start_when_armed(LegacyStreamStartWhenArmed {
gen,
state.generation.clone(),
state.stream_playback_armed.clone(),
state.samples_played.clone(),
state.current.clone(),
app.clone(),
gen_arc: state.generation.clone(),
playback_armed: state.stream_playback_armed.clone(),
samples_played: state.samples_played.clone(),
current: state.current.clone(),
app: app.clone(),
duration_secs,
);
} else {
hold_paused: start_paused,
});
} else if !start_paused {
app.emit("audio:playing", duration_secs).ok();
}
@@ -2,9 +2,7 @@
//! `commands.rs` so playback / radio / EQ aren't entangled with the device
//! enumeration + reopen path.
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
use tauri::{Emitter, State};
@@ -78,16 +76,13 @@ pub async fn audio_set_device(
*state.selected_device.lock().unwrap() = device_name.clone();
let rate = state.stream_sample_rate.load(Ordering::Relaxed);
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
state.stream_reopen_tx
.send((rate, false, device_name, reply_tx))
.map_err(|e| e.to_string())?;
let new_handle = tauri::async_runtime::spawn_blocking(move || {
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
}).await.unwrap_or(None).ok_or("device open timed out")?;
*state.stream_handle.lock().unwrap() = new_handle;
let open_rate = if rate > 0 {
rate
} else {
state.device_default_rate
};
super::engine::open_output_stream_blocking(&state, open_rate, false, device_name.clone())
.map_err(|_| "device open timed out".to_string())?;
// Capture position and drop the active sink atomically so the position
// reading is still valid (play_started / paused_at intact before take).
@@ -23,10 +23,11 @@ use tauri::Emitter;
use tauri::Manager;
use super::engine::AudioEngine;
use super::play_input::{
build_playback_source_with_probe_fallback, swap_in_new_sink, url_format_hint,
BuildSourceArgs, PlayInput, PlaybackSource, SinkSwapInputs,
use super::play_input::{url_format_hint, PlayInput};
use super::source_build::{
build_playback_source_with_probe_fallback, BuildSourceArgs, PlaybackSource,
};
use super::sink_swap::{swap_in_new_sink, SinkSwapInputs};
use super::progress_task::spawn_progress_task;
use super::stream::LocalFileSource;
@@ -187,9 +188,14 @@ pub(crate) async fn try_resume_after_device_change(
.current_channels
.store(ps.built.output_channels as u32, Ordering::Relaxed);
let sink = Arc::new(Player::connect_new(
engine.stream_handle.lock().unwrap().mixer(),
));
let stream = match super::engine::ensure_output_stream_open(&engine) {
Ok(s) => s,
Err(e) => {
crate::app_eprintln!("[device-resume] output stream open failed: {e}");
return false;
}
};
let sink = Arc::new(Player::connect_new(stream.mixer()));
let effective_volume = (snap.base_volume * snap.gain_linear).clamp(0.0, 1.0);
sink.set_volume(effective_volume);
sink.append(ps.built.source);
@@ -205,6 +211,7 @@ pub(crate) async fn try_resume_after_device_change(
fadeout_samples: ps.built.fadeout_samples,
crossfade_enabled: false,
actual_fade_secs: 0.0,
start_paused: false,
},
);
@@ -1,6 +1,5 @@
//! Poll default output device and pinned-device presence; reopen stream when needed.
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tauri::Emitter;
@@ -41,8 +40,11 @@ pub(crate) async fn reopen_output_stream(
};
let rate = engine.stream_sample_rate.load(Ordering::Relaxed);
let reopen_tx = engine.stream_reopen_tx.clone();
let stream_handle = engine.stream_handle.clone();
let open_rate = if rate > 0 {
rate
} else {
engine.device_default_rate
};
let current = engine.current.clone();
let fading_out = engine.fading_out_sink.clone();
@@ -62,25 +64,24 @@ pub(crate) async fn reopen_output_stream(
}
};
let new_handle = tauri::async_runtime::spawn_blocking(move || {
let (reply_tx, reply_rx) =
std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
if reopen_tx
.send((rate, false, device_name, reply_tx))
.is_err()
{
return None;
}
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
let app_for_open = app.clone();
let device_name_for_open = device_name.clone();
let opened = tauri::async_runtime::spawn_blocking(move || {
let engine = app_for_open.state::<AudioEngine>();
super::engine::open_output_stream_blocking(
&engine,
open_rate,
false,
device_name_for_open,
)
.is_ok()
})
.await
.unwrap_or(None);
.unwrap_or(false);
let Some(handle) = new_handle else {
if !opened {
return false;
};
*stream_handle.lock().unwrap() = handle;
}
if let Some(s) = current.lock().unwrap().sink.take() {
s.stop();
}
+158 -57
View File
@@ -7,21 +7,32 @@ use rodio::Player;
use super::state::{ChainedInfo, PreloadedTrack, StreamCompletedSpill};
/// Reply channel handed back to the audio-stream thread once a re-open finishes.
pub type StreamReopenReply = std::sync::mpsc::SyncSender<Arc<rodio::MixerDeviceSink>>;
/// Stream-thread re-open request: `(desired_rate, is_hi_res, device_name, reply_tx)`.
pub type StreamReopenRequest = (u32, bool, Option<String>, StreamReopenReply);
/// Reply channel handed back to the audio-stream thread once an open finishes.
pub type StreamOpenReply =
std::sync::mpsc::SyncSender<(Arc<rodio::MixerDeviceSink>, u32)>;
/// Requests handled on the dedicated audio-stream thread (open / idle release).
pub enum StreamThreadMsg {
Open {
desired_rate: u32,
is_hi_res: bool,
device_name: Option<String>,
reply: StreamOpenReply,
},
Release {
reply: std::sync::mpsc::SyncSender<()>,
},
}
pub struct AudioEngine {
pub stream_handle: Arc<std::sync::Mutex<Arc<rodio::MixerDeviceSink>>>,
pub stream_handle: Arc<std::sync::Mutex<Option<Arc<rodio::MixerDeviceSink>>>>,
/// Sample rate the output stream was last opened at (updated on every re-open).
pub stream_sample_rate: Arc<AtomicU32>,
/// The rate the device was opened at on cold start — used to restore the
/// stream when Hi-Res is toggled off while a hi-res rate is active.
pub device_default_rate: u32,
/// Sends `(desired_rate, is_hi_res, device_name, reply_tx)` to the audio-stream
/// thread to re-open the output device. `device_name = None` → system default.
pub stream_reopen_tx: std::sync::mpsc::SyncSender<StreamReopenRequest>,
/// Open or release the CPAL output stream on the audio-stream thread.
pub stream_thread_tx: std::sync::mpsc::SyncSender<StreamThreadMsg>,
/// User-selected output device name (None = follow system default).
pub selected_device: Arc<Mutex<Option<String>>>,
pub current: Arc<Mutex<AudioCurrent>>,
@@ -147,6 +158,15 @@ impl AudioCurrent {
/// 3. Device default.
/// 4. System default (last resort).
///
/// Rodio prints a stderr line on every intentional stream drop. Keep that only
/// when runtime logging is in **debug** mode; normal/off silence the noise.
fn finalize_mixer_device_sink(mut handle: rodio::MixerDeviceSink) -> Arc<rodio::MixerDeviceSink> {
if !crate::logging::should_log_debug() {
handle.log_on_drop(false);
}
Arc::new(handle)
}
/// Returns `(stream_handle, actual_sample_rate)`.
fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32) -> (Arc<rodio::MixerDeviceSink>, u32) {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
@@ -214,7 +234,7 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
.and_then(|b| b.with_sample_rate(std::num::NonZeroU32::new(desired_rate).unwrap_or(std::num::NonZeroU32::MIN)).open_stream())
{
crate::app_eprintln!("[psysonic] audio stream opened at {} Hz (exact)", desired_rate);
return (Arc::new(handle), desired_rate);
return (finalize_mixer_device_sink(handle), desired_rate);
}
}
@@ -231,7 +251,7 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
"[psysonic] audio stream opened at {} Hz (highest, wanted {})",
rate, desired_rate
);
return (Arc::new(handle), rate);
return (finalize_mixer_device_sink(handle), rate);
}
}
}
@@ -244,7 +264,7 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
.map(|c| c.sample_rate())
.unwrap_or(44100);
crate::app_eprintln!("[psysonic] audio stream opened at {} Hz (device default)", rate);
return (Arc::new(handle), rate);
return (finalize_mixer_device_sink(handle), rate);
}
}
@@ -257,7 +277,78 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
.and_then(|d| d.default_output_config().ok())
.map(|c| c.sample_rate())
.unwrap_or(44100);
(Arc::new(handle), rate)
(finalize_mixer_device_sink(handle), rate)
}
fn probe_device_default_rate() -> u32 {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
rodio::cpal::default_host()
.default_output_device()
.and_then(|d| d.default_output_config().ok())
.map(|c| c.sample_rate())
.unwrap_or(44_100)
}
/// Open the output stream (blocking). Updates `stream_handle` and `stream_sample_rate`.
pub(crate) fn open_output_stream_blocking(
engine: &AudioEngine,
desired_rate: u32,
is_hi_res: bool,
device_name: Option<String>,
) -> Result<Arc<rodio::MixerDeviceSink>, String> {
let rate = if desired_rate > 0 {
desired_rate
} else {
engine.device_default_rate
};
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel(0);
engine
.stream_thread_tx
.send(StreamThreadMsg::Open {
desired_rate: rate,
is_hi_res,
device_name,
reply: reply_tx,
})
.map_err(|e| e.to_string())?;
let (handle, actual_rate) = reply_rx
.recv_timeout(Duration::from_secs(5))
.map_err(|_| "audio stream open timed out".to_string())?;
engine
.stream_sample_rate
.store(actual_rate, std::sync::atomic::Ordering::Relaxed);
*engine.stream_handle.lock().unwrap() = Some(handle.clone());
Ok(handle)
}
/// Ensure a live output stream exists; lazy-opens on first playback.
pub(crate) fn ensure_output_stream_open(
engine: &AudioEngine,
) -> Result<Arc<rodio::MixerDeviceSink>, String> {
if let Some(handle) = engine.stream_handle.lock().unwrap().clone() {
return Ok(handle);
}
let rate = engine.stream_sample_rate.load(std::sync::atomic::Ordering::Relaxed);
let open_rate = if rate > 0 {
rate
} else {
engine.device_default_rate
};
let device = engine.selected_device.lock().unwrap().clone();
open_output_stream_blocking(engine, open_rate, false, device)
}
pub(crate) fn request_stream_release(engine: &AudioEngine) -> Result<(), String> {
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel(0);
engine
.stream_thread_tx
.send(StreamThreadMsg::Release { reply: reply_tx })
.map_err(|e| e.to_string())?;
reply_rx
.recv_timeout(Duration::from_secs(5))
.map_err(|_| "audio stream release timed out".to_string())?;
Ok(())
}
pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
@@ -269,13 +360,12 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
}
}
// Channels: main thread ←→ audio-stream thread.
// init_tx/rx : (Arc<rodio::MixerDeviceSink>, actual_rate) sent once at startup.
// reopen_tx/rx: (desired_rate, reply_tx) — triggers a stream re-open.
let (init_tx, init_rx) =
std::sync::mpsc::sync_channel::<(Arc<rodio::MixerDeviceSink>, u32)>(0);
let (reopen_tx, reopen_rx) =
std::sync::mpsc::sync_channel::<(u32, bool, Option<String>, std::sync::mpsc::SyncSender<Arc<rodio::MixerDeviceSink>>)>(4);
// Channel: main thread ←→ audio-stream thread (lazy open + idle release).
let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::<()>(0);
let (stream_thread_tx, stream_thread_rx) =
std::sync::mpsc::sync_channel::<StreamThreadMsg>(4);
let device_default_rate = probe_device_default_rate();
let thread = std::thread::Builder::new()
.name("psysonic-audio-stream".into())
@@ -296,52 +386,63 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
// Thread priority is kept at default during standard-mode playback.
// It is escalated to Max only when a Hi-Res stream reopen is requested,
// to prevent PipeWire underruns at high quantum sizes (8192 frames).
let (mut _stream, rate) = open_stream_for_device_and_rate(None, 0);
let handle = _stream.clone();
init_tx.send((handle, rate)).ok();
let mut _stream: Option<Arc<rodio::MixerDeviceSink>> = None;
ready_tx.send(()).ok();
// Keep the stream alive and handle sample-rate / device-switch requests.
while let Ok((desired_rate, is_hi_res, device_name, reply_tx)) = reopen_rx.recv() {
// Escalate to Max for Hi-Res reopens (large PipeWire quanta need
// real-time scheduling to avoid underruns). No escalation for
// standard mode — the thread blocks on recv() between reopens so
// elevated priority would only waste scheduler budget.
if is_hi_res {
thread_priority::set_current_thread_priority(
thread_priority::ThreadPriority::Max
).ok();
while let Ok(msg) = stream_thread_rx.recv() {
match msg {
StreamThreadMsg::Release { reply } => {
_stream = None;
let _ = reply.send(());
}
StreamThreadMsg::Open {
desired_rate,
is_hi_res,
device_name,
reply,
} => {
// Escalate to Max for Hi-Res reopens (large PipeWire quanta need
// real-time scheduling to avoid underruns). No escalation for
// standard mode — the thread blocks on recv() between reopens so
// elevated priority would only waste scheduler budget.
if is_hi_res {
thread_priority::set_current_thread_priority(
thread_priority::ThreadPriority::Max,
)
.ok();
}
_stream = None;
// Scale the PipeWire quantum with the sample rate so wall-clock
// latency stays roughly constant (≈93 ms) at all rates.
#[cfg(target_os = "linux")]
if desired_rate > 0 {
let frames: u32 = if desired_rate > 48_000 { 8192 } else { 4096 };
std::env::set_var("PIPEWIRE_LATENCY", format!("{frames}/{desired_rate}"));
let latency_ms =
(frames as f64 / desired_rate as f64 * 1000.0).round() as u64;
std::env::set_var("PULSE_LATENCY_MSEC", latency_ms.to_string());
}
let (new_stream, actual_rate) =
open_stream_for_device_and_rate(device_name.as_deref(), desired_rate);
let new_handle = new_stream.clone();
_stream = Some(new_stream);
let _ = reply.send((new_handle, actual_rate));
}
}
drop(_stream); // close old stream before opening new one
// Scale the PipeWire quantum with the sample rate so wall-clock
// latency stays roughly constant (≈93 ms) at all rates.
// 8192 frames at 88200 Hz ≈ 92.9 ms (same as 4096 at 48000 Hz).
#[cfg(target_os = "linux")]
{
let frames: u32 = if desired_rate > 48_000 { 8192 } else { 4096 };
std::env::set_var("PIPEWIRE_LATENCY", format!("{frames}/{desired_rate}"));
// Keep PULSE_LATENCY_MSEC in sync so PulseAudio-based setups
// get the same wall-clock quantum as PipeWire.
let latency_ms = (frames as f64 / desired_rate as f64 * 1000.0).round() as u64;
std::env::set_var("PULSE_LATENCY_MSEC", latency_ms.to_string());
}
let (new_stream, _actual) = open_stream_for_device_and_rate(device_name.as_deref(), desired_rate);
let new_handle = new_stream.clone();
_stream = new_stream;
reply_tx.send(new_handle).ok();
}
})
.expect("spawn audio stream thread");
let (initial_handle, initial_rate) = init_rx.recv().expect("audio stream handle");
ready_rx.recv().expect("audio stream thread ready");
let engine = AudioEngine {
stream_handle: Arc::new(std::sync::Mutex::new(initial_handle)),
stream_sample_rate: Arc::new(AtomicU32::new(initial_rate)),
device_default_rate: initial_rate,
stream_reopen_tx: reopen_tx,
stream_handle: Arc::new(std::sync::Mutex::new(None)),
stream_sample_rate: Arc::new(AtomicU32::new(0)),
device_default_rate,
stream_thread_tx,
selected_device: Arc::new(Mutex::new(None)),
current: Arc::new(Mutex::new(AudioCurrent {
sink: None,
@@ -16,6 +16,8 @@ pub mod device_commands;
pub mod mix_commands;
mod play_input;
pub mod playback_rate;
mod sink_swap;
mod source_build;
mod preserve_worker;
pub mod preload_commands;
pub(crate) mod progress_task;
@@ -24,6 +26,7 @@ pub mod transport_commands;
mod device_resume;
mod device_watcher;
mod engine;
mod stream_idle;
#[cfg(any(target_os = "windows", target_os = "linux"))]
mod power_resume;
#[cfg(target_os = "windows")]
@@ -39,6 +42,7 @@ mod stream;
pub use device_commands::{audio_default_output_device_name, audio_list_devices_for_engine};
pub use device_watcher::start_device_watcher;
pub use stream_idle::start_stream_idle_watcher;
pub use engine::{create_engine, refresh_http_user_agent, AudioEngine};
pub use helpers::{
cleanup_orphan_stream_spill_dir, take_stream_completed_for_url,
@@ -15,11 +15,10 @@ use super::analysis_dispatch::{
prepare_playback_analysis, spawn_track_analysis_bytes, spawn_track_analysis_file,
TrackAnalysisOrigin,
};
use super::decode::{build_source, build_streaming_source, BuiltSource, SizedDecoder};
use super::engine::{audio_http_client, AudioEngine};
use super::helpers::{
content_type_to_hint, fetch_data, format_hint_from_content_disposition,
normalize_stream_suffix_for_hint, resolve_playback_format_hint, sniff_stream_format_extension,
normalize_stream_suffix_for_hint, sniff_stream_format_extension,
same_playback_target,
STREAM_FORMAT_SNIFF_PROBE_BYTES,
};
@@ -401,47 +400,6 @@ async fn open_ranged_or_streaming_input(
}))
}
/// Legacy `AudioStreamReader`: keep the sink paused until the download task arms
/// playback, then reset counters and emit `audio:playing` so the UI does not
/// extrapolate ahead of audible output.
pub(super) fn spawn_legacy_stream_start_when_armed(
gen: u64,
gen_arc: Arc<AtomicU64>,
playback_armed: Arc<AtomicBool>,
samples_played: Arc<AtomicU64>,
current: Arc<Mutex<super::engine::AudioCurrent>>,
app: AppHandle,
duration_secs: f64,
) {
tokio::spawn(async move {
loop {
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
if playback_armed.load(Ordering::Relaxed) {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
samples_played.store(0, Ordering::Relaxed);
let sink = current.lock().unwrap().sink.clone();
if let Some(sink) = sink {
{
let mut cur = current.lock().unwrap();
cur.play_started = Some(std::time::Instant::now());
cur.paused_at = None;
cur.seek_offset = 0.0;
}
sink.play();
app.emit("audio:playing", duration_secs).ok();
crate::app_deprintln!("[stream] legacy track-stream: playback started after buffer ready");
}
});
}
/// Pulled out of the format_hint extraction block in `audio_play` — strip the
/// query string first so Subsonic-style URLs (`stream.view?...&v=1.16.1&...`)
/// don't latch onto random query-param substrings; only accept short
@@ -460,459 +418,3 @@ pub(crate) fn url_format_hint(url: &str) -> Option<String> {
})
.map(|s| s.to_lowercase())
}
/// Arguments forwarded from `audio_play` into the source-build pipeline.
/// Bundles the format-hint inputs, playback-shaping parameters and the shared
/// done flag so that `build_playback_source_with_probe_fallback` stays below
/// the `clippy::too_many_arguments` threshold.
pub(crate) struct BuildSourceArgs<'a> {
pub url: &'a str,
pub gen: u64,
pub cache_id_for_tasks: Option<&'a str>,
pub server_id: Option<&'a str>,
pub url_format_hint: Option<&'a str>,
pub stream_format_suffix: Option<&'a str>,
pub done_flag: Arc<AtomicBool>,
pub fade_in_dur: Duration,
pub hi_res_enabled: bool,
pub duration_hint: f64,
}
/// Output of `build_source_from_play_input`: the wrapped rodio source plus
/// whether the chosen source path is seekable (only the Streaming variant
/// is not).
pub(crate) struct PlaybackSource {
pub(crate) built: BuiltSource,
pub(crate) is_seekable: bool,
}
/// State + decisions audio_play computed before the sink swap.
pub(crate) struct SinkSwapInputs {
pub(crate) sink: Arc<rodio::Player>,
pub(crate) duration_secs: f64,
pub(crate) volume: f32,
pub(crate) gain_linear: f32,
pub(crate) fadeout_trigger: Arc<AtomicBool>,
pub(crate) fadeout_samples: Arc<std::sync::atomic::AtomicU64>,
pub(crate) crossfade_enabled: bool,
pub(crate) actual_fade_secs: f32,
}
/// Atomically swap the new sink into `state.current`, then handle the old
/// sink: trigger sample-level fade-out (crossfade enabled) or stop it
/// immediately (hard cut). The fade-out is handed off to a small spawned
/// task that drops the old sink ~`actual_fade_secs + 0.5 s` later.
pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapInputs) {
use std::time::Instant;
let SinkSwapInputs {
sink,
duration_secs,
volume,
gain_linear,
fadeout_trigger: new_fadeout_trigger,
fadeout_samples: new_fadeout_samples,
crossfade_enabled,
actual_fade_secs,
} = inputs;
let (old_sink, old_fadeout_trigger, old_fadeout_samples) = {
let mut cur = state.current.lock().unwrap();
let old = cur.sink.take();
let old_fo_trigger = cur.fadeout_trigger.take();
let old_fo_samples = cur.fadeout_samples.take();
cur.sink = Some(sink);
cur.duration_secs = duration_secs;
cur.seek_offset = 0.0;
cur.play_started = Some(Instant::now());
cur.paused_at = None;
cur.replay_gain_linear = gain_linear;
cur.base_volume = volume.clamp(0.0, 1.0);
cur.fadeout_trigger = Some(new_fadeout_trigger);
cur.fadeout_samples = Some(new_fadeout_samples);
(old, old_fo_trigger, old_fo_samples)
};
if crossfade_enabled {
if let Some(old) = old_sink {
// Trigger sample-level fade-out on Track A via TriggeredFadeOut.
// Calculate total fade samples from the measured actual_fade_secs.
let rate = state.current_sample_rate.load(Ordering::Relaxed);
let ch = state.current_channels.load(Ordering::Relaxed);
let fade_total = (actual_fade_secs as f64 * rate as f64 * ch as f64) as u64;
if let (Some(trigger), Some(samples)) = (old_fadeout_trigger, old_fadeout_samples) {
samples.store(fade_total.max(1), Ordering::SeqCst);
trigger.store(true, Ordering::SeqCst);
}
// Keep old sink alive until the fade completes + small margin,
// then drop it. No volume stepping needed — the fade-out runs
// at sample level inside the audio thread.
*state.fading_out_sink.lock().unwrap() = Some(old);
let fo_arc = state.fading_out_sink.clone();
let cleanup_dur = Duration::from_secs_f32(actual_fade_secs + 0.5);
tokio::spawn(async move {
tokio::time::sleep(cleanup_dur).await;
if let Some(s) = fo_arc.lock().unwrap().take() {
s.stop();
}
});
}
} else if let Some(old) = old_sink {
old.stop();
}
}
fn play_media_format_hint(input: &PlayInput) -> Option<String> {
match input {
PlayInput::SeekableMedia { format_hint, .. } | PlayInput::Streaming { format_hint, .. } => {
format_hint.clone()
}
PlayInput::Bytes(_) => None,
}
}
/// Ranged HTTP probe/decode failed in a way that may succeed after the
/// background download finishes (moov-at-end, demuxer EOF during partial buffer).
fn is_ranged_stream_probe_failure(err: &str) -> bool {
err.contains("ranged-stream")
&& (err.contains("format probe failed")
|| err.contains("moov metadata")
|| err.contains("end of stream"))
}
/// Completed ranged download or spill file for `url`, if ready.
async fn try_take_completed_stream_bytes(
url: &str,
state: &State<'_, AudioEngine>,
) -> Option<Vec<u8>> {
if let Some(data) = super::helpers::take_stream_completed_for_url(state, url) {
return Some(data);
}
let spill_path = {
let guard = state.stream_completed_spill.lock().unwrap();
guard
.as_ref()
.filter(|p| same_playback_target(&p.url, url))
.map(|p| p.path.clone())
};
if let Some(path) = spill_path {
let data = tokio::fs::read(&path).await.ok()?;
if !data.is_empty() {
return Some(data);
}
}
None
}
/// Ranged assembly can be byte-complete but missing `moov` (holes) or non-audio HTTP body.
async fn prefer_clean_http_bytes_for_fallback(
url: &str,
gen: u64,
state: &State<'_, AudioEngine>,
app: &AppHandle,
ranged_data: Vec<u8>,
format_hint: Option<&str>,
label: &str,
) -> Result<Option<Vec<u8>>, String> {
let is_mp4 = super::stream::container_hint_is_mp4(format_hint);
if is_mp4 {
super::stream::log_isobmff_buffer_diagnostic(&ranged_data, format_hint, label);
if !super::stream::isobmff_buffer_looks_complete(&ranged_data)
|| super::stream::mp4_suspect_zero_holes(&ranged_data)
{
crate::app_deprintln!(
"[stream] ranged buffer looks incomplete or holey — refetching via sequential HTTP"
);
if let Some(fresh) = fetch_data(url, state, gen, app).await? {
if super::stream::isobmff_buffer_looks_complete(&fresh) {
return Ok(Some(fresh));
}
super::stream::log_isobmff_buffer_diagnostic(&fresh, format_hint, "http-refetch");
}
}
}
Ok(Some(ranged_data))
}
/// Wait for the in-flight ranged download to finish, then HTTP-fetch if needed.
pub(super) async fn wait_or_fetch_bytes_for_stream_fallback(
url: &str,
gen: u64,
state: &State<'_, AudioEngine>,
app: &AppHandle,
format_hint: Option<&str>,
) -> Result<Option<Vec<u8>>, String> {
use std::time::{Duration, Instant};
let deadline = Instant::now() + Duration::from_secs(TRACK_READ_TIMEOUT_SECS);
loop {
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(None);
}
if let Some(data) = try_take_completed_stream_bytes(url, state).await {
crate::app_deprintln!(
"[stream] full-buffer fallback: using completed download ({} KiB)",
data.len() / 1024
);
return prefer_clean_http_bytes_for_fallback(
url,
gen,
state,
app,
data,
format_hint,
"ranged-cache",
)
.await;
}
if Instant::now() >= deadline {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
crate::app_deprintln!(
"[stream] full-buffer fallback: download still in progress after {}s — HTTP fetch",
TRACK_READ_TIMEOUT_SECS
);
fetch_data(url, state, gen, app).await
}
fn is_in_memory_probe_failure(err: &str) -> bool {
err.contains("format probe failed")
|| err.contains("could not open audio stream")
|| err.contains("no playable audio track")
}
/// Like [`build_source_from_play_input`], but on ranged-stream probe failure waits
/// for a full download (or fetches it) and retries from in-memory bytes.
pub(crate) async fn build_playback_source_with_probe_fallback(
play_input: PlayInput,
args: BuildSourceArgs<'_>,
state: &State<'_, AudioEngine>,
app: &AppHandle,
) -> Result<PlaybackSource, String> {
let BuildSourceArgs {
url,
gen,
cache_id_for_tasks,
server_id,
url_format_hint,
stream_format_suffix,
done_flag,
fade_in_dur,
hi_res_enabled,
duration_hint,
} = args;
let media_hint = play_media_format_hint(&play_input);
let effective_hint = resolve_playback_format_hint(
url_format_hint,
stream_format_suffix,
media_hint.as_deref(),
None,
);
if let Some(ref h) = effective_hint {
crate::app_deprintln!("[stream] playback format hint: {h}");
}
match build_source_from_play_input(
play_input,
state,
effective_hint.as_deref(),
done_flag.clone(),
fade_in_dur,
hi_res_enabled,
duration_hint,
)
.await
{
Ok(p) => Ok(p),
Err(e) if is_ranged_stream_probe_failure(&e) => {
crate::app_deprintln!(
"[stream] ranged-stream probe failed — trying full-buffer fallback: {}",
e
);
let data = match wait_or_fetch_bytes_for_stream_fallback(
url,
gen,
state,
app,
effective_hint.as_deref(),
)
.await?
{
Some(d) => d,
None => return Err(e),
};
if state.generation.load(Ordering::SeqCst) != gen {
return Err("ranged-stream: superseded during full-buffer fallback".into());
}
let bytes_hint = resolve_playback_format_hint(
url_format_hint,
stream_format_suffix,
media_hint.as_deref(),
Some(&data),
);
if bytes_hint.as_ref() != effective_hint.as_ref() {
crate::app_deprintln!(
"[stream] full-buffer fallback: resolved hint {:?} (was {:?})",
bytes_hint,
effective_hint
);
}
if let Some(track_id) = cache_id_for_tasks
.map(str::trim)
.filter(|s| !s.is_empty())
{
let (sid, high) =
prepare_playback_analysis(app, state, server_id, track_id, None);
spawn_track_analysis_bytes(
app.clone(),
TrackAnalysisOrigin::StreamDownloadComplete,
sid,
track_id.to_string(),
data.clone(),
high,
Some((gen, state.generation.clone())),
);
}
match build_source_from_play_input(
PlayInput::Bytes(data.clone()),
state,
bytes_hint.as_deref(),
done_flag.clone(),
fade_in_dur,
hi_res_enabled,
duration_hint,
)
.await
{
Ok(p) => Ok(p),
Err(pe) if is_in_memory_probe_failure(&pe) => {
if super::stream::container_hint_is_mp4(bytes_hint.as_deref()) {
super::stream::log_isobmff_buffer_diagnostic(
&data,
bytes_hint.as_deref(),
"ranged-cache-probe-fail",
);
}
crate::app_deprintln!(
"[stream] in-memory probe failed — sequential HTTP refetch: {}",
pe
);
let fresh = match fetch_data(url, state, gen, app).await? {
Some(d) => d,
None => return Err(pe),
};
if super::stream::container_hint_is_mp4(bytes_hint.as_deref()) {
super::stream::log_isobmff_buffer_diagnostic(
&fresh,
bytes_hint.as_deref(),
"http-refetch-after-probe-fail",
);
}
build_source_from_play_input(
PlayInput::Bytes(fresh),
state,
bytes_hint.as_deref(),
done_flag,
fade_in_dur,
hi_res_enabled,
duration_hint,
)
.await
}
Err(pe) => Err(pe),
}
}
Err(e) => Err(e),
}
}
/// Dispatch [`PlayInput`] → fully wrapped rodio source. For Bytes the full
/// in-memory pipeline (incl. iTunSMPB scan); for SeekableMedia / Streaming
/// the streaming variant runs the decoder build on a blocking thread.
pub(super) async fn build_source_from_play_input(
play_input: PlayInput,
state: &State<'_, AudioEngine>,
format_hint: Option<&str>,
done_flag: Arc<AtomicBool>,
fade_in_dur: Duration,
hi_res_enabled: bool,
duration_hint: f64,
) -> Result<PlaybackSource, String> {
// Always 0 — no application-level resampling. Rodio handles conversion to
// the output device rate internally; we let every track play at its native rate.
let target_rate: u32 = 0;
let mut is_seekable = true;
let built = match play_input {
PlayInput::Bytes(data) => build_source(
data,
duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
state.eq_pre_gain.clone(),
state.playback_rate.clone(),
done_flag,
fade_in_dur,
state.samples_played.clone(),
target_rate,
format_hint,
hi_res_enabled,
),
PlayInput::SeekableMedia {
reader,
format_hint: media_hint,
tag,
mp4_probe_gate,
} => {
if let Some(gate) = mp4_probe_gate.as_ref() {
super::stream::wait_for_ranged_mp4_probe_ready(gate).await?;
if gate.gen_arc.load(Ordering::SeqCst) != gate.gen {
return Err("ranged-stream: superseded before moov metadata ready".into());
}
}
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag)
})
.await
.map_err(|e| e.to_string())??;
build_streaming_source(
decoder,
duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
state.eq_pre_gain.clone(),
state.playback_rate.clone(),
done_flag,
fade_in_dur,
state.samples_played.clone(),
target_rate,
None,
)
}
PlayInput::Streaming { reader, format_hint: stream_hint } => {
is_seekable = false;
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(Box::new(reader), stream_hint.as_deref(), "track-stream")
})
.await
.map_err(|e| e.to_string())??;
build_streaming_source(
decoder,
duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
state.eq_pre_gain.clone(),
state.playback_rate.clone(),
done_flag,
fade_in_dur,
state.samples_played.clone(),
target_rate,
Some(state.stream_playback_armed.clone()),
)
}
}?;
Ok(PlaybackSource { built, is_seekable })
}
@@ -4,11 +4,11 @@
use std::sync::Mutex;
use std::time::{Duration, Instant};
use tauri::AppHandle;
use tauri::Manager;
use tauri::{AppHandle, Emitter, Manager};
use super::device_watcher::{reopen_output_stream, ReopenNotify};
use super::engine::AudioEngine;
use super::engine::{request_stream_release, AudioEngine};
use super::stream_idle::{output_stream_is_needed, teardown_playback_sinks_for_idle_release};
static RESUME_REOPEN_DEBOUNCE: Mutex<Option<Instant>> = Mutex::new(None);
const DEBOUNCE: Duration = Duration::from_millis(900);
@@ -30,10 +30,22 @@ pub(crate) fn debounce_allow_resume_reopen() -> bool {
pub(crate) async fn reopen_audio_after_system_resume(app: &AppHandle) {
tokio::time::sleep(Duration::from_millis(400)).await;
let device_name = match app.try_state::<AudioEngine>() {
Some(e) => e.selected_device.lock().unwrap().clone(),
None => return,
let Some(state) = app.try_state::<AudioEngine>() else {
return;
};
let engine = state.inner();
if !output_stream_is_needed(engine) {
if engine.stream_handle.lock().unwrap().is_some() {
teardown_playback_sinks_for_idle_release(engine);
let _ = request_stream_release(engine);
*engine.stream_handle.lock().unwrap() = None;
let _ = app.emit("audio:output-released", ());
}
return;
}
let device_name = engine.selected_device.lock().unwrap().clone();
if reopen_output_stream(app, device_name, ReopenNotify::DeviceChanged).await {
crate::app_eprintln!("[psysonic] audio output reopened after system resume");
@@ -399,7 +399,8 @@ pub async fn audio_preview_play(
let source = PriorityBoostSource::new(source);
// ── Build secondary sink on the existing OutputStream ────────────────────
let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer()));
let stream = super::engine::ensure_output_stream_open(&state)?;
let sink = Arc::new(Player::connect_new(stream.mixer()));
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
sink.append(source);
@@ -150,7 +150,8 @@ pub async fn audio_play_radio(
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer()));
let stream = super::engine::ensure_output_stream_open(&state)?;
let sink = Arc::new(Player::connect_new(stream.mixer()));
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
sink.append(boosted);
@@ -0,0 +1,165 @@
//! Sink-lifecycle glue for `audio_play`: atomically swap a freshly built sink
//! into `state.current` (handing off the old one to a crossfade tail or a hard
//! stop), and the legacy non-seekable path that holds a sink paused until the
//! download task arms playback. Split out of `play_input.rs` so source
//! selection and source building stay focused on their own concerns.
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, State};
use super::engine::{AudioCurrent, AudioEngine};
/// Args for [`spawn_legacy_stream_start_when_armed`].
pub(super) struct LegacyStreamStartWhenArmed {
pub gen: u64,
pub gen_arc: Arc<AtomicU64>,
pub playback_armed: Arc<AtomicBool>,
pub samples_played: Arc<AtomicU64>,
pub current: Arc<Mutex<AudioCurrent>>,
pub app: AppHandle,
pub duration_secs: f64,
pub hold_paused: bool,
}
/// Legacy `AudioStreamReader`: keep the sink paused until the download task arms
/// playback, then reset counters and emit `audio:playing` so the UI does not
/// extrapolate ahead of audible output.
pub(super) fn spawn_legacy_stream_start_when_armed(args: LegacyStreamStartWhenArmed) {
let LegacyStreamStartWhenArmed {
gen,
gen_arc,
playback_armed,
samples_played,
current,
app,
duration_secs,
hold_paused,
} = args;
tokio::spawn(async move {
loop {
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
if playback_armed.load(Ordering::Relaxed) {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
samples_played.store(0, Ordering::Relaxed);
let sink = current.lock().unwrap().sink.clone();
if let Some(sink) = sink {
if hold_paused {
sink.pause();
let mut cur = current.lock().unwrap();
cur.play_started = None;
if cur.paused_at.is_none() {
cur.paused_at = Some(0.0);
}
cur.seek_offset = 0.0;
crate::app_deprintln!(
"[stream] legacy track-stream: buffer ready, holding paused (silent prepare)"
);
} else {
{
let mut cur = current.lock().unwrap();
cur.play_started = Some(Instant::now());
cur.paused_at = None;
cur.seek_offset = 0.0;
}
sink.play();
app.emit("audio:playing", duration_secs).ok();
crate::app_deprintln!("[stream] legacy track-stream: playback started after buffer ready");
}
}
});
}
/// State + decisions audio_play computed before the sink swap.
pub(crate) struct SinkSwapInputs {
pub(crate) sink: Arc<rodio::Player>,
pub(crate) duration_secs: f64,
pub(crate) volume: f32,
pub(crate) gain_linear: f32,
pub(crate) fadeout_trigger: Arc<AtomicBool>,
pub(crate) fadeout_samples: Arc<AtomicU64>,
pub(crate) crossfade_enabled: bool,
pub(crate) actual_fade_secs: f32,
pub(crate) start_paused: bool,
}
/// Atomically swap the new sink into `state.current`, then handle the old
/// sink: trigger sample-level fade-out (crossfade enabled) or stop it
/// immediately (hard cut). The fade-out is handed off to a small spawned
/// task that drops the old sink ~`actual_fade_secs + 0.5 s` later.
pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapInputs) {
let SinkSwapInputs {
sink,
duration_secs,
volume,
gain_linear,
fadeout_trigger: new_fadeout_trigger,
fadeout_samples: new_fadeout_samples,
crossfade_enabled,
actual_fade_secs,
start_paused,
} = inputs;
let (old_sink, old_fadeout_trigger, old_fadeout_samples) = {
let mut cur = state.current.lock().unwrap();
let old = cur.sink.take();
let old_fo_trigger = cur.fadeout_trigger.take();
let old_fo_samples = cur.fadeout_samples.take();
cur.sink = Some(sink.clone());
cur.duration_secs = duration_secs;
cur.seek_offset = 0.0;
if start_paused {
sink.pause();
cur.play_started = None;
cur.paused_at = Some(0.0);
} else {
cur.play_started = Some(Instant::now());
cur.paused_at = None;
}
cur.replay_gain_linear = gain_linear;
cur.base_volume = volume.clamp(0.0, 1.0);
cur.fadeout_trigger = Some(new_fadeout_trigger);
cur.fadeout_samples = Some(new_fadeout_samples);
(old, old_fo_trigger, old_fo_samples)
};
if crossfade_enabled {
if let Some(old) = old_sink {
// Trigger sample-level fade-out on Track A via TriggeredFadeOut.
// Calculate total fade samples from the measured actual_fade_secs.
let rate = state.current_sample_rate.load(Ordering::Relaxed);
let ch = state.current_channels.load(Ordering::Relaxed);
let fade_total = (actual_fade_secs as f64 * rate as f64 * ch as f64) as u64;
if let (Some(trigger), Some(samples)) = (old_fadeout_trigger, old_fadeout_samples) {
samples.store(fade_total.max(1), Ordering::SeqCst);
trigger.store(true, Ordering::SeqCst);
}
// Keep old sink alive until the fade completes + small margin,
// then drop it. No volume stepping needed — the fade-out runs
// at sample level inside the audio thread.
*state.fading_out_sink.lock().unwrap() = Some(old);
let fo_arc = state.fading_out_sink.clone();
let cleanup_dur = Duration::from_secs_f32(actual_fade_secs + 0.5);
tokio::spawn(async move {
tokio::time::sleep(cleanup_dur).await;
if let Some(s) = fo_arc.lock().unwrap().take() {
s.stop();
}
});
}
} else if let Some(old) = old_sink {
old.stop();
}
}
@@ -0,0 +1,398 @@
//! Source-building pipeline for `audio_play`: turn a resolved [`PlayInput`]
//! into a fully wrapped rodio source, including the ranged-stream probe
//! fallback (wait for / fetch a full download and retry from in-memory bytes
//! when a partial ranged buffer can't be probed yet). Split out of
//! `play_input.rs` so source *selection* stays separate from source *building*.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, State};
use super::analysis_dispatch::{
prepare_playback_analysis, spawn_track_analysis_bytes, TrackAnalysisOrigin,
};
use super::decode::{build_source, build_streaming_source, BuiltSource, SizedDecoder};
use super::engine::AudioEngine;
use super::helpers::{fetch_data, resolve_playback_format_hint, same_playback_target};
use super::play_input::PlayInput;
use super::stream::TRACK_READ_TIMEOUT_SECS;
/// Arguments forwarded from `audio_play` into the source-build pipeline.
/// Bundles the format-hint inputs, playback-shaping parameters and the shared
/// done flag so that `build_playback_source_with_probe_fallback` stays below
/// the `clippy::too_many_arguments` threshold.
pub(crate) struct BuildSourceArgs<'a> {
pub url: &'a str,
pub gen: u64,
pub cache_id_for_tasks: Option<&'a str>,
pub server_id: Option<&'a str>,
pub url_format_hint: Option<&'a str>,
pub stream_format_suffix: Option<&'a str>,
pub done_flag: Arc<AtomicBool>,
pub fade_in_dur: Duration,
pub hi_res_enabled: bool,
pub duration_hint: f64,
}
/// Output of `build_source_from_play_input`: the wrapped rodio source plus
/// whether the chosen source path is seekable (only the Streaming variant
/// is not).
pub(crate) struct PlaybackSource {
pub(crate) built: BuiltSource,
pub(crate) is_seekable: bool,
}
fn play_media_format_hint(input: &PlayInput) -> Option<String> {
match input {
PlayInput::SeekableMedia { format_hint, .. } | PlayInput::Streaming { format_hint, .. } => {
format_hint.clone()
}
PlayInput::Bytes(_) => None,
}
}
/// Ranged HTTP probe/decode failed in a way that may succeed after the
/// background download finishes (moov-at-end, demuxer EOF during partial buffer).
fn is_ranged_stream_probe_failure(err: &str) -> bool {
err.contains("ranged-stream")
&& (err.contains("format probe failed")
|| err.contains("moov metadata")
|| err.contains("end of stream"))
}
/// Completed ranged download or spill file for `url`, if ready.
async fn try_take_completed_stream_bytes(
url: &str,
state: &State<'_, AudioEngine>,
) -> Option<Vec<u8>> {
if let Some(data) = super::helpers::take_stream_completed_for_url(state, url) {
return Some(data);
}
let spill_path = {
let guard = state.stream_completed_spill.lock().unwrap();
guard
.as_ref()
.filter(|p| same_playback_target(&p.url, url))
.map(|p| p.path.clone())
};
if let Some(path) = spill_path {
let data = tokio::fs::read(&path).await.ok()?;
if !data.is_empty() {
return Some(data);
}
}
None
}
/// Ranged assembly can be byte-complete but missing `moov` (holes) or non-audio HTTP body.
async fn prefer_clean_http_bytes_for_fallback(
url: &str,
gen: u64,
state: &State<'_, AudioEngine>,
app: &AppHandle,
ranged_data: Vec<u8>,
format_hint: Option<&str>,
label: &str,
) -> Result<Option<Vec<u8>>, String> {
let is_mp4 = super::stream::container_hint_is_mp4(format_hint);
if is_mp4 {
super::stream::log_isobmff_buffer_diagnostic(&ranged_data, format_hint, label);
if !super::stream::isobmff_buffer_looks_complete(&ranged_data)
|| super::stream::mp4_suspect_zero_holes(&ranged_data)
{
crate::app_deprintln!(
"[stream] ranged buffer looks incomplete or holey — refetching via sequential HTTP"
);
if let Some(fresh) = fetch_data(url, state, gen, app).await? {
if super::stream::isobmff_buffer_looks_complete(&fresh) {
return Ok(Some(fresh));
}
super::stream::log_isobmff_buffer_diagnostic(&fresh, format_hint, "http-refetch");
}
}
}
Ok(Some(ranged_data))
}
/// Wait for the in-flight ranged download to finish, then HTTP-fetch if needed.
async fn wait_or_fetch_bytes_for_stream_fallback(
url: &str,
gen: u64,
state: &State<'_, AudioEngine>,
app: &AppHandle,
format_hint: Option<&str>,
) -> Result<Option<Vec<u8>>, String> {
use std::time::Instant;
let deadline = Instant::now() + Duration::from_secs(TRACK_READ_TIMEOUT_SECS);
loop {
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(None);
}
if let Some(data) = try_take_completed_stream_bytes(url, state).await {
crate::app_deprintln!(
"[stream] full-buffer fallback: using completed download ({} KiB)",
data.len() / 1024
);
return prefer_clean_http_bytes_for_fallback(
url,
gen,
state,
app,
data,
format_hint,
"ranged-cache",
)
.await;
}
if Instant::now() >= deadline {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
crate::app_deprintln!(
"[stream] full-buffer fallback: download still in progress after {}s — HTTP fetch",
TRACK_READ_TIMEOUT_SECS
);
fetch_data(url, state, gen, app).await
}
fn is_in_memory_probe_failure(err: &str) -> bool {
err.contains("format probe failed")
|| err.contains("could not open audio stream")
|| err.contains("no playable audio track")
}
/// Like [`build_source_from_play_input`], but on ranged-stream probe failure waits
/// for a full download (or fetches it) and retries from in-memory bytes.
pub(crate) async fn build_playback_source_with_probe_fallback(
play_input: PlayInput,
args: BuildSourceArgs<'_>,
state: &State<'_, AudioEngine>,
app: &AppHandle,
) -> Result<PlaybackSource, String> {
let BuildSourceArgs {
url,
gen,
cache_id_for_tasks,
server_id,
url_format_hint,
stream_format_suffix,
done_flag,
fade_in_dur,
hi_res_enabled,
duration_hint,
} = args;
let media_hint = play_media_format_hint(&play_input);
let effective_hint = resolve_playback_format_hint(
url_format_hint,
stream_format_suffix,
media_hint.as_deref(),
None,
);
if let Some(ref h) = effective_hint {
crate::app_deprintln!("[stream] playback format hint: {h}");
}
match build_source_from_play_input(
play_input,
state,
effective_hint.as_deref(),
done_flag.clone(),
fade_in_dur,
hi_res_enabled,
duration_hint,
)
.await
{
Ok(p) => Ok(p),
Err(e) if is_ranged_stream_probe_failure(&e) => {
crate::app_deprintln!(
"[stream] ranged-stream probe failed — trying full-buffer fallback: {}",
e
);
let data = match wait_or_fetch_bytes_for_stream_fallback(
url,
gen,
state,
app,
effective_hint.as_deref(),
)
.await?
{
Some(d) => d,
None => return Err(e),
};
if state.generation.load(Ordering::SeqCst) != gen {
return Err("ranged-stream: superseded during full-buffer fallback".into());
}
let bytes_hint = resolve_playback_format_hint(
url_format_hint,
stream_format_suffix,
media_hint.as_deref(),
Some(&data),
);
if bytes_hint.as_ref() != effective_hint.as_ref() {
crate::app_deprintln!(
"[stream] full-buffer fallback: resolved hint {:?} (was {:?})",
bytes_hint,
effective_hint
);
}
if let Some(track_id) = cache_id_for_tasks
.map(str::trim)
.filter(|s| !s.is_empty())
{
let (sid, high) =
prepare_playback_analysis(app, state, server_id, track_id, None);
spawn_track_analysis_bytes(
app.clone(),
TrackAnalysisOrigin::StreamDownloadComplete,
sid,
track_id.to_string(),
data.clone(),
high,
Some((gen, state.generation.clone())),
);
}
match build_source_from_play_input(
PlayInput::Bytes(data.clone()),
state,
bytes_hint.as_deref(),
done_flag.clone(),
fade_in_dur,
hi_res_enabled,
duration_hint,
)
.await
{
Ok(p) => Ok(p),
Err(pe) if is_in_memory_probe_failure(&pe) => {
if super::stream::container_hint_is_mp4(bytes_hint.as_deref()) {
super::stream::log_isobmff_buffer_diagnostic(
&data,
bytes_hint.as_deref(),
"ranged-cache-probe-fail",
);
}
crate::app_deprintln!(
"[stream] in-memory probe failed — sequential HTTP refetch: {}",
pe
);
let fresh = match fetch_data(url, state, gen, app).await? {
Some(d) => d,
None => return Err(pe),
};
if super::stream::container_hint_is_mp4(bytes_hint.as_deref()) {
super::stream::log_isobmff_buffer_diagnostic(
&fresh,
bytes_hint.as_deref(),
"http-refetch-after-probe-fail",
);
}
build_source_from_play_input(
PlayInput::Bytes(fresh),
state,
bytes_hint.as_deref(),
done_flag,
fade_in_dur,
hi_res_enabled,
duration_hint,
)
.await
}
Err(pe) => Err(pe),
}
}
Err(e) => Err(e),
}
}
/// Dispatch [`PlayInput`] → fully wrapped rodio source. For Bytes the full
/// in-memory pipeline (incl. iTunSMPB scan); for SeekableMedia / Streaming
/// the streaming variant runs the decoder build on a blocking thread.
async fn build_source_from_play_input(
play_input: PlayInput,
state: &State<'_, AudioEngine>,
format_hint: Option<&str>,
done_flag: Arc<AtomicBool>,
fade_in_dur: Duration,
hi_res_enabled: bool,
duration_hint: f64,
) -> Result<PlaybackSource, String> {
// Always 0 — no application-level resampling. Rodio handles conversion to
// the output device rate internally; we let every track play at its native rate.
let target_rate: u32 = 0;
let mut is_seekable = true;
let built = match play_input {
PlayInput::Bytes(data) => build_source(
data,
duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
state.eq_pre_gain.clone(),
state.playback_rate.clone(),
done_flag,
fade_in_dur,
state.samples_played.clone(),
target_rate,
format_hint,
hi_res_enabled,
),
PlayInput::SeekableMedia {
reader,
format_hint: media_hint,
tag,
mp4_probe_gate,
} => {
if let Some(gate) = mp4_probe_gate.as_ref() {
super::stream::wait_for_ranged_mp4_probe_ready(gate).await?;
if gate.gen_arc.load(Ordering::SeqCst) != gate.gen {
return Err("ranged-stream: superseded before moov metadata ready".into());
}
}
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag)
})
.await
.map_err(|e| e.to_string())??;
build_streaming_source(
decoder,
duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
state.eq_pre_gain.clone(),
state.playback_rate.clone(),
done_flag,
fade_in_dur,
state.samples_played.clone(),
target_rate,
None,
)
}
PlayInput::Streaming { reader, format_hint: stream_hint } => {
is_seekable = false;
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(Box::new(reader), stream_hint.as_deref(), "track-stream")
})
.await
.map_err(|e| e.to_string())??;
build_streaming_source(
decoder,
duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
state.eq_pre_gain.clone(),
state.playback_rate.clone(),
done_flag,
fade_in_dur,
state.samples_played.clone(),
target_rate,
Some(state.stream_playback_armed.clone()),
)
}
}?;
Ok(PlaybackSource { built, is_seekable })
}
@@ -0,0 +1,306 @@
//! Release the CPAL/rodio output stream after playback has been idle so the OS
//! can sleep (issue #1071 — Windows `powercfg` "audio stream in use").
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, Manager};
use super::engine::AudioEngine;
/// Wall-clock idle period before closing the output device handle.
pub(crate) const OUTPUT_STREAM_IDLE_RELEASE_SECS: u64 = 60;
const IDLE_POLL_SECS: u64 = 5;
/// Returns true while the app must keep an open output stream (playing, preview, crossfade).
pub(crate) fn output_stream_is_needed(engine: &AudioEngine) -> bool {
if engine.preview_sink.lock().unwrap().is_some() {
return true;
}
if engine.fading_out_sink.lock().unwrap().is_some() {
return true;
}
let cur = engine.current.lock().unwrap();
if let Some(sink) = &cur.sink {
if sink.empty() {
return false;
}
if cur.play_started.is_some() && cur.paused_at.is_none() {
return true;
}
}
if let Some(rs) = engine.radio_state.lock().unwrap().as_ref() {
if !rs.flags.is_paused.load(Ordering::Relaxed) {
return true;
}
}
false
}
/// Stop sinks tied to the open stream; keep pause position / URLs for cold resume.
pub(crate) fn teardown_playback_sinks_for_idle_release(engine: &AudioEngine) {
if let Some(s) = engine.preview_sink.lock().unwrap().take() {
s.stop();
}
if let Some(s) = engine.fading_out_sink.lock().unwrap().take() {
s.stop();
}
let mut cur = engine.current.lock().unwrap();
if let Some(s) = cur.sink.take() {
s.stop();
}
cur.play_started = None;
}
fn close_output_device_handle(engine: &AudioEngine, app: &AppHandle) -> Result<(), String> {
super::engine::request_stream_release(engine)?;
*engine.stream_handle.lock().unwrap() = None;
let _ = app.emit("audio:output-released", ());
Ok(())
}
/// Release the output device after the idle timer (pause with no other active audio).
pub(crate) fn release_output_stream(
engine: &AudioEngine,
app: &AppHandle,
) -> Result<(), String> {
if engine.stream_handle.lock().unwrap().is_none() {
return Ok(());
}
teardown_playback_sinks_for_idle_release(engine);
close_output_device_handle(engine, app)?;
crate::app_eprintln!(
"[psysonic] audio output stream released after {}s idle",
OUTPUT_STREAM_IDLE_RELEASE_SECS
);
Ok(())
}
/// Release immediately on explicit stop / queue end — do not wait for the idle timer.
pub(crate) fn release_output_stream_on_stop(
engine: &AudioEngine,
app: &AppHandle,
) -> Result<(), String> {
if engine.stream_handle.lock().unwrap().is_none() {
return Ok(());
}
// `audio_stop` already tore down the main sink; clear any crossfade/preview tail
// still tied to the open device before closing the handle.
if engine.preview_sink.lock().unwrap().is_some()
|| engine.fading_out_sink.lock().unwrap().is_some()
{
teardown_playback_sinks_for_idle_release(engine);
}
close_output_device_handle(engine, app)?;
crate::app_eprintln!("[psysonic] audio output stream released on stop");
Ok(())
}
/// Resolves the engine from `app` each poll (the engine is managed Tauri state),
/// so it takes only the `AppHandle` — no engine reference is needed here.
pub fn start_stream_idle_watcher(app: AppHandle) {
tauri::async_runtime::spawn(async move {
let mut idle_since: Option<Instant> = None;
loop {
tokio::time::sleep(Duration::from_secs(IDLE_POLL_SECS)).await;
let Some(state) = app.try_state::<AudioEngine>() else {
idle_since = None;
continue;
};
let engine = state.inner();
let stream_open = engine.stream_handle.lock().unwrap().is_some();
if !stream_open {
idle_since = None;
continue;
}
if output_stream_is_needed(engine) {
idle_since = None;
continue;
}
let since = *idle_since.get_or_insert_with(Instant::now);
if since.elapsed() < Duration::from_secs(OUTPUT_STREAM_IDLE_RELEASE_SECS) {
continue;
}
let _ = release_output_stream(engine, &app);
idle_since = None;
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64};
use std::sync::{Arc, Mutex, RwLock};
use ringbuf::HeapCons;
use rodio::source::Zero;
use rodio::{ChannelCount, Player, SampleRate};
use super::super::engine::AudioCurrent;
use super::super::playback_rate::PlaybackRateAtomics;
use super::super::stream::{RadioLiveState, RadioSharedFlags};
/// A device-less rodio `Player` with one infinite source appended, so
/// `empty()` reports `false` without ever opening an output device.
/// Returns the queue output too — keep it alive for the test's duration.
fn nonempty_player() -> (Arc<Player>, rodio::queue::SourcesQueueOutput) {
let (player, out) = Player::new();
player.append(Zero::new(
ChannelCount::new(2).unwrap(),
SampleRate::new(44_100).unwrap(),
));
(Arc::new(player), out)
}
fn radio_session(is_paused: bool) -> RadioLiveState {
let (tx, _rx) = std::sync::mpsc::channel::<HeapCons<u8>>();
RadioLiveState {
url: "http://example.test/stream".to_string(),
gen: 0,
// Detached no-op task; never polled. Drop just aborts it.
task: tokio::spawn(async {}),
flags: Arc::new(RadioSharedFlags {
is_paused: AtomicBool::new(is_paused),
is_hard_paused: AtomicBool::new(false),
new_cons_tx: Mutex::new(tx),
}),
}
}
fn minimal_engine() -> AudioEngine {
let (stream_thread_tx, _) = std::sync::mpsc::sync_channel(0);
AudioEngine {
stream_handle: Arc::new(Mutex::new(None)),
stream_sample_rate: Arc::new(AtomicU32::new(0)),
device_default_rate: 48_000,
stream_thread_tx,
selected_device: Arc::new(Mutex::new(None)),
current: Arc::new(Mutex::new(AudioCurrent {
sink: None,
duration_secs: 0.0,
seek_offset: 0.0,
play_started: None,
paused_at: None,
replay_gain_linear: 1.0,
base_volume: 0.8,
fadeout_trigger: None,
fadeout_samples: None,
})),
generation: Arc::new(AtomicU64::new(0)),
http_client: Arc::new(RwLock::new(reqwest::Client::new())),
eq_gains: Arc::new(std::array::from_fn(|_| AtomicU32::new(0))),
eq_enabled: Arc::new(AtomicBool::new(false)),
eq_pre_gain: Arc::new(AtomicU32::new(0)),
playback_rate: PlaybackRateAtomics::new(),
preloaded: Arc::new(Mutex::new(None)),
stream_completed_cache: Arc::new(Mutex::new(None)),
stream_completed_spill: Arc::new(Mutex::new(None)),
current_is_seekable: Arc::new(AtomicBool::new(true)),
stream_playback_armed: Arc::new(AtomicBool::new(true)),
crossfade_enabled: Arc::new(AtomicBool::new(false)),
crossfade_secs: Arc::new(AtomicU32::new(0)),
fading_out_sink: Arc::new(Mutex::new(None)),
gapless_enabled: Arc::new(AtomicBool::new(false)),
normalization_engine: Arc::new(AtomicU32::new(0)),
normalization_target_lufs: Arc::new(AtomicU32::new(0)),
loudness_pre_analysis_attenuation_db: Arc::new(AtomicU32::new(0)),
chained_info: Arc::new(Mutex::new(None)),
samples_played: Arc::new(AtomicU64::new(0)),
current_sample_rate: Arc::new(AtomicU32::new(44_100)),
current_channels: Arc::new(AtomicU32::new(2)),
gapless_switch_at: Arc::new(AtomicU64::new(0)),
radio_state: Mutex::new(None),
current_playback_url: Arc::new(Mutex::new(None)),
current_analysis_track_id: Arc::new(Mutex::new(None)),
current_playback_server_id: Arc::new(Mutex::new(None)),
ranged_loudness_seed_hold: Arc::new(Mutex::new(None)),
preview_sink: Arc::new(Mutex::new(None)),
preview_gen: Arc::new(AtomicU64::new(0)),
preview_main_resume: Arc::new(AtomicBool::new(false)),
preview_song_id: Arc::new(Mutex::new(None)),
}
}
#[test]
fn idle_when_no_sink_and_no_preview() {
let engine = minimal_engine();
assert!(!output_stream_is_needed(&engine));
}
#[test]
fn idle_when_sink_empty() {
// A live but drained main sink (track finished) must not pin the device.
let (player, _out) = Player::new(); // no source appended → empty()
let player = Arc::new(player);
let engine = minimal_engine();
{
let mut cur = engine.current.lock().unwrap();
cur.sink = Some(player);
cur.play_started = Some(Instant::now());
cur.paused_at = None;
}
assert!(!output_stream_is_needed(&engine));
}
#[test]
fn needed_when_sink_playing() {
let (sink, _out) = nonempty_player();
let engine = minimal_engine();
{
let mut cur = engine.current.lock().unwrap();
cur.sink = Some(sink);
cur.play_started = Some(Instant::now());
cur.paused_at = None; // actively playing
}
assert!(output_stream_is_needed(&engine));
}
#[test]
fn idle_when_sink_paused() {
// Non-empty sink but paused (paused_at set) — the idle watcher may release.
let (sink, _out) = nonempty_player();
let engine = minimal_engine();
{
let mut cur = engine.current.lock().unwrap();
cur.sink = Some(sink);
cur.play_started = Some(Instant::now());
cur.paused_at = Some(12.0);
}
assert!(!output_stream_is_needed(&engine));
}
#[test]
fn needed_when_preview_sink_present() {
let (sink, _out) = nonempty_player();
let engine = minimal_engine();
*engine.preview_sink.lock().unwrap() = Some(sink);
assert!(output_stream_is_needed(&engine));
}
#[test]
fn needed_when_fading_out_sink_present() {
let (sink, _out) = nonempty_player();
let engine = minimal_engine();
*engine.fading_out_sink.lock().unwrap() = Some(sink);
assert!(output_stream_is_needed(&engine));
}
#[tokio::test]
async fn needed_when_radio_playing() {
let engine = minimal_engine();
*engine.radio_state.lock().unwrap() = Some(radio_session(false));
assert!(output_stream_is_needed(&engine));
}
#[tokio::test]
async fn idle_when_radio_paused() {
let engine = minimal_engine();
*engine.radio_state.lock().unwrap() = Some(radio_session(true));
assert!(!output_stream_is_needed(&engine));
}
}
@@ -130,6 +130,8 @@ pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) {
cur.seek_offset = 0.0;
cur.play_started = None;
cur.paused_at = None;
drop(cur);
let _ = super::stream_idle::release_output_stream_on_stop(state.inner(), &app);
}
#[tauri::command]
@@ -287,11 +287,31 @@ pub async fn resolve_stream_url(url: String) -> String {
resolve_playlist_url(&client, &url).await.unwrap_or(url)
}
/// Proxy Last.fm API calls through Rust/reqwest to avoid WebView networking restrictions.
/// `params` is a list of [key, value] pairs (method must be included).
/// If `sign` is true an api_sig is computed. If `get` is true, a GET request is made.
/// Default Audioscrobbler v2 endpoint (Last.fm). Other presets (Libre.fm,
/// Rocksky, GNU FM, Maloja compat) pass their own `base_url`.
const LASTFM_API_BASE: &str = "https://ws.audioscrobbler.com/2.0/";
/// Generic Audioscrobbler v2 transport. Provider-agnostic: the caller supplies
/// the endpoint `base_url`, so Last.fm, Libre.fm, Rocksky, custom GNU FM and the
/// Shared HTTP client for the Music Network provider transports
/// (audioscrobbler / listenbrainz / maloja). A bounded timeout keeps a hung
/// provider from leaving scrobble/probe/loved-sync promises unresolved — the
/// sibling `fetch_*` commands in this module set the same kind of bound.
fn provider_http_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| e.to_string())
}
/// Maloja Audioscrobbler-compat surface all share this one command.
///
/// `params` is a list of [key, value] pairs (method must be included). If `sign`
/// is true an `api_sig` is computed (MD5 of sorted params + secret). If `get` is
/// true a GET request is made, otherwise a form POST.
#[tauri::command]
pub async fn lastfm_request(
pub async fn audioscrobbler_request(
base_url: String,
params: Vec<[String; 2]>,
sign: bool,
get: bool,
@@ -300,6 +320,8 @@ pub async fn lastfm_request(
) -> Result<serde_json::Value, String> {
use std::collections::HashMap;
let base = if base_url.trim().is_empty() { LASTFM_API_BASE.to_string() } else { base_url };
let mut map: HashMap<String, String> = params.into_iter().map(|[k, v]| (k, v)).collect();
map.insert("api_key".into(), api_key.clone());
@@ -317,17 +339,17 @@ pub async fn lastfm_request(
map.insert("format".into(), "json".into());
let client = reqwest::Client::new();
let client = provider_http_client()?;
let resp = if get {
client
.get("https://ws.audioscrobbler.com/2.0/")
.get(&base)
.query(&map)
.header("User-Agent", subsonic_wire_user_agent())
.send()
.await
} else {
client
.post("https://ws.audioscrobbler.com/2.0/")
.post(&base)
.form(&map)
.header("User-Agent", subsonic_wire_user_agent())
.send()
@@ -337,7 +359,88 @@ pub async fn lastfm_request(
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
if let Some(err) = json.get("error") {
return Err(format!("Last.fm {} {}", err, json.get("message").and_then(|m| m.as_str()).unwrap_or("")));
return Err(format!("Audioscrobbler {} {}", err, json.get("message").and_then(|m| m.as_str()).unwrap_or("")));
}
Ok(json)
}
/// Generic ListenBrainz transport. Used by both the direct
/// `api.listenbrainz.org` preset and the Maloja `/apis/listenbrainz` compat
/// surface — they differ only by `base_url`. Auth is a `Token` header.
///
/// `path` is appended to `base_url` (e.g. `/1/submit-listens`). When `json_body`
/// is present the request is a POST with that body; otherwise a GET.
#[tauri::command]
pub async fn listenbrainz_request(
base_url: String,
path: String,
auth_token: String,
json_body: Option<serde_json::Value>,
) -> Result<serde_json::Value, String> {
let url = format!("{}{}", base_url.trim_end_matches('/'), path);
let client = provider_http_client()?;
let mut req = if json_body.is_some() {
client.post(&url)
} else {
client.get(&url)
};
req = req
.header("Authorization", format!("Token {}", auth_token))
.header("User-Agent", subsonic_wire_user_agent());
if let Some(body) = json_body {
req = req.json(&body);
}
let resp = req.send().await.map_err(|e| e.to_string())?;
let status = resp.status();
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
if !status.is_success() {
let msg = json.get("error").and_then(|m| m.as_str()).unwrap_or("");
return Err(format!("ListenBrainz {} {}", status.as_u16(), msg));
}
Ok(json)
}
/// Generic Maloja native (`/apis/mlj_1`) transport. Protocol-agnostic JSON:
/// the caller builds the body (including the Maloja key) and chooses the path.
///
/// `path` is appended to `base_url`. When `json_body` is present the request is a
/// POST with that body; otherwise a GET with `query` pairs.
#[tauri::command]
pub async fn maloja_request(
base_url: String,
path: String,
query: Vec<[String; 2]>,
json_body: Option<serde_json::Value>,
) -> Result<serde_json::Value, String> {
let url = format!("{}{}", base_url.trim_end_matches('/'), path);
let client = provider_http_client()?;
let resp = if let Some(body) = json_body {
client.post(&url).json(&body)
} else {
let q: Vec<(String, String)> = query.into_iter().map(|[k, v]| (k, v)).collect();
client.get(&url).query(&q)
}
.header("User-Agent", subsonic_wire_user_agent())
.send()
.await
.map_err(|e| e.to_string())?;
let status = resp.status();
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
if !status.is_success() {
let msg = json
.get("error")
.and_then(|e| e.get("desc").or_else(|| e.get("type")))
.and_then(|m| m.as_str())
.unwrap_or("");
return Err(format!("Maloja {} {}", status.as_u16(), msg));
}
Ok(json)
@@ -495,4 +598,66 @@ mod tests {
Some("https://pls.example/audio".to_string())
);
}
// ── audioscrobbler_request ────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread")]
async fn audioscrobbler_request_uses_custom_base_url() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(wm_path("/2.0/"))
.respond_with(ResponseTemplate::new(200).set_body_raw(
r#"{"similarartists":{"artist":[{"name":"Boards of Canada"}]}}"#,
"application/json",
))
.mount(&server)
.await;
let base = format!("{}/2.0/", server.uri());
let json = audioscrobbler_request(
base,
vec![
["method".into(), "artist.getSimilar".into()],
["artist".into(), "Aphex Twin".into()],
],
false,
true,
"key".into(),
"secret".into(),
)
.await
.expect("request should succeed");
assert_eq!(
json["similarartists"]["artist"][0]["name"].as_str(),
Some("Boards of Canada")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn audioscrobbler_request_surfaces_api_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(wm_path("/2.0/"))
.respond_with(ResponseTemplate::new(200).set_body_raw(
r#"{"error":9,"message":"Invalid session key"}"#,
"application/json",
))
.mount(&server)
.await;
let base = format!("{}/2.0/", server.uri());
let err = audioscrobbler_request(
base,
vec![["method".into(), "track.scrobble".into()]],
true,
false,
"key".into(),
"secret".into(),
)
.await
.expect_err("api error should map to Err");
assert!(err.contains("Audioscrobbler 9"), "unexpected error: {err}");
}
}
@@ -248,6 +248,23 @@ CREATE TABLE play_session (
CHECK (completion IN ('partial', 'full'))
);
CREATE TABLE track_genre (
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
genre TEXT NOT NULL,
album_id TEXT,
library_id TEXT,
PRIMARY KEY (server_id, track_id, genre COLLATE NOCASE),
FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id) ON DELETE CASCADE
);
CREATE TABLE library_data_migration (
id TEXT PRIMARY KEY,
cursor_rowid INTEGER NOT NULL DEFAULT 0,
completed_at INTEGER,
started_at INTEGER
);
CREATE INDEX idx_track_album ON track(server_id, album_id) WHERE deleted = 0;
CREATE INDEX idx_track_artist ON track(server_id, artist_id) WHERE deleted = 0;
CREATE INDEX idx_track_updated ON track(server_id, server_updated_at DESC) WHERE deleted = 0;
@@ -293,3 +310,7 @@ CREATE INDEX idx_play_session_started
CREATE INDEX idx_track_fact_mood_tag
ON track_fact(server_id, fact_kind, value_text, track_id)
WHERE fact_kind = 'mood_tag';
CREATE INDEX idx_track_genre_browse
ON track_genre(server_id, genre COLLATE NOCASE, album_id, track_id)
WHERE album_id IS NOT NULL AND album_id != '';
@@ -1,6 +0,0 @@
-- psysonic-library schema v2 — large-library ingest policy (R7-15).
-- Per-server learned flag: when N1 (`/api/song`) returns HTTP 500 beyond a
-- deep offset on a large catalog, the strategy selector stops choosing N1 for
-- that server on future initial syncs (spec §6.3 / R7-15 Q1/Q5). Additive
-- column, DEFAULT 0 → existing rows keep N1 eligible until they hit the wall.
ALTER TABLE sync_state ADD COLUMN n1_bulk_unreliable INTEGER NOT NULL DEFAULT 0;
@@ -1,10 +0,0 @@
-- Remap detection (§6.9) and unstable-id servers: without these indexes
-- each upsert in a 500-row batch can scan the whole track table.
CREATE INDEX IF NOT EXISTS idx_track_remap_path
ON track(server_id, server_path)
WHERE deleted = 0 AND server_path IS NOT NULL AND server_path != '';
CREATE INDEX IF NOT EXISTS idx_track_remap_hash
ON track(server_id, content_hash)
WHERE deleted = 0 AND content_hash IS NOT NULL AND content_hash != '';
@@ -1,4 +0,0 @@
-- Browse / sort-by-title without sorting the full server slice on every page.
CREATE INDEX IF NOT EXISTS idx_track_title
ON track(server_id, title COLLATE NOCASE)
WHERE deleted = 0;
@@ -1,8 +0,0 @@
-- Advanced search filters on genre and year (partial indexes — only non-null rows).
CREATE INDEX IF NOT EXISTS idx_track_genre
ON track(server_id, genre COLLATE NOCASE)
WHERE deleted = 0 AND genre IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_track_year
ON track(server_id, year)
WHERE deleted = 0 AND year IS NOT NULL;
@@ -1,22 +0,0 @@
-- Player listening history — see workdocs player-stats spec §3.1
CREATE TABLE play_session (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
started_at_ms INTEGER NOT NULL,
listened_sec REAL NOT NULL,
position_max_sec REAL NOT NULL,
completion TEXT NOT NULL,
end_reason TEXT NOT NULL,
FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id),
CHECK (completion IN ('partial', 'full'))
);
CREATE INDEX idx_play_session_server_time
ON play_session(server_id, started_at_ms DESC);
CREATE INDEX idx_play_session_track
ON play_session(server_id, track_id, started_at_ms DESC);
CREATE INDEX idx_play_session_started
ON play_session(started_at_ms DESC);
@@ -1,4 +0,0 @@
-- Full-resync orphan sweep (mark-and-sweep via generation stamp).
-- Rows ingested during a resync pass carry the active `resync_gen`; after
-- IS-6 succeeds, live rows with a stale generation are soft-deleted.
ALTER TABLE track ADD COLUMN resync_gen INTEGER NOT NULL DEFAULT 0;
@@ -1,4 +0,0 @@
-- Atomic mood tags for Advanced Search (EXISTS on track_fact).
CREATE INDEX IF NOT EXISTS idx_track_fact_mood_tag
ON track_fact(server_id, fact_kind, value_text, track_id)
WHERE fact_kind = 'mood_tag';
@@ -1,3 +0,0 @@
-- Oximedia mood heuristics were misleading; drop accumulated mood facts.
DELETE FROM track_fact
WHERE fact_kind IN ('mood_tag', 'moods', 'valence', 'arousal', 'mood_labels');
@@ -1,8 +0,0 @@
-- Genre album browse: filter by (server, genre) then group by album_id.
CREATE INDEX IF NOT EXISTS idx_track_genre_album_browse
ON track(server_id, genre COLLATE NOCASE, album_id)
WHERE deleted = 0
AND genre IS NOT NULL
AND TRIM(genre) != ''
AND album_id IS NOT NULL
AND album_id != '';
@@ -1,8 +0,0 @@
-- Genre album browse sort: (server, genre, album name, album_id) covering walk.
CREATE INDEX IF NOT EXISTS idx_track_genre_album_name_browse
ON track(server_id, genre COLLATE NOCASE, album COLLATE NOCASE, album_id)
WHERE deleted = 0
AND genre IS NOT NULL
AND TRIM(genre) != ''
AND album_id IS NOT NULL
AND album_id != '';
@@ -0,0 +1,22 @@
-- Repair for DBs that recorded legacy migrations 002011 (removed) before
-- multi-genre tables shipped. Safe on fresh installs (IF NOT EXISTS).
CREATE TABLE IF NOT EXISTS track_genre (
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
genre TEXT NOT NULL,
album_id TEXT,
library_id TEXT,
PRIMARY KEY (server_id, track_id, genre COLLATE NOCASE),
FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_track_genre_browse
ON track_genre(server_id, genre COLLATE NOCASE, album_id, track_id)
WHERE album_id IS NOT NULL AND album_id != '';
CREATE TABLE IF NOT EXISTS library_data_migration (
id TEXT PRIMARY KEY,
cursor_rowid INTEGER NOT NULL DEFAULT 0,
completed_at INTEGER,
started_at INTEGER
);
@@ -49,6 +49,7 @@ type AlbumBrowseTrackRow = (
String,
Option<String>,
Option<String>,
Option<String>,
Option<i64>,
Option<String>,
Option<String>,
@@ -648,8 +649,8 @@ fn build_album_from_fts(
let where_sql = w.where_sql();
store.with_read_conn(|conn| {
let sql = format!(
"SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.year, \
t.genre, t.cover_art_id, t.starred_at, t.synced_at \
"SELECT t.server_id, t.album_id, t.album, t.artist, t.album_artist, t.artist_id, \
t.year, t.genre, t.cover_art_id, t.starred_at, t.synced_at \
FROM track t \
WHERE {where_sql}"
);
@@ -668,13 +669,27 @@ fn build_album_from_fts(
r.get(7)?,
r.get(8)?,
r.get(9)?,
r.get(10)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let mut seen = HashSet::new();
let mut deduped: Vec<LibraryAlbumDto> = Vec::new();
for (server_id, album_id, album, artist, artist_id, year, genre, cover_art_id, starred_at, synced_at) in rows {
for (
server_id,
album_id,
album,
track_artist,
album_artist,
artist_id,
year,
genre,
cover_art_id,
starred_at,
synced_at,
) in rows
{
if !seen.insert(album_id.clone()) {
continue;
}
@@ -682,7 +697,10 @@ fn build_album_from_fts(
server_id,
id: album_id,
name: album,
artist,
artist: crate::album_compilation_filter::pick_album_group_artist(
track_artist,
album_artist,
),
artist_id,
song_count: None,
duration_sec: None,
@@ -882,8 +900,25 @@ fn resolve_clause(
if c.field == "genre" {
let v = json_to_text(&c.field, c.value.as_ref())?;
let sql = match entity {
EntityKind::Track => {
"EXISTS (SELECT 1 FROM track_genre tg \
WHERE tg.server_id = t.server_id AND tg.track_id = t.id \
AND tg.genre = ? COLLATE NOCASE)"
.to_string()
}
EntityKind::Album => {
"EXISTS (SELECT 1 FROM track_genre tg \
WHERE tg.server_id = a.server_id AND tg.album_id = a.id \
AND tg.genre = ? COLLATE NOCASE)"
.to_string()
}
_ => {
return Err(filter::FilterError::NotQueryable(c.field.clone()).to_string());
}
};
return Ok(Some(SqlFragment {
sql: format!("{col} = ? COLLATE NOCASE"),
sql,
params: vec![v],
}));
}
@@ -2067,6 +2102,24 @@ mod tests {
assert_eq!(resp.albums[0].artist.as_deref(), Some("Various Artists"));
}
#[test]
fn track_grouped_album_browse_prefers_album_artist_over_track_artist() {
let store = LibraryStore::open_in_memory();
let mut t1 = track("s1", "t1", "Anthem", "Groove Armada", "Back to Mine");
t1.album_id = Some("al_mix".into());
t1.album_artist = Some("Underworld".into());
let mut t2 = track("s1", "t2", "Zebra", "UNKLE", "Back to Mine");
t2.album_id = Some("al_mix".into());
t2.album_artist = Some("Underworld".into());
TrackRepository::new(&store)
.upsert_batch(&[t1, t2])
.unwrap();
let r = req("s1", &[EntityKind::Album]);
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].artist.as_deref(), Some("Underworld"));
}
#[test]
fn compilation_filter_on_track_grouped_album_browse() {
let store = LibraryStore::open_in_memory();
@@ -49,16 +49,30 @@ 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.
/// SQL mirror of [`pick_album_group_artist`] for track-grouped browse subqueries
/// (`la`). Used where `ORDER BY` / `COALESCE(a.artist, …)` must stay in SQL;
/// keep both implementations in sync.
pub fn sql_track_group_display_artist(alias: &str) -> String {
format!(
"CASE WHEN trim(coalesce({a}.album_artist, '')) != '' \
THEN trim({a}.album_artist) \
ELSE NULLIF(trim(coalesce({a}.artist, '')), '') END",
a = alias
)
}
/// Row-mapper form of the album-artist display rule — mirror of
/// [`sql_track_group_display_artist`]. Prefer a non-empty album-artist tag;
/// fall back to track artist only when album artist is absent (solo albums without TALB).
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) {
if !aa.is_empty() {
return Some(aa.to_string());
}
track_artist
track_artist.filter(|s| !s.trim().is_empty())
}
#[cfg(test)]
@@ -81,14 +95,70 @@ mod tests {
}
#[test]
fn pick_album_group_artist_prefers_va_album_artist() {
fn pick_album_group_artist_prefers_nonempty_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("Groove Armada".into()), Some("Underworld".into())),
Some("Underworld".to_string())
);
assert_eq!(
pick_album_group_artist(Some("Alice".into()), Some("Bob".into())),
Some("Alice".to_string())
Some("Bob".to_string())
);
}
#[test]
fn pick_album_group_artist_falls_back_to_track_artist() {
assert_eq!(
pick_album_group_artist(Some("Alice".into()), None),
Some("Alice".to_string())
);
assert_eq!(
pick_album_group_artist(Some("Alice".into()), Some("".into())),
Some("Alice".to_string())
);
assert_eq!(pick_album_group_artist(None, None), None);
}
#[test]
fn sql_track_group_display_artist_matches_pick_album_group_artist() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute(
"CREATE TABLE la (artist TEXT, album_artist TEXT)",
[],
)
.unwrap();
let sql = format!("SELECT {} FROM la", sql_track_group_display_artist("la"));
let cases: [(&str, &str); 7] = [
("Groove Armada", "Underworld"),
("Alice", ""),
("", "Various Artists"),
("Alice", "Bob"),
(" ", "Bob"),
("Alice", " "),
("", ""),
];
for (track, album) in cases {
conn.execute("DELETE FROM la", []).unwrap();
conn.execute(
"INSERT INTO la (artist, album_artist) VALUES (?1, ?2)",
rusqlite::params![track, album],
)
.unwrap();
let sql_out: Option<String> = conn.query_row(&sql, [], |r| r.get(0)).ok();
let rust_out = pick_album_group_artist(
(!track.is_empty()).then(|| track.to_string()),
(!album.is_empty()).then(|| album.to_string()),
);
assert_eq!(
sql_out, rust_out,
"track={track:?} album={album:?}"
);
}
}
}
@@ -81,12 +81,13 @@ pub fn get_artist_lossless_browse(
}
let album_where_sql = album_where.join(" AND ");
let la_artist = crate::album_compilation_filter::sql_track_group_display_artist("la");
let albums_sql = format!(
"SELECT \
la.server_id, \
la.album_id, \
COALESCE(a.name, la.album_name), \
COALESCE(a.artist, la.artist), \
COALESCE(a.artist, {la_artist}), \
COALESCE(a.artist_id, la.artist_id), \
COALESCE(a.song_count, la.track_count), \
COALESCE(a.duration_sec, la.duration_sec), \
@@ -102,6 +103,7 @@ pub fn get_artist_lossless_browse(
t.album_id, \
MAX(t.album) AS album_name, \
MAX(t.artist) AS artist, \
MAX(t.album_artist) AS album_artist, \
MAX(t.artist_id) AS artist_id, \
MAX(t.year) AS year, \
MAX(t.genre) AS genre, \
@@ -113,13 +113,26 @@ pub(crate) fn genre_album_counts_for_server(
) -> Result<Vec<GenreAlbumCountDto>, String> {
store
.with_read_conn(|conn| {
let mut sql = String::from(
"SELECT t.genre, COUNT(DISTINCT t.album_id) AS album_count, COUNT(*) AS song_count \
FROM track t \
WHERE t.server_id = ?1 AND t.deleted = 0 \
AND t.genre IS NOT NULL AND TRIM(t.genre) != '' \
AND t.album_id IS NOT NULL AND t.album_id != ''",
);
let scoped = library_scope.is_some_and(|s| !s.trim().is_empty());
let mut sql = if scoped {
String::from(
"SELECT tg.genre, COUNT(DISTINCT tg.album_id) AS album_count, \
COUNT(DISTINCT tg.track_id) AS song_count \
FROM track_genre tg \
INNER JOIN track t \
ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0 \
WHERE tg.server_id = ?1 \
AND tg.album_id IS NOT NULL AND tg.album_id != ''",
)
} else {
String::from(
"SELECT tg.genre, COUNT(DISTINCT tg.album_id) AS album_count, \
COUNT(DISTINCT tg.track_id) AS song_count \
FROM track_genre tg \
WHERE tg.server_id = ?1 \
AND tg.album_id IS NOT NULL AND tg.album_id != ''",
)
};
let mut params: Vec<rusqlite::types::Value> =
vec![rusqlite::types::Value::Text(server_id.to_string())];
if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) {
@@ -127,8 +140,8 @@ pub(crate) fn genre_album_counts_for_server(
params.push(rusqlite::types::Value::Text(scope.to_string()));
}
sql.push_str(
" GROUP BY t.genre COLLATE NOCASE \
ORDER BY album_count DESC, t.genre COLLATE NOCASE ASC",
" GROUP BY tg.genre COLLATE NOCASE \
ORDER BY album_count DESC, tg.genre COLLATE NOCASE ASC",
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
@@ -337,6 +350,26 @@ mod tests {
assert_eq!(counts[0].song_count, 1);
}
#[test]
fn genre_album_counts_scope_reads_library_id_from_track_raw_json() {
let store = Arc::new(LibraryStore::open_in_memory());
let mut scoped = make_row("s1", "r1", "al_a", 1);
scoped.genre = Some("Rock".into());
scoped.library_id = None;
scoped.raw_json = r#"{"libraryId":"lib1"}"#.into();
let mut other = make_row("s1", "r2", "al_b", 1);
other.genre = Some("Rock".into());
other.library_id = None;
other.raw_json = r#"{"libraryId":"lib2"}"#.into();
TrackRepository::new(&store)
.upsert_batch(&[scoped, other])
.unwrap();
let counts = genre_album_counts_for_server(&store, "s1", Some("lib1")).unwrap();
assert_eq!(counts.len(), 1);
assert_eq!(counts[0].album_count, 1);
}
#[test]
fn reconcile_album_stars_clears_all_when_server_list_empty() {
let store = Arc::new(LibraryStore::open_in_memory());
@@ -526,6 +526,23 @@ pub async fn library_list_albums_by_genre(
.await
}
#[tauri::command]
pub fn library_genre_tags_inspect(
runtime: State<'_, LibraryRuntime>,
) -> Result<crate::genre_tags_backfill::GenreTagsInspectDto, String> {
crate::genre_tags_backfill::inspect_genre_tags_backfill(&runtime.store)
}
#[tauri::command]
pub async fn library_genre_tags_run(
app: tauri::AppHandle,
runtime: State<'_, LibraryRuntime>,
) -> Result<(), String> {
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || crate::genre_tags_backfill::run_genre_tags_backfill(&store, &app))
.await
}
#[tauri::command]
pub async fn library_get_artist_lossless_browse(
runtime: State<'_, LibraryRuntime>,
@@ -19,19 +19,20 @@ fn trimmed_nonempty(s: Option<&str>) -> Option<String> {
}
fn genre_album_order_sql(sort: &[LibrarySortClause]) -> String {
let la_artist = crate::album_compilation_filter::sql_track_group_display_artist("la");
let mut keys: Vec<String> = Vec::new();
for s in sort {
let col = match s.field.as_str() {
"name" => "COALESCE(a.name, la.album_name) COLLATE NOCASE",
"artist" => "COALESCE(a.artist, la.artist) COLLATE NOCASE",
"year" => "COALESCE(a.year, la.year)",
"name" => "COALESCE(a.name, la.album_name) COLLATE NOCASE".to_string(),
"artist" => format!("COALESCE(a.artist, {la_artist}) COLLATE NOCASE"),
"year" => "COALESCE(a.year, la.year)".to_string(),
_ => continue,
};
let dir = match s.dir {
SortDir::Asc => "ASC",
SortDir::Desc => "DESC",
};
keys.push(format!("{col} {dir}"));
keys.push(format!("{col} {dir}", col = col));
}
if keys.is_empty() {
keys.push("COALESCE(a.name, la.album_name) COLLATE NOCASE ASC".to_string());
@@ -44,10 +45,16 @@ fn count_genre_albums(
conn: &rusqlite::Connection,
where_sql: &str,
params: &[SqlValue],
library_scoped: bool,
) -> Result<u32, rusqlite::Error> {
let count_sql = format!(
"SELECT COUNT(DISTINCT t.album_id) FROM track t WHERE {where_sql}"
);
let from = if library_scoped {
"FROM track_genre tg \
INNER JOIN track t \
ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0"
} else {
"FROM track_genre tg"
};
let count_sql = format!("SELECT COUNT(DISTINCT tg.album_id) {from} WHERE {where_sql}");
let n: i64 = conn.query_row(
&count_sql,
rusqlite::params_from_iter(params.iter()),
@@ -107,28 +114,29 @@ pub fn list_albums_by_genre(
let order_sql = genre_album_order_sql(&req.sort);
let mut where_clauses = vec![
"t.deleted = 0".to_string(),
"t.server_id = ?1".to_string(),
"t.album_id IS NOT NULL AND t.album_id != ''".to_string(),
"t.genre = ?2 COLLATE NOCASE".to_string(),
"tg.server_id = ?1".to_string(),
"tg.album_id IS NOT NULL AND tg.album_id != ''".to_string(),
"tg.genre = ?2 COLLATE NOCASE".to_string(),
];
let mut params: Vec<SqlValue> = vec![
SqlValue::Text(req.server_id.clone()),
SqlValue::Text(genre.to_string()),
];
let library_scoped = trimmed_nonempty(req.library_scope.as_deref()).is_some();
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
where_clauses.push(library_scope_equals_sql("t"));
params.push(SqlValue::Text(scope));
}
let where_sql = where_clauses.join(" AND ");
let la_artist = crate::album_compilation_filter::sql_track_group_display_artist("la");
let sql = format!(
"SELECT \
la.server_id, \
la.album_id, \
COALESCE(a.name, la.album_name), \
COALESCE(a.artist, la.artist), \
COALESCE(a.artist, {la_artist}), \
COALESCE(a.artist_id, la.artist_id), \
COALESCE(a.song_count, la.track_count), \
COALESCE(a.duration_sec, la.duration_sec), \
@@ -140,10 +148,11 @@ pub fn list_albums_by_genre(
a.raw_json \
FROM ( \
SELECT \
t.server_id, \
t.album_id, \
tg.server_id, \
tg.album_id, \
MAX(t.album) AS album_name, \
MAX(t.artist) AS artist, \
MAX(t.album_artist) AS album_artist, \
MAX(t.artist_id) AS artist_id, \
MAX(t.year) AS year, \
MAX(t.genre) AS genre, \
@@ -152,9 +161,11 @@ pub fn list_albums_by_genre(
MAX(t.synced_at) AS synced_at, \
COUNT(*) AS track_count, \
COALESCE(SUM(t.duration_sec), 0) AS duration_sec \
FROM track t \
FROM track_genre tg \
INNER JOIN track t \
ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0 \
WHERE {where_sql} \
GROUP BY t.server_id, t.album_id \
GROUP BY tg.server_id, tg.album_id \
) la \
LEFT JOIN album a ON a.server_id = la.server_id AND a.id = la.album_id \
{order_sql} \
@@ -167,7 +178,7 @@ pub fn list_albums_by_genre(
store.with_read_conn(|conn| {
let total = if req.include_total {
Some(count_genre_albums(conn, &where_sql, &count_params)?)
Some(count_genre_albums(conn, &where_sql, &count_params, library_scoped)?)
} else {
None
};
@@ -282,4 +293,49 @@ mod tests {
assert_eq!(all.total, Some(3));
assert!(all.has_more);
}
#[test]
fn list_albums_by_atomic_genre_from_compound_tag() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track(
"s1",
"t1",
"al_a",
"Noise Metal/Dark Ambient/Experimental Black Metal",
)])
.unwrap();
let dark = list_albums_by_genre(
&store,
&LibraryGenreAlbumsRequest {
server_id: "s1".into(),
genre: "Dark Ambient".into(),
library_scope: None,
sort: vec![],
limit: 10,
offset: 0,
include_total: true,
},
)
.unwrap();
assert_eq!(dark.total, Some(1));
assert_eq!(dark.albums.len(), 1);
assert_eq!(dark.albums[0].id, "al_a");
let noise = list_albums_by_genre(
&store,
&LibraryGenreAlbumsRequest {
server_id: "s1".into(),
genre: "Noise Metal".into(),
library_scope: None,
sort: vec![],
limit: 10,
offset: 0,
include_total: true,
},
)
.unwrap();
assert_eq!(noise.total, Some(1));
}
}
@@ -0,0 +1,226 @@
//! Atomic genre resolution for multi-value tags (OpenSubsonic `genres[]` first,
//! Navidrome-default string split as fallback).
use std::collections::HashSet;
use rusqlite::{params, Transaction};
use serde_json::Value;
const GENRE_SEPARATORS: [&str; 3] = [";", "/", ","];
/// Fallback split when the server sent no `genres[]` array (legacy Subsonic).
pub fn split_genre_tags(raw: &str) -> Vec<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Vec::new();
}
let mut parts = vec![trimmed.to_string()];
for sep in GENRE_SEPARATORS {
let mut next = Vec::new();
for part in parts {
for sub in part.split(sep) {
next.push(sub.to_string());
}
}
parts = next;
}
dedupe_genres(parts)
}
fn dedupe_genres(genres: Vec<String>) -> Vec<String> {
let mut seen = HashSet::new();
let mut out = Vec::new();
for g in genres {
let t = g.trim();
if t.is_empty() {
continue;
}
let key = t.to_ascii_lowercase();
if seen.insert(key) {
out.push(t.to_string());
}
}
out
}
fn parse_genres_array_value(value: &Value) -> Option<Vec<String>> {
let arr = value.as_array()?;
if arr.is_empty() {
return None;
}
let mut out = Vec::new();
for item in arr {
if let Some(name) = item.get("name").and_then(|v| v.as_str()) {
let t = name.trim();
if !t.is_empty() {
out.push(t.to_string());
}
} else if let Some(s) = item.as_str() {
let t = s.trim();
if !t.is_empty() {
out.push(t.to_string());
}
}
}
if out.is_empty() {
None
} else {
Some(dedupe_genres(out))
}
}
fn parse_genres_json_str(genres_json: &str) -> Option<Vec<String>> {
let trimmed = genres_json.trim();
if trimmed.is_empty() {
return None;
}
let value: Value = serde_json::from_str(trimmed).ok()?;
parse_genres_array_value(&value)
}
/// Source-priority resolver (§2.0): `genres[]` from parsed payload, else split `genre`.
pub fn genres_for_track_value(raw_json: &Value, genre: Option<&str>) -> Vec<String> {
if let Some(genres) = raw_json.get("genres").and_then(parse_genres_array_value) {
return genres;
}
genre
.map(str::trim)
.filter(|s| !s.is_empty())
.map(split_genre_tags)
.unwrap_or_default()
}
/// Backfill path: `genres_json` from `json_extract(raw_json, '$.genres')`.
pub fn genres_for_track_extracted(genres_json: Option<&str>, genre: Option<&str>) -> Vec<String> {
if let Some(json) = genres_json {
if let Some(genres) = parse_genres_json_str(json) {
return genres;
}
}
genre
.map(str::trim)
.filter(|s| !s.is_empty())
.map(split_genre_tags)
.unwrap_or_default()
}
pub fn genres_for_track_raw_json(raw_json: &str, genre: Option<&str>) -> Vec<String> {
if let Ok(value) = serde_json::from_str::<Value>(raw_json) {
return genres_for_track_value(&value, genre);
}
genre
.map(str::trim)
.filter(|s| !s.is_empty())
.map(split_genre_tags)
.unwrap_or_default()
}
pub fn replace_track_genre_rows(
tx: &Transaction<'_>,
server_id: &str,
track_id: &str,
album_id: Option<&str>,
library_id: Option<&str>,
genres: &[String],
) -> rusqlite::Result<()> {
tx.execute(
"DELETE FROM track_genre WHERE server_id = ?1 AND track_id = ?2",
params![server_id, track_id],
)?;
if genres.is_empty() {
return Ok(());
}
let mut insert = tx.prepare_cached(
"INSERT OR IGNORE INTO track_genre (server_id, track_id, genre, album_id, library_id) \
VALUES (?1, ?2, ?3, ?4, ?5)",
)?;
for genre in genres {
insert.execute(params![server_id, track_id, genre, album_id, library_id])?;
}
Ok(())
}
pub fn delete_track_genre_for_track(
conn: &rusqlite::Connection,
server_id: &str,
track_id: &str,
) -> rusqlite::Result<()> {
conn.execute(
"DELETE FROM track_genre WHERE server_id = ?1 AND track_id = ?2",
params![server_id, track_id],
)?;
Ok(())
}
pub fn delete_track_genre_for_server_tracks(
conn: &rusqlite::Connection,
server_id: &str,
track_ids: &[String],
) -> rusqlite::Result<()> {
if track_ids.is_empty() {
return Ok(());
}
for id in track_ids {
delete_track_genre_for_track(conn, server_id, id)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn split_separators_and_dedupe() {
assert_eq!(
split_genre_tags("Rock/Jazz"),
vec!["Rock".to_string(), "Jazz".to_string()]
);
assert_eq!(
split_genre_tags("Rock; Jazz, Electronic"),
vec![
"Rock".to_string(),
"Jazz".to_string(),
"Electronic".to_string()
]
);
assert_eq!(split_genre_tags("Rock/rock/ROCK"), vec!["Rock".to_string()]);
assert!(split_genre_tags("").is_empty());
}
#[test]
fn array_wins_over_genre_string() {
let raw = json!({
"genres": [{"name": "A"}, {"name": "B"}],
"genre": "A/B/C"
});
assert_eq!(
genres_for_track_value(&raw, Some("A/B/C")),
vec!["A".to_string(), "B".to_string()]
);
}
#[test]
fn bare_string_array_and_empty_array_fallback() {
let bare = json!({ "genres": ["A", "B"] });
assert_eq!(
genres_for_track_value(&bare, None),
vec!["A".to_string(), "B".to_string()]
);
let empty = json!({ "genres": [], "genre": "A/B" });
assert_eq!(
genres_for_track_value(&empty, Some("A/B")),
vec!["A".to_string(), "B".to_string()]
);
}
#[test]
fn extracted_json_matches_value_path() {
let genres_json = r#"[{"name":"Jazz"},{"name":"Rock"}]"#;
assert_eq!(
genres_for_track_extracted(Some(genres_json), Some("Noise/Metal")),
vec!["Jazz".to_string(), "Rock".to_string()]
);
}
}
@@ -0,0 +1,318 @@
//! One-time blocking backfill: populate `track_genre` from existing `track` rows.
use std::time::{SystemTime, UNIX_EPOCH};
use rusqlite::{params, Connection, OptionalExtension};
use tauri::{AppHandle, Emitter};
use crate::genre_tags::{genres_for_track_extracted, replace_track_genre_rows};
use crate::store::LibraryStore;
pub const GENRE_TAGS_MIGRATION_ID: &str = "genre_tags_v1";
const BATCH_SIZE: i64 = 10_000;
type BackfillTrackRow = (
i64,
String,
String,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
);
fn ensure_genre_tags_tables(conn: &mut Connection) -> rusqlite::Result<()> {
crate::store::ensure_genre_tags_schema(conn)
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GenreTagsInspectDto {
pub needed: bool,
pub total_tracks: u64,
pub done_tracks: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GenreTagsProgressEvent {
pub done: u64,
pub total: u64,
}
fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn migration_completed(conn: &Connection) -> Result<bool, rusqlite::Error> {
let completed: Option<Option<i64>> = conn
.query_row(
"SELECT completed_at FROM library_data_migration WHERE id = ?1",
params![GENRE_TAGS_MIGRATION_ID],
|r| r.get(0),
)
.optional()?;
Ok(completed.flatten().is_some())
}
fn count_live_tracks(conn: &Connection) -> Result<u64, rusqlite::Error> {
let n: i64 = conn.query_row(
"SELECT COUNT(*) FROM track WHERE deleted = 0",
[],
|r| r.get(0),
)?;
Ok(n.max(0) as u64)
}
fn cursor_rowid(conn: &Connection) -> Result<i64, rusqlite::Error> {
let rowid: Option<i64> = conn
.query_row(
"SELECT cursor_rowid FROM library_data_migration WHERE id = ?1",
params![GENRE_TAGS_MIGRATION_ID],
|r| r.get(0),
)
.optional()?;
Ok(rowid.unwrap_or(0))
}
pub fn inspect_genre_tags_backfill(store: &LibraryStore) -> Result<GenreTagsInspectDto, String> {
store.with_conn_mut("genre_tags.ensure_schema", ensure_genre_tags_tables)?;
store.with_read_conn(|conn| {
let total_tracks = count_live_tracks(conn)?;
if total_tracks == 0 {
return Ok(GenreTagsInspectDto {
needed: false,
total_tracks: 0,
done_tracks: 0,
});
}
if migration_completed(conn)? {
return Ok(GenreTagsInspectDto {
needed: false,
total_tracks,
done_tracks: total_tracks,
});
}
let cursor = cursor_rowid(conn)?;
let done: i64 = conn.query_row(
"SELECT COUNT(*) FROM track WHERE deleted = 0 AND rowid <= ?1",
params![cursor],
|r| r.get(0),
)?;
Ok(GenreTagsInspectDto {
needed: true,
total_tracks,
done_tracks: done.max(0) as u64,
})
})
}
fn emit_progress(app: &AppHandle, done: u64, total: u64) -> Result<(), String> {
app.emit(
"genre_tags:progress",
GenreTagsProgressEvent { done, total },
)
.map_err(|e| e.to_string())
}
pub fn run_genre_tags_backfill(store: &LibraryStore, app: &AppHandle) -> Result<(), String> {
run_genre_tags_backfill_impl(store, Some(app))
}
fn run_genre_tags_backfill_impl(
store: &LibraryStore,
app: Option<&AppHandle>,
) -> Result<(), String> {
let inspect = inspect_genre_tags_backfill(store)?;
if !inspect.needed {
return Ok(());
}
let total = inspect.total_tracks;
loop {
let (batch_done, finished) = store.with_conn_mut("genre_tags.backfill", |conn| {
if migration_completed(conn)? {
return Ok::<(i64, bool), rusqlite::Error>((total as i64, true));
}
conn.execute(
"INSERT INTO library_data_migration (id, cursor_rowid, started_at) \
VALUES (?1, 0, ?2) \
ON CONFLICT(id) DO UPDATE SET \
started_at = COALESCE(library_data_migration.started_at, excluded.started_at)",
params![GENRE_TAGS_MIGRATION_ID, now_unix()],
)?;
let cursor = cursor_rowid(conn)?;
let mut stmt = conn.prepare(
"SELECT rowid, server_id, id, genre, \
CASE WHEN json_valid(raw_json) THEN json_extract(raw_json, '$.genres') END, \
album_id, library_id \
FROM track \
WHERE deleted = 0 AND rowid > ?1 \
ORDER BY rowid \
LIMIT ?2",
)?;
let rows: Vec<BackfillTrackRow> =
stmt
.query_map(params![cursor, BATCH_SIZE], |r| {
Ok((
r.get(0)?,
r.get(1)?,
r.get(2)?,
r.get(3)?,
r.get(4)?,
r.get(5)?,
r.get(6)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
if rows.is_empty() {
conn.execute(
"UPDATE library_data_migration SET completed_at = ?2, cursor_rowid = \
(SELECT COALESCE(MAX(rowid), 0) FROM track WHERE deleted = 0) \
WHERE id = ?1",
params![GENRE_TAGS_MIGRATION_ID, now_unix()],
)?;
return Ok((total as i64, true));
}
let tx = conn.unchecked_transaction()?;
let mut last_rowid = cursor;
for (rowid, server_id, track_id, genre, genres_json, album_id, library_id) in rows {
let genres = genres_for_track_extracted(
genres_json.as_deref(),
genre.as_deref(),
);
replace_track_genre_rows(
&tx,
&server_id,
&track_id,
album_id.as_deref(),
library_id.as_deref(),
&genres,
)?;
last_rowid = rowid;
}
tx.commit()?;
conn.execute(
"UPDATE library_data_migration SET cursor_rowid = ?2 WHERE id = ?1",
params![GENRE_TAGS_MIGRATION_ID, last_rowid],
)?;
let done: i64 = conn.query_row(
"SELECT COUNT(*) FROM track WHERE deleted = 0 AND rowid <= ?1",
params![last_rowid],
|r| r.get(0),
)?;
Ok((done, false))
})?;
if let Some(app) = app {
emit_progress(app, batch_done.max(0) as u64, total)?;
}
if finished {
break;
}
}
// Belt-and-suspenders: all live tracks processed but `completed_at` not set
// (can happen when rowid gaps from soft-deletes make done == total early).
store.with_conn_mut("genre_tags.backfill.finalize", |conn| {
if migration_completed(conn)? {
return Ok(());
}
let cursor = cursor_rowid(conn)?;
let pending: i64 = conn.query_row(
"SELECT COUNT(*) FROM track WHERE deleted = 0 AND rowid > ?1",
params![cursor],
|r| r.get(0),
)?;
if pending == 0 {
conn.execute(
"UPDATE library_data_migration SET completed_at = ?2, cursor_rowid = \
(SELECT COALESCE(MAX(rowid), 0) FROM track WHERE deleted = 0) \
WHERE id = ?1",
params![GENRE_TAGS_MIGRATION_ID, now_unix()],
)?;
}
Ok(())
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::track::{TrackRepository, TrackRow};
use crate::store::LibraryStore;
fn track(server_id: &str, id: &str, genre: &str, deleted: bool) -> TrackRow {
TrackRow {
server_id: server_id.into(),
id: id.into(),
title: id.into(),
title_sort: None,
artist: Some("Artist".into()),
artist_id: None,
album: "Album".into(),
album_id: Some("al1".into()),
album_artist: None,
duration_sec: 100,
track_number: Some(1),
disc_number: Some(1),
year: None,
genre: Some(genre.into()),
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: Some("lib1".into()),
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn backfill_marks_complete_when_rowid_gaps_leave_pending_rows() {
let store = LibraryStore::open_in_memory();
let live: Vec<TrackRow> = (1..=5)
.map(|n| track("s1", &format!("t{n}"), "Rock", false))
.collect();
let mut batch = live;
for n in 6..=20 {
batch.push(track("s1", &format!("del{n}"), "Rock", true));
}
batch.push(track("s1", "t6", "Jazz", false));
TrackRepository::new(&store).upsert_batch(&batch).unwrap();
run_genre_tags_backfill_impl(&store, None).unwrap();
let inspect = inspect_genre_tags_backfill(&store).unwrap();
assert!(!inspect.needed, "backfill should complete despite rowid gaps");
}
}
@@ -25,6 +25,8 @@ pub mod dto;
pub mod enrichment;
pub mod filter;
pub mod genre_album_browse;
pub mod genre_tags;
pub mod genre_tags_backfill;
pub mod mood_groups;
pub mod live_search;
pub mod lossless_albums;
@@ -244,8 +244,9 @@ fn query_albums(
ORDER BY rank \
LIMIT ?\
) \
SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.year, \
t.genre, t.cover_art_id, t.starred_at, t.synced_at, MIN(h.rank) AS best_rank \
SELECT t.server_id, t.album_id, MAX(t.album), MAX(t.artist), MAX(t.album_artist), \
MAX(t.artist_id), MAX(t.year), MAX(t.genre), MAX(t.cover_art_id), \
MAX(t.starred_at), MAX(t.synced_at), MIN(h.rank) AS best_rank \
FROM fts_hits h \
JOIN track t ON t.rowid = h.rowid \
WHERE t.server_id = ? \
@@ -261,24 +262,29 @@ fn query_albums(
params.push(rusqlite::types::Value::Integer(LIVE_SEARCH_FTS_CANDIDATE_CAP));
params.push(rusqlite::types::Value::Text(server_id.to_string()));
append_library_scope(&mut sql, &mut params, library_scope);
sql.push_str(" GROUP BY t.album_id ORDER BY best_rank LIMIT ?");
sql.push_str(" GROUP BY t.server_id, t.album_id ORDER BY best_rank LIMIT ?");
params.push(rusqlite::types::Value::Integer(i64::from(limit)));
let mut stmt = conn.prepare(&sql)?;
let mut out = Vec::new();
for row in stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
let track_artist: Option<String> = r.get(3)?;
let album_artist: Option<String> = r.get(4)?;
Ok(LibraryAlbumDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
artist: r.get(3)?,
artist_id: r.get(4)?,
artist: crate::album_compilation_filter::pick_album_group_artist(
track_artist,
album_artist,
),
artist_id: r.get(5)?,
song_count: None,
duration_sec: None,
year: r.get(5)?,
genre: r.get(6)?,
cover_art_id: r.get(7)?,
starred_at: r.get(8)?,
synced_at: r.get(9)?,
year: r.get(6)?,
genre: r.get(7)?,
cover_art_id: r.get(8)?,
starred_at: r.get(9)?,
synced_at: r.get(10)?,
raw_json: serde_json::Value::Null,
})
})? {
@@ -44,12 +44,13 @@ pub fn list_lossless_albums(
}
let where_sql = where_clauses.join(" AND ");
let la_artist = crate::album_compilation_filter::sql_track_group_display_artist("la");
let sql = format!(
"SELECT \
la.server_id, \
la.album_id, \
COALESCE(a.name, la.album_name), \
COALESCE(a.artist, la.artist), \
COALESCE(a.artist, {la_artist}), \
COALESCE(a.artist_id, la.artist_id), \
COALESCE(a.song_count, la.track_count), \
COALESCE(a.duration_sec, la.duration_sec), \
@@ -65,6 +66,7 @@ pub fn list_lossless_albums(
t.album_id, \
MAX(t.album) AS album_name, \
MAX(t.artist) AS artist, \
MAX(t.album_artist) AS album_artist, \
MAX(t.artist_id) AS artist_id, \
MAX(t.year) AS year, \
MAX(t.genre) AS genre, \
@@ -1,7 +1,23 @@
use rusqlite::{params, OptionalExtension};
use rusqlite::{params, OptionalExtension, Transaction};
use crate::genre_tags::{self, genres_for_track_raw_json};
use crate::store::{LibraryStore, WriteOpTiming};
fn sync_track_genre_row(tx: &Transaction<'_>, row: &TrackRow) -> rusqlite::Result<()> {
if row.deleted {
return genre_tags::delete_track_genre_for_track(tx, &row.server_id, &row.id);
}
let genres = genres_for_track_raw_json(&row.raw_json, row.genre.as_deref());
genre_tags::replace_track_genre_rows(
tx,
&row.server_id,
&row.id,
row.album_id.as_deref(),
row.library_id.as_deref(),
&genres,
)
}
/// One row of the `track` table — every hot column from spec §5.1 plus
/// `raw_json` (the full normalized SubsonicSong). Sync code (PR-2/PR-3) is
/// expected to project ingested payloads into this shape, not to talk SQL
@@ -181,6 +197,7 @@ impl<'a> TrackRepository<'a> {
r.raw_json,
])?;
}
sync_track_genre_row(&tx, r)?;
}
drop(upsert);
tx.commit()?;
@@ -204,6 +221,14 @@ impl<'a> TrackRepository<'a> {
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("track.sweep_resync_orphans", |c| {
c.execute(
"DELETE FROM track_genre \
WHERE server_id = ?1 AND track_id IN ( \
SELECT id FROM track \
WHERE server_id = ?1 AND deleted = 0 AND resync_gen != ?2 \
)",
params![server_id, resync_gen],
)?;
c.execute(
"UPDATE track SET deleted = 1, synced_at = ?3 \
WHERE server_id = ?1 AND deleted = 0 AND resync_gen != ?2",
@@ -477,6 +502,7 @@ impl<'a> TrackRepository<'a> {
r.synced_at,
r.raw_json,
])?;
sync_track_genre_row(&tx, r)?;
if let Some(old_id) = detected_old {
remap_existing_to_new(
@@ -1043,8 +1069,9 @@ mod tests {
repo.upsert_batch_initial_ingest(&rows).unwrap();
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_millis(500),
"initial ingest batch(500) took {elapsed:?}"
elapsed < std::time::Duration::from_millis(1000),
"initial ingest batch(500) took {elapsed:?}; includes per-row track_genre \
maintenance and large raw_json payloads"
);
}
+59 -7
View File
@@ -9,7 +9,7 @@ use tauri::Manager;
/// Current head of the embedded migrations. Bump each time a new
/// `migrations/NNN_*.sql` is added.
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 1;
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 12;
/// Lowest applied schema version the current code can advance from purely
/// additively. If a DB carries a version below this, the breaking-bump hook
@@ -22,10 +22,20 @@ pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 1;
pub const LIBRARY_DB_MIN_COMPATIBLE_VERSION: i64 = 1;
pub(crate) const INITIAL_SQL: &str = include_str!("../migrations/001_initial.sql");
/// Version 12 is above the removed legacy migrations 002011 so existing DBs
/// still pick up `track_genre` + `library_data_migration`.
pub(crate) const MIGRATION_012_TRACK_GENRE_LEGACY: &str =
include_str!("../migrations/012_track_genre_legacy_repair.sql");
/// Embedded migrations. Ordered ascending by `version`; the runner sorts
/// defensively before applying so the source order can stay readable.
const MIGRATIONS: &[(i64, &str)] = &[(1, INITIAL_SQL)];
const MIGRATIONS: &[(i64, &str)] = &[(1, INITIAL_SQL), (12, MIGRATION_012_TRACK_GENRE_LEGACY)];
/// Idempotent repair — also runs after the migration runner on every open so
/// DBs that recorded the wrong version numbers still get the tables.
pub(crate) fn ensure_genre_tags_schema(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(MIGRATION_012_TRACK_GENRE_LEGACY)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MigrationOutcome {
@@ -67,6 +77,7 @@ impl LibraryStore {
let write_conn = Connection::open(db_path).map_err(|e| e.to_string())?;
configure_write_connection(&write_conn).map_err(|e| e.to_string())?;
run_migrations(&write_conn).map_err(|e| e.to_string())?;
ensure_genre_tags_schema(&write_conn).map_err(|e| e.to_string())?;
checkpoint_wal_conn(&write_conn, "open").map_err(|e| e.to_string())?;
let read_conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|e| e.to_string())?;
@@ -84,6 +95,7 @@ impl LibraryStore {
let write_conn = Connection::open(&uri).expect("in-memory write connection");
configure_write_connection(&write_conn).expect("write pragmas");
run_migrations(&write_conn).expect("schema migration");
ensure_genre_tags_schema(&write_conn).expect("genre tags schema");
let read_conn = Connection::open(&uri).expect("in-memory read connection");
configure_read_connection(&read_conn).expect("read pragmas");
Self {
@@ -556,8 +568,7 @@ mod tests {
rows
})
.unwrap();
// Embedded migrations are numbered 1..=head, all applied on a fresh DB.
let expected: Vec<i64> = (1..=LIBRARY_DB_SCHEMA_VERSION).collect();
let expected: Vec<i64> = MIGRATIONS.iter().map(|(version, _)| *version).collect();
assert_eq!(versions, expected);
}
@@ -574,11 +585,53 @@ mod tests {
})
.unwrap();
assert_eq!(
count, LIBRARY_DB_SCHEMA_VERSION,
count,
MIGRATIONS.len() as i64,
"one schema_migrations row per embedded migration, no duplicates"
);
}
#[test]
fn migration_012_repairs_db_that_recorded_legacy_versions_without_genre_tables() {
let uri = in_memory_uri();
let conn = Connection::open(&uri).expect("connection");
configure_write_connection(&conn).expect("pragmas");
conn.execute_batch(INITIAL_SQL).expect("initial");
conn.execute("DROP TABLE IF EXISTS track_genre", [])
.expect("drop track_genre");
conn.execute("DROP TABLE IF EXISTS library_data_migration", [])
.expect("drop cursor table");
for version in 1..=11_i64 {
conn.execute(
"INSERT INTO schema_migrations (version, applied_at) VALUES (?1, ?1)",
params![version],
)
.expect("seed legacy versions");
}
let outcome = run_migrations_with(
&conn,
MIGRATIONS,
LIBRARY_DB_MIN_COMPATIBLE_VERSION,
no_op_hook,
)
.expect("apply v12 repair");
assert_eq!(outcome, MigrationOutcome::Applied);
ensure_genre_tags_schema(&conn).expect("ensure");
for table in ["track_genre", "library_data_migration"] {
let exists: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master \
WHERE type = 'table' AND name = ?1",
params![table],
|r| r.get(0),
)
.expect("table probe");
assert_eq!(exists, 1, "missing table {table}");
}
}
#[test]
fn fts_virtual_table_exists() {
let store = LibraryStore::open_in_memory();
@@ -656,8 +709,7 @@ mod tests {
rows
})
.unwrap();
// Real embedded migrations (1..=head) plus the additive fixture.
let mut expected: Vec<i64> = (1..=LIBRARY_DB_SCHEMA_VERSION).collect();
let mut expected: Vec<i64> = MIGRATIONS.iter().map(|(version, _)| *version).collect();
expected.push(FIXTURE_ADD_BIO_VERSION);
assert_eq!(versions, expected);
}
@@ -139,6 +139,10 @@ impl<'a> TombstoneReconciler<'a> {
WHERE server_id = ?1 AND id = ?2",
rusqlite::params![self.server_id, id, now_unix_ms()],
)?;
c.execute(
"DELETE FROM track_genre WHERE server_id = ?1 AND track_id = ?2",
rusqlite::params![self.server_id, id],
)?;
Ok(())
})
.map_err(SyncError::Storage)
+9 -1
View File
@@ -434,6 +434,9 @@ pub fn run() {
let _ = win.set_decorations(false);
let _ = linux_webkit_apply_wayland_gpu_font_tuning(&win);
let _ = linux_webkit_reapply_cached_wayland_text_render_profile(&win);
// Suppress WebKit's own MPRIS player so radio (HTML <audio>)
// doesn't duplicate the souvlaki one (issue #1048).
let _ = linux_webkit_disable_media_session(&win);
}
}
@@ -559,6 +562,7 @@ pub fn run() {
use tauri::Manager;
let engine = app.state::<audio::AudioEngine>();
audio::start_device_watcher(&engine, app.handle().clone());
audio::start_stream_idle_watcher(app.handle().clone());
}
// ── Reopen output after system sleep/resume (WASAPI / PipeWire etc.)
@@ -718,7 +722,9 @@ pub fn run() {
audio::commands::audio_chain_preload,
psysonic_integration::discord::discord_update_presence,
psysonic_integration::discord::discord_clear_presence,
psysonic_integration::remote::lastfm_request,
psysonic_integration::remote::audioscrobbler_request,
psysonic_integration::remote::listenbrainz_request,
psysonic_integration::remote::maloja_request,
psysonic_integration::navidrome::covers::upload_playlist_cover,
psysonic_integration::navidrome::covers::upload_radio_cover,
psysonic_integration::navidrome::covers::upload_artist_image,
@@ -768,6 +774,8 @@ pub fn run() {
psysonic_library::commands::library_advanced_search,
psysonic_library::commands::library_list_lossless_albums,
psysonic_library::commands::library_list_albums_by_genre,
psysonic_library::commands::library_genre_tags_inspect,
psysonic_library::commands::library_genre_tags_run,
psysonic_library::commands::library_get_artist_lossless_browse,
psysonic_library::commands::library_search_cross_server,
psysonic_library::commands::library_get_track,
+2 -1
View File
@@ -27,7 +27,8 @@ pub(crate) use platform::{
};
#[cfg(target_os = "linux")]
pub(crate) use platform::{
linux_webkit_apply_wayland_gpu_font_tuning, linux_webkit_reapply_cached_wayland_text_render_profile,
linux_webkit_apply_wayland_gpu_font_tuning, linux_webkit_disable_media_session,
linux_webkit_reapply_cached_wayland_text_render_profile,
sync_wayland_text_profile_cache_from_disk,
};
pub(crate) use integration::{
@@ -136,6 +136,30 @@ pub(crate) fn linux_webkit_apply_wayland_gpu_font_tuning(win: &tauri::WebviewWin
.map_err(|e| e.to_string())
}
/// WebKitGTK auto-registers its **own** MPRIS player whenever an HTML
/// `<audio>`/`<video>` element plays — internet radio uses one (`radioPlayer.ts`).
/// That duplicates the app's souvlaki MPRIS player on Linux desktops that list
/// every player (issue #1048: one feed as "psysonic", one as "Psysonic").
/// Disabling the WebKit media session stops that registration, leaving souvlaki
/// as the single now-playing source; radio metadata still reaches it through
/// `mpris_set_metadata`.
#[cfg(target_os = "linux")]
pub(crate) fn linux_webkit_disable_media_session(win: &tauri::WebviewWindow) -> Result<(), String> {
win.with_webview(|platform| {
use webkit2gtk::glib::prelude::ObjectExt;
use webkit2gtk::WebViewExt;
if let Some(settings) = platform.inner().settings() {
// `enable-media-session` (WebKitGTK 2.40+) has no typed setter in the
// pinned binding; set it by GObject property name. The find_property
// guard keeps older runtimes from panicking on an unknown property.
if settings.find_property("enable-media-session").is_some() {
settings.set_property("enable-media-session", false);
}
}
})
.map_err(|e| e.to_string())
}
/// 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
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.48.0-dev",
"version": "1.48.0",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
@@ -28,7 +28,7 @@
}
],
"security": {
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval' ipc: http://ipc.localhost tauri:; connect-src 'self' ipc: http://ipc.localhost tauri: https: http: ws: wss:; img-src 'self' asset: http://asset.localhost https: http: data: blob:; media-src 'self' asset: http://asset.localhost https: http: data: blob:;",
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval' ipc: http://ipc.localhost tauri:; style-src 'self' 'unsafe-inline' asset: http://asset.localhost https://asset.localhost tauri: blob:; style-src-elem 'self' 'unsafe-inline' asset: http://asset.localhost https://asset.localhost tauri: blob:; font-src 'self' data: asset: http://asset.localhost https://asset.localhost; connect-src 'self' ipc: http://ipc.localhost tauri: https: http: ws: wss:; img-src 'self' asset: http://asset.localhost https: http: data: blob:; media-src 'self' asset: http://asset.localhost https: http: data: blob:;",
"assetProtocol": {
"enable": true,
"scope": [
-378
View File
@@ -1,378 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
const API_KEY = '9917fb39049225a13bec225ad6d49054';
const API_SECRET = '03817dda02bee87a178aab7581abae3b';
export function lastfmIsConfigured(): boolean {
return Boolean(API_KEY && API_SECRET);
}
function errMsg(e: unknown): string {
if (typeof e === 'string') return e;
if (e instanceof Error) return e.message;
return String(e);
}
async function call(params: Record<string, string>, sign = false, get = false): Promise<any> {
const entries = Object.entries(params) as [string, string][];
try {
const result = await invoke('lastfm_request', {
params: entries,
sign,
get,
apiKey: API_KEY,
apiSecret: API_SECRET,
});
// Clear session error on any successful authenticated call
if (sign) useAuthStore.getState().setLastfmSessionError(false);
return result;
} catch (e) {
// Last.fm error codes 4, 9, 14 = auth/session invalid
if (sign && /^Last\.fm (4|9|14)\b/.test(errMsg(e))) {
useAuthStore.getState().setLastfmSessionError(true);
}
throw e;
}
}
export async function lastfmGetToken(): Promise<string> {
try {
const data = await call({ method: 'auth.getToken' }, false, true);
return data.token as string;
} catch (e) {
throw new Error(errMsg(e));
}
}
export function lastfmAuthUrl(token: string): string {
return `https://www.last.fm/api/auth/?api_key=${API_KEY}&token=${token}`;
}
export async function lastfmGetSession(token: string): Promise<{ key: string; name: string }> {
try {
const data = await call({ method: 'auth.getSession', token }, true, false);
return { key: data.session.key as string, name: data.session.name as string };
} catch (e) {
throw new Error(errMsg(e));
}
}
export async function lastfmGetSimilarArtists(artistName: string): Promise<string[]> {
try {
const data = await call({ method: 'artist.getSimilar', artist: artistName, limit: '50' }, false, true);
const artists = data?.similarartists?.artist;
if (!artists) return [];
const arr = Array.isArray(artists) ? artists : [artists];
return arr.map((a: any) => a.name as string);
} catch {
return [];
}
}
export async function lastfmGetAllLovedTracks(
username: string,
sessionKey: string,
): Promise<Array<{ title: string; artist: string }>> {
const results: Array<{ title: string; artist: string }> = [];
let page = 1;
const limit = 200;
while (true) {
try {
const data = await call({
method: 'user.getLovedTracks',
user: username,
sk: sessionKey,
limit: String(limit),
page: String(page),
}, false, true);
const tracks = data?.lovedtracks?.track;
if (!tracks) break;
const arr = Array.isArray(tracks) ? tracks : [tracks];
for (const t of arr) {
results.push({ title: t.name, artist: t.artist?.name ?? '' });
}
const totalPages = Number(data?.lovedtracks?.['@attr']?.totalPages ?? 1);
if (page >= totalPages || page >= 10) break; // max 10 pages = 2000 tracks
page++;
} catch {
break;
}
}
return results;
}
export async function lastfmGetTrackLoved(
title: string,
artist: string,
sessionKey: string,
): Promise<boolean> {
try {
const data = await call({ method: 'track.getInfo', track: title, artist, sk: sessionKey }, false, true);
return data?.track?.userloved === '1' || data?.track?.userloved === 1;
} catch {
return false;
}
}
export async function lastfmUpdateNowPlaying(
track: { title: string; artist: string; album: string; duration: number },
sessionKey: string,
): Promise<void> {
try {
await call({
method: 'track.updateNowPlaying',
track: track.title,
artist: track.artist,
album: track.album,
duration: String(Math.round(track.duration)),
sk: sessionKey,
}, true, false);
} catch {
// best effort
}
}
export async function lastfmLoveTrack(
track: { title: string; artist: string },
sessionKey: string,
): Promise<void> {
try {
await call({ method: 'track.love', track: track.title, artist: track.artist, sk: sessionKey }, true, false);
} catch {
// best effort
}
}
export async function lastfmUnloveTrack(
track: { title: string; artist: string },
sessionKey: string,
): Promise<void> {
try {
await call({ method: 'track.unlove', track: track.title, artist: track.artist, sk: sessionKey }, true, false);
} catch {
// best effort
}
}
export interface LastfmUserInfo {
playcount: number;
registeredAt: number; // unix timestamp
}
export async function lastfmGetUserInfo(
username: string,
sessionKey: string,
): Promise<LastfmUserInfo | null> {
try {
const data = await call({ method: 'user.getInfo', user: username, sk: sessionKey }, false, true);
const u = data?.user;
if (!u) return null;
return {
playcount: Number(u.playcount),
registeredAt: Number(u.registered?.unixtime ?? 0),
};
} catch {
return null;
}
}
export interface LastfmRecentTrack {
name: string;
artist: string;
album: string;
timestamp: number | null; // null = currently playing
nowPlaying: boolean;
}
export async function lastfmGetRecentTracks(
username: string,
sessionKey: string,
limit = 20,
): Promise<LastfmRecentTrack[]> {
try {
const data = await call({ method: 'user.getRecentTracks', user: username, sk: sessionKey, limit: String(limit) }, false, true);
const items = data?.recenttracks?.track;
if (!items) return [];
const arr = Array.isArray(items) ? items : [items];
return arr.map((t: any) => ({
name: t.name,
artist: t.artist?.['#text'] ?? t.artist?.name ?? '',
album: t.album?.['#text'] ?? '',
timestamp: t.date?.uts ? Number(t.date.uts) : null,
nowPlaying: t['@attr']?.nowplaying === 'true',
}));
} catch {
return [];
}
}
export type LastfmPeriod = 'overall' | '7day' | '1month' | '3month' | '6month' | '12month';
export interface LastfmTopArtist {
name: string;
playcount: string;
}
export interface LastfmTopAlbum {
name: string;
playcount: string;
artist: string;
}
export interface LastfmTopTrack {
name: string;
playcount: string;
artist: string;
}
export async function lastfmGetTopArtists(
username: string,
sessionKey: string,
period: LastfmPeriod,
limit = 10,
): Promise<LastfmTopArtist[]> {
try {
const data = await call({ method: 'user.getTopArtists', user: username, sk: sessionKey, period, limit: String(limit) }, false, true);
const items = data?.topartists?.artist;
if (!items) return [];
const arr = Array.isArray(items) ? items : [items];
return arr.map((a: any) => ({ name: a.name, playcount: a.playcount }));
} catch {
return [];
}
}
export async function lastfmGetTopAlbums(
username: string,
sessionKey: string,
period: LastfmPeriod,
limit = 10,
): Promise<LastfmTopAlbum[]> {
try {
const data = await call({ method: 'user.getTopAlbums', user: username, sk: sessionKey, period, limit: String(limit) }, false, true);
const items = data?.topalbums?.album;
if (!items) return [];
const arr = Array.isArray(items) ? items : [items];
return arr.map((a: any) => ({ name: a.name, playcount: a.playcount, artist: a.artist?.name ?? '' }));
} catch {
return [];
}
}
export async function lastfmGetTopTracks(
username: string,
sessionKey: string,
period: LastfmPeriod,
limit = 10,
): Promise<LastfmTopTrack[]> {
try {
const data = await call({ method: 'user.getTopTracks', user: username, sk: sessionKey, period, limit: String(limit) }, false, true);
const items = data?.toptracks?.track;
if (!items) return [];
const arr = Array.isArray(items) ? items : [items];
return arr.map((t: any) => ({ name: t.name, playcount: t.playcount, artist: t.artist?.name ?? '' }));
} catch {
return [];
}
}
export async function lastfmScrobble(
track: { title: string; artist: string; album: string; duration: number },
timestamp: number,
sessionKey: string,
): Promise<void> {
try {
await call({
method: 'track.scrobble',
track: track.title,
artist: track.artist,
album: track.album,
duration: String(Math.round(track.duration)),
timestamp: String(Math.floor(timestamp / 1000)),
sk: sessionKey,
}, true, false);
} catch {
// best effort
}
}
export interface LastfmTrackInfo {
listeners: number;
playcount: number;
userPlaycount: number | null;
userLoved: boolean;
tags: string[];
url: string | null;
}
export async function lastfmGetTrackInfo(
artist: string,
track: string,
username?: string,
): Promise<LastfmTrackInfo | null> {
try {
const params: Record<string, string> = { method: 'track.getInfo', artist, track };
if (username) params.username = username;
const data = await call(params, false, true);
const t = data?.track;
if (!t) return null;
const rawTags = t.toptags?.tag;
const tags = rawTags
? (Array.isArray(rawTags) ? rawTags : [rawTags]).map((tg: any) => String(tg.name)).slice(0, 5)
: [];
const userPc = t.userplaycount != null ? Number(t.userplaycount) : null;
return {
listeners: Number(t.listeners) || 0,
playcount: Number(t.playcount) || 0,
userPlaycount: Number.isFinite(userPc) ? userPc : null,
userLoved: t.userloved === '1' || t.userloved === 1,
tags,
url: t.url ?? null,
};
} catch {
return null;
}
}
export interface LastfmArtistStats {
listeners: number;
playcount: number;
userPlaycount: number | null;
tags: string[];
url: string | null;
bio: string | null;
}
export async function lastfmGetArtistStats(
artist: string,
username?: string,
): Promise<LastfmArtistStats | null> {
try {
const params: Record<string, string> = { method: 'artist.getInfo', artist };
if (username) params.username = username;
const data = await call(params, false, true);
const a = data?.artist;
if (!a) return null;
const rawTags = a.tags?.tag;
const tags = rawTags
? (Array.isArray(rawTags) ? rawTags : [rawTags]).map((tg: any) => String(tg.name)).slice(0, 5)
: [];
const userPc = a.stats?.userplaycount != null ? Number(a.stats.userplaycount) : null;
const bioRaw = (a.bio?.content || a.bio?.summary || '').trim();
return {
listeners: Number(a.stats?.listeners) || 0,
playcount: Number(a.stats?.playcount) || 0,
userPlaycount: Number.isFinite(userPc) ? userPc : null,
tags,
url: a.url ?? null,
bio: bioRaw || null,
};
} catch {
return null;
}
}
+21
View File
@@ -892,3 +892,24 @@ export function subscribeLibrarySyncIdle(
handler(payload),
);
}
// ── Genre tags startup backfill (multi-genre local index) ───────────────
export interface GenreTagsInspectDto {
needed: boolean;
totalTracks: number;
doneTracks: number;
}
export interface GenreTagsProgressEvent {
done: number;
total: number;
}
export function libraryGenreTagsInspect(): Promise<GenreTagsInspectDto> {
return invoke<GenreTagsInspectDto>('library_genre_tags_inspect');
}
export function libraryGenreTagsRun(): Promise<void> {
return invoke<void>('library_genre_tags_run');
}
+4
View File
@@ -1,4 +1,5 @@
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from './subsonicTypes';
import { parseItemGenres } from '../utils/library/genreTags';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { ndLogin } from './navidromeAdmin';
@@ -54,6 +55,7 @@ function mapNdSong(o: Record<string, unknown>): SubsonicSong {
userRating: asNumber(o.rating),
starred: o.starred ? asString(o.starredAt) || 'true' : undefined,
genre: typeof o.genre === 'string' ? o.genre : undefined,
genres: parseItemGenres(o.genres),
bitRate: asNumber(o.bitRate),
suffix: typeof o.suffix === 'string' ? o.suffix : undefined,
contentType: typeof o.contentType === 'string' ? o.contentType : undefined,
@@ -166,6 +168,7 @@ function mapNdAlbum(o: Record<string, unknown>): SubsonicAlbum {
duration: asNumber(o.duration) ?? 0,
year: asNumber(o.maxYear) ?? asNumber(o.year),
genre: typeof o.genre === 'string' ? o.genre : undefined,
genres: parseItemGenres(o.genres),
starred: starredFlag ? (starredAt ?? 'true') : undefined,
userRating: asNumber(o.rating),
isCompilation: o.compilation === true,
@@ -383,6 +386,7 @@ export async function ndListLosslessAlbumsPage(req: NdLosslessPageRequest): Prom
duration: 0,
year: asNumber(o.year),
genre: typeof o.genre === 'string' ? o.genre : undefined,
genres: parseItemGenres(o.genres),
};
pageEntries.push({ album, bitDepth, sampleRate: asNumber(o.sampleRate) ?? 0 });
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { nowPlayingPresence, NOW_PLAYING_IDLE_MINUTES } from './nowPlayingPresence';
import type { SubsonicNowPlaying, PlaybackReportState } from './subsonicTypes';
// The function only reads `state` and `minutesAgo`; cast a minimal fixture.
function entry(partial: { state?: PlaybackReportState; minutesAgo?: number }): SubsonicNowPlaying {
return { minutesAgo: 0, ...partial } as SubsonicNowPlaying;
}
describe('nowPlayingPresence', () => {
it('maps live playbackReport state authoritatively', () => {
expect(nowPlayingPresence(entry({ state: 'playing' }))).toBe('playing');
expect(nowPlayingPresence(entry({ state: 'starting' }))).toBe('playing');
expect(nowPlayingPresence(entry({ state: 'paused' }))).toBe('paused');
expect(nowPlayingPresence(entry({ state: 'stopped' }))).toBe('idle');
});
it('live state wins over a stale minutesAgo', () => {
// A paused session last reported minutes ago is still "paused", not idle.
expect(nowPlayingPresence(entry({ state: 'paused', minutesAgo: 99 }))).toBe('paused');
expect(nowPlayingPresence(entry({ state: 'playing', minutesAgo: 99 }))).toBe('playing');
});
it('falls back to recency for legacy entries without a state', () => {
expect(nowPlayingPresence(entry({ minutesAgo: 0 }))).toBe('playing');
expect(nowPlayingPresence(entry({ minutesAgo: NOW_PLAYING_IDLE_MINUTES }))).toBe('playing');
expect(nowPlayingPresence(entry({ minutesAgo: NOW_PLAYING_IDLE_MINUTES + 1 }))).toBe('idle');
});
});
+34
View File
@@ -0,0 +1,34 @@
import type { SubsonicNowPlaying } from './subsonicTypes';
/**
* Derived liveness of a now-playing entry, surfaced as the indicator dot in the
* "Who is listening?" popover. Unifies the two sources the server can provide:
* the OpenSubsonic `playbackReport` transport state (Navidrome 0.62, when
* present) and the classic `getNowPlaying` `minutesAgo` recency (legacy
* fallback). This replaces rendering the raw "Nm ago" line.
*/
export type NowPlayingPresence = 'playing' | 'paused' | 'idle';
/**
* Minutes since last activity beyond which a legacy entry (one without
* playbackReport transport state) is treated as idle rather than actively
* playing. Entries that carry a live `state` ignore this entirely.
*/
export const NOW_PLAYING_IDLE_MINUTES = 5;
/** Resolve the liveness an entry should display. Live `state` is authoritative;
* otherwise recency (`minutesAgo`) decides playing vs idle. */
export function nowPlayingPresence(entry: SubsonicNowPlaying): NowPlayingPresence {
// playbackReport extension: trust the reported transport state.
switch (entry.state) {
case 'playing':
case 'starting':
return 'playing';
case 'paused':
return 'paused';
case 'stopped':
return 'idle';
}
// Legacy getNowPlaying: only recency is known. Recent = playing, stale = idle.
return entry.minutesAgo > NOW_PLAYING_IDLE_MINUTES ? 'idle' : 'playing';
}
+21
View File
@@ -22,6 +22,7 @@ function reset() {
instantMixProbeByServer: {},
audiomuseNavidromeByServer: {},
audiomuseNavidromeIssueByServer: {},
openSubsonicExtensionsByServer: {},
} as never);
}
@@ -57,6 +58,26 @@ describe('scheduleInstantMixProbeForServer (idempotency)', () => {
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('stores the full extension list and caches both sonicSimilarity and playbackReport', async () => {
fetchMock.mockResolvedValue(['sonicSimilarity', 'playbackReport']);
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
await flush();
const s = useAuthStore.getState();
expect(s.openSubsonicExtensionsByServer[SID]).toEqual(['sonicSimilarity', 'playbackReport']);
expect(s.audiomusePluginProbeByServer[SID]).toBe('present');
});
it('stores the list on a non-Navidrome OpenSubsonic server without driving the AudioMuse probe', async () => {
fetchMock.mockResolvedValue(['playbackReport']);
const idGonic: SubsonicServerIdentity = { type: 'gonic', serverVersion: '0.16.0', openSubsonic: true };
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', idGonic);
expect(fetchMock).toHaveBeenCalledTimes(1);
await flush();
const s = useAuthStore.getState();
expect(s.openSubsonicExtensionsByServer[SID]).toEqual(['playbackReport']);
expect(s.audiomusePluginProbeByServer[SID]).toBeUndefined();
});
});
describe('setSubsonicServerIdentity (version-change cache invalidation)', () => {
+26 -34
View File
@@ -16,20 +16,11 @@ import {
import { neededProbeIds } from '../serverCapabilities/resolve';
import {
SUBSONIC_CLIENT,
SUBSONIC_API_VERSION,
api,
apiWithCredentials,
secureRandomSalt,
} from './subsonicClient';
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 };
}
import type { PingWithCredentialsResult, SubsonicSong } from './subsonicTypes';
export async function ping(): Promise<boolean> {
try {
@@ -52,33 +43,21 @@ 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: SUBSONIC_API_VERSION, c: SUBSONIC_CLIENT, f: 'json' },
params: { u: username, t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json' },
paramsSerializer: { indexes: null },
timeout: 15000,
});
const data = resp.data?.['subsonic-response'];
const ok = data?.status === 'ok';
const identity = {
return {
ok,
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, failure: { reason: 'network', message: String(detail) } };
return { ok: false };
}
}
@@ -161,16 +140,29 @@ export function scheduleInstantMixProbeForServer(
const store = useAuthStore.getState();
if (probeIds.has(PROBE_OPENSUBSONIC_EXTENSIONS)) {
// One `getOpenSubsonicExtensions` fetch answers every extension-gated feature.
// The AudioMuse `sonicSimilarity` lifecycle (with its opt-in side effects) is
// only driven on Navidrome ≥ 0.62, so broadening the probe to all OpenSubsonic
// servers for `playbackReport` does not disturb the legacy Instant Mix opt-in.
const audiomuseEligible = ctx.isNavidrome && ctx.semverGte([0, 62, 0]);
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');
const listMissing = store.openSubsonicExtensionsByServer[serverId] === undefined;
// Re-probe without a definitive cached result, on force / prior error, or when
// the extension list is missing (self-heal for state persisted before it was
// captured). `probing` means an in-flight fetch — skip to avoid a duplicate.
const audiomuseStale = audiomuseEligible && (cached === undefined || cached === 'error');
if (force || listMissing || audiomuseStale) {
if (audiomuseEligible) 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);
const st = useAuthStore.getState();
if (extensions === null) {
if (audiomuseEligible) st.setAudiomusePluginProbe(serverId, 'error');
return;
}
st.setOpenSubsonicExtensions(serverId, extensions);
if (audiomuseEligible) {
st.setAudiomusePluginProbe(serverId, extensions.includes(SONIC_SIMILARITY_EXTENSION) ? 'present' : 'absent');
}
});
}
}
+1 -10
View File
@@ -8,15 +8,6 @@ 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);
@@ -26,7 +17,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: SUBSONIC_API_VERSION, c: SUBSONIC_CLIENT, f: 'json' };
return { u: username, t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json' };
}
export function restBaseFromUrl(serverUrl: string): string {
+29 -2
View File
@@ -1,7 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { scrobbleSong } from './subsonicScrobble';
import { reportNowPlaying, scrobbleSong } from './subsonicScrobble';
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
const { apiForServerMock } = vi.hoisted(() => ({
apiForServerMock: vi.fn(async () => ({})),
@@ -12,12 +13,13 @@ vi.mock('./subsonicClient', () => ({
apiForServer: apiForServerMock,
}));
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
shouldAttemptSubsonicForServer: () => true,
shouldAttemptSubsonicForServer: vi.fn(() => true),
}));
describe('subsonicScrobble', () => {
beforeEach(() => {
apiForServerMock.mockClear();
vi.mocked(shouldAttemptSubsonicForServer).mockImplementation(() => true);
useAuthStore.setState({
servers: [
{ id: 'a', name: 'A', url: 'http://a.test', username: 'u', password: 'p' },
@@ -41,4 +43,29 @@ describe('subsonicScrobble', () => {
expect.objectContaining({ id: 't1', submission: true, time: 1_700_000_000_000 }),
);
});
it('reportNowPlaying and scrobbleSong use the presence guard without trackId', async () => {
vi.mocked(shouldAttemptSubsonicForServer).mockImplementation(
(_serverId: string, trackId?: string) => trackId === undefined,
);
await reportNowPlaying('t-local', 'a');
await scrobbleSong('t-local', 1_700_000_000_000, 'a');
expect(shouldAttemptSubsonicForServer).toHaveBeenCalledWith('a');
expect(shouldAttemptSubsonicForServer).not.toHaveBeenCalledWith('a', expect.anything());
expect(apiForServerMock).toHaveBeenCalledTimes(2);
expect(apiForServerMock).toHaveBeenNthCalledWith(
1,
'a',
'scrobble.view',
expect.objectContaining({ id: 't-local', submission: false }),
);
expect(apiForServerMock).toHaveBeenNthCalledWith(
2,
'a',
'scrobble.view',
expect.objectContaining({ id: 't-local', submission: true }),
);
});
});
+43 -2
View File
@@ -1,5 +1,5 @@
import { api, apiForServer } from './subsonicClient';
import type { SubsonicNowPlaying } from './subsonicTypes';
import type { PlaybackReportState, SubsonicNowPlaying } from './subsonicTypes';
import { patchLibraryTrackOnUse } from '../utils/library/patchOnUse';
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
@@ -9,7 +9,9 @@ async function scrobbleOnServer(
submission: boolean,
time?: number,
): Promise<void> {
if (!shouldAttemptSubsonicForServer(serverId, id)) return;
// Presence / play-count updates are not playback-byte fetches — omit trackId so
// hot cache, offline library, and favorites-auto do not suppress Navidrome calls.
if (!shouldAttemptSubsonicForServer(serverId)) return;
const params: Record<string, unknown> = { id, submission };
if (time !== undefined) params.time = time;
await apiForServer(serverId, 'scrobble.view', params);
@@ -37,6 +39,45 @@ export async function reportNowPlaying(id: string, serverId: string): Promise<vo
}
}
export interface ReportPlaybackParams {
mediaId: string;
positionMs: number;
state: PlaybackReportState;
/** Effective playback speed; lets the server extrapolate position correctly. */
playbackRate?: number;
/**
* When true, the server records live presence only and skips its scrobble /
* play-count side effects. psysonic keeps those on the dedicated `scrobble.view`
* channel (50% rule), so the timeline never double-counts a play.
*/
ignoreScrobble?: boolean;
}
/**
* OpenSubsonic `playbackReport` extension (Navidrome 0.62): report a point on
* the playback timeline for rich, live now-playing. Best-effort and gated by the
* same reachability guard as presence scrobbles; callers route through
* `playbackReportSession` which only invokes this when the server advertises the
* extension (otherwise the legacy `reportNowPlaying` presence call is used).
*/
export async function reportPlayback(serverId: string, params: ReportPlaybackParams): Promise<void> {
if (!serverId) return;
if (!shouldAttemptSubsonicForServer(serverId)) return;
const query: Record<string, unknown> = {
mediaId: params.mediaId,
mediaType: 'song',
positionMs: Math.max(0, Math.floor(params.positionMs)),
state: params.state,
};
if (params.playbackRate !== undefined) query.playbackRate = params.playbackRate;
if (params.ignoreScrobble !== undefined) query.ignoreScrobble = params.ignoreScrobble;
try {
await apiForServer(serverId, 'reportPlayback.view', query);
} catch {
// best effort
}
}
export async function getNowPlaying(): Promise<SubsonicNowPlaying[]> {
try {
const data = await api<{ nowPlaying: { entry?: SubsonicNowPlaying | SubsonicNowPlaying[] } }>('getNowPlaying.view', { _t: Date.now() });
+11 -7
View File
@@ -1,4 +1,5 @@
import { useAuthStore } from '../store/authStore';
import { genreTagsFor } from '../utils/library/genreTags';
import { getArtists } from './subsonicArtists';
import { getAlbumList, getRandomSongs } from './subsonicLibrary';
import type {
@@ -54,14 +55,17 @@ export async function fetchStatisticsLibraryAggregates(): Promise<StatisticsLibr
albumsCounted += 1;
const sc = a.songCount ?? 0;
songsCounted += sc;
const label = (a.genre?.trim()) ? a.genre.trim() : '';
let g = genreAgg.get(label);
if (!g) {
g = { songCount: 0, albumCount: 0 };
genreAgg.set(label, g);
const tags = genreTagsFor(a);
const labels = tags.length > 0 ? tags : [''];
for (const label of labels) {
let g = genreAgg.get(label);
if (!g) {
g = { songCount: 0, albumCount: 0 };
genreAgg.set(label, g);
}
g.songCount += sc;
g.albumCount += 1;
}
g.songCount += sc;
g.albumCount += 1;
}
if (albums.length < pageSize) break;
offset += pageSize;
+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, SUBSONIC_API_VERSION, secureRandomSalt } from './subsonicClient';
import { restBaseFromUrl, SUBSONIC_CLIENT, 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: SUBSONIC_API_VERSION,
v: '1.16.1',
c: SUBSONIC_CLIENT,
f: 'json',
});
@@ -36,7 +36,7 @@ function streamUrlFromProfile(
u: username,
t: token,
s: salt,
v: SUBSONIC_API_VERSION,
v: '1.16.1',
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: SUBSONIC_API_VERSION, c: SUBSONIC_CLIENT, f: 'json',
t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json',
});
return `${baseUrl}/rest/download.view?${p.toString()}`;
}
+19 -14
View File
@@ -1,5 +1,10 @@
import type { SubsonicServerIdentity } from '../utils/server/subsonicServerIdentity';
/** OpenSubsonic `ItemGenre` on songs/albums (atomic genres from the server). */
export interface SubsonicItemGenre {
name: string;
}
export interface SubsonicAlbum {
id: string;
name: string;
@@ -11,6 +16,8 @@ export interface SubsonicAlbum {
playCount?: number;
year?: number;
genre?: string;
/** OpenSubsonic atomic genres — preferred over splitting `genre`. */
genres?: SubsonicItemGenre[];
starred?: string;
recordLabel?: string;
created?: string;
@@ -72,6 +79,8 @@ export interface SubsonicSong {
channelCount?: number;
starred?: string;
genre?: string;
/** OpenSubsonic atomic genres — preferred over splitting `genre`. */
genres?: SubsonicItemGenre[];
path?: string;
albumArtist?: string;
/** OpenSubsonic: single-string album-artist for display (mirrors `albumArtists` joined). */
@@ -133,11 +142,20 @@ export interface SubsonicPlaylist {
coverArt?: string;
}
/** OpenSubsonic `playbackReport` lifecycle state, per the extension spec. */
export type PlaybackReportState = 'starting' | 'playing' | 'paused' | 'stopped';
export interface SubsonicNowPlaying extends SubsonicSong {
username: string;
minutesAgo: number;
playerId: number;
playerName: string;
/** OpenSubsonic `playbackReport`: live transport state for this stream. */
state?: PlaybackReportState;
/** OpenSubsonic `playbackReport`: server-extrapolated position in milliseconds. */
positionMs?: number;
/** OpenSubsonic `playbackReport`: effective playback speed (1.0 = normal). */
playbackRate?: number;
}
export interface SubsonicArtist {
@@ -201,20 +219,7 @@ export interface SubsonicDirectory {
child: SubsonicDirectoryEntry[];
}
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 type PingWithCredentialsResult = SubsonicServerIdentity & { ok: boolean };
export interface RandomSongsFilters {
size?: number;
+4 -2
View File
@@ -30,7 +30,7 @@ import {
mainRouteInpageScrollViewportId,
} from '../constants/appScroll';
import ConnectionIndicator from '../components/ConnectionIndicator';
import LastfmIndicator from '../components/LastfmIndicator';
import MusicNetworkIndicator from '../components/MusicNetworkIndicator';
import OfflineBanner from '../components/OfflineBanner';
import AppUpdater from '../components/AppUpdater';
import TitleBar from '../components/TitleBar';
@@ -44,6 +44,7 @@ import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
import { useWindowFullscreenState } from '../hooks/useWindowFullscreenState';
import { useNowPlayingTrayTitle } from '../hooks/useNowPlayingTrayTitle';
import { usePrefetchReleaseNotes } from '../hooks/usePrefetchReleaseNotes';
import { useTrayMenuI18n } from '../hooks/useTrayMenuI18n';
import { useServerCapabilitiesProbe } from '../hooks/useServerCapabilitiesProbe';
import { useQueueResizer } from '../hooks/useQueueResizer';
@@ -164,6 +165,7 @@ export function AppShell() {
// Post-update changelog is now surfaced via a dismissible banner in the
// sidebar (WhatsNewBanner) that links to the /whats-new page — no auto
// modal takeover on startup.
usePrefetchReleaseNotes();
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(readInitialSidebarCollapsed);
const isMainScrolling = useMainScrollingIndicator(location.pathname);
@@ -257,7 +259,7 @@ export function AppShell() {
{import.meta.env.DEV && <DevNetworkModeToggle />}
<div className="spacer" />
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
<LastfmIndicator />
<MusicNetworkIndicator />
<NowPlayingDropdown />
<OrbitStartTrigger />
{!isMobile && !isQueueVisible && (
+26 -13
View File
@@ -1,12 +1,17 @@
import type { ReactNode } from 'react';
import { retryServerIndexMigration } from '../hooks/useMigrationOrchestrator';
import { useTranslation } from 'react-i18next';
import { retryBlockingMigration } from '../hooks/useMigrationOrchestrator';
import { useMigrationStore } from '../store/migrationStore';
function MigrationModal() {
const { t } = useTranslation();
const phase = useMigrationStore(s => s.phase);
const step = useMigrationStore(s => s.step);
const progress = useMigrationStore(s => s.progress);
const genreTagsProgress = useMigrationStore(s => s.genreTagsProgress);
const inspect = useMigrationStore(s => s.inspect);
const error = useMigrationStore(s => s.lastError);
const isGenreTags = step === 'genreTags';
const migratedRows = (inspect?.library.totalLegacyRows ?? 0) + (inspect?.analysis.totalLegacyRows ?? 0);
return (
@@ -30,42 +35,50 @@ function MigrationModal() {
>
{phase === 'inspecting' && (
<>
<h3>Preparing data update</h3>
<p style={{ color: 'var(--text-muted)' }}>Looking at your library and analysis cache</p>
<h3>{isGenreTags ? t('migration.genreTagsTitle') : t('migration.preparing')}</h3>
<p style={{ color: 'var(--text-muted)' }}>
{isGenreTags ? t('migration.genreTagsBody') : t('migration.preparingBody')}
</p>
</>
)}
{phase === 'running' && (
<>
<h3>Migrating data</h3>
<h3>{isGenreTags ? t('migration.genreTagsTitle') : t('migration.migrating')}</h3>
<p style={{ color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
{progress ? `${progress.stage} - ${progress.table}` : 'running'}
{isGenreTags
? t('migration.genreTagsBody')
: (progress ? `${progress.stage} - ${progress.table}` : t('migration.working'))}
</p>
<p style={{ color: 'var(--text-muted)' }}>
{progress ? `${progress.done} / ${progress.total}` : 'working…'}
{isGenreTags
? (genreTagsProgress
? `${genreTagsProgress.done} / ${genreTagsProgress.total}`
: t('migration.working'))
: (progress ? `${progress.done} / ${progress.total}` : t('migration.working'))}
</p>
{inspect?.hasSkippedUnknownServerRows ? (
{!isGenreTags && inspect?.hasSkippedUnknownServerRows ? (
<p style={{ color: 'var(--text-muted)', marginTop: '0.5rem' }}>
Rows for removed servers were skipped and old backup DB will be removed after successful switch.
{t('migration.skippedRows')}
</p>
) : null}
</>
)}
{phase === 'error' && (
<>
<h3>Migration failed</h3>
<h3>{isGenreTags ? t('migration.genreTagsFailed') : t('migration.failed')}</h3>
<p style={{ color: 'var(--text-muted)' }}>{String(error ?? '').slice(0, 200)}</p>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<button className="btn-primary" onClick={() => retryServerIndexMigration()}>Retry</button>
<button className="btn-primary" onClick={() => retryBlockingMigration()}>{t('migration.retry')}</button>
<button className="btn-surface" onClick={() => navigator.clipboard.writeText(String(error ?? ''))}>
Copy details
{t('migration.copyDetails')}
</button>
</div>
</>
)}
{phase === 'completed' && (
<>
<h3>Update complete</h3>
<p style={{ color: 'var(--text-muted)' }}>{migratedRows} rows migrated</p>
<h3>{t('migration.complete')}</h3>
<p style={{ color: 'var(--text-muted)' }}>{t('migration.completeRows', { count: migratedRows })}</p>
</>
)}
</div>
+2
View File
@@ -1,5 +1,6 @@
import { installQueueUndoHotkey } from '../store/queueUndoHotkey';
import { configureStartupSplash } from './startupSplash';
import { setupMusicNetworkRuntime } from './musicNetworkBridge';
import { invoke } from '@tauri-apps/api/core';
import { getWindowKind } from './windowKind';
import { migrateThemeSelection } from '../utils/themes/themeMigration';
@@ -119,4 +120,5 @@ export function runPreReactBootstrap(): void {
pushUserAgentToBackend();
pushLoggingModeToBackend();
installQueueUndoHotkey();
setupMusicNetworkRuntime();
}
+38
View File
@@ -0,0 +1,38 @@
// Wires the Music Network runtime singleton to the app: the auth store backs the
// MusicNetworkStore port (reads are live via getState, so init order vs. rehydrate
// does not matter), and the Tauri shell backs the host (browser auth + uuid).
import { open } from '@tauri-apps/plugin-shell';
import {
initMusicNetworkRuntime,
type MusicNetworkStore,
type RuntimeHost,
} from '../music-network';
import { useAuthStore } from '../store/authStore';
const store: MusicNetworkStore = {
getState: () => {
const s = useAuthStore.getState();
return {
scrobblingMasterEnabled: s.scrobblingMasterEnabled,
enrichmentPrimaryId: s.enrichmentPrimaryId,
accounts: s.musicNetworkAccounts,
};
},
setAccounts: accounts => useAuthStore.getState().setMusicNetworkAccounts(accounts),
setEnrichmentPrimaryId: id => useAuthStore.getState().setEnrichmentPrimaryId(id),
};
const host: RuntimeHost = {
openExternal: url => open(url),
newId: () => crypto.randomUUID(),
};
let initialized = false;
/** Initialize the Music Network runtime once, before any consumer calls it. */
export function setupMusicNetworkRuntime(): void {
if (initialized) return;
initialized = true;
initMusicNetworkRuntime(store, host);
}
+4 -3
View File
@@ -21,7 +21,7 @@ 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 { albumArtistDisplayName, deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
import { coverServerScopeForServerId } from '../cover/serverScope';
import { appendServerQuery } from '../utils/navigation/detailServerScope';
@@ -93,6 +93,7 @@ function AlbumCard({
}, [coverRef, displayCssPx]);
const isNewAlbum = isAlbumRecentlyAdded(album.created);
const artistRefs = useMemo(() => deriveAlbumArtistRefs(album), [album]);
const artistLabel = useMemo(() => albumArtistDisplayName(album), [album]);
const handleClick = (opts?: { shiftKey?: boolean }) => {
if (selectionMode) { onToggleSelect?.(album.id, opts); return; }
@@ -105,7 +106,7 @@ function AlbumCard({
onClick={e => handleClick({ shiftKey: e.shiftKey })}
role="button"
tabIndex={0}
aria-label={`${album.name} von ${album.artist}`}
aria-label={`${album.name} von ${artistLabel}`}
onKeyDown={e => e.key === 'Enter' && handleClick()}
onContextMenu={(e) => {
e.preventDefault();
@@ -213,7 +214,7 @@ function AlbumCard({
<p className="album-card-artist truncate">
<OpenArtistRefInline
refs={artistRefs}
fallbackName={album.artist}
fallbackName={artistLabel}
onGoArtist={id => navigate(`/artist/${id}`)}
as="none"
linkTag="span"
+4 -2
View File
@@ -25,6 +25,7 @@ import { useLongPressAction } from '../hooks/useLongPressAction';
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
import { formatHumanHoursMinutes } from '../utils/format/formatHumanDuration';
import AlbumRow from './AlbumRow';
import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs';
const ANCHOR_HISTORY_KEY_PREFIX = 'psysonic_because_anchor_history:';
const PICKS_HISTORY_KEY_PREFIX = 'psysonic_because_picks:';
@@ -599,6 +600,7 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e
});
const imgSrc = coverImgSrc(coverHandle.src);
const bgResolved = coverHandle.src;
const artistLabel = useMemo(() => albumArtistDisplayName(album), [album]);
const handleOpen = () => navigate(`/album/${album.id}`);
const handleEnqueue = async (e: React.MouseEvent) => {
e.stopPropagation();
@@ -620,7 +622,7 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e
className={`because-card${enter ? ' because-card--slot-enter' : ''}`}
onClick={handleOpen}
onKeyDown={e => { if (e.key === 'Enter') handleOpen(); }}
aria-label={`${album.name} ${album.artist}`}
aria-label={`${album.name} ${artistLabel}`}
>
{!disableArtwork && bgResolved && (
<div
@@ -683,7 +685,7 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e
{t('home.similarTo', { artist: anchor })}
</div>
<div className="because-card-title">{album.name}</div>
<div className="because-card-artist">{album.artist}</div>
<div className="because-card-artist">{artistLabel}</div>
</div>
{album.releaseTypes && album.releaseTypes[0] ? (
<div className="because-card-pills">
-98
View File
@@ -1,98 +0,0 @@
import React, { useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Sparkles } from 'lucide-react';
import { version } from '../../package.json';
import changelogRaw from '../../CHANGELOG.md?raw';
import { useAuthStore } from '../store/authStore';
import { findChangelogReleaseEntry } from '../utils/changelog/changelogReleaseMatch';
function renderInline(text: string): React.ReactNode[] {
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g);
return parts.map((part, i) => {
if (part.startsWith('**') && part.endsWith('**'))
return <strong key={i}>{part.slice(2, -2)}</strong>;
if (part.startsWith('*') && part.endsWith('*'))
return <em key={i}>{part.slice(1, -1)}</em>;
if (part.startsWith('`') && part.endsWith('`'))
return <code key={i} className="changelog-code">{part.slice(1, -1)}</code>;
return part;
});
}
interface Props {
onClose: () => void;
}
export default function ChangelogModal({ onClose }: Props) {
const { t } = useTranslation();
const [dontShow, setDontShow] = useState(false);
const setShowChangelogOnUpdate = useAuthStore(s => s.setShowChangelogOnUpdate);
const setLastSeenChangelogVersion = useAuthStore(s => s.setLastSeenChangelogVersion);
const currentVersionData = useMemo(() => findChangelogReleaseEntry(changelogRaw, version), []);
const handleClose = () => {
if (dontShow) setShowChangelogOnUpdate(false);
setLastSeenChangelogVersion(version);
onClose();
};
if (!currentVersionData) return null;
return createPortal(
<>
<div className="eq-popup-backdrop" onClick={handleClose} style={{ zIndex: 300 }} />
<div
className="eq-popup"
style={{ zIndex: 301, width: 'min(580px, 92vw)', gap: 0, maxHeight: '85vh', display: 'flex', flexDirection: 'column' }}
>
<div className="eq-popup-header" style={{ flexShrink: 0 }}>
<span className="eq-popup-title" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Sparkles size={16} style={{ color: 'var(--accent)' }} />
{t('changelog.modalTitle')} v{version}
</span>
{currentVersionData.date && (
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 'auto' }}>
{currentVersionData.date}
</span>
)}
</div>
<div style={{ overflowY: 'auto', padding: '12px 20px', flex: 1 }}>
{currentVersionData.body.split('\n').map((line, i) => {
if (line.startsWith('### '))
return <div key={i} className="changelog-h3">{renderInline(line.slice(4))}</div>;
if (line.startsWith('#### '))
return <div key={i} className="changelog-h4">{renderInline(line.slice(5))}</div>;
if (line.startsWith('- '))
return <div key={i} className="changelog-item">{renderInline(line.slice(2))}</div>;
if (line.trim() === '') return null;
return <div key={i} className="changelog-text">{renderInline(line)}</div>;
})}
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 20px',
borderTop: '1px solid var(--border-subtle)',
flexShrink: 0,
gap: '1rem',
}}
>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: 13, color: 'var(--text-secondary)', cursor: 'pointer', userSelect: 'none' }}>
<input type="checkbox" checked={dontShow} onChange={e => setDontShow(e.target.checked)} />
{t('changelog.dontShowAgain')}
</label>
<button className="btn btn-primary" onClick={handleClose}>
{t('changelog.close')}
</button>
</div>
</div>
</>,
document.body
);
}
-8
View File
@@ -30,14 +30,6 @@ vi.mock('@/api/subsonic', () => ({
unstar: vi.fn(async () => undefined),
}));
vi.mock('@/api/lastfm', () => ({
lastfmScrobble: vi.fn(async () => undefined),
lastfmUpdateNowPlaying: vi.fn(async () => undefined),
lastfmLoveTrack: vi.fn(async () => undefined),
lastfmUnloveTrack: vi.fn(async () => undefined),
lastfmGetTrackLoved: vi.fn(async () => false),
lastfmGetAllLovedTracks: vi.fn(async () => []),
}));
vi.mock('@/utils/orbitBulkGuard', () => ({
orbitBulkGuard: vi.fn(async () => true),
+5 -5
View File
@@ -48,7 +48,7 @@ export default function ContextMenu() {
const navigate = useNavigate();
const navigatePlaybackLibrary = usePlaybackLibraryNavigate();
const orbitRole = useOrbitStore(s => s.role);
const { contextMenu, closeContextMenu, playTrack, enqueue, playNext, queueItems, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore(
const { contextMenu, closeContextMenu, playTrack, enqueue, playNext, queueItems, currentTrack, removeTrack, networkLovedCache, setNetworkLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore(
useShallow(s => ({
contextMenu: s.contextMenu,
closeContextMenu: s.closeContextMenu,
@@ -58,8 +58,8 @@ export default function ContextMenu() {
queueItems: s.queueItems,
currentTrack: s.currentTrack,
removeTrack: s.removeTrack,
lastfmLovedCache: s.lastfmLovedCache,
setLastfmLovedForSong: s.setLastfmLovedForSong,
networkLovedCache: s.networkLovedCache,
setNetworkLovedForSong: s.setNetworkLovedForSong,
starredOverrides: s.starredOverrides,
setStarredOverride: s.setStarredOverride,
openSongInfo: s.openSongInfo,
@@ -234,8 +234,8 @@ export default function ContextMenu() {
closeContextMenu={closeContextMenu}
starredOverrides={starredOverrides}
setStarredOverride={setStarredOverride}
lastfmLovedCache={lastfmLovedCache}
setLastfmLovedForSong={setLastfmLovedForSong}
networkLovedCache={networkLovedCache}
setNetworkLovedForSong={setNetworkLovedForSong}
openSongInfo={openSongInfo}
userRatingOverrides={userRatingOverrides}
setKeyboardRating={setKeyboardRating}
+6 -1
View File
@@ -19,6 +19,7 @@ import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
import { useLongPressAction } from '../hooks/useLongPressAction';
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs';
const INTERVAL_MS = 10000;
const HERO_ALBUM_COUNT = 8;
@@ -266,6 +267,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
}, [albums.length, startTimer]);
const album = albums[activeIdx] ?? null;
const heroArtistLabel = useMemo(
() => (album ? albumArtistDisplayName(album) : ''),
[album],
);
// Lazily fetch format label for the currently-visible album (cached by id)
const [albumFormats, setAlbumFormats] = useState<Record<string, string>>({});
@@ -335,7 +340,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
<div className="hero-text">
<span className="hero-eyebrow">{t('hero.eyebrow')}</span>
<h2 className="hero-title">{album.name}</h2>
<p className="hero-artist">{album.artist}</p>
<p className="hero-artist">{heroArtistLabel}</p>
<div className="hero-meta">
{album.year && <span className="badge">{album.year}</span>}
{album.genre && <span className="badge">{album.genre}</span>}
-39
View File
@@ -1,39 +0,0 @@
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import LastfmIcon from './LastfmIcon';
export default function LastfmIndicator() {
const { t } = useTranslation();
const navigate = useNavigate();
const { lastfmSessionKey, lastfmUsername, lastfmSessionError } = useAuthStore();
if (!lastfmSessionKey) return null;
const tooltip = lastfmSessionError
? t('connection.lastfmSessionInvalid')
: t('connection.lastfmConnected', { user: lastfmUsername });
return (
<div
className="connection-indicator"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/settings', { state: { tab: 'integrations' } })}
data-tooltip={tooltip}
data-tooltip-pos="bottom"
>
<div
className={`connection-led connection-led--${lastfmSessionError ? 'disconnected' : 'connected'}`}
/>
<div className="connection-meta">
<span className="connection-type" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<LastfmIcon size={11} />
Last.fm
</span>
<span className="connection-server">
{lastfmSessionError ? t('connection.lastfmSessionInvalid').split(' —')[0] : `@${lastfmUsername}`}
</span>
</div>
</div>
);
}
+2 -1
View File
@@ -28,6 +28,7 @@ import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { useTranslation } from 'react-i18next';
import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs';
import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
import type { SubsonicSong } from '../api/subsonicTypes';
import { AlbumCoverArtImage } from '../cover/AlbumCoverArtImage';
@@ -775,7 +776,7 @@ export default function LiveSearch() {
)}
<div>
<div className="search-result-name">{a.name}</div>
<div className="search-result-sub">{a.artist}</div>
<div className="search-result-sub">{albumArtistDisplayName(a)}</div>
</div>
</button>
);
-6
View File
@@ -25,12 +25,6 @@ vi.mock('@/api/subsonic', () => ({
scrobbleSong: vi.fn(async () => undefined),
}));
vi.mock('@/api/lastfm', () => ({
lastfmScrobble: vi.fn(async () => undefined),
lastfmUpdateNowPlaying: vi.fn(async () => undefined),
lastfmGetTrackLoved: vi.fn(async () => false),
lastfmGetAllLovedTracks: vi.fn(async () => []),
}));
import MiniPlayer from './MiniPlayer';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
+2 -1
View File
@@ -16,6 +16,7 @@ import { ArtistCoverArtImage } from '../cover/ArtistCoverArtImage';
import { CoverArtImage } from '../cover/CoverArtImage';
import { albumCoverRefForSong } from '../cover/ref';
import { showToast } from '../utils/ui/toast';
import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs';
import { useShareSearch } from '../hooks/useShareSearch';
import ShareSearchResults from './search/ShareSearchResults';
import {
@@ -383,7 +384,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
)}
<div className="mobile-search-item-info">
<span className="mobile-search-item-title">{a.name}</span>
<span className="mobile-search-item-sub">{a.artist}</span>
<span className="mobile-search-item-sub">{albumArtistDisplayName(a)}</span>
</div>
<ChevronRight size={16} className="mobile-search-item-chevron" />
</button>
+47
View File
@@ -0,0 +1,47 @@
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useEnrichmentPrimary } from '../music-network';
import { renderPresetIcon } from './settings/musicNetwork/presetIcon';
/**
* Sidebar status indicator for the enrichment primary (the account that drives
* love/similar/stats). Mirrors the old Last.fm indicator: green when connected,
* red on session error, click Integrations. Hidden when no primary is set.
*/
export default function MusicNetworkIndicator() {
const { t } = useTranslation();
const navigate = useNavigate();
const ep = useEnrichmentPrimary();
if (!ep) return null;
const { account: primary, icon } = ep;
const subtitle = primary.sessionError
? t('musicNetwork.statusError')
: primary.username
? `@${primary.username}`
: t('musicNetwork.statusConnected');
const tooltip = primary.sessionError
? t('musicNetwork.statusError')
: primary.username
? `${primary.label} · @${primary.username}`
: primary.label;
return (
<div
className="connection-indicator"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/settings', { state: { tab: 'integrations' } })}
data-tooltip={tooltip}
data-tooltip-pos="bottom"
>
<div className={`connection-led connection-led--${primary.sessionError ? 'disconnected' : 'connected'}`} />
<div className="connection-meta">
<span className="connection-type" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
{renderPresetIcon(icon, 11)}
{primary.label}
</span>
<span className="connection-server">{subtitle}</span>
</div>
</div>
);
}
+83 -10
View File
@@ -4,7 +4,8 @@ import { getNowPlaying } from '../api/subsonicScrobble';
import type { SubsonicNowPlaying } from '../api/subsonicTypes';
import React, { useState, useEffect, useRef, useCallback, useLayoutEffect } from 'react';
import { createPortal } from 'react-dom';
import { PlayCircle, User, Clock, Radio, RefreshCw } from 'lucide-react';
import { PlayCircle, Pause, User, Radio, RefreshCw } from 'lucide-react';
import { nowPlayingPresence } from '../api/nowPlayingPresence';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
@@ -22,8 +23,33 @@ export default function NowPlayingDropdown() {
const triggerWrapRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [panelPos, setPanelPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
// Wall-clock baseline for the last poll: between polls (every 10 s) we
// extrapolate the position of `playing` entries locally so the progress bar
// glides instead of snapping. The server already extrapolates positionMs at
// fetch time, so this just continues from there using the reported speed.
const fetchedAtRef = useRef(0);
const [, forceTick] = useState(0);
const PANEL_WIDTH = 340;
const formatClock = (totalSec: number) => {
const s = Math.max(0, Math.floor(totalSec));
const m = Math.floor(s / 60);
return `${m}:${String(s % 60).padStart(2, '0')}`;
};
// Live position in seconds: advance `playing` entries by elapsed × playbackRate
// since the last poll; freeze everything else at the reported position.
const livePositionSec = (entry: SubsonicNowPlaying): number | undefined => {
if (typeof entry.positionMs !== 'number') return undefined;
let ms = entry.positionMs;
if (entry.state === 'playing') {
const rate = entry.playbackRate && entry.playbackRate > 0 ? entry.playbackRate : 1;
ms += (Date.now() - fetchedAtRef.current) * rate;
}
const maxMs = entry.duration > 0 ? entry.duration * 1000 : ms;
return Math.min(ms, maxMs) / 1000;
};
const updatePanelPos = useCallback(() => {
const el = triggerWrapRef.current;
if (!el) return;
@@ -39,6 +65,7 @@ export default function NowPlayingDropdown() {
setLoading(true);
try {
const data = await getNowPlaying();
fetchedAtRef.current = Date.now();
setNowPlaying(data);
} catch (e) {
console.error('Failed to load Now Playing', e);
@@ -64,6 +91,17 @@ export default function NowPlayingDropdown() {
return () => clearInterval(id);
}, [isOpen]);
// Re-render once per second while a `playing` entry exposes a position, so the
// locally-extrapolated bar advances smoothly between the 10 s polls.
const hasLivePosition = nowPlaying.some(
e => e.state === 'playing' && typeof e.positionMs === 'number',
);
useEffect(() => {
if (!isOpen || !hasLivePosition) return;
const id = setInterval(() => forceTick(v => v + 1), 1000);
return () => clearInterval(id);
}, [isOpen, hasLivePosition]);
useLayoutEffect(() => {
if (!isOpen) return;
updatePanelPos();
@@ -155,7 +193,10 @@ export default function NowPlayingDropdown() {
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{visible.map((stream, idx) => (
{visible.map((stream, idx) => {
const presence = nowPlayingPresence(stream);
const presenceLabel = t(`nowPlaying.presence.${presence}`);
return (
<div
key={`${stream.id}-${idx}`}
onClick={() => { if (stream.albumId) { setIsOpen(false); navigate(`/album/${stream.albumId}`); } }}
@@ -180,23 +221,55 @@ export default function NowPlayingDropdown() {
)}
</div>
<div style={{ minWidth: 0, flex: 1, display: 'flex', flexDirection: 'column', gap: '2px' }}>
<div className="truncate" style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-primary)' }}>{stream.title}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 }}>
<span
className={`now-playing-led now-playing-led--${presence}`}
role="img"
aria-label={presenceLabel}
data-tooltip={presenceLabel}
data-tooltip-pos="bottom"
/>
<span className="truncate" style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-primary)' }}>{stream.title}</span>
</div>
<div className="truncate" style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>{stream.artist}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', marginTop: '2px', fontSize: '11px', color: 'var(--text-secondary)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', minWidth: 0 }}>
<User size={10} style={{ flexShrink: 0 }} />
<span className="truncate">{stream.username} ({stream.playerName || 'Web'})</span>
</div>
{stream.minutesAgo > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', minWidth: 0 }}>
<Clock size={10} style={{ flexShrink: 0 }} />
<span className="truncate">{t('nowPlaying.minutesAgo', { n: stream.minutesAgo })}</span>
</div>
)}
{(() => {
const posSec = livePositionSec(stream);
if (posSec === undefined || stream.duration <= 0) return null;
const playing = stream.state === 'playing';
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0, marginTop: '1px' }}>
{stream.state === 'paused' && <Pause size={10} style={{ flexShrink: 0 }} />}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ height: '3px', borderRadius: '2px', background: 'var(--border-subtle)', overflow: 'hidden' }}>
<div style={{
width: `${Math.min(100, Math.max(0, (posSec / stream.duration) * 100))}%`,
height: '100%',
background: playing ? 'var(--accent)' : 'var(--text-muted)',
transition: playing ? 'width 1s linear' : 'none',
}} />
</div>
</div>
<span style={{ flexShrink: 0, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }}>
{/* ~2ch reserve inside the current-time box (9:59→10:00), not empty gap before the bar. */}
<span style={{ display: 'inline-block', minWidth: '6ch', textAlign: 'right' }}>
{formatClock(posSec)}
</span>
{' / '}
{formatClock(stream.duration)}
</span>
</div>
);
})()}
</div>
</div>
</div>
))}
);
})}
</div>
)}
</div>,
-8
View File
@@ -30,14 +30,6 @@ vi.mock('@/api/subsonic', () => ({
scrobbleSong: vi.fn(async () => undefined),
}));
vi.mock('@/api/lastfm', () => ({
lastfmScrobble: vi.fn(async () => undefined),
lastfmUpdateNowPlaying: vi.fn(async () => undefined),
lastfmLoveTrack: vi.fn(async () => undefined),
lastfmUnloveTrack: vi.fn(async () => undefined),
lastfmGetTrackLoved: vi.fn(async () => false),
lastfmGetAllLovedTracks: vi.fn(async () => []),
}));
import PlayerBar from './PlayerBar';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
+7 -8
View File
@@ -19,7 +19,6 @@ import { useTranslation } from 'react-i18next';
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
import { useLyricsStore } from '../store/lyricsStore';
import MarqueeText from './MarqueeText';
import LastfmIcon from './LastfmIcon';
import { useRadioMetadata } from '../hooks/useRadioMetadata';
import { useRadioMprisSync } from '../hooks/useRadioMprisSync';
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
@@ -61,7 +60,7 @@ export default function PlayerBar() {
currentTrack, currentRadio, isPlaying, volume,
togglePlay, next, previous, setVolume,
stop, toggleRepeat, repeatMode, toggleFullscreen,
lastfmLoved, toggleLastfmLove,
networkLoved, toggleNetworkLove,
isQueueVisible, toggleQueue,
starredOverrides,
userRatingOverrides,
@@ -79,15 +78,15 @@ export default function PlayerBar() {
toggleRepeat: s.toggleRepeat,
repeatMode: s.repeatMode,
toggleFullscreen: s.toggleFullscreen,
lastfmLoved: s.lastfmLoved,
toggleLastfmLove: s.toggleLastfmLove,
networkLoved: s.networkLoved,
toggleNetworkLove: s.toggleNetworkLove,
isQueueVisible: s.isQueueVisible,
toggleQueue: s.toggleQueue,
starredOverrides: s.starredOverrides,
userRatingOverrides: s.userRatingOverrides,
openContextMenu: s.openContextMenu,
})));
const { lastfmSessionKey } = useAuthStore();
const enrichmentPrimaryId = useAuthStore(s => s.enrichmentPrimaryId);
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
const playerBarRef = useRef<HTMLElement>(null);
const perfFlags = usePerfProbeFlags();
@@ -208,9 +207,9 @@ export default function PlayerBar() {
previewingTrack={previewingTrack}
isStarred={isStarred}
toggleStar={toggleStar}
lastfmSessionKey={lastfmSessionKey}
lastfmLoved={lastfmLoved}
toggleLastfmLove={toggleLastfmLove}
enrichmentPrimaryId={enrichmentPrimaryId}
networkLoved={networkLoved}
toggleNetworkLove={toggleNetworkLove}
userRatingOverrides={userRatingOverrides}
toggleFullscreen={toggleFullscreen}
navigate={navigatePlaybackLibrary}
-6
View File
@@ -25,12 +25,6 @@ vi.mock('@/api/subsonic', () => ({
scrobbleSong: vi.fn(async () => undefined),
}));
vi.mock('@/api/lastfm', () => ({
lastfmScrobble: vi.fn(async () => undefined),
lastfmUpdateNowPlaying: vi.fn(async () => undefined),
lastfmGetTrackLoved: vi.fn(async () => false),
lastfmGetAllLovedTracks: vi.fn(async () => []),
}));
vi.mock('@/utils/orbitBulkGuard', () => ({
orbitBulkGuard: vi.fn(async () => true),
+22 -10
View File
@@ -1,11 +1,15 @@
import React from 'react';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { Minus, Square, X } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
export default function TitleBar() {
const win = getCurrentWindow();
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
const windowButtonStyle = useAuthStore(s => s.windowButtonStyle);
const showMinimizeButton = useAuthStore(s => s.showMinimizeButton);
return (
<div className="titlebar" data-tauri-drag-region>
@@ -20,28 +24,36 @@ export default function TitleBar() {
)}
</div>
<div className="titlebar-controls">
<button
className="titlebar-btn titlebar-btn-minimize"
onClick={() => win.minimize()}
data-tooltip="Minimize"
data-tooltip-pos="bottom"
aria-label="Minimize"
/>
<div className="titlebar-controls" data-btnstyle={windowButtonStyle}>
{showMinimizeButton && (
<button
className="titlebar-btn titlebar-btn-minimize"
onClick={() => win.minimize()}
data-tooltip="Minimize"
data-tooltip-pos="bottom"
aria-label="Minimize"
>
<Minus size={10} strokeWidth={2.5} aria-hidden />
</button>
)}
<button
className="titlebar-btn titlebar-btn-maximize"
onClick={() => win.toggleMaximize()}
data-tooltip="Maximize"
data-tooltip-pos="bottom"
aria-label="Maximize"
/>
>
<Square size={9} strokeWidth={2.5} aria-hidden />
</button>
<button
className="titlebar-btn titlebar-btn-close"
onClick={() => win.close()}
data-tooltip="Close"
data-tooltip-pos="bottom"
aria-label="Close"
/>
>
<X size={10} strokeWidth={2.5} aria-hidden />
</button>
</div>
</div>
);
+61
View File
@@ -66,4 +66,65 @@ describe('TooltipPortal open delay', () => {
fireEvent.mouseDown(btn);
expect(screen.queryByText('Play this album')).toBeNull();
});
it('shows immediately on click when data-tooltip-click is set', () => {
renderWithProviders(
<>
<TooltipPortal />
<button data-tooltip="Server version" data-tooltip-click="">info</button>
</>,
);
const btn = screen.getByText('info');
fireEvent.click(btn);
expect(screen.getByText('Server version')).toBeInTheDocument();
});
it('does not hide click-mode tooltips on mousedown', () => {
renderWithProviders(
<>
<TooltipPortal />
<button data-tooltip="Server version" data-tooltip-click="">info</button>
</>,
);
const btn = screen.getByText('info');
fireEvent.click(btn);
expect(screen.getByText('Server version')).toBeInTheDocument();
fireEvent.mouseDown(btn);
expect(screen.getByText('Server version')).toBeInTheDocument();
});
it('toggles off when the click anchor is clicked again', () => {
renderWithProviders(
<>
<TooltipPortal />
<button data-tooltip="Server version" data-tooltip-click="">info</button>
</>,
);
const btn = screen.getByText('info');
fireEvent.click(btn);
expect(screen.getByText('Server version')).toBeInTheDocument();
fireEvent.click(btn);
expect(screen.queryByText('Server version')).toBeNull();
});
it('keeps click-opened tooltips visible after mouseout', () => {
renderWithProviders(
<>
<TooltipPortal />
<button data-tooltip="Server version" data-tooltip-click="">info</button>
</>,
);
const btn = screen.getByText('info');
fireEvent.click(btn);
expect(screen.getByText('Server version')).toBeInTheDocument();
fireEvent.mouseOut(btn, { relatedTarget: document.body });
expect(screen.getByText('Server version')).toBeInTheDocument();
});
});
+34 -2
View File
@@ -21,6 +21,8 @@ export default function TooltipPortal() {
const pendingTimerRef = useRef<number | null>(null);
// Anchor whose tooltip is currently visible.
const shownAnchorRef = useRef<HTMLElement | null>(null);
/** Click-opened tooltips stay until outside click / second click, not pointer leave. */
const clickPinnedRef = useRef(false);
useEffect(() => {
const clearPending = () => {
@@ -34,13 +36,15 @@ export default function TooltipPortal() {
const hide = () => {
clearPending();
shownAnchorRef.current = null;
clickPinnedRef.current = false;
setTooltip(null);
};
const showAnchor = (anchor: HTMLElement) => {
const showAnchor = (anchor: HTMLElement, opts?: { clickPinned?: boolean }) => {
const text = anchor.getAttribute('data-tooltip');
if (!text) return;
shownAnchorRef.current = anchor;
clickPinnedRef.current = !!opts?.clickPinned;
// Fresh rect: layout may have shifted during the open delay.
setTooltip({
text,
@@ -75,16 +79,40 @@ export default function TooltipPortal() {
// Moving within the same anchor (e.g. onto a child icon) must not cancel the delay.
const to = e.relatedTarget as Node | null;
if (to && anchor.contains(to)) return;
if (clickPinnedRef.current) return;
hide();
};
const onMove = (e: MouseEvent) => {
if (!pendingAnchorRef.current && !shownAnchorRef.current) return;
if (clickPinnedRef.current) return;
const anchor = (e.target as HTMLElement).closest('[data-tooltip]');
if (!anchor) hide();
};
/** Clicking a tooltip anchor (e.g. opening a dropdown) keeps the cursor inside the element, so mouseout never runs — hide immediately. */
const onDown = (e: MouseEvent) => {
if ((e.target as HTMLElement).closest('[data-tooltip]')) hide();
const anchor = (e.target as HTMLElement).closest('[data-tooltip]') as HTMLElement | null;
if (anchor?.hasAttribute('data-tooltip-click')) return;
if (anchor) hide();
};
const onClick = (e: MouseEvent) => {
const anchor = (e.target as HTMLElement).closest('[data-tooltip-click]') as HTMLElement | null;
if (!anchor?.getAttribute('data-tooltip')) return;
e.preventDefault();
clearPending();
if (shownAnchorRef.current === anchor && clickPinnedRef.current) {
hide();
return;
}
showAnchor(anchor, { clickPinned: true });
};
const onDocumentClick = (e: MouseEvent) => {
window.setTimeout(() => {
const shown = shownAnchorRef.current;
if (!shown?.hasAttribute('data-tooltip-click') || !clickPinnedRef.current) return;
const target = e.target as Node;
if (shown.contains(target)) return;
hide();
}, 0);
};
/** Wheel interactions (e.g. volume on overflow button) should suppress tooltip immediately. */
const onWheel = (e: WheelEvent) => {
@@ -94,6 +122,8 @@ export default function TooltipPortal() {
document.addEventListener('mouseout', onOut);
document.addEventListener('mousemove', onMove, { passive: true });
document.addEventListener('mousedown', onDown, true);
document.addEventListener('click', onClick, true);
document.addEventListener('click', onDocumentClick);
document.addEventListener('wheel', onWheel, { capture: true, passive: true });
return () => {
clearPending();
@@ -101,6 +131,8 @@ export default function TooltipPortal() {
document.removeEventListener('mouseout', onOut);
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mousedown', onDown, true);
document.removeEventListener('click', onClick, true);
document.removeEventListener('click', onDocumentClick);
document.removeEventListener('wheel', onWheel, true);
};
}, []);
-6
View File
@@ -24,12 +24,6 @@ vi.mock('@/api/subsonic', () => ({
scrobbleSong: vi.fn(async () => undefined),
}));
vi.mock('@/api/lastfm', () => ({
lastfmScrobble: vi.fn(async () => undefined),
lastfmUpdateNowPlaying: vi.fn(async () => undefined),
lastfmGetTrackLoved: vi.fn(async () => false),
lastfmGetAllLovedTracks: vi.fn(async () => []),
}));
import WaveformSeek from './WaveformSeek';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
+69
View File
@@ -0,0 +1,69 @@
import React from 'react';
import { Minus, Square, X } from 'lucide-react';
import type { WindowButtonStyle } from '../store/authStoreTypes';
interface Props {
style: WindowButtonStyle;
label: string;
selected: boolean;
onClick: () => void;
}
/**
* Selection tile for the custom-title-bar window-button style picker. Renders
* the real `.titlebar-controls` / `.titlebar-btn` classes inside a mini title
* bar so the preview is exactly what the chosen style produces.
*/
export default function WindowButtonPreview({ style, label, selected, onClick }: Props) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={selected}
style={{
border: `2px solid ${selected ? 'var(--accent)' : 'var(--bg-hover)'}`,
borderRadius: 8,
background: selected
? 'color-mix(in srgb, var(--accent) 12%, transparent)'
: 'var(--bg-card, var(--bg-app))',
padding: '10px 12px 8px',
cursor: 'pointer',
width: 130,
display: 'flex',
flexDirection: 'column',
gap: 6,
alignItems: 'stretch',
transition: 'border-color 0.15s, background 0.15s',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
minHeight: 34,
background: 'var(--bg-sidebar)',
border: '1px solid var(--border-subtle)',
borderRadius: 6,
overflow: 'hidden',
}}
>
<div className="titlebar-controls" data-btnstyle={style} aria-hidden>
<span className="titlebar-btn titlebar-btn-minimize"><Minus size={10} strokeWidth={2.5} /></span>
<span className="titlebar-btn titlebar-btn-maximize"><Square size={9} strokeWidth={2.5} /></span>
<span className="titlebar-btn titlebar-btn-close"><X size={10} strokeWidth={2.5} /></span>
</div>
</div>
<span
style={{
fontSize: 11,
color: selected ? 'var(--accent)' : 'var(--text-secondary)',
textAlign: 'center',
fontWeight: selected ? 600 : 400,
}}
>
{label}
</span>
</button>
);
}

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