Compare commits

...

72 Commits

Author SHA1 Message Date
Psychotoxical 4c2a5b8853 fix(orbit): re-enable proportional speed correction for live testing
Flip SPEED_CORRECTION_ENABLED back to true so the proportional drift
controller drives the rate again. The disabled fallback (hold 1.0× +
manual Catch-Up button) stays one line away behind the same flag for a
quick switch-back if the pitch-preserving rate changes are audible again.

Restore the proportional planner tests that the disable commit had
rewritten for the held-rate path.
2026-06-23 11:12:21 +02:00
Psychotoxical 0286b9b161 fix(orbit): disable audible speed nudging, keep manual catch-up
Every pitch-preserving rate change was audible in practice (tempo wobble /
DSP distortion), and it only kicked in once a catch-up had pulled the drift
into the correction band — so a catch-up made playback worse until the next
track change. The host's sync at track change plus the manual Catch-Up button
keep the guest aligned well enough without it.

The loop now holds 1.0× and only surfaces the Catch-Up button past a moderate
drift; the proportional controller is kept behind SPEED_CORRECTION_ENABLED for
a future revisit if the preserve-pitch DSP improves.
2026-06-23 03:24:52 +02:00
Psychotoxical 61947ee5a7 fix(orbit): stamp host positionAt with the position it belongs to
The host's currentTime updates in coarse ~5.6 s steps (decode/progress
granularity) while the 2.5 s state-write tick runs steadily. Stamping
positionAt=now every tick while positionMs sat frozen made the guest's
extrapolation (posMs + (now - posAt)) stall, then jump ~5.6 s on the next
update — so the measured drift oscillated ±5 s and no controller could track
it (the real root behind the bang-bang/proportional swinging).

Pair the timestamp with the position: while playing and the position is
unchanged, keep the original (positionMs, positionAt) so the guest
extrapolates smoothly from the last real measurement. Extracted as a tested
makeHostPositionStamper; the drift jump at a position update drops from
~5600 ms to ~570 ms (under the deadband).
2026-06-23 02:30:33 +02:00
Psychotoxical f66ad3b578 fix(orbit): proportional drift correction to stop the bang-bang oscillation
Bang-bang drove the full ±10% until almost caught up, then overshot (the
~3.5 s smoothing/measurement latency) and reversed — it could never lock on.
Now the target rate is proportional to the smoothed drift: gentle near synced,
up to the cap when far, so it converges asymptotically instead of overshooting.
The live rate ramps gradually toward that target (stable now that the backend
restamp keeps speed changes position-stable). Settle is gone — it was only
needed against the old restamp jumps. Smoothing, the manual-catch-up button,
and no-auto-seek stay.
2026-06-23 02:12:18 +02:00
Psychotoxical 8dfe5f9094 fix(orbit): drop auto-seek, surface manual catch-up when drift is uncorrectable
A hard seek mid-track causes an audible dropout and chases the coarse host
position, so the drift loop no longer auto-seeks. When the smoothed drift is
too large to nudge softly it holds 1.0x and sets status 'seek'; the Orbit bar
shows the manual Catch-Up button exactly then (replacing the raw-drift
threshold that flickered on the noisy signal). The host stays the only driver;
the guest takes the jump only on the user's click.
2026-06-23 01:54:33 +02:00
Psychotoxical 42a84932e6 fix(audio): restamp playback position across active/neutral rate toggles
The sample counter maps to song position via a factor of `speed` while the
preserve-pitch effect is active and 1.0 while neutral. A change that flips
active<->neutral (enable/disable, or speed crossing 1.0x) therefore needs the
counter restamped to hold the position — but the condition only fired for
active->active speed changes. So every enable/disable toggle reinterpreted the
counter under the new factor and jumped the position (~raw_secs x dspeed —
e.g. +-18 s at 180 s on a +-10% toggle).

This broke Orbit drift correction, whose bang-bang nudges toggle the effect
on and off with the full +-10% delta. Extract the decision into
`rate_change_needs_restamp` and fire it on any active<->neutral transition (and
active->active speed changes), always re-deriving the counter for the new
config. Tests cover the condition and the position-stable invariant.
2026-06-23 01:54:26 +02:00
Psychotoxical 0d83a6e957 fix(orbit): smooth drift signal and switch to bang-bang correction
The test round showed the v2 ramp didn't converge: the raw drift swings
~1500 ms tick-to-tick (host position lands in coarse ~5 s quanta, and each
rate change perturbs the measured position), so the fine controller chased
its own tail — and the per-step speed switches caused audio artifacts.

- Median-smooth the raw drift before the controller sees it; spikes that
  used to trigger phantom seeks are rejected.
- Bang-bang correction: jump straight to the +-10% cap, hold until caught
  up, then back to 1.0x — two speed switches per cycle, not twenty.
- Settle for a few ticks after every speed change / seek so a correction
  can't read back its own perturbation (the feedback loop that oscillated).
- Seek only on sustained smoothed drift; deadband widened to 1.5 s to match
  the coarse host position cadence. CSV trace now carries raw + smoothed.
2026-06-23 00:49:58 +02:00
Psychotoxical d6a03532b2 feat(orbit): make guests hot-cache capable by mirroring the host queue
A guest loaded every track as a single-item queue, so the hot-cache
prefetcher and crossfade preload (which read queueItems[queueIndex + 1])
never had a next track to warm — every AutoDJ/crossfade switch cold-fetched
over HTTP, stalling the transition and drifting the guest off the host.

- Mirror the host's upcoming queue into the guest's local (invisible)
  playerStore queue as preload-only fodder. Race-free (only once the guest
  is on the host track) and idempotent (writes only on change).
- Guard runNext so a guest never auto-advances through that mirrored queue —
  the host stays the sole driver; at track end the guest waits for the host
  sync. The guest UI keeps rendering state.playQueue, not queueItems.
2026-06-22 23:57:04 +02:00
Psychotoxical 2fd99c7097 feat(orbit): add drift-trace logging and live diagnostics
Observability for the drift correction so we can tell by data whether it's
working, per the test-round request:
- New dense per-tick trace (driftTrace.ts) in its own ring buffer, kept
  separate from the 200-entry event log so 500 ms sampling can't bury the
  readable transitions. Copyable as CSV from the diagnostics popover for
  straight paste into a plot (drift vs rate over time).
- The guest loop samples every tick (drift, rate, target, action, track
  remainder, host/guest position); no re-render, no IPC.
- Diagnostics popover now ticks at 500 ms so the live rate reads in
  near-real-time, plus the CSV copy button.
- Manual catch-up clicks are logged into the same stream so user-driven
  seeks are distinguishable from automatic ones.
2026-06-22 22:54:32 +02:00
Psychotoxical 8ec32d7057 fix(orbit): stop redundant rate IPC while paused and reset status on leave
Self-review follow-ups on the drift correction loop:
- resetRate is now idempotent — a paused or diverged guest hit an abort
  guard every 500 ms tick and re-sent a rate IPC each time; now it no-ops
  once already neutral.
- The loop cleanup now resets the diagnostics status snapshot too, so a
  stale rate/status can't flash in the popover on the next session.
- Drop a dead seek-status assignment, a redundant no-op, and collapse the
  duplicated reset branch.
2026-06-22 21:59:04 +02:00
Psychotoxical 38843103b5 feat(orbit): surface drift correction in Orbit diagnostics
Show the live correction rate and status (hold / soft / seek / blend) in
the guest section of the diagnostics popover, alongside the existing drift
readout. Labels translated across all 11 locales; values stay raw like the
neighbouring role / track-id rows for copy-paste into bug reports.
2026-06-22 20:54:20 +02:00
Psychotoxical 688aa5dff6 feat(orbit): drive guest playback rate from drift correction
Guest-side 500 ms loop that plans the correction each tick and steps the
engine rate one 1% increment toward the target, through an Orbit drift-rate
carve-out that is independent of the user's (still suppressed) playback-rate
preference. Resets to 1.0x on track change, seek, pause, blend guard and
leave. Crossfade / AutoDJ smooth-skip near the track end hold at 1.0x so a
nudge can't disturb the blend. Mounted from useOrbitGuest.
2026-06-22 20:54:13 +02:00
Psychotoxical 90b7851785 feat(orbit): add pitch-preserving drift correction planner
Pure planner that decides hold / soft-nudge / seek for intra-track guest
drift, with ramp-inclusive feasibility math (triangle vs trapezoid) and a
30 s preferred horizon capped by the remaining track time. Gentlest rate
within budget wins; hard seek only when even the +-10% cap can't close the
gap before the track ends. Includes the 1%-per-tick rate stepper and unit
tests (9-case planner table + ramp simulator).
2026-06-22 20:54:04 +02:00
Psychotoxical 78a177b1bc fix(orbit): outbox lost-update recovery and session hardening (#1159)
* fix(orbit): harden the guest outbox — recover lost suggestions, avoid a duplicate outbox

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs(changelog): update notification popup fix (PR #1142)
2026-06-21 00:48:26 +02:00
Psychotoxical 538dbe3db1 fix(settings): complete the external-artwork disclosure (#1141)
The disclosure named fanart.tv but omitted musicbrainz.org, which the
name-to-MBID identity lookup contacts with the artist/album name when an artist
has no tag MBID; it also mis-stated what is sent. Rewrite the note across all 9
locales: names both fanart.tv (artwork source) and musicbrainz.org (identity
lookup with the artist/album name), and is accurate about on-demand/read-only
and that library tags are never changed. Satisfies fanart.tv terms ("inform
your users") and design-review §9.
2026-06-21 00:12:26 +02:00
Psychotoxical a5313d5cb1 fix(cover): embed fanart.tv project key so from-source builds work (#1139)
The project key was injected only at our build time (CI secret / option_env!),
so AUR, Nix and any from-source build had no key baked in and the External
Artwork toggle was inert there. Commit it as a source literal (like the Last.fm
key, and as fanart.tv terms expect: the app ships a project key, users add
their own on top via BYOK). Drops the now-redundant CI wiring.
2026-06-20 23:03:12 +02:00
Psychotoxical b950d4704b feat(cover): artist artwork from fanart.tv (off by default) (#1137)
* feat(cover): add artist_artwork_lookup table + accessors

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(cover): enable banner surface in ensureArgsFromRef

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Signed-off-by: Soli0222 <github@str08.net>
Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
2026-06-20 13:52:10 +02:00
cucadmuh 2d1a078186 fix(queue): push edited queue when playback starts on paused client (#1133) 2026-06-20 01:15:28 +03:00
cucadmuh c037ab459a fix(queue): suspend idle pull after local queue edits (#1132) 2026-06-19 20:37:18 +03:00
cucadmuh 955a9fcbd6 feat(queue): play queue sync — manual pull, idle auto-sync, multi-server push (#1131) 2026-06-19 18:19:11 +03:00
Psychotoxical c428d37e0e Settings — own Audio categories + Queue Settings consolidation (#1130)
* refactor(settings): promote Normalization and Track transitions to own Audio categories

Pull Normalization and Track transitions out of the combined Playback
section into their own top-level SettingsSubSection categories, placed
directly under Audio Output Device. Both follow the established reusable
pattern (SettingsSubSection header + title-less SettingsGroup inside a
single-group settings-card, so the frame-collapse CSS applies).

- New TrackTransitionsBlock extracted from PlaybackBehaviorBlock; the
  latter now holds only the Queue behaviour toggle (slated to move to
  Personalisation).
- NormalizationBlock's SettingsGroup is now title-less; the section
  header and description name it.
- Split the single audio search-index row into three (Normalization /
  Track transitions / Playback) so crossfade/replaygain/lufs keywords
  focus the right section.

* refactor(settings): consolidate Queue Settings under Personalisation, drop Audio Playback section

Combine Queue Display Mode and Queue Toolbar under one 'Queue Settings'
category in the Personalisation tab, and move the Queue behaviour toggle
(preservePlayNextOrder) there from Audio. The now-empty Audio Playback
section is removed.

- New 'Queue Settings' SettingsSubSection holds three titled groups:
  Queue Display Mode, Queue behaviour, and (advanced-only) Queue Toolbar.
  The toolbar group keeps its reset, now via a new optional 'action' slot
  on SettingsGroup (+ .settings-group-title-action CSS).
- Delete PlaybackBehaviorBlock; its single toggle is inlined.
- Search index: drop the audio 'playbackTitle' row; the personalisation
  queue row now points at 'queueSettingsTitle' with merged keywords.
- i18n: add settings.queueSettingsTitle to all 9 locales.

* fix(settings): align queue-toolbar separator label and restore Advanced badge

- QueueToolbarCustomizer: the separator row rendered a 1px rule where other
  rows have a 16px icon, so its label sat shifted left. Reserve the full
  16px icon column (1px rule centred) so the label lines up. (Preexisting.)
- SettingsGroup gains an optional 'advanced' flag rendering the Advanced
  badge; badge + action now share a right-aligned title-end slot
  (.settings-group-title-end), so the badge sits just left of the reset
  button. Restores the Advanced indicator the Queue Toolbar lost when it
  moved from a SettingsSubSection into a group.

* fix(audio): hide the output-device category on macOS instead of showing a notice

Playback is pinned to the system default on macOS, so the picker showed a
notice explaining it does nothing there. Gate the whole Audio Output Device
category out on macOS (`!IS_MACOS` in AudioTab) and drop the now-dead notice
branch + the `audioOutputDeviceMacNotice` string from all 9 locales. The
device-probe hook already short-circuits on macOS, so no work is wasted.

* refactor(settings): box the sidebar customizer groups consistently

The sidebar display toggles sat as a bare div and looked unfinished. Box
them in a SettingsGroup, with the nav-item drag list in a second group.

Render the groups directly in the sub-section content (no settings-card
wrapper) — matching the other Personalisation customizers, which use bare
SettingsGroups. Also drop the settings-card around Queue Settings for the
same reason, so every Personalisation section's boxes share one width and
inset instead of the card-wrapped ones sitting narrower/indented.

* docs(settings): changelog, what's new, and credits for the settings reorg (#1130)

Fold the Audio/Personalisation reorganization into the existing
'Settings — consistent grouped layout' changelog entry (now #1126 + #1130)
and the matching What's New highlight, and add a consolidated settings-
refactor line to the contributor credits.
2026-06-19 13:57:08 +02:00
cucadmuh 4225146a16 feat(autodj): smooth skip and interrupt blend transitions (#1128) 2026-06-19 04:52:15 +03:00
Psychotoxical d50c9c444d refactor(settings): reusable SettingsGroup/SettingsToggle + boxed sections across tabs (#1126)
* refactor(settings): extract reusable SettingsGroup component

Pull the boxed sub-section pattern (bordered panel + accent uppercase
header) introduced for the Audio tab into a reusable <SettingsGroup
title desc> component, and migrate NormalizationBlock and
PlaybackBehaviorBlock onto it. No visual change.

* feat(settings): box the System behavior section into groups

Split the System -> App Behavior card (which bundled tray toggles, Linux
rendering tweaks and the clock format) into titled SettingsGroup panels
(Tray / Linux rendering / Clock) for a clearer, consistent boxed look.
Adds the group titles across all 9 locales.

* feat(settings): box Appearance visual options; optional SettingsGroup title

Split the Appearance -> Visual options card into a Display group and a
Window group (Linux custom titlebar controls). SettingsGroup now allows an
optional/omitted title for plain boxed panels. Group titles added across
all 9 locales.

* feat(settings): box Discord cover source and templates

Group the Discord cover-source toggles and the activity templates into
separate SettingsGroup panels (templates reuse their existing title/desc),
dropping the manual indent and inline header. No new strings.

* feat(settings): box the Music Network section

Give the scrobble-master toggle, the enrichment-primary picker and the
provider list each a boxed SettingsGroup with an accent header, and switch
the add-a-service provider rows to the boxed panel style for consistent
contrast with the rest of settings.

* feat(settings): titled boxes for Integrations sections + cover source label

Wrap the Discord enable toggle, the Discord cover-source picker (now with a
"Cover art source" title and explainer), Bandsintown and Show-in-Now-Playing
in titled SettingsGroup panels so each single-item section gets the same
accent-headed boxed look. New cover-source strings across all 9 locales.

* feat(settings): box Lyrics tab sections

Wrap the lyrics-sources customizer and the sidebar-style picker in titled
SettingsGroup panels (reusing the existing section titles).

* refactor(settings): consistent content inset in SettingsGroup

Add a body wrapper with a small left inset so every boxed section indents its
controls uniformly (title stays flush at the box edge). Defined once in the
component instead of per section.

* feat(settings): box Storage tab sections incl. cover art cache

Wrap media directory, cover art cache strategy, next-track buffering and
downloads in titled SettingsGroup panels (reusing existing section titles);
align the cover-cache table's first column flush with the new content inset.

* feat(settings): box Library tab, relocate Lucky Mix, add SettingsToggle

- Add a reusable SettingsToggle component for the repeated label/desc/switch row.
- Box the Random Mix blacklist (grouping built-in keywords with the custom
  filter), Ratings and the Analytics strategy section.
- Move the "Show Lucky Mix in menu" toggle out of the blacklist and up next to
  split-mix navigation and now-playing-at-top in the sidebar customizer.

* feat(settings): box remaining Appearance sections

Wrap library grid, UI scale, font and seekbar style in titled SettingsGroup
panels (reusing the existing section titles).

* feat(settings): box System language, logging and backup sections

Wrap language and logging in titled SettingsGroup panels, and rework the
backup section to drop its redundant inline header (the subsection already
provides it) and box its content. About / Contributors / Licenses stay as
free display content.

* feat(settings): box remaining Audio sections

Wrap Hi-Res, equalizer, playback speed, audio output device and track
previews in titled SettingsGroup panels (reusing the existing section titles).

* feat(settings): box Input tab keybinding sections

Wrap the in-app and global shortcut lists in titled SettingsGroup panels.

* feat(settings): box the Personalisation tab

Convert every customizer (sidebar, home, artist, queue toolbar, playlist,
player bar) to plain inner containers and wrap each in a titled SettingsGroup,
so the boxed look is consistent without nested cards. Box the queue display
mode picker too.

* feat(settings): box the Themes sections above the store

Add a boxed option to the flat Themes sections and apply it to Your Themes,
Auto-Switch Theme and Import (the theme store stays unboxed). Drop the inner
settings-card from InstalledThemes, ThemeImportSection and the scheduler so
the SettingsGroup is the only frame.

* fix(settings): give the Themes boxes a card carrier and accent header

The flat Themes sections had no settings-card behind them, so the bg-app
SettingsGroup had no contrast against the page. Box each above-store section
as settings-card > titled SettingsGroup (accent header) like the rest.

* fix(settings): clean up the server library sync status line

Collapse the offline status to a single icon + phrase ("Server offline —
sync deferred") instead of a redundant "Deferred" badge alongside it, and
drop the now-unused string across all 9 locales.

* fix(queue): keep toolbar toggle buttons coloured active while hovered

The generic .queue-round-btn:hover rule outweighed .active in specificity, so
an active button kept the hover colour until the pointer left — the toggle
only looked on after moving the mouse away. Add an explicit active-hover rule
(mirroring the mini player) so active wins immediately on click.

* fix(settings): fix Lyrics triple title, drop dead Discord cover keys

- Remove LyricsSourcesCustomizer's own section header (the subsection already
  titles it) and make its inner cards plain; the Lyrics boxes are now title-less.
- Drop the dead discordCoverSource/discordCoverSourceDesc i18n keys (superseded
  by discordCoverTitle/Desc) and un-jam the libraryIndexServer status line in
  all 9 locales.

* refactor(settings): drop redundant group titles and double frames

Single-group sections duplicated the subsection title in the group header — make
those groups title-less so the subsection header is the only title. A card whose
sole child is one group now collapses via CSS so single-group sections render a
single frame (matching the card-less Lyrics layout) instead of a card-in-group
double border. Multi-group cards keep their frame as the grouping container.

* fix(settings): keep the flat Themes cards framed when collapsing single-group cards

The single-group card collapse also flattened the Themes section cards, which —
not being inside an accordion — are themselves the contrast surface. Exclude
.themes-section descendants from the collapse.

* refactor(settings): use SettingsToggle for Appearance and System toggle rows

Replace the hand-rolled toggle-row markup in the Appearance visual options and
System tray/Linux/changelog rows with the shared SettingsToggle component, and
extend it with a searchText prop and ReactNode descriptions for the remaining
call sites.

* refactor(settings): roll SettingsToggle out to Integrations, Audio and Storage

Replace hand-rolled toggle rows in the Discord/Bandsintown/Now-Playing,
Hi-Res, hot cache and playback behaviour/rate sections with SettingsToggle.
Make its label optional (desc-only rows whose title is the group header) and
add searchText/id pass-throughs.

* refactor(settings): use SettingsToggle for the remaining standard toggle rows

Track previews master, sidebar lyrics style, YouLyPlus/static-only, queue
display mode and the sidebar split-nav/now-playing/lucky-mix rows now use the
shared component. Genuinely custom rows (drag lists, the skip-star threshold,
the AudioMuse row with its inline link, the fine-step advanced badge) stay
hand-rolled.

* fix(settings): drop duplicate titles on Hi-Res/Bandsintown/Now-Playing, un-jam locale line

These single-toggle sections kept a group title identical to their subsection
header; once the single-group card collapses the title rendered twice stacked.
Make them title-less (the subsection header is the label). Also split the
discordCoverTitle locale entry onto its own line in all 9 locales.

* feat(settings): restore section icons in boxed Themes headers

SettingsGroup gained an optional accent icon slot rendered before the
title; ThemesTab forwards the Palette/Clock/Upload icons the boxed
sections lost when they moved off the bespoke <h2> header.

* style(settings): re-indent SettingsGroup children to nesting level

Pure whitespace: children wrapped in SettingsGroup were left at their
pre-wrap indent. No content change (git diff -w is empty).

* fix(settings): clarify Native Hi-Res Playback description

The old copy led with the disabled-state behaviour ("forces 44.1 kHz"),
which read as if the toggle itself locked output to 44.1 kHz. Describe
what enabling actually does: play each track at its native sample rate
(reconfiguring the output device to match) instead of resampling to
44.1 kHz. Updated across all 9 locales.

* docs: changelog + what's-new for the settings layout refactor (#1126)
2026-06-18 22:24:34 +02:00
cucadmuh 99c0b6cdac fix(linux): detect Niri as a tiling window manager (#1127) 2026-06-18 22:16:23 +03:00
cucadmuh ee044ece1a fix(now-playing): poll Live listener count every 30s in the background (#1125) 2026-06-18 21:22:35 +03:00
Psychotoxical fde7ab432f feat: split AutoDJ into a standalone playback feature (#1124)
* feat(playback): add transition-mode helper

Centralise the crossfade/AutoDJ/gapless mutual exclusivity in one place
instead of the scattered setter combinations across the toolbar, mini
player and settings. AutoDJ stays encoded as crossfade + trim-silence, so
the persisted flags and the audio engine are unchanged.

* feat(queue): split AutoDJ into its own toolbar button + playlist submenu

- AutoDJ becomes a standalone toolbar button (Blend icon) next to
  crossfade, driven by the shared transition-mode helper. The crossfade
  right-click popover drops the mode switch and keeps only the seconds
  slider.
- Save + load playlist collapse into one Playlist button opening a small
  submenu, freeing up toolbar space.
- queueToolbarStore gains a position-preserving rehydrate migration
  (legacy save/load -> playlist, autodj inserted after crossfade) with
  unit tests.
- Toolbar customizer and all 9 queue locales updated to match.

* feat(mini-player): standalone AutoDJ button, shared transition helper

Mirror the queue toolbar: AutoDJ gets its own Blend button, the crossfade
popover keeps only the seconds slider. The mini player now drives all
three transitions through a single additive `mini:set-transition-mode`
event handled by the shared helper, replacing the per-flag mini events.
Drop the now-dead crossfade-mode CSS.

* feat(settings): segmented track-transition picker, regroup playback

Replace the crossfade toggle + inner crossfade/AutoDJ switch with a single
Off | Gapless | Crossfade | AutoDJ segmented control (mirroring the
Normalization picker above), driven by the shared transition helper — the
mutual exclusivity now reads at a glance. Crossfade keeps its seconds
slider; AutoDJ shows its content-driven explainer. The block is regrouped
under "Track transitions" and "Queue behaviour" headings. Drops the now
unused crossfade/gapless description and not-available i18n keys across all
9 locales and adds the new transition strings.

* fix(queue): open the playlist submenu inward

The playlist submenu inherited the crossfade popover's right:0 anchor and,
sitting on the left of the toolbar, opened out under the main container.
Anchor it left:0 so it stays inside the queue panel. Update the toolbar
test for the new playlist submenu (save/load moved off the toolbar).

* feat(settings): box playback sub-sections into panels

Wrap Normalization, Track transitions and Queue behaviour each in their own
bordered panel with an accent uppercase header (new reusable .settings-group
classes), so the sections read as distinct blocks instead of one wall of
text. Drops the thin divider that separated them.

* docs: changelog, credits and what's new for AutoDJ standalone

Fold the standalone-AutoDJ changes into the existing AutoDJ entry in the
changelog and the in-app What's New, and add the credit (#1124).
2026-06-18 13:31:38 +02:00
cucadmuh f28e82c022 docs(whats-new): add user-facing highlights for 1.49.0 (#1123)
Add the in-app Highlights copy for the current dev line so What's New
and Changelog tabs show distinct content again.
2026-06-18 11:36:57 +03:00
ImAsra 0f580f58c8 Edit readme to add winget (#1088)
Added installation instructions for Windows Package Manager.
2026-06-18 11:19:01 +03:00
cucadmuh a6ee0668c8 feat(crossfade): AutoDJ — content-aware silence-trimming crossfade (#1122)
* feat(crossfade): add "trim silence between tracks" toggle

New persisted setting `crossfadeTrimSilence` (default off; existing
installs rehydrate off via the persist default-merge). Surfaced in
Settings -> Audio and in the crossfade popovers of the queue toolbar
and the mini-player.

Crossfade buttons now separate the two actions: left-click toggles
crossfade on/off, right-click opens the settings popover (seconds +
trim). Shared the mini popover positioning into useMiniAnchoredPopover
(now backing both volume and crossfade). Mini bridge carries
crossfadeSecs/crossfadeTrimSilence and gains mini:set-crossfade-secs /
mini:set-crossfade-trim-silence.

The actual silence-trimming playback behaviour is wired in a follow-up;
this commit only persists the user intent. i18n added across 9 locales.

* feat(crossfade): trim silence between tracks (waveform-driven)

Wire the actual silence-aware crossfade behind the (default-off)
crossfadeTrimSilence toggle. Detection is derived on the fly from the
cached 500-bin waveform + track duration — no new analysis pass or
cache fields.

- waveformSilence.ts: computeWaveformSilence(bins, duration) → lead/trail
  silence + content bounds, using the peak curve, a low absolute cut and
  a per-side cap. Unit-tested.
- A-tail (JS): handleAudioProgress advances the crossfade early, at
  contentEnd - crossfadeSecs, when the current track ends in real
  trailing silence, so the fade overlaps music. Guarded once per play
  generation.
- B-head: audio_play gains an additive optional start_secs; the freshly
  built source is try_seek'd past the next track's leading silence before
  append, then seek_offset/samples_played are re-anchored so position is
  content-relative. Non-seekable / cold sources degrade to today.
- Pre-buffer: crossfade next-track download + B-head probe moved to
  crossfadePreload.ts with a fixed ~30 s budget before the track needs to
  play (widened by trailing silence so the early advance keeps the
  budget). Also fired right after a seek into the window so jumping near
  the end still buffers in time.

Checks: tsc, vitest (store suite + new units), cargo test/clippy for
psysonic-audio.

* feat(crossfade): recommend hot cache for trim; probe B-head regardless

Add a "for reliable results, enable the Hot playback cache" note to the
trim-silence toggle description across all 9 locales — hot cache keeps
the next track on disk so it starts instantly past its lead silence.

Fix: the leading-silence probe (B-head) now runs even when hot cache is
on; only the redundant byte pre-download is gated on !hotCache. Without
this, enabling hot cache (the recommended setting) would have skipped the
probe and disabled leading-silence trimming.

* feat(crossfade): content-driven smart crossfade overlap

Smart crossfade derives the per-transition overlap purely from the
waveform envelopes — max(A outro fade, B intro rise) clamped 0.5–12s —
instead of the fixed crossfadeSecs ("work by fact"). The JS early
advance arms the computed overlap and audio_play applies it through a
new crossfade_secs_override (capping only this swap's fade); plain
loud→loud endings fall back to the engine crossfade at crossfadeSecs.

* feat(crossfade): "Crossfade | Smart crossfade" mode switch in UI

Replace the standalone "trim silence" toggle with a Crossfade / Smart
crossfade segmented control in settings and both crossfade popovers
(queue toolbar + mini-player). Classic Crossfade shows the seconds
slider; Smart crossfade is content-driven with no duration to set.
Adds smartCrossfade / smartCrossfadeDesc strings to all nine locales
and the "smart crossfade" search keyword.

* feat(crossfade): don't double-fade a track that already fades out

Decouple the outgoing track's fade-out from the incoming fade-in. When
A carries its own recorded fade-out (outroFadeA ≥ 1s and ≥ B's intro
rise), planCrossfadeTransition now sets outgoingFadeSec = 0; the engine
then skips A's TriggeredFadeOut so A keeps full gain and its recording
carries it down while B rises underneath — no more double attenuation
that made A vanish early and B blare in. Hard-cut endings still get an
engine fade over the overlap.

audio_play gains outgoing_fade_secs_override (Some(0) = ride A's own
fade), threaded via SinkSwapInputs.outgoing_fade_secs; the JS advance
arms it alongside the overlap.

* feat(crossfade): rename the smart crossfade mode to "AutoDJ"

User-facing rename of the content-driven crossfade mode from "Smart
crossfade" to "AutoDJ" across the settings segmented switch, the queue
and mini-player popovers, all nine locales, and the settings search
keywords. The underlying store flag (crossfadeTrimSilence) is unchanged.

* feat(crossfade): standard ~2s blend for hard loud→loud meetings

When AutoDJ trims a track's protective trailing silence and the loud
ending butts straight into a loud intro, neither edge fades, so the old
0.5s anti-click floor sounded like an abrupt cut. Use a standard ~2s
equal-power crossfade for that case (both edges analysed, nothing
fades); real fade-outs/buildups keep their longer content-driven span,
and the bare floor only survives when an envelope is missing.

* fix(crossfade): keep B's fade-in across the B-head start-offset seek

EqualPowerFadeIn::try_seek jumped straight to unity gain for any seek
≥100ms, which also hit the initial start-offset seek that skips the
incoming track's leading silence — so a crossfaded track with trimmed
lead silence popped in at full gain instead of fading in. Only skip the
fade-in for mid-playback seeks (sample_count > 0); a seek before any
audio has played keeps the fade-in.

* feat(crossfade): gate AutoDJ early fade on next-track readiness

The early, content-driven advance now fires only when the next track's
audio is actually available — in the engine RAM preload slot
(enginePreloadedTrackId) or local on disk (offline library, favourite-auto
or hot-cache ephemeral) — via isCrossfadeNextReady(). Analysis alone is
not enough: a cold, still-buffering stream would fade in over silence.

When B isn't ready the gen guard stays unset so it re-checks on later
ticks; if B never readies, the plain engine crossfade handles the
transition (graceful degrade) instead of a broken fade. A RAM preload
copy suffices — the full track need not be cached to disk.

* feat(crossfade): suppress engine auto-crossfade for AutoDJ + eager preload

With the hot cache off the readiness gate alone wasn't enough: the engine's
progress task autonomously fires its crossfade audio:ended ~crossfadeSecs
before the end, independently of JS, and would start a still-buffering next
track and fade over it — an audible jump.

- Engine: add autodj_suppress_autocrossfade flag (audio_set_autodj_suppress);
  the progress task treats it like crossfade-off, so the early timer never
  fires and audio:ended only comes from real source exhaustion / watchdog.
- JS drives the transition: set the flag when a content fade is pending
  (wantEarly) and clear it for plain loud→loud / non-AutoDJ, so the normal
  engine crossfade is preserved there. When the next track never readies, A
  plays out and we degrade to a clean sequential start instead of a jump.
- audio_preload gains an `eager` flag; the crossfade/AutoDJ pre-buffer passes
  it to skip the 8s start throttle so the RAM slot fills before the fade.

* docs(changelog): AutoDJ content-aware crossfade (PR #1122)

Add the 1.49.0 Added entry and the cucadmuh credits line for AutoDJ.
2026-06-18 02:15:20 +03:00
Psychotoxical ed52a9991f feat(albums): "Artist → Year" album sort option (#1120)
* feat(albums): "Artist → Year" sort option

Adds a third album browse sort that groups albums by artist and orders each
artist's albums chronologically (oldest first, title as a same-year tiebreak)
— the double-sort requested in #1113. The local index sorts globally via
[{artist},{year},{name}]; the server fallback fetches by artist and applies the
year ordering per page (Subsonic has no compound sort).

* i18n(albums): Artist → Year sort label (9 locales)

* docs(changelog): add Artist → Year to the album sorting entry (#1120)
2026-06-17 22:50:25 +02:00
Psychotoxical ad74578ef6 feat(playlists): local playlist folders (sidebar + page, DnD, view toggle) (#1119)
* feat(playlists): playlist folder model — store + pure grouping core

Local, per-server folder layer over the server's flat playlist list (the
Subsonic API has no folder concept). Adds:
- playlistFolders.ts: shared types + pure groupPlaylistsByFolder (used by
  every surface), with full unit coverage.
- playlistFolderStore.ts: persisted Zustand store (create/rename/delete/
  assign/collapse), per-server scoped; deleting a folder drops its
  assignments so playlists fall back to ungrouped.

UI surfaces (sidebar + Playlists page) build on this in following commits.

* i18n(playlists): folder strings across all 9 locales

Adds the playlists.folders.* namespace (folder names, move/remove, expand/
collapse, group-by-folders toggle, count plurals, and the local-only notice
explaining that Navidrome and the Subsonic API have no native folder support).

* feat(playlists): folder views, drag-to-folder, move-to-folder menu, view toggle

Surfaces the local folder layer on both the Playlists page and the sidebar:
- Page: collapsible folder sections + ungrouped remainder, each reusing
  VirtualCardGrid; flat grid is kept verbatim when no folders exist or the
  group view is toggled off.
- Drag-to-folder via the shared mouse-based psy-drop system (HTML5 DnD is
  unusable in WebKitGTK); the whole section is the drop zone, and the
  ungrouped zone appears during a drag so a playlist can always return to root.
- "Move to folder" submenu in the playlist context menu (keyboard-accessible
  path; also creates folders on the fly) — stays available offline.
- Header gets a "New folder" action and a "Group by folders" view toggle
  (both context-aware); a notice surfaces the local-only caveat.
- Sidebar renders the same collapsible folder groups.
- groupView preference added to the folder store.

* docs(changelog): note playlist folders (#1119)
2026-06-17 21:22:30 +02:00
cucadmuh ccb2d11fc4 fix(deps): bump transitive form-data to 4.0.6 (GHSA-hmw2-7cc7-3qxx) (#1118)
* fix(deps): bump transitive form-data to 4.0.6 (GHSA-hmw2-7cc7-3qxx)

Close Dependabot alert #18: axios pulls form-data for multipart bodies;
lockfile now pins the patched 4.0.6 release (CVE-2026-12143).

* docs: changelog and credits for form-data security bump (PR #1118)

* revert: drop settingsCredits entry for form-data bump

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

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-17 19:10:52 +03:00
Psychotoxical 44d373d7bb fix(player): player bar context menu acts on the current song, not its album (#1117)
* fix(player): player bar context menu acts on the current song, not its album

Right-clicking the current track in the player bar built an album object
from the playing track and opened the album context menu, so "Add to
playlist" added the whole album instead of the song. It now opens a
song-scoped menu for the current track. Left-click on the title still
navigates to the album.

* docs(changelog): note player bar add-to-playlist fix (#1117)
2026-06-17 17:07:47 +02:00
Psychotoxical 116196f0d4 fix(albums): order each artist's albums by title when sorting by artist (#1115)
* fix(albums): order each artist's albums by title when sorting by artist

Browsing albums by artist left the albums within each artist in an
undefined order. The local-index sort only emitted the artist key; it
now appends album title as a secondary key (and artist as the tiebreak
for the by-name sort), matching the network path's per-page ordering.

* docs(changelog): note album sort-within-artist fix (#1115)
2026-06-17 16:50:47 +02:00
Psychotoxical 68b21643f8 fix(windows): restore taskbar thumbnail media buttons after deferred window show (#1112)
* fix(windows): restore taskbar thumbnail media buttons after deferred window show

The Prev / Play-Pause / Next buttons in the Windows taskbar thumbnail
preview stopped appearing. The taskbar code was unchanged; the regression
came from the main window now starting hidden with a deferred show. ThumbBarAddButtons was still called at setup time, before the shell had
created the window's taskbar button, so it returned S_OK but added
nothing (no error logged).

Register the shell's "TaskbarButtonCreated" message and add the buttons
from the window subclass when it fires (first show, and again after an
explorer restart), which is the documented requirement for ThumbBarAddButtons.

* docs(changelog): note Windows taskbar media buttons fix (#1112)
2026-06-17 15:28:38 +02:00
Psychotoxical 8498d5a566 chore(audio): declare windows Power/WindowsAndMessaging features in psysonic-audio (#1111)
power_notify_win.rs imports windows::Win32::System::Power and
UI::WindowsAndMessaging, but the crate only declared Foundation/Com/Threading.
It compiled solely via workspace feature unification (the root src-tauri crate
enables them), so building or testing psysonic-audio in isolation on Windows
(cargo check/test -p psysonic-audio) failed with E0432 unresolved imports.
Declare the two features the crate actually uses so it builds standalone.
No behaviour change; workspace/release builds already had these features.
2026-06-17 12:55:39 +02:00
cucadmuh 3ec65a6407 fix(audio): make seeking work on streamed Opus/Ogg via on-demand HTTP Range (#1110)
* fix(audio): make seeking work on streamed Opus/Ogg via on-demand HTTP Range

Seeking inside an Opus/Ogg track streamed over ranged HTTP was a contained
no-op (the seekbar snapped back); it only worked once the track had fully
downloaded/cached. symphonia 0.6's Ogg demuxer seeks by bisecting the byte
range (reading pages at midpoints across the whole file) and scans the last
pages during the probe, but RangedHttpSource only filled the buffer linearly
from offset 0, so any read ahead of the download front blocked until the
linear download caught up. Keeping Ogg seekable through the probe without a
real random-access source would have forced a full pre-download.

Add an on-demand random-access fetcher to RangedHttpSource: when a read
lands well ahead of the contiguous linear download (a seek, a bisection
midpoint, or the end-of-stream probe), fetch the needed range over HTTP Range
(1 MiB window) on the tokio runtime and let the read loop poll for it, instead
of blocking on the linear filler. Ranged Ogg now stays seekable through the
probe (records its byte range) so seeking works for real; the catch_unwind in
try_seek stays as a safety net.

- New OnDemand fetcher writes arbitrary ranges into the shared buffer (same
  bytes the linear download would write; idempotent under the buffer mutex,
  mirroring the existing MP4 moov tail-prefetch). It never touches
  downloaded_to/done, so full-download completion and the track-analysis seed
  are unaffected.
- On-demand only fires on a forward gap > 512 KiB, so normal sequential
  read-ahead (and a slightly starved play cursor) still waits for the linear
  download without spurious range requests.
- ranged-stream now passes random_access=true; preview keeps on_demand=None.

Does not touch the Tauri boundary (no invoke/event changes).

* docs(changelog): add 1.49.0 entry for streamed Opus/Ogg seeking (#1110)

Also credit the streamed-seek work in settingsCredits.

* fix(audio): require 206 for ranged Range fetches at a non-zero offset

Address PR #1110 review note: ranged_write_http_range accepted a 200 the
same as a 206. A server that ignored the Range header and replied 200 returns
the whole body from byte 0; writing that at a non-zero offset would corrupt
the buffer (affects both the on-demand seek fetcher and the MP4 moov-tail
prefetch). Accept 200 only when the request started at offset 0; otherwise
require 206.
2026-06-17 13:49:50 +03:00
Psychotoxical 47b09d6f25 chore(aur): bump PKGBUILD to v1.48.1 (#1107) 2026-06-17 02:41:50 +02:00
cucadmuh 1e956d6043 Merge pull request #1106 from Psychotoxical/fix/backport-1.48.1
Backport 1.48.1 fixes to main
2026-06-17 01:47:22 +03:00
cucadmuh 82967caa9c docs: add 1.48.1 release section to CHANGELOG and WHATS_NEW
Backport of the 1.48.1 hotfix notes onto main (which is on 1.49.0-dev).
Insert the released [1.48.1] section between [1.49.0] and [1.48.0] in
CHANGELOG.md (all eight Fixed entries with their attribution, identical to the
fix/1.48.1 branch) and the matching [1.48.1] What's New section. Application
version is intentionally left at 1.49.0-dev — only the notes are carried over.
2026-06-17 01:32:40 +03:00
Psychotoxical a6122f9db4 fix(windows): transcode WebP cover to PNG for the media controls (#1102)
Windows SMTC could not render our cached WebP album covers: souvlaki loads the
file and SetThumbnail/set_metadata succeed, but the lock screen and Quick
Settings media tile showed a blank cover, because the OS thumbnail decoder does
not handle WebP even with the Store WebP extension installed.

Transcode local file:// WebP covers to PNG (libwebp decode then image PNG
encode, into a single reusable temp file) before handing them to the OS media
controls, gated to Windows. macOS (ImageIO) and Linux pass through unchanged.

(cherry picked from commit 76d028127d)
2026-06-17 01:30:36 +03:00
Psychotoxical 6d63365c2a fix(windows): show app name in media controls via AppUserModelID (#1102)
The Windows system media controls (Quick Settings media tile, lock screen,
third-party media flyouts) labelled playback as "Unknown application" with no
icon, because souvlaki creates the SMTC via GetForWindow and Windows resolves
the source name from the process AppUserModelID, which was never set.

Call SetCurrentProcessExplicitAppUserModelID early in run() so the process has
an explicit identity that matches the installer shortcut's AppUserModelID;
Windows then resolves the name and icon to Psysonic.

(cherry picked from commit 4fd85f2dd4)
2026-06-17 01:30:23 +03:00
cucadmuh 6168e81195 fix(library): use partial indexes for §6.9 remap lookup (#1105)
* fix(library): use partial indexes for §6.9 remap lookup

The delta-sync remap detection ran a single lookup with an OR across
content_hash and server_path. SQLite could not use the partial
idx_track_remap_hash / idx_track_remap_path indexes for it: a partial
index is only applied when the query's WHERE provably implies the index
predicate (… != ''), and an OR spanning two columns blocks the per-branch
index plan. The query degraded to a full track scan on every incoming
row → O(rows × catalog), causing multi-minute stalls on large libraries
(observed upsert_batch_remap exec_ms=162001 on a ~200k-track Navidrome
sync, which in turn blocked all other writers on the single write mutex).

Split it into two single-column lookups that each repeat the index
predicate so the planner picks the matching partial index (SEARCH, not
SCAN); hash is checked first, matching §6.9 strong-key priority. Adds an
EXPLAIN QUERY PLAN regression test asserting index usage.

* docs(changelog): note remap-lookup sync stall fix (PR #1105)

(cherry picked from commit bca0acbaff)
2026-06-17 01:30:23 +03:00
cucadmuh 067ed00ae2 fix(audio): fix Opus/Ogg seek crash (symphonia do_seek panic) (#1100)
* fix(audio): fix Opus/Ogg seek crash by keeping random-access sources seekable through probe

Scrubbing the seekbar on Opus/Ogg files (then pressing Stop) crashed the whole
app. symphonia 0.6's Ogg demuxer records the physical stream's byte range only
when the source is seekable during the probe, but ProbeSeekGate hid seekability
there — so phys_byte_range_end stayed None and the first seek hit
Option::unwrap() on None on the cpal audio thread. That poisoned the engine
mutexes and aborted the process at the non-unwinding cpal FFI boundary (the
"crash on Stop" was a downstream symptom).

- Keep Ogg/Opus seekable through the probe on random-access sources (local
  files, in-memory) so the demuxer computes its seek bounds and seeking works
  for real. Progressive ranged-HTTP keeps the gate to avoid forcing a full
  download before playback starts.
- Contain any demuxer unwind inside SizedDecoder::try_seek (catch_unwind) so a
  panic on the audio thread can no longer poison engine state — covers streamed
  Ogg (still gated) and any future demuxer panic.
- Thread a random_access flag through PlayInput::SeekableMedia and new_streaming.

* docs(changelog): add 1.48.1 entry for the Opus/Ogg seek crash fix

Also note the Opus seek crash fix in WHATS_NEW.md (1.48.1).

(cherry picked from commit 8bfde08199)
2026-06-17 01:30:23 +03:00
Psychotoxical 16e562b42d fix(window): honour minimize-to-tray on the macOS close button
The macOS red close button always emitted app:force-quit, which exits
unconditionally and never checked the minimizeToTray setting — so on macOS
the window closed the app even with "Minimize to Tray" enabled. Route the
main-window close through window:close-requested on all platforms, so JS
decides hide-vs-exit from the setting (default off = unchanged quit). The
tray "Exit" item still force-quits.

Fixes #1103

(cherry picked from commit acd6f12aba)
2026-06-17 01:29:07 +03:00
Psychotoxical 961dba996c fix(discord): resolve cover profile from the store, not getPlaybackServerId
Follow-up to the dual-address cover fix: a playback/active cover scope was
routed through getPlaybackServerId(), which returns a `string` that can be
empty or an index-key (not a profile id) for locally-cached tracks. The
`?? activeServerId` fallback never fired (empty string isn't nullish), so no
profile matched and the URL came back null — which the presence layer caches
per cover id, producing intermittent "cover shows, then doesn't".

A playback/active scope always means the active server (a cross-server track
gets an explicit `server` scope), so resolve the active profile directly.

(cherry picked from commit 8f93f30e6f)
2026-06-17 01:29:07 +03:00
Psychotoxical 4fd558fa28 fix(discord): use the public server address for Rich Presence cover art
The Discord cover URL was built via the connect endpoint, which prefers the
LAN address — but Discord fetches the image from its own servers, so a LAN
address is unreachable and the cover falls back to the app icon. This is a
dual-address regression: before a second (public) address could be added,
the only configured URL was the public one.

Build the Discord large-image URL via serverShareBaseUrl (public preferred,
like share links / Orbit invites) instead of the connect URL. Adds a test.

(cherry picked from commit 5f15784b7d)
2026-06-17 01:29:07 +03:00
Psychotoxical 42dcbb9323 fix(audio): bump generation on paused device reopen to stop spurious audio:ended
Third defence for #1094: on a device change while paused/stopped,
reopen_output_stream stopped the old sink without bumping the engine
generation (the bump only happened on a successful internal resume). The
still-running progress task could then flip done_flag and emit a spurious
audio:ended, which the frontend turns into a restart. Bump the generation
before sink.stop() in the non-playing case so the progress task bails out;
the active-playback path keeps bumping inside try_resume_after_device_change.

(cherry picked from commit 9034882bf6)
2026-06-17 01:29:07 +03:00
Psychotoxical 997e697a53 fix(audio): don't restart playback on device change when engine is paused
Defence-in-depth for #1094: the device-changed/-reset handlers restarted
playback based on the UI `isPlaying` flag alone, which can be stale or
desynced at the moment of a device change. Gate the restart on the
engine-paused flag as well (`isPlaying && !getIsAudioPaused()`), so a
paused engine never auto-restarts regardless of how `isPlaying` got set;
when paused it just resets for the cold path. Adds a regression test.

(cherry picked from commit 588dd8c48d)
2026-06-17 01:29:07 +03:00
Psychotoxical 4c0dfaaada fix(media-controls): honour explicit Play/Pause from OS media keys
Toggle, Play and Pause from the OS media-control bridge (souvlaki) were
all collapsed onto the play-pause toggle event. On an audio-route change
(e.g. macOS sending an explicit Pause when headphones disconnect) this
turned the pause into a toggle, resuming paused playback on the new
output device.

Map Play and Pause to dedicated media:play / media:pause events (the
frontend handlers already exist); only a real toggle key emits
media:play-pause. Paused playback now stays paused across device changes.

Fixes #1094

(cherry picked from commit f04bfb3d35)
2026-06-17 01:29:07 +03:00
Psychotoxical e563749ace fix(queue): stop re-walking the whole queue on every track change
QueueHeader aggregated total/future duration in a useMemo keyed on queueIndex,
so every skip ran a synchronous O(n) pass over the entire queue (resolveQueueTrack
per item). On very large queues this blocked the main thread for seconds — the
UI froze on skip and on the device-switch playTrack fallback (#1072; the freeze
half of #1090). QueuePanel is always mounted, so it hit even with the queue
collapsed.

Build a cumulative-duration prefix keyed on queue/resolver-version only; the
future-tracks total is now an O(1) lookup per skip. Display output unchanged.

(cherry picked from commit 7e91a5b2a1)
2026-06-17 01:29:07 +03:00
Psychotoxical 15fb0f6c56 Theme store: version display + animated/static filter (#1104)
* feat(themes): show theme version in the store and installed list

* feat(themes): filter the theme store by animated / static

* docs(changelog): add 1.49.0 entry for theme store version + animated filter
2026-06-16 20:52:02 +02:00
Psychotoxical 6d404fdc2d chore(aur): bump PKGBUILD to 1.48.0 (#1093) 2026-06-15 00:34:18 +02:00
github-actions[bot] c453f01b94 chore(release): bump main to 1.49.0-dev (#1091)
* chore(release): bump main to 1.49.0-dev

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

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-14 22:45:00 +02:00
486 changed files with 22661 additions and 3171 deletions
+281
View File
@@ -9,6 +9,287 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
>
## [1.49.0]
## Added
### Theme store — version numbers and an animated/static filter
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1104](https://github.com/Psychotoxical/psysonic/pull/1104)**
* Theme versions now show in the store (next to the author) and under each installed community theme; when an update is available, the store shows the installed → available version.
* New store filter to show only animated themes or only static ones, next to the existing mode and sort controls.
### Playlist folders
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1119](https://github.com/Psychotoxical/psysonic/pull/1119)**, suggested by [@SilverWolf24](https://github.com/SilverWolf24)
* Organise your playlists into folders on the Playlists page and in the sidebar — create folders, drag playlists into them (or use the right-click "Move to folder" menu), rename, collapse and switch between the folder view and a single flat list. Folders are saved locally on this device only, since the Subsonic API has no folder support.
### AutoDJ — content-aware crossfade
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1122](https://github.com/Psychotoxical/psysonic/pull/1122) and [@Psychotoxical](https://github.com/Psychotoxical), PR [#1124](https://github.com/Psychotoxical/psysonic/pull/1124)**
* New **AutoDJ** crossfade mode. Instead of a fixed crossfade time, it blends what you actually hear: it trims the dead silence at the end of one track and the start of the next, and picks the overlap from the music itself — a track that fades out rides its own fade while the next one rises underneath, and two tracks that both start/end loud get a short musical blend instead of an abrupt cut. Works most reliably with the Hot playback cache enabled, since the next track's audio needs to be ready for the blend.
* AutoDJ is now its own mode rather than a sub-option of Crossfade — its own button in the queue toolbar and its own entry in the audio settings. Crossfade, AutoDJ and Gapless are mutually exclusive (only one active at a time) under a single Off / Gapless / Crossfade / AutoDJ picker, the playback settings are regrouped into clearer Normalization / Track transitions / Queue behaviour panels, and the queue toolbar's separate Save and Load playlist buttons are combined into one Playlist menu (existing toolbar layouts are preserved). Off by default; classic Crossfade is unchanged.
### AutoDJ — smooth skip and interrupt blend
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1128](https://github.com/Psychotoxical/psysonic/pull/1128)**
* New **Smooth skip** toggle under Settings → Audio → Track transitions (on by default when AutoDJ is active). Manual Next/Previous and picking a track from the library, an album, or the infinite queue crossfade from where you are listening instead of hard-cutting.
* Loud→loud queue advances use a consistent ~2s musical blend; manual skips cap at the same length so quiet intros are not drowned out.
* When the target track is not buffered yet, the player briefly ducks the outgoing track while preloading; the player bar keeps showing the current song until the handoff so titles and artwork do not flicker or pause spuriously.
* During an active blend, the play/pause button shows a pulsing Blend icon.
### Play queue sync — cross-device handoff
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1131](https://github.com/Psychotoxical/psysonic/pull/1131)**, closes [#1129](https://github.com/Psychotoxical/psysonic/issues/1129)
* Manual **pull** from the header connection indicator (LED + sync ring): click to fetch the active server's play queue when it differs from the local player; no-op when already in sync. Yellow LED when browse server ≠ playback server (e.g. after switching servers).
* **Idle auto-pull** when paused/stopped for 30+ seconds on a single-server queue (active = playback): polls every 10s and applies server changes.
* **Push** now sends only tracks owned by the playback server (fixes mixed-server queues). Switching browse servers flushes the old server's queue slice without auto-pull.
### Japanese translation
**By [@Soli0222](https://github.com/Soli0222), PR [#1134](https://github.com/Psychotoxical/psysonic/pull/1134)**
* Full Japanese (日本語) UI translation — selectable from the language picker on the Settings and Login screens.
### Artist artwork from fanart.tv
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1137](https://github.com/Psychotoxical/psysonic/pull/1137)**
* New opt-in **External Artwork Scraper** (Settings → Integrations, off by default): artist imagery from fanart.tv — a 16:9 background on the fullscreen player and a wide banner on the artist page — with Navidrome staying the canonical cover. Optional personal key; turning it off removes the fetched images again.
### Remember the equalizer per audio output device
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1146](https://github.com/Psychotoxical/psysonic/pull/1146)**, suggested by [@JustBuddy](https://github.com/JustBuddy)
* New opt-in **Remember EQ per device** toggle (Settings → Audio → Audio Output Device, off by default): the equalizer profile — bands, pre-gain, enabled state and active preset — is saved per audio output device and restored automatically when you switch devices.
### Hungarian translation
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1149](https://github.com/Psychotoxical/psysonic/pull/1149)**, a gift to [@falu](https://github.com/falu) for the first independent review of Psysonic
* Psysonic is now available in **Hungarian (Magyar)** — pick it from the language menu on the Settings and Login screens.
### Custom HTTP headers for gated servers
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1156](https://github.com/Psychotoxical/psysonic/pull/1156)**, closes [#1095](https://github.com/Psychotoxical/psysonic/issues/1095)
* Per-server **custom HTTP headers** in Settings → Servers for reverse-proxy gates (Cloudflare Access, Pangolin, and similar): add name/value pairs, choose whether they apply to the local URL, public URL, or both on dual-address profiles.
* Headers attach to every user-server HTTP path — library sync, playback, covers, offline download, Navidrome admin, capability probes, and share-link preview — without putting secrets in invite links or magic strings.
* Gate header values are redacted from application logs.
### Orbit — shared crossfade, gapless and AutoDJ
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1158](https://github.com/Psychotoxical/psysonic/pull/1158)**
* In an Orbit session the host's track-transition settings — crossfade, gapless or AutoDJ, including the crossfade length and smooth-skip — now apply to everyone, so guests blend between tracks the same way the host does instead of each person using their own. Your own settings are restored when you leave.
* While you are a guest in a session, the transition controls in Settings → Audio and the queue toolbar are shown as host-controlled.
## Changed
### Settings — consistent grouped layout
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1126](https://github.com/Psychotoxical/psysonic/pull/1126) and PR [#1130](https://github.com/Psychotoxical/psysonic/pull/1130)**
* The settings tabs now group related controls into clearly bordered, labelled panels for a more consistent, easier-to-scan layout — across Appearance, System, Audio, Storage, Library, Integrations, Music Network, Lyrics, Personalisation, Input and Themes. Standalone toggles are left as they were, and a few duplicated section titles are gone.
* The **Lucky Mix menu** toggle moved from the Library tab to the sidebar customizer, alongside the other navigation toggles.
* The **Native Hi-Res Playback** description now explains what turning it on actually does — play each track at its original sample rate, matching the audio device to the file, instead of resampling everything to 44.1 kHz. The old wording described the off state and read as if the option forced 44.1 kHz.
* **Settings → Audio**: **Normalization** and **Track transitions** are now their own top-level categories (directly under Audio Output Device) instead of being grouped together inside one *Playback* section.
* **Settings → Personalisation** gains a **Queue Settings** category that brings the queue display mode, the queue toolbar customizer, and the **Preserve "Play Next" order** toggle (moved here from Audio) together in one place.
* On macOS, the **Audio Output Device** category is now hidden rather than showing a notice — playback there always follows the system output device.
## Fixed
### Playlists header buttons clipped at narrow widths
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1153](https://github.com/Psychotoxical/psysonic/pull/1153)**
* The action buttons at the top of the Playlists page (New Playlist, New Smart Playlist, folder controls, Select) could run off-screen and get cut off when the window was narrow or the queue panel was open. They now wrap onto multiple rows, left-aligned.
### Seeking in streamed Opus/Ogg tracks
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1110](https://github.com/Psychotoxical/psysonic/pull/1110)**
* Scrubbing an Opus/Ogg track that was still streaming did nothing — the seekbar snapped back, and seeking only worked once the track had fully downloaded. Seeking now works mid-stream: the player fetches just the part of the file it needs over HTTP instead of waiting for the whole track to download. Cached and local files are unchanged. (Follow-up to the 1.48.1 Opus/Ogg seek-crash fix, #1100, which made streamed seeking a safe no-op rather than a crash.)
### Media buttons missing from the Windows taskbar preview
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1112](https://github.com/Psychotoxical/psysonic/pull/1112)**
* The Previous / Play-Pause / Next buttons in the Windows taskbar thumbnail preview (the popup shown when hovering the taskbar icon) had stopped appearing. They are back, and the middle button's icon again reflects the current playback state.
### Album sorting within artists
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1115](https://github.com/Psychotoxical/psysonic/pull/1115), PR [#1120](https://github.com/Psychotoxical/psysonic/pull/1120)**, suggested by [@kingley82](https://github.com/kingley82)
* When browsing albums sorted by artist, each artist's albums appeared in an arbitrary order. They are now ordered AZ by album title within each artist.
* New **Artist → Year** sort option groups albums by artist and orders each artist's albums chronologically (oldest first).
### "Add to playlist" from the player bar added the whole album
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1117](https://github.com/Psychotoxical/psysonic/pull/1117)**
* Right-clicking the current track in the player bar opened an album menu, so "Add to playlist" added the entire album instead of the playing song. The player bar menu now acts on the current song.
### Security — transitive form-data CRLF injection (GHSA-hmw2-7cc7-3qxx)
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1118](https://github.com/Psychotoxical/psysonic/pull/1118)**
* Bumped transitive `form-data` 4.0.5 → 4.0.6 (via axios) to close Dependabot alert [#18](https://github.com/Psychotoxical/psysonic/security/dependabot/18) for CRLF injection in multipart field names (CVE-2026-12143). Psysonic only uses axios for GET requests, so exploitability was low; the lockfile bump clears the advisory.
### Live listener badge stale when the popover was closed
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1125](https://github.com/Psychotoxical/psysonic/pull/1125)**
* The Live header badge only refreshed `getNowPlaying` while the "Who is listening?" popover was open, so the listener count could stay stale or hidden until opened. Poll every 30 s while the window is visible (10 s while the popover is open); background fetches are silent so the header does not flash a loading state.
### Niri compositor tiling WM detection
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1127](https://github.com/Psychotoxical/psysonic/pull/1127)**
* Niri is now recognized as a tiling window manager (`NIRI_SOCKET`, `XDG_CURRENT_DESKTOP=niri`), so it gets the same custom title bar, window decorations, and mini-player behavior as Hyprland and Sway instead of being treated like a floating desktop.
### Play queue idle pull overwrote local edits
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1132](https://github.com/Psychotoxical/psysonic/pull/1132)**
* After cross-device idle pull while paused, a local queue change (e.g. enqueue) could be overwritten when auto-pull ran again. Idle auto-pull now stops on local mutations until manual sync from the header; the connection LED turns yellow while auto-sync is paused.
### Paused client did not push edited queue on Play
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1133](https://github.com/Psychotoxical/psysonic/pull/1133)**
* After editing the queue while paused (yellow sync LED), pressing Play only resumed audio and could leave the server on another device's queue until the debounced push fired. Resume and play-from-queue now flush the local play queue immediately and clear the yellow indicator when the push succeeds.
### Connection indicator flapping on flaky links
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1135](https://github.com/Psychotoxical/psysonic/pull/1135)**
* The header connection probe now retries a failed ping twice (2 s apart) before marking the server unreachable, so a single dropped packet on an otherwise fine link no longer flips the LED to disconnected.
### Yellow sync LED during normal playback
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1136](https://github.com/Psychotoxical/psysonic/pull/1136)**
* Track-advance queue pushes no longer suspend idle auto-pull, so the connection LED does not flash yellow on every song change. Yellow sync still appears after a local queue edit while paused; it clears while audio is playing.
### Update notification — clearer popup on Linux
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1142](https://github.com/Psychotoxical/psysonic/pull/1142)**, reported by zunoz on Discord
* The "new version available" popup no longer shows blurry, unfocused text on some Linux setups (the background blur could bleed onto the dialog). The version arrow now lines up with the heading, and the Skip / Remind me later buttons read clearly — Remind me later is the highlighted action when there's no in-app installer.
### Favorites — bulk add to playlist and play/enqueue selected
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#1140](https://github.com/Psychotoxical/psysonic/pull/1140)**
* Bulk **Add to playlist** no longer cleared the selection on `mousedown` before the click ran, so chosen tracks were not actually added.
* With rows selected, **Play all** / **Add all to queue** become **Play selected** / **Add selected to queue** and act on the checked tracks only.
* Bulk add now snapshots every checked row when the picker opens so all selected tracks land in the playlist, not just the last one.
### Artists letter index — Navidrome ignored articles
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1145](https://github.com/Psychotoxical/psysonic/pull/1145)**, closes [#1144](https://github.com/Psychotoxical/psysonic/issues/1144)
* On the **Artists** page (and **Composers**), the AZ filter now groups names like Navidrome: leading articles such as **The** are skipped before picking the letter — **The Beatles** lands under **B**, not **T**. The bucket follows the server's own `ignoredArticles` list when the local index knows it.
* The local library index stores `name_sort` and the server's `ignoredArticles` from `getArtists`, sorts browse SQL by the sort key (now indexed), and repairs stale keys once on upgrade.
### Library index — safer open, swap and recovery
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1145](https://github.com/Psychotoxical/psysonic/pull/1145)**
* The local library database now opens, swaps and restores through one pipeline, so a swapped or restored file always picks up pending migrations and one-time repairs instead of serving a stale schema.
* A panic or a poisoned lock in one query no longer wedges the whole library index — connections recover and report the error instead, and the new sort-key migration applies idempotently so a half-applied upgrade self-heals on the next launch.
### Equalizer — the active AutoEQ profile name stays visible
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1147](https://github.com/Psychotoxical/psysonic/pull/1147)**
* After applying an AutoEQ headphone profile, the preset picker now shows the profile name under an AutoEQ group instead of going blank, and the delete button no longer appears for AutoEQ profiles (where it did nothing).
### All Albums — compilation and favorites filters
**By [@cucadmuh](https://github.com/cucadmuh), reported by [@bcorporaal](https://github.com/bcorporaal), PR [#1151](https://github.com/Psychotoxical/psysonic/pull/1151)**, closes [#1143](https://github.com/Psychotoxical/psysonic/issues/1143)
* **Only compilations** no longer shows a handful of albums after the local index already filtered them — slice mode skips the redundant client pass that dropped rows without `isCompilation` on the DTO.
* **Favorites** on All Albums uses the same `getStarred2` catalog path as the Favorites page instead of the empty sparse `album` table browse.
* Pre-index compilation filtering auto-paginates again in network page mode; offline library aggregates set `isCompilation` from track tags.
### Orbit — session reliability fixes
**By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#1155](https://github.com/Psychotoxical/psysonic/pull/1155), [#1157](https://github.com/Psychotoxical/psysonic/pull/1157), [#1159](https://github.com/Psychotoxical/psysonic/pull/1159)**
* Opening Psysonic on a second device no longer deletes a session that is still live on another device.
* Long sessions keep updating for guests instead of silently stalling once the shared state grew too large.
* Radio no longer adds unrelated tracks to a guest's queue mid-session.
* Auto-shuffle and auto-approve are independent again — toggling one in an older session no longer flips the other.
* A session is kept within its guest limit even when several people join at once.
* Guest suggestions no longer get silently lost or stuck on "waiting on host": overlapping host updates are serialised, a lost suggestion is re-sent (with a notice if it still can't get through), and a flaky join no longer leaves a duplicate suggestion list on the server.
* Pasted invites are rejected unless they point at a normal http/https server address.
## [1.48.1] - 2026-06-15
## Fixed
### Playback freeze on track changes
**By [@Psychotoxical](https://github.com/Psychotoxical)**
* Changing tracks — skipping, or the automatic advance at the end of a song — could freeze the interface for several seconds while audio kept playing (the progress bar and lyrics stopped updating). The queue header recomputed its duration totals on every track change instead of only when the queue itself changes; it now recomputes only on queue changes, so track changes stay instant.
* This also resolves output-device changes not being applied on Windows: the same freeze was blocking playback from following the newly selected device.
### Paused or stopped playback restarting on headphone disconnect (macOS)
**By [@Psychotoxical](https://github.com/Psychotoxical)**
* On macOS, pausing or stopping playback and then disconnecting headphones (or otherwise switching the audio output device) could make playback restart on the newly selected device. Playback now reliably stays paused or stopped across a device change.
### Crash when seeking Opus/Ogg files
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1100](https://github.com/Psychotoxical/psysonic/pull/1100)**
* Scrubbing the seekbar on an Opus/Ogg file — and then pressing Stop — crashed the whole app (a 1.48 regression from the Symphonia 0.6 migration). The Ogg demuxer recorded its seek bounds only when the source was seekable during the format probe, but probing hid seekability, so the first seek panicked on the audio thread (`Option::unwrap()` on `None`) and took the process down at the audio backend boundary.
* Local and in-memory Opus/Ogg sources now stay seekable through the probe, so seeking works correctly. As a safety net, any decoder panic during a seek is contained instead of crashing the app; for Opus/Ogg streamed over HTTP, seeking is a no-op for now rather than a crash.
### Discord Rich Presence cover art missing with two server addresses
**By [@Psychotoxical](https://github.com/Psychotoxical)**
* When a server profile had both a local and a public address, Discord Rich Presence showed the placeholder icon instead of the album cover. The cover URL used the local address, which Discord's servers can't reach; it now uses the public address (the same one used for share links).
### "Minimize to Tray" ignored on the macOS close button
**By [@Psychotoxical](https://github.com/Psychotoxical)**
* On macOS, closing the window with the red close button always quit the app, even with "Minimize to Tray" enabled. The close button now respects the setting — with it on, the window hides to the tray instead of quitting, the same as the tray icon's "Hide".
### Library sync stalling for many seconds on large Navidrome collections
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1105](https://github.com/Psychotoxical/psysonic/pull/1105)**
* On large libraries (reported with ~200,000 tracks on Navidrome), background library sync could lock up database writes for minutes at a time — playback history, ratings and other saves piled up waiting behind it.
* Root cause: the track id-remap step ran a database lookup that couldn't use its indexes and scanned the entire track table once per incoming track, so the cost grew with the square of the library size. The lookup now uses the proper indexes, bringing it back to a fast, near-instant operation.
### Album cover missing in Windows media controls
**By [@Psychotoxical](https://github.com/Psychotoxical)**
* On Windows, the system media controls (the Quick Settings media tile, the lock screen and third-party media flyouts) showed the track title and artist but no album cover. Windows could not decode the cached WebP cover art for its thumbnail, even with the Store WebP extension installed. The cover is now converted to PNG before it is handed to the media controls, so the artwork shows again. macOS and Linux are unaffected.
### Windows media controls showed "Unknown application"
**By [@Psychotoxical](https://github.com/Psychotoxical)**
* On Windows, the system media controls (the Quick Settings media tile, the lock screen and third-party media flyouts) labelled playback as "Unknown application" with no icon. The app now registers an explicit application identity at startup so Windows shows "Psysonic" and its icon as the playback source.
## [1.48.0]
## Added
+17 -4
View File
@@ -14,11 +14,11 @@ Psysonic is built primarily for **Navidrome** and also works with **Gonic**, **A
<a href="https://discord.gg/AMnDRErm4u"><img src="https://img.shields.io/badge/Discord-Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord Community"></a> <a href="https://t.me/+GLBx1_xeH28xYTJi"><img src="https://img.shields.io/badge/Telegram-Community-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community"></a> <a href="https://ko-fi.com/psychotoxic"><img src="https://img.shields.io/badge/Ko--fi-Support%20Psysonic-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white" alt="Support Psysonic on Ko-fi"></a>
<a href="https://aur.archlinux.org/packages/psysonic"><img src="https://img.shields.io/badge/AUR-psysonic-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic"></a> <a href="https://aur.archlinux.org/packages/psysonic-bin"><img src="https://img.shields.io/badge/AUR-psysonic--bin-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic-bin"></a> <a href="https://psysonic.cachix.org"><img src="https://img.shields.io/badge/Cachix-psysonic.cachix.org-5277C3?style=for-the-badge&logo=nixos&logoColor=white" alt="Cachix"></a>
<a href="https://aur.archlinux.org/packages/psysonic"><img src="https://img.shields.io/badge/AUR-psysonic-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic"></a> <a href="https://aur.archlinux.org/packages/psysonic-bin"><img src="https://img.shields.io/badge/AUR-psysonic--bin-1793d1?style=for-the-badge&logo=arch-linux&logoColor=white" alt="AUR psysonic-bin"></a> <a href="https://psysonic.cachix.org"><img src="https://img.shields.io/badge/Cachix-psysonic.cachix.org-5277C3?style=for-the-badge&logo=nixos&logoColor=white" alt="Cachix"></a> <a href="https://github.com/microsoft/winget-pkgs/tree/master/manifests/p/Psychotoxical/Psysonic/1.47.0"><img src="https://img.shields.io/badge/WinGet-psysonic-blue?style=for-the-badge&logo=windows" alt="WinGet psysonic"></a>
<br><br>
**Available languages:** English, German, Spanish, French, Norwegian Bokmål, Dutch, Romanian, Russian and Chinese.
**Available languages:** English, German, Spanish, French, Norwegian Bokmål, Dutch, Romanian, Russian, Chinese, Japanese and Hungarian.
More translations are added over time.
@@ -134,7 +134,7 @@ Start a session, invite others with a link and listen together with host-control
| OS | Support |
| ------- | --------------------------------------------------------------- |
| Windows | Native installer |
| Windows | Native installer / WinGet |
| macOS | Signed DMG |
| Linux | AppImage / DEB / RPM / AUR (`psysonic`, `psysonic-bin`) / NixOS |
@@ -154,7 +154,14 @@ Linux builds are also available through GitHub Releases, AUR and Cachix/Nix.
## Windows
Download the latest installer from the [GitHub Releases](https://github.com/Psychotoxical/psysonic/releases/latest).
Download the latest installer from the [GitHub Releases](https://github.com/Psychotoxical/psysonic/releases/latest).
or,
install via Windows Package Manager (WinGet):
```powershell
winget install Psysonic
```
You can also browse and install it on [winstall.app](https://winstall.app/apps/Psychotoxical.Psysonic).
## macOS
@@ -194,6 +201,12 @@ See [TELEMETRY.md](TELEMETRY.md) for the telemetry stance and [PRIVACY.md](PRIVA
---
# Reviews
* [An independent review at falu.github.io](https://falu.github.io/2026/06/19/psysonic.html)
---
# Community & Support
Join the community, report bugs, suggest features, share themes and help shape the future of Psysonic.
+80
View File
@@ -7,6 +7,86 @@ current line before promoting to `next` / `release`. Technical details and PR cr
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.49.0]
## Highlights
### AutoDJ — minimum pauses, maximum music
- New **AutoDJ** mode — a smart crossfade that blends tracks intelligently. Each transition is shaped to fit the music: awkward gaps fade away, handovers feel natural, and you spend less time in silence between songs. It's now its own choice in **Settings → Audio** and its own button in the queue toolbar, sitting alongside Crossfade and Gapless — pick one at a time. Off by default; classic **Crossfade** is unchanged.
### Playlist folders — your playlists, organised
- Folders on the **Playlists** page and in the sidebar keep long lists tidy — group by mood, occasion, or anything you like. Drag playlists in, rename and collapse folders, or choose **Move to folder** from the right-click menu. Switch back to a flat list whenever you prefer.
### Theme store — spot updates, pick your style
- Version numbers on store themes and ones you have installed make it obvious when an update is ready.
- Filter for **animated** or **static** themes only — less scrolling when you already know the look you want.
### Settings — tidier and easier to scan
- Settings are grouped into clear, labelled panels so related options sit together — less hunting around. The **Native Hi-Res Playback** option now explains in plain language what it actually does.
- **Normalization** and **Track transitions** are now their own sections under **Settings → Audio**, and the queue options (display mode, toolbar, and Play-Next order) are gathered into one **Queue Settings** group under **Personalisation**.
### Japanese — now in your language
- Psysonic is now available in **Japanese (日本語)** — pick it from the language menu on the **Settings** and **Login** screens.
### Artist artwork — richer artist and fullscreen views
- Switch on **External Artwork Scraper** under **Settings → Integrations** to pull artist imagery from fanart.tv: a wide backdrop on the fullscreen player and a banner across the top of the artist page. Off by default, your Navidrome covers stay in charge, and turning it back off removes the fetched images again.
### Equalizer — a profile per output device
- Turn on **Remember EQ per device** under **Settings → Audio** and Psysonic keeps a separate equalizer setup for each output — speakers, headphones, a USB DAC — and switches to the right one automatically when you change devices. Off by default.
## Fixed
### Playback and audio
- **Opus/Ogg** tracks no longer fight the seekbar while they are still loading — scrub to where you want to be and keep listening.
### Player and playlists
- **Add to playlist** from the player bar adds the song you are hearing, not the whole album.
### Browse and library
- Albums sorted by artist now list each artist's work AZ by title — no more random order within a name.
- **Artist → Year** keeps artists grouped but walks through their albums chronologically, oldest first.
### Windows
- **Windows:** Previous, Play/Pause, and Next are back when you hover the taskbar icon — and Play/Pause shows whether music is playing or paused.
## [1.48.1]
## Fixed
### Playback and audio
- Changing tracks — skipping, or the automatic advance at the end of a song — no longer freezes the interface for a few seconds: the progress bar and lyrics keep updating, and on **Windows** a change of output device now takes effect right away.
- Seeking an **Opus/Ogg** track — and then pressing **Stop** — no longer crashes the app.
- **macOS:** pausing or stopping playback and then unplugging headphones (or switching the output device) no longer makes playback restart — it stays paused or stopped.
### Offline, Now Playing, and Navidrome
- On large **Navidrome** libraries, background library sync no longer locks up database writes for minutes at a time, so play history, ratings, and other saves go through without long delays.
### Themes and integrations
- **Discord** Rich Presence shows the album cover again when a server profile has both a local and a public address.
### Other
- **Windows:** the system media controls (Quick Settings media tile, lock screen, and third-party flyouts) now show the album cover and display **Psysonic** with its icon instead of "Unknown application".
- **macOS:** closing the window with the red close button now respects **Minimize to Tray** — with it on, the window hides to the tray instead of quitting.
## [1.48.0]
## Highlights
+1 -1
View File
@@ -1,3 +1,3 @@
{
"npmDepsHash": "sha256-nwCrZ0enhyLNkS15sgSR9CuQX9cif4fLDNAL2yUPG8s="
"npmDepsHash": "sha256-Y5gZccVREEG9XpYJ/MTtoDn7lC3u9sKiZ8tN3OyEYCU="
}
+13 -13
View File
@@ -1,12 +1,12 @@
{
"name": "psysonic",
"version": "1.48.0-dev",
"version": "1.49.0-dev",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "psysonic",
"version": "1.48.0-dev",
"version": "1.49.0-dev",
"dependencies": {
"@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/figtree": "^5.2.10",
@@ -2466,16 +2466,16 @@
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
@@ -2592,9 +2592,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -3674,9 +3674,9 @@
}
},
"node_modules/undici": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.1.tgz",
"integrity": "sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"dev": true,
"license": "MIT",
"engines": {
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.48.0-dev",
"version": "1.49.0-dev",
"private": true,
"scripts": {
"check:css-imports": "node scripts/check-css-import-graph.mjs",
@@ -69,5 +69,8 @@
"typescript": "^6.0.3",
"vite": "^8.0.14",
"vitest": "^4.1.8"
},
"overrides": {
"undici": "^7.28.0"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.46.0
pkgver=1.48.1
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
+9 -7
View File
@@ -4114,7 +4114,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.48.0-dev"
version = "1.49.0-dev"
dependencies = [
"biquad",
"dasp_sample",
@@ -4167,7 +4167,7 @@ dependencies = [
[[package]]
name = "psysonic-analysis"
version = "1.48.0-dev"
version = "1.49.0-dev"
dependencies = [
"ebur128",
"futures-util",
@@ -4186,7 +4186,7 @@ dependencies = [
[[package]]
name = "psysonic-audio"
version = "1.48.0-dev"
version = "1.49.0-dev"
dependencies = [
"biquad",
"dasp_sample",
@@ -4216,16 +4216,18 @@ dependencies = [
[[package]]
name = "psysonic-core"
version = "1.48.0-dev"
version = "1.49.0-dev"
dependencies = [
"libc",
"reqwest",
"serde",
"tauri",
"url",
]
[[package]]
name = "psysonic-integration"
version = "1.48.0-dev"
version = "1.49.0-dev"
dependencies = [
"discord-rich-presence",
"futures-util",
@@ -4242,7 +4244,7 @@ dependencies = [
[[package]]
name = "psysonic-library"
version = "1.48.0-dev"
version = "1.49.0-dev"
dependencies = [
"psysonic-core",
"psysonic-integration",
@@ -4257,7 +4259,7 @@ dependencies = [
[[package]]
name = "psysonic-syncfs"
version = "1.48.0-dev"
version = "1.49.0-dev"
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.49.0-dev"
edition = "2021"
rust-version = "1.95"
license = "GPL-3.0-or-later"
@@ -5,6 +5,7 @@ use std::sync::{Arc, Mutex, OnceLock};
use tauri::{Emitter, Manager};
use psysonic_core::ports::PlaybackQueryHandle;
use psysonic_core::server_http::{apply_optional_registry_headers, ServerHttpRegistry};
use psysonic_core::user_agent::subsonic_wire_user_agent;
use psysonic_core::track_enrichment::TrackEnrichmentOutcome;
@@ -679,10 +680,22 @@ fn analysis_http_client() -> &'static reqwest::Client {
})
}
async fn analysis_backfill_download_bytes(url: &str) -> Result<(Vec<u8>, u64), String> {
async fn analysis_backfill_download_bytes(
app: &tauri::AppHandle,
server_id: &str,
url: &str,
) -> Result<(Vec<u8>, u64), String> {
let fetch_started = std::time::Instant::now();
let response = analysis_http_client()
.get(url)
let registry = app
.try_state::<Arc<ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
let request = apply_optional_registry_headers(
registry.as_deref(),
Some(server_id),
url,
analysis_http_client().get(url),
);
let response = request
.send()
.await
.map_err(|e| e.to_string())?;
@@ -781,7 +794,7 @@ async fn spawn_backfill_slots(app: &tauri::AppHandle, shared: &Arc<AnalysisBackf
let app = app.clone();
let shared = shared.clone();
tauri::async_runtime::spawn(async move {
let download_result = analysis_backfill_download_bytes(&url).await;
let download_result = analysis_backfill_download_bytes(&app, &server_id, &url).await;
{
let mut st = shared
.state
@@ -40,6 +40,8 @@ windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_System_Com",
"Win32_System_Threading",
"Win32_System_Power",
"Win32_UI_WindowsAndMessaging",
] }
[dev-dependencies]
@@ -11,7 +11,7 @@ use rodio::Source;
use tauri::{AppHandle, Emitter, State};
use super::decode::build_source;
use super::engine::{audio_http_client, AudioEngine};
use super::engine::AudioEngine;
use super::helpers::*;
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
use super::play_input::{select_play_input, url_format_hint, PlayInputContext};
@@ -20,7 +20,7 @@ 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::playback_rate::{preserve_pitch_will_run, raw_counter_samples_for_content_position};
use super::preview::preview_clear_for_new_main_playback;
use super::progress_task::spawn_progress_task;
use super::state::{ChainedInfo, PreloadedTrack};
@@ -57,10 +57,29 @@ pub async fn audio_play(
// 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>,
// Silence-aware crossfade (B-head): begin playback past the next track's
// leading silence. Optional + defaults to `0` so existing callers are
// unaffected; only applied when the freshly built source is seekable.
start_secs: Option<f64>,
// Dynamic crossfade (phase 2): per-transition overlap length, computed by the
// frontend from both tracks' waveform envelopes. Caps the fade for *this*
// transition instead of the global `crossfade_secs`. `None` → use the global
// setting (today's behaviour); always still clamped to the measured remaining.
crossfade_secs_override: Option<f32>,
// Scenario A (dynamic crossfade): engine fade-out length for the *outgoing*
// track A, decoupled from B's fade-in. `Some(0)` → don't fade A at all (it
// already fades out in the recording, so let it ride at full engine gain
// while B rises underneath); `Some(x)` → fade A over x s; `None` → mirror
// B's fade (today's behaviour). Always clamped to A's measured remaining.
outgoing_fade_secs_override: Option<f32>,
// AutoDJ smooth skip: short outgoing fade when the user hits next/previous
// while a track is playing. Optional; only honoured when `manual` is true.
manual_autodj_blend: Option<bool>,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
let start_paused = start_paused.unwrap_or(false);
let start_secs = start_secs.unwrap_or(0.0).max(0.0);
let gapless = state.gapless_enabled.load(Ordering::Relaxed);
// ── Ghost-command guard ───────────────────────────────────────────────────
@@ -220,9 +239,15 @@ pub async fn audio_play(
},
);
// Manual skips (user-initiated) bypass crossfade — the track should start immediately.
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed) && !manual;
let crossfade_secs_val = f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)).clamp(0.5, 12.0);
// Manual skips bypass crossfade unless AutoDJ smooth skip requests a full blend.
let manual_blend = manual && manual_autodj_blend.unwrap_or(false);
let crossfade_enabled =
state.crossfade_enabled.load(Ordering::Relaxed) && (!manual || manual_blend);
// Per-transition override (dynamic crossfade) caps the fade for this swap;
// otherwise fall back to the global crossfade length. Both clamped the same.
let crossfade_secs_val = crossfade_secs_override
.unwrap_or_else(|| f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)))
.clamp(0.5, 12.0);
// Measure how much audio Track A actually has left right now.
// By the time audio_play is called, near_end_ticks (2×500ms) + IPC latency
@@ -247,6 +272,19 @@ pub async fn audio_play(
Duration::from_millis(5)
};
// Outgoing (Track A) fade-out, decoupled from B's fade-in. Defaults to
// `actual_fade_secs` (symmetric crossfade, today's behaviour); a `Some(0)`
// override means A already fades out in the recording, so we leave it at
// full engine gain (scenario A). Never longer than A's remaining audio.
let outgoing_fade_secs: f32 = if crossfade_enabled {
match outgoing_fade_secs_override {
Some(v) => v.max(0.0).min(actual_fade_secs),
None => actual_fade_secs,
}
} else {
0.0
};
// Build source: decode → trim → resample → EQ → fade-in → fade-out → notify → count.
let done_flag = Arc::new(AtomicBool::new(false));
// Reset sample counter for the new track.
@@ -286,8 +324,9 @@ pub async fn audio_play(
e
})?;
state.current_is_seekable.store(playback_source.is_seekable, Ordering::SeqCst);
let source_seekable = playback_source.is_seekable;
let built = playback_source.built;
let source = built.source;
let mut source = built.source;
let duration_secs = built.duration_secs;
let output_rate = built.output_rate;
let output_channels = built.output_channels;
@@ -397,6 +436,17 @@ pub async fn audio_play(
}
}
// Silence-aware crossfade (B-head): skip the next track's leading silence by
// seeking the freshly built source before it is appended. The outermost
// `CountingSource` stores the sample counter on a successful seek; we still
// re-seed `samples_played` + `seek_offset` explicitly after the swap (below)
// so the seekbar and the crossfade-remaining math are content-relative.
let did_start_seek = if start_secs > 0.05 && source_seekable {
source.try_seek(Duration::from_secs_f64(start_secs)).is_ok()
} else {
false
};
sink.append(source);
if needs_prefill {
@@ -423,9 +473,29 @@ pub async fn audio_play(
fadeout_samples: built.fadeout_samples,
crossfade_enabled,
actual_fade_secs,
outgoing_fade_secs,
start_paused,
});
// B-head: `swap_in_new_sink` resets `seek_offset` to 0 and starts the play
// clock — re-anchor both the wall-clock baseline (`seek_offset`) and the
// sample counter to the content offset so position reporting is correct.
if did_start_seek {
{
let mut cur = state.current.lock().unwrap();
cur.seek_offset = start_secs;
}
state.samples_played.store(
raw_counter_samples_for_content_position(
start_secs,
output_rate,
output_channels as u32,
&state.playback_rate,
),
Ordering::Relaxed,
);
}
if defer_playback_start {
if !start_paused {
let mut cur = state.current.lock().unwrap();
@@ -455,6 +525,7 @@ pub async fn audio_play(
state.chained_info.clone(),
state.crossfade_enabled.clone(),
state.crossfade_secs.clone(),
state.autodj_suppress_autocrossfade.clone(),
done_flag,
app,
Some(analysis_app),
@@ -526,7 +597,14 @@ pub async fn audio_chain_preload(
} else if let Some(path) = url.strip_prefix("psysonic-local://") {
tokio::fs::read(path).await.map_err(|e| e.to_string())?
} else {
let resp = audio_http_client(&state).get(&url).send().await
let resp = crate::engine::playback_scoped_get(
&state,
&app,
&url,
server_id.as_deref(),
)
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Ok(()); // silently fail — audio_play will retry
+57 -19
View File
@@ -169,8 +169,14 @@ impl SizedDecoder {
// Symphonia 0.6 scans trailing metadata on seekable sources — hide
// seekability during probe (same as `new_streaming`) so preview does not
// read the entire in-memory file before the first sample.
let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint))
.then(|| Arc::new(AtomicBool::new(false)));
//
// Exception: Ogg (Vorbis/Opus/…) must stay seekable through the probe,
// otherwise its demuxer never records `phys_byte_range_end` and the first
// seek panics (see `container_hint_is_ogg`). This source is fully
// in-memory, so the trailing-metadata scan it re-enables is free.
let gate_needed = !crate::stream::container_hint_is_mp4(format_hint)
&& !crate::stream::container_hint_is_ogg(format_hint);
let probe_seek_gate = gate_needed.then(|| Arc::new(AtomicBool::new(false)));
let media: Box<dyn MediaSource> = match &probe_seek_gate {
Some(gate) => Box::new(ProbeSeekGate {
inner: Box::new(source),
@@ -315,19 +321,33 @@ impl SizedDecoder {
/// Build a decoder from any `MediaSource` (e.g. track-stream or radio).
/// Uses `enable_gapless: false` — live streams are not seekable; gapless
/// trimming requires seeking to read the LAME/iTunSMPB end-padding info.
/// `source_random_access`: the underlying source can cheaply seek to EOF
/// (e.g. a local file), so the probe-time trailing-metadata / stream-end scan
/// is not a full download. Progressive sources (ranged HTTP) pass `false`.
pub(crate) fn new_streaming(
media: Box<dyn MediaSource>,
format_hint: Option<&str>,
source_tag: &str,
source_random_access: bool,
) -> Result<Self, String> {
// For non-MP4 progressive streams, hide seekability during the probe so
// Symphonia 0.6 skips its trailing-metadata scan (which would seek to EOF
// and block until the whole file is downloaded). Re-enabled right after.
// MP4 keeps seekability (its demuxer needs it to find `moov`; tail is
// prefetched separately).
//
// Ogg also keeps seekability through the probe, but only on random-access
// sources: its demuxer records `phys_byte_range_end` during the probe and
// panics on the first seek otherwise (see `container_hint_is_ogg`). On a
// local file the stream-end scan is cheap; on a progressive ranged stream
// it would force a full download, so there we keep the gate and accept
// that seeking is a no-op (the panic itself is contained in `try_seek`).
let stream_len = media.byte_len();
let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint))
.then(|| Arc::new(AtomicBool::new(false)));
let ogg_needs_seekable_probe =
source_random_access && crate::stream::container_hint_is_ogg(format_hint);
let gate_needed = !crate::stream::container_hint_is_mp4(format_hint)
&& !ogg_needs_seekable_probe;
let probe_seek_gate = gate_needed.then(|| Arc::new(AtomicBool::new(false)));
let media: Box<dyn MediaSource> = match &probe_seek_gate {
Some(gate) => Box::new(ProbeSeekGate { inner: media, seekable: gate.clone() }),
None => media,
@@ -588,20 +608,36 @@ impl Source for SizedDecoder {
let to_skip = self.current_frame_offset % self.channels().get() as usize;
let seek_res = self
.format
.seek(SeekMode::Accurate, SeekTo::Time { time, track_id: None })
.map_err(|e| rodio::source::SeekError::Other(
std::sync::Arc::new(std::io::Error::other(e.to_string()))
))?;
// symphonia 0.6's OGG demuxer can `panic!` (e.g. `Option::unwrap()` on
// `None` in `OggReader::do_seek`) on some streams instead of returning
// an `Err`. `try_seek` runs on rodio's cpal output thread, so an escaping
// panic poisons the engine mutexes and then aborts the whole process at
// the non-unwinding cpal FFI boundary (the "crash on Stop" is a downstream
// symptom of that poison). Contain the unwind here — including the packet
// reads in `refine_position`, which can hit the same broken demuxer state —
// and surface it as a recoverable `SeekError` so the engine stays alive
// (the seek becomes a no-op rather than killing playback).
let seek_outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let seek_res = self
.format
.seek(SeekMode::Accurate, SeekTo::Time { time, track_id: None })
.map_err(|e| e.to_string())?;
self.refine_position(seek_res)?;
Ok::<(), String>(())
}));
self.refine_position(seek_res)
.map_err(|e| rodio::source::SeekError::Other(
std::sync::Arc::new(std::io::Error::other(e))
))?;
self.current_frame_offset += to_skip;
Ok(())
match seek_outcome {
Ok(Ok(())) => {
self.current_frame_offset += to_skip;
Ok(())
}
Ok(Err(e)) => Err(rodio::source::SeekError::Other(std::sync::Arc::new(
std::io::Error::other(e),
))),
Err(_panic) => Err(rodio::source::SeekError::Other(std::sync::Arc::new(
std::io::Error::other("seek panicked inside the demuxer (contained)"),
))),
}
}
}
@@ -1019,8 +1055,9 @@ mod tests {
#[test]
fn new_streaming_constructs_from_synthetic_wav() {
let wav = synthetic_wav_bytes(0.5);
let decoder = SizedDecoder::new_streaming(seekable_source(wav), Some("wav"), "test-stream")
.expect("streaming WAV decode setup");
let decoder =
SizedDecoder::new_streaming(seekable_source(wav), Some("wav"), "test-stream", true)
.expect("streaming WAV decode setup");
assert_eq!(decoder.spec.rate(), 44_100);
assert_eq!(decoder.spec.channels().count(), 1);
// Live streams report no total duration.
@@ -1033,6 +1070,7 @@ mod tests {
seekable_source(vec![0x00u8; 64]),
None,
"test-stream",
true,
);
assert!(result.is_err());
}
@@ -87,6 +87,7 @@ pub(crate) async fn try_resume_after_device_change(
reader: Box::new(LocalFileSource { file, len }),
format_hint: url_format_hint(url),
tag: "LocalFile[device-resume]",
random_access: true,
mp4_probe_gate: None,
}
}
@@ -211,6 +212,7 @@ pub(crate) async fn try_resume_after_device_change(
fadeout_samples: ps.built.fadeout_samples,
crossfade_enabled: false,
actual_fade_secs: 0.0,
outgoing_fade_secs: 0.0,
start_paused: false,
},
);
@@ -260,6 +262,7 @@ pub(crate) async fn try_resume_after_device_change(
engine.chained_info.clone(),
engine.crossfade_enabled.clone(),
engine.crossfade_secs.clone(),
engine.autodj_suppress_autocrossfade.clone(),
done_flag,
app.clone(),
Some(analysis_app),
@@ -82,6 +82,15 @@ pub(crate) async fn reopen_output_stream(
if !opened {
return false;
}
// When we're not actively playing (paused/stopped), bump the generation
// before stopping the old sink so the still-running progress task sees the
// mismatch and bails out instead of emitting a spurious `audio:ended` —
// which would otherwise trigger a frontend restart of paused playback
// (#1094). The active-playback path bumps inside
// `try_resume_after_device_change`, so only guard the non-playing case here.
if !snapshot.is_playing {
engine.generation.fetch_add(1, Ordering::SeqCst);
}
if let Some(s) = current.lock().unwrap().sink.take() {
s.stop();
}
@@ -4,6 +4,7 @@ use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant};
use rodio::Player;
use tauri::Manager;
use super::state::{ChainedInfo, PreloadedTrack, StreamCompletedSpill};
@@ -60,6 +61,15 @@ pub struct AudioEngine {
pub(crate) stream_playback_armed: Arc<AtomicBool>,
pub crossfade_enabled: Arc<AtomicBool>,
pub crossfade_secs: Arc<AtomicU32>,
/// AutoDJ: when true, the progress task does NOT fire its autonomous
/// `crossfade_secs`-before-end `audio:ended` timer — the JS A-tail logic
/// drives every advance (gated on the next track being playable). Prevents
/// the engine from starting a still-buffering next track and fading over it
/// (an audible "jump"); cold next-track degrades to a clean sequential start.
pub(crate) autodj_suppress_autocrossfade: Arc<AtomicBool>,
/// AutoDJ interrupt prep: `audio_begin_outgoing_fade` volume-ducked the
/// outgoing sink; block normalization/volume ramps until the handoff swap.
pub(crate) interrupt_outgoing_duck_active: Arc<AtomicBool>,
pub fading_out_sink: Arc<Mutex<Option<Arc<Player>>>>,
/// When true, audio_play chains sources to the existing Sink instead of
/// creating a new one, achieving sample-accurate gapless transitions.
@@ -475,6 +485,8 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
stream_playback_armed: Arc::new(AtomicBool::new(true)),
crossfade_enabled: Arc::new(AtomicBool::new(false)),
crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())),
autodj_suppress_autocrossfade: Arc::new(AtomicBool::new(false)),
interrupt_outgoing_duck_active: Arc::new(AtomicBool::new(false)),
fading_out_sink: Arc::new(Mutex::new(None)),
gapless_enabled: Arc::new(AtomicBool::new(false)),
normalization_engine: Arc::new(AtomicU32::new(0)),
@@ -559,3 +571,83 @@ pub fn refresh_http_user_agent(state: &AudioEngine, ua: &str) {
*slot = client;
}
}
pub(crate) fn apply_playback_request_headers(
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_id: Option<&str>,
url: &str,
req: reqwest::RequestBuilder,
) -> reqwest::RequestBuilder {
if let Some(reg) = registry {
if let Some(sid) = server_id.filter(|s| !s.is_empty()) {
return reg.apply_for_http_url(sid, url, req);
}
if let Some(ctx) = reg.get_for_server_url(url) {
return psysonic_core::server_http::apply_server_headers_for_http_url(req, &ctx, url);
}
}
req
}
/// Custom HTTP headers for reverse-proxy gates — cloned into background download tasks.
#[derive(Clone, Default)]
pub(crate) struct PlaybackHttpHeaders {
registry: Option<Arc<psysonic_core::server_http::ServerHttpRegistry>>,
server_id: Option<String>,
}
impl PlaybackHttpHeaders {
pub fn from_app(app: &tauri::AppHandle, server_id: Option<&str>) -> Self {
Self {
registry: app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s)),
server_id: server_id.filter(|s| !s.is_empty()).map(str::to_string),
}
}
pub fn apply(&self, url: &str, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
apply_playback_request_headers(
self.registry.as_deref(),
self.server_id.as_deref(),
url,
req,
)
}
}
pub(crate) fn scoped_http_get(
state: &AudioEngine,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_id: Option<&str>,
url: &str,
) -> reqwest::RequestBuilder {
apply_playback_request_headers(
registry,
server_id,
url,
audio_http_client(state).get(url),
)
}
/// Resolve registry + server id for playback/preload HTTP GETs.
pub(crate) fn playback_scoped_get(
state: &AudioEngine,
app: &tauri::AppHandle,
url: &str,
server_id: Option<&str>,
) -> reqwest::RequestBuilder {
let registry = app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
let sid = server_id
.filter(|s| !s.is_empty())
.map(str::to_string)
.or_else(|| state.current_playback_server_id.lock().unwrap().clone());
scoped_http_get(
state,
registry.as_deref(),
sid.as_deref(),
url,
)
}
+55 -12
View File
@@ -708,7 +708,10 @@ pub(crate) async fn fetch_data(
return Ok(Some(data));
}
let response = crate::engine::audio_http_client(state).get(url).send().await.map_err(|e| e.to_string())?;
let response = crate::engine::playback_scoped_get(state, app, url, None)
.send()
.await
.map_err(|e| e.to_string())?;
let status = response.status();
let ct = response.headers()
.get(reqwest::header::CONTENT_TYPE)
@@ -805,6 +808,19 @@ pub(crate) fn loudness_ui_current_gain_db(gain_linear: f32) -> Option<f32> {
gain_linear_to_db(gain_linear)
}
static SINK_VOLUME_RAMP_GEN: AtomicU64 = AtomicU64::new(0);
/// Cancel any in-flight sink-volume ramp (new ramp wins).
pub(crate) fn cancel_sink_volume_ramp() {
SINK_VOLUME_RAMP_GEN.fetch_add(1, Ordering::SeqCst);
}
/// Audible sink multiplier — may differ from `base_volume * replay_gain` after
/// interrupt prep or a mid-ramp correction.
pub(crate) fn sink_volume_now(sink: &Player) -> f32 {
sink.volume().clamp(0.0, 1.0)
}
pub(crate) fn ramp_sink_volume(sink: Arc<Player>, from: f32, to: f32) {
let from = from.clamp(0.0, 1.0);
let to = to.clamp(0.0, 1.0);
@@ -812,8 +828,7 @@ pub(crate) fn ramp_sink_volume(sink: Arc<Player>, from: f32, to: f32) {
sink.set_volume(to);
return;
}
static RAMP_GEN: AtomicU64 = AtomicU64::new(0);
let my_gen = RAMP_GEN.fetch_add(1, Ordering::SeqCst) + 1;
let my_gen = SINK_VOLUME_RAMP_GEN.fetch_add(1, Ordering::SeqCst) + 1;
std::thread::spawn(move || {
let delta = (to - from).abs();
// Stretch large corrections to avoid audible "step down" moments.
@@ -826,18 +841,46 @@ pub(crate) fn ramp_sink_volume(sink: Arc<Player>, from: f32, to: f32) {
} else {
(8, 16)
};
for i in 1..=steps {
if RAMP_GEN.load(Ordering::SeqCst) != my_gen {
return;
}
let t = i as f32 / steps as f32;
let v = from + (to - from) * t;
sink.set_volume(v.clamp(0.0, 1.0));
std::thread::sleep(Duration::from_millis(step_ms));
}
ramp_sink_volume_steps(sink, from, to, steps, step_ms, my_gen);
});
}
/// Linear sink-volume ramp over an explicit wall-clock duration (interrupt prep).
pub(crate) fn ramp_sink_volume_over_secs(sink: Arc<Player>, from: f32, to: f32, secs: f32) {
let from = from.clamp(0.0, 1.0);
let to = to.clamp(0.0, 1.0);
if (to - from).abs() < 0.002 {
sink.set_volume(to);
return;
}
let my_gen = SINK_VOLUME_RAMP_GEN.fetch_add(1, Ordering::SeqCst) + 1;
let secs = secs.clamp(0.1, 12.0);
let step_ms: u64 = 20;
let steps = ((secs * 1000.0) / step_ms as f32).round().max(1.0) as usize;
std::thread::spawn(move || {
ramp_sink_volume_steps(sink, from, to, steps, step_ms, my_gen);
});
}
fn ramp_sink_volume_steps(
sink: Arc<Player>,
from: f32,
to: f32,
steps: usize,
step_ms: u64,
my_gen: u64,
) {
for i in 1..=steps {
if SINK_VOLUME_RAMP_GEN.load(Ordering::SeqCst) != my_gen {
return;
}
let t = i as f32 / steps as f32;
let v = from + (to - from) * t;
sink.set_volume(v.clamp(0.0, 1.0));
std::thread::sleep(Duration::from_millis(step_ms));
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -13,9 +13,9 @@ use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
#[tauri::command]
pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
let mut cur = state.current.lock().unwrap();
let prev_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
cur.base_volume = volume.clamp(0.0, 1.0);
if let Some(sink) = &cur.sink {
let prev_effective = sink_volume_now(sink);
let next_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
ramp_sink_volume(Arc::clone(sink), prev_effective, next_effective);
}
@@ -105,11 +105,19 @@ pub fn audio_update_replay_gain(
volume,
effective
);
if state
.interrupt_outgoing_duck_active
.load(Ordering::Relaxed)
{
// Interrupt prep ducked the outgoing sink; syncing B's loudness here would
// ramp A back to full gain before the handoff swap.
return;
}
let mut cur = state.current.lock().unwrap();
let prev_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
cur.replay_gain_linear = gain_linear;
cur.base_volume = volume.clamp(0.0, 1.0);
if let Some(sink) = &cur.sink {
let prev_effective = sink_volume_now(sink);
ramp_sink_volume(Arc::clone(sink), prev_effective, effective);
}
drop(cur);
@@ -143,6 +151,34 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
state.gapless_enabled.store(enabled, Ordering::Relaxed);
}
/// Duck the current sink over `fade_secs` without exhausting its source (which
/// would spuriously emit `audio:ended` before the interrupt handoff).
#[tauri::command]
pub fn audio_begin_outgoing_fade(fade_secs: f32, state: State<'_, AudioEngine>) {
let fade_secs = fade_secs.clamp(0.1, 12.0);
let cur = state.current.lock().unwrap();
let Some(sink) = cur.sink.as_ref() else {
return;
};
state
.interrupt_outgoing_duck_active
.store(true, Ordering::Relaxed);
cancel_sink_volume_ramp();
let from = sink_volume_now(sink);
ramp_sink_volume_over_secs(Arc::clone(sink), from, 0.0, fade_secs);
}
/// AutoDJ: when `true`, the progress task stops firing its autonomous
/// crossfade `audio:ended` timer so the JS A-tail logic drives every advance
/// (only when the next track is actually playable). When `false`, the engine's
/// normal early crossfade trigger is restored (plain crossfade / loud→loud).
#[tauri::command]
pub fn audio_set_autodj_suppress(enabled: bool, state: State<'_, AudioEngine>) {
state
.autodj_suppress_autocrossfade
.store(enabled, Ordering::Relaxed);
}
#[tauri::command]
pub fn audio_set_playback_rate(
enabled: bool,
@@ -152,14 +188,13 @@ pub fn audio_set_playback_rate(
state: State<'_, AudioEngine>,
) {
use crate::playback_rate::{
content_position_from_samples, is_effect_active, raw_counter_samples_for_content_position,
uses_preserve_dsp, STRATEGY_PRESERVE_PITCH, STRATEGY_SPEED_CORRECTED,
STRATEGY_VARISPEED,
content_position_from_samples, is_effect_active, rate_change_needs_restamp,
raw_counter_samples_for_content_position, STRATEGY_PRESERVE_PITCH,
STRATEGY_SPEED_CORRECTED, STRATEGY_VARISPEED,
};
let clamped_speed = speed.clamp(0.5, 2.0);
let clamped_pitch = pitch_semitones.clamp(-12.0, 12.0);
let old_enabled = state.playback_rate.enabled.load(Ordering::Relaxed);
let old_strat = state.playback_rate.load_strategy();
let old_speed = state.playback_rate.load_speed();
let was_active = is_effect_active(&state.playback_rate);
@@ -170,24 +205,37 @@ pub fn audio_set_playback_rate(
};
let speed_changed = (clamped_speed - old_speed).abs() > 0.001;
let restamp_content = if was_active
&& enabled == old_enabled
&& uses_preserve_dsp(old_strat)
&& new_strat == old_strat
&& speed_changed
// Will the *new* config leave the rate effect active?
let new_active = enabled
&& match new_strat {
STRATEGY_PRESERVE_PITCH => {
(clamped_speed - 1.0).abs() > 0.001 || clamped_pitch.abs() > 0.001
}
_ => (clamped_speed - 1.0).abs() > 0.001,
};
// Preserve the content (song) position across any change to the
// sample-counter ↔ position mapping: an active↔neutral toggle (enable /
// disable, or speed crossing 1.0×) OR a speed change while active. Scoped to
// the preserve-pitch DSP family and same strategy — varispeed has no
// content/raw factor, and a strategy switch is out of scope here.
//
// The old condition only restamped active→active, so every enable/disable
// toggle reinterpreted `samples_played` under the new factor and jumped the
// position (≈ raw_secs × Δspeed — e.g. ±18 s at the 180 s mark on a ±10%
// toggle; this is what broke Orbit drift correction).
let sample_rate = state.current_sample_rate.load(Ordering::Relaxed);
let channels = state.current_channels.load(Ordering::Relaxed);
let restamp_content = if sample_rate > 0
&& channels > 0
&& rate_change_needs_restamp(old_strat, new_strat, was_active, new_active, speed_changed)
{
let sample_rate = state.current_sample_rate.load(Ordering::Relaxed);
let channels = state.current_channels.load(Ordering::Relaxed);
if sample_rate > 0 && channels > 0 {
Some(content_position_from_samples(
state.samples_played.load(Ordering::Relaxed),
sample_rate,
channels,
&state.playback_rate,
))
} else {
None
}
Some(content_position_from_samples(
state.samples_played.load(Ordering::Relaxed),
sample_rate,
channels,
&state.playback_rate,
))
} else {
None
};
@@ -210,19 +258,19 @@ pub fn audio_set_playback_rate(
.store(clamped_pitch.to_bits(), Ordering::Relaxed);
if let Some(content_secs) = restamp_content {
if is_effect_active(&state.playback_rate) {
let sample_rate = state.current_sample_rate.load(Ordering::Relaxed);
let channels = state.current_channels.load(Ordering::Relaxed);
state.samples_played.store(
raw_counter_samples_for_content_position(
content_secs,
sample_rate,
channels,
&state.playback_rate,
),
Ordering::Relaxed,
);
}
// Always re-derive the counter for the NEW config — including the
// neutral case (raw_counter_… maps content == raw there), which is
// exactly the active↔neutral transition the old is_effect_active gate
// skipped.
state.samples_played.store(
raw_counter_samples_for_content_position(
content_secs,
sample_rate,
channels,
&state.playback_rate,
),
Ordering::Relaxed,
);
}
}
@@ -15,7 +15,7 @@ use super::analysis_dispatch::{
prepare_playback_analysis, spawn_track_analysis_bytes, spawn_track_analysis_file,
TrackAnalysisOrigin,
};
use super::engine::{audio_http_client, AudioEngine};
use super::engine::{audio_http_client, AudioEngine, PlaybackHttpHeaders};
use super::helpers::{
content_type_to_hint, fetch_data, format_hint_from_content_disposition,
normalize_stream_suffix_for_hint, sniff_stream_format_extension,
@@ -40,6 +40,9 @@ pub(crate) enum PlayInput {
reader: Box<dyn MediaSource>,
format_hint: Option<String>,
tag: &'static str,
/// Source can cheaply seek to EOF (local file). Drives whether Ogg keeps
/// seekability through the probe so its seek path does not panic.
random_access: bool,
/// When set, Symphonia probe waits for moov (tail or fast-start prefix).
mp4_probe_gate: Option<super::stream::RangedMp4ProbeGate>,
},
@@ -201,6 +204,7 @@ fn open_local_file_input(
reader: Box::new(reader),
format_hint: local_hint,
tag: "local-file",
random_access: true,
mp4_probe_gate: None,
})
}
@@ -213,7 +217,12 @@ async fn open_ranged_or_streaming_input(
state: &State<'_, AudioEngine>,
app: &AppHandle,
) -> Result<Option<PlayInput>, String> {
let response = audio_http_client(state).get(ctx.url).send().await.map_err(|e| e.to_string())?;
let http_headers = PlaybackHttpHeaders::from_app(app, ctx.server_id);
let response = http_headers
.apply(ctx.url, audio_http_client(state).get(ctx.url))
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
if state.generation.load(Ordering::SeqCst) != ctx.gen {
return Ok(None); // superseded
@@ -252,8 +261,8 @@ async fn open_ranged_or_streaming_input(
let last = total_u64
.saturating_sub(1)
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
if let Ok(pr) = audio_http_client(state)
.get(ctx.url)
if let Ok(pr) = http_headers
.apply(ctx.url, audio_http_client(state).get(ctx.url))
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
.send()
.await
@@ -324,12 +333,28 @@ async fn open_ranged_or_streaming_input(
state.loudness_pre_analysis_attenuation_db.clone(),
ctx.cache_id_for_tasks.map(|s| s.to_string()),
ctx.server_id.map(|s| s.to_string()),
http_headers.clone(),
loudness_hold_for_defer,
playback_armed,
stream_hint.clone(),
tail_ready.clone(),
tail_filled_from.clone(),
));
// On-demand random-access fetcher: lets seeks (Ogg bisection, end-of-
// stream probe, forward scrubs) pull arbitrary byte ranges over HTTP
// Range instead of blocking until the linear filler reaches the target.
// This is what makes seeking work on a still-downloading Opus/Ogg stream
// (previously a contained no-op) without forcing a full pre-download.
let on_demand = Some(Arc::new(super::stream::OnDemand::new(
audio_http_client(state),
tokio::runtime::Handle::current(),
ctx.url.to_string(),
buf.clone(),
total,
state.generation.clone(),
ctx.gen,
http_headers.clone(),
)));
let reader = RangedHttpSource {
buf,
downloaded_to,
@@ -340,11 +365,16 @@ async fn open_ranged_or_streaming_input(
done,
gen_arc: state.generation.clone(),
gen: ctx.gen,
on_demand,
};
return Ok(Some(PlayInput::SeekableMedia {
reader: Box::new(reader),
format_hint: stream_hint,
tag: "ranged-stream",
// The on-demand fetcher makes a seek-to-EOF during the probe cheap,
// so Ogg can stay seekable through the probe (records its byte range
// → real seeking) without forcing a full download.
random_access: true,
mp4_probe_gate,
}));
}
@@ -378,6 +408,7 @@ async fn open_ranged_or_streaming_input(
state.loudness_pre_analysis_attenuation_db.clone(),
ctx.cache_id_for_tasks.map(|s| s.to_string()),
ctx.server_id.map(|s| s.to_string()),
http_headers,
playback_armed,
));
@@ -82,6 +82,27 @@ pub fn is_effect_active(atomics: &PlaybackRateAtomics) -> bool {
}
}
/// Whether a playback-rate config change must restamp the sample counter to keep
/// the content (song) position stable.
///
/// The counter ↔ position factor is `speed` while the preserve-pitch effect is
/// active and `1.0` while neutral (see [`effective_position_secs`]). So any
/// transition that flips active↔neutral, or changes speed while staying active,
/// changes that factor and needs a restamp. Scoped to the preserve-pitch DSP
/// family with an unchanged strategy: varispeed has no content/raw factor, and a
/// strategy switch is handled elsewhere.
pub(crate) fn rate_change_needs_restamp(
old_strategy: u32,
new_strategy: u32,
was_active: bool,
now_active: bool,
speed_changed: bool,
) -> bool {
uses_preserve_dsp(old_strategy)
&& new_strategy == old_strategy
&& (was_active != now_active || (was_active && now_active && speed_changed))
}
/// True when preserve-pitch DSP (background worker) should run for this track.
pub(crate) fn preserve_pitch_will_run(atomics: &PlaybackRateAtomics) -> bool {
atomics.enabled.load(Ordering::Relaxed)
@@ -659,4 +680,55 @@ mod tests {
let after = content_position_from_samples(restamped, 44_100, 2, &atomics);
assert!((after - 30.0).abs() < 0.05);
}
#[test]
fn rate_change_needs_restamp_covers_active_neutral_toggles() {
let sc = STRATEGY_SPEED_CORRECTED;
// Both directions of an active↔neutral toggle need a restamp.
assert!(rate_change_needs_restamp(sc, sc, false, true, true));
assert!(rate_change_needs_restamp(sc, sc, true, false, true));
// Active→active with a speed change needs one too.
assert!(rate_change_needs_restamp(sc, sc, true, true, true));
// Active→active with no speed change, and neutral→neutral, do not.
assert!(!rate_change_needs_restamp(sc, sc, true, true, false));
assert!(!rate_change_needs_restamp(sc, sc, false, false, false));
}
#[test]
fn rate_change_needs_restamp_skips_varispeed_and_strategy_switch() {
let sc = STRATEGY_SPEED_CORRECTED;
let vs = STRATEGY_VARISPEED;
// Varispeed has no content/raw factor → never restamp.
assert!(!rate_change_needs_restamp(vs, vs, false, true, true));
// A strategy switch is out of scope for the restamp path.
assert!(!rate_change_needs_restamp(sc, vs, true, true, true));
}
#[test]
fn restamp_keeps_position_across_active_neutral_toggle() {
// The bug: toggling the effect on/off must not move the song position.
// Start active at 1.10×, sitting at 180 s of content.
let a = PlaybackRateAtomics::new();
a.enabled.store(true, Ordering::Relaxed);
a.strategy.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
a.speed.store(1.10f32.to_bits(), Ordering::Relaxed);
let samples = raw_counter_samples_for_content_position(180.0, 44_100, 2, &a);
assert!((content_position_from_samples(samples, 44_100, 2, &a) - 180.0).abs() < 0.05);
// Toggle to neutral (disabled). Without a restamp the position would
// jump ~18 s (180 × 0.10); with it, the position is preserved.
let old_content = content_position_from_samples(samples, 44_100, 2, &a);
a.enabled.store(false, Ordering::Relaxed);
let restamped = raw_counter_samples_for_content_position(old_content, 44_100, 2, &a);
let after = content_position_from_samples(restamped, 44_100, 2, &a);
assert!((after - 180.0).abs() < 0.05, "position jumped to {after}");
// Back to active at 0.90× — still stable.
let old_content2 = content_position_from_samples(restamped, 44_100, 2, &a);
a.enabled.store(true, Ordering::Relaxed);
a.speed.store(0.90f32.to_bits(), Ordering::Relaxed);
let restamped2 = raw_counter_samples_for_content_position(old_content2, 44_100, 2, &a);
let after2 = content_position_from_samples(restamped2, 44_100, 2, &a);
assert!((after2 - 180.0).abs() < 0.05, "position jumped to {after2}");
}
}
@@ -16,7 +16,7 @@ use super::analysis_dispatch::{
dispatch_track_analysis_bytes, prepare_playback_analysis, spawn_track_analysis_file,
TrackAnalysisOrigin,
};
use super::engine::{audio_http_client, AudioEngine};
use super::engine::AudioEngine;
use super::helpers::{analysis_cache_track_id, same_playback_target};
use super::state::PreloadedTrack;
@@ -119,6 +119,7 @@ pub async fn audio_preload(
duration_hint: f64,
analysis_track_id: Option<String>,
server_id: Option<String>,
eager: Option<bool>,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
@@ -183,15 +184,28 @@ pub async fn audio_preload(
// Throttle: wait 8 s before starting the background download so it does not
// compete with the decode + sink-feed work of the just-started current track.
// If the user skips during the wait the generation counter changes and we abort.
// Eager callers (crossfade/AutoDJ pre-buffer, fired ~30 s before the fade
// when the current track is long-settled) skip the wait so the RAM slot
// fills in time for the fade to fire. If the user skips during the wait the
// generation counter changes and we abort.
let gen_snapshot = state.generation.load(Ordering::Relaxed);
tokio::time::sleep(Duration::from_secs(8)).await;
if state.generation.load(Ordering::Relaxed) != gen_snapshot {
emit_preload_cancelled(&app, url, track_id_for_events);
return Ok(());
if !eager.unwrap_or(false) {
tokio::time::sleep(Duration::from_secs(8)).await;
if state.generation.load(Ordering::Relaxed) != gen_snapshot {
emit_preload_cancelled(&app, url, track_id_for_events);
return Ok(());
}
}
let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?;
let response = crate::engine::playback_scoped_get(
&state,
&app,
&url,
server_id.as_deref(),
)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
emit_preload_cancelled(&app, url, track_id_for_events);
return Ok(());
+11 -6
View File
@@ -8,7 +8,7 @@ use rodio::Source;
use tauri::{AppHandle, Emitter, State};
use super::decode::SizedDecoder;
use super::engine::{audio_http_client, AudioEngine};
use super::engine::{audio_http_client, AudioEngine, PlaybackHttpHeaders};
use super::helpers::{
content_type_to_hint, format_hint_from_content_disposition, normalize_stream_suffix_for_hint,
resolve_playback_format_hint, sniff_stream_format_extension, STREAM_FORMAT_SNIFF_PROBE_BYTES,
@@ -155,9 +155,10 @@ async fn open_preview_decoder(
state: &AudioEngine,
app: &AppHandle,
) -> Result<Option<SizedDecoder>, String> {
let http_headers = PlaybackHttpHeaders::from_app(app, None);
let preview_http = preview_http_client(state);
let response = preview_http
.get(url)
let response = http_headers
.apply(url, preview_http.get(url))
.send()
.await
.map_err(|e| format!("preview: connection failed: {e}"))?
@@ -194,8 +195,8 @@ async fn open_preview_decoder(
let last = total_u64
.saturating_sub(1)
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
if let Ok(pr) = preview_http
.get(url)
if let Ok(pr) = http_headers
.apply(url, preview_http.get(url))
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
.send()
.await
@@ -257,6 +258,7 @@ async fn open_preview_decoder(
state.loudness_pre_analysis_attenuation_db.clone(),
None,
None,
http_headers.clone(),
None,
playback_armed,
stream_hint.clone(),
@@ -279,10 +281,13 @@ async fn open_preview_decoder(
done,
gen_arc: state.preview_gen.clone(),
gen,
// Preview plays a fixed short segment; no user seeking → no need for
// the on-demand random-access fetcher.
on_demand: None,
};
let hint = stream_hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream")
SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream", false)
})
.await
.map_err(|e| format!("preview: decoder thread: {e}"))??;
@@ -61,6 +61,7 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
chained_arc: Arc<Mutex<Option<ChainedInfo>>>,
crossfade_enabled_arc: Arc<AtomicBool>,
crossfade_secs_arc: Arc<AtomicU32>,
autodj_suppress_arc: Arc<AtomicBool>,
initial_done: Arc<AtomicBool>,
emitter: E,
analysis_app: Option<AppHandle>,
@@ -245,7 +246,12 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
continue;
}
let cf_enabled = crossfade_enabled_arc.load(Ordering::Relaxed);
// AutoDJ may suppress the autonomous crossfade trigger so JS drives
// every advance (gated on the next track being playable). Treat it
// like crossfade-off here: only emit `audio:ended` on real source
// exhaustion (above) or the watchdog — never the early timer.
let cf_enabled = crossfade_enabled_arc.load(Ordering::Relaxed)
&& !autodj_suppress_arc.load(Ordering::Relaxed);
let cf_secs = f32::from_bits(crossfade_secs_arc.load(Ordering::Relaxed)).clamp(0.5, 12.0) as f64;
let end_threshold = if cf_enabled { cf_secs.max(1.0) } else { 1.0 };
@@ -335,6 +341,7 @@ mod tests {
chained: Arc<Mutex<Option<ChainedInfo>>>,
crossfade_enabled: Arc<AtomicBool>,
crossfade_secs: Arc<AtomicU32>,
autodj_suppress: Arc<AtomicBool>,
done: Arc<AtomicBool>,
samples_played: Arc<AtomicU64>,
sample_rate: Arc<AtomicU32>,
@@ -365,6 +372,7 @@ mod tests {
chained: Arc::new(Mutex::new(None)),
crossfade_enabled: Arc::new(AtomicBool::new(false)),
crossfade_secs: Arc::new(AtomicU32::new(0f32.to_bits())),
autodj_suppress: Arc::new(AtomicBool::new(false)),
done: Arc::new(AtomicBool::new(false)),
samples_played: Arc::new(AtomicU64::new(0)),
sample_rate: Arc::new(AtomicU32::new(44_100)),
@@ -384,6 +392,7 @@ mod tests {
self.chained.clone(),
self.crossfade_enabled.clone(),
self.crossfade_secs.clone(),
self.autodj_suppress.clone(),
self.done.clone(),
emitter,
None,
@@ -639,4 +648,34 @@ mod tests {
);
assert!(h.gen_counter.load(Ordering::SeqCst) > h.gen);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn autodj_suppress_does_not_fire_crossfade_timer() {
// AutoDJ suppression on: even with crossfade enabled and the position
// inside the crossfade window, the autonomous timer must NOT emit
// audio:ended (JS drives the advance, gated on the next track being
// ready). The real end is still reached via source exhaustion.
let h = TaskHarness::new(120.0);
h.crossfade_enabled.store(true, Ordering::SeqCst);
h.crossfade_secs.store(5.0f32.to_bits(), Ordering::SeqCst);
h.autodj_suppress.store(true, Ordering::SeqCst);
// Position inside the crossfade window (>= dur - 5 s), source not done.
let played = (117.0 * 44_100.0 * 2.0) as u64;
h.samples_played.store(played, Ordering::SeqCst);
let emitter = Arc::new(MockEmitter::default());
h.spawn_with(emitter.clone());
tokio::time::sleep(Duration::from_millis(1300)).await;
assert_eq!(
emitter.ended_count(),
0,
"suppressed AutoDJ must not fire the autonomous crossfade timer"
);
// Source exhausts → audio:ended fires (clean sequential end).
h.done.store(true, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(emitter.ended_count(), 1, "audio:ended fires on exhaustion");
}
}
@@ -124,7 +124,7 @@ pub async fn audio_play_radio(
let hint_clone = fmt_hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref(), "radio")
SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref(), "radio", false)
})
.await
.map_err(|e| e.to_string())??;
@@ -184,6 +184,7 @@ pub async fn audio_play_radio(
state.chained_info.clone(),
state.crossfade_enabled.clone(),
state.crossfade_secs.clone(),
state.autodj_suppress_autocrossfade.clone(),
done_flag,
app,
None,
@@ -90,9 +90,49 @@ pub(crate) struct SinkSwapInputs {
pub(crate) fadeout_samples: Arc<AtomicU64>,
pub(crate) crossfade_enabled: bool,
pub(crate) actual_fade_secs: f32,
/// Track A fade-out length (decoupled from B's `actual_fade_secs` fade-in).
/// `0` ⇒ don't fade A — it rides its own recorded fade-out (scenario A).
pub(crate) outgoing_fade_secs: f32,
pub(crate) start_paused: bool,
}
/// Hand off the outgoing sink to a sample-level fade-out, then stop it after
/// `cleanup_secs`. No-op when `fade_secs <= 0` (immediate stop).
fn handoff_old_sink_fade_out(
state: &State<'_, AudioEngine>,
old_sink: Option<Arc<rodio::Player>>,
old_fadeout_trigger: Option<Arc<AtomicBool>>,
old_fadeout_samples: Option<Arc<AtomicU64>>,
fade_secs: f32,
cleanup_secs: f32,
) {
let Some(old) = old_sink else {
return;
};
if fade_secs <= 0.0 {
old.stop();
return;
}
let rate = state.current_sample_rate.load(Ordering::Relaxed);
let ch = state.current_channels.load(Ordering::Relaxed);
let fade_total = (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);
}
*state.fading_out_sink.lock().unwrap() = Some(old);
let fo_arc = state.fading_out_sink.clone();
let cleanup_dur = Duration::from_secs_f32(cleanup_secs.max(fade_secs + 0.1));
tokio::spawn(async move {
tokio::time::sleep(cleanup_dur).await;
if let Some(s) = fo_arc.lock().unwrap().take() {
s.stop();
}
});
}
/// 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
@@ -107,6 +147,7 @@ pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapI
fadeout_samples: new_fadeout_samples,
crossfade_enabled,
actual_fade_secs,
outgoing_fade_secs,
start_paused,
} = inputs;
@@ -134,21 +175,26 @@ pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapI
};
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);
if outgoing_fade_secs > 0.0 {
// Scenario A (`outgoing_fade_secs == 0`): A keeps full engine gain;
// still keep the old sink alive until B's fade-in window elapses.
handoff_old_sink_fade_out(
state,
old_sink,
old_fadeout_trigger,
old_fadeout_samples,
outgoing_fade_secs,
actual_fade_secs.max(outgoing_fade_secs) + 0.5,
);
} else if let Some(old) = old_sink {
// Prep already volume-ducked A; scenario-A keeps sample gain at 1.0
// so clamp the handoff sink or A blasts over B's fade-in.
if state
.interrupt_outgoing_duck_active
.load(Ordering::Relaxed)
{
old.set_volume(0.0);
}
// 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);
@@ -162,4 +208,7 @@ pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapI
} else if let Some(old) = old_sink {
old.stop();
}
state
.interrupt_outgoing_duck_active
.store(false, Ordering::Relaxed);
}
@@ -345,6 +345,7 @@ async fn build_source_from_play_input(
reader,
format_hint: media_hint,
tag,
random_access,
mp4_probe_gate,
} => {
if let Some(gate) = mp4_probe_gate.as_ref() {
@@ -354,7 +355,7 @@ async fn build_source_from_play_input(
}
}
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag)
SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag, random_access)
})
.await
.map_err(|e| e.to_string())??;
@@ -375,7 +376,12 @@ async fn build_source_from_play_input(
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")
SizedDecoder::new_streaming(
Box::new(reader),
stream_hint.as_deref(),
"track-stream",
false,
)
})
.await
.map_err(|e| e.to_string())??;
+10 -5
View File
@@ -221,13 +221,18 @@ impl<S: Source<Item = f32>> Source for EqualPowerFadeIn<S> {
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
// For mid-track seeks: skip straight to unity gain so the new position
// plays at full volume immediately — no audible fade-in glitch.
// For seeks to the very start (< 100 ms): keep the micro-fade to
// suppress any DC-offset click from the fresh decode.
if pos.as_millis() < 100 {
if self.sample_count == 0 {
// Seek before any audio has played → this is the initial start-offset
// seek (B-head: skip the incoming track's leading silence). Keep the
// fade-in (`sample_count` stays 0) so a crossfaded track still rises
// in from its trimmed start instead of popping in at full gain.
} else if pos.as_millis() < 100 {
// Mid-playback seek to the very start: keep the micro-fade to
// suppress any DC-offset click from the fresh decode.
self.sample_count = 0;
} else {
// Mid-playback seek elsewhere (user dragging the seekbar): skip
// straight to unity gain so the new position is at full volume.
self.sample_count = self.fade_samples;
}
self.inner.try_seek(pos)
@@ -21,9 +21,26 @@ pub(crate) use mp4::{
container_hint_is_mp4, isobmff_buffer_looks_complete, log_isobmff_buffer_diagnostic,
mp4_needs_tail_prefetch, mp4_suspect_zero_holes,
};
/// True when the container hint denotes an Ogg-encapsulated stream (Vorbis,
/// Opus, Speex, FLAC-in-Ogg).
///
/// symphonia 0.6's Ogg demuxer records the physical stream's byte range at
/// construction time, but only when the source reports `is_seekable()` *during
/// the probe*. If seekability is hidden then (see `ProbeSeekGate`),
/// `phys_byte_range_end` stays `None` and the first real seek panics with
/// `Option::unwrap()` on `None` (`demuxer.rs:180`). Sources that can cheaply
/// seek to EOF must therefore stay seekable through the probe for Ogg.
pub(crate) fn container_hint_is_ogg(hint: Option<&str>) -> bool {
let Some(h) = hint else { return false };
matches!(
h.to_ascii_lowercase().as_str(),
"ogg" | "oga" | "ogx" | "opus" | "spx"
)
}
pub(crate) use local_file::LocalFileSource;
pub(crate) use radio::{RadioLiveState, RadioSharedFlags, radio_download_task};
pub(crate) use ranged_http::{RangedHttpSource, ranged_download_task};
pub(crate) use ranged_http::{OnDemand, RangedHttpSource, ranged_download_task};
pub(crate) use reader::AudioStreamReader;
pub(crate) use track_stream::track_download_task;
@@ -21,6 +21,7 @@ use futures_util::StreamExt;
use symphonia::core::io::MediaSource;
use tauri::{AppHandle, Emitter};
use super::super::engine::PlaybackHttpHeaders;
use super::super::state::PreloadedTrack;
use super::{
RADIO_YIELD_MS, TRACK_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_RECONNECTS,
@@ -50,6 +51,131 @@ impl Drop for RangedLoudnessSeedHoldClear {
}
}
/// Minimum bytes fetched per on-demand Range request. A seek often triggers a
/// short read; fetching a window amortizes the HTTP round-trip and lets the few
/// pages a bisection lands on (and the playback that follows a forward seek) be
/// served without a fresh request each time.
const OD_FETCH_WINDOW: u64 = 1024 * 1024;
/// Forward gap (cursor ahead of the contiguous linear download) above which a
/// read is treated as a *seek* and served by an on-demand HTTP Range fetch
/// instead of waiting for the linear filler to catch up. Below it we assume
/// ordinary read-ahead that the linear download will satisfy shortly, so we do
/// not issue redundant range requests during normal (slightly starved) play.
const OD_SEEK_GAP: u64 = 512 * 1024;
/// Random-access companion for [`RangedHttpSource`]: fetches arbitrary byte
/// ranges over HTTP `Range` on demand so seeks (which jump the read cursor far
/// ahead of the linear download) resolve quickly instead of blocking until the
/// linear filler reaches the target.
///
/// symphonia 0.6's Ogg demuxer seeks by *bisection* — it reads pages at
/// midpoints across the whole byte range, and its probe scans the last pages to
/// find the stream-end timestamp. On a purely linear-fill source every such read
/// would block until the download caught up (effectively forcing a full
/// download before any seek). On-demand range fetches make those reads cheap.
pub(crate) struct OnDemand {
http: reqwest::Client,
handle: tokio::runtime::Handle,
url: String,
buf: Arc<Mutex<Vec<u8>>>,
total_size: u64,
gen_arc: Arc<AtomicU64>,
gen: u64,
/// Byte ranges already fetched on demand (sorted/merged not required — N is
/// the handful of seek targets per track).
filled: Mutex<Vec<(u64, u64)>>,
/// Ranges with an in-flight fetch, so a polling read does not respawn them.
inflight: Mutex<Vec<(u64, u64)>>,
/// Bumped after every completed (success or failure) fetch so the read loop
/// can reset its stall deadline while on-demand fetches make progress.
progress: AtomicU64,
http_headers: PlaybackHttpHeaders,
}
impl OnDemand {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
http: reqwest::Client,
handle: tokio::runtime::Handle,
url: String,
buf: Arc<Mutex<Vec<u8>>>,
total_size: u64,
gen_arc: Arc<AtomicU64>,
gen: u64,
http_headers: PlaybackHttpHeaders,
) -> Self {
OnDemand {
http,
handle,
url,
buf,
total_size,
gen_arc,
gen,
filled: Mutex::new(Vec::new()),
inflight: Mutex::new(Vec::new()),
progress: AtomicU64::new(0),
http_headers,
}
}
fn covers(&self, start: u64, end: u64) -> bool {
self.filled
.lock()
.unwrap()
.iter()
.any(|&(s, e)| s <= start && end <= e)
}
fn inflight_covers(&self, start: u64, end: u64) -> bool {
self.inflight
.lock()
.unwrap()
.iter()
.any(|&(s, e)| s <= start && end <= e)
}
/// Spawn a Range fetch covering at least `[start, end)` (rounded up to
/// [`OD_FETCH_WINDOW`]) unless it is already filled or in flight. Returns
/// immediately; the caller polls [`OnDemand::covers`] / `progress`.
fn request(self: &Arc<Self>, start: u64, end: u64) {
if start >= self.total_size {
return;
}
let want_end = end.max(start + OD_FETCH_WINDOW).min(self.total_size);
if self.covers(start, want_end) || self.inflight_covers(start, want_end) {
return;
}
self.inflight.lock().unwrap().push((start, want_end));
let me = Arc::clone(self);
self.handle.spawn(async move {
let end_inclusive = want_end.saturating_sub(1);
let res = ranged_write_http_range(
&me.http,
&me.url,
&me.buf,
start,
end_inclusive,
me.gen,
&me.gen_arc,
&me.http_headers,
)
.await;
if let Ok(written) = res {
if written > 0 {
me.filled.lock().unwrap().push((start, start + written as u64));
}
}
// Drop the reservation either way so a failed fetch can be retried.
me.inflight
.lock()
.unwrap()
.retain(|&(s, e)| !(s == start && e == want_end));
me.progress.fetch_add(1, Ordering::SeqCst);
});
}
}
pub(crate) struct RangedHttpSource {
/// Pre-allocated buffer of total size. Filled linearly from offset 0.
pub(crate) buf: Arc<Mutex<Vec<u8>>>,
@@ -64,6 +190,10 @@ pub(crate) struct RangedHttpSource {
pub(crate) done: Arc<AtomicBool>,
pub(crate) gen_arc: Arc<AtomicU64>,
pub(crate) gen: u64,
/// On-demand random-access fetcher. `None` keeps the legacy linear-only
/// behaviour (used by unit tests); production ranged playback sets it so
/// seeks resolve via HTTP `Range` instead of blocking on the linear filler.
pub(crate) on_demand: Option<Arc<OnDemand>>,
}
impl RangedHttpSource {
@@ -78,6 +208,11 @@ impl RangedHttpSource {
return true;
}
}
if let Some(od) = &self.on_demand {
if od.covers(start, end) {
return true;
}
}
false
}
}
@@ -103,6 +238,11 @@ impl Read for RangedHttpSource {
let stall_timeout = Duration::from_secs(TRACK_READ_TIMEOUT_SECS);
let mut deadline = Instant::now() + stall_timeout;
let mut last_dl_seen = self.downloaded_to.load(Ordering::Relaxed) as u64;
let mut last_od_seen = self
.on_demand
.as_ref()
.map(|od| od.progress.load(Ordering::Relaxed))
.unwrap_or(0);
loop {
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
crate::app_deprintln!(
@@ -120,6 +260,24 @@ impl Read for RangedHttpSource {
last_dl_seen = dl;
deadline = Instant::now() + stall_timeout;
}
// A read whose cursor is far ahead of the contiguous linear download
// is a seek (Ogg bisection midpoint, end-of-stream probe, or a
// forward scrub). Serve it from an on-demand HTTP Range fetch rather
// than blocking until the linear filler crawls there. While the
// download is still running; an aborted download keeps the legacy
// partial/EOF behaviour below.
if let Some(od) = &self.on_demand {
let od_progress = od.progress.load(Ordering::SeqCst);
if od_progress != last_od_seen {
last_od_seen = od_progress;
deadline = Instant::now() + stall_timeout;
}
if !self.done.load(Ordering::SeqCst)
&& self.pos > dl.saturating_add(OD_SEEK_GAP)
{
od.request(self.pos, target_end);
}
}
// Download finished but our cursor is past downloaded_to (e.g. seek
// beyond a partial download that aborted). Return what we have.
if self.done.load(Ordering::SeqCst) {
@@ -214,6 +372,7 @@ pub(crate) async fn ranged_http_download_loop<F>(
downloaded_to: &Arc<AtomicUsize>,
gen: u64,
gen_arc: &Arc<AtomicU64>,
http_headers: &PlaybackHttpHeaders,
mut on_partial: F,
playback_armed: Option<&AtomicBool>,
) -> (usize, RangedHttpLoopOutcome)
@@ -234,6 +393,7 @@ where
if downloaded > 0 {
req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
}
req = http_headers.apply(url, req);
match req.send().await {
Ok(r) => r,
Err(err) => {
@@ -334,6 +494,7 @@ where
}
/// Fetch `bytes=start-end` into `buf[start..=end]` (inclusive HTTP Range).
#[allow(clippy::too_many_arguments)]
async fn ranged_write_http_range(
http_client: &reqwest::Client,
url: &str,
@@ -342,22 +503,32 @@ async fn ranged_write_http_range(
end_inclusive: u64,
gen: u64,
gen_arc: &Arc<AtomicU64>,
http_headers: &PlaybackHttpHeaders,
) -> Result<usize, ()> {
if gen_arc.load(Ordering::SeqCst) != gen {
return Err(());
}
let response = http_client
.get(url)
.header(reqwest::header::RANGE, format!("bytes={start}-{end_inclusive}"))
let response = http_headers
.apply(
url,
http_client
.get(url)
.header(reqwest::header::RANGE, format!("bytes={start}-{end_inclusive}")),
)
.send()
.await
.map_err(|_| ())?;
if gen_arc.load(Ordering::SeqCst) != gen {
return Err(());
}
if !(response.status() == reqwest::StatusCode::PARTIAL_CONTENT
|| response.status() == reqwest::StatusCode::OK)
{
// Require 206 for any non-zero offset. A server that ignored the `Range`
// header and replied 200 returns the *whole* body from byte 0; writing that
// at `start` would corrupt the buffer. A 200 is only safe when we asked from
// offset 0 (the body genuinely starts there).
let status = response.status();
let ok = status == reqwest::StatusCode::PARTIAL_CONTENT
|| (status == reqwest::StatusCode::OK && start == 0);
if !ok {
return Err(());
}
let mut written = 0usize;
@@ -397,6 +568,7 @@ async fn ranged_prefetch_mp4_tail(
playback_armed: Arc<AtomicBool>,
gen: u64,
gen_arc: Arc<AtomicU64>,
http_headers: PlaybackHttpHeaders,
) {
const MIN_TAIL: u64 = 256 * 1024;
const MAX_TAIL: u64 = 8 * 1024 * 1024;
@@ -415,6 +587,7 @@ async fn ranged_prefetch_mp4_tail(
end_inclusive,
gen,
&gen_arc,
&http_headers,
)
.await
{
@@ -464,6 +637,7 @@ pub(crate) async fn ranged_download_task(
cache_track_id: Option<String>,
// Playback server scope for the analysis-cache write key (empty/`None` → legacy '').
server_id: Option<String>,
http_headers: PlaybackHttpHeaders,
// When `Some`, ranged playback seeds on completion — defer HTTP backfill for that
// track; `None` for large files where ranged skips seed (needs backfill).
loudness_seed_hold: Option<LoudnessSeedHold>,
@@ -547,6 +721,7 @@ pub(crate) async fn ranged_download_task(
let tail_from_bg = tail_filled_from.clone();
let armed_bg = playback_armed.clone();
let gen_bg = gen_arc.clone();
let headers_bg = http_headers.clone();
Some(tokio::spawn(async move {
ranged_prefetch_mp4_tail(
client,
@@ -558,6 +733,7 @@ pub(crate) async fn ranged_download_task(
armed_bg,
gen,
gen_bg,
headers_bg,
)
.await;
}))
@@ -578,6 +754,7 @@ pub(crate) async fn ranged_download_task(
&downloaded_to,
gen,
&gen_arc,
&http_headers,
on_partial,
linear_arm,
)
@@ -736,6 +913,7 @@ mod tests {
done,
gen_arc,
gen: 7,
on_demand: None,
}
}
@@ -805,6 +983,7 @@ mod tests {
done,
gen_arc,
gen: 1,
on_demand: None,
};
let mut out = [0u8; 8];
let n = src.read(&mut out).unwrap();
@@ -835,6 +1014,7 @@ mod tests {
done,
gen_arc,
gen: 1,
on_demand: None,
};
let mut out = [0u8; 2];
let n = src.read(&mut out).unwrap();
@@ -859,6 +1039,7 @@ mod tests {
done,
gen_arc,
gen: 1,
on_demand: None,
};
let mut out = [0u8; 8];
assert_eq!(src.read(&mut out).unwrap(), 0);
@@ -965,6 +1146,7 @@ mod tests {
&dl,
1,
&gen_arc,
&PlaybackHttpHeaders::default(),
|_, _| {},
None,
)
@@ -1000,6 +1182,7 @@ mod tests {
&dl,
1,
&gen_arc,
&PlaybackHttpHeaders::default(),
|downloaded, total| calls.lock().unwrap().push((downloaded, total)),
None,
)
@@ -1028,7 +1211,7 @@ mod tests {
let (buf, dl, gen_arc) = loop_state(1024);
let (downloaded, outcome) =
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
.await;
assert_eq!(outcome, RangedHttpLoopOutcome::Aborted);
@@ -1059,7 +1242,7 @@ mod tests {
gen_arc.store(99, Ordering::SeqCst);
let (downloaded, outcome) =
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
.await;
assert_eq!(outcome, RangedHttpLoopOutcome::Superseded);
@@ -1118,7 +1301,7 @@ mod tests {
let (buf, dl, gen_arc) = loop_state(body.len());
let (downloaded, outcome) =
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
.await;
// Stream finishes via a Range-resumed second request.
@@ -1136,6 +1319,126 @@ mod tests {
}
}
/// Serves whatever inclusive byte range the request asks for out of `body`,
/// as a 206 — models a server that honours arbitrary `Range` requests.
struct RangeResponder {
body: Vec<u8>,
}
impl Respond for RangeResponder {
fn respond(&self, req: &Request) -> ResponseTemplate {
let range = req
.headers
.get(reqwest::header::RANGE.as_str())
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("bytes="))
.map(|s| s.to_string());
let Some(range) = range else {
return ResponseTemplate::new(200).set_body_bytes(self.body.clone());
};
let mut parts = range.splitn(2, '-');
let start: usize = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let end_inclusive: usize = parts
.next()
.filter(|s| !s.is_empty())
.and_then(|s| s.parse().ok())
.unwrap_or(self.body.len().saturating_sub(1));
let end = (end_inclusive + 1).min(self.body.len());
ResponseTemplate::new(206).set_body_bytes(self.body[start..end].to_vec())
}
}
#[tokio::test(flavor = "multi_thread")]
async fn read_far_ahead_is_served_by_on_demand_range_fetch() {
// 4 MiB track; nothing downloaded linearly yet and the download is still
// "in progress" (done = false). A read whose cursor sits well past the
// linear front must be satisfied by an on-demand Range fetch.
let total: usize = 4 * 1024 * 1024;
let body: Vec<u8> = (0..total).map(|i| (i % 256) as u8).collect();
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/track"))
.respond_with(RangeResponder { body: body.clone() })
.mount(&server)
.await;
let url = format!("{}/track", server.uri());
let buf = Arc::new(Mutex::new(vec![0u8; total]));
let downloaded_to = Arc::new(AtomicUsize::new(0));
let gen_arc = Arc::new(AtomicU64::new(1));
let on_demand = Some(Arc::new(OnDemand::new(
reqwest::Client::new(),
tokio::runtime::Handle::current(),
url,
buf.clone(),
total as u64,
gen_arc.clone(),
1,
PlaybackHttpHeaders::default(),
)));
let mut src = RangedHttpSource {
buf,
downloaded_to,
tail_ready: Arc::new(AtomicBool::new(false)),
tail_filled_from: Arc::new(AtomicU64::new(0)),
total_size: total as u64,
pos: 2 * 1024 * 1024, // 2 MiB — far past the (empty) linear front
done: Arc::new(AtomicBool::new(false)),
gen_arc,
gen: 1,
on_demand,
};
// The blocking read polls until the on-demand fetch fills the region.
let out = tokio::task::spawn_blocking(move || {
let mut out = [0u8; 16];
let n = src.read(&mut out).unwrap();
(n, out)
})
.await
.unwrap();
assert_eq!(out.0, 16, "read returns the requested bytes via on-demand fetch");
let base = 2 * 1024 * 1024usize;
let expected: Vec<u8> = (base..base + 16).map(|i| (i % 256) as u8).collect();
assert_eq!(&out.1[..], &expected[..]);
}
#[tokio::test(flavor = "multi_thread")]
async fn ranged_write_http_range_rejects_200_at_nonzero_offset() {
// A server that ignores Range and answers 200 with the whole body must
// NOT be written at a non-zero offset (would corrupt the buffer).
let server = MockServer::start().await;
let body = vec![0xCDu8; 4096];
Mock::given(method("GET"))
.and(path("/track"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(body))
.mount(&server)
.await;
let url = format!("{}/track", server.uri());
let buf = Arc::new(Mutex::new(vec![0u8; 4096]));
let gen_arc = Arc::new(AtomicU64::new(1));
let res = ranged_write_http_range(
&reqwest::Client::new(),
&url,
&buf,
1024, // non-zero offset
2047,
1,
&gen_arc,
&PlaybackHttpHeaders::default(),
)
.await;
assert!(res.is_err(), "200 at a non-zero offset must be rejected");
assert!(
buf.lock().unwrap().iter().all(|&b| b == 0),
"buffer must be left untouched on a rejected 200"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn loop_aborts_when_reconnect_returns_non_206() {
// Returns 200 first time (partial body), then 200 again (not 206) on the
@@ -1160,7 +1463,7 @@ mod tests {
let (buf, dl, gen_arc) = loop_state(body.len());
let (downloaded, outcome) =
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, |_, _| {}, None)
ranged_http_download_loop(client, &url, initial, &buf, &dl, 1, &gen_arc, &PlaybackHttpHeaders::default(), |_, _| {}, None)
.await;
// Reconnect server returned 200 instead of 206 → Aborted, downloaded
@@ -15,6 +15,7 @@ use ringbuf::HeapProd;
use ringbuf::traits::Producer;
use tauri::AppHandle;
use super::super::engine::PlaybackHttpHeaders;
use super::super::state::PreloadedTrack;
use super::{
maybe_arm_stream_playback, TRACK_STREAM_MAX_RECONNECTS, TRACK_STREAM_PROMOTE_MAX_BYTES,
@@ -37,6 +38,7 @@ pub(crate) async fn track_download_task(
cache_track_id: Option<String>,
// Playback server scope for the analysis-cache write key (empty/`None` → legacy '').
server_id: Option<String>,
http_headers: PlaybackHttpHeaders,
playback_armed: Arc<AtomicBool>,
) {
let mut downloaded: u64 = 0;
@@ -53,6 +55,7 @@ pub(crate) async fn track_download_task(
if downloaded > 0 {
req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
}
req = http_headers.apply(&url, req);
match req.send().await {
Ok(r) => r,
Err(err) => {
@@ -204,6 +204,8 @@ mod tests {
stream_playback_armed: Arc::new(AtomicBool::new(true)),
crossfade_enabled: Arc::new(AtomicBool::new(false)),
crossfade_secs: Arc::new(AtomicU32::new(0)),
autodj_suppress_autocrossfade: Arc::new(AtomicBool::new(false)),
interrupt_outgoing_duck_active: Arc::new(AtomicBool::new(false)),
fading_out_sink: Arc::new(Mutex::new(None)),
gapless_enabled: Arc::new(AtomicBool::new(false)),
normalization_engine: Arc::new(AtomicU32::new(0)),
@@ -9,6 +9,8 @@ publish = false
[dependencies]
tauri = { version = "2" }
serde = { version = "1", features = ["derive"] }
reqwest = { version = "0.13", default-features = false, features = ["rustls"] }
url = "2"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
@@ -4,6 +4,7 @@
//! macros) and the cross-crate port traits used to break dependency cycles
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
pub mod server_http;
pub mod cover_cache_layout;
pub mod log_sanitize;
pub mod media_layout;
@@ -8,12 +8,14 @@ const SENSITIVE_QUERY_KEYS: &[&str] = &[
const SENSITIVE_KV_KEYS: &[&str] = &[
"password", "passwd", "token", "secret", "api_key", "apikey", "access_token",
"refresh_token", "authorization", "auth",
"refresh_token", "authorization", "auth", "cookie", "x-api-key",
"cf-access-client-secret", "cf-access-client-id", "x-auth-token",
];
/// Sanitize one runtime log line for display and export.
pub fn sanitize_log_line(line: &str) -> String {
let mut out = redact_bearer_tokens(line);
out = redact_pangolin_headers(&out);
out = redact_sensitive_key_values(&out);
out = redact_urls_in_text(&out);
out
@@ -43,6 +45,37 @@ fn redact_bearer_tokens(line: &str) -> String {
s
}
fn redact_pangolin_headers(line: &str) -> String {
let lower = line.to_ascii_lowercase();
let mut out = line.to_string();
let mut search_from = 0;
while let Some(rel) = lower[search_from..].find("x-pangolin-") {
let idx = search_from + rel;
let after_prefix = &lower[idx..];
let Some(sep_rel) = after_prefix.find([':', '=']) else {
search_from = idx + 1;
continue;
};
let sep_idx = idx + sep_rel;
let val_start = sep_idx + 1;
let slice = &out[val_start..];
let trimmed = slice.trim_start();
let ws = slice.len().saturating_sub(trimmed.len());
let val_start = val_start + ws;
let end = trimmed
.find(|c: char| c.is_whitespace() || c == '&' || c == ',' || c == ';' || c == ')')
.unwrap_or(trimmed.len());
if end > 0 {
out.replace_range(val_start..val_start + end, "REDACTED");
}
search_from = val_start + "REDACTED".len();
if search_from >= out.len() {
break;
}
}
out
}
fn redact_sensitive_key_values(line: &str) -> String {
let mut out = line.to_string();
for key in SENSITIVE_KV_KEYS {
@@ -368,6 +401,17 @@ mod tests {
assert!(!out.contains("user:pass"));
}
#[test]
fn redacts_reverse_proxy_gate_headers() {
let line = "req CF-Access-Client-Secret: gate-secret Authorization: Bearer tok123 x-pangolin-auth: pangolin-key";
let out = sanitize_log_line(line);
assert!(out.contains("CF-Access-Client-Secret: REDACTED"));
assert!(!out.contains("gate-secret"));
assert!(!out.contains("tok123"));
assert!(out.contains("x-pangolin-auth: REDACTED"));
assert!(!out.contains("pangolin-key"));
}
#[test]
fn stream_log_with_em_dash_does_not_panic() {
let line = "[stream] RangedHttpSource selected — total=15666KB, hint=Some(\"mp3\")";
@@ -0,0 +1,366 @@
//! Per-server custom HTTP headers for reverse-proxy gates (Pangolin, Cloudflare Access).
//! Registry is keyed by index key; app server UUID aliases resolve via `ref_to_key`.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use reqwest::RequestBuilder;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EndpointKind {
Local,
Public,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CustomHeadersApplyTo {
Local,
#[default]
Public,
Both,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ServerHttpEndpointWire {
pub url: String,
pub kind: EndpointKind,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CustomHeaderEntryWire {
pub name: String,
pub value: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ServerHttpContextSyncWire {
#[serde(rename = "serverId")]
pub server_id: String,
#[serde(rename = "appServerId")]
pub app_server_id: String,
pub endpoints: Vec<ServerHttpEndpointWire>,
#[serde(rename = "customHeaders", default)]
pub custom_headers: Vec<CustomHeaderEntryWire>,
#[serde(rename = "customHeadersApplyTo", default)]
pub custom_headers_apply_to: Option<CustomHeadersApplyTo>,
}
#[derive(Clone, Debug)]
pub struct ServerHttpContext {
pub endpoints: Vec<(String, EndpointKind)>,
pub headers: Vec<(String, String)>,
pub apply_to: CustomHeadersApplyTo,
}
impl From<ServerHttpContextSyncWire> for ServerHttpContext {
fn from(w: ServerHttpContextSyncWire) -> Self {
Self {
endpoints: w
.endpoints
.into_iter()
.map(|e| (normalize_server_base_url(&e.url), e.kind))
.collect(),
headers: w
.custom_headers
.into_iter()
.map(|h| (h.name.trim().to_string(), h.value))
.filter(|(n, _)| !n.is_empty())
.collect(),
apply_to: w.custom_headers_apply_to.unwrap_or_default(),
}
}
}
fn normalize_server_base_url(raw: &str) -> String {
let trimmed = raw.trim().trim_end_matches('/');
if trimmed.is_empty() {
return String::new();
}
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
trimmed.to_string()
} else {
format!("http://{trimmed}")
}
}
/// Strip `/rest/…`, `/api/…`, `/auth/…`, and query from a full HTTP URL to match TS `requestBaseUrlFromHttpUrl`.
pub fn request_base_url_from_http_url(raw_url: &str) -> String {
let trimmed = raw_url.trim();
if trimmed.is_empty() {
return String::new();
}
let with_scheme = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
trimmed.to_string()
} else {
format!("http://{trimmed}")
};
let Ok(mut parsed) = url::Url::parse(&with_scheme) else {
return normalize_server_base_url(trimmed);
};
parsed.set_query(None);
parsed.set_fragment(None);
let mut path = parsed.path().to_string();
if let Some(idx) = path.find("/rest/") {
path.truncate(idx);
} else if path.ends_with("/rest") {
path.truncate(path.len().saturating_sub("/rest".len()));
} else {
for seg in ["/api/", "/auth/"] {
if let Some(idx) = path.find(seg) {
path.truncate(idx);
break;
}
}
}
while path.ends_with('/') && path.len() > 1 {
path.pop();
}
parsed.set_path(if path.is_empty() { "/" } else { &path });
let host = parsed.host_str().unwrap_or_default();
if host.is_empty() {
return normalize_server_base_url(trimmed);
}
let mut out = format!("{}://{}", parsed.scheme(), host);
if let Some(port) = parsed.port() {
out.push(':');
out.push_str(&port.to_string());
}
if !path.is_empty() && path != "/" {
out.push_str(&path);
}
normalize_server_base_url(&out)
}
pub fn headers_for_request_base_url(ctx: &ServerHttpContext, request_base_url: &str) -> HeaderMap {
let mut map = HeaderMap::new();
if ctx.headers.is_empty() {
return map;
}
let normalized = normalize_server_base_url(request_base_url);
let Some((_, kind)) = ctx.endpoints.iter().find(|(u, _)| *u == normalized) else {
return map;
};
let apply = match ctx.apply_to {
CustomHeadersApplyTo::Both => true,
CustomHeadersApplyTo::Public => *kind == EndpointKind::Public,
CustomHeadersApplyTo::Local => *kind == EndpointKind::Local,
};
if !apply {
return map;
}
for (name, value) in &ctx.headers {
let Ok(header_name) = HeaderName::from_bytes(name.as_bytes()) else {
continue;
};
let Ok(header_value) = HeaderValue::from_str(value) else {
continue;
};
map.insert(header_name, header_value);
}
map
}
pub fn apply_server_headers(
builder: RequestBuilder,
ctx: &ServerHttpContext,
request_base_url: &str,
) -> RequestBuilder {
let map = headers_for_request_base_url(ctx, request_base_url);
if map.is_empty() {
return builder;
}
builder.headers(map)
}
pub fn apply_server_headers_for_http_url(
builder: RequestBuilder,
ctx: &ServerHttpContext,
full_http_url: &str,
) -> RequestBuilder {
let base = request_base_url_from_http_url(full_http_url);
apply_server_headers(builder, ctx, &base)
}
#[derive(Default)]
pub struct ServerHttpRegistry {
contexts: Mutex<HashMap<String, Arc<ServerHttpContext>>>,
ref_to_key: Mutex<HashMap<String, String>>,
}
impl ServerHttpRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn sync(&self, wire: ServerHttpContextSyncWire) {
let index_key = wire.server_id.clone();
let app_id = wire.app_server_id.clone();
let ctx = Arc::new(ServerHttpContext::from(wire));
if ctx.headers.is_empty() {
self.remove(&index_key, &app_id);
return;
}
{
let mut contexts = self.contexts.lock().unwrap();
contexts.insert(index_key.clone(), Arc::clone(&ctx));
}
let mut refs = self.ref_to_key.lock().unwrap();
refs.insert(index_key.clone(), index_key.clone());
refs.insert(app_id, index_key);
}
pub fn sync_all(&self, entries: Vec<ServerHttpContextSyncWire>) {
let mut new_contexts = HashMap::new();
let mut new_refs = HashMap::new();
for wire in entries {
let index_key = wire.server_id.clone();
let app_id = wire.app_server_id.clone();
let ctx = Arc::new(ServerHttpContext::from(wire));
if ctx.headers.is_empty() {
continue;
}
new_contexts.insert(index_key.clone(), Arc::clone(&ctx));
new_refs.insert(index_key.clone(), index_key.clone());
new_refs.insert(app_id, index_key);
}
*self.contexts.lock().unwrap() = new_contexts;
*self.ref_to_key.lock().unwrap() = new_refs;
}
pub fn remove(&self, index_key: &str, app_server_id: &str) {
self.contexts.lock().unwrap().remove(index_key);
let mut refs = self.ref_to_key.lock().unwrap();
refs.remove(index_key);
refs.remove(app_server_id);
}
pub fn get(&self, index_key: &str) -> Option<Arc<ServerHttpContext>> {
self.contexts.lock().unwrap().get(index_key).cloned()
}
pub fn get_for_server_ref(&self, server_ref: &str) -> Option<Arc<ServerHttpContext>> {
if server_ref.is_empty() {
return None;
}
let key = {
let refs = self.ref_to_key.lock().unwrap();
refs.get(server_ref).cloned()
};
if let Some(k) = key {
return self.get(&k);
}
self.get(server_ref)
}
/// Fallback when only a server base URL is known (Navidrome invoke paths).
pub fn get_for_server_url(&self, server_url: &str) -> Option<Arc<ServerHttpContext>> {
let base = request_base_url_from_http_url(server_url);
if base.is_empty() {
return None;
}
let contexts = self.contexts.lock().unwrap();
for ctx in contexts.values() {
if ctx.endpoints.iter().any(|(u, _)| *u == base) {
return Some(Arc::clone(ctx));
}
}
None
}
pub fn apply_for_http_url(
&self,
server_ref: &str,
full_http_url: &str,
builder: RequestBuilder,
) -> RequestBuilder {
let Some(ctx) = self.get_for_server_ref(server_ref) else {
return builder;
};
apply_server_headers_for_http_url(builder, &ctx, full_http_url)
}
pub fn apply_for_base_url(
&self,
server_ref: &str,
request_base_url: &str,
builder: RequestBuilder,
) -> RequestBuilder {
let Some(ctx) = self.get_for_server_ref(server_ref) else {
return builder;
};
apply_server_headers(builder, &ctx, request_base_url)
}
}
/// Apply custom headers when `registry` is present — prefers `server_ref`, falls back to URL match.
pub fn apply_optional_registry_headers(
registry: Option<&ServerHttpRegistry>,
server_ref: Option<&str>,
full_http_url: &str,
builder: RequestBuilder,
) -> RequestBuilder {
if let Some(reg) = registry {
if let Some(sid) = server_ref.filter(|s| !s.is_empty()) {
return reg.apply_for_http_url(sid, full_http_url, builder);
}
if let Some(ctx) = reg.get_for_server_url(full_http_url) {
return apply_server_headers_for_http_url(builder, &ctx, full_http_url);
}
}
builder
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_base_url_strips_rest_and_query() {
let url = "https://music.example/rest/stream.view?id=1&u=x";
assert_eq!(
request_base_url_from_http_url(url),
"https://music.example"
);
}
#[test]
fn headers_apply_public_only_on_public_endpoint() {
let ctx = ServerHttpContext {
endpoints: vec![
("http://192.168.0.10".into(), EndpointKind::Local),
("https://music.example".into(), EndpointKind::Public),
],
headers: vec![("X-Gate".into(), "secret".into())],
apply_to: CustomHeadersApplyTo::Public,
};
let lan = headers_for_request_base_url(&ctx, "http://192.168.0.10");
assert!(lan.is_empty());
let pub_ = headers_for_request_base_url(&ctx, "https://music.example");
assert_eq!(pub_.get("X-Gate").map(|v| v.to_str().ok()), Some(Some("secret")));
}
#[test]
fn registry_resolves_app_id_alias() {
let reg = ServerHttpRegistry::new();
reg.sync(ServerHttpContextSyncWire {
server_id: "music.example".into(),
app_server_id: "uuid-1".into(),
endpoints: vec![ServerHttpEndpointWire {
url: "https://music.example".into(),
kind: EndpointKind::Public,
}],
custom_headers: vec![CustomHeaderEntryWire {
name: "X-Gate".into(),
value: "tok".into(),
}],
custom_headers_apply_to: Some(CustomHeadersApplyTo::Public),
});
assert!(reg.get("music.example").is_some());
assert!(reg.get_for_server_ref("uuid-1").is_some());
assert!(reg.get("uuid-1").is_none());
}
}
@@ -1,15 +1,31 @@
//! Auth + retry + HTTP client for Navidrome's native REST API.
//! Used by every other navidrome submodule for `/auth/*` and `/api/*` calls.
use psysonic_core::server_http::{apply_server_headers_for_http_url, apply_optional_registry_headers, ServerHttpRegistry};
/// Authenticate with Navidrome's own REST API and return a Bearer token.
pub async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> {
navidrome_token_with_registry(None, server_url, username, password).await
}
pub async fn navidrome_token_with_registry(
registry: Option<&ServerHttpRegistry>,
server_url: &str,
username: &str,
password: &str,
) -> Result<String, String> {
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/auth/login", server_url))
.json(&serde_json::json!({ "username": username, "password": password }))
.send()
.await
.map_err(|e| e.to_string())?;
let base = server_url.trim_end_matches('/');
let login_url = format!("{base}/auth/login");
let mut req = client
.post(&login_url)
.json(&serde_json::json!({ "username": username, "password": password }));
if let Some(reg) = registry {
if let Some(ctx) = reg.get_for_server_url(server_url) {
req = apply_server_headers_for_http_url(req, &ctx, &login_url);
}
}
let resp = req.send().await.map_err(|e| e.to_string())?;
let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
data["token"]
.as_str()
@@ -17,6 +33,16 @@ pub async fn navidrome_token(server_url: &str, username: &str, password: &str) -
.ok_or_else(|| "Navidrome auth: no token in response".to_string())
}
/// Attach gate headers for Navidrome `/auth/*` and `/api/*` requests.
pub fn nd_apply_request(
registry: Option<&ServerHttpRegistry>,
server_ref: Option<&str>,
full_url: &str,
builder: reqwest::RequestBuilder,
) -> reqwest::RequestBuilder {
apply_optional_registry_headers(registry, server_ref, full_url, builder)
}
/// Payload returned by Navidrome's `/auth/login`.
#[derive(serde::Serialize)]
pub struct NdLoginResult {
@@ -2,10 +2,16 @@
//! login (via `navidrome_token`) and then a multipart POST to the relevant
//! `/api/{playlist|radio|artist}/{id}/image` endpoint.
use super::client::navidrome_token;
use std::sync::Arc;
use psysonic_core::server_http::ServerHttpRegistry;
use tauri::State;
use super::client::{navidrome_token_with_registry, nd_apply_request, nd_http_client};
#[tauri::command]
pub async fn upload_playlist_cover(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
playlist_id: String,
username: String,
@@ -13,26 +19,34 @@ pub async fn upload_playlist_cover(
file_bytes: Vec<u8>,
mime_type: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let reg = http_registry.as_ref();
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg")
.mime_str(&mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new()
.post(format!("{}/api/playlist/{}/image", server_url, playlist_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let url = format!("{}/api/playlist/{}/image", server_url, playlist_id);
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.post(&url)
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form),
)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn upload_radio_cover(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
radio_id: String,
username: String,
@@ -40,26 +54,34 @@ pub async fn upload_radio_cover(
file_bytes: Vec<u8>,
mime_type: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let reg = http_registry.as_ref();
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg")
.mime_str(&mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new()
.post(format!("{}/api/radio/{}/image", server_url, radio_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let url = format!("{}/api/radio/{}/image", server_url, radio_id);
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.post(&url)
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form),
)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn upload_artist_image(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
artist_id: String,
username: String,
@@ -67,40 +89,58 @@ pub async fn upload_artist_image(
file_bytes: Vec<u8>,
mime_type: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let reg = http_registry.as_ref();
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg")
.mime_str(&mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new()
.post(format!("{}/api/artist/{}/image", server_url, artist_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let url = format!("{}/api/artist/{}/image", server_url, artist_id);
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.post(&url)
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form),
)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn delete_radio_cover(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
radio_id: String,
username: String,
password: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let resp = reqwest::Client::new()
.delete(format!("{}/api/radio/{}/image", server_url, radio_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| e.to_string())?;
let reg = http_registry.as_ref();
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
let url = format!("{}/api/radio/{}/image", server_url, radio_id);
let resp = nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.delete(&url)
.header("X-ND-Authorization", format!("Bearer {}", token)),
)
.send()
.await
.map_err(|e| e.to_string())?;
// 404/503 = no image existed — treat as success
if !resp.status().is_success() && resp.status() != reqwest::StatusCode::NOT_FOUND && resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE {
if !resp.status().is_success()
&& resp.status() != reqwest::StatusCode::NOT_FOUND
&& resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE
{
resp.error_for_status().map_err(|e| e.to_string())?;
}
Ok(())
@@ -11,4 +11,4 @@ pub mod probe;
pub mod queries;
pub mod users;
pub use client::navidrome_token;
pub use client::{navidrome_token, navidrome_token_with_registry, nd_apply_request};
@@ -2,24 +2,41 @@
//! payload is forwarded as-is so the frontend can compose any rule the
//! Navidrome version supports without backend changes.
use super::client::{nd_err, nd_http_client, nd_retry};
use std::sync::Arc;
use psysonic_core::server_http::ServerHttpRegistry;
use tauri::State;
use super::client::{nd_apply_request, nd_err, nd_http_client, nd_retry};
/// GET `/api/playlist` — list playlists; pass `smart=true` to filter smart playlists.
#[tauri::command]
pub async fn nd_list_playlists(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
smart: Option<bool>,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let base = format!("{}/api/playlist", server_url);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
let client = nd_http_client();
let mut req = client
.get(format!("{}/api/playlist", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token));
if let Some(s) = smart {
req = req.query(&[("smart", s)]);
let base = base.clone();
let auth = auth.clone();
async move {
let mut req = nd_apply_request(
Some(reg),
None,
&base,
nd_http_client()
.get(&base)
.header("X-ND-Authorization", auth),
);
if let Some(s) = smart {
req = req.query(&[("smart", s)]);
}
req.send().await
}
req.send()
})
.await?;
if !resp.status().is_success() {
@@ -31,16 +48,31 @@ pub async fn nd_list_playlists(
/// POST `/api/playlist` — create playlist (supports smart rules payload).
#[tauri::command]
pub async fn nd_create_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
body: serde_json::Value,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/playlist", server_url);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.post(format!("{}/api/playlist", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body)
let url = url.clone();
let auth = auth.clone();
let body = body.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.post(&url)
.header("X-ND-Authorization", auth)
.json(&body),
)
.send()
.await
}
})
.await?;
let status = resp.status();
@@ -54,17 +86,32 @@ pub async fn nd_create_playlist(
/// PUT `/api/playlist/{id}` — update playlist (supports smart rules payload).
#[tauri::command]
pub async fn nd_update_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
id: String,
body: serde_json::Value,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/playlist/{}", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.put(format!("{}/api/playlist/{}", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body)
let url = url.clone();
let auth = auth.clone();
let body = body.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.put(&url)
.header("X-ND-Authorization", auth)
.json(&body),
)
.send()
.await
}
})
.await?;
let status = resp.status();
@@ -78,15 +125,29 @@ pub async fn nd_update_playlist(
/// GET `/api/playlist/{id}` — get a single playlist (includes smart rules if available).
#[tauri::command]
pub async fn nd_get_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
id: String,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/playlist/{}", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.get(format!("{}/api/playlist/{}", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.get(&url)
.header("X-ND-Authorization", auth),
)
.send()
.await
}
})
.await?;
let status = resp.status();
@@ -100,15 +161,29 @@ pub async fn nd_get_playlist(
/// DELETE `/api/playlist/{id}` — delete playlist.
#[tauri::command]
pub async fn nd_delete_playlist(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
id: String,
) -> Result<(), String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/playlist/{}", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.delete(format!("{}/api/playlist/{}", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.delete(&url)
.header("X-ND-Authorization", auth),
)
.send()
.await
}
})
.await?;
let status = resp.status();
@@ -6,7 +6,7 @@
//! endpoint? — so this stays a free function rather than a client
//! struct. The full `nd_list_songs`-style ingest loop lands with PR-3b.
use super::client::{nd_err, nd_http_client};
use super::client::{nd_apply_request, nd_err, nd_http_client};
/// Returns `Ok(true)` when `GET /api/song?_start=0&_end=1` answers with
/// a 2xx status, `Ok(false)` for 4xx (auth ok but endpoint missing or
@@ -16,15 +16,25 @@ use super::client::{nd_err, nd_http_client};
/// Spec §6.1 ties the result to the `NavidromeNativeBulk` capability
/// flag. Wider call into the actual ingest path (`nd_list_songs` port)
/// is PR-3b's job.
pub async fn native_bulk_available(server_url: &str, token: &str) -> Result<bool, String> {
pub async fn native_bulk_available(
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: Option<&str>,
server_url: &str,
token: &str,
) -> Result<bool, String> {
let client = nd_http_client();
let url = format!("{}/api/song?_start=0&_end=1", server_url.trim_end_matches('/'));
let resp = client
.get(url)
.header("X-ND-Authorization", format!("Bearer {token}"))
.send()
.await
.map_err(nd_err)?;
let resp = nd_apply_request(
registry,
server_ref,
&url,
client
.get(&url)
.header("X-ND-Authorization", format!("Bearer {token}")),
)
.send()
.await
.map_err(nd_err)?;
let status = resp.status();
if status.is_success() {
@@ -56,7 +66,7 @@ mod tests {
.mount(&server)
.await;
let ok = native_bulk_available(&server.uri(), "tok-123").await.unwrap();
let ok = native_bulk_available(None, None, &server.uri(), "tok-123").await.unwrap();
assert!(ok);
}
@@ -71,7 +81,7 @@ mod tests {
.mount(&server)
.await;
let ok = native_bulk_available(&server.uri(), "tok").await.unwrap();
let ok = native_bulk_available(None, None, &server.uri(), "tok").await.unwrap();
assert!(!ok);
}
@@ -84,7 +94,7 @@ mod tests {
.mount(&server)
.await;
let ok = native_bulk_available(&server.uri(), "bad").await.unwrap();
let ok = native_bulk_available(None, None, &server.uri(), "bad").await.unwrap();
assert!(!ok, "401 reads as `endpoint not available for this caller`");
}
@@ -97,7 +107,7 @@ mod tests {
.mount(&server)
.await;
let err = native_bulk_available(&server.uri(), "tok").await.unwrap_err();
let err = native_bulk_available(None, None, &server.uri(), "tok").await.unwrap_err();
assert!(err.contains("503"));
}
@@ -111,6 +121,6 @@ mod tests {
.await;
let with_slash = format!("{}/", server.uri());
assert!(native_bulk_available(&with_slash, "tok").await.unwrap());
assert!(native_bulk_available(None, None, &with_slash, "tok").await.unwrap());
}
}
@@ -2,13 +2,21 @@
//! incompletely: songs, role-filtered artist/album lists, libraries,
//! per-user library assignment, and absolute song path resolution.
use super::client::{navidrome_token, nd_err, nd_http_client, nd_retry};
use std::sync::Arc;
use psysonic_core::server_http::ServerHttpRegistry;
use tauri::State;
use super::client::{navidrome_token_with_registry, nd_apply_request, nd_err, nd_http_client, nd_retry};
/// GET `/api/song?_sort=...&_order=...&_start=...&_end=...` — paginated
/// song list. Pure async helper used by the library-side N1 ingest
/// loop (spec §6.3, PR-3*); also wrapped by the `#[tauri::command]`
/// variant below for existing frontend callers.
#[allow(clippy::too_many_arguments)]
pub async fn nd_list_songs_internal(
registry: Option<&ServerHttpRegistry>,
server_ref: Option<&str>,
server_url: &str,
token: &str,
sort: &str,
@@ -20,12 +28,24 @@ pub async fn nd_list_songs_internal(
"{}/api/song?_sort={}&_order={}&_start={}&_end={}",
server_url, sort, order, start, end
);
let auth = format!("Bearer {token}");
let resp = nd_retry(|| {
nd_http_client()
.get(&url)
.header("X-ND-Authorization", format!("Bearer {token}"))
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
registry,
server_ref,
&url,
nd_http_client()
.get(&url)
.header("X-ND-Authorization", auth),
)
.send()
}).await?;
.await
}
})
.await?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
@@ -36,6 +56,7 @@ pub async fn nd_list_songs_internal(
/// surface unchanged for existing call sites in the WebView.
#[tauri::command]
pub async fn nd_list_songs(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
sort: String,
@@ -43,7 +64,17 @@ pub async fn nd_list_songs(
start: u32,
end: u32,
) -> Result<serde_json::Value, String> {
nd_list_songs_internal(&server_url, &token, &sort, &order, start, end).await
nd_list_songs_internal(
Some(http_registry.as_ref()),
None,
&server_url,
&token,
&sort,
&order,
start,
end,
)
.await
}
/// Build the `_filters` JSON for native-API list calls. Optionally narrows the
@@ -70,6 +101,7 @@ fn nd_build_filters(seed: serde_json::Map<String, serde_json::Value>, library_id
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_list_artists_by_role(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
role: String,
@@ -79,24 +111,42 @@ pub async fn nd_list_artists_by_role(
end: u32,
library_id: Option<String>,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let mut seed = serde_json::Map::new();
seed.insert("role".to_string(), serde_json::Value::String(role.clone()));
let filters = nd_build_filters(seed, library_id.as_deref());
let start_s = start.to_string();
let end_s = end.to_string();
let base = format!("{}/api/artist", server_url);
let resp = nd_retry(|| {
nd_http_client()
.get(format!("{}/api/artist", server_url))
.query(&[
("_filters", filters.as_str()),
("_sort", sort.as_str()),
("_order", order.as_str()),
("_start", start_s.as_str()),
("_end", end_s.as_str()),
])
.header("X-ND-Authorization", format!("Bearer {}", token))
let base = base.clone();
let filters = filters.clone();
let sort = sort.clone();
let order = order.clone();
let start_s = start_s.clone();
let end_s = end_s.clone();
let auth = format!("Bearer {}", token);
async move {
nd_apply_request(
Some(reg),
None,
&base,
nd_http_client()
.get(&base)
.query(&[
("_filters", filters.as_str()),
("_sort", sort.as_str()),
("_order", order.as_str()),
("_start", start_s.as_str()),
("_end", end_s.as_str()),
])
.header("X-ND-Authorization", auth),
)
.send()
}).await?;
.await
}
})
.await?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
@@ -111,6 +161,7 @@ pub async fn nd_list_artists_by_role(
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_list_albums_by_artist_role(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
artist_id: String,
@@ -121,25 +172,43 @@ pub async fn nd_list_albums_by_artist_role(
end: u32,
library_id: Option<String>,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let filter_key = format!("role_{}_id", role);
let mut seed = serde_json::Map::new();
seed.insert(filter_key, serde_json::Value::String(artist_id.clone()));
let filters = nd_build_filters(seed, library_id.as_deref());
let start_s = start.to_string();
let end_s = end.to_string();
let base = format!("{}/api/album", server_url);
let resp = nd_retry(|| {
nd_http_client()
.get(format!("{}/api/album", server_url))
.query(&[
("_filters", filters.as_str()),
("_sort", sort.as_str()),
("_order", order.as_str()),
("_start", start_s.as_str()),
("_end", end_s.as_str()),
])
.header("X-ND-Authorization", format!("Bearer {}", token))
let base = base.clone();
let filters = filters.clone();
let sort = sort.clone();
let order = order.clone();
let start_s = start_s.clone();
let end_s = end_s.clone();
let auth = format!("Bearer {}", token);
async move {
nd_apply_request(
Some(reg),
None,
&base,
nd_http_client()
.get(&base)
.query(&[
("_filters", filters.as_str()),
("_sort", sort.as_str()),
("_order", order.as_str()),
("_start", start_s.as_str()),
("_end", end_s.as_str()),
])
.header("X-ND-Authorization", auth),
)
.send()
}).await?;
.await
}
})
.await?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
@@ -149,15 +218,28 @@ pub async fn nd_list_albums_by_artist_role(
/// GET `/api/library` — list all libraries (admin only). Returns the raw JSON array.
#[tauri::command]
pub async fn nd_list_libraries(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/library", server_url);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.get(format!("{}/api/library", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token))
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client().get(&url).header("X-ND-Authorization", auth),
)
.send()
}).await?;
.await
}
})
.await?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
@@ -168,19 +250,35 @@ pub async fn nd_list_libraries(
/// Admin users auto-receive all libraries; calling this for an admin returns HTTP 400.
#[tauri::command]
pub async fn nd_set_user_libraries(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
id: String,
library_ids: Vec<i64>,
) -> Result<(), String> {
let reg = http_registry.as_ref();
let body = serde_json::json!({ "libraryIds": library_ids });
let url = format!("{}/api/user/{}/library", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.put(format!("{}/api/user/{}/library", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body)
let url = url.clone();
let auth = auth.clone();
let body = body.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.put(&url)
.header("X-ND-Authorization", auth)
.json(&body),
)
.send()
}).await?;
.await
}
})
.await?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
@@ -202,18 +300,31 @@ pub async fn nd_set_user_libraries(
/// it for non-admin users on some configurations.
#[tauri::command]
pub async fn nd_get_song_path(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
username: String,
password: String,
id: String,
) -> Result<Option<String>, String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let reg = http_registry.as_ref();
let token = navidrome_token_with_registry(Some(reg), &server_url, &username, &password).await?;
let url = format!("{}/api/song/{}", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.get(&url)
.header("X-ND-Authorization", format!("Bearer {}", token))
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.get(&url)
.header("X-ND-Authorization", auth),
)
.send()
.await
}
})
.await?;
if !resp.status().is_success() {
@@ -2,22 +2,39 @@
//! `token` (obtained via `navidrome_login`); admin-only ones return 401/403
//! when the caller is not an admin.
use super::client::{nd_err, nd_http_client, nd_retry, NdLoginResult};
use std::sync::Arc;
use psysonic_core::server_http::ServerHttpRegistry;
use tauri::State;
use super::client::{nd_apply_request, nd_err, nd_http_client, nd_retry, NdLoginResult};
/// Log in to Navidrome's native REST API. Returns a Bearer token and whether the user is admin.
#[tauri::command]
pub async fn navidrome_login(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
username: String,
password: String,
) -> Result<NdLoginResult, String> {
let reg = http_registry.as_ref();
let body = serde_json::json!({ "username": username, "password": password });
let login_url = format!("{}/auth/login", server_url.trim_end_matches('/'));
let resp = nd_retry(|| {
nd_http_client()
.post(format!("{}/auth/login", server_url))
.json(&body)
let login_url = login_url.clone();
let body = body.clone();
async move {
nd_apply_request(
Some(reg),
None,
&login_url,
nd_http_client().post(&login_url).json(&body),
)
.send()
}).await?;
.await
}
})
.await?;
if !resp.status().is_success() {
return Err(format!("Navidrome login failed: HTTP {}", resp.status()));
}
@@ -25,21 +42,40 @@ pub async fn navidrome_login(
let token = data["token"].as_str().ok_or("no token in response")?.to_string();
let user_id = data["id"].as_str().unwrap_or("").to_string();
let is_admin = data["isAdmin"].as_bool().unwrap_or(false);
Ok(NdLoginResult { token, user_id, is_admin })
Ok(NdLoginResult {
token,
user_id,
is_admin,
})
}
/// GET `/api/user` — admin only. Returns the raw JSON array verbatim so the frontend can pick fields.
#[tauri::command]
pub async fn nd_list_users(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/user", server_url);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.get(format!("{}/api/user", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token))
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.get(&url)
.header("X-ND-Authorization", auth),
)
.send()
}).await?;
.await
}
})
.await?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
@@ -48,7 +84,9 @@ pub async fn nd_list_users(
/// POST `/api/user` — create a user.
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_create_user(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
user_name: String,
@@ -57,6 +95,7 @@ pub async fn nd_create_user(
password: String,
is_admin: bool,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let body = serde_json::json!({
"userName": user_name,
"name": name,
@@ -64,13 +103,27 @@ pub async fn nd_create_user(
"password": password,
"isAdmin": is_admin,
});
let url = format!("{}/api/user", server_url);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.post(format!("{}/api/user", server_url))
.header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body)
let url = url.clone();
let auth = auth.clone();
let body = body.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.post(&url)
.header("X-ND-Authorization", auth)
.json(&body),
)
.send()
}).await?;
.await
}
})
.await?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
@@ -83,6 +136,7 @@ pub async fn nd_create_user(
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn nd_update_user(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
id: String,
@@ -92,6 +146,7 @@ pub async fn nd_update_user(
password: String,
is_admin: bool,
) -> Result<serde_json::Value, String> {
let reg = http_registry.as_ref();
let mut body = serde_json::json!({
"id": id,
"userName": user_name,
@@ -102,13 +157,27 @@ pub async fn nd_update_user(
if !password.is_empty() {
body["password"] = serde_json::Value::String(password);
}
let url = format!("{}/api/user/{}", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.put(format!("{}/api/user/{}", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.json(&body)
let url = url.clone();
let auth = auth.clone();
let body = body.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.put(&url)
.header("X-ND-Authorization", auth)
.json(&body),
)
.send()
}).await?;
.await
}
})
.await?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
@@ -120,16 +189,31 @@ pub async fn nd_update_user(
/// DELETE `/api/user/{id}`.
#[tauri::command]
pub async fn nd_delete_user(
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_url: String,
token: String,
id: String,
) -> Result<(), String> {
let reg = http_registry.as_ref();
let url = format!("{}/api/user/{}", server_url, id);
let auth = format!("Bearer {}", token);
let resp = nd_retry(|| {
nd_http_client()
.delete(format!("{}/api/user/{}", server_url, id))
.header("X-ND-Authorization", format!("Bearer {}", token))
let url = url.clone();
let auth = auth.clone();
async move {
nd_apply_request(
Some(reg),
None,
&url,
nd_http_client()
.delete(&url)
.header("X-ND-Authorization", auth),
)
.send()
}).await?;
.await
}
})
.await?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
@@ -12,6 +12,7 @@ use serde::Deserialize;
use super::auth::SubsonicCredentials;
use super::error::{flatten_reqwest_error, SubsonicError};
use super::types::{Album, AlbumSummary, ArtistIndex, ScanStatus, SearchResult, ServerInfo, Song};
use psysonic_core::server_http::{apply_server_headers, ServerHttpContext};
/// Protocol level we advertise — pre-OpenSubsonic Subsonic baseline that
/// Navidrome and other servers in the wild support. OpenSubsonic
@@ -42,6 +43,7 @@ pub struct SubsonicClient {
base_url: String,
credentials: CredentialsMode,
http: reqwest::Client,
http_context: Option<ServerHttpContext>,
}
impl SubsonicClient {
@@ -75,9 +77,28 @@ impl SubsonicClient {
password: password.into(),
},
http,
http_context: None,
}
}
pub fn with_http_context(mut self, ctx: ServerHttpContext) -> Self {
self.http_context = Some(ctx);
self
}
/// Production helper — attach registry context when present for `server_ref`
/// (app server id or index key).
pub fn with_registry(
self,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: &str,
) -> Self {
registry
.and_then(|r| r.get_for_server_ref(server_ref))
.map(|ctx| self.clone().with_http_context((*ctx).clone()))
.unwrap_or(self)
}
/// Test-/cache-friendly constructor — re-uses the same
/// `SubsonicCredentials` triple on every call. Wiremock tests rely on
/// this for deterministic `s=` and `t=` query params; production code
@@ -95,6 +116,7 @@ impl SubsonicClient {
base_url: url,
credentials: CredentialsMode::Static(credentials),
http,
http_context: None,
}
}
@@ -301,10 +323,15 @@ impl SubsonicClient {
let mut query: Vec<(&str, &str)> = auth.to_vec();
query.extend_from_slice(extra);
let resp = self
let mut req = self
.http
.get(format!("{}/rest/{method}.view", self.base_url))
.query(&query)
.query(&query);
if let Some(ctx) = &self.http_context {
req = apply_server_headers(req, ctx, &self.base_url);
}
let resp = req
.send()
.await
.map_err(|e| SubsonicError::Transport(flatten_reqwest_error(e)))?;
@@ -331,6 +358,16 @@ fn default_http_client() -> reqwest::Client {
.unwrap_or_else(|_| reqwest::Client::new())
}
pub fn subsonic_client_with_registry(
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: &str,
base_url: impl Into<String>,
username: impl Into<String>,
password: impl Into<String>,
) -> SubsonicClient {
SubsonicClient::new(base_url, username, password).with_registry(registry, server_ref)
}
/// Validate the Subsonic envelope and return the raw `serde_json::Value`
/// at `body_key`. Maps `error.code = 70` to the dedicated `NotFound`
/// variant; surfaces every other failed status as `Api { code, message }`.
@@ -10,7 +10,8 @@ pub mod types;
pub use auth::SubsonicCredentials;
pub use client::{
fingerprint_sample, SubsonicClient, SUBSONIC_API_VERSION, SUBSONIC_CLIENT_ID,
fingerprint_sample, subsonic_client_with_registry, SubsonicClient, SUBSONIC_API_VERSION,
SUBSONIC_CLIENT_ID,
};
pub use stream_url::{build_stream_view_url, rest_base_from_url};
pub use error::SubsonicError;
@@ -0,0 +1,16 @@
-- External artist artwork lookup (fanart.tv etc.) — image-scraper design-review §12.
-- Render NEVER reads this table; only the on-demand cover ensure path + the
-- negative cache (`mbid_ambiguous` 24h backoff) use it. `server_id` is the
-- serverIndexKey (same key as coverStorageKey / the on-disk cover path, §27),
-- NOT the auth-profile UUID.
CREATE TABLE IF NOT EXISTS artist_artwork_lookup (
server_id TEXT NOT NULL,
artist_id TEXT NOT NULL,
surface_kind TEXT NOT NULL, -- 'fanart' (| 'thumb' later)
mbid TEXT, -- nullable; from tag or MusicBrainz
mbid_source TEXT, -- 'tag' | 'musicbrainz' | NULL
status TEXT NOT NULL, -- pending|hit|miss|skipped|no_mbid|mbid_ambiguous|error
provider TEXT, -- hit source (e.g. 'fanart'); NULL for miss/skipped
updated_at INTEGER NOT NULL, -- unix ms
PRIMARY KEY (server_id, artist_id, surface_kind)
);
@@ -0,0 +1,6 @@
-- Artist browse sort key (Navidrome OrderArtistName parity) + server ignoredArticles watermark.
-- Applied idempotently from store.rs `apply_migration_14` (per-column guard) so a
-- partial apply recovers; keep this file in sync as the canonical DDL.
ALTER TABLE artist ADD COLUMN name_sort TEXT;
ALTER TABLE sync_state ADD COLUMN ignored_articles TEXT;
CREATE INDEX IF NOT EXISTS idx_artist_name_sort ON artist(server_id, name_sort);
@@ -39,7 +39,7 @@ const ALBUM_COLUMNS: &str = "a.server_id, a.id, a.name, a.artist, a.artist_id, \
WHERE t.server_id = a.server_id AND t.album_id = a.id AND t.deleted = 0)), \
a.genre, a.cover_art_id, a.starred_at, a.synced_at, a.raw_json";
const ARTIST_COLUMNS: &str = "ar.server_id, ar.id, ar.name, ar.album_count, \
const ARTIST_COLUMNS: &str = "ar.server_id, ar.id, ar.name, ar.name_sort, ar.album_count, \
ar.synced_at, ar.raw_json";
/// Flat track projection used when browsing albums in advanced search.
@@ -527,7 +527,9 @@ fn build_artist_from_table(
}
}
let order = order_clause(&req.sort, EntityKind::Artist)
.unwrap_or_else(|| "ORDER BY ar.name COLLATE NOCASE ASC, ar.id ASC".to_string());
.unwrap_or_else(|| {
"ORDER BY COALESCE(ar.name_sort, ar.name) COLLATE NOCASE ASC, ar.id ASC".to_string()
});
query_rows(
store,
ARTIST_COLUMNS,
@@ -794,10 +796,16 @@ fn build_artist_from_fts(
if !seen.insert(artist_id.clone()) {
continue;
}
let name = artist.unwrap_or_default();
let name_sort = crate::artist_sort::sort_key_for_display_name(
&name,
crate::artist_sort::DEFAULT_IGNORED_ARTICLES,
);
deduped.push(LibraryArtistDto {
server_id,
id: artist_id,
name: artist.unwrap_or_default(),
name,
name_sort: Some(name_sort),
album_count: None,
synced_at,
raw_json: Value::Null,
@@ -1179,13 +1187,14 @@ fn map_album(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryAlbumDto> {
}
fn map_artist(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryArtistDto> {
let raw: Option<String> = r.get(5)?;
let raw: Option<String> = r.get(6)?;
Ok(LibraryArtistDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
album_count: r.get(3)?,
synced_at: r.get(4)?,
name_sort: r.get(3)?,
album_count: r.get(4)?,
synced_at: r.get(5)?,
raw_json: parse_raw_json(raw),
})
}
@@ -1211,10 +1220,16 @@ fn map_album_from_tracks(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryAlbum
}
fn map_artist_from_tracks(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryArtistDto> {
let name: String = r.get(2)?;
let name_sort = crate::artist_sort::sort_key_for_display_name(
&name,
crate::artist_sort::DEFAULT_IGNORED_ARTICLES,
);
Ok(LibraryArtistDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
name,
name_sort: Some(name_sort),
album_count: Some(r.get(3)?),
synced_at: r.get(4)?,
raw_json: Value::Null,
@@ -1289,7 +1304,7 @@ fn sort_column(field: &str, entity: EntityKind) -> Option<&'static str> {
("name", EntityKind::Album) => Some("a.name COLLATE NOCASE"),
("year", EntityKind::Album) => Some("a.year"),
("artist", EntityKind::Album) => Some("a.artist COLLATE NOCASE"),
("name", EntityKind::Artist) => Some("ar.name COLLATE NOCASE"),
("name", EntityKind::Artist) => Some("COALESCE(ar.name_sort, ar.name) COLLATE NOCASE"),
// SQLite built-in: ORDER BY RANDOM() LIMIT N — fast pseudo-random sample,
// no index scan needed beyond the row-id range. Direction is ignored.
("random", _) => Some("RANDOM()"),
@@ -0,0 +1,146 @@
//! External artist artwork lookup table accessors (fanart.tv etc.) —
//! image-scraper design-review §12. Render NEVER reads this; only the
//! on-demand cover ensure path + the `mbid_ambiguous` 24h negative cache use
//! it. `server_id` is the serverIndexKey (§27), not the auth-profile UUID.
use rusqlite::OptionalExtension;
use crate::store::LibraryStore;
/// One `artist_artwork_lookup` row. The `(server_id, artist_id, surface_kind)`
/// primary key is implied by the lookup; this carries the resolution state.
#[derive(Debug, Clone)]
pub struct ArtistArtworkRow {
pub mbid: Option<String>,
pub mbid_source: Option<String>,
pub status: String,
pub provider: Option<String>,
pub updated_at: i64,
}
/// Fetch the cached lookup row for `(server_id, artist_id, surface_kind)`, if any.
pub fn get_artist_artwork(
store: &LibraryStore,
server_id: &str,
artist_id: &str,
surface_kind: &str,
) -> Result<Option<ArtistArtworkRow>, String> {
store.with_read_conn(|conn| {
conn.query_row(
"SELECT mbid, mbid_source, status, provider, updated_at
FROM artist_artwork_lookup
WHERE server_id = ?1 AND artist_id = ?2 AND surface_kind = ?3",
rusqlite::params![server_id, artist_id, surface_kind],
|row| {
Ok(ArtistArtworkRow {
mbid: row.get(0)?,
mbid_source: row.get(1)?,
status: row.get(2)?,
provider: row.get(3)?,
updated_at: row.get(4)?,
})
},
)
.optional()
})
}
/// Insert or replace the lookup row for `(server_id, artist_id, surface_kind)`.
#[allow(clippy::too_many_arguments)]
pub fn upsert_artist_artwork(
store: &LibraryStore,
server_id: &str,
artist_id: &str,
surface_kind: &str,
mbid: Option<&str>,
mbid_source: Option<&str>,
status: &str,
provider: Option<&str>,
updated_at: i64,
) -> Result<(), String> {
store.with_conn_mut("artist_artwork.upsert", |conn| {
conn.execute(
"INSERT INTO artist_artwork_lookup
(server_id, artist_id, surface_kind, mbid, mbid_source, status, provider, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(server_id, artist_id, surface_kind) DO UPDATE SET
mbid = excluded.mbid,
mbid_source = excluded.mbid_source,
status = excluded.status,
provider = excluded.provider,
updated_at = excluded.updated_at",
rusqlite::params![
server_id,
artist_id,
surface_kind,
mbid,
mbid_source,
status,
provider,
updated_at
],
)?;
Ok(())
})
}
/// Delete all lookup rows for a server — part of clear-cover-cache-per-server
/// (§12 / Appendix B.4).
pub fn clear_artist_artwork_for_server(
store: &LibraryStore,
server_id: &str,
) -> Result<usize, String> {
store.with_conn_mut("artist_artwork.clear_server", |conn| {
conn.execute(
"DELETE FROM artist_artwork_lookup WHERE server_id = ?1",
rusqlite::params![server_id],
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::LibraryStore;
#[test]
fn upsert_then_get_roundtrips_and_replaces() {
let store = LibraryStore::open_in_memory();
let sk = "fanart";
assert!(get_artist_artwork(&store, "srv", "ar-1", sk).unwrap().is_none());
upsert_artist_artwork(
&store, "srv", "ar-1", sk, None, None, "no_mbid", None, 1000,
)
.unwrap();
let row = get_artist_artwork(&store, "srv", "ar-1", sk).unwrap().unwrap();
assert_eq!(row.status, "no_mbid");
assert_eq!(row.mbid, None);
assert_eq!(row.updated_at, 1000);
// Replace (e.g. tag MBID appeared, fanart hit).
upsert_artist_artwork(
&store,
"srv",
"ar-1",
sk,
Some("mbid-123"),
Some("tag"),
"hit",
Some("fanart"),
2000,
)
.unwrap();
let row = get_artist_artwork(&store, "srv", "ar-1", sk).unwrap().unwrap();
assert_eq!(row.status, "hit");
assert_eq!(row.mbid.as_deref(), Some("mbid-123"));
assert_eq!(row.mbid_source.as_deref(), Some("tag"));
assert_eq!(row.provider.as_deref(), Some("fanart"));
assert_eq!(row.updated_at, 2000);
// Clear-per-server removes it.
assert_eq!(clear_artist_artwork_for_server(&store, "srv").unwrap(), 1);
assert!(get_artist_artwork(&store, "srv", "ar-1", sk).unwrap().is_none());
}
}
@@ -0,0 +1,72 @@
//! Display-name → sort/bucket key (Navidrome `SanitizeFieldForSortingNoArticle` subset).
/// Navidrome default (`utils/str/sanitize_strings_test.go`).
pub const DEFAULT_IGNORED_ARTICLES: &str = "The El La Los Las Le Les Os As O A";
/// Strip leading articles from a display name (case-insensitive article match).
pub fn strip_leading_articles(name: &str, ignored_articles: &str) -> String {
let trimmed = name.trim();
for article in ignored_articles.split(' ').filter(|s| !s.is_empty()) {
let prefix = format!("{} ", article);
// `prefix` is ASCII; use `get` so we never slice inside a multibyte rune
// (e.g. "Elə…" must not panic when probing the "El " article).
let head = trimmed.get(0..prefix.len());
if head.is_some_and(|h| h.eq_ignore_ascii_case(&prefix)) {
return trimmed[prefix.len()..].trim_start().to_string();
}
}
trimmed.to_string()
}
/// Lowercase sort key used for SQL `ORDER BY` and UI letter buckets.
pub fn sort_key_for_display_name(name: &str, ignored_articles: &str) -> String {
strip_leading_articles(name, ignored_articles).to_lowercase()
}
pub fn ignored_articles_or_default(ignored_articles: Option<&str>) -> &str {
match ignored_articles.map(str::trim).filter(|s| !s.is_empty()) {
Some(s) => s,
None => DEFAULT_IGNORED_ARTICLES,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_the_from_beatles() {
let key = sort_key_for_display_name("The Beatles", DEFAULT_IGNORED_ARTICLES);
assert_eq!(key, "beatles");
}
#[test]
fn strips_the_from_kinks() {
let key = sort_key_for_display_name("The Kinks", DEFAULT_IGNORED_ARTICLES);
assert_eq!(key, "kinks");
}
#[test]
fn leaves_non_article_names() {
assert_eq!(
sort_key_for_display_name("Adele", DEFAULT_IGNORED_ARTICLES),
"adele"
);
}
#[test]
fn custom_ignored_articles() {
assert_eq!(
sort_key_for_display_name("The Beatles", "The"),
"beatles"
);
}
#[test]
fn does_not_panic_when_article_prefix_aligns_with_multibyte_rune() {
// Regression: byte slice `trimmed[..prefix.len()]` panicked on "Elə…"
// when probing the "El " ignored article (ə spans bytes 2..4).
let key = sort_key_for_display_name("Eləmir", DEFAULT_IGNORED_ARTICLES);
assert_eq!(key, "eləmir");
}
}
@@ -11,8 +11,9 @@ use rusqlite::params;
use serde_json::Value;
use tauri::{AppHandle, Emitter, Manager, State};
use psysonic_integration::navidrome::navidrome_token;
use psysonic_integration::subsonic::SubsonicClient;
use psysonic_core::server_http::ServerHttpRegistry;
use psysonic_integration::navidrome::navidrome_token_with_registry;
use psysonic_integration::subsonic::subsonic_client_with_registry;
use crate::advanced_search;
use crate::analysis_backfill::{self, LibraryAnalysisBackfillBatchDto, LibraryAnalysisProgressDto};
@@ -180,8 +181,8 @@ pub async fn library_get_status(
conn.query_row(
"SELECT sync_phase, capability_flags, library_tier, last_full_sync_at, \
last_delta_sync_at, next_poll_at, server_last_scan_iso, \
indexes_last_modified_ms, artists_last_modified_ms, local_track_count, \
server_track_count, last_error \
indexes_last_modified_ms, artists_last_modified_ms, ignored_articles, \
local_track_count, server_track_count, last_error \
FROM sync_state WHERE server_id = ?1 AND library_scope = ?2",
params![server_id, scope],
|r| {
@@ -195,9 +196,10 @@ pub async fn library_get_status(
server_last_scan_iso: r.get(6)?,
indexes_last_modified_ms: r.get(7)?,
artists_last_modified_ms: r.get(8)?,
local_track_count: r.get(9)?,
server_track_count: r.get(10)?,
last_error: r.get(11)?,
ignored_articles: r.get(9)?,
local_track_count: r.get(10)?,
server_track_count: r.get(11)?,
last_error: r.get(12)?,
})
},
)
@@ -246,6 +248,7 @@ pub async fn library_get_status(
server_last_scan_iso: row.server_last_scan_iso,
indexes_last_modified_ms: row.indexes_last_modified_ms,
artists_last_modified_ms: row.artists_last_modified_ms,
ignored_articles: row.ignored_articles,
local_track_count,
server_track_count: row.server_track_count,
last_error: row.last_error,
@@ -622,6 +625,7 @@ struct SyncStateRow {
server_last_scan_iso: Option<String>,
indexes_last_modified_ms: Option<i64>,
artists_last_modified_ms: Option<i64>,
ignored_articles: Option<String>,
local_track_count: Option<i64>,
server_track_count: Option<i64>,
last_error: Option<String>,
@@ -654,13 +658,14 @@ fn normalize_base_url(raw: &str) -> String {
/// caller falls back to a cached bearer / the Subsonic-only path. Never logs
/// the token or credentials.
async fn navidrome_token_with_retry(
registry: Option<&ServerHttpRegistry>,
base_url: &str,
username: &str,
password: &str,
) -> Option<String> {
const ATTEMPTS: u32 = 3;
for attempt in 1..=ATTEMPTS {
match navidrome_token(base_url, username, password).await {
match navidrome_token_with_registry(registry, base_url, username, password).await {
Ok(tok) => return Some(tok),
Err(_) if attempt < ATTEMPTS => {
tokio::time::sleep(Duration::from_millis(250 * attempt as u64)).await;
@@ -674,6 +679,7 @@ async fn navidrome_token_with_retry(
#[tauri::command]
pub async fn library_sync_bind_session(
runtime: State<'_, LibraryRuntime>,
http_registry: State<'_, Arc<ServerHttpRegistry>>,
server_id: String,
base_url: String,
username: String,
@@ -687,8 +693,13 @@ pub async fn library_sync_bind_session(
// keep a bearer cached from a prior bind rather than dropping to
// Subsonic-only — a transient miss must not strip an N1-capable server
// (R7-15 Q3). Non-Navidrome servers stay `None` and sync via Subsonic.
let navidrome_token_cached = match navidrome_token_with_retry(&base_url, &username, &password)
.await
let navidrome_token_cached = match navidrome_token_with_retry(
Some(http_registry.as_ref()),
&base_url,
&username,
&password,
)
.await
{
Some(tok) => Some(tok),
None => runtime.get_session(&server_id).and_then(|s| s.navidrome_token),
@@ -706,7 +717,13 @@ pub async fn library_sync_bind_session(
// Run the probe + persist capability flags. Failure to probe is a
// bind-time error — caller should fix credentials / URL.
let subsonic = SubsonicClient::new(base_url, username, password);
let subsonic = subsonic_client_with_registry(
Some(http_registry.as_ref()),
&server_id,
base_url,
username,
password,
);
let navidrome_creds = navidrome_token_cached.map(|tok| NavidromeProbeCredentials {
server_url: subsonic_base_url_from(&runtime, &server_id),
bearer_token: tok,
@@ -716,6 +733,7 @@ pub async fn library_sync_bind_session(
&runtime.store,
&subsonic,
navidrome_creds.as_ref(),
Some(http_registry.as_ref()),
&server_id,
scope,
)
@@ -869,8 +887,12 @@ async fn library_sync_start_inner(
let job_id_for_task = job_id.clone();
let parallelism = ParallelismBudget::resolve(runtime.current_playback_hint());
let app_for_runner = app.clone();
let runner_handle: tokio::task::JoinHandle<Result<(), String>> = tokio::task::spawn(async move {
let subsonic = SubsonicClient::new(
let registry = app_for_runner.state::<Arc<ServerHttpRegistry>>();
let subsonic = subsonic_client_with_registry(
Some(registry.as_ref()),
&session_clone.server_id,
session_clone.base_url.clone(),
session_clone.username.clone(),
session_clone.password.clone(),
@@ -892,7 +914,8 @@ async fn library_sync_start_inner(
)
.with_cancellation(Arc::clone(&cancel_for_task))
.with_progress(Arc::clone(&progress))
.with_parallelism_budget(parallelism);
.with_parallelism_budget(parallelism)
.with_http_registry(Some(Arc::clone(&registry)));
if let Some(creds) = navidrome_creds.clone() {
runner = runner.with_navidrome_credentials(creds);
}
@@ -916,7 +939,8 @@ async fn library_sync_start_inner(
capability_flags,
)
.with_cancellation(Arc::clone(&cancel_for_task))
.with_progress(Arc::clone(&progress));
.with_progress(Arc::clone(&progress))
.with_http_registry(Some(Arc::clone(&registry)));
if tombstone_budget > 0 {
runner = runner.with_tombstone_budget(tombstone_budget);
}
@@ -1644,7 +1668,7 @@ mod tests {
})))
.mount(&server)
.await;
let tok = navidrome_token_with_retry(&server.uri(), "user", "pw").await;
let tok = navidrome_token_with_retry(None, &server.uri(), "user", "pw").await;
assert_eq!(tok.as_deref(), Some("nd-tok"));
}
@@ -1661,7 +1685,7 @@ mod tests {
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&server)
.await;
let tok = navidrome_token_with_retry(&server.uri(), "user", "pw").await;
let tok = navidrome_token_with_retry(None, &server.uri(), "user", "pw").await;
assert!(tok.is_none());
}
}
@@ -28,6 +28,8 @@ pub struct SyncStateDto {
pub server_last_scan_iso: Option<String>,
pub indexes_last_modified_ms: Option<i64>,
pub artists_last_modified_ms: Option<i64>,
/// Space-separated leading articles from the server's `getArtists` response.
pub ignored_articles: Option<String>,
pub local_track_count: Option<i64>,
pub server_track_count: Option<i64>,
pub last_error: Option<String>,
@@ -476,6 +478,8 @@ pub struct LibraryArtistDto {
pub server_id: String,
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name_sort: Option<String>,
pub album_count: Option<i64>,
pub synced_at: i64,
pub raw_json: Value,
@@ -861,6 +865,7 @@ mod tests {
server_last_scan_iso: None,
indexes_last_modified_ms: None,
artists_last_modified_ms: None,
ignored_articles: None,
local_track_count: None,
server_track_count: None,
last_error: None,
@@ -15,7 +15,9 @@ mod advanced_search_mood;
pub mod analysis_backfill;
pub mod analysis_backfill_policy;
pub mod library_readiness;
pub mod artist_artwork;
pub mod artist_lossless_browse;
pub mod artist_sort;
pub mod cover_backfill;
pub mod cover_resolve;
pub mod canonical;
@@ -193,6 +193,7 @@ fn query_artists(
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get::<_, Option<String>>(2)?.unwrap_or_default(),
name_sort: None,
album_count: None,
synced_at: r.get(3)?,
raw_json: serde_json::Value::Null,
@@ -0,0 +1,198 @@
//! `artist` table — browse index rows from `getArtists` and track-derived backfill.
use rusqlite::{params, Transaction};
use crate::artist_sort::{ignored_articles_or_default, sort_key_for_display_name};
use crate::store::LibraryStore;
use psysonic_integration::subsonic::ArtistIndex;
pub struct ArtistRepository<'a> {
store: &'a LibraryStore,
}
impl<'a> ArtistRepository<'a> {
pub fn new(store: &'a LibraryStore) -> Self {
Self { store }
}
/// Upsert artists from a Subsonic `getArtists` / `getIndexes` body.
pub fn upsert_index(
&self,
server_id: &str,
index: &ArtistIndex,
synced_at: i64,
) -> Result<u32, String> {
let ignored = ignored_articles_or_default(index.ignored_articles.as_deref());
let mut count = 0u32;
self.store.with_conn_mut("artist.upsert_index", |conn| {
let tx = conn.transaction()?;
for bucket in &index.index {
for artist in &bucket.artist {
let name_sort = sort_key_for_display_name(&artist.name, ignored);
upsert_artist_row(
&tx,
server_id,
&artist.id,
&artist.name,
&name_sort,
artist.album_count,
synced_at,
)?;
count += 1;
}
}
tx.commit()?;
Ok(())
})?;
Ok(count)
}
/// Materialize missing `artist` rows from synced tracks (pre-pass backfill).
pub fn backfill_from_tracks(
&self,
server_id: &str,
ignored_articles: &str,
synced_at: i64,
) -> Result<u32, String> {
let rows: Vec<(String, String)> = self
.store
.with_read_conn(|conn| {
let mut stmt = conn.prepare(
"SELECT artist_id, MAX(artist) \
FROM track \
WHERE server_id = ?1 AND deleted = 0 \
AND artist_id IS NOT NULL AND artist_id != '' \
AND artist IS NOT NULL AND artist != '' \
AND NOT EXISTS ( \
SELECT 1 FROM artist ar \
WHERE ar.server_id = track.server_id AND ar.id = track.artist_id \
) \
GROUP BY artist_id",
)?;
let collected = stmt
.query_map(params![server_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(collected)
})
.map_err(|e| e.to_string())?;
if rows.is_empty() {
return Ok(0);
}
let mut count = 0u32;
self.store.with_conn_mut("artist.backfill_from_tracks", |conn| {
let tx = conn.transaction()?;
for (id, name) in &rows {
let name_sort = sort_key_for_display_name(name, ignored_articles);
upsert_artist_row(&tx, server_id, id, name, &name_sort, None, synced_at)?;
count += 1;
}
tx.commit()?;
Ok(())
})?;
Ok(count)
}
/// One-time repair: fill `name_sort` where null (upgrade path).
pub fn backfill_null_name_sort(&self, ignored_articles: &str) -> Result<u32, String> {
let rows: Vec<(String, String, String)> = self
.store
.with_read_conn(|conn| {
let mut stmt =
conn.prepare("SELECT server_id, id, name FROM artist WHERE name_sort IS NULL")?;
let collected = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(collected)
})
.map_err(|e| e.to_string())?;
if rows.is_empty() {
return Ok(0);
}
let mut count = 0u32;
self.store.with_conn_mut("artist.backfill_null_name_sort", |conn| {
let tx = conn.transaction()?;
for (server_id, id, name) in &rows {
let name_sort = sort_key_for_display_name(name, ignored_articles);
tx.execute(
"UPDATE artist SET name_sort = ?1 WHERE server_id = ?2 AND id = ?3",
params![name_sort, server_id, id],
)?;
count += 1;
}
tx.commit()?;
Ok(())
})?;
Ok(count)
}
}
fn upsert_artist_row(
tx: &Transaction<'_>,
server_id: &str,
id: &str,
name: &str,
name_sort: &str,
album_count: Option<i64>,
synced_at: i64,
) -> rusqlite::Result<()> {
tx.execute(
"INSERT INTO artist (server_id, id, name, name_sort, album_count, synced_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6) \
ON CONFLICT(server_id, id) DO UPDATE SET \
name = excluded.name, \
name_sort = excluded.name_sort, \
album_count = COALESCE(excluded.album_count, artist.album_count), \
synced_at = excluded.synced_at",
params![server_id, id, name, name_sort, album_count, synced_at],
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::LibraryStore;
use psysonic_integration::subsonic::{ArtistIndex, ArtistRef, IndexBucket};
#[test]
fn upsert_index_stores_name_sort_for_the_beatles() {
let store = LibraryStore::open_in_memory();
let repo = ArtistRepository::new(&store);
let index = ArtistIndex {
last_modified_ms: Some(1),
ignored_articles: Some("The".into()),
index: vec![IndexBucket {
name: "B".into(),
artist: vec![ArtistRef {
id: "ar_1".into(),
name: "The Beatles".into(),
album_count: Some(3),
cover_art: None,
}],
}],
};
repo.upsert_index("s1", &index, 1000).unwrap();
let name_sort: String = store
.with_conn("misc", |c| {
c.query_row(
"SELECT name_sort FROM artist WHERE server_id = 's1' AND id = 'ar_1'",
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(name_sort, "beatles");
}
}
@@ -1,3 +1,4 @@
pub mod artist;
pub mod artifact;
pub mod fact;
pub mod play_session;
@@ -5,6 +6,7 @@ pub mod sync_state;
pub mod track;
pub mod track_id_history;
pub use artist::ArtistRepository;
pub use artifact::ArtifactRepository;
pub use fact::FactRepository;
pub use play_session::PlaySessionRepository;
@@ -561,6 +561,44 @@ impl<'a> SyncStateRepository<'a> {
})
}
/// Read `ignored_articles` from the last `getArtists` pass (Navidrome
/// `IgnoredArticles` string — space-separated article tokens).
pub fn get_ignored_articles(
&self,
server_id: &str,
library_scope: &str,
) -> Result<Option<String>, String> {
self.read(|conn| {
conn.query_row(
"SELECT ignored_articles FROM sync_state \
WHERE server_id = ?1 AND library_scope = ?2",
params![server_id, library_scope],
|row| row.get::<_, Option<String>>(0),
)
.optional()
})
.map(|opt| opt.flatten())
}
/// Persist server `ignoredArticles` for local artist sort-key computation.
pub fn set_ignored_articles(
&self,
server_id: &str,
library_scope: &str,
ignored_articles: &str,
) -> Result<(), String> {
self.store.with_conn("sync_state.set_ignored_articles", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, ignored_articles) \
VALUES (?1, ?2, ?3) \
ON CONFLICT(server_id, library_scope) DO UPDATE SET \
ignored_articles = excluded.ignored_articles",
params![server_id, library_scope, ignored_articles],
)?;
Ok(())
})
}
/// Write `library_tier` (spec §6.2.2 — `small` / `medium` / `huge`
/// / `unknown`). Drives the adaptive poll interval; PR-3d wires
/// the EWMA loop that picks this.
@@ -448,7 +448,10 @@ impl<'a> TrackRepository<'a> {
let mut remapped: Vec<RemapEntry> = Vec::new();
let mut upsert = tx.prepare_cached(UPSERT_SQL)?;
let mut remap_lookup = if unstable_track_ids {
Some(tx.prepare_cached(REMAP_LOOKUP_SQL)?)
Some((
tx.prepare_cached(REMAP_LOOKUP_BY_HASH_SQL)?,
tx.prepare_cached(REMAP_LOOKUP_BY_PATH_SQL)?,
))
} else {
None
};
@@ -459,11 +462,12 @@ impl<'a> TrackRepository<'a> {
// then do we retarget children to the new id, since
// child tables FK→track(server_id, id) and would refuse
// an UPDATE pointing at an id that doesn't exist yet.
let detected_old: Option<String> = if let Some(ref mut lookup) = remap_lookup {
detect_remap_target_cached(lookup, r)?
} else {
None
};
let detected_old: Option<String> =
if let Some((ref mut by_hash, ref mut by_path)) = remap_lookup {
detect_remap_target_cached(by_hash, by_path, r)?
} else {
None
};
upsert.execute(params![
r.server_id,
@@ -543,38 +547,76 @@ impl<'a> TrackRepository<'a> {
}
}
const REMAP_LOOKUP_SQL: &str = r#"
// Two single-column lookups instead of one `OR` across `content_hash`
// and `server_path`. The combined `OR` form could not use the partial
// `idx_track_remap_hash` / `idx_track_remap_path` indexes — SQLite only
// applies a partial index when the query's WHERE provably implies the
// index predicate (`… != ''`), and an `OR` spanning two columns blocks
// the per-branch index plan. The result was a full `track` scan per
// incoming row → O(rows × catalog) on large libraries (observed:
// `upsert_batch_remap exec_ms=162001` on a ~200k-track Navidrome sync).
// Each statement below repeats the index predicate so the planner picks
// the matching partial index (SEARCH, not SCAN); hash wins over path,
// matching §6.9's strong-key priority.
const REMAP_LOOKUP_BY_HASH_SQL: &str = r#"
SELECT id FROM track
WHERE server_id = ?1
AND deleted = 0
AND id != ?2
AND (
(?3 IS NOT NULL AND content_hash = ?3)
OR (?4 IS NOT NULL AND server_path = ?4)
)
AND content_hash IS NOT NULL
AND content_hash != ''
AND content_hash = ?2
AND id != ?3
LIMIT 1
"#;
const REMAP_LOOKUP_BY_PATH_SQL: &str = r#"
SELECT id FROM track
WHERE server_id = ?1
AND deleted = 0
AND server_path IS NOT NULL
AND server_path != ''
AND server_path = ?2
AND id != ?3
LIMIT 1
"#;
/// Run the `SELECT old.id` half of §6.9 — returns `Some(old_id)` if a
/// non-deleted row with a different id on this server matches the
/// incoming row's `content_hash` or `server_path`.
/// incoming row's `content_hash` or `server_path`. Hash is the stronger
/// key, so it is checked first.
fn detect_remap_target_cached(
lookup: &mut rusqlite::Statement<'_>,
by_hash: &mut rusqlite::Statement<'_>,
by_path: &mut rusqlite::Statement<'_>,
incoming: &TrackRow,
) -> rusqlite::Result<Option<String>> {
// Empty-string sentinels are *not* eligible — spec §6.9 explicitly
// excludes them so the file-tree default never collides.
let hash = incoming.content_hash.as_deref().filter(|s| !s.is_empty());
let path = incoming.server_path.as_deref().filter(|s| !s.is_empty());
if hash.is_none() && path.is_none() {
return Ok(None);
if let Some(hash) = hash {
let old = by_hash
.query_row(params![incoming.server_id, hash, incoming.id], |row| {
row.get::<_, String>(0)
})
.optional()?;
if old.is_some() {
return Ok(old);
}
}
lookup
.query_row(
params![incoming.server_id, incoming.id, hash, path],
|row| row.get::<_, String>(0),
)
.optional()
if let Some(path) = path {
let old = by_path
.query_row(params![incoming.server_id, path, incoming.id], |row| {
row.get::<_, String>(0)
})
.optional()?;
if old.is_some() {
return Ok(old);
}
}
Ok(None)
}
/// Run the §6.9 retarget half — UPDATE every FK-bound child to the
@@ -1247,6 +1289,48 @@ mod tests {
assert_eq!(count, 2, "both rows kept; identity-less rows can't shadow");
}
#[test]
fn remap_lookup_uses_partial_indexes_not_full_scan() {
// Regression: the §6.9 remap lookup must hit
// idx_track_remap_hash / idx_track_remap_path. The prior
// `OR`-based query fell back to a full `track` scan on every
// incoming row → O(rows × catalog) stalls on large libraries
// (`upsert_batch_remap exec_ms=162001` on a ~200k Navidrome sync).
let store = LibraryStore::open_in_memory();
let plan = |sql: &str| -> String {
store
.with_conn("misc", |c| {
let mut stmt = c.prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
let rows: rusqlite::Result<Vec<String>> = stmt
.query_map(params!["s1", "v", "id"], |r| r.get::<_, String>(3))?
.collect();
rows
})
.unwrap()
.join("\n")
};
let hash_plan = plan(REMAP_LOOKUP_BY_HASH_SQL);
assert!(
hash_plan.contains("idx_track_remap_hash"),
"hash lookup must use idx_track_remap_hash, got: {hash_plan}"
);
assert!(
!hash_plan.contains("SCAN"),
"hash lookup must not full-scan track, got: {hash_plan}"
);
let path_plan = plan(REMAP_LOOKUP_BY_PATH_SQL);
assert!(
path_plan.contains("idx_track_remap_path"),
"path lookup must use idx_track_remap_path, got: {path_plan}"
);
assert!(
!path_plan.contains("SCAN"),
"path lookup must not full-scan track, got: {path_plan}"
);
}
#[test]
fn remap_is_noop_when_new_id_matches_existing_id() {
// Standard delta-sync: same id, same hash. Must not trigger
+486 -59
View File
@@ -4,12 +4,18 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::Duration;
use rusqlite::{params, Connection, OpenFlags};
use rusqlite::{params, Connection, OpenFlags, OptionalExtension};
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 = 12;
///
/// Migration checklist (wiring, data backfill, open/swap path):
/// psysonic-workdocs `ai/agent-rules/08-library-db-migrations.md`.
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 14;
/// One-time data repair after migration 014 (`artist.name_sort`).
pub(crate) const ARTIST_NAME_SORT_RECONCILE_ID: &str = "artist_name_sort_reconcile_v1";
/// Lowest applied schema version the current code can advance from purely
/// additively. If a DB carries a version below this, the breaking-bump hook
@@ -26,10 +32,21 @@ pub(crate) const INITIAL_SQL: &str = include_str!("../migrations/001_initial.sql
/// 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");
/// Version 13: additive `artist_artwork_lookup` table for external artist
/// artwork (fanart.tv) — image-scraper §12. Pure CREATE TABLE IF NOT EXISTS.
pub(crate) const MIGRATION_013_ARTIST_ARTWORK_LOOKUP: &str =
include_str!("../migrations/013_artist_artwork_lookup.sql");
pub(crate) const MIGRATION_014_ARTIST_NAME_SORT: &str =
include_str!("../migrations/014_artist_name_sort.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), (12, MIGRATION_012_TRACK_GENRE_LEGACY)];
const MIGRATIONS: &[(i64, &str)] = &[
(1, INITIAL_SQL),
(12, MIGRATION_012_TRACK_GENRE_LEGACY),
(13, MIGRATION_013_ARTIST_ARTWORK_LOOKUP),
(14, MIGRATION_014_ARTIST_NAME_SORT),
];
/// Idempotent repair — also runs after the migration runner on every open so
/// DBs that recorded the wrong version numbers still get the tables.
@@ -62,6 +79,9 @@ pub struct LibraryStore {
read_conn: Mutex<Connection>,
/// IS-3 bulk ingest in progress — read paths skip write-lock work.
bulk_ingest_active: AtomicBool,
/// `swap_database_file` / `restore_database_backup` — fail fast instead of
/// touching in-memory placeholder connections while the file is offline.
swap_in_progress: AtomicBool,
}
impl LibraryStore {
@@ -75,10 +95,7 @@ impl LibraryStore {
fn open_file(db_path: &Path) -> Result<Self, String> {
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())?;
prepare_write_connection_for_open(&write_conn).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())?;
configure_read_connection(&read_conn).map_err(|e| e.to_string())?;
@@ -86,6 +103,7 @@ impl LibraryStore {
write_conn: Mutex::new(write_conn),
read_conn: Mutex::new(read_conn),
bulk_ingest_active: AtomicBool::new(false),
swap_in_progress: AtomicBool::new(false),
})
}
@@ -94,14 +112,14 @@ impl LibraryStore {
let uri = in_memory_uri();
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");
prepare_write_connection_for_open(&write_conn).expect("schema migration");
let read_conn = Connection::open(&uri).expect("in-memory read connection");
configure_read_connection(&read_conn).expect("read pragmas");
Self {
write_conn: Mutex::new(write_conn),
read_conn: Mutex::new(read_conn),
bulk_ingest_active: AtomicBool::new(false),
swap_in_progress: AtomicBool::new(false),
}
}
@@ -114,6 +132,36 @@ impl LibraryStore {
self.bulk_ingest_active.load(Ordering::Acquire)
}
fn swap_in_progress(&self) -> bool {
self.swap_in_progress.load(Ordering::Acquire)
}
fn lock_write_conn(&self) -> Result<std::sync::MutexGuard<'_, Connection>, String> {
if self.swap_in_progress() {
return Err("library database swap in progress".to_string());
}
match self.write_conn.lock() {
Ok(guard) => Ok(guard),
Err(poisoned) => {
crate::app_eprintln!("[library-db] write lock was poisoned — recovering");
Ok(poisoned.into_inner())
}
}
}
fn lock_read_conn(&self) -> Result<std::sync::MutexGuard<'_, Connection>, String> {
if self.swap_in_progress() {
return Err("library database swap in progress".to_string());
}
match self.read_conn.lock() {
Ok(guard) => Ok(guard),
Err(poisoned) => {
crate::app_eprintln!("[library-db] read lock was poisoned — recovering");
Ok(poisoned.into_inner())
}
}
}
/// Writer connection — sync ingest, migrations, mutations.
///
/// `op` is logged on slow writes (`[library-db] SLOW write op=…`) — use a
@@ -126,13 +174,10 @@ impl LibraryStore {
f: impl FnOnce(&Connection) -> rusqlite::Result<R>,
) -> Result<R, String> {
let lock_start = std::time::Instant::now();
let conn = self
.write_conn
.lock()
.map_err(|_| "library store write lock poisoned".to_string())?;
let conn = self.lock_write_conn()?;
let lock_wait_ms = lock_start.elapsed().as_millis();
let exec_start = std::time::Instant::now();
let out = f(&conn).map_err(|e| e.to_string());
let out = run_conn_closure(&conn, f);
let exec_ms = exec_start.elapsed().as_millis();
log_write_op(op, lock_wait_ms, exec_ms);
out
@@ -143,11 +188,8 @@ impl LibraryStore {
&self,
f: impl FnOnce(&Connection) -> rusqlite::Result<R>,
) -> Result<R, String> {
let conn = self
.read_conn
.lock()
.map_err(|_| "library store read lock poisoned".to_string())?;
f(&conn).map_err(|e| e.to_string())
let conn = self.lock_read_conn()?;
run_conn_closure(&conn, f)
}
pub(crate) fn with_conn_mut<R>(
@@ -164,13 +206,10 @@ impl LibraryStore {
f: impl FnOnce(&mut Connection) -> rusqlite::Result<R>,
) -> Result<(R, WriteOpTiming), String> {
let lock_start = std::time::Instant::now();
let mut conn = self
.write_conn
.lock()
.map_err(|_| "library store write lock poisoned".to_string())?;
let mut conn = self.lock_write_conn()?;
let lock_wait_ms = lock_start.elapsed().as_millis() as u64;
let exec_start = std::time::Instant::now();
let out = f(&mut conn).map_err(|e| e.to_string())?;
let out = run_conn_mut_closure(&mut conn, f)?;
let exec_ms = exec_start.elapsed().as_millis() as u64;
log_write_op(op, lock_wait_ms as u128, exec_ms as u128);
Ok((out, WriteOpTiming { lock_wait_ms, exec_ms }))
@@ -184,8 +223,8 @@ impl LibraryStore {
}
/// Atomically switch the active sqlite file while replacing long-lived
/// write/read connections under the same locks so no command can keep
/// writing to the old inode after the swap.
/// write/read connections. Other threads see `library database swap in
/// progress` while the file is offline instead of touching placeholder DBs.
pub fn swap_database_file(
&self,
active_path: &Path,
@@ -194,14 +233,14 @@ impl LibraryStore {
if !destination_path.exists() {
return Ok(None);
}
let mut write_conn = self
.write_conn
.lock()
.map_err(|_| "library store write lock poisoned".to_string())?;
let mut read_conn = self
.read_conn
.lock()
.map_err(|_| "library store read lock poisoned".to_string())?;
let mut swap_guard = SwapInProgressGuard::new(self);
let mut write_conn = self.write_conn.lock().map_err(|_| {
"library store write lock poisoned during database swap".to_string()
})?;
let mut read_conn = self.read_conn.lock().map_err(|_| {
"library store read lock poisoned during database swap".to_string()
})?;
let write_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?;
let read_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?;
@@ -229,28 +268,68 @@ impl LibraryStore {
let _ = move_sidecar(&backup, active_path, "-wal");
let _ = move_sidecar(&backup, active_path, "-shm");
}
drop(read_conn);
drop(write_conn);
let (reopened_write, reopened_read) = open_database_connections(active_path)
.map_err(|e| format!("library swap reopen failed after rename error: {e}"))?;
let mut write_conn = self.write_conn.lock().map_err(|_| {
"library store write lock poisoned during database swap".to_string()
})?;
let mut read_conn = self.read_conn.lock().map_err(|_| {
"library store read lock poisoned during database swap".to_string()
})?;
*write_conn = reopened_write;
*read_conn = reopened_read;
swap_guard.release();
return Err(err.to_string());
}
let reopened_write = Connection::open(active_path).map_err(|e| e.to_string())?;
configure_write_connection(&reopened_write).map_err(|e| e.to_string())?;
let reopened_read = Connection::open_with_flags(active_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|e| e.to_string())?;
configure_read_connection(&reopened_read).map_err(|e| e.to_string())?;
*write_conn = reopened_write;
*read_conn = reopened_read;
Ok(Some(backup))
drop(read_conn);
drop(write_conn);
let reopen = open_database_connections(active_path);
let mut write_conn = self.write_conn.lock().map_err(|_| {
"library store write lock poisoned during database swap".to_string()
})?;
let mut read_conn = self.read_conn.lock().map_err(|_| {
"library store read lock poisoned during database swap".to_string()
})?;
match reopen {
Ok((reopened_write, reopened_read)) => {
*write_conn = reopened_write;
*read_conn = reopened_read;
swap_guard.release();
Ok(Some(backup))
}
Err(open_err) => {
if backup.exists() {
if active_path.exists() {
remove_db_with_sidecars(active_path).ok();
}
let _ = fs::rename(&backup, active_path);
let _ = move_sidecar(&backup, active_path, "-wal");
let _ = move_sidecar(&backup, active_path, "-shm");
}
let (reopened_write, reopened_read) = open_database_connections(active_path)
.map_err(|e| format!("library swap reopen failed after revert: {e}"))?;
*write_conn = reopened_write;
*read_conn = reopened_read;
swap_guard.release();
Err(format!("library swap failed: {open_err}"))
}
}
}
pub fn restore_database_backup(&self, backup_path: &Path, active_path: &Path) -> Result<(), String> {
let mut write_conn = self
.write_conn
.lock()
.map_err(|_| "library store write lock poisoned".to_string())?;
let mut read_conn = self
.read_conn
.lock()
.map_err(|_| "library store read lock poisoned".to_string())?;
let mut swap_guard = SwapInProgressGuard::new(self);
let mut write_conn = self.write_conn.lock().map_err(|_| {
"library store write lock poisoned during database restore".to_string()
})?;
let mut read_conn = self.read_conn.lock().map_err(|_| {
"library store read lock poisoned during database restore".to_string()
})?;
let write_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?;
let read_tmp = Connection::open_in_memory().map_err(|e| e.to_string())?;
@@ -268,13 +347,21 @@ impl LibraryStore {
move_sidecar(backup_path, active_path, "-shm")?;
}
let reopened_write = Connection::open(active_path).map_err(|e| e.to_string())?;
configure_write_connection(&reopened_write).map_err(|e| e.to_string())?;
let reopened_read = Connection::open_with_flags(active_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|e| e.to_string())?;
configure_read_connection(&reopened_read).map_err(|e| e.to_string())?;
drop(read_conn);
drop(write_conn);
let (reopened_write, reopened_read) =
open_database_connections(active_path).map_err(|e| e.to_string())?;
let mut write_conn = self.write_conn.lock().map_err(|_| {
"library store write lock poisoned during database restore".to_string()
})?;
let mut read_conn = self.read_conn.lock().map_err(|_| {
"library store read lock poisoned during database restore".to_string()
})?;
*write_conn = reopened_write;
*read_conn = reopened_read;
swap_guard.release();
Ok(())
}
}
@@ -302,6 +389,74 @@ fn log_write_op(op: &str, lock_wait_ms: u128, exec_ms: u128) {
}
}
struct SwapInProgressGuard<'a> {
store: &'a LibraryStore,
released: bool,
}
impl<'a> SwapInProgressGuard<'a> {
fn new(store: &'a LibraryStore) -> Self {
store.swap_in_progress.store(true, Ordering::Release);
Self {
store,
released: false,
}
}
fn release(&mut self) {
if !self.released {
self.store.swap_in_progress.store(false, Ordering::Release);
self.released = true;
}
}
}
impl Drop for SwapInProgressGuard<'_> {
fn drop(&mut self) {
self.release();
}
}
fn run_conn_closure<R>(
conn: &Connection,
f: impl FnOnce(&Connection) -> rusqlite::Result<R>,
) -> Result<R, String> {
let out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(conn)));
match out {
Ok(result) => result.map_err(|e| e.to_string()),
Err(payload) => {
let detail = panic_payload_to_string(payload);
crate::app_eprintln!("[library-db] connection query panicked: {detail}");
Err(format!("library connection query panicked: {detail}"))
}
}
}
fn run_conn_mut_closure<R>(
conn: &mut Connection,
f: impl FnOnce(&mut Connection) -> rusqlite::Result<R>,
) -> Result<R, String> {
let out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(conn)));
match out {
Ok(result) => result.map_err(|e| e.to_string()),
Err(payload) => {
let detail = panic_payload_to_string(payload);
crate::app_eprintln!("[library-db] connection mutation panicked: {detail}");
Err(format!("library connection mutation panicked: {detail}"))
}
}
}
fn panic_payload_to_string(payload: Box<dyn std::any::Any + Send>) -> String {
if let Some(msg) = payload.downcast_ref::<&str>() {
msg.to_string()
} else if let Some(msg) = payload.downcast_ref::<String>() {
msg.clone()
} else {
"unknown panic payload".to_string()
}
}
fn library_db_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let base = app.path().app_data_dir().map_err(|e| e.to_string())?;
let db_dir = base.join("databases").join("library");
@@ -421,6 +576,151 @@ fn checkpoint_wal_conn(conn: &Connection, op: &str) -> rusqlite::Result<()> {
Ok(())
}
/// Open write + read handles after migrations, one-time repairs, and WAL checkpoint.
fn open_database_connections(db_path: &Path) -> rusqlite::Result<(Connection, Connection)> {
let write_conn = Connection::open(db_path)?;
configure_write_connection(&write_conn)?;
prepare_write_connection_for_open(&write_conn)?;
let read_conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
configure_read_connection(&read_conn)?;
Ok((write_conn, read_conn))
}
fn prepare_write_connection_for_open(conn: &Connection) -> rusqlite::Result<()> {
run_migrations(conn)?;
maybe_reconcile_artist_name_sort(conn)?;
ensure_genre_tags_schema(conn)?;
checkpoint_wal_conn(conn, "open")?;
Ok(())
}
fn artist_name_sort_column_exists(conn: &Connection) -> rusqlite::Result<bool> {
let column_exists: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('artist') WHERE name = 'name_sort'",
[],
|row| row.get(0),
)
.unwrap_or(0);
Ok(column_exists > 0)
}
fn sync_state_ignored_articles_column_exists(conn: &Connection) -> rusqlite::Result<bool> {
let column_exists: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('sync_state') WHERE name = 'ignored_articles'",
[],
|row| row.get(0),
)
.unwrap_or(0);
Ok(column_exists > 0)
}
/// Apply schema 014 idempotently — mirrors `migrations/014_artist_name_sort.sql`
/// but tolerates a partial prior apply (missing one column / re-run).
fn apply_migration_14(conn: &Connection) -> rusqlite::Result<()> {
if !artist_name_sort_column_exists(conn)? {
conn.execute_batch("ALTER TABLE artist ADD COLUMN name_sort TEXT;")?;
}
if !sync_state_ignored_articles_column_exists(conn)? {
conn.execute_batch("ALTER TABLE sync_state ADD COLUMN ignored_articles TEXT;")?;
}
conn.execute_batch(
"CREATE INDEX IF NOT EXISTS idx_artist_name_sort ON artist(server_id, name_sort);",
)?;
finish_migration_14_reconcile(conn)?;
Ok(())
}
fn record_schema_migration(conn: &Connection, version: i64) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO schema_migrations (version, applied_at) VALUES (?1, strftime('%s','now'))",
params![version],
)?;
Ok(())
}
fn finish_migration_14_reconcile(conn: &Connection) -> rusqlite::Result<()> {
if !artist_name_sort_reconcile_completed(conn)? {
repair_artist_name_sort_keys(conn)?;
mark_artist_name_sort_reconcile_completed(conn)?;
}
Ok(())
}
fn artist_name_sort_reconcile_completed(conn: &Connection) -> rusqlite::Result<bool> {
let completed: Option<Option<i64>> = conn
.query_row(
"SELECT completed_at FROM library_data_migration WHERE id = ?1",
params![ARTIST_NAME_SORT_RECONCILE_ID],
|row| row.get(0),
)
.optional()?;
Ok(completed.flatten().is_some())
}
fn mark_artist_name_sort_reconcile_completed(conn: &Connection) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO library_data_migration (id, cursor_rowid, started_at, completed_at) \
VALUES (?1, 0, strftime('%s','now'), strftime('%s','now')) \
ON CONFLICT(id) DO UPDATE SET completed_at = excluded.completed_at",
params![ARTIST_NAME_SORT_RECONCILE_ID],
)?;
Ok(())
}
/// One-time reconcile after schema 014 — not on every open (avoids long write locks at startup).
fn maybe_reconcile_artist_name_sort(conn: &Connection) -> rusqlite::Result<()> {
if !artist_name_sort_column_exists(conn)? {
return Ok(());
}
if artist_name_sort_reconcile_completed(conn)? {
return Ok(());
}
repair_artist_name_sort_keys(conn)?;
mark_artist_name_sort_reconcile_completed(conn)?;
Ok(())
}
/// Reconcile `artist.name_sort` with display `name` (upgrade / stale rows).
fn repair_artist_name_sort_keys(conn: &Connection) -> rusqlite::Result<()> {
let table_exists: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'artist'",
[],
|row| row.get(0),
)
.unwrap_or(0);
if table_exists == 0 {
return Ok(());
}
if !artist_name_sort_column_exists(conn)? {
return Ok(());
}
let ignored = crate::artist_sort::DEFAULT_IGNORED_ARTICLES;
let tx = conn.unchecked_transaction()?;
{
let mut stmt = tx.prepare("SELECT server_id, id, name, name_sort FROM artist")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let server_id: String = row.get(0)?;
let id: String = row.get(1)?;
let name: String = row.get(2)?;
let current: Option<String> = row.get(3)?;
let expected = crate::artist_sort::sort_key_for_display_name(&name, ignored);
if current.as_deref() == Some(&expected) {
continue;
}
tx.execute(
"UPDATE artist SET name_sort = ?1 WHERE server_id = ?2 AND id = ?3",
rusqlite::params![expected, server_id, id],
)?;
}
}
tx.commit()?;
Ok(())
}
fn run_migrations(conn: &Connection) -> rusqlite::Result<MigrationOutcome> {
run_migrations_with(
conn,
@@ -469,11 +769,17 @@ pub(crate) fn run_migrations_with(
if already > 0 {
continue;
}
if version == 14 {
// Applied idempotently (per-column ADD + IF NOT EXISTS index) so a
// partial DDL apply — one ALTER landed before a crash, no
// schema_migrations row — recovers instead of failing on a
// duplicate-column re-run of the batch.
apply_migration_14(conn)?;
record_schema_migration(conn, version)?;
continue;
}
conn.execute_batch(sql)?;
conn.execute(
"INSERT INTO schema_migrations (version, applied_at) VALUES (?1, strftime('%s','now'))",
params![version],
)?;
record_schema_migration(conn, version)?;
}
Ok(MigrationOutcome::Applied)
}
@@ -774,4 +1080,125 @@ mod tests {
.unwrap();
assert_eq!(outcome, MigrationOutcome::Applied);
}
#[test]
fn artist_name_sort_reconcile_runs_once_and_sets_name_sort() {
let store = LibraryStore::open_in_memory();
store
.with_conn_mut("test.seed_artist", |conn| {
conn.execute(
"INSERT INTO artist (server_id, id, name, name_sort, synced_at) \
VALUES ('s1', 'ar1', 'The Beatles', 'the beatles', 1)",
[],
)?;
conn.execute(
"DELETE FROM library_data_migration WHERE id = ?1",
params![ARTIST_NAME_SORT_RECONCILE_ID],
)?;
Ok(())
})
.expect("seed artist");
store
.with_conn("test.reconcile", maybe_reconcile_artist_name_sort)
.expect("reconcile");
let name_sort: String = store
.with_read_conn(|conn| {
conn.query_row(
"SELECT name_sort FROM artist WHERE server_id = 's1' AND id = 'ar1'",
[],
|r| r.get(0),
)
})
.expect("read name_sort");
assert_eq!(name_sort, "beatles");
let completed_before: i64 = store
.with_read_conn(|conn| {
conn.query_row(
"SELECT completed_at FROM library_data_migration WHERE id = ?1",
params![ARTIST_NAME_SORT_RECONCILE_ID],
|r| r.get(0),
)
})
.expect("reconcile marker");
assert!(completed_before > 0);
store
.with_conn("test.reconcile_again", maybe_reconcile_artist_name_sort)
.expect("reconcile again");
let name_sort_after: String = store
.with_read_conn(|conn| {
conn.query_row(
"SELECT name_sort FROM artist WHERE server_id = 's1' AND id = 'ar1'",
[],
|r| r.get(0),
)
})
.expect("read name_sort again");
assert_eq!(name_sort_after, "beatles");
}
#[test]
fn migration_14_recovers_partial_schema_without_schema_migrations_row() {
let uri = in_memory_uri();
let conn = Connection::open(&uri).expect("connection");
configure_write_connection(&conn).expect("pragmas");
let migrations_through_13: &[(i64, &str)] = &[
(1, INITIAL_SQL),
(12, MIGRATION_012_TRACK_GENRE_LEGACY),
(13, MIGRATION_013_ARTIST_ARTWORK_LOOKUP),
];
run_migrations_with(
&conn,
migrations_through_13,
LIBRARY_DB_MIN_COMPATIBLE_VERSION,
no_op_hook,
)
.expect("migrate through v13");
conn.execute_batch(MIGRATION_014_ARTIST_NAME_SORT)
.expect("apply ddl only");
let recorded: i64 = conn
.query_row(
"SELECT COUNT(*) FROM schema_migrations WHERE version = 14",
[],
|r| r.get(0),
)
.expect("count migration");
assert_eq!(recorded, 0);
run_migrations_with(
&conn,
MIGRATIONS,
LIBRARY_DB_MIN_COMPATIBLE_VERSION,
no_op_hook,
)
.expect("recover partial migration");
let recorded_after: i64 = conn
.query_row(
"SELECT COUNT(*) FROM schema_migrations WHERE version = 14",
[],
|r| r.get(0),
)
.expect("count migration after");
assert_eq!(recorded_after, 1);
}
#[test]
fn read_conn_recovers_after_closure_panic() {
let store = LibraryStore::open_in_memory();
let first: Result<i64, String> = store.with_read_conn(|_conn| {
panic!("simulated read panic");
});
assert!(first.is_err());
let ok: i64 = store
.with_read_conn(|conn| conn.query_row("SELECT 1", [], |r| r.get(0)))
.expect("read after panic recovery");
assert_eq!(ok, 1);
}
}
@@ -0,0 +1,32 @@
//! Persist Subsonic `getArtists` / `getIndexes` bodies into the local `artist` table.
use crate::repos::{ArtistRepository, SyncStateRepository};
use crate::store::LibraryStore;
use psysonic_integration::subsonic::ArtistIndex;
use super::error::SyncError;
pub fn apply_artist_index(
store: &LibraryStore,
server_id: &str,
library_scope: &str,
index: &ArtistIndex,
) -> Result<(), SyncError> {
let synced_at = super::now_unix_ms();
let ignored = crate::artist_sort::ignored_articles_or_default(
index.ignored_articles.as_deref(),
);
let sync_state = SyncStateRepository::new(store);
sync_state
.set_ignored_articles(server_id, library_scope, ignored)
.map_err(SyncError::Storage)?;
let repo = ArtistRepository::new(store);
repo.upsert_index(server_id, index, synced_at).map_err(SyncError::Storage)?;
repo.backfill_from_tracks(server_id, ignored, synced_at).map_err(SyncError::Storage)?;
if let Some(ms) = index.last_modified_ms {
sync_state
.set_artists_last_modified_ms(server_id, library_scope, ms)
.map_err(SyncError::Storage)?;
}
Ok(())
}
@@ -91,6 +91,7 @@ pub async fn probe_and_persist(
store: &crate::store::LibraryStore,
subsonic: &psysonic_integration::subsonic::SubsonicClient,
navidrome: Option<&NavidromeProbeCredentials>,
http_registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_id: &str,
library_scope: &str,
) -> Result<CapabilityProbeResult, psysonic_integration::subsonic::SubsonicError> {
@@ -110,7 +111,7 @@ pub async fn probe_and_persist(
.map_err(psysonic_integration::subsonic::SubsonicError::Transport)?
.unwrap_or(0);
let mut result = CapabilityProbe::run(subsonic, navidrome).await?;
let mut result = CapabilityProbe::run(subsonic, navidrome, http_registry, Some(server_id)).await?;
// R7-15 Q3: a probe run without a Navidrome bearer can't test N1, so it
// must not drop a previously-learned NavidromeNativeBulk capability — the
@@ -173,6 +174,8 @@ impl CapabilityProbe {
pub async fn run(
subsonic: &SubsonicClient,
navidrome: Option<&NavidromeProbeCredentials>,
http_registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_id: Option<&str>,
) -> Result<CapabilityProbeResult, SubsonicError> {
let server_info = subsonic.server_info().await?;
@@ -207,7 +210,14 @@ impl CapabilityProbe {
}
if let Some(creds) = navidrome {
match native_bulk_available(&creds.server_url, &creds.bearer_token).await {
match native_bulk_available(
http_registry,
server_id,
&creds.server_url,
&creds.bearer_token,
)
.await
{
Ok(true) => flags.insert(CapabilityFlags::NAVIDROME_NATIVE_BULK),
Ok(false) => {}
Err(_) => {
@@ -345,7 +355,7 @@ mod tests {
let server = MockServer::start().await;
mount_subsonic_full_navidrome(&server).await;
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None)
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None, None, None)
.await
.unwrap();
assert!(result.flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK));
@@ -372,7 +382,7 @@ mod tests {
.mount(&server)
.await;
let err = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None)
let err = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None, None, None)
.await
.unwrap_err();
assert!(matches!(err, SubsonicError::Api { code: 40, .. }));
@@ -415,7 +425,7 @@ mod tests {
.mount(&server)
.await;
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None)
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), None, None, None)
.await
.unwrap();
assert!(result.flags.contains(CapabilityFlags::SUBSONIC_SEARCH3_BULK));
@@ -440,7 +450,7 @@ mod tests {
server_url: server.uri(),
bearer_token: "nd-tok".into(),
};
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav))
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav), None, None)
.await
.unwrap();
assert!(result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK));
@@ -461,6 +471,7 @@ mod tests {
&store,
&test_subsonic_client(&server.uri()),
None,
None,
"s1",
"",
)
@@ -498,6 +509,7 @@ mod tests {
&store,
&test_subsonic_client(&server.uri()),
None,
None,
"s1",
"",
)
@@ -530,6 +542,7 @@ mod tests {
&store,
&test_subsonic_client(&server.uri()),
None,
None,
"s1",
"",
)
@@ -584,7 +597,7 @@ mod tests {
let store = LibraryStore::open_in_memory();
let result =
super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, "s1", "")
super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, None, "s1", "")
.await
.unwrap();
assert_eq!(result.server_track_count, Some(170_000));
@@ -617,6 +630,7 @@ mod tests {
&store,
&test_subsonic_client(&server.uri()),
None,
None,
"s1",
"",
)
@@ -647,7 +661,7 @@ mod tests {
mount_subsonic_full_navidrome(&server).await; // scanStatus has no count
let result =
super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, "s1", "")
super::probe_and_persist(&store, &test_subsonic_client(&server.uri()), None, None, "s1", "")
.await
.unwrap();
assert_eq!(result.server_track_count, None);
@@ -672,7 +686,7 @@ mod tests {
server_url: server.uri(),
bearer_token: "nd-tok".into(),
};
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav))
let result = CapabilityProbe::run(&test_subsonic_client(&server.uri()), Some(&nav), None, None)
.await
.unwrap();
assert!(!result.flags.contains(CapabilityFlags::NAVIDROME_NATIVE_BULK));
@@ -20,6 +20,7 @@ use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Duration;
use psysonic_core::server_http::ServerHttpRegistry;
use psysonic_integration::navidrome::queries::nd_list_songs_internal;
use psysonic_integration::subsonic::SubsonicClient;
use serde_json::Value;
@@ -71,6 +72,7 @@ pub struct DeltaSyncRunner<'a> {
store: &'a LibraryStore,
subsonic: &'a SubsonicClient,
navidrome: Option<NavidromeProbeCredentials>,
http_registry: Option<Arc<ServerHttpRegistry>>,
server_id: String,
library_scope: String,
capability_flags: CapabilityFlags,
@@ -95,6 +97,7 @@ impl<'a> DeltaSyncRunner<'a> {
store,
subsonic,
navidrome: None,
http_registry: None,
server_id: server_id.into(),
library_scope: library_scope.into(),
capability_flags,
@@ -111,6 +114,11 @@ impl<'a> DeltaSyncRunner<'a> {
self
}
pub fn with_http_registry(mut self, registry: Option<Arc<ServerHttpRegistry>>) -> Self {
self.http_registry = registry;
self
}
pub fn with_cancellation(mut self, flag: Arc<std::sync::atomic::AtomicBool>) -> Self {
self.cancel = Some(flag);
self
@@ -204,8 +212,20 @@ impl<'a> DeltaSyncRunner<'a> {
}
}
// DS-9 — stamp watermarks.
// DS-9 — stamp watermarks + refresh artist browse index when applicable.
if let Some(ms) = probe.next_artists_watermark {
let scope = self.library_scope_opt();
if let Ok(index) = self.subsonic.get_artists(scope).await {
super::artist_index::apply_artist_index(
self.store,
&self.server_id,
&self.library_scope,
&index,
)?;
}
// Advance the watermark to the probed value regardless of the index
// refresh result — a failed/empty `getArtists` must not force a full
// refetch on every delta. Wins over the index's own last-modified.
sync_state
.set_artists_last_modified_ms(&self.server_id, &self.library_scope, ms)
.map_err(SyncError::Storage)?;
@@ -384,6 +404,8 @@ impl<'a> DeltaSyncRunner<'a> {
self,
|| {
nd_list_songs_internal(
self.http_registry.as_deref(),
Some(&self.server_id),
&creds.server_url,
&creds.bearer_token,
"updated_at",
@@ -529,13 +551,7 @@ struct DeltaPollOutcome {
next_artists_watermark: Option<i64>,
}
fn now_unix_ms() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis().min(i64::MAX as u128) as i64)
.unwrap_or(0)
}
use super::now_unix_ms;
async fn retry_with_backoff<'a, F, FFut, T, E>(
runner: &DeltaSyncRunner<'a>,
@@ -12,6 +12,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use psysonic_core::server_http::ServerHttpRegistry;
use psysonic_integration::navidrome::queries::nd_list_songs_internal;
use psysonic_integration::subsonic::SubsonicClient;
use serde_json::Value;
@@ -109,6 +110,7 @@ pub struct InitialSyncRunner<'a> {
store: &'a LibraryStore,
subsonic: &'a SubsonicClient,
navidrome: Option<NavidromeProbeCredentials>,
http_registry: Option<Arc<ServerHttpRegistry>>,
server_id: String,
library_scope: String,
capability_flags: CapabilityFlags,
@@ -132,6 +134,7 @@ impl<'a> InitialSyncRunner<'a> {
store,
subsonic,
navidrome: None,
http_registry: None,
server_id: server_id.into(),
library_scope: library_scope.into(),
capability_flags,
@@ -154,6 +157,11 @@ impl<'a> InitialSyncRunner<'a> {
self
}
pub fn with_http_registry(mut self, registry: Option<Arc<ServerHttpRegistry>>) -> Self {
self.http_registry = registry;
self
}
pub fn with_cancellation(mut self, flag: Arc<AtomicBool>) -> Self {
self.cancel = Some(flag);
self
@@ -580,6 +588,8 @@ impl<'a> InitialSyncRunner<'a> {
let cancel = self.cancel.clone();
let sleep_enabled = self.sleep_enabled;
let creds = creds.clone();
let http_registry = self.http_registry.clone();
let server_id = self.server_id.clone();
let mut queue = LinearPrefetchQueue::new(&budget, batch_size, offset);
loop {
@@ -590,6 +600,8 @@ impl<'a> InitialSyncRunner<'a> {
queue.pump(|| self.check_cancellation(), |off| {
let creds = creds.clone();
let cancel = cancel.clone();
let http_registry = http_registry.clone();
let server_id = server_id.clone();
tokio::spawn(async move {
retry_fetch(
sleep_enabled,
@@ -597,6 +609,8 @@ impl<'a> InitialSyncRunner<'a> {
|| async {
let end = off.saturating_add(batch_size);
let response = nd_list_songs_internal(
http_registry.as_deref(),
Some(&server_id),
&creds.server_url,
&creds.bearer_token,
"id",
@@ -671,6 +685,8 @@ impl<'a> InitialSyncRunner<'a> {
self,
|| {
nd_list_songs_internal(
self.http_registry.as_deref(),
Some(&self.server_id),
&creds.server_url,
&creds.bearer_token,
"id",
@@ -1179,7 +1195,7 @@ impl<'a> InitialSyncRunner<'a> {
async fn run_artist_pass(
&self,
sync_state: &SyncStateRepository<'_>,
_sync_state: &SyncStateRepository<'_>,
) -> Result<(), SyncError> {
let scope = self.library_scope_opt();
let artists = retry_with_backoff(
@@ -1190,11 +1206,12 @@ impl<'a> InitialSyncRunner<'a> {
.await
.ok();
if let Some(index) = artists {
if let Some(ms) = index.last_modified_ms {
sync_state
.set_artists_last_modified_ms(&self.server_id, &self.library_scope, ms)
.map_err(SyncError::Storage)?;
}
super::artist_index::apply_artist_index(
self.store,
&self.server_id,
&self.library_scope,
&index,
)?;
}
Ok(())
}
@@ -1227,13 +1244,7 @@ fn is_empty_cursor(v: &Value) -> bool {
matches!(v, Value::Object(o) if o.is_empty())
}
fn now_unix_ms() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis().min(i64::MAX as u128) as i64)
.unwrap_or(0)
}
use super::now_unix_ms;
/// Wrap an async closure in §6.8 backoff. Retries on `SyncError::Transport`
/// up to `MAX_ATTEMPTS_PER_BATCH`, sleeping per the backoff schedule
@@ -6,6 +6,7 @@
//! `DeltaSyncRunner` / background scheduler / Tauri surface follow in
//! PR-3c / PR-3d / PR-5.
pub mod artist_index;
pub mod backoff;
pub mod bandwidth;
pub mod budget;
@@ -38,3 +39,12 @@ pub use scheduler::{BackgroundScheduler, SchedulerTickReport, DEFAULT_TOMBSTONE_
pub use strategy::IngestStrategy;
pub use supervisor::SyncSupervisor;
pub use tombstone::{should_auto_reconcile, TombstoneReconciler, TombstoneReport};
/// Wall-clock milliseconds since the Unix epoch, saturating to `i64::MAX`.
pub(crate) fn now_unix_ms() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis().min(i64::MAX as u128) as i64)
.unwrap_or(0)
}
@@ -11,6 +11,7 @@
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use psysonic_core::server_http::ServerHttpRegistry;
use psysonic_integration::subsonic::SubsonicClient;
use super::bandwidth::{ParallelismBudget, PlaybackHint};
@@ -45,6 +46,7 @@ pub struct BackgroundScheduler<'a> {
store: &'a LibraryStore,
subsonic: &'a SubsonicClient,
navidrome: Option<NavidromeProbeCredentials>,
http_registry: Option<Arc<ServerHttpRegistry>>,
server_id: String,
library_scope: String,
capability_flags: CapabilityFlags,
@@ -70,6 +72,7 @@ impl<'a> BackgroundScheduler<'a> {
store,
subsonic,
navidrome: None,
http_registry: None,
server_id: server_id.into(),
library_scope: library_scope.into(),
capability_flags,
@@ -87,6 +90,11 @@ impl<'a> BackgroundScheduler<'a> {
self
}
pub fn with_http_registry(mut self, registry: Option<Arc<ServerHttpRegistry>>) -> Self {
self.http_registry = registry;
self
}
pub fn with_playback_hint(mut self, hint: PlaybackHint) -> Self {
self.playback_hint = hint;
self
@@ -220,7 +228,8 @@ impl<'a> BackgroundScheduler<'a> {
&self.library_scope,
self.capability_flags,
)
.with_progress(Arc::clone(&self.progress));
.with_progress(Arc::clone(&self.progress))
.with_http_registry(self.http_registry.clone());
if let Some(creds) = &self.navidrome {
runner = runner.with_navidrome_credentials(creds.clone());
}
+14 -1
View File
@@ -2,6 +2,8 @@ use tauri::{Emitter, Manager};
use psysonic_core::user_agent::subsonic_wire_user_agent;
use crate::file_transfer::apply_server_http_get;
pub fn resolve_hot_cache_root(
custom_dir: Option<String>,
app: &tauri::AppHandle,
@@ -340,7 +342,18 @@ pub async fn download_zip(
.build()
.map_err(|e| e.to_string())?;
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
let http_registry = app
.try_state::<std::sync::Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| std::sync::Arc::clone(&*s));
let response = apply_server_http_get(
&client,
http_registry.as_deref(),
None,
&url,
)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
+12 -2
View File
@@ -1,8 +1,11 @@
use std::sync::Arc;
use psysonic_analysis::analysis_runtime::{enqueue_track_analysis, AnalysisBackfillPriority};
use psysonic_audio as audio;
use psysonic_core::user_agent::subsonic_wire_user_agent;
use tauri::Manager;
use crate::file_transfer::stream_to_file;
use crate::file_transfer::{apply_server_http_get, stream_to_file};
use super::downloads::{resolve_hot_cache_root, HotCacheDownloadResult};
use super::offline::enqueue_analysis_seed_from_file;
@@ -70,7 +73,14 @@ pub async fn download_track_hot_cache(
.build()
.map_err(|e| e.to_string())?;
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
let http_registry = app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
let response = apply_server_http_get(&client, http_registry.as_deref(), Some(&server_id), &url)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
+13 -2
View File
@@ -22,7 +22,7 @@ use psysonic_library::repos::TrackRow;
use psysonic_library::{repos::TrackRepository, LibraryRuntime};
use tauri::{AppHandle, Manager, State};
use crate::file_transfer::{finalize_streamed_download, subsonic_http_client};
use crate::file_transfer::{apply_server_http_get, finalize_streamed_download, subsonic_http_client};
use crate::{offline_cancel_flags, DownloadSemaphore};
use super::offline::enqueue_analysis_seed_from_file;
@@ -359,7 +359,18 @@ pub async fn download_track_local(
}
let client = subsonic_http_client(std::time::Duration::from_secs(120))?;
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
let http_registry = app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
let response = apply_server_http_get(
&client,
http_registry.as_deref(),
Some(&server_index_key),
&url,
)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
+18 -7
View File
@@ -9,7 +9,7 @@ use psysonic_analysis::analysis_runtime::{
};
use crate::{offline_cancel_flags, DownloadSemaphore};
use crate::file_transfer::{finalize_streamed_download, subsonic_http_client};
use crate::file_transfer::{apply_server_http_get, finalize_streamed_download, subsonic_http_client};
// ─── Offline Track Cache ──────────────────────────────────────────────────────
@@ -31,12 +31,15 @@ pub async fn enqueue_analysis_seed_from_file(
///
/// `cancel`, when supplied, aborts the in-flight stream with `Err("CANCELLED")`
/// (the `.part` file is cleaned up); `None` means the download is not cancellable.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn download_track_to_cache_dir(
cache_dir: &std::path::Path,
track_id: &str,
suffix: &str,
url: &str,
client: &reqwest::Client,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: Option<&str>,
cancel: Option<&AtomicBool>,
) -> Result<std::path::PathBuf, String> {
tokio::fs::create_dir_all(cache_dir)
@@ -48,7 +51,10 @@ pub(crate) async fn download_track_to_cache_dir(
return Ok(file_path);
}
let response = client.get(url).send().await.map_err(|e| e.to_string())?;
let response = apply_server_http_get(client, registry, server_ref, url)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
@@ -129,12 +135,17 @@ pub async fn download_track_offline(
}
let client = subsonic_http_client(std::time::Duration::from_secs(120))?;
let http_registry = app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
let final_path = download_track_to_cache_dir(
&cache_dir,
&track_id,
&suffix,
&url,
&client,
http_registry.as_deref(),
Some(&server_id),
cancel_flag.as_deref(),
)
.await?;
@@ -191,7 +202,7 @@ mod tests {
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/stream/track-1", server.uri());
let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None)
let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None, None, None)
.await
.unwrap();
assert!(path.exists());
@@ -211,7 +222,7 @@ mod tests {
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/should-not-be-hit", server.uri());
let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None)
let path = download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None, None, None)
.await
.unwrap();
assert_eq!(path, pre_existing);
@@ -232,7 +243,7 @@ mod tests {
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/stream/missing", server.uri());
let err = download_track_to_cache_dir(&cache_dir, "missing", "flac", &url, &client, None)
let err = download_track_to_cache_dir(&cache_dir, "missing", "flac", &url, &client, None, None, None)
.await
.unwrap_err();
assert!(err.contains("HTTP 404"), "got {err}");
@@ -256,7 +267,7 @@ mod tests {
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/track", server.uri());
download_track_to_cache_dir(&cache_dir, "t", "mp3", &url, &client, None)
download_track_to_cache_dir(&cache_dir, "t", "mp3", &url, &client, None, None, None)
.await
.unwrap();
assert!(cache_dir.join("t.mp3").exists());
@@ -278,7 +289,7 @@ mod tests {
let cancel = AtomicBool::new(true);
let err =
download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, Some(&cancel))
download_track_to_cache_dir(&cache_dir, "track-1", "flac", &url, &client, None, None, Some(&cancel))
.await
.unwrap_err();
assert_eq!(err, "CANCELLED");
@@ -13,6 +13,20 @@ pub fn subsonic_http_client(timeout: Duration) -> Result<reqwest::Client, String
.map_err(|e| e.to_string())
}
pub fn apply_server_http_get(
client: &reqwest::Client,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: Option<&str>,
url: &str,
) -> reqwest::RequestBuilder {
psysonic_core::server_http::apply_optional_registry_headers(
registry,
server_ref,
url,
client.get(url),
)
}
/// Streams an HTTP response body to `dest_path` in chunks. Never buffers the full
/// file in memory — keeps RAM flat regardless of file size.
///
@@ -1,11 +1,11 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tauri::Emitter;
use tauri::{Emitter, Manager};
use crate::sync_cancel_flags;
use crate::file_transfer::{finalize_streamed_download, subsonic_http_client};
use crate::file_transfer::{apply_server_http_get, finalize_streamed_download, subsonic_http_client};
use super::device::{
build_track_path, get_removable_drives, is_path_on_mounted_volume, SyncBatchResult,
TrackSyncInfo,
@@ -107,6 +107,7 @@ pub struct SyncDeltaResult {
pub async fn fetch_subsonic_songs(
client: &reqwest::Client,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
auth: &SubsonicAuthPayload,
endpoint: &str,
id: &str,
@@ -121,7 +122,11 @@ pub async fn fetch_subsonic_songs(
("f", auth.f.as_str()),
("id", id),
];
let res = client.get(&url).query(&query).send().await.map_err(|e| e.to_string())?;
let res = apply_server_http_get(client, registry, None, &url)
.query(&query)
.send()
.await
.map_err(|e| e.to_string())?;
let json: serde_json::Value = res.json().await.map_err(|e| e.to_string())?;
parse_subsonic_songs(&json, endpoint)
}
@@ -239,8 +244,12 @@ pub async fn calculate_sync_payload(
deletion_ids: Vec<String>,
auth: SubsonicAuthPayload,
target_dir: String,
app: tauri::AppHandle,
) -> Result<SyncDeltaResult, String> {
let client = subsonic_http_client(std::time::Duration::from_secs(30))?;
let http_registry = app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
let mut add_bytes = 0;
let mut add_count = 0;
@@ -264,17 +273,19 @@ pub async fn calculate_sync_payload(
v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(),
};
let cli = client.clone();
let reg_for_task = http_registry.clone();
let source_snapshot = source.clone();
let handle = tokio::spawn(async move {
let registry = reg_for_task.as_deref();
let mut res_tracks = Vec::new();
if source.source_type == "album" {
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); }
if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); }
} else if source.source_type == "playlist" {
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); }
if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); }
} else if source.source_type == "artist" {
let url = format!("{}/getArtist.view", auth_clone.base_url);
let query = vec![("u", auth_clone.u.as_str()), ("t", auth_clone.t.as_str()), ("s", auth_clone.s.as_str()), ("v", auth_clone.v.as_str()), ("c", auth_clone.c.as_str()), ("f", auth_clone.f.as_str()), ("id", &source.id)];
if let Ok(re) = cli.get(&url).query(&query).send().await {
if let Ok(re) = apply_server_http_get(&cli, registry, None, &url).query(&query).send().await {
if let Ok(js) = re.json::<serde_json::Value>().await {
if let Some(root) = js.get("subsonic-response").and_then(|r| r.get("artist")).and_then(|a| a.get("album")) {
let arr = root.as_array().cloned().unwrap_or_else(|| {
@@ -282,7 +293,7 @@ pub async fn calculate_sync_payload(
});
for al in arr {
if let Some(aid) = al.get("id").and_then(|i| i.as_str()) {
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", aid).await {
if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getAlbum.view", aid).await {
res_tracks.extend(ts);
}
}
@@ -303,12 +314,14 @@ pub async fn calculate_sync_payload(
v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(),
};
let cli = client.clone();
let reg_for_task = http_registry.clone();
del_handles.push(tokio::spawn(async move {
let registry = reg_for_task.as_deref();
let mut res_tracks = Vec::new();
if source.source_type == "album" {
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); }
if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); }
} else if source.source_type == "playlist" {
if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); }
if let Ok(ts) = fetch_subsonic_songs(&cli, registry, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); }
}
res_tracks
}));
@@ -437,6 +450,9 @@ pub async fn sync_batch_to_device(
// Shared reqwest client — reused across all downloads.
let client = subsonic_http_client(Duration::from_secs(300))?;
let http_registry = app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
// Concurrency limiter: max 2 parallel USB writes.
let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(2));
@@ -455,6 +471,7 @@ pub async fn sync_batch_to_device(
for track in tracks {
let sem = semaphore.clone();
let cli = client.clone();
let reg_for_task = http_registry.clone();
let app2 = app.clone();
let job = job_id.clone();
let dest = dest_dir.clone();
@@ -466,6 +483,7 @@ pub async fn sync_batch_to_device(
handles.push(tokio::spawn(async move {
let _permit = sem.acquire().await.expect("semaphore closed");
let registry = reg_for_task.as_deref();
// Bail out if cancelled while waiting in the semaphore queue.
if cancel.load(Ordering::Relaxed) { return; }
@@ -492,7 +510,7 @@ pub async fn sync_batch_to_device(
}
}
let response = match cli.get(&track.url).send().await {
let response = match apply_server_http_get(&cli, registry, None, &track.url).send().await {
Ok(r) if r.status().is_success() => r,
Ok(r) => {
f.fetch_add(1, Ordering::Relaxed);
@@ -819,7 +837,7 @@ mod tests {
let client = crate::file_transfer::subsonic_http_client(std::time::Duration::from_secs(5))
.unwrap();
let auth = fake_auth(server.uri());
let songs = fetch_subsonic_songs(&client, &auth, "getAlbum.view", "album-42")
let songs = fetch_subsonic_songs(&client, None, &auth, "getAlbum.view", "album-42")
.await
.unwrap();
assert_eq!(songs.len(), 2);
@@ -838,7 +856,7 @@ mod tests {
let client = crate::file_transfer::subsonic_http_client(std::time::Duration::from_secs(5))
.unwrap();
let auth = fake_auth(server.uri());
let result = fetch_subsonic_songs(&client, &auth, "getAlbum.view", "missing").await;
let result = fetch_subsonic_songs(&client, None, &auth, "getAlbum.view", "missing").await;
// 404 with HTML/empty body fails the JSON parse, surfacing as an Err — we
// just assert the function does not panic and propagates an error string.
assert!(result.is_err());
@@ -989,7 +1007,7 @@ mod tests {
let client = crate::file_transfer::subsonic_http_client(std::time::Duration::from_secs(5))
.unwrap();
let auth = fake_auth(server.uri());
let songs = fetch_subsonic_songs(&client, &auth, "getPlaylist.view", "p1")
let songs = fetch_subsonic_songs(&client, None, &auth, "getPlaylist.view", "p1")
.await
.unwrap();
assert_eq!(songs.len(), 1, "single-object response normalised to 1-element vec");
@@ -1,6 +1,6 @@
use tauri::Emitter;
use tauri::{Emitter, Manager};
use crate::file_transfer::{finalize_streamed_download, subsonic_http_client};
use crate::file_transfer::{apply_server_http_get, finalize_streamed_download, subsonic_http_client};
// ─── Device Sync ─────────────────────────────────────────────────────────────
@@ -333,6 +333,8 @@ pub(crate) async fn sync_download_one_track(
suffix: &str,
url: &str,
client: &reqwest::Client,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: Option<&str>,
) -> Result<bool, String> {
if dest_path.exists() {
return Ok(false);
@@ -342,7 +344,10 @@ pub(crate) async fn sync_download_one_track(
.await
.map_err(|e| e.to_string())?;
}
let response = client.get(url).send().await.map_err(|e| e.to_string())?;
let response = apply_server_http_get(client, registry, server_ref, url)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
@@ -366,7 +371,19 @@ pub async fn sync_track_to_device(
let path_str = dest_path.to_string_lossy().to_string();
let client = subsonic_http_client(std::time::Duration::from_secs(300))?;
match sync_download_one_track(&dest_path, &track.suffix, &track.url, &client).await {
let http_registry = app
.try_state::<std::sync::Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| std::sync::Arc::clone(&*s));
match sync_download_one_track(
&dest_path,
&track.suffix,
&track.url,
&client,
http_registry.as_deref(),
None,
)
.await
{
Ok(false) => {
let _ = app.emit("device:sync:progress", serde_json::json!({
"jobId": job_id, "trackId": track.id, "status": "skipped", "path": path_str,
@@ -656,7 +673,7 @@ mod tests {
let dest = dir.path().join("Album").join("01 - track.flac");
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/track", server.uri());
let downloaded = sync_download_one_track(&dest, "flac", &url, &client)
let downloaded = sync_download_one_track(&dest, "flac", &url, &client, None, None)
.await
.unwrap();
assert!(downloaded, "fresh download must report Ok(true)");
@@ -672,7 +689,7 @@ mod tests {
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/should-not-be-hit", server.uri());
let downloaded = sync_download_one_track(&dest, "mp3", &url, &client)
let downloaded = sync_download_one_track(&dest, "mp3", &url, &client, None, None)
.await
.unwrap();
assert!(!downloaded, "pre-existing file must be reported as skipped");
@@ -692,7 +709,7 @@ mod tests {
let dest = dir.path().join("track.opus");
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/missing", server.uri());
let err = sync_download_one_track(&dest, "opus", &url, &client)
let err = sync_download_one_track(&dest, "opus", &url, &client, None, None)
.await
.unwrap_err();
assert!(err.contains("HTTP 403"));
@@ -714,7 +731,7 @@ mod tests {
assert!(!dest.parent().unwrap().exists());
let client = subsonic_http_client(std::time::Duration::from_secs(5)).unwrap();
let url = format!("{}/t", server.uri());
sync_download_one_track(&dest, "mp3", &url, &client)
sync_download_one_track(&dest, "mp3", &url, &client, None, None)
.await
.unwrap();
assert!(dest.exists());
@@ -309,6 +309,12 @@ async fn ensure_one(
password: session.password,
library_bulk: true,
library_server_id: Some(session.library_server_id),
// Library backfill never touches external providers (§15).
external_artwork_enabled: false,
surface_kind: None,
artist_name: None,
album_title: None,
external_artwork_byok: None,
};
let _ = CoverCacheState::ensure_inner(&st, &app, &args, Some(http_sem)).await;
}
+16
View File
@@ -13,6 +13,22 @@ pub fn tier_path(dir: &Path, tier: u32) -> PathBuf {
dir.join(format!("{tier}.webp"))
}
/// External-provider tier file in the SAME entity dir, differentiated by a
/// filename suffix only (image-scraper §14/§16): `{tier}-{provider}.webp`
/// (e.g. `2000-fanart.webp`). The `coverStorageKey`/`cacheKind` is unchanged.
pub fn provider_tier_path(dir: &Path, tier: u32, provider: &str) -> PathBuf {
dir.join(format!("{tier}-{provider}.webp"))
}
pub fn provider_tier_exists(dir: &Path, tier: u32, provider: &str) -> Option<PathBuf> {
let p = provider_tier_path(dir, tier, provider);
if p.is_file() {
Some(p)
} else {
None
}
}
#[allow(dead_code)]
pub fn meta_path(dir: &Path) -> PathBuf {
dir.join("meta.json")
+354
View File
@@ -0,0 +1,354 @@
//! External artist-artwork providers (image-scraper P0 spike).
//!
//! - Subsonic `getArtistInfo2` → the artist's tag MusicBrainz id (§19 step 2;
//! MBID resolution stays Rust-side per §23).
//! - fanart.tv `v3/music/<mbid>` → the first `artistbackground` URL.
//!
//! Mirrors the token auth of `fetch.rs`. The chosen background image's bytes
//! are downloaded by the ensure flow via `fetch::fetch_cover_bytes` (a generic
//! retrying GET). All network use is gated by the caller (feature flag +
//! reachability + the dedicated low-concurrency fanart semaphore).
use reqwest::Client;
use super::fetch::build_subsonic_url;
const FANART_API_BASE: &str = "https://webservice.fanart.tv/v3/music";
const MUSICBRAINZ_BASE: &str = "https://musicbrainz.org/ws/2";
/// fanart.tv project `api_key`, embedded in the binary like Last.fm's key and as
/// fanart.tv's own terms expect ("sent in addition to your project key" — the app
/// ships a project key, users add a personal one on top). Committed as a literal
/// (not a build secret) so every build — CI, local, AUR, Nix, from-source — has
/// it; desktop-app keys are extractable from any binary anyway. Users can still
/// add their own personal key (BYOK, §22), sent in addition to this one.
pub(super) const FANART_PROJECT_KEY: &str = "a32e00543d18deadb797bc0cc9826760";
/// MusicBrainz requires a meaningful, contactable User-Agent (their ToS).
const MUSICBRAINZ_USER_AGENT: &str = concat!(
"Psysonic/",
env!("CARGO_PKG_VERSION"),
" ( https://github.com/Psychotoxical/psysonic )"
);
/// Subsonic `getArtistInfo2.view` (JSON) URL for an artist id.
pub fn build_artist_info2_url(
rest_base: &str,
username: &str,
password: &str,
artist_id: &str,
) -> String {
build_subsonic_url(
rest_base,
"getArtistInfo2",
username,
password,
&[("id", artist_id), ("f", "json")],
)
}
/// fanart.tv music endpoint URL for an MBID. The BYOK personal `client_key` is
/// sent **in addition to** the project `api_key` when non-empty (fanart.tv ToS,
/// §22) — never a replacement.
pub fn build_fanart_url(mbid: &str, api_key: &str, client_key: Option<&str>) -> String {
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
serializer.append_pair("api_key", api_key);
if let Some(ck) = client_key {
if !ck.is_empty() {
serializer.append_pair("client_key", ck);
}
}
format!("{FANART_API_BASE}/{mbid}?{}", serializer.finish())
}
/// GET `getArtistInfo2` and extract `artistInfo2.musicBrainzId` (tag MBID).
/// `Ok(None)` when the artist carries no MBID tag.
pub async fn fetch_artist_tag_mbid(
client: &Client,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: Option<&str>,
rest_base: &str,
username: &str,
password: &str,
artist_id: &str,
) -> Result<Option<String>, String> {
let url = build_artist_info2_url(rest_base, username, password, artist_id);
let body = http_get_text_scoped(client, registry, server_ref, &url).await?;
let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;
let mbid = v
.get("subsonic-response")
.and_then(|r| r.get("artistInfo2"))
.and_then(|a| a.get("musicBrainzId"))
.and_then(|m| m.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
Ok(mbid)
}
/// Map a render surface to its fanart.tv JSON array key. `fanart` (the 16:9
/// fullscreen background) → `artistbackground`; `banner` (the wide artist-detail
/// header strip) → `musicbanner`.
pub fn fanart_json_key(surface: &str) -> &'static str {
match surface {
"banner" => "musicbanner",
_ => "artistbackground",
}
}
/// GET the fanart.tv music JSON for an MBID and return the first image URL for
/// the requested `surface` (the API returns each kind most-liked first).
/// `Ok(None)` when the artist has no image of that kind (404 or empty array).
pub async fn fetch_fanart_image_url(
client: &Client,
mbid: &str,
api_key: &str,
client_key: Option<&str>,
surface: &str,
) -> Result<Option<String>, String> {
let url = build_fanart_url(mbid, api_key, client_key);
let Some(body) = http_get_text_opt(client, &url).await? else {
return Ok(None); // 404 → artist has no fanart at all
};
let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;
let img = v
.get(fanart_json_key(surface))
.and_then(|a| a.as_array())
.and_then(|arr| arr.first())
.and_then(|o| o.get("url"))
.and_then(|u| u.as_str())
.filter(|s| !s.is_empty())
.map(str::to_string);
Ok(img)
}
/// Outcome of a name→MusicBrainz artist-MBID resolution (§19).
pub enum MbResolution {
/// A single, confident artist MBID (one artist across high-score releases).
Found(String),
/// Multiple candidate artists — never guess; the caller backs off 24h.
Ambiguous,
/// No matching release at all.
None,
}
/// Escape the Lucene special characters that would break a MusicBrainz query.
fn mb_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
/// Strip a single trailing parenthetical / bracketed qualifier (e.g.
/// "(2004 Remastered)", "[Deluxe Edition]") so a decorated library album title
/// still matches the canonical MusicBrainz release. Leaves leading qualifiers
/// (e.g. "(What's the Story) Morning Glory?") untouched.
fn normalize_album_for_mb(title: &str) -> String {
let t = title.trim();
let stripped = if t.ends_with(')') {
t.rfind(" (").map(|i| &t[..i]).unwrap_or(t)
} else if t.ends_with(']') {
t.rfind(" [").map(|i| &t[..i]).unwrap_or(t)
} else {
t
};
stripped.trim().to_string()
}
/// Resolve an artist MBID by name, confirmed by an album release (§19). One
/// query to the MusicBrainz release search; the primary artist across the
/// high-confidence releases wins, conflicting ids → `Ambiguous`. Sends the
/// required User-Agent. The caller enforces the ≤1 req/s rate limit.
pub async fn resolve_mbid_via_musicbrainz(
client: &Client,
artist_name: &str,
album_title: &str,
) -> Result<MbResolution, String> {
let album = normalize_album_for_mb(album_title);
let query = format!(
"artist:\"{}\" AND release:\"{}\"",
mb_escape(artist_name),
mb_escape(&album)
);
// Scope the (non-Send) serializer so it is dropped before the await below.
let url = {
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
serializer.append_pair("query", &query);
serializer.append_pair("fmt", "json");
serializer.append_pair("limit", "8");
format!("{MUSICBRAINZ_BASE}/release/?{}", serializer.finish())
};
let resp = client
.get(&url)
.header(reqwest::header::USER_AGENT, MUSICBRAINZ_USER_AGENT)
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
let body = resp.text().await.map_err(|e| e.to_string())?;
let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;
Ok(classify_mb_releases(&v))
}
/// Pure classification of a MusicBrainz release-search response: the primary
/// artist id of each release scoring ≥ 90. One distinct id → `Found`, several →
/// `Ambiguous`, none → `None`.
fn classify_mb_releases(v: &serde_json::Value) -> MbResolution {
let mut ids = std::collections::BTreeSet::new();
if let Some(releases) = v.get("releases").and_then(|r| r.as_array()) {
for rel in releases {
let score = rel.get("score").and_then(|s| s.as_i64()).unwrap_or(0);
if score < 90 {
continue;
}
if let Some(id) = rel
.get("artist-credit")
.and_then(|c| c.as_array())
.and_then(|a| a.first())
.and_then(|c| c.get("artist"))
.and_then(|a| a.get("id"))
.and_then(|i| i.as_str())
{
ids.insert(id.to_string());
}
}
}
match ids.len() {
0 => MbResolution::None,
1 => MbResolution::Found(ids.into_iter().next().unwrap_or_default()),
_ => MbResolution::Ambiguous,
}
}
/// Single GET → response text; any non-2xx is an error.
async fn http_get_text_scoped(
client: &Client,
registry: Option<&psysonic_core::server_http::ServerHttpRegistry>,
server_ref: Option<&str>,
url: &str,
) -> Result<String, String> {
let resp = psysonic_core::server_http::apply_optional_registry_headers(
registry,
server_ref,
url,
client.get(url),
)
.send()
.await
.map_err(|e| e.to_string())?;
let status = resp.status();
if !status.is_success() {
return Err(format!("HTTP {status}"));
}
resp.text().await.map_err(|e| e.to_string())
}
/// Single GET → `Some(text)` on success, `None` on 404, error otherwise.
async fn http_get_text_opt(client: &Client, url: &str) -> Result<Option<String>, String> {
let resp = client.get(url).send().await.map_err(|e| e.to_string())?;
let status = resp.status();
if status == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !status.is_success() {
return Err(format!("HTTP {status}"));
}
resp.text().await.map(Some).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn artist_info2_url_is_json_and_token_authed() {
let u = build_artist_info2_url("http://nav.local:4533", "u", "p", "ar-1");
assert!(u.starts_with("http://nav.local:4533/rest/getArtistInfo2.view?"));
assert!(u.contains("id=ar-1"));
assert!(u.contains("f=json"));
assert!(u.contains("&t=") && u.contains("&s="));
}
#[test]
fn fanart_url_adds_client_key_only_when_present() {
assert_eq!(
build_fanart_url("mbid-123", "PROJ", None),
"https://webservice.fanart.tv/v3/music/mbid-123?api_key=PROJ"
);
let byok = build_fanart_url("mbid-123", "PROJ", Some("PERS"));
assert!(byok.contains("api_key=PROJ") && byok.contains("client_key=PERS"));
// empty BYOK is ignored — project key only
assert!(!build_fanart_url("mbid-123", "PROJ", Some("")).contains("client_key"));
}
#[test]
fn parses_first_artistbackground_url() {
let json = r#"{"artistbackground":[{"id":"1","url":"https://a/bg1.jpg","likes":"9"},{"url":"https://a/bg2.jpg"}]}"#;
let v: serde_json::Value = serde_json::from_str(json).unwrap();
let bg = v
.get("artistbackground")
.and_then(|a| a.as_array())
.and_then(|arr| arr.first())
.and_then(|o| o.get("url"))
.and_then(|u| u.as_str());
assert_eq!(bg, Some("https://a/bg1.jpg"));
}
#[test]
fn json_key_maps_surface() {
assert_eq!(fanart_json_key("fanart"), "artistbackground");
assert_eq!(fanart_json_key("banner"), "musicbanner");
assert_eq!(fanart_json_key("anything-else"), "artistbackground");
}
#[test]
fn normalize_album_strips_trailing_qualifier_only() {
assert_eq!(normalize_album_for_mb("Show No Mercy (2004 Remastered)"), "Show No Mercy");
assert_eq!(normalize_album_for_mb("Album [Deluxe Edition]"), "Album");
assert_eq!(normalize_album_for_mb("Reign in Blood"), "Reign in Blood");
// leading qualifier left intact (does not end with a close bracket)
assert_eq!(
normalize_album_for_mb("(What's the Story) Morning Glory?"),
"(What's the Story) Morning Glory?"
);
}
#[test]
fn mb_escape_handles_quotes_and_backslashes() {
assert_eq!(mb_escape("AC/DC"), "AC/DC");
assert_eq!(mb_escape(r#"a"b"#), r#"a\"b"#);
assert_eq!(mb_escape(r"a\b"), r"a\\b");
}
#[test]
fn classify_mb_picks_single_high_score_artist() {
let v: serde_json::Value = serde_json::from_str(
r#"{"releases":[
{"score":100,"artist-credit":[{"artist":{"id":"mbid-A"}}]},
{"score":95,"artist-credit":[{"artist":{"id":"mbid-A"}}]},
{"score":40,"artist-credit":[{"artist":{"id":"mbid-Z"}}]}
]}"#,
)
.unwrap();
assert!(matches!(classify_mb_releases(&v), MbResolution::Found(id) if id == "mbid-A"));
}
#[test]
fn classify_mb_ambiguous_and_none() {
let two: serde_json::Value = serde_json::from_str(
r#"{"releases":[
{"score":100,"artist-credit":[{"artist":{"id":"mbid-A"}}]},
{"score":92,"artist-credit":[{"artist":{"id":"mbid-B"}}]}
]}"#,
)
.unwrap();
assert!(matches!(classify_mb_releases(&two), MbResolution::Ambiguous));
let low: serde_json::Value =
serde_json::from_str(r#"{"releases":[{"score":50,"artist-credit":[{"artist":{"id":"x"}}]}]}"#)
.unwrap();
assert!(matches!(classify_mb_releases(&low), MbResolution::None));
let empty: serde_json::Value = serde_json::from_str(r#"{"releases":[]}"#).unwrap();
assert!(matches!(classify_mb_releases(&empty), MbResolution::None));
}
}
@@ -0,0 +1,415 @@
//! External artist-artwork ensure path (image-scraper). Split out of `mod.rs`
//! to keep the cover-cache orchestrator navigable: the on-demand fanart/banner
//! fetch (fanart.tv via MBID resolution), the §11 quality gate, the
//! surface-aware peek, and the §12 lookup-table cache. Everything here is gated
//! by `ensure_inner` (feature flag, `!library_bulk`, artist kind) — see the call
//! site in `mod.rs`. Pure code move; behaviour unchanged.
use super::encode::write_webp_tier;
use super::{decode_image_bytes, disk, external, fetch, peek_fallback_tiers, peek_tier_path};
use super::CoverCacheEnsureArgs;
use psysonic_library::LibraryRuntime;
use reqwest::Client;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, Manager};
use tokio::sync::Semaphore;
/// Spike negative-cache marker — "this artist has no fanart", mirrors the
/// §11 quality gate for the fanart (16:9) surface: an existing on-disk image
/// pre-empts an external fetch only when it is wide enough AND roughly 16:9.
/// Square Navidrome artist portraits never satisfy it.
const FANART_MIN_WIDTH: u32 = 1280;
const FANART_ASPECT_MIN: f32 = 1.6;
const FANART_ASPECT_MAX: f32 = 2.0;
/// The external-artwork surfaces fanart.tv serves for an artist. Returns the
/// surface name — also the on-disk file suffix (`{tier}-{surface}.webp`) and the
/// lookup `surface_kind` — when the requested surface is external, else `None`.
pub(super) fn external_surface(surface_kind: Option<&str>) -> Option<&str> {
match surface_kind {
Some("fanart") => Some("fanart"),
Some("banner") => Some("banner"),
_ => None,
}
}
/// Like [`peek_tier_path`] but, for an external surface (`fanart`/`banner`),
/// serves only the matching `{tier}-{surface}.webp` tiers. If none exist yet it
/// returns None so ensure runs the external branch (fetch; Navidrome is the
/// fallback inside that branch's miss path) instead of short-circuiting on a
/// cached Navidrome tier (§18, "external prioritised").
pub(super) fn peek_cover_path(dir: &Path, want: u32, args: &CoverCacheEnsureArgs) -> Option<PathBuf> {
if let Some(surface) = external_surface(args.surface_kind.as_deref()) {
if let Some(p) = disk::provider_tier_exists(dir, want, surface) {
return Some(p);
}
for &tier in peek_fallback_tiers(want) {
if let Some(p) = disk::provider_tier_exists(dir, tier, surface) {
return Some(p);
}
}
return None;
}
peek_tier_path(dir, want)
}
fn marker_recent(path: &Path, max_age: Duration) -> bool {
std::fs::metadata(path)
.and_then(|m| m.modified())
.map(|t| t.elapsed().map(|e| e < max_age).unwrap_or(true))
.unwrap_or(false)
}
fn write_marker(path: &Path) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(path, b"1");
}
/// §11: do these pixel dimensions satisfy the fanart (16:9) surface?
fn dims_satisfy_fanart(w: u32, h: u32) -> bool {
if w < FANART_MIN_WIDTH || h == 0 {
return false;
}
let aspect = w as f32 / h as f32;
(FANART_ASPECT_MIN..=FANART_ASPECT_MAX).contains(&aspect)
}
/// §11 quality gate: true when a Navidrome tier already on disk is an HQ ~16:9
/// image (so the external fetch can be skipped). Reads dimensions only — no
/// full decode. Square artist portraits fail and external proceeds.
fn navidrome_tier_is_hq_fanart(dir: &Path) -> bool {
for &tier in &[2000u32, 800, 512, 256, 128] {
let p = disk::tier_path(dir, tier);
if p.is_file() {
if let Ok((w, h)) = image::image_dimensions(&p) {
if dims_satisfy_fanart(w, h) {
return true;
}
}
}
}
false
}
fn now_unix_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
/// Read the cached `artist_artwork_lookup` row for a surface off the async
/// executor (§12).
async fn read_artist_lookup(
store: &Option<Arc<psysonic_library::store::LibraryStore>>,
server_id: &str,
artist_id: &str,
surface: &str,
) -> Option<psysonic_library::artist_artwork::ArtistArtworkRow> {
let store = store.clone()?;
let (server_id, artist_id, surface) =
(server_id.to_string(), artist_id.to_string(), surface.to_string());
tauri::async_runtime::spawn_blocking(move || {
psysonic_library::artist_artwork::get_artist_artwork(&store, &server_id, &artist_id, &surface)
.ok()
.flatten()
})
.await
.ok()
.flatten()
}
/// Upsert an `artist_artwork_lookup` row off the async executor (§12). No-op
/// when the library store is absent (e.g. before login).
#[allow(clippy::too_many_arguments)]
async fn persist_artist_lookup(
store: &Option<Arc<psysonic_library::store::LibraryStore>>,
server_id: &str,
artist_id: &str,
surface: &str,
status: &str,
mbid: Option<&str>,
mbid_source: Option<&str>,
provider: Option<&str>,
now: i64,
) {
let Some(store) = store.clone() else {
return;
};
let (server_id, artist_id, surface, status) = (
server_id.to_string(),
artist_id.to_string(),
surface.to_string(),
status.to_string(),
);
let (mbid, mbid_source, provider) = (
mbid.map(String::from),
mbid_source.map(String::from),
provider.map(String::from),
);
let _ = tauri::async_runtime::spawn_blocking(move || {
psysonic_library::artist_artwork::upsert_artist_artwork(
&store,
&server_id,
&artist_id,
&surface,
mbid.as_deref(),
mbid_source.as_deref(),
&status,
provider.as_deref(),
now,
)
})
.await;
}
/// Try to satisfy an external artist `surface` (`fanart` 16:9 background or
/// `banner` strip) from fanart.tv. Writes `{2000,512}-{surface}.webp` into the
/// entity dir and returns the requested-tier path on success. `None` = "no
/// image, fall through to Navidrome" — never writes a `.fetch-failed` marker
/// (§28).
///
/// MBID resolution stays Rust-side (§23): the tag MBID via `getArtistInfo2`,
/// else a name→MusicBrainz album-confirmed lookup (§19), cached per surface in
/// `artist_artwork_lookup` (§12). The §11 quality gate runs first for the
/// `fanart` surface only.
#[allow(clippy::too_many_arguments)]
pub(super) async fn try_external_fanart(
app: &AppHandle,
args: &CoverCacheEnsureArgs,
dir: &Path,
client: &Client,
fanart_sem: &Arc<Semaphore>,
musicbrainz_sem: &Arc<Semaphore>,
requested: u32,
surface: &str,
) -> Option<PathBuf> {
// Project key: a runtime env var (dev convenience) wins, else the embedded
// `FANART_PROJECT_KEY` committed in the source — so the feature works in every
// build (CI, local, AUR, Nix, from-source), not just ones built with a secret.
// The BYOK personal key is optional and sent in addition (§22).
let api_key = std::env::var("PSYSONIC_FANART_KEY")
.ok()
.filter(|k| !k.is_empty())
.unwrap_or_else(|| external::FANART_PROJECT_KEY.to_string());
// BYOK personal key (§22): the settings field wins, else the dev env var.
let byok = args
.external_artwork_byok
.as_deref()
.map(str::trim)
.filter(|k| !k.is_empty())
.map(str::to_string)
.or_else(|| std::env::var("PSYSONIC_FANART_CLIENT_KEY").ok())
.filter(|k| !k.is_empty());
// §11 quality gate applies to the 16:9 `fanart` surface only — if Navidrome
// already serves an HQ ~16:9 image, skip the external fetch. The `banner`
// strip has its own aspect and is never pre-empted by a Navidrome tier.
if surface == "fanart" && navidrome_tier_is_hq_fanart(dir) {
return None;
}
// §12: the lookup table is both the MBID resolution cache and the negative
// cache. Absent before login → all reads/writes become no-ops.
let store: Option<Arc<psysonic_library::store::LibraryStore>> =
app.try_state::<LibraryRuntime>().map(|rt| rt.store.clone());
let server_id = &args.server_index_key;
let artist_id = &args.cache_entity_id;
let now = now_unix_ms();
let cached = read_artist_lookup(&store, server_id, artist_id, surface).await;
if let Some(row) = &cached {
// Back off: no/ambiguous MBID for 24h; a confirmed "no fanart" miss for
// 30 min (also held by the `.miss-fanart` marker).
let within = |window: Duration| now - row.updated_at < window.as_millis() as i64;
match row.status.as_str() {
"no_mbid" | "mbid_ambiguous" if within(Duration::from_secs(24 * 60 * 60)) => {
return None;
}
"miss" if within(Duration::from_secs(30 * 60)) => return None,
_ => {}
}
}
let miss_marker = dir.join(format!(".miss-{surface}"));
if marker_recent(&miss_marker, Duration::from_secs(30 * 60)) {
return None;
}
let _permit = fanart_sem.clone().acquire_owned().await.ok()?;
let http_registry = app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
// §23: resolve the tag MBID Rust-side via getArtistInfo2 — unless the cache
// already carries one (skip the Navidrome round-trip).
let (mbid, mbid_source) = match cached.as_ref().and_then(|r| r.mbid.clone()) {
Some(m) => (m, cached.as_ref().and_then(|r| r.mbid_source.clone())),
None => match external::fetch_artist_tag_mbid(
client,
http_registry.as_deref(),
Some(server_id),
&args.rest_base_url,
&args.username,
&args.password,
&args.cache_entity_id,
)
.await
{
Ok(Some(m)) => (m, Some("tag".to_string())),
Ok(None) => {
// No tag MBID. §19: try a name→MusicBrainz album-confirmed lookup
// when both the artist name and an album are in context.
match (args.artist_name.as_deref(), args.album_title.as_deref()) {
(Some(name), Some(album))
if !name.trim().is_empty() && !album.trim().is_empty() =>
{
// ≤1 req/s: hold the single MB permit across the request
// plus a ≥1s spacing so concurrent ensures can't burst MB.
let _mb = musicbrainz_sem.clone().acquire_owned().await.ok()?;
let resolved =
external::resolve_mbid_via_musicbrainz(client, name, album).await;
tokio::time::sleep(Duration::from_millis(1100)).await;
drop(_mb);
match resolved {
Ok(external::MbResolution::Found(m)) => {
(m, Some("musicbrainz".to_string()))
}
Ok(external::MbResolution::Ambiguous) => {
persist_artist_lookup(
&store, server_id, artist_id, surface, "mbid_ambiguous", None,
None, None, now,
)
.await;
return None;
}
Ok(external::MbResolution::None) => {
persist_artist_lookup(
&store, server_id, artist_id, surface, "no_mbid", None, None,
None, now,
)
.await;
return None;
}
Err(e) => {
eprintln!("[fanart] musicbrainz failed: {e}"); // transient
return None;
}
}
}
_ => {
// No album context → we could not even *attempt* name→MB.
// Do NOT cache `no_mbid`: a later ensure that arrives with
// album context (e.g. once the artist's album list loads)
// would otherwise be blocked by the 24h backoff.
return None;
}
}
}
Err(e) => {
eprintln!("[fanart] getArtistInfo2 failed: {e}"); // transient — don't cache
return None;
}
},
};
let img_url = match external::fetch_fanart_image_url(
client,
&mbid,
&api_key,
byok.as_deref(),
surface,
)
.await
{
Ok(Some(u)) => u,
Ok(None) => {
write_marker(&miss_marker); // artist has no image of this kind
persist_artist_lookup(
&store,
server_id,
artist_id,
surface,
"miss",
Some(&mbid),
mbid_source.as_deref(),
None,
now,
)
.await;
return None;
}
Err(e) => {
eprintln!("[fanart] lookup failed: {e}"); // transient — don't cache
return None;
}
};
let bytes = match fetch::fetch_cover_bytes(client, &img_url, None, None).await {
Ok(b) => b,
Err(e) => {
eprintln!("[fanart] download failed: {e}"); // transient — don't cache
return None;
}
};
// Decode + write {2000,512}-{surface}.webp (matryoshka §17).
let dir_owned = dir.to_path_buf();
let surface_owned = surface.to_string();
let encoded = tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
let img = decode_image_bytes(&bytes)?;
std::fs::create_dir_all(&dir_owned).map_err(|e| e.to_string())?;
for tier in [2000u32, 512u32] {
write_webp_tier(&img, tier, &disk::provider_tier_path(&dir_owned, tier, &surface_owned))?;
}
Ok(())
})
.await;
if !matches!(encoded, Ok(Ok(()))) {
eprintln!("[fanart] encode failed: {encoded:?}");
return None;
}
persist_artist_lookup(
&store,
server_id,
artist_id,
surface,
"hit",
Some(&mbid),
mbid_source.as_deref(),
Some("fanart"),
now,
)
.await;
// NOTE: do NOT emit `cover:tier-ready` here. That event is keyed by the
// canonical cover key (cacheKind/cacheEntityId/tier, no surface), so emitting
// it with the `{tier}-{surface}.webp` path would seed the frontend disk-src
// cache for the *Navidrome* artist cover with the external image — leaking
// fanart/banner into the plain artist cover (avatar, FS "navidrome-artist"
// fallback) even with the scraper off. The external hooks read the path from
// this function's return value, so no event is needed.
Some(disk::provider_tier_path(dir, requested, surface))
}
#[cfg(test)]
mod fanart_gate_tests {
use super::dims_satisfy_fanart;
#[test]
fn gate_accepts_wide_16_9_and_rejects_square_or_small() {
assert!(dims_satisfy_fanart(2000, 1125)); // 16:9, wide
assert!(dims_satisfy_fanart(1280, 800)); // aspect 1.6 boundary
assert!(dims_satisfy_fanart(1280, 640)); // aspect 2.0 boundary
assert!(!dims_satisfy_fanart(2000, 2000)); // square portrait
assert!(!dims_satisfy_fanart(1000, 560)); // width < 1280
assert!(!dims_satisfy_fanart(1280, 600)); // aspect 2.13 > 2.0
assert!(!dims_satisfy_fanart(1280, 0)); // div-by-zero guard
}
}
+52 -12
View File
@@ -1,4 +1,5 @@
use reqwest::Client;
use psysonic_core::server_http::ServerHttpRegistry;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use url::Url;
@@ -19,12 +20,15 @@ fn random_salt() -> String {
format!("{nanos:x}")
}
pub fn build_cover_art_url(
/// Build a token-authed Subsonic REST URL `{rest_base}/rest/{endpoint}.view`
/// with the standard `u/t/s/v/c` auth params plus the given `extra` query
/// pairs. Shared by all Subsonic GETs (cover art, `getArtistInfo2`, …).
pub(crate) fn build_subsonic_url(
rest_base: &str,
endpoint: &str,
username: &str,
password: &str,
cover_art_id: &str,
size: u32,
extra: &[(&str, &str)],
) -> String {
let base = rest_base.trim_end_matches('/');
let api_base = if base.ends_with("/rest") {
@@ -34,25 +38,43 @@ pub fn build_cover_art_url(
};
let salt = random_salt();
let token = format!("{:x}", md5::compute(format!("{password}{salt}")));
let endpoint = format!("{api_base}/getCoverArt.view");
let endpoint_url = format!("{api_base}/{endpoint}.view");
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
serializer.append_pair("id", cover_art_id);
serializer.append_pair("size", &size.to_string());
for (k, v) in extra {
serializer.append_pair(k, v);
}
serializer.append_pair("u", username);
serializer.append_pair("t", &token);
serializer.append_pair("s", &salt);
serializer.append_pair("v", "1.16.1");
serializer.append_pair("c", SUBSONIC_CLIENT);
let query = serializer.finish();
match Url::parse(&endpoint) {
match Url::parse(&endpoint_url) {
Ok(mut url) => {
url.set_query(Some(&query));
url.to_string()
}
Err(_) => format!("{endpoint}?{query}"),
Err(_) => format!("{endpoint_url}?{query}"),
}
}
pub fn build_cover_art_url(
rest_base: &str,
username: &str,
password: &str,
cover_art_id: &str,
size: u32,
) -> String {
let size_s = size.to_string();
build_subsonic_url(
rest_base,
"getCoverArt",
username,
password,
&[("id", cover_art_id), ("size", &size_s)],
)
}
/// Outcome of a single fetch attempt: transient errors are worth retrying,
/// permanent ones (a real 4xx like 404 — the cover simply does not exist) are
/// not, so we never hammer the server for genuinely-missing art.
@@ -62,8 +84,21 @@ enum FetchAttempt {
Permanent(String),
}
async fn fetch_cover_once(client: &Client, url: &str) -> FetchAttempt {
let resp = match client.get(url).send().await {
async fn fetch_cover_once(
client: &Client,
url: &str,
registry: Option<&ServerHttpRegistry>,
server_ref: Option<&str>,
) -> FetchAttempt {
let mut req = client.get(url);
if let Some(reg) = registry {
if let Some(sid) = server_ref.filter(|s| !s.is_empty()) {
req = reg.apply_for_http_url(sid, url, req);
} else if let Some(ctx) = reg.get_for_server_url(url) {
req = psysonic_core::server_http::apply_server_headers_for_http_url(req, &ctx, url);
}
}
let resp = match req.send().await {
Ok(r) => r,
// Connection reset / timeout / DNS — transient under server load.
Err(e) => return FetchAttempt::Transient(e.to_string()),
@@ -83,10 +118,15 @@ async fn fetch_cover_once(client: &Client, url: &str) -> FetchAttempt {
}
}
pub async fn fetch_cover_bytes(client: &Client, url: &str) -> Result<Vec<u8>, String> {
pub async fn fetch_cover_bytes(
client: &Client,
url: &str,
registry: Option<&ServerHttpRegistry>,
server_ref: Option<&str>,
) -> Result<Vec<u8>, String> {
let mut last_err = String::from("cover fetch failed");
for attempt in 0..COVER_FETCH_ATTEMPTS {
match fetch_cover_once(client, url).await {
match fetch_cover_once(client, url, registry, server_ref).await {
FetchAttempt::Ok(bytes) => return Ok(bytes),
FetchAttempt::Permanent(e) => return Err(e),
FetchAttempt::Transient(e) => {
+181 -4
View File
@@ -3,6 +3,8 @@
mod backfill_worker;
mod disk;
mod encode;
mod external;
mod external_ensure;
mod fetch;
use disk::{cover_dir, tier_exists, tier_path, DERIVE_TIERS};
@@ -133,6 +135,27 @@ pub struct CoverCacheEnsureArgs {
/// with the album/artist name. On-demand UI ensures leave it `None`.
#[serde(default)]
pub library_server_id: Option<String>,
/// External artwork (§16): when true, an artist `fanart`/`banner` ensure may
/// fetch from fanart.tv into `{tier}-{provider}.webp`. Gated by the master
/// toggle (off by default); the project key is embedded (`FANART_PROJECT_KEY`).
#[serde(default)]
pub external_artwork_enabled: bool,
/// Surface intent for external artwork — `fanart` for the 16:9 artist
/// background. `None` on plain cover ensures.
#[serde(default)]
pub surface_kind: Option<String>,
/// Artist display name — context for the §19 name→MusicBrainz fallback when
/// the artist carries no tag MBID. `None` skips that fallback.
#[serde(default)]
pub artist_name: Option<String>,
/// Album title currently in context (fullscreen playback) — disambiguates
/// the name→MusicBrainz query (§19).
#[serde(default)]
pub album_title: Option<String>,
/// Optional BYOK personal fanart.tv key from settings — sent in addition to
/// the project key (§22). Falls back to the `PSYSONIC_FANART_CLIENT_KEY` env.
#[serde(default)]
pub external_artwork_byok: Option<String>,
}
fn cover_dir_for_args(root: &Path, args: &CoverCacheEnsureArgs) -> PathBuf {
@@ -148,6 +171,9 @@ const COVER_CPU_UI_CONCURRENCY: usize = 2;
const COVER_CPU_BACKFILL_CONCURRENCY: usize = 2;
/// Upper bound for the runtime encode-pool knob (matches the worker cap).
const COVER_CPU_BACKFILL_MAX: usize = 16;
/// External providers (fanart.tv) get their own low-concurrency HTTP lane so
/// they can never starve Navidrome cover / getArtistInfo2 fetches (§26).
const FANART_HTTP_CONCURRENCY: usize = 4;
pub struct CoverCacheState {
pub root: PathBuf,
@@ -158,6 +184,13 @@ pub struct CoverCacheState {
pub http_sem: Arc<Semaphore>,
pub cover_cpu_ui_sem: Arc<Semaphore>,
pub cover_cpu_backfill_sem: Arc<Semaphore>,
/// External-provider (fanart.tv) HTTP lane — separate from `http_sem` so
/// external fetches never starve Navidrome cover / getArtistInfo2 (§26).
pub fanart_http_sem: Arc<Semaphore>,
/// MusicBrainz name→MBID lane — a single permit, so the §19 resolver runs
/// strictly serially and the caller's ≥1s spacing keeps us under MB's rate
/// limit (their ToS).
pub musicbrainz_sem: Arc<Semaphore>,
/// Live permit count of `cover_cpu_backfill_sem` (the semaphore itself only
/// exposes *available* permits, not the configured ceiling).
cover_cpu_backfill_max: AtomicUsize,
@@ -180,6 +213,8 @@ impl CoverCacheState {
http_sem: Arc::new(Semaphore::new(COVER_HTTP_CONCURRENCY)),
cover_cpu_ui_sem: Arc::new(Semaphore::new(COVER_CPU_UI_CONCURRENCY)),
cover_cpu_backfill_sem: Arc::new(Semaphore::new(COVER_CPU_BACKFILL_CONCURRENCY)),
fanart_http_sem: Arc::new(Semaphore::new(FANART_HTTP_CONCURRENCY)),
musicbrainz_sem: Arc::new(Semaphore::new(1)),
cover_cpu_backfill_max: AtomicUsize::new(COVER_CPU_BACKFILL_CONCURRENCY),
})
}
@@ -229,7 +264,7 @@ impl CoverCacheState {
) -> Result<CoverCacheEnsureResult, String> {
let this = state.lock().await;
let dir = cover_dir_for_args(&this.root, args);
if let Some(path) = peek_tier_path(&dir, args.tier) {
if let Some(path) = external_ensure::peek_cover_path(&dir, args.tier, args) {
return Ok(CoverCacheEnsureResult {
hit: true,
path: path.to_string_lossy().into_owned(),
@@ -254,6 +289,8 @@ impl CoverCacheState {
let root = this.root.clone();
let http_sem = http_sem_override.unwrap_or_else(|| this.http_sem.clone());
let cover_cpu_sem = this.cpu_sem_for(args.library_bulk);
let fanart_sem = this.fanart_http_sem.clone();
let musicbrainz_sem = this.musicbrainz_sem.clone();
drop(this);
if cover_fetch_recently_failed(&dir) {
@@ -264,6 +301,33 @@ impl CoverCacheState {
});
}
// For an external artist surface (`fanart` 16:9 background or `banner`
// strip), try fanart.tv before the Navidrome fallback. On any miss it
// falls through WITHOUT writing a `.fetch-failed` marker, so Navidrome
// stays the display fallback (§28).
if args.external_artwork_enabled && !args.library_bulk && args.cache_kind == "artist" {
if let Some(surface) = external_ensure::external_surface(args.surface_kind.as_deref()) {
if let Some(path) = external_ensure::try_external_fanart(
app,
args,
&dir,
&client,
&fanart_sem,
&musicbrainz_sem,
args.tier,
surface,
)
.await
{
return Ok(CoverCacheEnsureResult {
hit: true,
path: path.to_string_lossy().into_owned(),
tier: args.tier,
});
}
}
}
let requested = args.tier;
let quiet = args.library_bulk;
let tiers_now: Vec<u32> = if args.library_bulk {
@@ -290,7 +354,10 @@ impl CoverCacheState {
let source = if let Some(img) = load_image_from_disk(&dir) {
CoverSource::Image(img)
} else {
match download_cover_payload(&dir, &client, &http_sem, args).await {
let http_registry = app
.try_state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>()
.map(|s| Arc::clone(&*s));
match download_cover_payload(&dir, &client, &http_sem, args, http_registry).await {
Ok(bytes) => CoverSource::Bytes(bytes),
Err(err) => {
log_cover_fetch_failure(app, args, &err);
@@ -468,6 +535,7 @@ async fn download_cover_payload(
client: &Client,
http_sem: &Semaphore,
args: &CoverCacheEnsureArgs,
registry: Option<Arc<psysonic_core::server_http::ServerHttpRegistry>>,
) -> Result<Vec<u8>, String> {
let _permit = http_sem
.acquire()
@@ -485,7 +553,13 @@ async fn download_cover_payload(
&args.cover_art_id,
fetch_size,
);
fetch::fetch_cover_bytes(client, &url).await
fetch::fetch_cover_bytes(
client,
&url,
registry.as_deref(),
Some(args.server_index_key.as_str()),
)
.await
}
fn spawn_derive_remaining_tiers(
@@ -824,6 +898,7 @@ fn peek_tier_path(dir: &Path, want: u32) -> Option<PathBuf> {
None
}
#[tauri::command]
pub async fn cover_cache_ensure(
app: AppHandle,
@@ -923,6 +998,17 @@ pub async fn cover_cache_clear_server(
}
invalidate_dir_usage_cache(&server_index_key);
drop(guard);
// §12/B.4: the on-disk external tiers (`{tier}-fanart.webp` / `-banner.webp`)
// + `.miss-*` markers went with the dir removal above; also drop the
// `artist_artwork_lookup` rows for this server so no resolution state lingers.
if let Some(rt) = app.try_state::<LibraryRuntime>() {
let store = rt.store.clone();
let key = server_index_key.clone();
let _ = tauri::async_runtime::spawn_blocking(move || {
psysonic_library::artist_artwork::clear_artist_artwork_for_server(&store, &key)
})
.await;
}
// Clearing drops files the cheap idle-gate signature can't see, so re-arm
// the backfill worker — otherwise the next sync-idle would skip the rescan.
if let Some(worker) = app.try_state::<Arc<CoverBackfillWorker>>() {
@@ -935,6 +1021,65 @@ pub async fn cover_cache_clear_server(
Ok(())
}
/// Delete only external-provider artifacts under a server's cover dir — the
/// `{tier}-{provider}.webp` tiers and `.miss-{provider}` markers — leaving the
/// canonical Navidrome `{tier}.webp` and `.fetch-failed` untouched (Navidrome
/// tiers have no `-` in the stem; their marker is `.fetch-failed`, not
/// `.miss-*`). FS-only so it is testable against a real `tempdir`. Returns the
/// number of files removed.
fn purge_external_files(server_dir: &Path) -> usize {
fn is_external(name: &str) -> bool {
(name.ends_with(".webp") && name.contains('-')) || name.starts_with(".miss-")
}
fn walk(dir: &Path, count: &mut usize) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() {
walk(&p, count);
} else if p.file_name().and_then(|n| n.to_str()).is_some_and(is_external)
&& std::fs::remove_file(&p).is_ok()
{
*count += 1;
}
}
}
let mut count = 0;
walk(server_dir, &mut count);
count
}
/// Opt-out purge (§9, §12, Appendix B.4): drop every external artwork artifact
/// for a server — `{tier}-{provider}.webp`, `.miss-{provider}`, and the
/// `artist_artwork_lookup` rows — while leaving the canonical Navidrome covers
/// intact. Fired when the user turns the External Artwork toggle off. Unlike
/// `cover_cache_clear_server`, Navidrome tiers survive.
#[tauri::command]
pub async fn cover_cache_purge_external(
app: AppHandle,
server_index_key: String,
) -> Result<(), String> {
let st = state(&app)?;
let guard = st.lock().await;
let path = cover_server_dir(&guard.root, &server_index_key);
if path.is_dir() {
purge_external_files(&path);
}
invalidate_dir_usage_cache(&server_index_key);
drop(guard);
if let Some(rt) = app.try_state::<LibraryRuntime>() {
let store = rt.store.clone();
let key = server_index_key.clone();
let _ = tauri::async_runtime::spawn_blocking(move || {
psysonic_library::artist_artwork::clear_artist_artwork_for_server(&store, &key)
})
.await;
}
Ok(())
}
/// Rename a server's cover-cache bucket on disk after the user edits the
/// primary URL (and the derived index key changes). Used by the URL-change
/// remigration pipeline (dual-server-address spec §8.3) so cached covers
@@ -1214,7 +1359,10 @@ mod tests {
use super::decode_image_bytes;
use super::disk::{cover_dir, tier_path};
use super::{count_cached_cover_ids, is_safe_index_key, merge_cover_bucket, rename_bucket_inner};
use super::{
count_cached_cover_ids, is_safe_index_key, merge_cover_bucket, purge_external_files,
rename_bucket_inner,
};
use psysonic_core::cover_cache_layout::CANONICAL_PROGRESS_TIER;
use std::fs;
use std::path::PathBuf;
@@ -1409,4 +1557,33 @@ mod tests {
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn purge_external_removes_only_external_artifacts() {
let root = fresh_tmpdir("purge-external");
let entity = root.join("artist").join("ar-1");
fs::create_dir_all(&entity).unwrap();
// Navidrome canonical — must survive.
fs::write(entity.join("2000.webp"), b"n").unwrap();
fs::write(entity.join("512.webp"), b"n").unwrap();
fs::write(entity.join(".fetch-failed"), b"1").unwrap();
// External — must go.
fs::write(entity.join("2000-fanart.webp"), b"f").unwrap();
fs::write(entity.join("512-fanart.webp"), b"f").unwrap();
fs::write(entity.join("2000-banner.webp"), b"b").unwrap();
fs::write(entity.join(".miss-fanart"), b"1").unwrap();
fs::write(entity.join(".miss-banner"), b"1").unwrap();
assert_eq!(purge_external_files(&root), 5);
assert!(entity.join("2000.webp").exists());
assert!(entity.join("512.webp").exists());
assert!(entity.join(".fetch-failed").exists());
assert!(!entity.join("2000-fanart.webp").exists());
assert!(!entity.join("512-fanart.webp").exists());
assert!(!entity.join("2000-banner.webp").exists());
assert!(!entity.join(".miss-fanart").exists());
assert!(!entity.join(".miss-banner").exists());
let _ = fs::remove_dir_all(&root);
}
}
+54 -22
View File
@@ -64,7 +64,28 @@ fn on_second_instance<R: tauri::Runtime>(
}
}
/// Windows: associate this process with an explicit AppUserModelID. Windows uses
/// it to name the app in taskbar grouping and the SMTC media controls; without it
/// the media tile reads "Unknown application". Must match the AppUserModelID the
/// installer sets on the Start-menu shortcut so the name/icon resolve.
#[cfg(target_os = "windows")]
fn set_app_user_model_id() {
use windows::core::w;
use windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
// SAFETY: a Win32 call with a static wide string; errors are non-fatal.
unsafe {
let _ = SetCurrentProcessExplicitAppUserModelID(w!("dev.psysonic.player"));
}
}
pub fn run() {
// Windows: bind this process to an explicit AppUserModelID before any window
// or the SMTC media controls are created, so the OS can resolve the app
// name/icon for taskbar grouping and the media tile (#1102 follow-up: the
// Quick-Settings / lock-screen media tile showed "Unknown application").
#[cfg(target_os = "windows")]
set_app_user_model_id();
// Linux: second `psysonic --player …` forwards over D-Bus before heavy startup.
#[cfg(target_os = "linux")]
{
@@ -85,6 +106,7 @@ pub fn run() {
let builder = tauri::Builder::default()
.manage(audio_engine)
.manage(Arc::new(psysonic_core::server_http::ServerHttpRegistry::new()))
.manage(ShortcutMap::default())
.manage(discord::DiscordState::new())
.manage(Arc::new(tokio::sync::Semaphore::new(MAX_DL_CONCURRENCY)) as DownloadSemaphore)
@@ -217,7 +239,10 @@ pub fn run() {
let flags = psysonic_library::sync::capability::CapabilityFlags::new(
flags_bits,
);
let subsonic = psysonic_integration::subsonic::SubsonicClient::new(
let registry = app_for_sched.state::<Arc<psysonic_core::server_http::ServerHttpRegistry>>();
let subsonic = psysonic_integration::subsonic::subsonic_client_with_registry(
Some(registry.as_ref()),
&session.server_id,
session.base_url.clone(),
session.username.clone(),
session.password.clone(),
@@ -230,7 +255,8 @@ pub fn run() {
scope.clone(),
flags,
)
.with_playback_hint(hint);
.with_playback_hint(hint)
.with_http_registry(Some(Arc::clone(&registry)));
if let Some(tok) = session.navidrome_token.clone() {
sched = sched.with_navidrome_credentials(
psysonic_library::sync::capability::NavidromeProbeCredentials {
@@ -502,11 +528,20 @@ pub fn run() {
let app_handle = app.handle().clone();
if let Err(e) = controls.attach(move |event: MediaControlEvent| {
match event {
MediaControlEvent::Toggle
| MediaControlEvent::Play
| MediaControlEvent::Pause => {
// Keep Play/Pause distinct from Toggle: the OS
// (notably macOS on audio-route changes, e.g. a
// headphone disconnect) sends an explicit Pause,
// and collapsing all three into a toggle would
// resume paused playback on the new device (#1094).
MediaControlEvent::Toggle => {
let _ = app_handle.emit("media:play-pause", ());
}
MediaControlEvent::Play => {
let _ = app_handle.emit("media:play", ());
}
MediaControlEvent::Pause => {
let _ = app_handle.emit("media:pause", ());
}
MediaControlEvent::Next => {
let _ = app_handle.emit("media:next", ());
}
@@ -599,24 +634,15 @@ pub fn run() {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if window.label() == "main" {
api.prevent_close();
#[cfg(target_os = "macos")]
{
// On macOS the red close button quits the app entirely.
// Route through JS so playback position + Orbit state get
// flushed; exit_app on the way back stops the audio engine.
let _ = window.emit("app:force-quit", ());
}
#[cfg(not(target_os = "macos"))]
{
// Pause rendering before JS decides whether to hide to tray or exit.
if let Some(w) = window.app_handle().get_webview_window("main") {
let _ = w.eval(PAUSE_RENDERING_JS);
}
// Let JS decide: minimize to tray or exit, based on user setting.
let _ = window.emit("window:close-requested", ());
// All platforms: pause rendering, then let JS decide hide-to-tray
// vs exit based on the minimizeToTray setting. macOS previously
// always force-quit on the red close button, ignoring the setting
// (#1103). The tray "Exit" item still emits app:force-quit for an
// unconditional quit.
if let Some(w) = window.app_handle().get_webview_window("main") {
let _ = w.eval(PAUSE_RENDERING_JS);
}
let _ = window.emit("window:close-requested", ());
} else if window.label() == "mini" {
// Native close on the mini: hide instead of destroying so
// state is preserved, and restore the main window.
@@ -661,6 +687,9 @@ pub fn run() {
migration_inspect,
migration_run,
resolve_host_addresses,
server_http_context_sync,
server_http_context_sync_all,
server_http_context_clear,
psysonic_syncfs::sync::batch::calculate_sync_payload,
exit_app,
cli_publish_player_snapshot,
@@ -714,6 +743,8 @@ pub fn run() {
audio::preview::audio_preview_set_volume,
audio::mix_commands::audio_set_crossfade,
audio::mix_commands::audio_set_gapless,
audio::mix_commands::audio_begin_outgoing_fade,
audio::mix_commands::audio_set_autodj_suppress,
audio::mix_commands::audio_set_normalization,
audio::device_commands::audio_list_devices,
audio::device_commands::audio_canonicalize_selected_device,
@@ -820,6 +851,7 @@ pub fn run() {
cover_cache::cover_cache_configure,
cover_cache::cover_cache_clear,
cover_cache::cover_cache_clear_server,
cover_cache::cover_cache_purge_external,
cover_cache::cover_cache_rename_server_bucket,
cover_cache::cover_cache_stats_server,
cover_cache::cover_cache_get_pipeline_queue_stats,
@@ -84,6 +84,15 @@ pub(crate) fn mpris_set_metadata(
let duration = duration_secs.map(Duration::from_secs_f64);
let mut guard = controls.lock().unwrap();
let Some(ctrl) = guard.as_mut() else { return Ok(()); };
// #1102: Windows SMTC cannot render our cached WebP covers. souvlaki loads
// the file and SetThumbnail/set_metadata succeed, but the lock screen and
// Quick-Settings media tile show a blank cover (the OS thumbnail decoder
// does not handle WebP, even with the Store WebP extension installed).
// Transcode local WebP covers to PNG for the OS media controls; macOS
// (ImageIO) decodes WebP fine, so other platforms pass through unchanged.
let cover_url = smtc_cover_url(cover_url);
ctrl.set_metadata(MediaMetadata {
title: title.as_deref(),
artist: artist.as_deref(),
@@ -94,6 +103,48 @@ pub(crate) fn mpris_set_metadata(
.map_err(|e| format!("MPRIS set_metadata failed: {e:?}"))
}
/// Rewrite a cached WebP cover URL to a PNG the OS media controls can render.
/// Windows SMTC cannot decode WebP thumbnails (#1102); other platforms and any
/// non-`file://`/non-WebP URL pass through unchanged.
fn smtc_cover_url(cover_url: Option<String>) -> Option<String> {
#[cfg(target_os = "windows")]
{
if let Some(url) = cover_url.as_deref() {
if let Some(path) = url.strip_prefix("file://") {
let is_webp = std::path::Path::new(path)
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("webp"));
if is_webp {
match webp_file_to_temp_png(path) {
Ok(png) => return Some(format!("file://{png}")),
Err(e) => {
crate::app_eprintln!("[mpris] cover WebP->PNG transcode failed: {e}")
}
}
}
}
}
}
cover_url
}
/// Decode a WebP file (libwebp, the same codec that wrote the cover cache) and
/// re-encode it as a PNG in the temp dir, returning the native path. A single
/// reusable file is fine: souvlaki reads it synchronously inside `set_metadata`,
/// and the controls mutex serializes calls so it is never written concurrently.
#[cfg(target_os = "windows")]
fn webp_file_to_temp_png(webp_path: &str) -> Result<String, String> {
let bytes = std::fs::read(webp_path).map_err(|e| e.to_string())?;
let decoded = webp::Decoder::new(&bytes)
.decode()
.ok_or_else(|| "WebP decode returned None".to_string())?;
let img = decoded.to_image();
let out = std::env::temp_dir().join("psysonic-smtc-cover.png");
img.save_with_format(&out, image::ImageFormat::Png)
.map_err(|e| e.to_string())?;
Ok(out.to_string_lossy().into_owned())
}
#[tauri::command]
pub(crate) fn mpris_set_playback(
controls: tauri::State<MprisControls>,
+4 -1
View File
@@ -36,7 +36,10 @@ pub(crate) use integration::{
unregister_global_shortcut,
};
pub(crate) use migration::{migration_inspect, migration_run};
pub(crate) use network::resolve_host_addresses;
pub(crate) use network::{
resolve_host_addresses, server_http_context_clear, server_http_context_sync,
server_http_context_sync_all,
};
// Discord, Navidrome admin, last.fm + radio-browser + CORS proxy, bandsintown,
// and analysis admin commands now live in their domain crates. invoke_handler!
@@ -2,6 +2,11 @@
//! dual-server-address add/edit form (UI hint only — not for connect).
use std::collections::HashSet;
use std::sync::Arc;
use psysonic_core::server_http::{ServerHttpContextSyncWire, ServerHttpRegistry};
use tauri::State;
use tokio::net::lookup_host;
/// Resolve a hostname to a deduped list of IP address strings (IPv4 + IPv6).
@@ -58,6 +63,34 @@ pub(crate) async fn resolve_host_addresses(hostname: String) -> Result<Vec<Strin
Ok(result)
}
#[tauri::command]
pub(crate) fn server_http_context_sync(
registry: State<'_, Arc<ServerHttpRegistry>>,
wire: ServerHttpContextSyncWire,
) -> Result<(), String> {
registry.sync(wire);
Ok(())
}
#[tauri::command]
pub(crate) fn server_http_context_sync_all(
registry: State<'_, Arc<ServerHttpRegistry>>,
entries: Vec<ServerHttpContextSyncWire>,
) -> Result<(), String> {
registry.sync_all(entries);
Ok(())
}
#[tauri::command]
pub(crate) fn server_http_context_clear(
registry: State<'_, Arc<ServerHttpRegistry>>,
server_id: String,
app_server_id: String,
) -> Result<(), String> {
registry.remove(&server_id, &app_server_id);
Ok(())
}
/// Strip a `:port` suffix. Handles `host:port` and `[ipv6]:port`; leaves
/// bracketed IPv6 with no port (`[::1]`) and bare hosts alone.
fn strip_port(input: &str) -> String {
+3 -2
View File
@@ -415,7 +415,7 @@ pub(crate) fn toggle_tray_icon(
pub(crate) use crate::audio::stop_audio_engine;
/// Returns `true` if running under a tiling window manager (Hyprland, Sway, i3,
/// Returns `true` if running under a tiling window manager (Hyprland, Niri, Sway, i3,
/// bspwm, AwesomeWM, Openbox, etc.). Detection is based on environment variables
/// set by the compositor / DE.
#[cfg(target_os = "linux")]
@@ -423,6 +423,7 @@ pub(crate) fn is_tiling_wm() -> bool {
// Direct compositor signatures (most reliable).
let direct = [
"HYPRLAND_INSTANCE_SIGNATURE", // Hyprland
"NIRI_SOCKET", // Niri
"SWAYSOCK", // Sway
"I3SOCK", // i3
]
@@ -437,7 +438,7 @@ pub(crate) fn is_tiling_wm() -> bool {
if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") {
let desktop = desktop.to_lowercase();
let tiling_wms = [
"hyprland", "sway", "i3", "bspwm", "awesome", "openbox",
"hyprland", "niri", "sway", "i3", "bspwm", "awesome", "openbox",
"xmonad", "dwm", "qtile", "herbstluftwm", "leftwm",
];
if tiling_wms.iter().any(|&wm| desktop.contains(wm)) {
+59 -7
View File
@@ -12,10 +12,11 @@
//! strict.
#![cfg_attr(debug_assertions, allow(dead_code))]
use std::sync::atomic::{AtomicIsize, Ordering};
use std::sync::atomic::{AtomicIsize, AtomicU32, Ordering};
use tauri::{AppHandle, Emitter};
use windows::{
core::w,
Win32::{
Foundation::{HWND, LPARAM, LRESULT, WPARAM},
System::Com::{
@@ -29,7 +30,7 @@ use windows::{
},
WindowsAndMessaging::{
CreateIconFromResourceEx, DestroyIcon, HICON, LR_DEFAULTCOLOR,
WM_COMMAND, WM_NCDESTROY,
RegisterWindowMessageW, WM_COMMAND, WM_NCDESTROY,
},
},
},
@@ -61,6 +62,10 @@ static HICON_PLAY: AtomicIsize = AtomicIsize::new(0);
static HICON_PAUSE: AtomicIsize = AtomicIsize::new(0);
static HICON_NEXT: AtomicIsize = AtomicIsize::new(0);
// Registered window-message id for the shell's "TaskbarButtonCreated"
// broadcast (0 until registered in `init`).
static TASKBAR_BUTTON_CREATED_MSG: AtomicU32 = AtomicU32::new(0);
// ── ICO resource loader ──────────────────────────────────────────────────────
/// Load the best-match image from a raw `.ico` file in memory and return an HICON.
@@ -174,6 +179,15 @@ unsafe extern "system" fn subclass_proc(
_uid: usize,
data: usize,
) -> LRESULT {
// The shell sends this once the taskbar button exists (on the first window
// show) and again after an explorer.exe restart — the only safe moment to
// (re)add the thumbnail buttons. See `add_thumb_buttons`.
let tb_created = TASKBAR_BUTTON_CREATED_MSG.load(Ordering::SeqCst);
if tb_created != 0 && msg == tb_created {
add_thumb_buttons();
return DefSubclassProc(hwnd, msg, wparam, lparam);
}
if msg == WM_COMMAND {
let hi = (wparam.0 >> 16) as u32;
let lo = (wparam.0 & 0xFFFF) as u32;
@@ -211,6 +225,43 @@ unsafe extern "system" fn subclass_proc(
DefSubclassProc(hwnd, msg, wparam, lparam)
}
// ── Thumbnail-button (re)attach ────────────────────────────────────────────────
/// (Re)add the three media buttons to the taskbar thumbnail toolbar.
///
/// Must run only after the shell has created the window's taskbar button and
/// broadcast `TaskbarButtonCreated`. Calling `ThumbBarAddButtons` before that
/// point returns `S_OK` but silently adds nothing — which is why the buttons
/// disappeared once the main window started hidden/deferred (PR #1030 moved the
/// window to `visible: false` with a deferred show, so at `setup` time the
/// taskbar button does not exist yet).
unsafe fn add_thumb_buttons() {
let taskbar_raw = TASKBAR_PTR.load(Ordering::SeqCst);
let hwnd_raw = HWND_VAL.load(Ordering::SeqCst);
if taskbar_raw == 0 || hwnd_raw == 0 { return; }
let h_prev = HICON_PREV.load(Ordering::SeqCst);
let h_play = HICON_PLAY.load(Ordering::SeqCst);
let h_next = HICON_NEXT.load(Ordering::SeqCst);
if h_prev == 0 || h_play == 0 || h_next == 0 { return; }
let taskbar = &*(taskbar_raw as *const ITaskbarList3);
let hwnd = HWND(hwnd_raw as *mut _);
// Harmless on an already-initialised object; required again after an
// explorer restart recreates the taskbar button.
let _ = taskbar.HrInit();
let buttons = make_buttons(
HICON(h_prev as *mut _),
HICON(h_play as *mut _),
HICON(h_next as *mut _),
);
if let Err(e) = taskbar.ThumbBarAddButtons(hwnd, &buttons) {
crate::app_eprintln!("[psysonic] taskbar: ThumbBarAddButtons failed: {e}");
}
}
// ── Public init ──────────────────────────────────────────────────────────────
pub fn init(app: &AppHandle, hwnd_raw: isize) {
@@ -242,11 +293,12 @@ pub fn init(app: &AppHandle, hwnd_raw: isize) {
HICON_PAUSE.store(h_pause.0 as isize, Ordering::SeqCst);
HICON_NEXT .store(h_next .0 as isize, Ordering::SeqCst);
let buttons = make_buttons(h_prev, h_play, h_next);
if let Err(e) = taskbar.ThumbBarAddButtons(hwnd, &buttons) {
crate::app_eprintln!("[psysonic] taskbar: ThumbBarAddButtons failed: {e}");
return;
}
// Register the shell's "TaskbarButtonCreated" message. The buttons are
// added from the subclass proc when it fires (after the first window
// show), because the deferred/hidden main window means the taskbar
// button does not exist yet at this point.
let tb_msg = RegisterWindowMessageW(w!("TaskbarButtonCreated"));
TASKBAR_BUTTON_CREATED_MSG.store(tb_msg, Ordering::SeqCst);
let raw = Box::into_raw(Box::new(taskbar));
TASKBAR_PTR.store(raw as isize, Ordering::SeqCst);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.48.0-dev",
"version": "1.49.0-dev",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
+52 -2
View File
@@ -1,5 +1,6 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
import { coverIndexKeyFromRef, coverStorageKeyFromRef } from '../cover/storageKeys';
import { connectBaseUrlForServer } from '../utils/server/serverEndpoint';
import { serverIndexKeyForProfile } from '../utils/server/serverIndexKey';
@@ -52,7 +53,36 @@ export function setCoverCacheAutoDownloadEnabled(enabled: boolean): void {
coverAutoDownloadEnabled = enabled;
}
function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier) {
export type CoverEnsureOpts = {
/** External-artwork surface intent — `'fanart'` for the 16:9 artist background (§28). */
surfaceKind?: string;
/** §19 name→MusicBrainz context: the artist display name + the album in context. */
artistName?: string;
albumTitle?: string;
};
/**
* External-artwork ensure fields (§28). `externalArtworkEnabled` is gated by the
* master toggle AND restricted to the external artist surfaces (`fanart` /
* `banner`), so plain album/artist cover ensures are never affected.
*/
function externalEnsureFields(ref: CoverArtRef, opts?: CoverEnsureOpts) {
const surfaceKind = opts?.surfaceKind;
const isExternalSurface = surfaceKind === 'fanart' || surfaceKind === 'banner';
const theme = useThemeStore.getState();
const externalArtworkEnabled =
isExternalSurface && ref.cacheKind === 'artist' && theme.externalArtworkEnabled;
return {
externalArtworkEnabled,
surfaceKind,
artistName: opts?.artistName,
albumTitle: opts?.albumTitle,
// BYOK personal fanart.tv key (§22), only when the external branch will run.
externalArtworkByok: externalArtworkEnabled ? theme.externalArtworkByok : undefined,
};
}
function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier, opts?: CoverEnsureOpts) {
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const scope = ref.serverScope;
if (scope.kind === 'server') {
@@ -69,6 +99,7 @@ function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier) {
),
username: scope.username,
password: scope.password,
...externalEnsureFields(ref, opts),
};
}
const server =
@@ -94,6 +125,7 @@ function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier) {
restBaseUrl: baseUrl ? coverCacheRestHost(baseUrl) : '',
username: server?.username ?? '',
password: server?.password ?? '',
...externalEnsureFields(ref, opts),
};
}
@@ -125,9 +157,10 @@ export async function coverCacheEnsure(
ref: CoverArtRef,
tier: CoverArtTier,
_priority?: string,
opts?: CoverEnsureOpts,
): Promise<CoverCacheEnsureResult> {
return invoke<CoverCacheEnsureResult>('cover_cache_ensure', {
args: ensureArgsFromRef(ref, tier),
args: ensureArgsFromRef(ref, tier, opts),
});
}
@@ -156,6 +189,23 @@ export async function coverCacheClearServer(serverIndexKey: string): Promise<voi
return invoke('cover_cache_clear_server', { serverIndexKey });
}
/**
* Opt-out purge: when the External Artwork toggle is turned off, drop every
* fetched external image + `.miss-*` marker + lookup row across all configured
* servers (Navidrome covers are left intact). Fire-and-forget; per-server
* failures are swallowed so one unreachable server can't block the rest.
*/
export async function purgeExternalArtworkAllServers(): Promise<void> {
const { servers } = useAuthStore.getState();
await Promise.all(
servers.map(s =>
invoke('cover_cache_purge_external', {
serverIndexKey: serverIndexKeyForProfile(s),
}).catch(() => undefined),
),
);
}
export async function coverCacheStatsServer(
serverIndexKey: string,
): Promise<Pick<CoverCacheStats, 'bytes' | 'entryCount'>> {
+2
View File
@@ -80,6 +80,7 @@ export interface SyncStateDto {
serverLastScanIso?: string | null;
indexesLastModifiedMs?: number | null;
artistsLastModifiedMs?: number | null;
ignoredArticles?: string | null;
localTrackCount?: number | null;
serverTrackCount?: number | null;
lastError?: string | null;
@@ -237,6 +238,7 @@ export interface LibraryArtistDto {
serverId: string;
id: string;
name: string;
nameSort?: string | null;
albumCount?: number | null;
syncedAt: number;
rawJson: unknown;
+22
View File
@@ -116,6 +116,21 @@ export interface OrbitState {
export const ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN = [1, 5, 10, 15, 30] as const;
export type OrbitShuffleIntervalMin = typeof ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN[number];
/**
* Host's playback-transition preferences, mirrored into the session so guests
* blend tracks the same way the host does (otherwise each client uses its own
* local transition settings, re-introducing the drift the Catch-Up button
* exists to fix). Optional on the wire a session hosted by a build that
* predates transition sync simply omits it, and guests keep their own.
*/
export interface OrbitTransitionSettings {
crossfadeEnabled: boolean;
crossfadeSecs: number;
crossfadeTrimSilence: boolean;
autodjSmoothSkip: boolean;
gaplessEnabled: boolean;
}
export interface OrbitSettings {
/** Guest suggestions go straight into the host's play queue. */
autoApprove: boolean;
@@ -127,6 +142,13 @@ export interface OrbitSettings {
* field fall back to 15 via `effectiveShuffleIntervalMs`.
*/
shuffleIntervalMin?: OrbitShuffleIntervalMin;
/**
* Host's track-transition prefs (crossfade / gapless / AutoDJ), refreshed
* every host tick from the host's own playback settings. Guests adopt these
* for the session and restore their own on leave. Optional: absent on
* pre-transition-sync sessions.
*/
transitions?: OrbitTransitionSettings;
}
export const ORBIT_DEFAULT_SETTINGS: OrbitSettings = {
+37 -1
View File
@@ -26,7 +26,7 @@ vi.mock('../utils/network/subsonicNetworkGuard', () => ({
}));
import axios from 'axios';
import { pingWithCredentials, ping } from './subsonic';
import { pingWithCredentials, pingWithCredentialsForProfile, ping } from './subsonic';
import { getAlbumInfo2 } from './subsonicAlbumInfo';
import { getStarred } from './subsonicStarRating';
import { search } from './subsonicSearch';
@@ -440,3 +440,39 @@ describe('pingWithCredentials — explicit URL/credentials path', () => {
expect(r.openSubsonic).toBe(false);
});
});
describe('pingWithCredentialsForProfile — custom gate headers', () => {
it('sends resolved custom headers on the ping request', async () => {
vi.mocked(axios.get).mockResolvedValue(okResponse({ type: 'navidrome' }));
await pingWithCredentialsForProfile(
{
url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10:4533',
username: 'u',
password: 'p',
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
customHeadersApplyTo: 'public',
},
'https://music.example.com',
);
const config = vi.mocked(axios.get).mock.calls[0]?.[1] as { headers?: Record<string, string> };
expect(config.headers?.['CF-Access-Client-Secret']).toBe('gate-secret');
});
it('omits gate headers when probing the LAN endpoint with applyTo=public', async () => {
vi.mocked(axios.get).mockResolvedValue(okResponse({}));
await pingWithCredentialsForProfile(
{
url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10:4533',
username: 'u',
password: 'p',
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
customHeadersApplyTo: 'public',
},
'http://192.168.0.10:4533',
);
const config = vi.mocked(axios.get).mock.calls[0]?.[1] as { headers?: Record<string, string> };
expect(config.headers?.['CF-Access-Client-Secret']).toBeUndefined();
});
});
+15
View File
@@ -31,11 +31,26 @@ describe('scheduleInstantMixProbeForServer (idempotency)', () => {
fetchMock.mockReset();
fetchMock.mockResolvedValue(['sonicSimilarity']);
reset();
useAuthStore.setState({
servers: [{
id: SID,
name: 'Probe',
url: 'https://music.example.com',
username: 'u',
password: 'p',
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
customHeadersApplyTo: 'public',
}],
} as never);
});
it('probes once, caches the result, then skips on the next poll', async () => {
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[3]).toMatchObject({
url: 'https://music.example.com',
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
});
await flush();
expect(useAuthStore.getState().audiomusePluginProbeByServer[SID]).toBe('present');
+56 -2
View File
@@ -1,6 +1,9 @@
import axios from 'axios';
import md5 from 'md5';
import { useAuthStore } from '../store/authStore';
import type { ServerProfile } from '../store/authStoreTypes';
import { headersForServerRequest } from '../utils/server/serverHttpHeaders';
import { findServerByIdOrIndexKey } from '../utils/server/serverLookup';
import {
type InstantMixProbeResult,
type SubsonicServerIdentity,
@@ -19,6 +22,7 @@ import {
api,
apiWithCredentials,
secureRandomSalt,
type ServerHttpHeaderProfile,
} from './subsonicClient';
import type { PingWithCredentialsResult, SubsonicSong } from './subsonicTypes';
@@ -61,6 +65,47 @@ export async function pingWithCredentials(
}
}
/** Profile-aware ping for connect probe — attaches custom headers per endpoint. */
export async function pingWithCredentialsForProfile(
profile: Pick<
ServerProfile,
'url' | 'alternateUrl' | 'username' | 'password' | 'customHeaders' | 'customHeadersApplyTo'
>,
endpointBaseUrl: string,
): Promise<PingWithCredentialsResult> {
try {
const base = endpointBaseUrl.startsWith('http')
? endpointBaseUrl.replace(/\/$/, '')
: `http://${endpointBaseUrl.replace(/\/$/, '')}`;
const salt = secureRandomSalt();
const token = md5(profile.password + salt);
const resp = await axios.get(`${base}/rest/ping.view`, {
params: {
u: profile.username,
t: token,
s: salt,
v: '1.16.1',
c: SUBSONIC_CLIENT,
f: 'json',
},
headers: headersForServerRequest(profile, endpointBaseUrl),
paramsSerializer: { indexes: null },
timeout: 15000,
});
const data = resp.data?.['subsonic-response'];
const ok = data?.status === 'ok';
return {
ok,
type: typeof data?.type === 'string' ? data.type : undefined,
serverVersion: typeof data?.serverVersion === 'string' ? data.serverVersion : undefined,
openSubsonic: data?.openSubsonic === true,
};
} catch (err) {
console.warn('[psysonic] pingWithCredentialsForProfile failed:', endpointBaseUrl, err);
return { ok: false };
}
}
const INSTANT_MIX_PROBE_RANDOM_SIZE = 8;
const INSTANT_MIX_PROBE_SIMILAR_COUNT = 12;
const INSTANT_MIX_PROBE_MAX_TRACKS = 4;
@@ -74,6 +119,7 @@ export async function probeInstantMixWithCredentials(
serverUrl: string,
username: string,
password: string,
headerProfile?: ServerHttpHeaderProfile,
): Promise<InstantMixProbeResult> {
try {
const data = await apiWithCredentials<{ randomSongs: { song: SubsonicSong | SubsonicSong[] } }>(
@@ -83,6 +129,7 @@ export async function probeInstantMixWithCredentials(
'getRandomSongs.view',
{ size: INSTANT_MIX_PROBE_RANDOM_SIZE, _t: Date.now() },
12000,
headerProfile,
);
const raw = data.randomSongs?.song;
const songs: SubsonicSong[] = !raw ? [] : Array.isArray(raw) ? raw : [raw];
@@ -98,6 +145,7 @@ export async function probeInstantMixWithCredentials(
'getSimilarSongs.view',
{ id: song.id, count: INSTANT_MIX_PROBE_SIMILAR_COUNT },
12000,
headerProfile,
);
const sRaw = simData.similarSongs?.song;
const list: SubsonicSong[] = !sRaw ? [] : Array.isArray(sRaw) ? sRaw : [sRaw];
@@ -138,6 +186,7 @@ export function scheduleInstantMixProbeForServer(
const ctx = buildCapabilityContext(identity);
const probeIds = neededProbeIds(SERVER_CAPABILITY_CATALOG, ctx);
const store = useAuthStore.getState();
const headerProfile = findServerByIdOrIndexKey(serverId);
if (probeIds.has(PROBE_OPENSUBSONIC_EXTENSIONS)) {
// One `getOpenSubsonicExtensions` fetch answers every extension-gated feature.
@@ -153,7 +202,12 @@ export function scheduleInstantMixProbeForServer(
const audiomuseStale = audiomuseEligible && (cached === undefined || cached === 'error');
if (force || listMissing || audiomuseStale) {
if (audiomuseEligible) store.setAudiomusePluginProbe(serverId, 'probing');
void fetchOpenSubsonicExtensionsWithCredentials(serverUrl, username, password).then(extensions => {
void fetchOpenSubsonicExtensionsWithCredentials(
serverUrl,
username,
password,
headerProfile,
).then(extensions => {
const st = useAuthStore.getState();
if (extensions === null) {
if (audiomuseEligible) st.setAudiomusePluginProbe(serverId, 'error');
@@ -170,7 +224,7 @@ export function scheduleInstantMixProbeForServer(
if (probeIds.has(PROBE_LEGACY_INSTANT_MIX)) {
const cached = store.instantMixProbeByServer[serverId];
if (force || cached === undefined || cached === 'error') {
void probeInstantMixWithCredentials(serverUrl, username, password).then(result =>
void probeInstantMixWithCredentials(serverUrl, username, password, headerProfile).then(result =>
useAuthStore.getState().setInstantMixProbe(serverId, result),
);
}
+16
View File
@@ -4,10 +4,17 @@ import { version } from '../../package.json';
import { useAuthStore } from '../store/authStore';
import type { ServerProfile } from '../store/authStoreTypes';
import { connectBaseUrlForServer } from '../utils/server/serverEndpoint';
import { headersForServerRequest } from '../utils/server/serverHttpHeaders';
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../utils/server/serverLookup';
export const SUBSONIC_CLIENT = `psysonic/${version}`;
/** Subset of `ServerProfile` needed to attach gate headers on credential-based REST calls. */
export type ServerHttpHeaderProfile = Pick<
ServerProfile,
'url' | 'alternateUrl' | 'customHeaders' | 'customHeadersApplyTo'
>;
export function secureRandomSalt(): string {
const buf = new Uint8Array(8);
crypto.getRandomValues(buf);
@@ -32,10 +39,13 @@ export async function apiWithCredentials<T>(
endpoint: string,
extra: Record<string, unknown> = {},
timeout = 15000,
headerProfile?: ServerHttpHeaderProfile,
): Promise<T> {
const params = { ...getAuthParams(username, password), ...extra };
const headers = headerProfile ? headersForServerRequest(headerProfile, serverUrl) : {};
const resp = await axios.get(`${restBaseFromUrl(serverUrl)}/${endpoint}`, {
params,
headers,
paramsSerializer: { indexes: null },
timeout,
});
@@ -78,6 +88,7 @@ export async function apiForServer<T>(
endpoint,
extra,
timeout,
server,
);
}
@@ -88,8 +99,13 @@ export async function api<T>(
signal?: AbortSignal,
): Promise<T> {
const { baseUrl, params } = getClient();
const server = useAuthStore.getState().getActiveServer();
const connectBase = useAuthStore.getState().getBaseUrl();
const headers =
server && connectBase ? headersForServerRequest(server, connectBase) : {};
const resp = await axios.get(`${baseUrl}/${endpoint}`, {
params: { ...params, ...extra },
headers,
paramsSerializer: { indexes: null },
timeout,
signal,
+10 -1
View File
@@ -1,4 +1,4 @@
import { apiWithCredentials } from './subsonicClient';
import { apiWithCredentials, type ServerHttpHeaderProfile } from './subsonicClient';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from './subsonicTypes';
export async function getSongWithCredentials(
@@ -6,6 +6,7 @@ export async function getSongWithCredentials(
username: string,
password: string,
id: string,
headerProfile?: ServerHttpHeaderProfile,
): Promise<SubsonicSong | null> {
try {
const data = await apiWithCredentials<{ song: SubsonicSong }>(
@@ -14,6 +15,8 @@ export async function getSongWithCredentials(
password,
'getSong.view',
{ id },
15000,
headerProfile,
);
return data.song ?? null;
} catch {
@@ -26,6 +29,7 @@ export async function getAlbumWithCredentials(
username: string,
password: string,
id: string,
headerProfile?: ServerHttpHeaderProfile,
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
const data = await apiWithCredentials<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>(
serverUrl,
@@ -33,6 +37,8 @@ export async function getAlbumWithCredentials(
password,
'getAlbum.view',
{ id },
15000,
headerProfile,
);
const { song, ...album } = data.album;
return { album, songs: song ?? [] };
@@ -43,6 +49,7 @@ export async function getArtistWithCredentials(
username: string,
password: string,
id: string,
headerProfile?: ServerHttpHeaderProfile,
): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
const data = await apiWithCredentials<{ artist: SubsonicArtist & { album: SubsonicAlbum[] } }>(
serverUrl,
@@ -50,6 +57,8 @@ export async function getArtistWithCredentials(
password,
'getArtist.view',
{ id },
15000,
headerProfile,
);
const { album, ...artist } = data.artist;
return { artist, albums: album ?? [] };
+11
View File
@@ -69,4 +69,15 @@ describe('fetchOpenSubsonicExtensionsWithCredentials', () => {
fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p'),
).resolves.toBeNull();
});
it('sends custom gate headers when a header profile is supplied', async () => {
vi.mocked(axios.get).mockResolvedValue(okExtensions([]));
await fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p', {
url: 'https://music.test',
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
customHeadersApplyTo: 'public',
});
const config = vi.mocked(axios.get).mock.calls[0]?.[1] as { headers?: Record<string, string> };
expect(config.headers?.['CF-Access-Client-Secret']).toBe('gate-secret');
});
});
+3 -1
View File
@@ -1,4 +1,4 @@
import { apiWithCredentials } from './subsonicClient';
import { apiWithCredentials, type ServerHttpHeaderProfile } from './subsonicClient';
export interface OpenSubsonicExtension {
name: string;
@@ -30,6 +30,7 @@ export async function fetchOpenSubsonicExtensionsWithCredentials(
serverUrl: string,
username: string,
password: string,
headerProfile?: ServerHttpHeaderProfile,
): Promise<string[] | null> {
try {
const data = await apiWithCredentials<{ openSubsonicExtensions?: unknown }>(
@@ -39,6 +40,7 @@ export async function fetchOpenSubsonicExtensionsWithCredentials(
'getOpenSubsonicExtensions.view',
{},
12000,
headerProfile,
);
return parseOpenSubsonicExtensions(data.openSubsonicExtensions).map(ext => ext.name);
} catch {
+24 -3
View File
@@ -1,11 +1,32 @@
import { api, apiForServer } from './subsonicClient';
import type { SubsonicSong } from './subsonicTypes';
export async function getPlayQueue(): Promise<{ current?: string; position?: number; songs: SubsonicSong[] }> {
export type PlayQueueResult = { current?: string; position?: number; songs: SubsonicSong[] };
function parsePlayQueueResponse(
data: { playQueue?: { current?: string; position?: number; entry?: SubsonicSong[] } },
): PlayQueueResult {
const pq = data.playQueue;
return { current: pq?.current, position: pq?.position, songs: pq?.entry ?? [] };
}
export async function getPlayQueue(): Promise<PlayQueueResult> {
try {
const data = await api<{ playQueue: { current?: string; position?: number; entry?: SubsonicSong[] } }>('getPlayQueue.view');
const pq = data.playQueue;
return { current: pq?.current, position: pq?.position, songs: pq?.entry ?? [] };
return parsePlayQueueResponse(data);
} catch {
return { songs: [] };
}
}
export async function getPlayQueueForServer(serverId: string): Promise<PlayQueueResult> {
if (!serverId) return { songs: [] };
try {
const data = await apiForServer<{ playQueue: { current?: string; position?: number; entry?: SubsonicSong[] } }>(
serverId,
'getPlayQueue.view',
);
return parsePlayQueueResponse(data);
} catch {
return { songs: [] };
}
+2
View File
@@ -161,6 +161,8 @@ export interface SubsonicNowPlaying extends SubsonicSong {
export interface SubsonicArtist {
id: string;
name: string;
/** Article-stripped lowercase sort key (local index / OpenSubsonic). */
nameSort?: string;
albumCount?: number;
coverArt?: string;
starred?: string;
+2
View File
@@ -59,6 +59,7 @@ import { useOfflineLibraryFilterSuspend } from '../hooks/useOfflineLibraryFilter
import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam';
import { IS_LINUX } from '../utils/platform';
import { useConnectionStatus } from '../hooks/useConnectionStatus';
import { useIdlePlayQueuePull } from '../hooks/useIdlePlayQueuePull';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import '../store/previewPlayerVolumeSync';
@@ -102,6 +103,7 @@ export function AppShell() {
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
const { status: connStatus, isRetrying: connRetrying, retry: connRetry, isLan, serverName } = useConnectionStatus();
useIdlePlayQueuePull(connStatus);
const navigate = useNavigate();
const location = useLocation();
const prevPathnameRef = useRef(location.pathname);
+198 -211
View File
@@ -1,10 +1,11 @@
import { createPortal } from 'react-dom';
import { type ReactNode } from 'react';
import { open } from '@tauri-apps/plugin-shell';
import { ArrowUpCircle, CheckCircle2, ChevronDown, Download, FolderOpen, RefreshCw, ShieldCheck, X } from 'lucide-react';
import { ArrowUpCircle, CheckCircle2, ChevronDown, Download, FolderOpen, RefreshCw, ShieldCheck } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { version as currentVersion } from '../../package.json';
import { formatBytes } from '../utils/format/formatBytes';
import { useAppUpdater } from '../hooks/useAppUpdater';
import Modal from './Modal';
import Changelog from './appUpdater/Changelog';
export default function AppUpdater() {
@@ -18,222 +19,208 @@ export default function AppUpdater() {
if (!release || dismissed) return null;
return createPortal(
<>
<div className="eq-popup-backdrop" onClick={() => setDismissed(true)} style={{ zIndex: 3000 }} />
<div
className="eq-popup update-modal"
style={{ zIndex: 3001 }}
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="eq-popup-header update-modal-header">
<ArrowUpCircle size={16} style={{ color: 'var(--accent)', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<span className="eq-popup-title">{t('common.updaterModalTitle')}</span>
<span className="update-modal-versions">
v{currentVersion} <strong>v{release.version}</strong>
</span>
</div>
<button
className="app-updater-dismiss"
onClick={() => setDismissed(true)}
data-tooltip={t('common.updaterRemindBtn')}
data-tooltip-pos="bottom"
>
<X size={14} />
// Footer actions — state-dependent. Downloading has no actions (no footer).
// When there is no in-app install (AUR / from-source), "Remind me later" is the
// primary action, so it gets the accent button; Skip stays a clear button.
let footer: ReactNode = null;
if (dlState === 'idle') {
footer = (
<>
<button className="btn btn-surface" onClick={handleSkip}>
{t('common.updaterSkipBtn')}
</button>
<div style={{ flex: 1 }} />
<button
className={`btn ${showInstallBtn ? 'btn-surface' : 'btn-primary'}`}
onClick={() => setDismissed(true)}
>
{t('common.updaterRemindBtn')}
</button>
{showInstallBtn && (
<button className="btn btn-primary" onClick={handleDownload}>
<Download size={14} />
{useTauriUpdater
? t('common.updaterInstallNow', { defaultValue: 'Install now' })
: t('common.updaterDownloadBtn')}
</button>
</div>
)}
</>
);
} else if (dlState === 'done' && useTauriUpdater) {
footer = (
<>
<div style={{ flex: 1 }} />
<button className="btn btn-primary" onClick={handleRestartNow}>
<RefreshCw size={14} />
{t('common.updaterRestartNow', { defaultValue: 'Restart now' })}
</button>
</>
);
} else if (dlState === 'done') {
footer = (
<>
<div style={{ flex: 1 }} />
<button className="btn btn-primary" onClick={() => setDismissed(true)}>
{t('common.updaterRemindBtn')}
</button>
</>
);
} else if (dlState === 'error') {
footer = (
<>
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
{t('common.updaterRemindBtn')}
</button>
<div style={{ flex: 1 }} />
<button className="btn btn-primary" onClick={handleDownload}>
{t('common.updaterRetryBtn')}
</button>
</>
);
}
{/* Scrollable body: changelog + download area — single overflow container */}
<div className="update-modal-body">
{/* Collapsible Changelog */}
{release.body && (
<div className="update-modal-changelog">
<button
type="button"
className="update-modal-changelog-toggle"
onClick={() => setChangelogOpen(v => !v)}
>
<ChevronDown
size={13}
style={{
transform: changelogOpen ? 'rotate(180deg)' : 'none',
transition: 'transform 0.2s',
flexShrink: 0,
}}
/>
{t('common.updaterChangelog')}
</button>
{changelogOpen && (
<div className="update-modal-changelog-body">
<Changelog body={release.body} />
</div>
)}
</div>
)}
{/* Download / AUR area */}
<div className="update-modal-download-area">
{showAurHint ? (
<div className="update-modal-aur">
<div className="update-modal-aur-title">{t('common.updaterAurHint')}</div>
<code className="update-modal-aur-cmd">yay -S psysonic-bin</code>
<code className="update-modal-aur-cmd update-modal-aur-alt">sudo pacman -Syu psysonic-bin</code>
</div>
) : useTauriUpdater ? (
<>
{dlState === 'idle' && (
<div className="update-modal-mac-info">
<div className="update-modal-mac-info-main">
{t('common.updaterMacReadyTitle', { defaultValue: 'Ready to install' })}
</div>
<div className="update-modal-mac-info-sub">
{t('common.updaterMacReady', {
defaultValue: 'The update downloads, verifies and applies in place — no DMG needed. The app restarts automatically when done.',
})}
</div>
<div className="update-modal-trust-badges">
<span className="update-modal-trust-badge">
<ShieldCheck size={12} />
{t('common.updaterTrustNotarized', { defaultValue: 'Notarized by Apple' })}
</span>
<span className="update-modal-trust-badge">
<CheckCircle2 size={12} />
{t('common.updaterTrustSignature', { defaultValue: 'Signature verified' })}
</span>
</div>
</div>
)}
{dlState === 'downloading' && (
<div className="update-modal-progress">
<div className="app-updater-progress-bar">
<div className="app-updater-progress-fill" style={{ width: `${pct}%` }} />
</div>
<span className="app-updater-pct">{pct}%</span>
<span className="update-modal-dl-bytes">
{formatBytes(dlProgress.bytes)}
{dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`}
</span>
</div>
)}
{dlState === 'done' && (
<div className="update-modal-done">
<CheckCircle2 size={32} className="update-modal-done-icon" />
<div className="update-modal-done-title">
{t('common.updaterMacDoneTitle', { defaultValue: 'Update installed' })}
</div>
<div className="update-modal-done-countdown">
{countdown !== null
? t('common.updaterRestartingIn', { defaultValue: 'Restarting in {{n}}s…', n: countdown })
: t('common.updaterRestarting', { defaultValue: 'Restarting…' })}
</div>
</div>
)}
{dlState === 'error' && (
<div className="app-updater-error">{dlError || t('common.updaterErrorMsg')}</div>
)}
</>
) : asset ? (
<>
{dlState === 'idle' && (
<div className="update-modal-asset">
<span className="update-modal-asset-name">{asset.name}</span>
<span className="update-modal-asset-size">{formatBytes(asset.size)}</span>
</div>
)}
{dlState === 'downloading' && (
<div className="update-modal-progress">
<div className="app-updater-progress-bar">
<div className="app-updater-progress-fill" style={{ width: `${pct}%` }} />
</div>
<span className="app-updater-pct">{pct}%</span>
<span className="update-modal-dl-bytes">
{formatBytes(dlProgress.bytes)}
{dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`}
</span>
</div>
)}
{dlState === 'done' && (
<div className="update-modal-done">
<div className="update-modal-done-title">{t('common.updaterDone')}</div>
<div className="update-modal-done-hint">{t('common.updaterInstallHint')}</div>
<button className="btn btn-surface update-modal-folder-btn" onClick={handleShowFolder}>
<FolderOpen size={14} />
{t('common.updaterShowFolder')}
</button>
</div>
)}
{dlState === 'error' && (
<div className="app-updater-error">{dlError || t('common.updaterErrorMsg')}</div>
)}
</>
) : (
<div className="update-modal-asset-none">
<button
className="app-updater-btn-primary"
onClick={() => open(`https://github.com/Psychotoxical/psysonic/releases/tag/${release.tag}`)}
>
{t('common.updaterOpenGitHub')}
</button>
return (
<Modal
open
onClose={() => setDismissed(true)}
icon={<ArrowUpCircle size={18} />}
title={t('common.updaterModalTitle')}
subtitle={<>v{currentVersion} <strong>v{release.version}</strong></>}
closeLabel={t('common.updaterRemindBtn')}
footer={footer}
>
{/* Collapsible changelog */}
{release.body && (
<div className="update-modal-changelog">
<button
type="button"
className="update-modal-changelog-toggle"
onClick={() => setChangelogOpen(v => !v)}
>
<ChevronDown
size={13}
style={{
transform: changelogOpen ? 'rotate(180deg)' : 'none',
transition: 'transform 0.2s',
flexShrink: 0,
}}
/>
{t('common.updaterChangelog')}
</button>
{changelogOpen && (
<div className="update-modal-changelog-body">
<Changelog body={release.body} />
</div>
)}
</div>
</div>{/* end update-modal-body */}
)}
{/* Footer buttons — state-dependent to avoid redundant/jumping buttons */}
<div className="update-modal-footer">
{dlState === 'idle' && (
<>
<button className="btn btn-ghost update-modal-skip" onClick={handleSkip}>
{t('common.updaterSkipBtn')}
</button>
<div style={{ flex: 1 }} />
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
{t('common.updaterRemindBtn')}
</button>
{showInstallBtn && (
<button className="btn btn-primary" onClick={handleDownload}>
<Download size={14} />
{useTauriUpdater
? t('common.updaterInstallNow', { defaultValue: 'Install now' })
: t('common.updaterDownloadBtn')}
{/* Download / AUR area */}
<div className="update-modal-download-area">
{showAurHint ? (
<div className="update-modal-aur">
<div className="update-modal-aur-title">{t('common.updaterAurHint')}</div>
<code className="update-modal-aur-cmd">yay -S psysonic-bin</code>
<code className="update-modal-aur-cmd update-modal-aur-alt">sudo pacman -Syu psysonic-bin</code>
</div>
) : useTauriUpdater ? (
<>
{dlState === 'idle' && (
<div className="update-modal-mac-info">
<div className="update-modal-mac-info-main">
{t('common.updaterMacReadyTitle', { defaultValue: 'Ready to install' })}
</div>
<div className="update-modal-mac-info-sub">
{t('common.updaterMacReady', {
defaultValue: 'The update downloads, verifies and applies in place — no DMG needed. The app restarts automatically when done.',
})}
</div>
<div className="update-modal-trust-badges">
<span className="update-modal-trust-badge">
<ShieldCheck size={12} />
{t('common.updaterTrustNotarized', { defaultValue: 'Notarized by Apple' })}
</span>
<span className="update-modal-trust-badge">
<CheckCircle2 size={12} />
{t('common.updaterTrustSignature', { defaultValue: 'Signature verified' })}
</span>
</div>
</div>
)}
{dlState === 'downloading' && (
<div className="update-modal-progress">
<div className="app-updater-progress-bar">
<div className="app-updater-progress-fill" style={{ width: `${pct}%` }} />
</div>
<span className="app-updater-pct">{pct}%</span>
<span className="update-modal-dl-bytes">
{formatBytes(dlProgress.bytes)}
{dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`}
</span>
</div>
)}
{dlState === 'done' && (
<div className="update-modal-done">
<CheckCircle2 size={32} className="update-modal-done-icon" />
<div className="update-modal-done-title">
{t('common.updaterMacDoneTitle', { defaultValue: 'Update installed' })}
</div>
<div className="update-modal-done-countdown">
{countdown !== null
? t('common.updaterRestartingIn', { defaultValue: 'Restarting in {{n}}s…', n: countdown })
: t('common.updaterRestarting', { defaultValue: 'Restarting…' })}
</div>
</div>
)}
{dlState === 'error' && (
<div className="app-updater-error">{dlError || t('common.updaterErrorMsg')}</div>
)}
</>
) : asset ? (
<>
{dlState === 'idle' && (
<div className="update-modal-asset">
<span className="update-modal-asset-name">{asset.name}</span>
<span className="update-modal-asset-size">{formatBytes(asset.size)}</span>
</div>
)}
{dlState === 'downloading' && (
<div className="update-modal-progress">
<div className="app-updater-progress-bar">
<div className="app-updater-progress-fill" style={{ width: `${pct}%` }} />
</div>
<span className="app-updater-pct">{pct}%</span>
<span className="update-modal-dl-bytes">
{formatBytes(dlProgress.bytes)}
{dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`}
</span>
</div>
)}
{dlState === 'done' && (
<div className="update-modal-done">
<div className="update-modal-done-title">{t('common.updaterDone')}</div>
<div className="update-modal-done-hint">{t('common.updaterInstallHint')}</div>
<button className="btn btn-surface update-modal-folder-btn" onClick={handleShowFolder}>
<FolderOpen size={14} />
{t('common.updaterShowFolder')}
</button>
)}
</>
)}
{dlState === 'downloading' && <div style={{ flex: 1 }} />}
{dlState === 'done' && useTauriUpdater && (
<>
<div style={{ flex: 1 }} />
<button className="btn btn-primary" onClick={handleRestartNow}>
<RefreshCw size={14} />
{t('common.updaterRestartNow', { defaultValue: 'Restart now' })}
</button>
</>
)}
{dlState === 'done' && !useTauriUpdater && (
<>
<div style={{ flex: 1 }} />
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
{t('common.updaterRemindBtn')}
</button>
</>
)}
{dlState === 'error' && (
<>
<div style={{ flex: 1 }} />
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
{t('common.updaterRemindBtn')}
</button>
<button className="btn btn-primary" onClick={handleDownload}>
{t('common.updaterRetryBtn')}
</button>
</>
)}
</div>
</div>
)}
{dlState === 'error' && (
<div className="app-updater-error">{dlError || t('common.updaterErrorMsg')}</div>
)}
</>
) : (
<div className="update-modal-asset-none">
<button
className="app-updater-btn-primary"
onClick={() => open(`https://github.com/Psychotoxical/psysonic/releases/tag/${release.tag}`)}
>
{t('common.updaterOpenGitHub')}
</button>
</div>
)}
</div>
</>,
document.body
</Modal>
);
}
+61 -27
View File
@@ -1,10 +1,12 @@
import type { ServerProfile } from '../store/authStoreTypes';
import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import type React from 'react';
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Check, ChevronDown } from 'lucide-react';
import { ConnectionStatus } from '../hooks/useConnectionStatus';
import { Check, ChevronDown, RefreshCw } from 'lucide-react';
import type { ConnectionStatus } from '../hooks/useConnectionStatus';
import { usePlayQueueSyncLedState } from '../hooks/usePlayQueueSyncLedState';
import type { ServerProfile } from '../store/authStoreTypes';
import { useAuthStore } from '../store/authStore';
import { switchActiveServer } from '../utils/server/switchActiveServer';
import { showToast } from '../utils/ui/toast';
@@ -21,6 +23,14 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
const navigate = useNavigate();
const servers = useAuthStore(s => s.servers);
const activeServerId = useAuthStore(s => s.activeServerId);
const {
ledVariant,
localQueueSyncPaused,
queueHandoffReason,
pullInFlight,
syncRingVisible,
pullFromActiveServer,
} = usePlayQueueSyncLedState(status);
const [menuOpen, setMenuOpen] = useState(false);
const [switchingId, setSwitchingId] = useState<string | null>(null);
const [menuFixed, setMenuFixed] = useState({ top: 0, right: 0 });
@@ -51,9 +61,9 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
useEffect(() => {
if (!menuOpen) return;
const onDown = (e: MouseEvent) => {
const t = e.target as Node;
if (hostRef.current?.contains(t)) return;
if (menuPanelRef.current?.contains(t)) return;
const target = e.target as Node;
if (hostRef.current?.contains(target)) return;
if (menuPanelRef.current?.contains(target)) return;
setMenuOpen(false);
};
const onKey = (e: KeyboardEvent) => {
@@ -72,7 +82,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
navigate('/settings', { state: { tab: 'servers' } });
};
const onTriggerClick = () => {
const onMetaClick = () => {
if (!multi) {
goServerSettings();
return;
@@ -80,6 +90,12 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
setMenuOpen(o => !o);
};
const onSyncClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (status !== 'connected') return;
void pullFromActiveServer();
};
const onPickServer = async (srv: ServerProfile) => {
if (srv.id === activeServerId) {
setMenuOpen(false);
@@ -97,28 +113,46 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
};
const label = isLan ? 'LAN' : t('connection.extern');
const tooltip = multi
? t('connection.switchServerHint')
: status === 'connected'
? t('connection.connectedTo', { server: serverName })
: status === 'disconnected'
? t('connection.disconnectedFrom', { server: serverName })
: t('connection.checking');
const tooltip = pullInFlight
? t('connection.queuePulling')
: ledVariant === 'queue-handoff'
? localQueueSyncPaused && !queueHandoffReason
? t('connection.queueLocalEditHint')
: t('connection.queuePullHint', { server: serverName })
: ledVariant === 'connected'
? t('connection.queueSynced')
: multi
? t('connection.switchServerHint')
: status === 'connected'
? t('connection.connectedTo', { server: serverName })
: status === 'disconnected'
? t('connection.disconnectedFrom', { server: serverName })
: t('connection.checking');
return (
<div className="connection-indicator-host" ref={hostRef}>
<div
className="connection-indicator"
style={{ cursor: 'pointer' }}
onClick={onTriggerClick}
data-tooltip={tooltip}
data-tooltip-pos="bottom"
role={multi ? 'button' : undefined}
aria-haspopup={multi ? 'menu' : undefined}
aria-expanded={multi ? menuOpen : undefined}
>
<div className={`connection-led connection-led--${status}`} />
<div className="connection-meta">
<div className="connection-indicator">
<button
type="button"
className={`connection-sync-btn${syncRingVisible ? ' connection-sync-btn--visible' : ''}${pullInFlight ? ' connection-sync-btn--busy' : ''}`}
onClick={onSyncClick}
disabled={status !== 'connected' || pullInFlight}
data-tooltip={tooltip}
data-tooltip-pos="bottom"
aria-label={t('connection.queuePullAria')}
>
<RefreshCw size={13} className="connection-sync-icon" aria-hidden />
<div className={`connection-led connection-led--${ledVariant}`} />
</button>
<div
className="connection-meta connection-meta--clickable"
onClick={onMetaClick}
data-tooltip={multi ? t('connection.switchServerHint') : undefined}
data-tooltip-pos="bottom"
role={multi ? 'button' : undefined}
aria-haspopup={multi ? 'menu' : undefined}
aria-expanded={multi ? menuOpen : undefined}
>
<span className="connection-type">{label}</span>
<span className="connection-server" style={{ display: 'flex', alignItems: 'center', gap: 4, maxWidth: 120 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{serverName}</span>
+52
View File
@@ -0,0 +1,52 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { cleanup, screen } from '@testing-library/react';
import { renderWithProviders } from '../test/helpers/renderWithProviders';
import Equalizer from './Equalizer';
import { useEqStore } from '../store/eqStore';
const FLAT = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
function resetEq(over: Record<string, unknown> = {}): void {
useEqStore.setState({
gains: [...FLAT],
enabled: true,
preGain: 0,
activePreset: 'Flat',
customPresets: [],
...over,
});
}
describe('Equalizer preset picker', () => {
beforeEach(() => resetEq());
afterEach(() => cleanup());
it('shows the active AutoEQ profile name in the picker', () => {
// AutoEQ sets activePreset to the headphone name — neither a built-in nor a
// saved custom preset. It must still be visible in the picker.
resetEq({ activePreset: 'Sennheiser HD 600', customPresets: [] });
const { container } = renderWithProviders(<Equalizer />);
expect(screen.getByText('Sennheiser HD 600')).toBeInTheDocument();
// AutoEQ profiles are not deletable custom presets → no delete button.
expect(container.querySelector('[data-tooltip="Delete preset"]')).toBeNull();
});
it('shows the delete button only for a saved custom preset', () => {
resetEq({
activePreset: 'My Mix',
customPresets: [{ name: 'My Mix', gains: [...FLAT], builtin: false }],
});
const { container } = renderWithProviders(<Equalizer />);
expect(screen.getByText('My Mix')).toBeInTheDocument();
expect(container.querySelector('[data-tooltip="Delete preset"]')).not.toBeNull();
});
it('shows no delete button for a built-in preset', () => {
resetEq({ activePreset: 'Rock' });
const { container } = renderWithProviders(<Equalizer />);
expect(container.querySelector('[data-tooltip="Delete preset"]')).toBeNull();
});
});

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