Compare commits

...

127 Commits

Author SHA1 Message Date
github-actions[bot] f54417aded chore(nix): refresh lock + npmDepsHash for v1.49.0 2026-06-29 00:30:42 +00:00
github-actions[bot] 30e4f9a9bb chore(release): finalize release version 1.49.0 2026-06-29 00:12:29 +00:00
github-actions[bot] 9710f59584 chore(nix): refresh lock + npmDepsHash for v1.49.0-rc.3 2026-06-28 23:56:58 +00:00
github-actions[bot] b40961fa32 chore(release): bump next channel to 1.49.0-rc.3 2026-06-28 23:39:01 +00:00
cucadmuh 6b1a898151 docs(release): sync 1.49.0 CHANGELOG, What's New, and credits (#1211)
Set the 1.49.0 release date, fix Added PR order for Polish (#1185),
refresh WHATS_NEW highlights for missing user-facing fixes, and credit
ImAsra for WinGet release automation (#1077).
2026-06-29 02:33:40 +03:00
Psychotoxical 19860672eb Update TELEMETRY.md (#1210)
added fanart to external services
2026-06-28 23:20:22 +02:00
Psychotoxical 2b80a87bfc refactor(settings): shared id-based reorder for the customizer panels (#1207)
Extract the drag-to-reorder boilerplate the sidebar, artist-layout, lyrics,
queue-toolbar and servers customizers each hand-rolled into a single
`useListReorderDnd` hook plus an id-based `applyListReorderById`, and migrate
all five onto it. Reorders resolve by stable item id, never positional index,
so a render filter can never share an index space with the reorder and desync
it (the class behind #1164). No user-facing change.

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

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

* docs(changelog): note sidebar reorder fix (#1206)
2026-06-28 15:42:35 +02:00
Psychotoxical 184501744b fix(cover): restore full-resolution album and artist covers (#1205)
* fix(cover): build cover tiers from the full-resolution source

Derive the larger tiers from the decoded download instead of re-reading the just-written smaller tier (resize never upscales, so 512/800 were stored at the small resolution). Resolve the full-res 2000 tier exactly so it is actually downloaded and cached. Bump the cache layout stamp to drop already-poisoned tiers.

* fix(cover): open the cover lightbox at full resolution

Require the exact requested tier for the full-res helper so a smaller warmed tier no longer pins the lightbox to a downscaled image. Race the 2000 fetch against a 500ms opening window, show the 800 tier meanwhile, and persist 2000 for the next open. Adds an opening animation (reduced-motion aware) and tests.

* docs(changelog): note full-resolution cover fix (#1205)

* fix(cover): keep full-res peek exact in peek_batch and the grid seeder

Review follow-up: ensure-path peek alone left a hole — cover_cache_peek_batch still laddered a 2000 request down to a smaller tier, and the in-memory grid seeder wrote that smaller path under the 2000 key, so Hero/fullscreen/lightbox surfaces (which peek 2000 before ensure) still showed a downscaled cover. Share one exact-2000 rule (peek_plain_cover_tier) across ensure and peek_batch, and never seed the full-res key from a smaller tier file.
2026-06-28 04:23:12 +02:00
cucadmuh 281e86fd3b fix(queue): persist timeline play history across queue replace (#1096) (#1204)
* fix(queue): persist timeline play history across queue replace (#1096)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Replace in-place push with a fresh array so getSnapshot returns a new
reference and React re-renders on live appends without a playerStore update.
2026-06-28 02:08:02 +00:00
Psychotoxical 979eb85ad1 docs(whats-new): refresh the 1.49.0 release highlights (#1203) 2026-06-27 22:36:16 +02:00
Psychotoxical 604cdd54d6 feat(updater): show the WinGet update command in the Windows update modal (#1202)
* feat(updater): show WinGet update command in the Windows update modal

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

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

* docs(changelog): Windows update notice WinGet moderation delay
2026-06-27 14:50:37 +02:00
Psychotoxical ed02ba4e1d feat(titlebar): theme-colored window bar on macOS (#1199)
* feat(titlebar): theme-colored window bar on macOS

macOS showed the grey native title bar that ignores the app theme
(#1198). Enable titleBarStyle: Overlay so the webview reaches the top
edge and the native traffic lights float over the existing in-page
title bar, which already paints with the active theme.

Reuses the Linux title-bar grid: the bar is shown on macOS too (hidden
in native fullscreen), without the custom window buttons since the
traffic lights stay native. The GTK resize grips are now Linux-only.
Renamed the stylesheet to drop the misleading linux-only name.

* docs(changelog): macOS themed title bar

* fix(titlebar): crisp macOS bar text — drop transform layer and shadow

* fix(titlebar): drop now-playing text from the macOS bar
2026-06-27 14:17:55 +02:00
ImAsra c609beddfa add GitHub Action to automate WinGet package updates (#1077)
* Add GitHub Actions workflow for WinGet publishing

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

* Add GitHub Actions workflow for WinGet publishing

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Corrections to the settings. Translated help page

* Translated orbit. Made corrections to help and settings

* playlists translated

* deviceSync translated

* search translated

* Statistics translated

* musicNetwork translated

* common translated

* player translated

* queue translated

* albumDetails translated

* connection and random mix translated

* artistDefails translated

* albums translated

* smartPlaylists translated

* nowPlaying translated. smartPlaylists corrections

* sidebar translated

* contextMenu translated

* translated radio

* translated sharePaste

* favorites translated

* nowPlayingInfo translated

* translated home and login

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

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

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

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

* settings updated with new strings. Deleted old unecessary ones

* translated missing translations

* Fixed typos

* translated missing strings

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs(changelog): expand the album genres entry

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

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

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

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

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

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

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

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

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

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

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

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

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

Behaviour unchanged; the table layouts and the playback-rate-* internals stay as
they are, only the surrounding sub-card is added for visual consistency.
2026-06-24 23:13:10 +02:00
cucadmuh 4e8f16b8ae docs: refresh WHATS_NEW and CHANGELOG for 1.49.0 (#1180) 2026-06-25 00:04:16 +03:00
cucadmuh 7b9e676af7 chore(library): CJK artist name_sort regression tests (#1178) 2026-06-24 23:27:26 +03:00
cucadmuh ec98fcc4ff fix(genres): hide empty genres after library resync (#1176) 2026-06-24 23:07:22 +03:00
Psychotoxical 1031590742 fix(settings): align AutoDJ and Hi-Res sub-options with the Normalization design (#1175)
* fix(settings): box the AutoDJ and Hi-Res sub-options like Normalization

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

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

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

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

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

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

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

* docs(changelog): credit the reporter for the context menu fixes
2026-06-24 21:19:04 +02:00
cucadmuh 58e6efc68c feat(autodj): configurable overlap cap (auto or 2–30 s) (#1173) 2026-06-24 22:00:35 +03:00
Psychotoxical c3e6be537c fix(artist): show fanart in the artist-detail hero, not the Navidrome cover (#1172)
* fix(cover): report a genuine miss for external artist surfaces

When external artwork is enabled, the fanart/banner ensure fell through to
the Navidrome cover and returned it as a hit on a fanart.tv miss. That made
'this artist has no banner' indistinguishable from a real hit, so the
artist-detail hero short-circuited on the ND cover and never reached the
fanart it actually had. Return hit=false on an external-surface miss instead;
the Navidrome fallback is the caller's job, since each surface has its own
chain (hero: banner->fanart->ND, fullscreen player: fanart->ND).

* fix(artist): hero background banner->fanart->Navidrome with raised focal point

The hero header background now steps through banner -> fanart -> Navidrome
artist cover, resolving the ND cover the same way the fullscreen player does.
Previously it had no Navidrome stage at all and, combined with the backend
masking a banner miss as a hit, showed the ND cover where the fanart belonged.

Also raise the focal point (background-position: center 30%) for the portrait-ish
fanart/ND images so the band's heads stay in frame on wide (2K+) viewports,
where 'cover' scales them up and overflows vertically. The wide banner strip
keeps the shared center. Done via a scoped inline style; .album-detail-bg stays
untouched for the album/playlist headers that share it.

* docs(changelog): artist header external-background fallback fix (#1172)
2026-06-24 20:41:16 +02:00
cucadmuh 09f24beb32 feat(audio): hi-res transition blend rate for crossfade, AutoDJ, and gapless (#1171) 2026-06-24 21:04:42 +03:00
Psychotoxical 0b7b5df30c test(ui): smoke-test the #1165 hook-guard split and recent-search rename (#1168)
* test(cover): smoke-test CoverArtImage hook-guard split (#1165)

* test(search): smoke-test MobileSearchOverlay recent-search (#1165)
2026-06-24 17:49:13 +02:00
cucadmuh de36f79e46 chore(ci): ESLint workflow and path-aware ci-ok merge gate (#1170) 2026-06-24 18:42:59 +03:00
Psychotoxical 53a4bf9330 fix(macos): pad dock icon to Apple icon grid (#1169)
* fix(macos): pad dock icon to Apple icon grid

The macOS .icns artwork filled the canvas edge-to-edge, so the dock icon
rendered larger than native apps. Replace icon.icns with a build whose artwork
is scaled to an ~824px body centred on a 1024px transparent canvas (Apple's
icon grid). macOS-only asset; Windows .ico and Linux PNGs unchanged. Refs #1166.

* fix(macos): skip the icns app-icon in dev to avoid a launch crash

Tauri's dev-only macOS path sets the app icon from icon.icns via
NSImage::initWithData(...).expect(...) (RunEvent::Ready); the padded icns
makes that return nil and abort at launch — release builds are unaffected.
Add a dev config override (tauri.dev.conf.json) that drops icon.icns from
bundle.icon so the dev app icon falls back to a PNG; tauri:dev passes it via
--config. Production bundling keeps the padded icon.icns. Refs #1166.

* docs(changelog): add entry for macOS dock icon padding (PR #1169)
2026-06-24 16:00:15 +02:00
Psychotoxical 7c724a642f chore(eslint): add ESLint toolchain and clean src to strict 0/0 (#1165)
* chore(eslint): add eslint toolchain and configs

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

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

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

* chore(eslint): clear unused vars in utils

* chore(eslint): clear unused vars in store

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

* chore(eslint): clear unused vars in components

* chore(eslint): clear unused vars in pages

* chore(eslint): remove explicit any in src

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

* chore(eslint): zero gradual config on src

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

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

* chore(eslint): strict hook rules in components

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(cover): enable banner surface in ensureArgsFromRef

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Signed-off-by: Soli0222 <github@str08.net>
Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
2026-06-20 13:52:10 +02:00
cucadmuh 2d1a078186 fix(queue): push edited queue when playback starts on paused client (#1133) 2026-06-20 01:15:28 +03:00
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
Psychotoxical 07232cea9a fix(fullscreen-player): honour the Lyrics scroll style setting (#1089)
The rebuilt fullscreen player hardcoded Apple-style lyrics scrolling (active
line ~35% from the top) and ignored Settings -> Lyrics -> Lyrics scroll style,
which still drives the sidebar/mobile LyricsPane. FsLyricsApple now reads
sidebarLyricsStyle: 'classic' centres the active line, 'apple' keeps the 35%
anchor, matching the sidebar lyrics.
2026-06-14 13:37:25 +02:00
cucadmuh 6f555bdc96 docs: sync WHATS_NEW with 1.48 changelog, fix Fixed PR order (#1087)
Add highlights for Live status dots, playback Semitones, Linux title bar
styles, Music Network self-hosted scrobble fix, and Navidrome radio admin
gating. Remove meta What's New page section. Re-sort CHANGELOG Fixed
entries (#1085/#1086 were at the top).
2026-06-14 03:16:31 +03:00
Psychotoxical c1403f8bd6 feat: now-playing liveness dot + admin-gated radio management (#1086)
* feat(now-playing): liveness indicator dot in the listening popover

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Bundle spawn_legacy_stream_start_when_armed parameters into
LegacyStreamStartWhenArmed so workspace clippy passes.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Add the scrobble-destination wires and presets:

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

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

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

Wire the framework together behind a single facade:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Was appended to Psychotoxical's contributions array by mistake; CHANGELOG
already credited cucadmuh (gh pr view author).
2026-06-10 00:25:30 +03:00
1110 changed files with 46145 additions and 7989 deletions
+187
View File
@@ -0,0 +1,187 @@
/**
* Path-aware ci-ok gate: wait for required workflow job checks on a ref, then pass
* or fail. Mirrors path filters in frontend-tests.yml, eslint.yml, rust-tests.yml.
*/
const FRONTEND_PATH_RE =
/^(src\/|package\.json$|package-lock\.json$|vitest\.config\.ts$|vite\.config\.ts$|tsconfig\.json$|eslint\.config\.mjs$|\.github\/workflows\/frontend-tests\.yml$|\.github\/workflows\/eslint\.yml$|\.github\/frontend-hot-path-files\.txt$|scripts\/check-frontend-hot-path-coverage\.sh$|scripts\/check-css-import-graph\.mjs$)/;
const RUST_PATH_RE = /^(src-tauri\/|\.github\/workflows\/rust-tests\.yml$)/;
const FRONTEND_JOBS = [
'vitest run',
'tsc --noEmit',
'vitest --coverage (baseline + hot-path file gate)',
'eslint',
];
const RUST_JOBS = [
'cargo test --workspace',
'cargo clippy --workspace',
'cargo llvm-cov (baseline + hot-path file gate)',
];
const POLL_MS = 30_000;
const TIMEOUT_MS = 90 * 60 * 1000;
const OK_CONCLUSIONS = new Set(['success', 'neutral', 'skipped']);
export function pathTriggersFrontend(file) {
return FRONTEND_PATH_RE.test(file);
}
export function pathTriggersRust(file) {
return RUST_PATH_RE.test(file);
}
export function requiredJobNames(changedFiles) {
const names = [];
if (changedFiles.some(pathTriggersFrontend)) {
names.push(...FRONTEND_JOBS);
}
if (changedFiles.some(pathTriggersRust)) {
names.push(...RUST_JOBS);
}
return names;
}
export function newestChecksByName(checks, excludeRunId) {
const newest = new Map();
for (const check of checks) {
if (excludeRunId && (check.details_url || '').includes(`/actions/runs/${excludeRunId}/`)) {
continue;
}
const key = check.name;
const prev = newest.get(key);
if (!prev) {
newest.set(key, check);
continue;
}
const prevTime = Date.parse(prev.started_at || prev.completed_at || '') || 0;
const curTime = Date.parse(check.started_at || check.completed_at || '') || 0;
if (curTime >= prevTime) {
newest.set(key, check);
}
}
return newest;
}
export function evaluateRequiredJobs(required, newestByName) {
const pending = [];
const failures = [];
for (const name of required) {
const latest = newestByName.get(name);
if (!latest) {
pending.push(`${name}: not started`);
continue;
}
if (latest.status !== 'completed') {
pending.push(`${name}: status=${latest.status}`);
continue;
}
if (!OK_CONCLUSIONS.has(latest.conclusion || '')) {
failures.push(`${name}: conclusion=${latest.conclusion}`);
}
}
return { pending, failures, done: pending.length === 0 && failures.length === 0 };
}
export async function listChangedFiles(github, context) {
const { owner, repo } = context.repo;
if (context.eventName === 'pull_request') {
const files = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: context.payload.pull_request.number,
per_page: 100,
});
return files.map((f) => f.filename);
}
if (context.eventName === 'push') {
const before = context.payload.before;
const after = context.sha;
if (!before || /^0+$/.test(before)) {
const commit = await github.rest.repos.getCommit({ owner, repo, ref: after });
return commit.data.files?.map((f) => f.filename) ?? [];
}
const compare = await github.rest.repos.compareCommits({
owner,
repo,
base: before,
head: after,
});
return compare.data.files?.map((f) => f.filename) ?? [];
}
if (context.eventName === 'workflow_run') {
const pr = context.payload.workflow_run.pull_requests?.[0];
if (pr?.number) {
const files = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: pr.number,
per_page: 100,
});
return files.map((f) => f.filename);
}
const headSha = context.payload.workflow_run.head_sha;
const commit = await github.rest.repos.getCommit({ owner, repo, ref: headSha });
return commit.data.files?.map((f) => f.filename) ?? [];
}
return [];
}
export function resolveTargetSha(context) {
if (context.eventName === 'pull_request') {
return context.payload.pull_request.head.sha;
}
if (context.eventName === 'workflow_run') {
return context.payload.workflow_run.head_sha;
}
return context.sha;
}
export async function runCiOkAggregate(github, context, core) {
const { owner, repo } = context.repo;
const sha = resolveTargetSha(context);
const excludeRunId = String(context.runId);
const changedFiles = await listChangedFiles(github, context);
const required = requiredJobNames(changedFiles);
core.info(`ci-ok @ ${sha}; ${changedFiles.length} changed file(s)`);
if (required.length === 0) {
core.info('No path-filtered test workflows apply — ci-ok passes.');
return;
}
core.info(`Waiting for required job checks: ${required.join(', ')}`);
const deadline = Date.now() + TIMEOUT_MS;
while (Date.now() < deadline) {
const checksAll = await github.paginate(github.rest.checks.listForRef, {
owner,
repo,
ref: sha,
per_page: 100,
});
const newestByName = newestChecksByName(checksAll, excludeRunId);
const { pending, failures, done } = evaluateRequiredJobs(required, newestByName);
if (failures.length > 0) {
core.setFailed(`Required checks failed:\n${failures.join('\n')}`);
return;
}
if (done) {
core.info('All required checks are green.');
return;
}
core.info(`Pending (${pending.length}): ${pending.join('; ')}`);
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
}
core.setFailed(`Timed out after ${TIMEOUT_MS / 60_000} minutes waiting for: ${required.join(', ')}`);
}
+54
View File
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
evaluateRequiredJobs,
newestChecksByName,
pathTriggersFrontend,
pathTriggersRust,
requiredJobNames,
} from './ci-ok-aggregate.mjs';
test('pathTriggersFrontend matches frontend workflow paths', () => {
assert.equal(pathTriggersFrontend('src/App.tsx'), true);
assert.equal(pathTriggersFrontend('eslint.config.mjs'), true);
assert.equal(pathTriggersFrontend('README.md'), false);
});
test('pathTriggersRust matches rust workflow paths', () => {
assert.equal(pathTriggersRust('src-tauri/src/lib.rs'), true);
assert.equal(pathTriggersRust('src/App.tsx'), false);
});
test('requiredJobNames unions frontend and rust jobs', () => {
const names = requiredJobNames(['src/foo.ts', 'src-tauri/bar.rs']);
assert.ok(names.includes('eslint'));
assert.ok(names.includes('cargo test --workspace'));
});
test('evaluateRequiredJobs fails on red conclusions', () => {
const newest = newestChecksByName([
{
name: 'eslint',
status: 'completed',
conclusion: 'failure',
started_at: '2026-01-01T00:00:00Z',
details_url: '',
},
]);
const result = evaluateRequiredJobs(['eslint'], newest);
assert.equal(result.done, false);
assert.equal(result.failures.length, 1);
});
test('evaluateRequiredJobs passes when all required jobs succeeded', () => {
const checks = ['eslint', 'vitest run'].map((name) => ({
name,
status: 'completed',
conclusion: 'success',
started_at: '2026-01-01T00:00:00Z',
details_url: '',
}));
const result = evaluateRequiredJobs(['eslint', 'vitest run'], newestChecksByName(checks));
assert.equal(result.done, true);
});
+19 -4
View File
@@ -1,15 +1,30 @@
name: ci-main
on:
push:
branches: [main]
pull_request:
branches: [main]
push:
branches: [main]
workflow_run:
workflows: [frontend-tests, rust-tests, eslint]
types: [completed]
workflow_dispatch:
permissions:
contents: read
checks: read
pull-requests: read
jobs:
ci-ok:
runs-on: ubuntu-latest
steps:
- name: ci sentinel
run: echo "main CI sentinel is green"
- uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.head.sha || github.event.workflow_run.head_sha || github.sha }}
- name: aggregate required checks
uses: actions/github-script@v9
with:
script: |
const { runCiOkAggregate } = await import('${{ github.workspace }}/.github/scripts/ci-ok-aggregate.mjs');
await runCiOkAggregate(github, context, core);
+49
View File
@@ -0,0 +1,49 @@
name: eslint
on:
pull_request:
branches: [main]
paths:
- 'src/**'
- 'package.json'
- 'package-lock.json'
- 'vitest.config.ts'
- 'vite.config.ts'
- 'tsconfig.json'
- 'eslint.config.mjs'
- '.github/workflows/eslint.yml'
- '.github/frontend-hot-path-files.txt'
- 'scripts/check-frontend-hot-path-coverage.sh'
- 'scripts/check-css-import-graph.mjs'
push:
branches: [main]
paths:
- 'src/**'
- 'package.json'
- 'package-lock.json'
- 'vitest.config.ts'
- 'vite.config.ts'
- 'tsconfig.json'
- 'eslint.config.mjs'
- '.github/workflows/eslint.yml'
- '.github/frontend-hot-path-files.txt'
- 'scripts/check-frontend-hot-path-coverage.sh'
- 'scripts/check-css-import-graph.mjs'
workflow_dispatch:
permissions:
contents: read
jobs:
eslint:
name: eslint
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 'lts/*'
cache: 'npm'
- run: npm ci
- name: eslint
run: npm run lint
+4
View File
@@ -10,6 +10,7 @@ on:
- 'vitest.config.ts'
- 'vite.config.ts'
- 'tsconfig.json'
- 'eslint.config.mjs'
- '.github/workflows/frontend-tests.yml'
- '.github/frontend-hot-path-files.txt'
- 'scripts/check-frontend-hot-path-coverage.sh'
@@ -23,6 +24,7 @@ on:
- 'vitest.config.ts'
- 'vite.config.ts'
- 'tsconfig.json'
- 'eslint.config.mjs'
- '.github/workflows/frontend-tests.yml'
- '.github/frontend-hot-path-files.txt'
- 'scripts/check-frontend-hot-path-coverage.sh'
@@ -56,6 +58,7 @@ jobs:
node-version: 'lts/*'
cache: 'npm'
- run: npm ci
- run: npm run prebuild:release-notes
- name: tsc
run: npx tsc --noEmit
@@ -71,6 +74,7 @@ jobs:
- name: install jq
run: sudo apt-get update && sudo apt-get install -y jq
- run: npm ci
- run: npm run prebuild:release-notes
- name: vitest run --coverage
run: npx vitest run --coverage
- name: hot-path file coverage gate
+28 -6
View File
@@ -176,18 +176,32 @@ jobs:
- name: extract changelog
id: changelog
run: |
set -euo pipefail
VERSION="${{ steps.get-version.outputs.version }}"
BODY=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
if [ -z "$BODY" ]; then
BASE_VERSION="$(node -e 'const v=process.argv[1]; const m=v.match(/^(\d+\.\d+\.\d+)/); if(m){process.stdout.write(m[1]);}' "$VERSION")"
if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "$VERSION" ]; then
BODY=$(awk "/^## \[$BASE_VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
fi
BODY=""
if node scripts/extract-release-section.mjs CHANGELOG.md "$VERSION" --allow-empty > /tmp/changelog-body.md; then
BODY="$(cat /tmp/changelog-body.md)"
fi
EOF_MARKER=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
echo "body<<$EOF_MARKER" >> "$GITHUB_OUTPUT"
echo "$BODY" >> "$GITHUB_OUTPUT"
echo "$EOF_MARKER" >> "$GITHUB_OUTPUT"
- name: extract what's new for release asset
id: whats-new
run: |
set -euo pipefail
VERSION="${{ steps.get-version.outputs.version }}"
CHANNEL="${{ inputs.channel }}"
if ! node scripts/extract-release-section.mjs WHATS_NEW.md "$VERSION" > /tmp/whats-new.md; then
if [ "$CHANNEL" = "release" ]; then
echo "::error::WHATS_NEW.md has no section for version $VERSION (required for stable release)"
exit 1
fi
echo "::warning::WHATS_NEW.md has no section for $VERSION — skipping whats-new.md asset"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: create or update release
id: create-release
uses: actions/github-script@v9
@@ -237,6 +251,14 @@ jobs:
prerelease,
});
return data.id;
- name: upload whats-new.md release asset
if: steps.whats-new.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
RELEASE_TAG="${{ steps.tag.outputs.value }}"
gh release upload "$RELEASE_TAG" /tmp/whats-new.md --clobber
build-macos-windows:
if: ${{ inputs.build_platform_artifacts }}
+21
View File
@@ -0,0 +1,21 @@
name: Publish to WinGet
on:
release:
types: [released]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Derive winget version from app-v* tag
id: ver
env:
TAG: ${{ github.event.release.tag_name }}
run: echo "value=${TAG#app-v}" >> "$GITHUB_OUTPUT"
- name: Submit to WinGet Community Repository
uses: vedantmgoyal9/winget-releaser@v2
with:
identifier: Psychotoxical.Psysonic
version: ${{ steps.ver.outputs.value }}
installers-regex: 'Psysonic_.*_x64-setup\.exe$'
token: ${{ secrets.WINGET_TOKEN }}
+3
View File
@@ -37,6 +37,9 @@ src-tauri/lcov.info
# Frontend test coverage
coverage/
# Generated at build/test/dev time (scripts/generate-release-notes-bundle.mjs)
src/generated/
# Documentation
CLAUDE.md
+657 -30
View File
@@ -9,6 +9,450 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
>
## [1.49.0] - 2026-06-29
## 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 and Hungarian translations
**By [@Soli0222](https://github.com/Soli0222), PR [#1134](https://github.com/Psychotoxical/psysonic/pull/1134)**
* Full Japanese (日本語) UI translation — selectable from the language picker on the Settings and Login screens.
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1149](https://github.com/Psychotoxical/psysonic/pull/1149)**, a gift to [@falu](https://github.com/falu) for the first independent review of Psysonic
* Psysonic is now available in **Hungarian (Magyar)** — pick it from the language menu on the Settings and Login screens.
### Artist artwork from fanart.tv
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1137](https://github.com/Psychotoxical/psysonic/pull/1137) and PR [#1193](https://github.com/Psychotoxical/psysonic/pull/1193)**
* New opt-in **External Artwork Scraper** (Settings → Integrations, off by default): artist imagery from fanart.tv — a 16:9 background on the fullscreen player and a wide banner on the artist page — with Navidrome staying the canonical cover. Optional personal key; turning it off removes the fetched images again.
* The **mainstage hero** on the home screen now shows the album artist's backdrop too, matching the fullscreen player and artist page.
* Choose, per place (mainstage hero, artist page, fullscreen player), which images to use as the background and in what order — drag to reorder or switch a source off, under the same setting. The hero also preloads the upcoming backdrops so they appear without a long blank.
### Remember the equalizer per audio output device
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1146](https://github.com/Psychotoxical/psysonic/pull/1146)**, suggested by [@JustBuddy](https://github.com/JustBuddy)
* New opt-in **Remember EQ per device** toggle (Settings → Audio → Audio Output Device, off by default): the equalizer profile — bands, pre-gain, enabled state and active preset — is saved per audio output device and restored automatically when you switch devices.
### Custom HTTP headers for gated servers
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1156](https://github.com/Psychotoxical/psysonic/pull/1156)**, closes [#1095](https://github.com/Psychotoxical/psysonic/issues/1095)
* Per-server **custom HTTP headers** in Settings → Servers for reverse-proxy gates (Cloudflare Access, Pangolin, and similar): add name/value pairs, choose whether they apply to the local URL, public URL, or both on dual-address profiles.
* Headers attach to every user-server HTTP path — library sync, playback, covers, offline download, Navidrome admin, capability probes, and share-link preview — without putting secrets in invite links or magic strings.
* Gate header values are redacted from application logs.
### Orbit — shared crossfade, gapless and AutoDJ
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1158](https://github.com/Psychotoxical/psysonic/pull/1158)**
* In an Orbit session the host's track-transition settings — crossfade, gapless or AutoDJ, including the crossfade length and smooth-skip — now apply to everyone, so guests blend between tracks the same way the host does instead of each person using their own. Your own settings are restored when you leave.
* While you are a guest in a session, the transition controls in Settings → Audio and the queue toolbar are shown as host-controlled.
### Theme scheduler — follow the system theme
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1163](https://github.com/Psychotoxical/psysonic/pull/1163)**, suggested by [@mokazemi](https://github.com/mokazemi)
* The theme scheduler can now switch your day/night theme pair based on your operating system's light/dark setting, in addition to the existing time-of-day schedule. Pick the trigger with a new Time of Day / System Theme switch; in system mode the two pickers read as Light and Dark theme. On Linux setups where the OS does not signal the change live, a hint notes it applies after restarting the app.
### Hi-Res transition blend rate
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1171](https://github.com/Psychotoxical/psysonic/pull/1171)**
* **Settings → Audio → Native Hi-Res** gains a blend-rate picker (44.1 / 88.2 / 96 kHz, default 44.1 kHz) for transitions when adjacent tracks have different sample rates, with a note that resampling uses extra CPU and memory.
* **Crossfade / AutoDJ:** both sides resample to the chosen rate; the output stream reopens when needed and the outgoing track rebuilds from cache so mixed 88.2 ↔ 44.1 kHz transitions no longer tear mid-fade.
* **Gapless:** the next track chains at the blend rate and the current track realigns when the stream Hz differs, instead of falling back to a hard cut.
### AutoDJ — configurable overlap cap
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1173](https://github.com/Psychotoxical/psysonic/pull/1173)**
* **Settings → Audio → Track transitions → AutoDJ:** choose **Auto** (content-driven overlap, up to 12 s) or **Limit** (slider 230 s, default 15 s when enabled) to cap how long AutoDJ may overlap tracks.
* The cap applies to end-of-track planning, JS auto-advance, smooth skip, and Orbit transition sync; the audio engine accepts dynamic overlap overrides up to 30 s.
### Polish translation
**By [@Rextens](https://github.com/Rextens), PR [#1185](https://github.com/Psychotoxical/psysonic/pull/1185)**
* Full Polish (Polski) UI translation — selectable from the language picker on the Settings and Login screens.
### Multiple genres in album details
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1186](https://github.com/Psychotoxical/psysonic/pull/1186)**, suggested by [@Thraka](https://github.com/Thraka)
* Album details now surface every genre a release spans instead of just the first one: the main genre shows inline with a **+N** chip that opens the full, clickable list, each genre linking to its genre page.
* Genres combine album and track tags (matching the genre browser) and read from the local library index when it is ready, so they also work offline.
### Compact buttons — switch action and toolbar buttons to icon-only
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1189](https://github.com/Psychotoxical/psysonic/pull/1189)**
* New **Compact buttons** setting under Settings → Appearance. Switch the action and toolbar buttons between large labelled buttons and small icon-only ones — across album, artist and playlist headers, the shared browse toolbars (sort, filters, multi-select), and the Most Played sort/filter controls. Defaults to large, so nothing changes unless you turn it on. On phones the album header keeps its large touch targets.
### Playlists — sort by date added
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1191](https://github.com/Psychotoxical/psysonic/pull/1191)**, suggested by SinFist
* Sort a playlist by **Date added** (newest or oldest first), or by title, artist, album and the other columns, from a new sort dropdown in the playlist filter toolbar. The Subsonic API has no per-track "added on" date, so this follows the playlist's own order — servers add new tracks at the end, so newest-first puts your latest additions on top.
### WinGet update command in the update dialog (Windows)
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1202](https://github.com/Psychotoxical/psysonic/pull/1202)**
* The Windows update dialog now also shows the WinGet command (`winget upgrade Psysonic`) next to the installer download, so you can update whichever way you installed.
## Changed
### 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.
### Russian locale — missing strings and phrasing cleanup
**By [@kilyabin](https://github.com/kilyabin), PR [#1181](https://github.com/Psychotoxical/psysonic/pull/1181)**
* Fifty strings that still fell back to English in the Russian UI are now translated — macOS in-place updater, device sync file migration, fullscreen lyrics, and statistics share-image export.
* User-facing descriptions in Russian and English no longer mention WebKitGTK or FisherYates internals; several Russian labels and section titles read more naturally (settings casing, smart playlists, track transitions, and home rails).
### macOS — themed window title bar
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1199](https://github.com/Psychotoxical/psysonic/pull/1199)**, suggested by [@bcorporaal](https://github.com/bcorporaal)
* On macOS the window's title bar now follows the active theme instead of the grey system bar; the native macOS window buttons stay in place, floating over the themed bar.
## Fixed
### 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 sync — follow-up fixes
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1132](https://github.com/Psychotoxical/psysonic/pull/1132)**
* After cross-device idle pull while paused, a local queue change (e.g. enqueue) could be overwritten when auto-pull ran again. Idle auto-pull now stops on local mutations until manual sync from the header; the connection LED turns yellow while auto-sync is paused.
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1133](https://github.com/Psychotoxical/psysonic/pull/1133)**
* After editing the queue while paused (yellow sync LED), pressing Play only resumed audio and could leave the server on another device's queue until the debounced push fired. Resume and play-from-queue now flush the local play queue immediately and clear the yellow indicator when the push succeeds.
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1135](https://github.com/Psychotoxical/psysonic/pull/1135)**
* The header connection probe now retries a failed ping twice (2 s apart) before marking the server unreachable, so a single dropped packet on an otherwise fine link no longer flips the LED to disconnected.
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1136](https://github.com/Psychotoxical/psysonic/pull/1136)**
* Track-advance queue pushes no longer suspend idle auto-pull, so the connection LED does not flash yellow on every song change. Yellow sync still appears after a local queue edit while paused; it clears while audio is playing.
### Favorites — bulk add to playlist and play/enqueue selected
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#1140](https://github.com/Psychotoxical/psysonic/pull/1140)**
* Bulk **Add to playlist** no longer cleared the selection on `mousedown` before the click ran, so chosen tracks were not actually added.
* With rows selected, **Play all** / **Add all to queue** become **Play selected** / **Add selected to queue** and act on the checked tracks only.
* Bulk add now snapshots every checked row when the picker opens so all selected tracks land in the playlist, not just the last one.
### Update notification — clearer popup on Linux
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1142](https://github.com/Psychotoxical/psysonic/pull/1142)**, reported by zunoz on Discord
* The "new version available" popup no longer shows blurry, unfocused text on some Linux setups (the background blur could bleed onto the dialog). The version arrow now lines up with the heading, and the Skip / Remind me later buttons read clearly — Remind me later is the highlighted action when there's no in-app installer.
### Artists letter index — Navidrome ignored articles and library index
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1145](https://github.com/Psychotoxical/psysonic/pull/1145)**, closes [#1144](https://github.com/Psychotoxical/psysonic/issues/1144)
* On the **Artists** page (and **Composers**), the AZ filter now groups names like Navidrome: leading articles such as **The** are skipped before picking the letter — **The Beatles** lands under **B**, not **T**. The bucket follows the server's own `ignoredArticles` list when the local index knows it.
* The local library index stores `name_sort` and the server's `ignoredArticles` from `getArtists`, sorts browse SQL by the sort key (now indexed), and repairs stale keys once on upgrade.
* The local library database now opens, swaps and restores through one pipeline, so a swapped or restored file always picks up pending migrations and one-time repairs instead of serving a stale schema.
* A panic or a poisoned lock in one query no longer wedges the whole library index — connections recover and report the error instead, and the new sort-key migration applies idempotently so a half-applied upgrade self-heals on the next launch.
### Equalizer — the active AutoEQ profile name stays visible
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1147](https://github.com/Psychotoxical/psysonic/pull/1147)**
* After applying an AutoEQ headphone profile, the preset picker now shows the profile name under an AutoEQ group instead of going blank, and the delete button no longer appears for AutoEQ profiles (where it did nothing).
### All Albums — compilation and favorites filters
**By [@cucadmuh](https://github.com/cucadmuh), reported by [@bcorporaal](https://github.com/bcorporaal), PR [#1151](https://github.com/Psychotoxical/psysonic/pull/1151)**, closes [#1143](https://github.com/Psychotoxical/psysonic/issues/1143)
* **Only compilations** no longer shows a handful of albums after the local index already filtered them — slice mode skips the redundant client pass that dropped rows without `isCompilation` on the DTO.
* **Favorites** on All Albums uses the same `getStarred2` catalog path as the Favorites page instead of the empty sparse `album` table browse.
* Pre-index compilation filtering auto-paginates again in network page mode; offline library aggregates set `isCompilation` from track tags.
### Playlists header buttons clipped at narrow widths
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1153](https://github.com/Psychotoxical/psysonic/pull/1153)**
* The action buttons at the top of the Playlists page (New Playlist, New Smart Playlist, folder controls, Select) could run off-screen and get cut off when the window was narrow or the queue panel was open. They now wrap onto multiple rows, left-aligned.
### Orbit — session reliability fixes
**By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#1155](https://github.com/Psychotoxical/psysonic/pull/1155), [#1157](https://github.com/Psychotoxical/psysonic/pull/1157), [#1159](https://github.com/Psychotoxical/psysonic/pull/1159)**
* Opening Psysonic on a second device no longer deletes a session that is still live on another device.
* Long sessions keep updating for guests instead of silently stalling once the shared state grew too large.
* Radio no longer adds unrelated tracks to a guest's queue mid-session.
* Auto-shuffle and auto-approve are independent again — toggling one in an older session no longer flips the other.
* A session is kept within its guest limit even when several people join at once.
* Guest suggestions no longer get silently lost or stuck on "waiting on host": overlapping host updates are serialised, a lost suggestion is re-sent (with a notice if it still can't get through), and a flaky join no longer leaves a duplicate suggestion list on the server.
* Pasted invites are rejected unless they point at a normal http/https server address.
### macOS dock icon larger than native apps
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1169](https://github.com/Psychotoxical/psysonic/pull/1169)**, closes [#1166](https://github.com/Psychotoxical/psysonic/issues/1166)
* On macOS the dock icon was rendered edge-to-edge and looked larger than other apps; it is now padded to Apple's icon grid so it matches native sizing.
### Artist header showing the plain image instead of the external background
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1172](https://github.com/Psychotoxical/psysonic/pull/1172)**
* On the artist page, when an artist had an external background image (from fanart.tv) but no banner, the header showed the plain Navidrome artist image instead of the background — even though the fullscreen player used the background correctly. The header now falls back banner → background → Navidrome image as intended. The background also sits a little higher so band members' heads aren't cropped on wide screens.
### Context menu "Play Now" and resize behaviour
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1174](https://github.com/Psychotoxical/psysonic/pull/1174)**, reported by [@peri4ko](https://github.com/peri4ko)
* On the Playlists page, right-clicking a playlist and choosing "Play Now" only opened the playlist instead of playing it. It now starts playback.
* Resizing the window while a context menu was open could leave the menu stranded and drifting off-screen. The context menu now closes when the window is resized.
### Genres page kept empty genres after tag changes
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1176](https://github.com/Psychotoxical/psysonic/pull/1176)**, closes [#1162](https://github.com/Psychotoxical/psysonic/issues/1162)
* After retagging a track and resyncing the library, genres with no remaining albums could still appear on the Genres page until restart. The local genre catalog now counts only live indexed tracks, filters zero-count genres, and the Genres page refreshes when library sync finishes.
### AutoDJ — last track in the queue was cut short
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1183](https://github.com/Psychotoxical/psysonic/pull/1183)**
* With AutoDJ active and no next track to blend into, the engine could still fire the crossfade end timer and trim the final song. The last track now plays through to real source exhaustion.
### Play queue sync — idle pull rewound after the queue finished
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1183](https://github.com/Psychotoxical/psysonic/pull/1183)**
* After the last track ended (repeat off), idle auto-pull could restore an earlier server position from the last debounced push and seek backward. The client now flushes end-of-track position to the server and skips idle auto-pull until playback resumes, the queue is edited, or the user pulls manually.
### Sidebar — offline nav gating after manual reconnect Retry
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1190](https://github.com/Psychotoxical/psysonic/pull/1190)**, closes [#1160](https://github.com/Psychotoxical/psysonic/issues/1160)
* Strengthens the existing disconnect/recovery path: connection status is now shared across all `useConnectionStatus` hook instances, so a successful **Retry** on the offline banner clears offline-browse sidebar filtering in step with the header connection indicator (no app restart).
### Timeline play history disappeared on album/playlist play
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1204](https://github.com/Psychotoxical/psysonic/pull/1204)**, closes [#1096](https://github.com/Psychotoxical/psysonic/issues/1096)
* Timeline mode now keeps a session play-history strip (plus cold bootstrap of the last 50 plays from statistics) when Play album/playlist replaces the queue; canonical queue sync is unchanged.
* The current track stays pinned to the top of the list; clicking a history row inserts after the playing track instead of replacing the queue, and replayed tracks remain in the history strip.
* History rows from other servers resolve album/cover metadata per server so Now Playing artwork loads when replaying cross-server plays.
* Cross-server queue switches now send `playbackReport` **stopped** to the previous server so its Who is listening entry clears promptly.
### Album and artist covers — full resolution restored
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1205](https://github.com/Psychotoxical/psysonic/pull/1205)**
* Album and artist covers — and the full-size view when you click a cover — could appear small and low-quality even though the source image was large, depending on how you reached the album. Root cause: the cache built its larger sizes from a smaller already-saved size instead of the full-resolution download, so they were stored downscaled. Covers are now built from the full-resolution image, and the full-size view opens at full resolution. The cover cache refreshes once on update. Reported by users on Discord.
## Under the Hood
### WinGet — automated manifest updates on release
**By [@ImAsra](https://github.com/ImAsra), PR [#1077](https://github.com/Psychotoxical/psysonic/pull/1077)**
* New GitHub Actions workflow publishes Windows installer updates to `microsoft/winget-pkgs` on each release — scans the `_x64-setup.exe` asset, computes SHA-256, and opens the upstream PR via `winget-releaser`.
### ESLint setup and a strict lint pass over the frontend
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1165](https://github.com/Psychotoxical/psysonic/pull/1165)**
* Added an ESLint config and `npm run lint`, and brought `src/` to zero errors and warnings under the strict React-hooks ruleset. Developer-only — no user-facing behaviour change.
### CI — ESLint gate and path-aware ci-ok merge check
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1170](https://github.com/Psychotoxical/psysonic/pull/1170)**
* Strict `npm run lint` runs in CI on frontend path filters via a dedicated workflow parallel to the existing frontend test jobs.
* The `ci-ok` check waits for every applicable test and lint job on a PR (frontend and/or Rust, depending on changed paths) and blocks merge when any required job failed or did not finish in time.
### Settings — consistent design for the Audio sub-sections
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1175](https://github.com/Psychotoxical/psysonic/pull/1175)**
* The AutoDJ overlap-cap and the Native Hi-Res blend-rate options in Settings → Audio now sit in the same bordered sub-card the Normalization options use, and the Hi-Res section no longer shows a double border.
### App no longer blanks on an unexpected error
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1194](https://github.com/Psychotoxical/psysonic/pull/1194)**
* If a screen hit an unexpected rendering error, the whole window could go blank with no way back. The app now shows a small recoverable error card (Try again / Reload app) instead, and playback keeps going.
### Windows update notice waits out WinGet moderation
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1200](https://github.com/Psychotoxical/psysonic/pull/1200)**
* On Windows, the "update available" notice now waits until a release is a couple of days old, so it no longer points to a version that WinGet has not finished publishing yet. macOS and Linux are unaffected.
### Playlist no longer reloads when you press Play
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1201](https://github.com/Psychotoxical/psysonic/pull/1201)**
* Pressing Play, Shuffle or Add to queue on a playlist no longer reloads the whole page with a spinner — it just starts playback. Editing the playlist (adding or removing songs) still refreshes the list as before.
### Sidebar items jumped back when reordered
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1206](https://github.com/Psychotoxical/psysonic/pull/1206)**, reported by [@tummydummy](https://github.com/tummydummy)
* In Settings → Personalization → Sidebar, dragging an item to a new position could snap it back or land it one place off, depending on which items were hidden. Reordering now tracks each item directly, so it stays exactly where you release it — both in the customizer and when long-pressing items in the sidebar itself.
## [1.48.1] - 2026-06-15
## 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
@@ -99,6 +543,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### What's New — remote release notes
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1058](https://github.com/Psychotoxical/psysonic/pull/1058)**
* The **What's New** page shows user-friendly highlights from `WHATS_NEW.md` instead of embedding the full technical changelog in the app bundle.
* RC and stable builds prefetch `whats-new.md` from the GitHub release on startup and cache it locally; offline users see a thin embedded fallback.
* **`tauri:dev`** reads markdown straight from the repo for easy editing; shipped bundles embed only the current release-line slice. A **Full changelog** tab on the page shows the technical list for the same version.
* CI uploads `whats-new.md` on each `next` / `release` tag alongside platform artifacts.
* Remote download uses the existing Rust `fetch_url_bytes` proxy so GitHub release assets work without browser CORS.
### Music Network — scrobble to more than just Last.fm
**By [@Psychotoxical](https://github.com/Psychotoxical) and [@cucadmuh](https://github.com/cucadmuh), PR [#1066](https://github.com/Psychotoxical/psysonic/pull/1066)**
* **Settings → Integrations** now hosts a **Music Network** that scrobbles your plays to one or more services at once: **Last.fm**, **Libre.fm**, **Rocksky**, **ListenBrainz** (the public service or any compatible server), **Maloja** (native, Audioscrobbler or ListenBrainz API), **Koito**, and any **custom GNU FM** instance.
* Choose a **primary** service — your loved tracks, similar artists and listening stats come from it — while scrobbles fan out to every connected service. A master switch turns the whole thing on or off.
* **Last.fm works exactly as before**, including love, similar artists and the Statistics page; your existing connection is migrated automatically on first launch.
* *Known limitation:* Rocksky currently rejects scrobbles whose title or artist contains non-standard (non-ASCII) characters — a Rocksky-side issue, not a Psysonic bug.
### Live — rich now-playing on Navidrome 0.62+
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1080](https://github.com/Psychotoxical/psysonic/pull/1080)**
* On servers that advertise the OpenSubsonic `playbackReport` extension (Navidrome ≥ 0.62), Psysonic reports live transport state and position so **Live** shows who is playing or paused and where in the track — including playback speed when the other client sends it.
* The position bar glides between refreshes; pause and resume update the server immediately instead of waiting for the audio engine.
* Play counts are unchanged — still driven by the existing scrobble path. Servers without the extension keep the previous now-playing behaviour.
### Title bar — selectable window button styles
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1083](https://github.com/Psychotoxical/psysonic/pull/1083), suggested by [@PHLAK](https://github.com/PHLAK)**
* The Linux custom title bar gets a **window button style** picker in **Settings → Appearance → Custom title bar** — choose between dots, dots with icons, flat, pill, outline, and minimal looks.
* All styles now carry minimize/maximize/close icons for clear, colour-blind-friendly buttons, and an optional toggle hides the minimize button (maximize and close only).
### Playback speed — Semitones strategy, finer labels, and advanced fine steps
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1084](https://github.com/Psychotoxical/psysonic/pull/1084)**
* New **Semitones** strategy sets varispeed directly in semitones (±12 st, 0.1 step) instead of coarse speed steps; the speed readout now shows two decimals so every slider notch is visible.
* Each strategy button has a short tooltip; **Advanced** mode adds an optional **Fine adjustment** toggle (0.01× / 0.01 st steps) in **Settings → Audio**.
### Now Playing — live status dot in "Who is listening?"
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1086](https://github.com/Psychotoxical/psysonic/pull/1086)**
* Each listener in the **Who is listening?** popover now shows a small status dot — playing, paused, or idle — derived from the live playback report, replacing the previous "minutes ago" line. The status is also read out for screen readers and on hover, so it is never conveyed by colour alone.
## Changed
### Dependencies — npm and Rust refresh
@@ -157,30 +659,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Settings → Servers — compact server cards
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1054](https://github.com/Psychotoxical/psysonic/pull/1054)**
* Two-line header: custom entry name plus `user@host`, HTTPS lock, and a clickable version info tooltip (hover or tap).
* Navidrome **0.62+**: green **AudioMuse-AI** inline badge when the plugin is detected; older Navidrome keeps the manual toggle row below the card.
* **Use** and **Active** share one rightmost slot (green badge vs primary button); card actions are edit → test → use/active. Delete lives in the edit form footer.
* **TooltipPortal**: `data-tooltip-click` for click-pinned tooltips (touch and explicit open without the 1s hover delay).
## Fixed
### Now Playing — cards no longer blank out on track change
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1042](https://github.com/Psychotoxical/psysonic/pull/1042)**
* The "from this album", "discography" and "most played" sections on the Now Playing page disappeared after a track change once the next track started playing from the local cache, and didn't come back. The page now keeps loading that info whenever the server is reachable, regardless of whether the audio plays from cached or offline bytes.
### Now Playing — metadata reads from the local library index first
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1049](https://github.com/Psychotoxical/psysonic/pull/1049)**
* The "from this album", "discography", "most played" and song details on the Now Playing page now come from the local library index when it has them, only falling back to the server when the index can't serve a row. Cards and fields (genre, play count, contributors) stay populated during cached and offline playback, with fewer server requests.
### Library DB — named slow-write ops for stall diagnosis
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1043](https://github.com/Psychotoxical/psysonic/pull/1043)**
* Production `library-db` write paths now log stable `module.action` op names instead of the generic `misc`, so the next `[library-db] SLOW write` line on macOS (or elsewhere) identifies the call site — diagnostic step for playback stalls under long write-lock holds ([#1040](https://github.com/Psychotoxical/psysonic/issues/1040)).
### Servers — complete border on the active server card
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#998](https://github.com/Psychotoxical/psysonic/pull/998)**
@@ -198,14 +688,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Playback — macOS stutter from background device checks
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1039](https://github.com/Psychotoxical/psysonic/pull/1039)**
* On some macOS setups playback stuttered at a steady ~3-second cadence: a background check that scans every audio output device ran on each poll and briefly contended with playback. It now runs only when a specific output device is pinned; with the system default (the common case) a single lightweight check runs instead.
### Track preview — Symphonia 0.6 format hints and fast stream start
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1006](https://github.com/Psychotoxical/psysonic/pull/1006)**
@@ -265,6 +747,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Playback — macOS stutter from background device checks
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1039](https://github.com/Psychotoxical/psysonic/pull/1039)**
* On some macOS setups playback stuttered at a steady ~3-second cadence: a background check that scans every audio output device ran on each poll and briefly contended with playback. It now runs only when a specific output device is pinned; with the system default (the common case) a single lightweight check runs instead.
### Now Playing — cards no longer blank out on track change
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1042](https://github.com/Psychotoxical/psysonic/pull/1042)**
* The "from this album", "discography" and "most played" sections on the Now Playing page disappeared after a track change once the next track started playing from the local cache, and didn't come back. The page now keeps loading that info whenever the server is reachable, regardless of whether the audio plays from cached or offline bytes.
### Library DB — named slow-write ops for stall diagnosis
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1043](https://github.com/Psychotoxical/psysonic/pull/1043)**
* Production `library-db` write paths now log stable `module.action` op names instead of the generic `misc`, so the next `[library-db] SLOW write` line on macOS (or elsewhere) identifies the call site — diagnostic step for playback stalls under long write-lock holds ([#1040](https://github.com/Psychotoxical/psysonic/issues/1040)).
### Now Playing — metadata reads from the local library index first
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1049](https://github.com/Psychotoxical/psysonic/pull/1049)**
* The "from this album", "discography", "most played" and song details on the Now Playing page now come from the local library index when it has them, only falling back to the server when the index can't serve a row. Cards and fields (genre, play count, contributors) stay populated during cached and offline playback, with fewer server requests.
### Themes — consistent focus borders on inputs and dropdowns
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1052](https://github.com/Psychotoxical/psysonic/pull/1052)**
@@ -282,6 +796,119 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Navidrome Now Playing and scrobble with local playback
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1055](https://github.com/Psychotoxical/psysonic/pull/1055)**
* **Show in Now Playing** and Navidrome play-count scrobbles no longer silently skip when audio plays from hot cache, offline library pins, or favorites-auto bytes.
* Presence and queue sync target the **playback server** reachability gate, so a queue on server A still reports to Navidrome while browsing server B.
### Album grids — album artist on compilation cards
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1057](https://github.com/Psychotoxical/psysonic/pull/1057), reported in [#1056](https://github.com/Psychotoxical/psysonic/issues/1056)**
* Random Albums, New Releases, All Albums, and other album grids no longer show a track artist on compilation albums when the tags set a single album artist (e.g. **Underworld** on a various-artists mix); the card matches the album page.
* Local index browse, live search, and FTS album dedupe prefer `album_artist` over per-track `artist`; Hero, Most Played, and offline pin labels use the same display helper.
### Local index — multi-genre browse, filters, and counts
**By [@cucadmuh](https://github.com/cucadmuh), reported by HiveMind on the Psysonic Discord, PR [#1059](https://github.com/Psychotoxical/psysonic/pull/1059)**
* Tracks tagged with several genres in one metadata field (e.g. `Noise Metal/Dark Ambient/Experimental Black Metal`) again match **each atomic genre** in Genres browse, All Albums filters, genre detail, and Advanced Search — not only the first segment.
* New `track_genre` index (OpenSubsonic `genres[]` when present, Navidrome-default split fallback), maintained on sync; one-time blocking startup backfill for existing libraries with progress.
* Migration v12 repairs databases that recorded legacy schema versions 211; TS network fallback uses robust `genreTagsFor` parsing.
### Dev startup — missing generated release-notes bundle
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1060](https://github.com/Psychotoxical/psysonic/pull/1060)**
* Fresh clones no longer crash Vite on `tauri:dev` when `src/generated/releaseNotesBundle.ts` is missing — `dev` and `tauri:dev` now run `prebuild:release-notes` before launch (file stays gitignored).
### What's New — release-notes cache file on disk (RC/stable)
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1062](https://github.com/Psychotoxical/psysonic/pull/1062)**
* RC and stable builds now persist the downloaded `whats-new.md` slice under AppData — `plugin-fs` had mkdir but lacked recursive write scope, so the `release-notes/` folder appeared empty and every launch re-fetched from GitHub.
### Favorites — player-bar star stays synced in track lists
**By [@artplan1](https://github.com/artplan1), PR [#1063](https://github.com/Psychotoxical/psysonic/pull/1063)**
* Liking a song from the **player bar**, fullscreen player, or global shortcuts now updates the star in album tracklists, playlists, Random Mix, and Favorites — the row no longer reverts the instant the server sync completes.
* List views seed starred state from a one-shot fetch and merge session `starredOverrides`; clearing those overrides on sync success had only patched `currentTrack` and the queue cache, so rows fell back to stale fetched values.
### Fullscreen player — title cleanup
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1068](https://github.com/Psychotoxical/psysonic/pull/1068)**
* The song title no longer shows a leading track number, and letters with descenders (g, j, p, q, y) are no longer clipped along the bottom edge.
### Discord Rich Presence — album art and clearer settings
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1068](https://github.com/Psychotoxical/psysonic/pull/1068)**
* Album art shows again when the cover source is "Server (via album info)" — Discord was handed a local file path it cannot fetch and fell back to the app icon; it now receives a reachable image URL.
* **Settings → Integrations:** added notices clarifying that this is the built-in Discord Rich Presence, and that the official Navidrome Discord RP plugin needs "Show in Now Playing" enabled instead.
### Internet radio — no more duplicate now-playing on Linux
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by agriffit79, PR [#1069](https://github.com/Psychotoxical/psysonic/pull/1069)**
* On Linux, playing an internet radio station showed the track twice in the desktop "now playing" overlay — once from the app's own media controls and once from a second player the web engine registered for the stream. The extra player is now suppressed, so radio shows a single entry like regular tracks.
### Windows — idle app no longer blocks system sleep
**By [@cucadmuh](https://github.com/cucadmuh), reported by [@Thraka](https://github.com/Thraka), PR [#1073](https://github.com/Psychotoxical/psysonic/pull/1073)**
* Psysonic no longer keeps the audio output device open while the app is idle — the CPAL stream opens on first playback and closes after **60 seconds** without active audio (pause), or **immediately** on Stop / natural queue end, so Windows `powercfg` no longer reports an in-use audio stream when music is not playing ([#1071](https://github.com/Psychotoxical/psysonic/issues/1071)).
* Resume after a long pause uses the existing cold path (`audio:output-released` resets the warm-pause flag); post-sleep recovery skips reopening the stream when nothing is playing.
* **Cold start while paused:** after `getPlayQueue` restores position, the seekbar shows the saved time immediately; the current track is hot-cache prefetched and the engine loads silently (`audio_play` with `startPaused`) at that position so the next Play is a warm `audio_resume` without an audible blip at the start of the track.
* Rodio `Dropping DeviceSink` warnings on stream release are suppressed unless logging is in debug mode.
* **Stop keeps the waveform:** stopping no longer wipes the seekbar waveform for the still-shown track — the cached analysis bins are kept and re-hydrated from the database, so the real waveform stays mounted instead of falling back to flat bars.
### Auto-install script — `curl | sudo bash` works again
**By [@kbennett2000](https://github.com/kbennett2000), PR [#1079](https://github.com/Psychotoxical/psysonic/pull/1079)**
* The Linux auto-installer (`install.sh`) failed before any download because `[INFO]` log lines were captured into the package URL and curl rejected the mangled string; logging now goes to stderr, the reinstall prompt reads from the terminal, and package downloads use `--fail --globoff`.
### Music Network — self-hosted scrobbling reaches the server
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1085](https://github.com/Psychotoxical/psysonic/pull/1085)**
* Self-hosted scrobble targets that use a pasted token (Koito, Maloja's ListenBrainz and Audioscrobbler compatibility surfaces) silently recorded nothing: the saved server address dropped the API path, so listens were sent to a route that does not exist while the account still showed as connected.
* The correct API path is now kept when connecting. Reconnect an affected account once so it picks up the corrected address.
### Internet Radio — station management limited to server admins
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1086](https://github.com/Psychotoxical/psysonic/pull/1086)**
* Navidrome 0.62 made creating, editing and deleting radio stations admin-only, so those actions failed for standard accounts. Add, edit and delete controls are now hidden for non-admin Navidrome users; playback and favourites stay available to everyone. Other server types are unaffected.
## [1.47.0]
> **🙏 Thank you to our amazing Discord community.** This release would not have been possible without your tireless support, quality checks, bug reports and all-round collaboration. Every report, every repro and every bit of feedback shaped what shipped here — thank you. Come join us: [discord.gg/AMnDRErm4u](https://discord.gg/AMnDRErm4u)
+6 -3
View File
@@ -73,7 +73,7 @@ If you use **Nix**, `nix develop` (see [`flake.nix`](flake.nix)) provides the pi
| Topic | Location |
|--------|----------|
| Frontend test stack (Vitest, Tauri/Subsonic mocks, store resets, i18n in tests) | [`src/test/README.md`](src/test/README.md) |
| What CI runs for frontend / backend | [`frontend-tests.yml`](.github/workflows/frontend-tests.yml), [`rust-tests.yml`](.github/workflows/rust-tests.yml) |
| What CI runs for frontend / backend | [`frontend-tests.yml`](.github/workflows/frontend-tests.yml), [`eslint.yml`](.github/workflows/eslint.yml), [`rust-tests.yml`](.github/workflows/rust-tests.yml) |
| Frontend "hot path" files held to a coverage threshold | [`frontend-hot-path-files.txt`](.github/frontend-hot-path-files.txt), [`check-frontend-hot-path-coverage.sh`](scripts/check-frontend-hot-path-coverage.sh) |
| Rust hot-path gate | [`hot-path-files.txt`](.github/hot-path-files.txt), [`check-hot-path-coverage.sh`](scripts/check-hot-path-coverage.sh) |
| Nix packaging / release automation | [`flake.nix`](flake.nix), workflows under [`.github/workflows/`](.github/workflows/) |
@@ -84,7 +84,7 @@ If you use **Nix**, `nix develop` (see [`flake.nix`](flake.nix)) provides the pi
1. **One pull request, one coherent goal.** Easier review, easier revert, fewer merge conflicts.
2. **Match existing style** in touched files (naming, module layout, comment density). Avoid drive-by refactors unrelated to the task.
3. **Linting and formatting:** there is no enforced JS/TS formatter or ESLint config in the repo today — `tsc --noEmit` is the only frontend gate beyond tests. For Rust, `cargo clippy --workspace --all-targets -- -D warnings` is the lint gate; `cargo fmt` is not currently required but won't hurt.
3. **Linting and formatting:** ESLint (strict `eslint.config.mjs`) runs in CI on frontend paths; run `npm run lint` locally before opening a frontend PR. `tsc --noEmit` is also required. For Rust, `cargo clippy --workspace --all-targets -- -D warnings` is the lint gate; `cargo fmt` is not currently required but won't hurt.
4. **Commit messages:** a short **human-readable** summary of what changed and why; Conventional Commits-style prefixes (`feat:`, `fix:`, ...) are fine if you prefer them. Do not include meta references (IDEs, assistants, or how the message was produced) — only what matters for project history.
5. **License:** new code must remain compatible with the project's GPLv3.
6. **Tests:** when you change behaviour users rely on, add or update tests next to the code (see [`src/test/README.md`](src/test/README.md)). Purely visual tweaks may not need tests, but behavioural regressions should be covered where the suite can catch them.
@@ -114,9 +114,11 @@ PRs must target `main`. `next` and `release` are maintainer-driven promotion bra
Workflows are path-filtered (see the YAML for exact `paths` / `paths-ignore`):
- **Frontend** (`src/**`, lockfile, Vitest/Vite/tsconfig, etc.): `npm test` (Vitest), `npx tsc --noEmit`, then a coverage run.
- **Frontend** (`src/**`, lockfile, Vitest/Vite/tsconfig, ESLint config, etc.): `npm run lint` (ESLint strict), `npm test` (Vitest), `npx tsc --noEmit`, then a coverage run.
- **Rust** (`src-tauri/**`): `cargo test --workspace --all-targets`, `cargo clippy --workspace --all-targets -- -D warnings`, then coverage.
The **`ci-ok`** job in [`ci-main.yml`](.github/workflows/ci-main.yml) is the merge gate: it waits for every required job above whose path filter matched the PR, and fails if any of them failed or did not finish in time.
Hot-path coverage gates are **required** on pull requests: the `coverage` jobs in [`frontend-tests.yml`](.github/workflows/frontend-tests.yml) and [`rust-tests.yml`](.github/workflows/rust-tests.yml) fail when any listed file drops below the floor. See the headers in [`frontend-hot-path-files.txt`](.github/frontend-hot-path-files.txt) and [`hot-path-files.txt`](.github/hot-path-files.txt) for curation rules and thresholds.
---
@@ -129,6 +131,7 @@ Assume the repository root is `psysonic/` (for example after `git clone https://
```bash
npm ci
npm run lint
npm test
npx tsc --noEmit
npm run test:coverage
+13 -7
View File
@@ -9,13 +9,19 @@ All third-party integrations listed below are **opt-in**. Nothing is sent until
### Your Subsonic / Navidrome server
Your server URL, username, and password are stored locally in the app's data directory. All playback and library requests go directly to your own server. Psysonic has no access to this data.
### Last.fm
If you connect a Last.fm account in Settings, Psysonic sends:
- **Scrobbles** — track title, artist, album, and timestamp when a song reaches 50% playback
- **Now Playing** — the currently playing track (title, artist, album)
- **Love / Unlove** — when you mark a track as loved or unloved
### Music Network (scrobble & enrichment services)
Psysonic can connect to one or more scrobble services in Settings → Integrations. Each service you connect is opt-in and independent; nothing is sent to a service you have not connected. Supported service classes:
All requests go to the [Last.fm API](https://www.last.fm/api). Your Last.fm credentials are stored locally and never leave your device. You can disconnect your account at any time in Settings.
- **Audioscrobbler / GNU FM services** — Last.fm, Libre.fm, Rocksky (AT Protocol), and any self-hosted GNU FM-compatible instance
- **ListenBrainz** — the public ListenBrainz.org service, or a self-hosted instance (e.g. Koito) via its ListenBrainz-compatible API
- **Maloja** — your own self-hosted Maloja server (native API or its ListenBrainz-compatible API)
To each connected service, Psysonic may send:
- **Scrobbles** — track title, artist, album, and timestamp when a song reaches 50% playback
- **Now Playing** — the currently playing track (title, artist, album), where the service supports it
- **Love / Unlove** — when you mark a track as loved, on services that support it
Additionally, the one service you choose as your **primary** is queried to enrich the UI (your loved tracks, similar artists, and listening stats). All requests go directly from your device to the service's own host — the public service's host (e.g. the [Last.fm API](https://www.last.fm/api), [ListenBrainz](https://listenbrainz.org)) or, for self-hosted services, the server URL you entered. Credentials (session keys / API tokens) are stored locally and never leave your device. You can disconnect any service at any time in Settings.
### LRCLIB (Lyrics)
When lyrics are fetched from LRCLIB, Psysonic sends the track title, artist, album, and duration to [lrclib.net](https://lrclib.net) as a search query. No account is required. This feature can be disabled in Settings → Lyrics.
@@ -37,7 +43,7 @@ If Discord is running and Rich Presence is not disabled, Psysonic connects to th
The following data is stored exclusively on your device in the app's local storage directory and is never transmitted:
- Server profiles (URL, username, password)
- Last.fm session key
- Scrobble service credentials (session keys / API tokens)
- Playback preferences, themes, keybindings, and all other settings
- Synced device manifests
+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.
+1
View File
@@ -49,6 +49,7 @@ Rules:
### Step B: Promote to RC (`next`)
0. Confirm `WHATS_NEW.md` has a `## [X.Y.Z]` section for the release line about to ship (user-facing copy for the in-app What's New screen; CI uploads it as `whats-new.md` on the release tag).
1. Run workflow: **Promote main to next**.
2. Workflow behavior:
- validates required `main` checks before promotion (default: `ci-ok`, or UI-style `ci-main / ci-ok`; either satisfies the gate)
+1
View File
@@ -36,6 +36,7 @@ Some Psysonic features can communicate with external services, such as:
- Last.fm
- Bandsintown
- Discord Rich Presence
- Fanart.tv
These integrations are optional and clearly presented as opt-in features. They are never required for using Psysonic.
+256
View File
@@ -0,0 +1,256 @@
# What's New
User-facing release highlights for the in-app **What's New** screen. Maintainers refresh the
current line before promoting to `next` / `release`. Technical details and PR credits stay in
`CHANGELOG.md`.
Within each section, order by **user impact** (most noticeable first) — not PR merge order.
`CHANGELOG.md` keeps strict PR order inside Added / Changed / Fixed.
## [1.49.0]
## Highlights
### Play queue sync — pick up where you left off on another device
- Click the header connection indicator to **pull** the active server's play queue when it differs from yours; a yellow LED shows when browse and playback servers do not match.
- While paused or stopped, **idle auto-pull** checks every 10 seconds and applies server changes when you have been still for 30+ seconds.
- Queue **push** sends only tracks owned by the playback server, so mixed-server queues stay sane when you switch servers.
- Local queue edits while paused are no longer overwritten by auto-pull; pressing **Play** pushes your changes immediately, and the sync LED no longer flashes on every track during normal playback.
- After the last track ends with repeat off, idle pull no longer rewinds to an earlier server position — the queue stays where playback finished.
### AutoDJ — minimum pauses, maximum music
- New **AutoDJ** mode — a smart crossfade that blends tracks intelligently: it trims dead air, rides natural fades, and keeps handovers musical instead of abrupt. Its own button in the queue toolbar and its own entry under **Settings → Audio**, alongside Crossfade and Gapless — only one at a time. Off by default; classic **Crossfade** is unchanged.
- **Smooth skip** (on by default with AutoDJ) crossfades manual Next/Previous and track picks from where you are listening instead of hard-cutting; the play/pause button pulses while a blend is active.
- Cap how long overlaps may last: **Auto** (content-driven, up to 12 s) or **Limit** (slider 230 s) under **Settings → Audio → Track transitions**.
- The last track in the queue plays through to the end instead of being trimmed when nothing follows.
### Playlist folders — your playlists, organised
- Folders on the **Playlists** page and in the sidebar keep long lists tidy — group by mood, occasion, or anything you like. Drag playlists in, rename and collapse folders, or choose **Move to folder** from the right-click menu. Switch back to a flat list whenever you prefer.
### Settings — tidier and easier to scan
- Settings are grouped into clear, labelled panels so related options sit together — less hunting around. The **Native Hi-Res Playback** option now explains in plain language what it actually does.
- **Normalization** and **Track transitions** are now their own sections under **Settings → Audio**, and the queue options (display mode, toolbar, and Play-Next order) are gathered into one **Queue Settings** group under **Personalisation**.
### Japanese, Hungarian, and Polish — now in your language
- Psysonic is now available in **Japanese (日本語)**, **Hungarian (Magyar)**, and **Polish (Polski)** — pick any of them from the language menu on the **Settings** and **Login** screens.
### Theme store — spot updates, pick your style
- Version numbers on store themes and ones you have installed make it obvious when an update is ready.
- Filter for **animated** or **static** themes only — less scrolling when you already know the look you want.
### Hi-Res playback — smoother transitions between sample rates
- Under **Settings → Audio → Native Hi-Res**, choose a **blend rate** (44.1 / 88.2 / 96 kHz) for crossfade, AutoDJ, and gapless when adjacent tracks differ in sample rate — mixed 88.2 ↔ 44.1 kHz handovers no longer tear mid-transition.
### Artist artwork — richer home, artist, and fullscreen views
- Switch on **External Artwork Scraper** under **Settings → Integrations** to pull artist imagery from fanart.tv: a wide backdrop on the fullscreen player, a banner across the top of the artist page, and now the artist's backdrop behind the home screen's **mainstage** too. Off by default, your Navidrome covers stay in charge, and turning it back off removes the fetched images again.
- Choose which images each place uses as its background, and in what order — drag to reorder or switch a source off — right under the same setting. The mainstage also loads the next backdrops ahead of time so they appear without a blank gap.
### Equalizer — a profile per output device
- Turn on **Remember EQ per device** under **Settings → Audio** and Psysonic keeps a separate equalizer setup for each output — speakers, headphones, a USB DAC — and switches to the right one automatically when you change devices. Off by default.
### Orbit — everyone hears transitions the host chose
- In a shared **Orbit** session, the host's crossfade, gapless, or AutoDJ settings — including length and smooth skip — apply to all guests until you leave. Transition controls in **Settings → Audio** and the queue toolbar show as host-controlled while you are a guest.
### Themes — follow your system's light and dark mode
- The theme scheduler can now match your **system's light/dark setting** instead of a fixed clock: pick a light theme and a dark one, and Psysonic switches along with your OS. Choose **Time of Day** or **System Theme** under **Settings → Themes** — the existing time-based schedule is still there.
### Servers behind a reverse proxy — custom HTTP headers
- Per-server **custom HTTP headers** in **Settings → Servers** for Cloudflare Access, Pangolin, and similar gates — applied to library sync, playback, covers, offline download, and the rest without putting secrets in invite links.
### Album details — every genre, not just the first
- Album details now show **all** the genres a release spans: the main genre appears inline with a **+N** chip that opens the full, clickable list, each genre linking to its own page. Genres combine album and track tags and read from the local library index, so they work offline too.
### Compact buttons — switch to icon-only controls
- New **Compact buttons** option under **Settings → Appearance** switches the action and toolbar buttons between large labelled buttons and small icon-only ones — across album, artist and playlist headers, the shared browse toolbars, and the Most Played controls. Defaults to large; on phones the album header keeps its large touch targets.
### Playlists — sort by date added
- Sort a playlist by **Date added** (newest or oldest first), or by title, artist, album and the other columns, from a new sort dropdown in the playlist toolbar. The Subsonic API has no per-track "added on" date, so this follows the playlist's own order — servers add new tracks at the end, so newest-first puts your latest additions on top.
## Improved
- **macOS:** the window's title bar now follows the active theme instead of the grey system bar; the native window buttons stay in place, floating over the themed bar.
- Pressing **Play**, **Shuffle**, or **Add to queue** on a playlist starts playback without reloading the whole page with a spinner — editing the playlist still refreshes the list as before.
- Dragging sidebar items in **Settings → Personalisation → Sidebar** (or long-pressing in the sidebar itself) keeps each item exactly where you release it — no snap-back or off-by-one landing.
## Fixed
### Playback and audio
- **Timeline** mode keeps your session play-history strip when you **Play** an album or playlist; the current track stays pinned at the top, and replaying a history row inserts after the playing track instead of replacing the queue.
- **Opus/Ogg** tracks no longer fight the seekbar while they are still loading — scrub to where you want to be and keep listening.
- The equalizer preset picker shows the active **AutoEQ** profile name again instead of going blank.
### Offline, Now Playing, and Navidrome
- The **Live** listener count in the header stays up to date even when the "Who is listening?" popover is closed.
### Browse and library
- Album and artist covers — and the full-size view when you click a cover — open at full resolution again instead of looking soft or small.
- Albums sorted by artist now list each artist's work AZ by title — no more random order within a name.
- **Artist → Year** keeps artists grouped but walks through their albums chronologically, oldest first.
- Genres with no remaining tracks disappear after you retag and resync the library, without restarting the app.
- The **Artists** AZ index matches Navidrome ignored articles — **The Beatles** lands under **B**, not **T**.
- **All Albums → Only compilations** and **Favorites** return the albums you expect instead of an empty or partial list.
### Player and playlists
- **Add to playlist** from the player bar adds the song you are hearing, not the whole album.
- On **Favorites**, bulk **Add to playlist** and **Play selected** / **Add selected to queue** act on every checked row.
- **Play Now** on a playlist in the right-click menu starts playback instead of only opening the list.
- Playlists page header buttons wrap on narrow windows instead of clipping off-screen when the queue panel is open.
### Other
- **Orbit** sessions stay reliable on long listens — guests keep receiving updates, radio no longer pollutes the shared queue, and opening Psysonic on a second device does not delete a live session elsewhere.
- On the artist page, the header uses the fanart.tv background when no banner is available — the same image the fullscreen player already showed.
- **Windows:** Previous, Play/Pause, and Next are back when you hover the taskbar icon — and Play/Pause shows whether music is playing or paused.
- **macOS:** the dock icon matches native app sizing instead of looking oversized.
- **Linux:** **Niri** is recognised as a tiling compositor and gets the same custom title bar behaviour as Hyprland and Sway; the "new version available" popup reads clearly on setups where the background blur used to bleed through.
## Under the hood
- If a screen hits an unexpected error, the app now shows a small recoverable card (**Try again** / **Reload app**) and keeps playing, instead of the whole window going blank.
## [1.48.1]
## 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
### Offline listening
- When the server is unreachable, browse and detail pages show what you already have locally instead of empty errors — albums, artists, playlists, and cross-server favorites.
- Starred tracks, pinned albums, and playlists live under one **media** folder; browse them in **Offline Library** and see disk usage at a glance.
- **Favorites auto-sync** keeps loved songs on disk; pinned albums and playlists refresh when the library index updates.
### Music Network — scrobble beyond Last.fm
- **Settings → Integrations** now hosts a **Music Network**: connect **Last.fm**, **Libre.fm**, **ListenBrainz**, **Maloja**, **Rocksky**, **Koito**, or your own **GNU FM** instance — and scrobble to several at once.
- Pick a **primary** service for loved tracks, similar artists, and stats; other connections still receive scrobbles. Your existing Last.fm setup migrates automatically.
- A master switch turns the whole network on or off.
### Theme Store
- Browse and install community themes from **Settings → Themes** — search, dark/light filter, full-size previews, and sort by popularity or date.
- Six palettes ship with the app; everything else installs on demand and works offline after the first download.
- **Now Playing** follows every theme cleanly, including light palettes.
- Import a theme from a local `.zip` when you have a package from a friend or your own design.
- The sidebar nudges you when an installed theme has an update; one-click update from the theme card.
### Fullscreen player
- Rebuilt for much lower CPU and memory use: a calm, sharp fullscreen view with album art, waveform seekbar, up-next queue, synced lyrics, ratings, and a clock that follows your **Clock format** setting.
- The song title no longer shows a leading track number, and descenders (g, j, p, q, y) are no longer clipped.
### Live — richer now playing on Navidrome 0.62+
- On servers with OpenSubsonic **playbackReport** (Navidrome ≥ 0.62), **Live** shows who is playing or paused, how far into the track they are, and playback speed when another client sends it — with smooth position updates between refreshes.
- In **Who is listening?**, each listener shows a small status dot (playing, paused, or idle) instead of a vague “minutes ago” line.
### Queue — Timeline mode
- A third queue layout keeps the current track in the middle with history above and up next below — great for long listening sessions. Cycle the header control or pick it in **Settings → Personalisation → Queue display**.
### Settings → Servers
- Each card shows the server software and version (e.g. **Navidrome 0.62.0**) under the name, with a cleaner two-line layout and compact actions.
- Navidrome **0.62+** shows a green **AudioMuse-AI** badge when the plugin is detected — no manual toggle on current Navidrome.
### Sidebar — pin Now Playing to the top
- New **Settings → Sidebar** toggle moves **Now Playing** to the top of the sidebar instead of the bottom (off by default).
### Startup
- A themed loading splash appears while the app starts — colours follow your active theme, including community palettes.
## Improved
- Audio decoding runs on **Symphonia 0.6**; streams start sooner and recover from stalls without restarting the player.
- The **Preload Next Track** toggle under **Settings → Storage → Buffering** is gone — playback no longer waits on that extra RAM prefetch. Gapless, crossfade, and Hot Cache behave as before.
- New **Semitones** playback-speed strategy (±12 st, 0.1 step) with two-decimal speed readout; optional fine steps in **Settings → Audio → Advanced**.
## Fixed
### Playback and audio
- **Windows:** the app no longer keeps the audio device open while idle, so the system can sleep when music is not playing.
- **macOS:** steady playback stutter from background device polling is gone on the default output path.
- After a long pause, the seekbar shows the saved position immediately and the next **Play** resumes without an audible blip at track start.
- **Stop** keeps the real waveform on the seekbar instead of falling back to flat bars.
### Offline, Now Playing, and Navidrome
- Now Playing cards (**from this album**, discography, most played) stay populated during cached and offline playback instead of blanking out on track change.
- Navidrome **Show in Now Playing** and play-count scrobbles work when audio plays from hot cache, offline pins, or auto-synced favorites.
- Mixed-server queues still report to the correct Navidrome server.
### Themes and integrations
- Self-hosted Music Network targets (Koito, Maloja, custom GNU FM with a pasted token) scrobble again — reconnect once if you connected before this fix.
- Favoriting from the player bar, fullscreen player, or shortcuts updates the star in track lists and playlists immediately.
- Discord Rich Presence shows album art again when covers come from the server.
- Focus rings and dropdown borders follow the active theme consistently.
### Browse and library
- Tracks tagged with several genres in one field (e.g. `Metal/Ambient/Experimental`) match **each genre** again in browse, filters, and search.
- **All Albums → Only compilations** returns results for common tagging patterns.
- Album grids show the album artist on compilations instead of a random track artist.
- Song rails (**Random Picks**, **Discover Songs**, etc.) link each name in multi-artist credits separately.
- **Artist → Top Tracks** play works even when the artist page has no albums loaded yet.
- **Home → Most Played** no longer jumps the page when you load more albums.
- **Mainstage** hero backdrop stays in sync when you skip albums quickly.
### Other
- **Linux:** the `curl | bash` auto-installer works again.
- **Linux:** internet radio no longer appears twice in the desktop now-playing overlay.
- On Navidrome **0.62+**, add/edit/delete radio stations is shown only to admin accounts; everyone can still play and favourite stations.
- **Linux custom title bar:** pick window button styles (dots, flat, pill, and more) and optionally hide minimize in **Settings → Appearance**.
- The active server card under **Settings → Servers** draws a complete border on all sides.
## Under the hood
- Navidrome **0.62+** auto-detects **AudioMuse-AI** and routes Instant Mix / Lucky Mix through the smarter API when the plugin is present — older Navidrome keeps the manual toggle you already know.
+45
View File
@@ -0,0 +1,45 @@
import eslint from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
// `scripts/` (Node CI helpers) is intentionally ignored — this config targets the browser `src/` tree.
{ ignores: ['dist', 'coverage', 'src-tauri', 'research', 'scripts'] },
// This gradual baseline deliberately omits the React Compiler rules (set-state-in-effect, refs,
// immutability, …) that the strict config enables. The per-line `eslint-disable-next-line` directives
// those rules require therefore read as "unused" here, so unused-directive reporting is turned off for
// this config only — the strict config keeps the default reporting and stays 0/0.
{ linterOptions: { reportUnusedDisableDirectives: 'off' } },
eslint.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-explicit-any': 'warn',
},
},
);
+42
View File
@@ -0,0 +1,42 @@
import eslint from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
// `scripts/` (Node CI helpers) is intentionally ignored — this config targets the
// browser `src/` tree. See `npm run lint:scripts` if scripts ever need a Node-globals lint pass.
{ ignores: ['dist', 'coverage', 'src-tauri', 'research', 'scripts'] },
eslint.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-explicit-any': 'error',
// Promote deps to error at strict stage (wave 6+)
'react-hooks/exhaustive-deps': 'error',
},
},
);
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1779560665,
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"lastModified": 1782467914,
"narHash": "sha256-pGvFkM8N0xEkIIXDe5YYfbEAvHrk4IxBrjB/x8OomhE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"rev": "e73de5be04e0eff4190a1432b946d469c794e7b4",
"type": "github"
},
"original": {
+1 -1
View File
@@ -1,3 +1,3 @@
{
"npmDepsHash": "sha256-7BvKeTkZzAQoBVm2vw2oZRsRKWN/Du1pn89U1rtc47k="
"npmDepsHash": "sha256-ujJETHWrjZXDt+c0GGlsK/v8L8Ceturp16I3VxQvUo0="
}
+1740 -145
View File
File diff suppressed because it is too large Load Diff
+20 -8
View File
@@ -1,18 +1,21 @@
{
"name": "psysonic",
"version": "1.48.0-dev",
"version": "1.49.0",
"private": true,
"scripts": {
"check:css-imports": "node scripts/check-css-import-graph.mjs",
"dev": "vite",
"build": "tsc && vite build",
"prebuild:release-notes": "node scripts/generate-release-notes-bundle.mjs",
"dev": "npm run prebuild:release-notes && vite",
"build": "npm run prebuild:release-notes && tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"test": "vitest run && npm run check:css-imports",
"tauri:dev": "npm run prebuild:release-notes && tauri dev --config src-tauri/tauri.dev.conf.json",
"tauri:build": "npm run prebuild:release-notes && tauri build",
"lint": "eslint -c eslint.config.mjs src",
"lint:gradual": "eslint -c eslint.config.gradual.mjs src",
"test": "npm run prebuild:release-notes && vitest run && node --test scripts/extract-release-section.test.mjs && npm run check:css-imports",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage && npm run check:css-imports"
"test:coverage": "npm run prebuild:release-notes && vitest run --coverage && npm run check:css-imports"
},
"dependencies": {
"@fontsource-variable/dm-sans": "^5.2.8",
@@ -52,6 +55,7 @@
"zustand": "^5.0.14"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tauri-apps/cli": "^2.11.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -63,10 +67,18 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.8",
"esbuild": "^0.28.0",
"esbuild": "^0.28.1",
"eslint": "^10.5.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0",
"jsdom": "^29.1.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.62.0",
"vite": "^8.0.14",
"vitest": "^4.1.8"
},
"overrides": {
"undici": "^7.28.0"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.46.0
pkgver=1.48.1
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* Extract the body of a ## [version] section from a Keep-a-Changelog-style file.
* Resolution matches src/utils/releaseNotes/releaseNotesMatch.ts.
*
* Usage: node scripts/extract-release-section.mjs <file> <version> [--allow-empty]
* Stdout: section body (no ## header). Exit 1 if empty unless --allow-empty.
*/
import { readFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
const SEMVER_CORE = /^v?(\d+\.\d+\.\d+)/i;
function versionCore(version) {
const m = version.trim().match(SEMVER_CORE);
return m ? m[1] : null;
}
function isPlainTriple(header) {
return /^\d+\.\d+\.\d+$/.test(header.trim());
}
function splitBlocks(raw) {
return raw.split(/\n(?=## \[)/).filter((b) => b.startsWith('## ['));
}
function headerVersion(block) {
const m = block.match(/^## \[([^\]]+)\]/);
return m ? m[1] : null;
}
function parseBlock(block) {
const lines = block.split('\n');
const m = lines[0].match(/## \[([^\]]+)\](?:\s*-\s*(.+))?/);
if (!m) return null;
return {
headerVersion: m[1],
date: (m[2] ?? '').trim(),
body: lines.slice(1).join('\n').trim(),
};
}
export function findReleaseSection(raw, appVersion) {
const blocks = splitBlocks(raw);
const exact = blocks.find((b) => b.startsWith(`## [${appVersion}]`));
if (exact) return parseBlock(exact);
const appCore = versionCore(appVersion);
if (!appCore) return null;
const candidates = blocks.filter((b) => {
const hv = headerVersion(b);
return hv !== null && versionCore(hv) === appCore;
});
if (candidates.length === 0) return null;
const plain = candidates.find((b) => {
const hv = headerVersion(b);
return hv !== null && isPlainTriple(hv);
});
return parseBlock(plain ?? candidates[0]);
}
function main() {
const args = process.argv.slice(2);
const allowEmpty = args.includes('--allow-empty');
const positional = args.filter((a) => a !== '--allow-empty');
const [file, version] = positional;
if (!file || !version) {
console.error('Usage: node scripts/extract-release-section.mjs <file> <version> [--allow-empty]');
process.exit(2);
}
const raw = readFileSync(file, 'utf8');
const entry = findReleaseSection(raw, version);
const body = entry?.body?.trim() ?? '';
if (!body) {
if (allowEmpty) process.exit(0);
console.error(`No release section found in ${file} for version ${version}`);
process.exit(1);
}
process.stdout.write(`${body}\n`);
}
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMain) main();
+26
View File
@@ -0,0 +1,26 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { findReleaseSection } from './extract-release-section.mjs';
const FIXTURE = `
## [1.48.0] - 2026-06-10
## Highlights
- One
## [1.47.0]
- Old
`;
describe('findReleaseSection', () => {
it('matches base line for -rc versions', () => {
const entry = findReleaseSection(FIXTURE, '1.48.0-rc.3');
assert.equal(entry.headerVersion, '1.48.0');
assert.match(entry.body, /Highlights/);
});
it('matches base line for -dev versions', () => {
const entry = findReleaseSection(FIXTURE, '1.48.0-dev');
assert.equal(entry.headerVersion, '1.48.0');
});
});
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env node
/**
* Build src/generated/releaseNotesBundle.ts for production bundles.
* Embeds only the ## [X.Y.Z] slice for package.json version (dev, RC, and stable).
* tauri:dev reads live markdown from the repo via Vite ?raw imports instead.
*/
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { findReleaseSection } from './extract-release-section.mjs';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
const version = pkg.version;
const whatsNewPath = join(root, 'WHATS_NEW.md');
const changelogPath = join(root, 'CHANGELOG.md');
const whatsNewFull = readFileSync(whatsNewPath, 'utf8');
const changelogFull = readFileSync(changelogPath, 'utf8');
function sliceForVersion(full, fileLabel) {
const entry = findReleaseSection(full, version);
if (!entry?.body) {
console.warn(`warn: no section in ${fileLabel} for ${version} — embedding empty slice`);
return '';
}
const dateSuffix = entry.date ? ` - ${entry.date}` : '';
return `## [${entry.headerVersion}]${dateSuffix}\n\n${entry.body}`;
}
const whatsNewRaw = sliceForVersion(whatsNewFull, 'WHATS_NEW.md');
const changelogRaw = sliceForVersion(changelogFull, 'CHANGELOG.md');
const outDir = join(root, 'src/generated');
mkdirSync(outDir, { recursive: true });
const ts = `/** @generated — run: node scripts/generate-release-notes-bundle.mjs */
export const WHATS_NEW_RAW: string = ${JSON.stringify(whatsNewRaw)};
export const CHANGELOG_RAW: string = ${JSON.stringify(changelogRaw)};
`;
writeFileSync(join(outDir, 'releaseNotesBundle.ts'), ts, 'utf8');
console.log(`wrote src/generated/releaseNotesBundle.ts (sliced for ${version})`);
+19 -8
View File
@@ -14,20 +14,22 @@ YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Log helpers write to stderr so functions that return values via stdout
# (e.g. get_download_url) stay clean when called in command substitution.
info() {
echo -e "${BLUE}[INFO]${NC} $1"
echo -e "${BLUE}[INFO]${NC} $1" >&2
}
success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
echo -e "${GREEN}[SUCCESS]${NC} $1" >&2
}
warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
echo -e "${YELLOW}[WARN]${NC} $1" >&2
}
error() {
echo -e "${RED}[ERROR]${NC} $1"
echo -e "${RED}[ERROR]${NC} $1" >&2
exit 1
}
@@ -105,7 +107,7 @@ install_package() {
if [ "$OS_TYPE" = "debian" ]; then
package_file="${package_file}.deb"
curl -L -o "$package_file" "$download_url"
curl --fail --globoff -L -o "$package_file" "$download_url"
info "Installing package..."
$PACKAGE_MANAGER install -y "$package_file" || {
@@ -114,7 +116,7 @@ install_package() {
}
elif [ "$OS_TYPE" = "rhel" ]; then
package_file="${package_file}.rpm"
curl -L -o "$package_file" "$download_url"
curl --fail --globoff -L -o "$package_file" "$download_url"
info "Installing package..."
$PACKAGE_MANAGER install -y "$package_file"
@@ -128,8 +130,17 @@ install_package() {
check_installed() {
if command -v $APP_NAME &> /dev/null || command -v ${APP_NAME^} &> /dev/null; then
warn "${APP_NAME} appears to be already installed."
read -p "Do you want to reinstall? (y/N): " -n 1 -r
echo
# Under `curl ... | bash`, stdin is the script stream itself, so
# read the answer from the controlling terminal instead. Probe by
# opening: `[ -r /dev/tty ]` passes on the 0666 device node even
# without a controlling terminal; only open() reports the failure.
if { : < /dev/tty; } 2>/dev/null; then
read -p "Do you want to reinstall? (y/N): " -n 1 -r < /dev/tty
echo
else
warn "No terminal available for prompt; skipping reinstall."
exit 0
fi
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
info "Installation cancelled."
exit 0
+9 -7
View File
@@ -4114,7 +4114,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.48.0-dev"
version = "1.49.0"
dependencies = [
"biquad",
"dasp_sample",
@@ -4167,7 +4167,7 @@ dependencies = [
[[package]]
name = "psysonic-analysis"
version = "1.48.0-dev"
version = "1.49.0"
dependencies = [
"ebur128",
"futures-util",
@@ -4186,7 +4186,7 @@ dependencies = [
[[package]]
name = "psysonic-audio"
version = "1.48.0-dev"
version = "1.49.0"
dependencies = [
"biquad",
"dasp_sample",
@@ -4216,16 +4216,18 @@ dependencies = [
[[package]]
name = "psysonic-core"
version = "1.48.0-dev"
version = "1.49.0"
dependencies = [
"libc",
"reqwest",
"serde",
"tauri",
"url",
]
[[package]]
name = "psysonic-integration"
version = "1.48.0-dev"
version = "1.49.0"
dependencies = [
"discord-rich-presence",
"futures-util",
@@ -4242,7 +4244,7 @@ dependencies = [
[[package]]
name = "psysonic-library"
version = "1.48.0-dev"
version = "1.49.0"
dependencies = [
"psysonic-core",
"psysonic-integration",
@@ -4257,7 +4259,7 @@ dependencies = [
[[package]]
name = "psysonic-syncfs"
version = "1.48.0-dev"
version = "1.49.0"
dependencies = [
"futures-util",
"id3",
+1 -1
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "1.48.0-dev"
version = "1.49.0"
edition = "2021"
rust-version = "1.95"
license = "GPL-3.0-or-later"
+1
View File
@@ -22,6 +22,7 @@
"fs:allow-write-file",
"fs:allow-read-file",
"fs:allow-mkdir",
"fs:allow-app-write-recursive",
"fs:scope-download-recursive",
"fs:scope-home-recursive",
"window-state:allow-save-window-state",
@@ -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]
+247 -59
View File
@@ -11,15 +11,17 @@ use rodio::Source;
use tauri::{AppHandle, Emitter, State};
use super::decode::build_source;
use super::engine::{audio_http_client, AudioEngine};
use super::engine::AudioEngine;
use super::helpers::*;
use super::hi_res_blend::{self, OutgoingBlendSnapshot};
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
use super::play_input::{
build_playback_source_with_probe_fallback, select_play_input,
spawn_legacy_stream_start_when_armed, swap_in_new_sink, url_format_hint, BuildSourceArgs,
PlayInputContext, SinkSwapInputs,
use super::play_input::{select_play_input, url_format_hint, PlayInputContext};
use super::source_build::{build_playback_source_with_probe_fallback, BuildSourceArgs};
use super::sink_swap::{
spawn_legacy_stream_start_when_armed, swap_in_new_sink, LegacyStreamStartWhenArmed,
SinkSwapInputs,
};
use super::playback_rate::preserve_pitch_will_run;
use super::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};
@@ -50,12 +52,36 @@ pub async fn audio_play(
fallback_db: f32,
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
hi_res_enabled: bool, // false = safe 44.1 kHz mode; true = native rate (alpha)
hi_res_crossfade_resample_hz: Option<u32>, // 44100 / 88200 / 96000 when hi-res + crossfade
analysis_track_id: Option<String>,
server_id: Option<String>,
stream_format_suffix: Option<String>,
// 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 ───────────────────────────────────────────────────
@@ -215,9 +241,18 @@ 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 = if let Some(override_secs) = crossfade_secs_override {
override_secs.clamp(0.5, 30.0)
} else {
f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed))
.clamp(0.5, 12.0)
};
// Measure how much audio Track A actually has left right now.
// By the time audio_play is called, near_end_ticks (2×500ms) + IPC latency
@@ -242,6 +277,26 @@ 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
};
let blend_rate = hi_res_blend::blend_rate_hz(
hi_res_enabled,
crossfade_enabled || (gapless && !manual),
hi_res_crossfade_resample_hz,
);
let resample_target_hz = blend_rate.unwrap_or(0);
// Build source: decode → trim → resample → EQ → fade-in → fade-out → notify → count.
let done_flag = Arc::new(AtomicBool::new(false));
// Reset sample counter for the new track.
@@ -258,6 +313,7 @@ pub async fn audio_play(
done_flag: done_flag.clone(),
fade_in_dur,
hi_res_enabled,
resample_target_hz,
duration_hint,
},
&state,
@@ -281,8 +337,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;
@@ -295,6 +352,26 @@ pub async fn audio_play(
return Ok(());
}
let current_stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
let outgoing_blend: Option<OutgoingBlendSnapshot> =
if let Some(blend) = blend_rate {
if crossfade_enabled && current_stream_rate > 0 && current_stream_rate != blend {
hi_res_blend::capture_outgoing_blend_snapshot(
&state,
outgoing_fade_secs,
actual_fade_secs,
)
} else {
None
}
} else {
None
};
if outgoing_blend.is_some() {
hi_res_blend::detach_current_sink_for_blend_reopen(&state);
}
// ── Stream rate management ────────────────────────────────────────────────
// Hi-Res ON: open device at file's native rate (bit-perfect, no resampler).
// Hi-Res OFF: if the stream was previously opened at a hi-res rate (e.g. the
@@ -303,31 +380,34 @@ pub async fn audio_play(
// If already at the device default — skip entirely (no IPC, no
// PipeWire reconfigure, no scheduler cost).
{
let current_stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
let target_rate = if hi_res_enabled {
output_rate // native file rate
let target_rate = if let Some(blend) = blend_rate {
blend
} else if hi_res_enabled {
output_rate // native file rate
} else {
state.device_default_rate // restore device default
state.device_default_rate // restore device default
};
let needs_switch = target_rate > 0 && target_rate != current_stream_rate;
if needs_switch {
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
let dev = state.selected_device.lock().unwrap().clone();
if state.stream_reopen_tx.send((target_rate, hi_res_enabled, dev, reply_tx)).is_ok() {
match reply_rx.recv_timeout(std::time::Duration::from_secs(5)) {
Ok(new_handle) => {
*state.stream_handle.lock().unwrap() = new_handle;
state.stream_sample_rate.store(target_rate, Ordering::Relaxed);
// Give PipeWire time to reconfigure at the new rate before
// we open a Sink — only needed for large hi-res quanta.
if hi_res_enabled && target_rate > 48_000 {
tokio::time::sleep(Duration::from_millis(150)).await;
}
}
Err(_) => {
crate::app_eprintln!("[psysonic] stream rate switch timed out, keeping {current_stream_rate} Hz");
match super::engine::open_output_stream_blocking(
&state,
target_rate,
hi_res_enabled,
dev,
) {
Ok(_) => {
// Give PipeWire time to reconfigure at the new rate before
// we open a Sink — only needed for large hi-res quanta.
if hi_res_enabled && target_rate > 48_000 {
tokio::time::sleep(Duration::from_millis(150)).await;
}
}
Err(_) => {
crate::app_eprintln!(
"[psysonic] stream rate switch timed out, keeping {current_stream_rate} Hz"
);
}
}
}
@@ -337,7 +417,25 @@ pub async fn audio_play(
}
}
let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer()));
if let (Some(snap), Some(blend)) = (&outgoing_blend, blend_rate) {
if let Err(e) = hi_res_blend::spawn_outgoing_blend_resample(
&app,
&state,
snap,
blend,
gen,
)
.await
{
crate::app_eprintln!("{e}");
}
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
}
}
let stream = super::engine::ensure_output_stream_open(&state)?;
let sink = Arc::new(Player::connect_new(stream.mixer()));
sink.set_volume(effective_volume);
// ── Sink pre-fill for hi-res tracks ──────────────────────────────────────
@@ -352,7 +450,7 @@ pub async fn audio_play(
// ring buffer time to build headroom before the cpal callback drains it.
let needs_preserve_prefill = preserve_pitch_will_run(&state.playback_rate);
let needs_prefill =
(hi_res_enabled && output_rate > 48_000) || needs_preserve_prefill;
(hi_res_enabled && blend_rate.unwrap_or(output_rate) > 48_000) || needs_preserve_prefill;
let defer_playback_start = !state.stream_playback_armed.load(Ordering::Relaxed);
if needs_prefill || defer_playback_start {
sink.pause();
@@ -389,6 +487,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 {
@@ -401,7 +510,7 @@ pub async fn audio_play(
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(()); // skipped during pre-fill — abort silently
}
if !defer_playback_start {
if !defer_playback_start && !start_paused {
sink.play();
}
}
@@ -415,24 +524,46 @@ pub async fn audio_play(
fadeout_samples: built.fadeout_samples,
crossfade_enabled,
actual_fade_secs,
outgoing_fade_secs,
start_paused,
});
if defer_playback_start {
// 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();
cur.play_started = None;
cur.paused_at = Some(0.0);
}
spawn_legacy_stream_start_when_armed(
spawn_legacy_stream_start_when_armed(LegacyStreamStartWhenArmed {
gen,
state.generation.clone(),
state.stream_playback_armed.clone(),
state.samples_played.clone(),
state.current.clone(),
app.clone(),
gen_arc: state.generation.clone(),
playback_armed: state.stream_playback_armed.clone(),
samples_played: state.samples_played.clone(),
current: state.current.clone(),
app: app.clone(),
duration_secs,
);
} else {
hold_paused: start_paused,
});
} else if !start_paused {
app.emit("audio:playing", duration_secs).ok();
}
@@ -445,6 +576,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),
@@ -481,6 +613,7 @@ pub async fn audio_chain_preload(
pre_gain_db: f32,
fallback_db: f32,
hi_res_enabled: bool,
hi_res_crossfade_resample_hz: Option<u32>,
analysis_track_id: Option<String>,
server_id: Option<String>,
app: AppHandle,
@@ -516,7 +649,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
@@ -587,8 +727,9 @@ pub async fn audio_chain_preload(
// Use a dedicated counter for the chained source — it will be swapped into
// samples_played when the chained track becomes active.
let chain_counter = Arc::new(AtomicU64::new(0));
// Always 0 — no application-level resampling (same as audio_play).
let target_rate: u32 = 0;
// Always 0 unless hi-res gapless blend resampling is active.
let blend_rate = hi_res_blend::blend_rate_hz(hi_res_enabled, hi_res_enabled, hi_res_crossfade_resample_hz);
let target_rate: u32 = blend_rate.unwrap_or(0);
let format_hint = url.rsplit('.').next()
.and_then(|ext| ext.split('?').next())
.map(|s| s.to_lowercase());
@@ -614,23 +755,70 @@ pub async fn audio_chain_preload(
return Ok(());
}
// In hi-res mode: if the next track's native rate differs from the current
// output stream, we cannot chain gaplessly — audio_play will do a hard cut
// with a stream re-open. Store raw bytes to avoid re-downloading.
// In safe mode (44.1 kHz locked): the stream rate is always 44100, so the
// chain proceeds and rodio resamples internally — no bail needed.
let next_rate = if hi_res_enabled { built.output_rate } else { 44_100 };
// Hi-res gapless: resample the chained track to the blend rate and realign
// the output stream when its Hz differs from the current track.
let stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
if hi_res_enabled && stream_rate > 0 && next_rate != stream_rate {
crate::app_eprintln!(
"[psysonic] gapless chain skipped: next track rate {} Hz ≠ stream {} Hz",
next_rate, stream_rate
);
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
url,
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
});
return Ok(());
if let Some(br) = blend_rate {
if stream_rate > 0 && stream_rate != br {
if let Some(snap) = hi_res_blend::capture_outgoing_blend_snapshot(&state, 0.0, 0.0) {
hi_res_blend::detach_current_sink_for_blend_reopen(&state);
let dev = state.selected_device.lock().unwrap().clone();
if super::engine::open_output_stream_blocking(&state, br, true, dev).is_ok() {
if hi_res_enabled && br > 48_000 {
tokio::time::sleep(Duration::from_millis(150)).await;
}
if state.generation.load(Ordering::SeqCst) == snapshot_gen {
if let Err(e) = hi_res_blend::rebuild_current_track_at_blend_rate(
&app,
&state,
&snap,
br,
snapshot_gen,
)
.await
{
crate::app_eprintln!("{e}");
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
url: url.clone(),
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
});
return Ok(());
}
}
} else {
crate::app_eprintln!(
"[psysonic] gapless blend stream reopen failed (wanted {br} Hz, had {stream_rate} Hz)"
);
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
url,
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
});
return Ok(());
}
} else {
crate::app_eprintln!(
"[psysonic] gapless blend skipped: current track not cached for realign"
);
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
url,
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
});
return Ok(());
}
}
} else {
let next_rate = if hi_res_enabled { built.output_rate } else { 44_100 };
if hi_res_enabled && stream_rate > 0 && next_rate != stream_rate {
crate::app_eprintln!(
"[psysonic] gapless chain skipped: next track rate {} Hz ≠ stream {} Hz",
next_rate, stream_rate
);
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
url,
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
});
return Ok(());
}
}
// Append to the existing Sink. The audio hardware stream never stalls.
+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());
}
@@ -2,9 +2,7 @@
//! `commands.rs` so playback / radio / EQ aren't entangled with the device
//! enumeration + reopen path.
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
use tauri::{Emitter, State};
@@ -78,16 +76,13 @@ pub async fn audio_set_device(
*state.selected_device.lock().unwrap() = device_name.clone();
let rate = state.stream_sample_rate.load(Ordering::Relaxed);
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
state.stream_reopen_tx
.send((rate, false, device_name, reply_tx))
.map_err(|e| e.to_string())?;
let new_handle = tauri::async_runtime::spawn_blocking(move || {
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
}).await.unwrap_or(None).ok_or("device open timed out")?;
*state.stream_handle.lock().unwrap() = new_handle;
let open_rate = if rate > 0 {
rate
} else {
state.device_default_rate
};
super::engine::open_output_stream_blocking(&state, open_rate, false, device_name.clone())
.map_err(|_| "device open timed out".to_string())?;
// Capture position and drop the active sink atomically so the position
// reading is still valid (play_started / paused_at intact before take).
@@ -23,10 +23,11 @@ use tauri::Emitter;
use tauri::Manager;
use super::engine::AudioEngine;
use super::play_input::{
build_playback_source_with_probe_fallback, swap_in_new_sink, url_format_hint,
BuildSourceArgs, PlayInput, PlaybackSource, SinkSwapInputs,
use super::play_input::{url_format_hint, PlayInput};
use super::source_build::{
build_playback_source_with_probe_fallback, BuildSourceArgs, PlaybackSource,
};
use super::sink_swap::{swap_in_new_sink, SinkSwapInputs};
use super::progress_task::spawn_progress_task;
use super::stream::LocalFileSource;
@@ -86,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,
}
}
@@ -159,6 +161,7 @@ pub(crate) async fn try_resume_after_device_change(
done_flag: done_flag.clone(),
fade_in_dur: std::time::Duration::from_millis(5),
hi_res_enabled,
resample_target_hz: 0,
duration_hint: snap.duration_secs,
},
&engine,
@@ -187,9 +190,14 @@ pub(crate) async fn try_resume_after_device_change(
.current_channels
.store(ps.built.output_channels as u32, Ordering::Relaxed);
let sink = Arc::new(Player::connect_new(
engine.stream_handle.lock().unwrap().mixer(),
));
let stream = match super::engine::ensure_output_stream_open(&engine) {
Ok(s) => s,
Err(e) => {
crate::app_eprintln!("[device-resume] output stream open failed: {e}");
return false;
}
};
let sink = Arc::new(Player::connect_new(stream.mixer()));
let effective_volume = (snap.base_volume * snap.gain_linear).clamp(0.0, 1.0);
sink.set_volume(effective_volume);
sink.append(ps.built.source);
@@ -205,6 +213,8 @@ 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,
},
);
@@ -253,6 +263,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),
@@ -1,6 +1,5 @@
//! Poll default output device and pinned-device presence; reopen stream when needed.
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tauri::Emitter;
@@ -41,8 +40,11 @@ pub(crate) async fn reopen_output_stream(
};
let rate = engine.stream_sample_rate.load(Ordering::Relaxed);
let reopen_tx = engine.stream_reopen_tx.clone();
let stream_handle = engine.stream_handle.clone();
let open_rate = if rate > 0 {
rate
} else {
engine.device_default_rate
};
let current = engine.current.clone();
let fading_out = engine.fading_out_sink.clone();
@@ -62,25 +64,33 @@ pub(crate) async fn reopen_output_stream(
}
};
let new_handle = tauri::async_runtime::spawn_blocking(move || {
let (reply_tx, reply_rx) =
std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
if reopen_tx
.send((rate, false, device_name, reply_tx))
.is_err()
{
return None;
}
reply_rx.recv_timeout(Duration::from_secs(5)).ok()
let app_for_open = app.clone();
let device_name_for_open = device_name.clone();
let opened = tauri::async_runtime::spawn_blocking(move || {
let engine = app_for_open.state::<AudioEngine>();
super::engine::open_output_stream_blocking(
&engine,
open_rate,
false,
device_name_for_open,
)
.is_ok()
})
.await
.unwrap_or(None);
.unwrap_or(false);
let Some(handle) = new_handle else {
if !opened {
return false;
};
*stream_handle.lock().unwrap() = handle;
}
// 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();
}
+250 -57
View File
@@ -4,24 +4,36 @@ use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant};
use rodio::Player;
use tauri::Manager;
use super::state::{ChainedInfo, PreloadedTrack, StreamCompletedSpill};
/// Reply channel handed back to the audio-stream thread once a re-open finishes.
pub type StreamReopenReply = std::sync::mpsc::SyncSender<Arc<rodio::MixerDeviceSink>>;
/// Stream-thread re-open request: `(desired_rate, is_hi_res, device_name, reply_tx)`.
pub type StreamReopenRequest = (u32, bool, Option<String>, StreamReopenReply);
/// Reply channel handed back to the audio-stream thread once an open finishes.
pub type StreamOpenReply =
std::sync::mpsc::SyncSender<(Arc<rodio::MixerDeviceSink>, u32)>;
/// Requests handled on the dedicated audio-stream thread (open / idle release).
pub enum StreamThreadMsg {
Open {
desired_rate: u32,
is_hi_res: bool,
device_name: Option<String>,
reply: StreamOpenReply,
},
Release {
reply: std::sync::mpsc::SyncSender<()>,
},
}
pub struct AudioEngine {
pub stream_handle: Arc<std::sync::Mutex<Arc<rodio::MixerDeviceSink>>>,
pub stream_handle: Arc<std::sync::Mutex<Option<Arc<rodio::MixerDeviceSink>>>>,
/// Sample rate the output stream was last opened at (updated on every re-open).
pub stream_sample_rate: Arc<AtomicU32>,
/// The rate the device was opened at on cold start — used to restore the
/// stream when Hi-Res is toggled off while a hi-res rate is active.
pub device_default_rate: u32,
/// Sends `(desired_rate, is_hi_res, device_name, reply_tx)` to the audio-stream
/// thread to re-open the output device. `device_name = None` → system default.
pub stream_reopen_tx: std::sync::mpsc::SyncSender<StreamReopenRequest>,
/// Open or release the CPAL output stream on the audio-stream thread.
pub stream_thread_tx: std::sync::mpsc::SyncSender<StreamThreadMsg>,
/// User-selected output device name (None = follow system default).
pub selected_device: Arc<Mutex<Option<String>>>,
pub current: Arc<Mutex<AudioCurrent>>,
@@ -49,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.
@@ -147,6 +168,15 @@ impl AudioCurrent {
/// 3. Device default.
/// 4. System default (last resort).
///
/// Rodio prints a stderr line on every intentional stream drop. Keep that only
/// when runtime logging is in **debug** mode; normal/off silence the noise.
fn finalize_mixer_device_sink(mut handle: rodio::MixerDeviceSink) -> Arc<rodio::MixerDeviceSink> {
if !crate::logging::should_log_debug() {
handle.log_on_drop(false);
}
Arc::new(handle)
}
/// Returns `(stream_handle, actual_sample_rate)`.
fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32) -> (Arc<rodio::MixerDeviceSink>, u32) {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
@@ -214,7 +244,7 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
.and_then(|b| b.with_sample_rate(std::num::NonZeroU32::new(desired_rate).unwrap_or(std::num::NonZeroU32::MIN)).open_stream())
{
crate::app_eprintln!("[psysonic] audio stream opened at {} Hz (exact)", desired_rate);
return (Arc::new(handle), desired_rate);
return (finalize_mixer_device_sink(handle), desired_rate);
}
}
@@ -231,7 +261,7 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
"[psysonic] audio stream opened at {} Hz (highest, wanted {})",
rate, desired_rate
);
return (Arc::new(handle), rate);
return (finalize_mixer_device_sink(handle), rate);
}
}
}
@@ -244,7 +274,7 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
.map(|c| c.sample_rate())
.unwrap_or(44100);
crate::app_eprintln!("[psysonic] audio stream opened at {} Hz (device default)", rate);
return (Arc::new(handle), rate);
return (finalize_mixer_device_sink(handle), rate);
}
}
@@ -257,7 +287,78 @@ fn open_stream_for_device_and_rate(device_name: Option<&str>, desired_rate: u32)
.and_then(|d| d.default_output_config().ok())
.map(|c| c.sample_rate())
.unwrap_or(44100);
(Arc::new(handle), rate)
(finalize_mixer_device_sink(handle), rate)
}
fn probe_device_default_rate() -> u32 {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
rodio::cpal::default_host()
.default_output_device()
.and_then(|d| d.default_output_config().ok())
.map(|c| c.sample_rate())
.unwrap_or(44_100)
}
/// Open the output stream (blocking). Updates `stream_handle` and `stream_sample_rate`.
pub(crate) fn open_output_stream_blocking(
engine: &AudioEngine,
desired_rate: u32,
is_hi_res: bool,
device_name: Option<String>,
) -> Result<Arc<rodio::MixerDeviceSink>, String> {
let rate = if desired_rate > 0 {
desired_rate
} else {
engine.device_default_rate
};
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel(0);
engine
.stream_thread_tx
.send(StreamThreadMsg::Open {
desired_rate: rate,
is_hi_res,
device_name,
reply: reply_tx,
})
.map_err(|e| e.to_string())?;
let (handle, actual_rate) = reply_rx
.recv_timeout(Duration::from_secs(5))
.map_err(|_| "audio stream open timed out".to_string())?;
engine
.stream_sample_rate
.store(actual_rate, std::sync::atomic::Ordering::Relaxed);
*engine.stream_handle.lock().unwrap() = Some(handle.clone());
Ok(handle)
}
/// Ensure a live output stream exists; lazy-opens on first playback.
pub(crate) fn ensure_output_stream_open(
engine: &AudioEngine,
) -> Result<Arc<rodio::MixerDeviceSink>, String> {
if let Some(handle) = engine.stream_handle.lock().unwrap().clone() {
return Ok(handle);
}
let rate = engine.stream_sample_rate.load(std::sync::atomic::Ordering::Relaxed);
let open_rate = if rate > 0 {
rate
} else {
engine.device_default_rate
};
let device = engine.selected_device.lock().unwrap().clone();
open_output_stream_blocking(engine, open_rate, false, device)
}
pub(crate) fn request_stream_release(engine: &AudioEngine) -> Result<(), String> {
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel(0);
engine
.stream_thread_tx
.send(StreamThreadMsg::Release { reply: reply_tx })
.map_err(|e| e.to_string())?;
reply_rx
.recv_timeout(Duration::from_secs(5))
.map_err(|_| "audio stream release timed out".to_string())?;
Ok(())
}
pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
@@ -269,13 +370,12 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
}
}
// Channels: main thread ←→ audio-stream thread.
// init_tx/rx : (Arc<rodio::MixerDeviceSink>, actual_rate) sent once at startup.
// reopen_tx/rx: (desired_rate, reply_tx) — triggers a stream re-open.
let (init_tx, init_rx) =
std::sync::mpsc::sync_channel::<(Arc<rodio::MixerDeviceSink>, u32)>(0);
let (reopen_tx, reopen_rx) =
std::sync::mpsc::sync_channel::<(u32, bool, Option<String>, std::sync::mpsc::SyncSender<Arc<rodio::MixerDeviceSink>>)>(4);
// Channel: main thread ←→ audio-stream thread (lazy open + idle release).
let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::<()>(0);
let (stream_thread_tx, stream_thread_rx) =
std::sync::mpsc::sync_channel::<StreamThreadMsg>(4);
let device_default_rate = probe_device_default_rate();
let thread = std::thread::Builder::new()
.name("psysonic-audio-stream".into())
@@ -296,52 +396,63 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
// Thread priority is kept at default during standard-mode playback.
// It is escalated to Max only when a Hi-Res stream reopen is requested,
// to prevent PipeWire underruns at high quantum sizes (8192 frames).
let (mut _stream, rate) = open_stream_for_device_and_rate(None, 0);
let handle = _stream.clone();
init_tx.send((handle, rate)).ok();
let mut _stream: Option<Arc<rodio::MixerDeviceSink>> = None;
ready_tx.send(()).ok();
// Keep the stream alive and handle sample-rate / device-switch requests.
while let Ok((desired_rate, is_hi_res, device_name, reply_tx)) = reopen_rx.recv() {
// Escalate to Max for Hi-Res reopens (large PipeWire quanta need
// real-time scheduling to avoid underruns). No escalation for
// standard mode — the thread blocks on recv() between reopens so
// elevated priority would only waste scheduler budget.
if is_hi_res {
thread_priority::set_current_thread_priority(
thread_priority::ThreadPriority::Max
).ok();
while let Ok(msg) = stream_thread_rx.recv() {
match msg {
StreamThreadMsg::Release { reply } => {
_stream = None;
let _ = reply.send(());
}
StreamThreadMsg::Open {
desired_rate,
is_hi_res,
device_name,
reply,
} => {
// Escalate to Max for Hi-Res reopens (large PipeWire quanta need
// real-time scheduling to avoid underruns). No escalation for
// standard mode — the thread blocks on recv() between reopens so
// elevated priority would only waste scheduler budget.
if is_hi_res {
thread_priority::set_current_thread_priority(
thread_priority::ThreadPriority::Max,
)
.ok();
}
_stream = None;
// Scale the PipeWire quantum with the sample rate so wall-clock
// latency stays roughly constant (≈93 ms) at all rates.
#[cfg(target_os = "linux")]
if desired_rate > 0 {
let frames: u32 = if desired_rate > 48_000 { 8192 } else { 4096 };
std::env::set_var("PIPEWIRE_LATENCY", format!("{frames}/{desired_rate}"));
let latency_ms =
(frames as f64 / desired_rate as f64 * 1000.0).round() as u64;
std::env::set_var("PULSE_LATENCY_MSEC", latency_ms.to_string());
}
let (new_stream, actual_rate) =
open_stream_for_device_and_rate(device_name.as_deref(), desired_rate);
let new_handle = new_stream.clone();
_stream = Some(new_stream);
let _ = reply.send((new_handle, actual_rate));
}
}
drop(_stream); // close old stream before opening new one
// Scale the PipeWire quantum with the sample rate so wall-clock
// latency stays roughly constant (≈93 ms) at all rates.
// 8192 frames at 88200 Hz ≈ 92.9 ms (same as 4096 at 48000 Hz).
#[cfg(target_os = "linux")]
{
let frames: u32 = if desired_rate > 48_000 { 8192 } else { 4096 };
std::env::set_var("PIPEWIRE_LATENCY", format!("{frames}/{desired_rate}"));
// Keep PULSE_LATENCY_MSEC in sync so PulseAudio-based setups
// get the same wall-clock quantum as PipeWire.
let latency_ms = (frames as f64 / desired_rate as f64 * 1000.0).round() as u64;
std::env::set_var("PULSE_LATENCY_MSEC", latency_ms.to_string());
}
let (new_stream, _actual) = open_stream_for_device_and_rate(device_name.as_deref(), desired_rate);
let new_handle = new_stream.clone();
_stream = new_stream;
reply_tx.send(new_handle).ok();
}
})
.expect("spawn audio stream thread");
let (initial_handle, initial_rate) = init_rx.recv().expect("audio stream handle");
ready_rx.recv().expect("audio stream thread ready");
let engine = AudioEngine {
stream_handle: Arc::new(std::sync::Mutex::new(initial_handle)),
stream_sample_rate: Arc::new(AtomicU32::new(initial_rate)),
device_default_rate: initial_rate,
stream_reopen_tx: reopen_tx,
stream_handle: Arc::new(std::sync::Mutex::new(None)),
stream_sample_rate: Arc::new(AtomicU32::new(0)),
device_default_rate,
stream_thread_tx,
selected_device: Arc::new(Mutex::new(None)),
current: Arc::new(Mutex::new(AudioCurrent {
sink: None,
@@ -374,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)),
@@ -458,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::*;
@@ -0,0 +1,351 @@
//! Hi-Res transition blend: resample to a user-chosen rate when crossfade,
//! AutoDJ, or gapless must cross a sample-rate boundary.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use rodio::Player;
use tauri::{AppHandle, State};
use super::engine::AudioEngine;
use super::playback_rate::raw_counter_samples_for_content_position;
use super::play_input::{url_format_hint, PlayInput};
use super::source_build::{build_playback_source_with_probe_fallback, BuildSourceArgs, PlaybackSource};
use super::stream::LocalFileSource;
const BLEND_44100: u32 = 44_100;
const BLEND_88200: u32 = 88_200;
const BLEND_96000: u32 = 96_000;
/// User-selected blend rate for hi-res transitions; `None` when inactive.
pub(crate) fn blend_rate_hz(
hi_res_enabled: bool,
transition_blend_active: bool,
hz: Option<u32>,
) -> Option<u32> {
if !hi_res_enabled || !transition_blend_active {
return None;
}
let raw = hz.unwrap_or(BLEND_44100);
match raw {
BLEND_44100 | BLEND_88200 | BLEND_96000 => Some(raw),
_ => Some(BLEND_44100),
}
}
pub(crate) struct OutgoingBlendSnapshot {
pub(crate) url: String,
pub(crate) position_secs: f64,
pub(crate) duration_secs: f64,
pub(crate) base_volume: f32,
pub(crate) gain_linear: f32,
pub(crate) outgoing_fade_secs: f32,
pub(crate) actual_fade_secs: f32,
pub(crate) analysis_track_id: Option<String>,
}
/// Capture the currently playing track before a hi-res blend stream reopen.
pub(crate) fn capture_outgoing_blend_snapshot(
state: &AudioEngine,
outgoing_fade_secs: f32,
actual_fade_secs: f32,
) -> Option<OutgoingBlendSnapshot> {
let url = state.current_playback_url.lock().unwrap().clone()?;
if url.is_empty() {
return None;
}
let (position_secs, duration_secs, base_volume, gain_linear, playing) = {
let cur = state.current.lock().unwrap();
let playing = cur.sink.is_some() && cur.paused_at.is_none();
(
cur.position(),
cur.duration_secs,
cur.base_volume,
cur.replay_gain_linear,
playing,
)
};
if !playing {
return None;
}
let analysis_track_id = state.current_analysis_track_id.lock().unwrap().clone();
Some(OutgoingBlendSnapshot {
url,
position_secs,
duration_secs,
base_volume,
gain_linear,
outgoing_fade_secs,
actual_fade_secs,
analysis_track_id,
})
}
/// Drop the live main sink so a stream reopen does not leave dangling players.
pub(crate) fn detach_current_sink_for_blend_reopen(state: &AudioEngine) {
let mut cur = state.current.lock().unwrap();
if let Some(old) = cur.sink.take() {
old.stop();
}
cur.fadeout_trigger = None;
cur.fadeout_samples = None;
}
fn resolve_cached_play_input(engine: &AudioEngine, url: &str) -> Option<PlayInput> {
if url.starts_with("psysonic-local://") {
let path = url.strip_prefix("psysonic-local://").unwrap_or(url);
let file = std::fs::File::open(path).ok()?;
let len = file.metadata().map(|m| m.len()).unwrap_or(0);
return Some(PlayInput::SeekableMedia {
reader: Box::new(LocalFileSource { file, len }),
format_hint: url_format_hint(url),
tag: "LocalFile[hi-res-blend]",
random_access: true,
mp4_probe_gate: None,
});
}
let ram_bytes = {
let guard = engine.stream_completed_cache.lock().unwrap();
guard
.as_ref()
.filter(|t| t.url == url)
.map(|t| t.data.clone())
};
let bytes = if let Some(b) = ram_bytes {
b
} else {
let spill_path = {
let guard = engine.stream_completed_spill.lock().unwrap();
guard
.as_ref()
.filter(|s| s.url == url)
.map(|s| s.path.clone())
};
match spill_path {
Some(p) => std::fs::read(&p).ok()?,
None => return None,
}
};
Some(PlayInput::Bytes(bytes))
}
/// Rebuild the outgoing track on `fading_out_sink` at `blend_rate` after reopen.
pub(crate) async fn spawn_outgoing_blend_resample(
app: &AppHandle,
state: &State<'_, AudioEngine>,
snap: &OutgoingBlendSnapshot,
blend_rate: u32,
gen: u64,
) -> Result<(), String> {
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
}
let play_input = resolve_cached_play_input(state, &snap.url).ok_or_else(|| {
format!(
"[hi-res-blend] outgoing track not cached for blend reopen: {}",
snap.url
)
})?;
let done_flag = Arc::new(AtomicBool::new(false));
let format_hint = url_format_hint(&snap.url);
let stream_format_suffix: Option<String> = snap
.url
.rsplit('.')
.next()
.and_then(|e| e.split('?').next())
.map(|s| s.to_lowercase());
let resume_server = super::helpers::current_playback_server_id_str(state);
let ps: PlaybackSource = build_playback_source_with_probe_fallback(
play_input,
BuildSourceArgs {
url: &snap.url,
gen,
cache_id_for_tasks: snap.analysis_track_id.as_deref(),
server_id: Some(resume_server.as_str()),
url_format_hint: format_hint.as_deref(),
stream_format_suffix: stream_format_suffix.as_deref(),
done_flag: done_flag.clone(),
fade_in_dur: Duration::from_millis(5),
hi_res_enabled: true,
resample_target_hz: blend_rate,
duration_hint: snap.duration_secs,
},
state,
app,
)
.await?;
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
}
let stream = super::engine::ensure_output_stream_open(state)?;
let sink = Arc::new(Player::connect_new(stream.mixer()));
let effective_volume = (snap.base_volume * snap.gain_linear).clamp(0.0, 1.0);
sink.set_volume(effective_volume);
sink.append(ps.built.source);
if ps.is_seekable && snap.position_secs > 0.05 {
let target = Duration::from_secs_f64(snap.position_secs.max(0.0));
sink.try_seek(target)
.map_err(|e| format!("[hi-res-blend] outgoing seek failed: {e}"))?;
}
let fade_secs = snap.outgoing_fade_secs;
if fade_secs > 0.0 {
let rate = blend_rate;
let ch = state.current_channels.load(Ordering::Relaxed).max(2);
let fade_total = (fade_secs as f64 * rate as f64 * ch as f64) as u64;
ps.built
.fadeout_samples
.store(fade_total.max(1), Ordering::SeqCst);
ps.built.fadeout_trigger.store(true, Ordering::SeqCst);
}
sink.play();
*state.fading_out_sink.lock().unwrap() = Some(sink);
let fo_arc = state.fading_out_sink.clone();
let cleanup_secs = snap.actual_fade_secs.max(snap.outgoing_fade_secs) + 0.5;
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs_f32(cleanup_secs)).await;
if let Some(s) = fo_arc.lock().unwrap().take() {
s.stop();
}
});
crate::app_deprintln!(
"[hi-res-blend] outgoing rebuilt at {blend_rate} Hz from {:.2}s (fade {:.2}s)",
snap.position_secs,
fade_secs
);
Ok(())
}
/// Rebuild the **current** track on a freshly opened blend-rate stream (gapless
/// chain realign) so the next source can append to the same sink.
pub(crate) async fn rebuild_current_track_at_blend_rate(
app: &AppHandle,
state: &State<'_, AudioEngine>,
snap: &OutgoingBlendSnapshot,
blend_rate: u32,
gen: u64,
) -> Result<(), String> {
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
}
let play_input = resolve_cached_play_input(state, &snap.url).ok_or_else(|| {
format!(
"[hi-res-blend] current track not cached for gapless realign: {}",
snap.url
)
})?;
let done_flag = Arc::new(AtomicBool::new(false));
let format_hint = url_format_hint(&snap.url);
let stream_format_suffix: Option<String> = snap
.url
.rsplit('.')
.next()
.and_then(|e| e.split('?').next())
.map(|s| s.to_lowercase());
let resume_server = super::helpers::current_playback_server_id_str(state);
let ps: PlaybackSource = build_playback_source_with_probe_fallback(
play_input,
BuildSourceArgs {
url: &snap.url,
gen,
cache_id_for_tasks: snap.analysis_track_id.as_deref(),
server_id: Some(resume_server.as_str()),
url_format_hint: format_hint.as_deref(),
stream_format_suffix: stream_format_suffix.as_deref(),
done_flag: done_flag.clone(),
fade_in_dur: Duration::from_millis(5),
hi_res_enabled: true,
resample_target_hz: blend_rate,
duration_hint: snap.duration_secs,
},
state,
app,
)
.await?;
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
}
state
.current_sample_rate
.store(ps.built.output_rate, Ordering::Relaxed);
state
.current_channels
.store(ps.built.output_channels as u32, Ordering::Relaxed);
let stream = super::engine::ensure_output_stream_open(state)?;
let sink = Arc::new(Player::connect_new(stream.mixer()));
let effective_volume = (snap.base_volume * snap.gain_linear).clamp(0.0, 1.0);
sink.set_volume(effective_volume);
sink.append(ps.built.source);
if ps.is_seekable && snap.position_secs > 0.05 {
let target = Duration::from_secs_f64(snap.position_secs.max(0.0));
sink.try_seek(target)
.map_err(|e| format!("[hi-res-blend] gapless realign seek failed: {e}"))?;
}
sink.play();
{
let mut cur = state.current.lock().unwrap();
cur.sink = Some(sink);
cur.duration_secs = ps.built.duration_secs;
cur.seek_offset = snap.position_secs;
cur.play_started = Some(Instant::now());
cur.paused_at = None;
cur.replay_gain_linear = snap.gain_linear;
cur.base_volume = snap.base_volume;
cur.fadeout_trigger = Some(ps.built.fadeout_trigger);
cur.fadeout_samples = Some(ps.built.fadeout_samples);
}
state.samples_played.store(
raw_counter_samples_for_content_position(
snap.position_secs,
ps.built.output_rate,
ps.built.output_channels as u32,
&state.playback_rate,
),
Ordering::Relaxed,
);
crate::app_deprintln!(
"[hi-res-blend] gapless realigned current track at {blend_rate} Hz from {:.2}s",
snap.position_secs
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blend_rate_inactive_without_hi_res_or_transition() {
assert_eq!(blend_rate_hz(false, true, Some(96_000)), None);
assert_eq!(blend_rate_hz(true, false, Some(96_000)), None);
}
#[test]
fn blend_rate_sanitizes_hz() {
assert_eq!(blend_rate_hz(true, true, None), Some(44_100));
assert_eq!(blend_rate_hz(true, true, Some(88_200)), Some(88_200));
assert_eq!(blend_rate_hz(true, true, Some(48_000)), Some(44_100));
}
}
@@ -16,6 +16,8 @@ pub mod device_commands;
pub mod mix_commands;
mod play_input;
pub mod playback_rate;
mod sink_swap;
mod source_build;
mod preserve_worker;
pub mod preload_commands;
pub(crate) mod progress_task;
@@ -24,6 +26,7 @@ pub mod transport_commands;
mod device_resume;
mod device_watcher;
mod engine;
mod stream_idle;
#[cfg(any(target_os = "windows", target_os = "linux"))]
mod power_resume;
#[cfg(target_os = "windows")]
@@ -31,6 +34,7 @@ mod power_notify_win;
#[cfg(target_os = "linux")]
mod power_notify_linux;
mod helpers;
mod hi_res_blend;
mod ipc;
pub mod preview;
mod sources;
@@ -39,6 +43,7 @@ mod stream;
pub use device_commands::{audio_default_output_device_name, audio_list_devices_for_engine};
pub use device_watcher::start_device_watcher;
pub use stream_idle::start_stream_idle_watcher;
pub use engine::{create_engine, refresh_http_user_agent, AudioEngine};
pub use helpers::{
cleanup_orphan_stream_spill_dir, take_stream_completed_for_url,
@@ -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,
+36 -503
View File
@@ -15,11 +15,10 @@ use super::analysis_dispatch::{
prepare_playback_analysis, spawn_track_analysis_bytes, spawn_track_analysis_file,
TrackAnalysisOrigin,
};
use super::decode::{build_source, build_streaming_source, BuiltSource, SizedDecoder};
use super::engine::{audio_http_client, AudioEngine};
use super::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, resolve_playback_format_hint, sniff_stream_format_extension,
normalize_stream_suffix_for_hint, sniff_stream_format_extension,
same_playback_target,
STREAM_FORMAT_SNIFF_PROBE_BYTES,
};
@@ -41,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>,
},
@@ -202,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,
})
}
@@ -214,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
@@ -253,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
@@ -325,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,
@@ -341,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,
}));
}
@@ -379,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,
));
@@ -401,47 +431,6 @@ async fn open_ranged_or_streaming_input(
}))
}
/// Legacy `AudioStreamReader`: keep the sink paused until the download task arms
/// playback, then reset counters and emit `audio:playing` so the UI does not
/// extrapolate ahead of audible output.
pub(super) fn spawn_legacy_stream_start_when_armed(
gen: u64,
gen_arc: Arc<AtomicU64>,
playback_armed: Arc<AtomicBool>,
samples_played: Arc<AtomicU64>,
current: Arc<Mutex<super::engine::AudioCurrent>>,
app: AppHandle,
duration_secs: f64,
) {
tokio::spawn(async move {
loop {
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
if playback_armed.load(Ordering::Relaxed) {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
samples_played.store(0, Ordering::Relaxed);
let sink = current.lock().unwrap().sink.clone();
if let Some(sink) = sink {
{
let mut cur = current.lock().unwrap();
cur.play_started = Some(std::time::Instant::now());
cur.paused_at = None;
cur.seek_offset = 0.0;
}
sink.play();
app.emit("audio:playing", duration_secs).ok();
crate::app_deprintln!("[stream] legacy track-stream: playback started after buffer ready");
}
});
}
/// Pulled out of the format_hint extraction block in `audio_play` — strip the
/// query string first so Subsonic-style URLs (`stream.view?...&v=1.16.1&...`)
/// don't latch onto random query-param substrings; only accept short
@@ -460,459 +449,3 @@ pub(crate) fn url_format_hint(url: &str) -> Option<String> {
})
.map(|s| s.to_lowercase())
}
/// Arguments forwarded from `audio_play` into the source-build pipeline.
/// Bundles the format-hint inputs, playback-shaping parameters and the shared
/// done flag so that `build_playback_source_with_probe_fallback` stays below
/// the `clippy::too_many_arguments` threshold.
pub(crate) struct BuildSourceArgs<'a> {
pub url: &'a str,
pub gen: u64,
pub cache_id_for_tasks: Option<&'a str>,
pub server_id: Option<&'a str>,
pub url_format_hint: Option<&'a str>,
pub stream_format_suffix: Option<&'a str>,
pub done_flag: Arc<AtomicBool>,
pub fade_in_dur: Duration,
pub hi_res_enabled: bool,
pub duration_hint: f64,
}
/// Output of `build_source_from_play_input`: the wrapped rodio source plus
/// whether the chosen source path is seekable (only the Streaming variant
/// is not).
pub(crate) struct PlaybackSource {
pub(crate) built: BuiltSource,
pub(crate) is_seekable: bool,
}
/// State + decisions audio_play computed before the sink swap.
pub(crate) struct SinkSwapInputs {
pub(crate) sink: Arc<rodio::Player>,
pub(crate) duration_secs: f64,
pub(crate) volume: f32,
pub(crate) gain_linear: f32,
pub(crate) fadeout_trigger: Arc<AtomicBool>,
pub(crate) fadeout_samples: Arc<std::sync::atomic::AtomicU64>,
pub(crate) crossfade_enabled: bool,
pub(crate) actual_fade_secs: f32,
}
/// Atomically swap the new sink into `state.current`, then handle the old
/// sink: trigger sample-level fade-out (crossfade enabled) or stop it
/// immediately (hard cut). The fade-out is handed off to a small spawned
/// task that drops the old sink ~`actual_fade_secs + 0.5 s` later.
pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapInputs) {
use std::time::Instant;
let SinkSwapInputs {
sink,
duration_secs,
volume,
gain_linear,
fadeout_trigger: new_fadeout_trigger,
fadeout_samples: new_fadeout_samples,
crossfade_enabled,
actual_fade_secs,
} = inputs;
let (old_sink, old_fadeout_trigger, old_fadeout_samples) = {
let mut cur = state.current.lock().unwrap();
let old = cur.sink.take();
let old_fo_trigger = cur.fadeout_trigger.take();
let old_fo_samples = cur.fadeout_samples.take();
cur.sink = Some(sink);
cur.duration_secs = duration_secs;
cur.seek_offset = 0.0;
cur.play_started = Some(Instant::now());
cur.paused_at = None;
cur.replay_gain_linear = gain_linear;
cur.base_volume = volume.clamp(0.0, 1.0);
cur.fadeout_trigger = Some(new_fadeout_trigger);
cur.fadeout_samples = Some(new_fadeout_samples);
(old, old_fo_trigger, old_fo_samples)
};
if crossfade_enabled {
if let Some(old) = old_sink {
// Trigger sample-level fade-out on Track A via TriggeredFadeOut.
// Calculate total fade samples from the measured actual_fade_secs.
let rate = state.current_sample_rate.load(Ordering::Relaxed);
let ch = state.current_channels.load(Ordering::Relaxed);
let fade_total = (actual_fade_secs as f64 * rate as f64 * ch as f64) as u64;
if let (Some(trigger), Some(samples)) = (old_fadeout_trigger, old_fadeout_samples) {
samples.store(fade_total.max(1), Ordering::SeqCst);
trigger.store(true, Ordering::SeqCst);
}
// Keep old sink alive until the fade completes + small margin,
// then drop it. No volume stepping needed — the fade-out runs
// at sample level inside the audio thread.
*state.fading_out_sink.lock().unwrap() = Some(old);
let fo_arc = state.fading_out_sink.clone();
let cleanup_dur = Duration::from_secs_f32(actual_fade_secs + 0.5);
tokio::spawn(async move {
tokio::time::sleep(cleanup_dur).await;
if let Some(s) = fo_arc.lock().unwrap().take() {
s.stop();
}
});
}
} else if let Some(old) = old_sink {
old.stop();
}
}
fn play_media_format_hint(input: &PlayInput) -> Option<String> {
match input {
PlayInput::SeekableMedia { format_hint, .. } | PlayInput::Streaming { format_hint, .. } => {
format_hint.clone()
}
PlayInput::Bytes(_) => None,
}
}
/// Ranged HTTP probe/decode failed in a way that may succeed after the
/// background download finishes (moov-at-end, demuxer EOF during partial buffer).
fn is_ranged_stream_probe_failure(err: &str) -> bool {
err.contains("ranged-stream")
&& (err.contains("format probe failed")
|| err.contains("moov metadata")
|| err.contains("end of stream"))
}
/// Completed ranged download or spill file for `url`, if ready.
async fn try_take_completed_stream_bytes(
url: &str,
state: &State<'_, AudioEngine>,
) -> Option<Vec<u8>> {
if let Some(data) = super::helpers::take_stream_completed_for_url(state, url) {
return Some(data);
}
let spill_path = {
let guard = state.stream_completed_spill.lock().unwrap();
guard
.as_ref()
.filter(|p| same_playback_target(&p.url, url))
.map(|p| p.path.clone())
};
if let Some(path) = spill_path {
let data = tokio::fs::read(&path).await.ok()?;
if !data.is_empty() {
return Some(data);
}
}
None
}
/// Ranged assembly can be byte-complete but missing `moov` (holes) or non-audio HTTP body.
async fn prefer_clean_http_bytes_for_fallback(
url: &str,
gen: u64,
state: &State<'_, AudioEngine>,
app: &AppHandle,
ranged_data: Vec<u8>,
format_hint: Option<&str>,
label: &str,
) -> Result<Option<Vec<u8>>, String> {
let is_mp4 = super::stream::container_hint_is_mp4(format_hint);
if is_mp4 {
super::stream::log_isobmff_buffer_diagnostic(&ranged_data, format_hint, label);
if !super::stream::isobmff_buffer_looks_complete(&ranged_data)
|| super::stream::mp4_suspect_zero_holes(&ranged_data)
{
crate::app_deprintln!(
"[stream] ranged buffer looks incomplete or holey — refetching via sequential HTTP"
);
if let Some(fresh) = fetch_data(url, state, gen, app).await? {
if super::stream::isobmff_buffer_looks_complete(&fresh) {
return Ok(Some(fresh));
}
super::stream::log_isobmff_buffer_diagnostic(&fresh, format_hint, "http-refetch");
}
}
}
Ok(Some(ranged_data))
}
/// Wait for the in-flight ranged download to finish, then HTTP-fetch if needed.
pub(super) async fn wait_or_fetch_bytes_for_stream_fallback(
url: &str,
gen: u64,
state: &State<'_, AudioEngine>,
app: &AppHandle,
format_hint: Option<&str>,
) -> Result<Option<Vec<u8>>, String> {
use std::time::{Duration, Instant};
let deadline = Instant::now() + Duration::from_secs(TRACK_READ_TIMEOUT_SECS);
loop {
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(None);
}
if let Some(data) = try_take_completed_stream_bytes(url, state).await {
crate::app_deprintln!(
"[stream] full-buffer fallback: using completed download ({} KiB)",
data.len() / 1024
);
return prefer_clean_http_bytes_for_fallback(
url,
gen,
state,
app,
data,
format_hint,
"ranged-cache",
)
.await;
}
if Instant::now() >= deadline {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
crate::app_deprintln!(
"[stream] full-buffer fallback: download still in progress after {}s — HTTP fetch",
TRACK_READ_TIMEOUT_SECS
);
fetch_data(url, state, gen, app).await
}
fn is_in_memory_probe_failure(err: &str) -> bool {
err.contains("format probe failed")
|| err.contains("could not open audio stream")
|| err.contains("no playable audio track")
}
/// Like [`build_source_from_play_input`], but on ranged-stream probe failure waits
/// for a full download (or fetches it) and retries from in-memory bytes.
pub(crate) async fn build_playback_source_with_probe_fallback(
play_input: PlayInput,
args: BuildSourceArgs<'_>,
state: &State<'_, AudioEngine>,
app: &AppHandle,
) -> Result<PlaybackSource, String> {
let BuildSourceArgs {
url,
gen,
cache_id_for_tasks,
server_id,
url_format_hint,
stream_format_suffix,
done_flag,
fade_in_dur,
hi_res_enabled,
duration_hint,
} = args;
let media_hint = play_media_format_hint(&play_input);
let effective_hint = resolve_playback_format_hint(
url_format_hint,
stream_format_suffix,
media_hint.as_deref(),
None,
);
if let Some(ref h) = effective_hint {
crate::app_deprintln!("[stream] playback format hint: {h}");
}
match build_source_from_play_input(
play_input,
state,
effective_hint.as_deref(),
done_flag.clone(),
fade_in_dur,
hi_res_enabled,
duration_hint,
)
.await
{
Ok(p) => Ok(p),
Err(e) if is_ranged_stream_probe_failure(&e) => {
crate::app_deprintln!(
"[stream] ranged-stream probe failed — trying full-buffer fallback: {}",
e
);
let data = match wait_or_fetch_bytes_for_stream_fallback(
url,
gen,
state,
app,
effective_hint.as_deref(),
)
.await?
{
Some(d) => d,
None => return Err(e),
};
if state.generation.load(Ordering::SeqCst) != gen {
return Err("ranged-stream: superseded during full-buffer fallback".into());
}
let bytes_hint = resolve_playback_format_hint(
url_format_hint,
stream_format_suffix,
media_hint.as_deref(),
Some(&data),
);
if bytes_hint.as_ref() != effective_hint.as_ref() {
crate::app_deprintln!(
"[stream] full-buffer fallback: resolved hint {:?} (was {:?})",
bytes_hint,
effective_hint
);
}
if let Some(track_id) = cache_id_for_tasks
.map(str::trim)
.filter(|s| !s.is_empty())
{
let (sid, high) =
prepare_playback_analysis(app, state, server_id, track_id, None);
spawn_track_analysis_bytes(
app.clone(),
TrackAnalysisOrigin::StreamDownloadComplete,
sid,
track_id.to_string(),
data.clone(),
high,
Some((gen, state.generation.clone())),
);
}
match build_source_from_play_input(
PlayInput::Bytes(data.clone()),
state,
bytes_hint.as_deref(),
done_flag.clone(),
fade_in_dur,
hi_res_enabled,
duration_hint,
)
.await
{
Ok(p) => Ok(p),
Err(pe) if is_in_memory_probe_failure(&pe) => {
if super::stream::container_hint_is_mp4(bytes_hint.as_deref()) {
super::stream::log_isobmff_buffer_diagnostic(
&data,
bytes_hint.as_deref(),
"ranged-cache-probe-fail",
);
}
crate::app_deprintln!(
"[stream] in-memory probe failed — sequential HTTP refetch: {}",
pe
);
let fresh = match fetch_data(url, state, gen, app).await? {
Some(d) => d,
None => return Err(pe),
};
if super::stream::container_hint_is_mp4(bytes_hint.as_deref()) {
super::stream::log_isobmff_buffer_diagnostic(
&fresh,
bytes_hint.as_deref(),
"http-refetch-after-probe-fail",
);
}
build_source_from_play_input(
PlayInput::Bytes(fresh),
state,
bytes_hint.as_deref(),
done_flag,
fade_in_dur,
hi_res_enabled,
duration_hint,
)
.await
}
Err(pe) => Err(pe),
}
}
Err(e) => Err(e),
}
}
/// Dispatch [`PlayInput`] → fully wrapped rodio source. For Bytes the full
/// in-memory pipeline (incl. iTunSMPB scan); for SeekableMedia / Streaming
/// the streaming variant runs the decoder build on a blocking thread.
pub(super) async fn build_source_from_play_input(
play_input: PlayInput,
state: &State<'_, AudioEngine>,
format_hint: Option<&str>,
done_flag: Arc<AtomicBool>,
fade_in_dur: Duration,
hi_res_enabled: bool,
duration_hint: f64,
) -> Result<PlaybackSource, String> {
// Always 0 — no application-level resampling. Rodio handles conversion to
// the output device rate internally; we let every track play at its native rate.
let target_rate: u32 = 0;
let mut is_seekable = true;
let built = match play_input {
PlayInput::Bytes(data) => build_source(
data,
duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
state.eq_pre_gain.clone(),
state.playback_rate.clone(),
done_flag,
fade_in_dur,
state.samples_played.clone(),
target_rate,
format_hint,
hi_res_enabled,
),
PlayInput::SeekableMedia {
reader,
format_hint: media_hint,
tag,
mp4_probe_gate,
} => {
if let Some(gate) = mp4_probe_gate.as_ref() {
super::stream::wait_for_ranged_mp4_probe_ready(gate).await?;
if gate.gen_arc.load(Ordering::SeqCst) != gate.gen {
return Err("ranged-stream: superseded before moov metadata ready".into());
}
}
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag)
})
.await
.map_err(|e| e.to_string())??;
build_streaming_source(
decoder,
duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
state.eq_pre_gain.clone(),
state.playback_rate.clone(),
done_flag,
fade_in_dur,
state.samples_played.clone(),
target_rate,
None,
)
}
PlayInput::Streaming { reader, format_hint: stream_hint } => {
is_seekable = false;
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(Box::new(reader), stream_hint.as_deref(), "track-stream")
})
.await
.map_err(|e| e.to_string())??;
build_streaming_source(
decoder,
duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
state.eq_pre_gain.clone(),
state.playback_rate.clone(),
done_flag,
fade_in_dur,
state.samples_played.clone(),
target_rate,
Some(state.stream_playback_armed.clone()),
)
}
}?;
Ok(PlaybackSource { built, is_seekable })
}
@@ -4,11 +4,11 @@
use std::sync::Mutex;
use std::time::{Duration, Instant};
use tauri::AppHandle;
use tauri::Manager;
use tauri::{AppHandle, Emitter, Manager};
use super::device_watcher::{reopen_output_stream, ReopenNotify};
use super::engine::AudioEngine;
use super::engine::{request_stream_release, AudioEngine};
use super::stream_idle::{output_stream_is_needed, teardown_playback_sinks_for_idle_release};
static RESUME_REOPEN_DEBOUNCE: Mutex<Option<Instant>> = Mutex::new(None);
const DEBOUNCE: Duration = Duration::from_millis(900);
@@ -30,10 +30,22 @@ pub(crate) fn debounce_allow_resume_reopen() -> bool {
pub(crate) async fn reopen_audio_after_system_resume(app: &AppHandle) {
tokio::time::sleep(Duration::from_millis(400)).await;
let device_name = match app.try_state::<AudioEngine>() {
Some(e) => e.selected_device.lock().unwrap().clone(),
None => return,
let Some(state) = app.try_state::<AudioEngine>() else {
return;
};
let engine = state.inner();
if !output_stream_is_needed(engine) {
if engine.stream_handle.lock().unwrap().is_some() {
teardown_playback_sinks_for_idle_release(engine);
let _ = request_stream_release(engine);
*engine.stream_handle.lock().unwrap() = None;
let _ = app.emit("audio:output-released", ());
}
return;
}
let device_name = engine.selected_device.lock().unwrap().clone();
if reopen_output_stream(app, device_name, ReopenNotify::DeviceChanged).await {
crate::app_eprintln!("[psysonic] audio output reopened after system resume");
@@ -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(());
+13 -7
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}"))??;
@@ -399,7 +404,8 @@ pub async fn audio_preview_play(
let source = PriorityBoostSource::new(source);
// ── Build secondary sink on the existing OutputStream ────────────────────
let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer()));
let stream = super::engine::ensure_output_stream_open(&state)?;
let sink = Arc::new(Player::connect_new(stream.mixer()));
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
sink.append(source);
@@ -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())??;
@@ -150,7 +150,8 @@ pub async fn audio_play_radio(
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
let sink = Arc::new(Player::connect_new(state.stream_handle.lock().unwrap().mixer()));
let stream = super::engine::ensure_output_stream_open(&state)?;
let sink = Arc::new(Player::connect_new(stream.mixer()));
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
sink.append(boosted);
@@ -183,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,
@@ -0,0 +1,214 @@
//! Sink-lifecycle glue for `audio_play`: atomically swap a freshly built sink
//! into `state.current` (handing off the old one to a crossfade tail or a hard
//! stop), and the legacy non-seekable path that holds a sink paused until the
//! download task arms playback. Split out of `play_input.rs` so source
//! selection and source building stay focused on their own concerns.
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, State};
use super::engine::{AudioCurrent, AudioEngine};
/// Args for [`spawn_legacy_stream_start_when_armed`].
pub(super) struct LegacyStreamStartWhenArmed {
pub gen: u64,
pub gen_arc: Arc<AtomicU64>,
pub playback_armed: Arc<AtomicBool>,
pub samples_played: Arc<AtomicU64>,
pub current: Arc<Mutex<AudioCurrent>>,
pub app: AppHandle,
pub duration_secs: f64,
pub hold_paused: bool,
}
/// Legacy `AudioStreamReader`: keep the sink paused until the download task arms
/// playback, then reset counters and emit `audio:playing` so the UI does not
/// extrapolate ahead of audible output.
pub(super) fn spawn_legacy_stream_start_when_armed(args: LegacyStreamStartWhenArmed) {
let LegacyStreamStartWhenArmed {
gen,
gen_arc,
playback_armed,
samples_played,
current,
app,
duration_secs,
hold_paused,
} = args;
tokio::spawn(async move {
loop {
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
if playback_armed.load(Ordering::Relaxed) {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
samples_played.store(0, Ordering::Relaxed);
let sink = current.lock().unwrap().sink.clone();
if let Some(sink) = sink {
if hold_paused {
sink.pause();
let mut cur = current.lock().unwrap();
cur.play_started = None;
if cur.paused_at.is_none() {
cur.paused_at = Some(0.0);
}
cur.seek_offset = 0.0;
crate::app_deprintln!(
"[stream] legacy track-stream: buffer ready, holding paused (silent prepare)"
);
} else {
{
let mut cur = current.lock().unwrap();
cur.play_started = Some(Instant::now());
cur.paused_at = None;
cur.seek_offset = 0.0;
}
sink.play();
app.emit("audio:playing", duration_secs).ok();
crate::app_deprintln!("[stream] legacy track-stream: playback started after buffer ready");
}
}
});
}
/// State + decisions audio_play computed before the sink swap.
pub(crate) struct SinkSwapInputs {
pub(crate) sink: Arc<rodio::Player>,
pub(crate) duration_secs: f64,
pub(crate) volume: f32,
pub(crate) gain_linear: f32,
pub(crate) fadeout_trigger: Arc<AtomicBool>,
pub(crate) fadeout_samples: Arc<AtomicU64>,
pub(crate) crossfade_enabled: bool,
pub(crate) actual_fade_secs: f32,
/// 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
/// task that drops the old sink ~`actual_fade_secs + 0.5 s` later.
pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapInputs) {
let SinkSwapInputs {
sink,
duration_secs,
volume,
gain_linear,
fadeout_trigger: new_fadeout_trigger,
fadeout_samples: new_fadeout_samples,
crossfade_enabled,
actual_fade_secs,
outgoing_fade_secs,
start_paused,
} = inputs;
let (old_sink, old_fadeout_trigger, old_fadeout_samples) = {
let mut cur = state.current.lock().unwrap();
let old = cur.sink.take();
let old_fo_trigger = cur.fadeout_trigger.take();
let old_fo_samples = cur.fadeout_samples.take();
cur.sink = Some(sink.clone());
cur.duration_secs = duration_secs;
cur.seek_offset = 0.0;
if start_paused {
sink.pause();
cur.play_started = None;
cur.paused_at = Some(0.0);
} else {
cur.play_started = Some(Instant::now());
cur.paused_at = None;
}
cur.replay_gain_linear = gain_linear;
cur.base_volume = volume.clamp(0.0, 1.0);
cur.fadeout_trigger = Some(new_fadeout_trigger);
cur.fadeout_samples = Some(new_fadeout_samples);
(old, old_fo_trigger, old_fo_samples)
};
if crossfade_enabled {
if 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);
}
*state.fading_out_sink.lock().unwrap() = Some(old);
let fo_arc = state.fading_out_sink.clone();
let cleanup_dur = Duration::from_secs_f32(actual_fade_secs + 0.5);
tokio::spawn(async move {
tokio::time::sleep(cleanup_dur).await;
if let Some(s) = fo_arc.lock().unwrap().take() {
s.stop();
}
});
}
} else if let Some(old) = old_sink {
old.stop();
}
state
.interrupt_outgoing_duck_active
.store(false, Ordering::Relaxed);
}
@@ -0,0 +1,419 @@
//! Source-building pipeline for `audio_play`: turn a resolved [`PlayInput`]
//! into a fully wrapped rodio source, including the ranged-stream probe
//! fallback (wait for / fetch a full download and retry from in-memory bytes
//! when a partial ranged buffer can't be probed yet). Split out of
//! `play_input.rs` so source *selection* stays separate from source *building*.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, State};
use super::analysis_dispatch::{
prepare_playback_analysis, spawn_track_analysis_bytes, TrackAnalysisOrigin,
};
use super::decode::{build_source, build_streaming_source, BuiltSource, SizedDecoder};
use super::engine::AudioEngine;
use super::helpers::{fetch_data, resolve_playback_format_hint, same_playback_target};
use super::play_input::PlayInput;
use super::stream::TRACK_READ_TIMEOUT_SECS;
/// Arguments forwarded from `audio_play` into the source-build pipeline.
/// Bundles the format-hint inputs, playback-shaping parameters and the shared
/// done flag so that `build_playback_source_with_probe_fallback` stays below
/// the `clippy::too_many_arguments` threshold.
pub(crate) struct BuildSourceArgs<'a> {
pub url: &'a str,
pub gen: u64,
pub cache_id_for_tasks: Option<&'a str>,
pub server_id: Option<&'a str>,
pub url_format_hint: Option<&'a str>,
pub stream_format_suffix: Option<&'a str>,
pub done_flag: Arc<AtomicBool>,
pub fade_in_dur: Duration,
pub hi_res_enabled: bool,
/// When > 0, resample decoded audio to this Hz (hi-res crossfade / AutoDJ blend).
pub resample_target_hz: u32,
pub duration_hint: f64,
}
/// Decoder/output-shaping inputs shared by [`build_source_from_play_input`].
struct PlaybackSourceShape {
done_flag: Arc<AtomicBool>,
fade_in_dur: Duration,
hi_res_enabled: bool,
resample_target_hz: u32,
duration_hint: f64,
}
/// Output of `build_source_from_play_input`: the wrapped rodio source plus
/// whether the chosen source path is seekable (only the Streaming variant
/// is not).
pub(crate) struct PlaybackSource {
pub(crate) built: BuiltSource,
pub(crate) is_seekable: bool,
}
fn play_media_format_hint(input: &PlayInput) -> Option<String> {
match input {
PlayInput::SeekableMedia { format_hint, .. } | PlayInput::Streaming { format_hint, .. } => {
format_hint.clone()
}
PlayInput::Bytes(_) => None,
}
}
/// Ranged HTTP probe/decode failed in a way that may succeed after the
/// background download finishes (moov-at-end, demuxer EOF during partial buffer).
fn is_ranged_stream_probe_failure(err: &str) -> bool {
err.contains("ranged-stream")
&& (err.contains("format probe failed")
|| err.contains("moov metadata")
|| err.contains("end of stream"))
}
/// Completed ranged download or spill file for `url`, if ready.
async fn try_take_completed_stream_bytes(
url: &str,
state: &State<'_, AudioEngine>,
) -> Option<Vec<u8>> {
if let Some(data) = super::helpers::take_stream_completed_for_url(state, url) {
return Some(data);
}
let spill_path = {
let guard = state.stream_completed_spill.lock().unwrap();
guard
.as_ref()
.filter(|p| same_playback_target(&p.url, url))
.map(|p| p.path.clone())
};
if let Some(path) = spill_path {
let data = tokio::fs::read(&path).await.ok()?;
if !data.is_empty() {
return Some(data);
}
}
None
}
/// Ranged assembly can be byte-complete but missing `moov` (holes) or non-audio HTTP body.
async fn prefer_clean_http_bytes_for_fallback(
url: &str,
gen: u64,
state: &State<'_, AudioEngine>,
app: &AppHandle,
ranged_data: Vec<u8>,
format_hint: Option<&str>,
label: &str,
) -> Result<Option<Vec<u8>>, String> {
let is_mp4 = super::stream::container_hint_is_mp4(format_hint);
if is_mp4 {
super::stream::log_isobmff_buffer_diagnostic(&ranged_data, format_hint, label);
if !super::stream::isobmff_buffer_looks_complete(&ranged_data)
|| super::stream::mp4_suspect_zero_holes(&ranged_data)
{
crate::app_deprintln!(
"[stream] ranged buffer looks incomplete or holey — refetching via sequential HTTP"
);
if let Some(fresh) = fetch_data(url, state, gen, app).await? {
if super::stream::isobmff_buffer_looks_complete(&fresh) {
return Ok(Some(fresh));
}
super::stream::log_isobmff_buffer_diagnostic(&fresh, format_hint, "http-refetch");
}
}
}
Ok(Some(ranged_data))
}
/// Wait for the in-flight ranged download to finish, then HTTP-fetch if needed.
async fn wait_or_fetch_bytes_for_stream_fallback(
url: &str,
gen: u64,
state: &State<'_, AudioEngine>,
app: &AppHandle,
format_hint: Option<&str>,
) -> Result<Option<Vec<u8>>, String> {
use std::time::Instant;
let deadline = Instant::now() + Duration::from_secs(TRACK_READ_TIMEOUT_SECS);
loop {
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(None);
}
if let Some(data) = try_take_completed_stream_bytes(url, state).await {
crate::app_deprintln!(
"[stream] full-buffer fallback: using completed download ({} KiB)",
data.len() / 1024
);
return prefer_clean_http_bytes_for_fallback(
url,
gen,
state,
app,
data,
format_hint,
"ranged-cache",
)
.await;
}
if Instant::now() >= deadline {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
crate::app_deprintln!(
"[stream] full-buffer fallback: download still in progress after {}s — HTTP fetch",
TRACK_READ_TIMEOUT_SECS
);
fetch_data(url, state, gen, app).await
}
fn is_in_memory_probe_failure(err: &str) -> bool {
err.contains("format probe failed")
|| err.contains("could not open audio stream")
|| err.contains("no playable audio track")
}
/// Like [`build_source_from_play_input`], but on ranged-stream probe failure waits
/// for a full download (or fetches it) and retries from in-memory bytes.
pub(crate) async fn build_playback_source_with_probe_fallback(
play_input: PlayInput,
args: BuildSourceArgs<'_>,
state: &State<'_, AudioEngine>,
app: &AppHandle,
) -> Result<PlaybackSource, String> {
let BuildSourceArgs {
url,
gen,
cache_id_for_tasks,
server_id,
url_format_hint,
stream_format_suffix,
done_flag,
fade_in_dur,
hi_res_enabled,
resample_target_hz,
duration_hint,
} = args;
let media_hint = play_media_format_hint(&play_input);
let effective_hint = resolve_playback_format_hint(
url_format_hint,
stream_format_suffix,
media_hint.as_deref(),
None,
);
if let Some(ref h) = effective_hint {
crate::app_deprintln!("[stream] playback format hint: {h}");
}
let shape = PlaybackSourceShape {
done_flag: done_flag.clone(),
fade_in_dur,
hi_res_enabled,
resample_target_hz,
duration_hint,
};
match build_source_from_play_input(play_input, state, effective_hint.as_deref(), &shape)
.await
{
Ok(p) => Ok(p),
Err(e) if is_ranged_stream_probe_failure(&e) => {
crate::app_deprintln!(
"[stream] ranged-stream probe failed — trying full-buffer fallback: {}",
e
);
let data = match wait_or_fetch_bytes_for_stream_fallback(
url,
gen,
state,
app,
effective_hint.as_deref(),
)
.await?
{
Some(d) => d,
None => return Err(e),
};
if state.generation.load(Ordering::SeqCst) != gen {
return Err("ranged-stream: superseded during full-buffer fallback".into());
}
let bytes_hint = resolve_playback_format_hint(
url_format_hint,
stream_format_suffix,
media_hint.as_deref(),
Some(&data),
);
if bytes_hint.as_ref() != effective_hint.as_ref() {
crate::app_deprintln!(
"[stream] full-buffer fallback: resolved hint {:?} (was {:?})",
bytes_hint,
effective_hint
);
}
if let Some(track_id) = cache_id_for_tasks
.map(str::trim)
.filter(|s| !s.is_empty())
{
let (sid, high) =
prepare_playback_analysis(app, state, server_id, track_id, None);
spawn_track_analysis_bytes(
app.clone(),
TrackAnalysisOrigin::StreamDownloadComplete,
sid,
track_id.to_string(),
data.clone(),
high,
Some((gen, state.generation.clone())),
);
}
match build_source_from_play_input(
PlayInput::Bytes(data.clone()),
state,
bytes_hint.as_deref(),
&shape,
)
.await
{
Ok(p) => Ok(p),
Err(pe) if is_in_memory_probe_failure(&pe) => {
if super::stream::container_hint_is_mp4(bytes_hint.as_deref()) {
super::stream::log_isobmff_buffer_diagnostic(
&data,
bytes_hint.as_deref(),
"ranged-cache-probe-fail",
);
}
crate::app_deprintln!(
"[stream] in-memory probe failed — sequential HTTP refetch: {}",
pe
);
let fresh = match fetch_data(url, state, gen, app).await? {
Some(d) => d,
None => return Err(pe),
};
if super::stream::container_hint_is_mp4(bytes_hint.as_deref()) {
super::stream::log_isobmff_buffer_diagnostic(
&fresh,
bytes_hint.as_deref(),
"http-refetch-after-probe-fail",
);
}
build_source_from_play_input(
PlayInput::Bytes(fresh),
state,
bytes_hint.as_deref(),
&PlaybackSourceShape {
done_flag,
fade_in_dur,
hi_res_enabled,
resample_target_hz,
duration_hint,
},
)
.await
}
Err(pe) => Err(pe),
}
}
Err(e) => Err(e),
}
}
/// Dispatch [`PlayInput`] → fully wrapped rodio source. For Bytes the full
/// in-memory pipeline (incl. iTunSMPB scan); for SeekableMedia / Streaming
/// the streaming variant runs the decoder build on a blocking thread.
async fn build_source_from_play_input(
play_input: PlayInput,
state: &State<'_, AudioEngine>,
format_hint: Option<&str>,
shape: &PlaybackSourceShape,
) -> Result<PlaybackSource, String> {
let PlaybackSourceShape {
done_flag,
fade_in_dur,
hi_res_enabled,
resample_target_hz,
duration_hint,
} = shape;
// 0 = native rate; hi-res crossfade blend passes an explicit Hz.
let target_rate: u32 = *resample_target_hz;
let mut is_seekable = true;
let built = match play_input {
PlayInput::Bytes(data) => build_source(
data,
*duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
state.eq_pre_gain.clone(),
state.playback_rate.clone(),
done_flag.clone(),
*fade_in_dur,
state.samples_played.clone(),
target_rate,
format_hint,
*hi_res_enabled,
),
PlayInput::SeekableMedia {
reader,
format_hint: media_hint,
tag,
random_access,
mp4_probe_gate,
} => {
if let Some(gate) = mp4_probe_gate.as_ref() {
super::stream::wait_for_ranged_mp4_probe_ready(gate).await?;
if gate.gen_arc.load(Ordering::SeqCst) != gate.gen {
return Err("ranged-stream: superseded before moov metadata ready".into());
}
}
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(reader, media_hint.as_deref(), tag, random_access)
})
.await
.map_err(|e| e.to_string())??;
build_streaming_source(
decoder,
*duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
state.eq_pre_gain.clone(),
state.playback_rate.clone(),
done_flag.clone(),
*fade_in_dur,
state.samples_played.clone(),
target_rate,
None,
)
}
PlayInput::Streaming { reader, format_hint: stream_hint } => {
is_seekable = false;
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(
Box::new(reader),
stream_hint.as_deref(),
"track-stream",
false,
)
})
.await
.map_err(|e| e.to_string())??;
build_streaming_source(
decoder,
*duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
state.eq_pre_gain.clone(),
state.playback_rate.clone(),
done_flag.clone(),
*fade_in_dur,
state.samples_played.clone(),
target_rate,
Some(state.stream_playback_armed.clone()),
)
}
}?;
Ok(PlaybackSource { built, is_seekable })
}
+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) => {
@@ -0,0 +1,308 @@
//! Release the CPAL/rodio output stream after playback has been idle so the OS
//! can sleep (issue #1071 — Windows `powercfg` "audio stream in use").
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, Manager};
use super::engine::AudioEngine;
/// Wall-clock idle period before closing the output device handle.
pub(crate) const OUTPUT_STREAM_IDLE_RELEASE_SECS: u64 = 60;
const IDLE_POLL_SECS: u64 = 5;
/// Returns true while the app must keep an open output stream (playing, preview, crossfade).
pub(crate) fn output_stream_is_needed(engine: &AudioEngine) -> bool {
if engine.preview_sink.lock().unwrap().is_some() {
return true;
}
if engine.fading_out_sink.lock().unwrap().is_some() {
return true;
}
let cur = engine.current.lock().unwrap();
if let Some(sink) = &cur.sink {
if sink.empty() {
return false;
}
if cur.play_started.is_some() && cur.paused_at.is_none() {
return true;
}
}
if let Some(rs) = engine.radio_state.lock().unwrap().as_ref() {
if !rs.flags.is_paused.load(Ordering::Relaxed) {
return true;
}
}
false
}
/// Stop sinks tied to the open stream; keep pause position / URLs for cold resume.
pub(crate) fn teardown_playback_sinks_for_idle_release(engine: &AudioEngine) {
if let Some(s) = engine.preview_sink.lock().unwrap().take() {
s.stop();
}
if let Some(s) = engine.fading_out_sink.lock().unwrap().take() {
s.stop();
}
let mut cur = engine.current.lock().unwrap();
if let Some(s) = cur.sink.take() {
s.stop();
}
cur.play_started = None;
}
fn close_output_device_handle(engine: &AudioEngine, app: &AppHandle) -> Result<(), String> {
super::engine::request_stream_release(engine)?;
*engine.stream_handle.lock().unwrap() = None;
let _ = app.emit("audio:output-released", ());
Ok(())
}
/// Release the output device after the idle timer (pause with no other active audio).
pub(crate) fn release_output_stream(
engine: &AudioEngine,
app: &AppHandle,
) -> Result<(), String> {
if engine.stream_handle.lock().unwrap().is_none() {
return Ok(());
}
teardown_playback_sinks_for_idle_release(engine);
close_output_device_handle(engine, app)?;
crate::app_eprintln!(
"[psysonic] audio output stream released after {}s idle",
OUTPUT_STREAM_IDLE_RELEASE_SECS
);
Ok(())
}
/// Release immediately on explicit stop / queue end — do not wait for the idle timer.
pub(crate) fn release_output_stream_on_stop(
engine: &AudioEngine,
app: &AppHandle,
) -> Result<(), String> {
if engine.stream_handle.lock().unwrap().is_none() {
return Ok(());
}
// `audio_stop` already tore down the main sink; clear any crossfade/preview tail
// still tied to the open device before closing the handle.
if engine.preview_sink.lock().unwrap().is_some()
|| engine.fading_out_sink.lock().unwrap().is_some()
{
teardown_playback_sinks_for_idle_release(engine);
}
close_output_device_handle(engine, app)?;
crate::app_eprintln!("[psysonic] audio output stream released on stop");
Ok(())
}
/// Resolves the engine from `app` each poll (the engine is managed Tauri state),
/// so it takes only the `AppHandle` — no engine reference is needed here.
pub fn start_stream_idle_watcher(app: AppHandle) {
tauri::async_runtime::spawn(async move {
let mut idle_since: Option<Instant> = None;
loop {
tokio::time::sleep(Duration::from_secs(IDLE_POLL_SECS)).await;
let Some(state) = app.try_state::<AudioEngine>() else {
idle_since = None;
continue;
};
let engine = state.inner();
let stream_open = engine.stream_handle.lock().unwrap().is_some();
if !stream_open {
idle_since = None;
continue;
}
if output_stream_is_needed(engine) {
idle_since = None;
continue;
}
let since = *idle_since.get_or_insert_with(Instant::now);
if since.elapsed() < Duration::from_secs(OUTPUT_STREAM_IDLE_RELEASE_SECS) {
continue;
}
let _ = release_output_stream(engine, &app);
idle_since = None;
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64};
use std::sync::{Arc, Mutex, RwLock};
use ringbuf::HeapCons;
use rodio::source::Zero;
use rodio::{ChannelCount, Player, SampleRate};
use super::super::engine::AudioCurrent;
use super::super::playback_rate::PlaybackRateAtomics;
use super::super::stream::{RadioLiveState, RadioSharedFlags};
/// A device-less rodio `Player` with one infinite source appended, so
/// `empty()` reports `false` without ever opening an output device.
/// Returns the queue output too — keep it alive for the test's duration.
fn nonempty_player() -> (Arc<Player>, rodio::queue::SourcesQueueOutput) {
let (player, out) = Player::new();
player.append(Zero::new(
ChannelCount::new(2).unwrap(),
SampleRate::new(44_100).unwrap(),
));
(Arc::new(player), out)
}
fn radio_session(is_paused: bool) -> RadioLiveState {
let (tx, _rx) = std::sync::mpsc::channel::<HeapCons<u8>>();
RadioLiveState {
url: "http://example.test/stream".to_string(),
gen: 0,
// Detached no-op task; never polled. Drop just aborts it.
task: tokio::spawn(async {}),
flags: Arc::new(RadioSharedFlags {
is_paused: AtomicBool::new(is_paused),
is_hard_paused: AtomicBool::new(false),
new_cons_tx: Mutex::new(tx),
}),
}
}
fn minimal_engine() -> AudioEngine {
let (stream_thread_tx, _) = std::sync::mpsc::sync_channel(0);
AudioEngine {
stream_handle: Arc::new(Mutex::new(None)),
stream_sample_rate: Arc::new(AtomicU32::new(0)),
device_default_rate: 48_000,
stream_thread_tx,
selected_device: Arc::new(Mutex::new(None)),
current: Arc::new(Mutex::new(AudioCurrent {
sink: None,
duration_secs: 0.0,
seek_offset: 0.0,
play_started: None,
paused_at: None,
replay_gain_linear: 1.0,
base_volume: 0.8,
fadeout_trigger: None,
fadeout_samples: None,
})),
generation: Arc::new(AtomicU64::new(0)),
http_client: Arc::new(RwLock::new(reqwest::Client::new())),
eq_gains: Arc::new(std::array::from_fn(|_| AtomicU32::new(0))),
eq_enabled: Arc::new(AtomicBool::new(false)),
eq_pre_gain: Arc::new(AtomicU32::new(0)),
playback_rate: PlaybackRateAtomics::new(),
preloaded: Arc::new(Mutex::new(None)),
stream_completed_cache: Arc::new(Mutex::new(None)),
stream_completed_spill: Arc::new(Mutex::new(None)),
current_is_seekable: Arc::new(AtomicBool::new(true)),
stream_playback_armed: Arc::new(AtomicBool::new(true)),
crossfade_enabled: Arc::new(AtomicBool::new(false)),
crossfade_secs: Arc::new(AtomicU32::new(0)),
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)),
normalization_target_lufs: Arc::new(AtomicU32::new(0)),
loudness_pre_analysis_attenuation_db: Arc::new(AtomicU32::new(0)),
chained_info: Arc::new(Mutex::new(None)),
samples_played: Arc::new(AtomicU64::new(0)),
current_sample_rate: Arc::new(AtomicU32::new(44_100)),
current_channels: Arc::new(AtomicU32::new(2)),
gapless_switch_at: Arc::new(AtomicU64::new(0)),
radio_state: Mutex::new(None),
current_playback_url: Arc::new(Mutex::new(None)),
current_analysis_track_id: Arc::new(Mutex::new(None)),
current_playback_server_id: Arc::new(Mutex::new(None)),
ranged_loudness_seed_hold: Arc::new(Mutex::new(None)),
preview_sink: Arc::new(Mutex::new(None)),
preview_gen: Arc::new(AtomicU64::new(0)),
preview_main_resume: Arc::new(AtomicBool::new(false)),
preview_song_id: Arc::new(Mutex::new(None)),
}
}
#[test]
fn idle_when_no_sink_and_no_preview() {
let engine = minimal_engine();
assert!(!output_stream_is_needed(&engine));
}
#[test]
fn idle_when_sink_empty() {
// A live but drained main sink (track finished) must not pin the device.
let (player, _out) = Player::new(); // no source appended → empty()
let player = Arc::new(player);
let engine = minimal_engine();
{
let mut cur = engine.current.lock().unwrap();
cur.sink = Some(player);
cur.play_started = Some(Instant::now());
cur.paused_at = None;
}
assert!(!output_stream_is_needed(&engine));
}
#[test]
fn needed_when_sink_playing() {
let (sink, _out) = nonempty_player();
let engine = minimal_engine();
{
let mut cur = engine.current.lock().unwrap();
cur.sink = Some(sink);
cur.play_started = Some(Instant::now());
cur.paused_at = None; // actively playing
}
assert!(output_stream_is_needed(&engine));
}
#[test]
fn idle_when_sink_paused() {
// Non-empty sink but paused (paused_at set) — the idle watcher may release.
let (sink, _out) = nonempty_player();
let engine = minimal_engine();
{
let mut cur = engine.current.lock().unwrap();
cur.sink = Some(sink);
cur.play_started = Some(Instant::now());
cur.paused_at = Some(12.0);
}
assert!(!output_stream_is_needed(&engine));
}
#[test]
fn needed_when_preview_sink_present() {
let (sink, _out) = nonempty_player();
let engine = minimal_engine();
*engine.preview_sink.lock().unwrap() = Some(sink);
assert!(output_stream_is_needed(&engine));
}
#[test]
fn needed_when_fading_out_sink_present() {
let (sink, _out) = nonempty_player();
let engine = minimal_engine();
*engine.fading_out_sink.lock().unwrap() = Some(sink);
assert!(output_stream_is_needed(&engine));
}
#[tokio::test]
async fn needed_when_radio_playing() {
let engine = minimal_engine();
*engine.radio_state.lock().unwrap() = Some(radio_session(false));
assert!(output_stream_is_needed(&engine));
}
#[tokio::test]
async fn idle_when_radio_paused() {
let engine = minimal_engine();
*engine.radio_state.lock().unwrap() = Some(radio_session(true));
assert!(!output_stream_is_needed(&engine));
}
}
@@ -130,6 +130,8 @@ pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) {
cur.seek_offset = 0.0;
cur.play_started = None;
cur.paused_at = None;
drop(cur);
let _ = super::stream_idle::release_output_stream_on_stop(state.inner(), &app);
}
#[tauri::command]
@@ -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"
@@ -17,7 +17,7 @@
use std::path::{Path, PathBuf};
/// Written to `{cover_root}/.storage-layout` — mismatch triggers cache reset.
pub const LAYOUT_STAMP: &str = "canonical-segment-v4";
pub const LAYOUT_STAMP: &str = "canonical-segment-v5";
/// True for ids that are only valid as `getCoverArt` targets, not library entity keys.
pub fn is_fetch_only_cover_id(id: &str) -> bool {
@@ -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();
@@ -287,11 +287,31 @@ pub async fn resolve_stream_url(url: String) -> String {
resolve_playlist_url(&client, &url).await.unwrap_or(url)
}
/// Proxy Last.fm API calls through Rust/reqwest to avoid WebView networking restrictions.
/// `params` is a list of [key, value] pairs (method must be included).
/// If `sign` is true an api_sig is computed. If `get` is true, a GET request is made.
/// Default Audioscrobbler v2 endpoint (Last.fm). Other presets (Libre.fm,
/// Rocksky, GNU FM, Maloja compat) pass their own `base_url`.
const LASTFM_API_BASE: &str = "https://ws.audioscrobbler.com/2.0/";
/// Generic Audioscrobbler v2 transport. Provider-agnostic: the caller supplies
/// the endpoint `base_url`, so Last.fm, Libre.fm, Rocksky, custom GNU FM and the
/// Shared HTTP client for the Music Network provider transports
/// (audioscrobbler / listenbrainz / maloja). A bounded timeout keeps a hung
/// provider from leaving scrobble/probe/loved-sync promises unresolved — the
/// sibling `fetch_*` commands in this module set the same kind of bound.
fn provider_http_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| e.to_string())
}
/// Maloja Audioscrobbler-compat surface all share this one command.
///
/// `params` is a list of [key, value] pairs (method must be included). If `sign`
/// is true an `api_sig` is computed (MD5 of sorted params + secret). If `get` is
/// true a GET request is made, otherwise a form POST.
#[tauri::command]
pub async fn lastfm_request(
pub async fn audioscrobbler_request(
base_url: String,
params: Vec<[String; 2]>,
sign: bool,
get: bool,
@@ -300,6 +320,8 @@ pub async fn lastfm_request(
) -> Result<serde_json::Value, String> {
use std::collections::HashMap;
let base = if base_url.trim().is_empty() { LASTFM_API_BASE.to_string() } else { base_url };
let mut map: HashMap<String, String> = params.into_iter().map(|[k, v]| (k, v)).collect();
map.insert("api_key".into(), api_key.clone());
@@ -317,17 +339,17 @@ pub async fn lastfm_request(
map.insert("format".into(), "json".into());
let client = reqwest::Client::new();
let client = provider_http_client()?;
let resp = if get {
client
.get("https://ws.audioscrobbler.com/2.0/")
.get(&base)
.query(&map)
.header("User-Agent", subsonic_wire_user_agent())
.send()
.await
} else {
client
.post("https://ws.audioscrobbler.com/2.0/")
.post(&base)
.form(&map)
.header("User-Agent", subsonic_wire_user_agent())
.send()
@@ -337,7 +359,88 @@ pub async fn lastfm_request(
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
if let Some(err) = json.get("error") {
return Err(format!("Last.fm {} {}", err, json.get("message").and_then(|m| m.as_str()).unwrap_or("")));
return Err(format!("Audioscrobbler {} {}", err, json.get("message").and_then(|m| m.as_str()).unwrap_or("")));
}
Ok(json)
}
/// Generic ListenBrainz transport. Used by both the direct
/// `api.listenbrainz.org` preset and the Maloja `/apis/listenbrainz` compat
/// surface — they differ only by `base_url`. Auth is a `Token` header.
///
/// `path` is appended to `base_url` (e.g. `/1/submit-listens`). When `json_body`
/// is present the request is a POST with that body; otherwise a GET.
#[tauri::command]
pub async fn listenbrainz_request(
base_url: String,
path: String,
auth_token: String,
json_body: Option<serde_json::Value>,
) -> Result<serde_json::Value, String> {
let url = format!("{}{}", base_url.trim_end_matches('/'), path);
let client = provider_http_client()?;
let mut req = if json_body.is_some() {
client.post(&url)
} else {
client.get(&url)
};
req = req
.header("Authorization", format!("Token {}", auth_token))
.header("User-Agent", subsonic_wire_user_agent());
if let Some(body) = json_body {
req = req.json(&body);
}
let resp = req.send().await.map_err(|e| e.to_string())?;
let status = resp.status();
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
if !status.is_success() {
let msg = json.get("error").and_then(|m| m.as_str()).unwrap_or("");
return Err(format!("ListenBrainz {} {}", status.as_u16(), msg));
}
Ok(json)
}
/// Generic Maloja native (`/apis/mlj_1`) transport. Protocol-agnostic JSON:
/// the caller builds the body (including the Maloja key) and chooses the path.
///
/// `path` is appended to `base_url`. When `json_body` is present the request is a
/// POST with that body; otherwise a GET with `query` pairs.
#[tauri::command]
pub async fn maloja_request(
base_url: String,
path: String,
query: Vec<[String; 2]>,
json_body: Option<serde_json::Value>,
) -> Result<serde_json::Value, String> {
let url = format!("{}{}", base_url.trim_end_matches('/'), path);
let client = provider_http_client()?;
let resp = if let Some(body) = json_body {
client.post(&url).json(&body)
} else {
let q: Vec<(String, String)> = query.into_iter().map(|[k, v]| (k, v)).collect();
client.get(&url).query(&q)
}
.header("User-Agent", subsonic_wire_user_agent())
.send()
.await
.map_err(|e| e.to_string())?;
let status = resp.status();
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
if !status.is_success() {
let msg = json
.get("error")
.and_then(|e| e.get("desc").or_else(|| e.get("type")))
.and_then(|m| m.as_str())
.unwrap_or("");
return Err(format!("Maloja {} {}", status.as_u16(), msg));
}
Ok(json)
@@ -495,4 +598,66 @@ mod tests {
Some("https://pls.example/audio".to_string())
);
}
// ── audioscrobbler_request ────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread")]
async fn audioscrobbler_request_uses_custom_base_url() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(wm_path("/2.0/"))
.respond_with(ResponseTemplate::new(200).set_body_raw(
r#"{"similarartists":{"artist":[{"name":"Boards of Canada"}]}}"#,
"application/json",
))
.mount(&server)
.await;
let base = format!("{}/2.0/", server.uri());
let json = audioscrobbler_request(
base,
vec![
["method".into(), "artist.getSimilar".into()],
["artist".into(), "Aphex Twin".into()],
],
false,
true,
"key".into(),
"secret".into(),
)
.await
.expect("request should succeed");
assert_eq!(
json["similarartists"]["artist"][0]["name"].as_str(),
Some("Boards of Canada")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn audioscrobbler_request_surfaces_api_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(wm_path("/2.0/"))
.respond_with(ResponseTemplate::new(200).set_body_raw(
r#"{"error":9,"message":"Invalid session key"}"#,
"application/json",
))
.mount(&server)
.await;
let base = format!("{}/2.0/", server.uri());
let err = audioscrobbler_request(
base,
vec![["method".into(), "track.scrobble".into()]],
true,
false,
"key".into(),
"secret".into(),
)
.await
.expect_err("api error should map to Err");
assert!(err.contains("Audioscrobbler 9"), "unexpected error: {err}");
}
}
@@ -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;
@@ -248,6 +248,23 @@ CREATE TABLE play_session (
CHECK (completion IN ('partial', 'full'))
);
CREATE TABLE track_genre (
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
genre TEXT NOT NULL,
album_id TEXT,
library_id TEXT,
PRIMARY KEY (server_id, track_id, genre COLLATE NOCASE),
FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id) ON DELETE CASCADE
);
CREATE TABLE library_data_migration (
id TEXT PRIMARY KEY,
cursor_rowid INTEGER NOT NULL DEFAULT 0,
completed_at INTEGER,
started_at INTEGER
);
CREATE INDEX idx_track_album ON track(server_id, album_id) WHERE deleted = 0;
CREATE INDEX idx_track_artist ON track(server_id, artist_id) WHERE deleted = 0;
CREATE INDEX idx_track_updated ON track(server_id, server_updated_at DESC) WHERE deleted = 0;
@@ -293,3 +310,7 @@ CREATE INDEX idx_play_session_started
CREATE INDEX idx_track_fact_mood_tag
ON track_fact(server_id, fact_kind, value_text, track_id)
WHERE fact_kind = 'mood_tag';
CREATE INDEX idx_track_genre_browse
ON track_genre(server_id, genre COLLATE NOCASE, album_id, track_id)
WHERE album_id IS NOT NULL AND album_id != '';
@@ -1,6 +0,0 @@
-- psysonic-library schema v2 — large-library ingest policy (R7-15).
-- Per-server learned flag: when N1 (`/api/song`) returns HTTP 500 beyond a
-- deep offset on a large catalog, the strategy selector stops choosing N1 for
-- that server on future initial syncs (spec §6.3 / R7-15 Q1/Q5). Additive
-- column, DEFAULT 0 → existing rows keep N1 eligible until they hit the wall.
ALTER TABLE sync_state ADD COLUMN n1_bulk_unreliable INTEGER NOT NULL DEFAULT 0;
@@ -1,10 +0,0 @@
-- Remap detection (§6.9) and unstable-id servers: without these indexes
-- each upsert in a 500-row batch can scan the whole track table.
CREATE INDEX IF NOT EXISTS idx_track_remap_path
ON track(server_id, server_path)
WHERE deleted = 0 AND server_path IS NOT NULL AND server_path != '';
CREATE INDEX IF NOT EXISTS idx_track_remap_hash
ON track(server_id, content_hash)
WHERE deleted = 0 AND content_hash IS NOT NULL AND content_hash != '';
@@ -1,4 +0,0 @@
-- Browse / sort-by-title without sorting the full server slice on every page.
CREATE INDEX IF NOT EXISTS idx_track_title
ON track(server_id, title COLLATE NOCASE)
WHERE deleted = 0;
@@ -1,8 +0,0 @@
-- Advanced search filters on genre and year (partial indexes — only non-null rows).
CREATE INDEX IF NOT EXISTS idx_track_genre
ON track(server_id, genre COLLATE NOCASE)
WHERE deleted = 0 AND genre IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_track_year
ON track(server_id, year)
WHERE deleted = 0 AND year IS NOT NULL;
@@ -1,22 +0,0 @@
-- Player listening history — see workdocs player-stats spec §3.1
CREATE TABLE play_session (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
started_at_ms INTEGER NOT NULL,
listened_sec REAL NOT NULL,
position_max_sec REAL NOT NULL,
completion TEXT NOT NULL,
end_reason TEXT NOT NULL,
FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id),
CHECK (completion IN ('partial', 'full'))
);
CREATE INDEX idx_play_session_server_time
ON play_session(server_id, started_at_ms DESC);
CREATE INDEX idx_play_session_track
ON play_session(server_id, track_id, started_at_ms DESC);
CREATE INDEX idx_play_session_started
ON play_session(started_at_ms DESC);
@@ -1,4 +0,0 @@
-- Full-resync orphan sweep (mark-and-sweep via generation stamp).
-- Rows ingested during a resync pass carry the active `resync_gen`; after
-- IS-6 succeeds, live rows with a stale generation are soft-deleted.
ALTER TABLE track ADD COLUMN resync_gen INTEGER NOT NULL DEFAULT 0;
@@ -1,4 +0,0 @@
-- Atomic mood tags for Advanced Search (EXISTS on track_fact).
CREATE INDEX IF NOT EXISTS idx_track_fact_mood_tag
ON track_fact(server_id, fact_kind, value_text, track_id)
WHERE fact_kind = 'mood_tag';
@@ -1,3 +0,0 @@
-- Oximedia mood heuristics were misleading; drop accumulated mood facts.
DELETE FROM track_fact
WHERE fact_kind IN ('mood_tag', 'moods', 'valence', 'arousal', 'mood_labels');
@@ -1,8 +0,0 @@
-- Genre album browse: filter by (server, genre) then group by album_id.
CREATE INDEX IF NOT EXISTS idx_track_genre_album_browse
ON track(server_id, genre COLLATE NOCASE, album_id)
WHERE deleted = 0
AND genre IS NOT NULL
AND TRIM(genre) != ''
AND album_id IS NOT NULL
AND album_id != '';
@@ -1,8 +0,0 @@
-- Genre album browse sort: (server, genre, album name, album_id) covering walk.
CREATE INDEX IF NOT EXISTS idx_track_genre_album_name_browse
ON track(server_id, genre COLLATE NOCASE, album COLLATE NOCASE, album_id)
WHERE deleted = 0
AND genre IS NOT NULL
AND TRIM(genre) != ''
AND album_id IS NOT NULL
AND album_id != '';
@@ -0,0 +1,22 @@
-- Repair for DBs that recorded legacy migrations 002011 (removed) before
-- multi-genre tables shipped. Safe on fresh installs (IF NOT EXISTS).
CREATE TABLE IF NOT EXISTS track_genre (
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
genre TEXT NOT NULL,
album_id TEXT,
library_id TEXT,
PRIMARY KEY (server_id, track_id, genre COLLATE NOCASE),
FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_track_genre_browse
ON track_genre(server_id, genre COLLATE NOCASE, album_id, track_id)
WHERE album_id IS NOT NULL AND album_id != '';
CREATE TABLE IF NOT EXISTS library_data_migration (
id TEXT PRIMARY KEY,
cursor_rowid INTEGER NOT NULL DEFAULT 0,
completed_at INTEGER,
started_at INTEGER
);
@@ -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.
@@ -49,6 +49,7 @@ type AlbumBrowseTrackRow = (
String,
Option<String>,
Option<String>,
Option<String>,
Option<i64>,
Option<String>,
Option<String>,
@@ -526,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,
@@ -648,8 +651,8 @@ fn build_album_from_fts(
let where_sql = w.where_sql();
store.with_read_conn(|conn| {
let sql = format!(
"SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.year, \
t.genre, t.cover_art_id, t.starred_at, t.synced_at \
"SELECT t.server_id, t.album_id, t.album, t.artist, t.album_artist, t.artist_id, \
t.year, t.genre, t.cover_art_id, t.starred_at, t.synced_at \
FROM track t \
WHERE {where_sql}"
);
@@ -668,13 +671,27 @@ fn build_album_from_fts(
r.get(7)?,
r.get(8)?,
r.get(9)?,
r.get(10)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let mut seen = HashSet::new();
let mut deduped: Vec<LibraryAlbumDto> = Vec::new();
for (server_id, album_id, album, artist, artist_id, year, genre, cover_art_id, starred_at, synced_at) in rows {
for (
server_id,
album_id,
album,
track_artist,
album_artist,
artist_id,
year,
genre,
cover_art_id,
starred_at,
synced_at,
) in rows
{
if !seen.insert(album_id.clone()) {
continue;
}
@@ -682,7 +699,10 @@ fn build_album_from_fts(
server_id,
id: album_id,
name: album,
artist,
artist: crate::album_compilation_filter::pick_album_group_artist(
track_artist,
album_artist,
),
artist_id,
song_count: None,
duration_sec: None,
@@ -776,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,
@@ -882,8 +908,25 @@ fn resolve_clause(
if c.field == "genre" {
let v = json_to_text(&c.field, c.value.as_ref())?;
let sql = match entity {
EntityKind::Track => {
"EXISTS (SELECT 1 FROM track_genre tg \
WHERE tg.server_id = t.server_id AND tg.track_id = t.id \
AND tg.genre = ? COLLATE NOCASE)"
.to_string()
}
EntityKind::Album => {
"EXISTS (SELECT 1 FROM track_genre tg \
WHERE tg.server_id = a.server_id AND tg.album_id = a.id \
AND tg.genre = ? COLLATE NOCASE)"
.to_string()
}
_ => {
return Err(filter::FilterError::NotQueryable(c.field.clone()).to_string());
}
};
return Ok(Some(SqlFragment {
sql: format!("{col} = ? COLLATE NOCASE"),
sql,
params: vec![v],
}));
}
@@ -1144,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),
})
}
@@ -1176,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,
@@ -1254,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()"),
@@ -2067,6 +2117,24 @@ mod tests {
assert_eq!(resp.albums[0].artist.as_deref(), Some("Various Artists"));
}
#[test]
fn track_grouped_album_browse_prefers_album_artist_over_track_artist() {
let store = LibraryStore::open_in_memory();
let mut t1 = track("s1", "t1", "Anthem", "Groove Armada", "Back to Mine");
t1.album_id = Some("al_mix".into());
t1.album_artist = Some("Underworld".into());
let mut t2 = track("s1", "t2", "Zebra", "UNKLE", "Back to Mine");
t2.album_id = Some("al_mix".into());
t2.album_artist = Some("Underworld".into());
TrackRepository::new(&store)
.upsert_batch(&[t1, t2])
.unwrap();
let r = req("s1", &[EntityKind::Album]);
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].artist.as_deref(), Some("Underworld"));
}
#[test]
fn compilation_filter_on_track_grouped_album_browse() {
let store = LibraryStore::open_in_memory();
@@ -49,16 +49,30 @@ pub fn various_artists_label(s: &str) -> bool {
s.trim().to_ascii_lowercase().contains("various artists")
}
/// Track-grouped album rows: prefer album artist when it marks a VA compilation.
/// SQL mirror of [`pick_album_group_artist`] for track-grouped browse subqueries
/// (`la`). Used where `ORDER BY` / `COALESCE(a.artist, …)` must stay in SQL;
/// keep both implementations in sync.
pub fn sql_track_group_display_artist(alias: &str) -> String {
format!(
"CASE WHEN trim(coalesce({a}.album_artist, '')) != '' \
THEN trim({a}.album_artist) \
ELSE NULLIF(trim(coalesce({a}.artist, '')), '') END",
a = alias
)
}
/// Row-mapper form of the album-artist display rule — mirror of
/// [`sql_track_group_display_artist`]. Prefer a non-empty album-artist tag;
/// fall back to track artist only when album artist is absent (solo albums without TALB).
pub fn pick_album_group_artist(
track_artist: Option<String>,
album_artist: Option<String>,
) -> Option<String> {
let aa = album_artist.as_deref().unwrap_or("").trim();
if various_artists_label(aa) {
if !aa.is_empty() {
return Some(aa.to_string());
}
track_artist
track_artist.filter(|s| !s.trim().is_empty())
}
#[cfg(test)]
@@ -81,14 +95,70 @@ mod tests {
}
#[test]
fn pick_album_group_artist_prefers_va_album_artist() {
fn pick_album_group_artist_prefers_nonempty_album_artist() {
assert_eq!(
pick_album_group_artist(Some("Alice".into()), Some("Various Artists".into())),
Some("Various Artists".to_string())
);
assert_eq!(
pick_album_group_artist(Some("Groove Armada".into()), Some("Underworld".into())),
Some("Underworld".to_string())
);
assert_eq!(
pick_album_group_artist(Some("Alice".into()), Some("Bob".into())),
Some("Alice".to_string())
Some("Bob".to_string())
);
}
#[test]
fn pick_album_group_artist_falls_back_to_track_artist() {
assert_eq!(
pick_album_group_artist(Some("Alice".into()), None),
Some("Alice".to_string())
);
assert_eq!(
pick_album_group_artist(Some("Alice".into()), Some("".into())),
Some("Alice".to_string())
);
assert_eq!(pick_album_group_artist(None, None), None);
}
#[test]
fn sql_track_group_display_artist_matches_pick_album_group_artist() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute(
"CREATE TABLE la (artist TEXT, album_artist TEXT)",
[],
)
.unwrap();
let sql = format!("SELECT {} FROM la", sql_track_group_display_artist("la"));
let cases: [(&str, &str); 7] = [
("Groove Armada", "Underworld"),
("Alice", ""),
("", "Various Artists"),
("Alice", "Bob"),
(" ", "Bob"),
("Alice", " "),
("", ""),
];
for (track, album) in cases {
conn.execute("DELETE FROM la", []).unwrap();
conn.execute(
"INSERT INTO la (artist, album_artist) VALUES (?1, ?2)",
rusqlite::params![track, album],
)
.unwrap();
let sql_out: Option<String> = conn.query_row(&sql, [], |r| r.get(0)).ok();
let rust_out = pick_album_group_artist(
(!track.is_empty()).then(|| track.to_string()),
(!album.is_empty()).then(|| album.to_string()),
);
assert_eq!(
sql_out, rust_out,
"track={track:?} album={album:?}"
);
}
}
}
@@ -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());
}
}
@@ -81,12 +81,13 @@ pub fn get_artist_lossless_browse(
}
let album_where_sql = album_where.join(" AND ");
let la_artist = crate::album_compilation_filter::sql_track_group_display_artist("la");
let albums_sql = format!(
"SELECT \
la.server_id, \
la.album_id, \
COALESCE(a.name, la.album_name), \
COALESCE(a.artist, la.artist), \
COALESCE(a.artist, {la_artist}), \
COALESCE(a.artist_id, la.artist_id), \
COALESCE(a.song_count, la.track_count), \
COALESCE(a.duration_sec, la.duration_sec), \
@@ -102,6 +103,7 @@ pub fn get_artist_lossless_browse(
t.album_id, \
MAX(t.album) AS album_name, \
MAX(t.artist) AS artist, \
MAX(t.album_artist) AS album_artist, \
MAX(t.artist_id) AS artist_id, \
MAX(t.year) AS year, \
MAX(t.genre) AS genre, \
@@ -0,0 +1,87 @@
//! 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. probing "The " / "El " on CJK names must not panic).
let Some(head) = trimmed.get(..prefix.len()) else {
continue;
};
if head.eq_ignore_ascii_case(&prefix) {
return trimmed
.get(prefix.len()..)
.map(str::trim_start)
.unwrap_or("")
.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");
}
#[test]
fn does_not_panic_on_cjk_multi_artist_credit_string() {
// Discord report (Asra): sync panicked on FromSoftware OST composer list
// when probing the 4-byte "The " article prefix against 北村友香…
let name = "北村友香, 齋藤司, 桜庭統 & 鈴木伸嘉";
let key = sort_key_for_display_name(name, DEFAULT_IGNORED_ARTICLES);
assert_eq!(key, name.to_lowercase());
}
}
@@ -114,11 +114,13 @@ pub(crate) fn genre_album_counts_for_server(
store
.with_read_conn(|conn| {
let mut sql = String::from(
"SELECT t.genre, COUNT(DISTINCT t.album_id) AS album_count, COUNT(*) AS song_count \
FROM track t \
WHERE t.server_id = ?1 AND t.deleted = 0 \
AND t.genre IS NOT NULL AND TRIM(t.genre) != '' \
AND t.album_id IS NOT NULL AND t.album_id != ''",
"SELECT tg.genre, COUNT(DISTINCT tg.album_id) AS album_count, \
COUNT(DISTINCT tg.track_id) AS song_count \
FROM track_genre tg \
INNER JOIN track t \
ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0 \
WHERE tg.server_id = ?1 \
AND tg.album_id IS NOT NULL AND tg.album_id != ''",
);
let mut params: Vec<rusqlite::types::Value> =
vec![rusqlite::types::Value::Text(server_id.to_string())];
@@ -127,8 +129,9 @@ pub(crate) fn genre_album_counts_for_server(
params.push(rusqlite::types::Value::Text(scope.to_string()));
}
sql.push_str(
" GROUP BY t.genre COLLATE NOCASE \
ORDER BY album_count DESC, t.genre COLLATE NOCASE ASC",
" GROUP BY tg.genre COLLATE NOCASE \
HAVING album_count > 0 \
ORDER BY album_count DESC, tg.genre COLLATE NOCASE ASC",
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
@@ -337,6 +340,69 @@ mod tests {
assert_eq!(counts[0].song_count, 1);
}
#[test]
fn genre_album_counts_scope_reads_library_id_from_track_raw_json() {
let store = Arc::new(LibraryStore::open_in_memory());
let mut scoped = make_row("s1", "r1", "al_a", 1);
scoped.genre = Some("Rock".into());
scoped.library_id = None;
scoped.raw_json = r#"{"libraryId":"lib1"}"#.into();
let mut other = make_row("s1", "r2", "al_b", 1);
other.genre = Some("Rock".into());
other.library_id = None;
other.raw_json = r#"{"libraryId":"lib2"}"#.into();
TrackRepository::new(&store)
.upsert_batch(&[scoped, other])
.unwrap();
let counts = genre_album_counts_for_server(&store, "s1", Some("lib1")).unwrap();
assert_eq!(counts.len(), 1);
assert_eq!(counts[0].album_count, 1);
}
#[test]
fn genre_album_counts_drop_genre_after_track_retag() {
let store = Arc::new(LibraryStore::open_in_memory());
let mut track = make_row("s1", "t1", "al1", 1);
track.genre = Some("ruspop".into());
TrackRepository::new(&store)
.upsert_batch(&[track.clone()])
.unwrap();
let counts = genre_album_counts_for_server(&store, "s1", None).unwrap();
assert_eq!(counts.len(), 1);
assert_eq!(counts[0].value, "ruspop");
track.genre = Some("Pop".into());
TrackRepository::new(&store).upsert_batch(&[track]).unwrap();
let counts = genre_album_counts_for_server(&store, "s1", None).unwrap();
assert_eq!(counts.len(), 1);
assert_eq!(counts[0].value, "Pop");
}
#[test]
fn genre_album_counts_ignore_orphan_track_genre_rows() {
let store = Arc::new(LibraryStore::open_in_memory());
let mut live = make_row("s1", "live", "al1", 1);
live.genre = Some("Rock".into());
let mut stale = make_row("s1", "gone", "al_stale", 1);
stale.genre = Some("ruspop".into());
TrackRepository::new(&store)
.upsert_batch(&[live, stale])
.unwrap();
store
.with_conn("test", |conn| {
conn.execute(
"UPDATE track SET deleted = 1 WHERE server_id = 's1' AND id = 'gone'",
[],
)
})
.unwrap();
let counts = genre_album_counts_for_server(&store, "s1", None).unwrap();
assert_eq!(counts.len(), 1);
assert_eq!(counts[0].value, "Rock");
}
#[test]
fn reconcile_album_stars_clears_all_when_server_list_empty() {
let store = Arc::new(LibraryStore::open_in_memory());
@@ -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};
@@ -23,7 +24,7 @@ use crate::dto::{
FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse,
LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse, LibraryTrackDto,
LibraryTracksEnvelope, OfflinePathDto, PlaySessionDayDetailDto, PlaySessionHeatmapDayDto,
PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionRecentTrackDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
TrackArtifactDto, TrackFactDto, TrackRefDto,
};
use crate::live_search;
@@ -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,
@@ -526,6 +529,23 @@ pub async fn library_list_albums_by_genre(
.await
}
#[tauri::command]
pub fn library_genre_tags_inspect(
runtime: State<'_, LibraryRuntime>,
) -> Result<crate::genre_tags_backfill::GenreTagsInspectDto, String> {
crate::genre_tags_backfill::inspect_genre_tags_backfill(&runtime.store)
}
#[tauri::command]
pub async fn library_genre_tags_run(
app: tauri::AppHandle,
runtime: State<'_, LibraryRuntime>,
) -> Result<(), String> {
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || crate::genre_tags_backfill::run_genre_tags_backfill(&store, &app))
.await
}
#[tauri::command]
pub async fn library_get_artist_lossless_browse(
runtime: State<'_, LibraryRuntime>,
@@ -605,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>,
@@ -637,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;
@@ -657,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,
@@ -670,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),
@@ -689,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,
@@ -699,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,
)
@@ -852,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(),
@@ -875,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);
}
@@ -899,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);
}
@@ -1193,6 +1234,16 @@ pub fn library_get_player_stats_recent_days(
PlaySessionRepository::new(&runtime.store).recent_days(limit.unwrap_or(30))
}
#[tauri::command]
pub fn library_get_recent_play_sessions(
runtime: State<'_, LibraryRuntime>,
limit: Option<u32>,
since_ms: Option<i64>,
) -> Result<Vec<PlaySessionRecentTrackDto>, String> {
PlaySessionRepository::new(&runtime.store)
.recent_plays(limit.unwrap_or(50), since_ms)
}
#[tauri::command]
pub fn library_purge_server(
runtime: State<'_, LibraryRuntime>,
@@ -1627,7 +1678,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"));
}
@@ -1644,7 +1695,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>,
@@ -344,6 +346,9 @@ pub struct PlaySessionDayTrackDto {
pub listened_sec: f64,
pub completion: String,
pub started_at_ms: i64,
pub album: Option<String>,
pub album_id: Option<String>,
pub cover_art_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -353,6 +358,9 @@ pub struct PlaySessionDayDetailDto {
pub tracks: Vec<PlaySessionDayTrackDto>,
}
/// One row from `library_get_recent_play_sessions` (timeline cold bootstrap).
pub type PlaySessionRecentTrackDto = PlaySessionDayTrackDto;
/// Summary for one day in the recent-days list (no track rows).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
@@ -476,6 +484,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 +871,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,
@@ -19,19 +19,20 @@ fn trimmed_nonempty(s: Option<&str>) -> Option<String> {
}
fn genre_album_order_sql(sort: &[LibrarySortClause]) -> String {
let la_artist = crate::album_compilation_filter::sql_track_group_display_artist("la");
let mut keys: Vec<String> = Vec::new();
for s in sort {
let col = match s.field.as_str() {
"name" => "COALESCE(a.name, la.album_name) COLLATE NOCASE",
"artist" => "COALESCE(a.artist, la.artist) COLLATE NOCASE",
"year" => "COALESCE(a.year, la.year)",
"name" => "COALESCE(a.name, la.album_name) COLLATE NOCASE".to_string(),
"artist" => format!("COALESCE(a.artist, {la_artist}) COLLATE NOCASE"),
"year" => "COALESCE(a.year, la.year)".to_string(),
_ => continue,
};
let dir = match s.dir {
SortDir::Asc => "ASC",
SortDir::Desc => "DESC",
};
keys.push(format!("{col} {dir}"));
keys.push(format!("{col} {dir}", col = col));
}
if keys.is_empty() {
keys.push("COALESCE(a.name, la.album_name) COLLATE NOCASE ASC".to_string());
@@ -44,10 +45,12 @@ fn count_genre_albums(
conn: &rusqlite::Connection,
where_sql: &str,
params: &[SqlValue],
_library_scoped: bool,
) -> Result<u32, rusqlite::Error> {
let count_sql = format!(
"SELECT COUNT(DISTINCT t.album_id) FROM track t WHERE {where_sql}"
);
let from = "FROM track_genre tg \
INNER JOIN track t \
ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0";
let count_sql = format!("SELECT COUNT(DISTINCT tg.album_id) {from} WHERE {where_sql}");
let n: i64 = conn.query_row(
&count_sql,
rusqlite::params_from_iter(params.iter()),
@@ -107,28 +110,29 @@ pub fn list_albums_by_genre(
let order_sql = genre_album_order_sql(&req.sort);
let mut where_clauses = vec![
"t.deleted = 0".to_string(),
"t.server_id = ?1".to_string(),
"t.album_id IS NOT NULL AND t.album_id != ''".to_string(),
"t.genre = ?2 COLLATE NOCASE".to_string(),
"tg.server_id = ?1".to_string(),
"tg.album_id IS NOT NULL AND tg.album_id != ''".to_string(),
"tg.genre = ?2 COLLATE NOCASE".to_string(),
];
let mut params: Vec<SqlValue> = vec![
SqlValue::Text(req.server_id.clone()),
SqlValue::Text(genre.to_string()),
];
let library_scoped = trimmed_nonempty(req.library_scope.as_deref()).is_some();
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
where_clauses.push(library_scope_equals_sql("t"));
params.push(SqlValue::Text(scope));
}
let where_sql = where_clauses.join(" AND ");
let la_artist = crate::album_compilation_filter::sql_track_group_display_artist("la");
let sql = format!(
"SELECT \
la.server_id, \
la.album_id, \
COALESCE(a.name, la.album_name), \
COALESCE(a.artist, la.artist), \
COALESCE(a.artist, {la_artist}), \
COALESCE(a.artist_id, la.artist_id), \
COALESCE(a.song_count, la.track_count), \
COALESCE(a.duration_sec, la.duration_sec), \
@@ -140,10 +144,11 @@ pub fn list_albums_by_genre(
a.raw_json \
FROM ( \
SELECT \
t.server_id, \
t.album_id, \
tg.server_id, \
tg.album_id, \
MAX(t.album) AS album_name, \
MAX(t.artist) AS artist, \
MAX(t.album_artist) AS album_artist, \
MAX(t.artist_id) AS artist_id, \
MAX(t.year) AS year, \
MAX(t.genre) AS genre, \
@@ -152,9 +157,11 @@ pub fn list_albums_by_genre(
MAX(t.synced_at) AS synced_at, \
COUNT(*) AS track_count, \
COALESCE(SUM(t.duration_sec), 0) AS duration_sec \
FROM track t \
FROM track_genre tg \
INNER JOIN track t \
ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0 \
WHERE {where_sql} \
GROUP BY t.server_id, t.album_id \
GROUP BY tg.server_id, tg.album_id \
) la \
LEFT JOIN album a ON a.server_id = la.server_id AND a.id = la.album_id \
{order_sql} \
@@ -167,7 +174,7 @@ pub fn list_albums_by_genre(
store.with_read_conn(|conn| {
let total = if req.include_total {
Some(count_genre_albums(conn, &where_sql, &count_params)?)
Some(count_genre_albums(conn, &where_sql, &count_params, library_scoped)?)
} else {
None
};
@@ -282,4 +289,49 @@ mod tests {
assert_eq!(all.total, Some(3));
assert!(all.has_more);
}
#[test]
fn list_albums_by_atomic_genre_from_compound_tag() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track(
"s1",
"t1",
"al_a",
"Noise Metal/Dark Ambient/Experimental Black Metal",
)])
.unwrap();
let dark = list_albums_by_genre(
&store,
&LibraryGenreAlbumsRequest {
server_id: "s1".into(),
genre: "Dark Ambient".into(),
library_scope: None,
sort: vec![],
limit: 10,
offset: 0,
include_total: true,
},
)
.unwrap();
assert_eq!(dark.total, Some(1));
assert_eq!(dark.albums.len(), 1);
assert_eq!(dark.albums[0].id, "al_a");
let noise = list_albums_by_genre(
&store,
&LibraryGenreAlbumsRequest {
server_id: "s1".into(),
genre: "Noise Metal".into(),
library_scope: None,
sort: vec![],
limit: 10,
offset: 0,
include_total: true,
},
)
.unwrap();
assert_eq!(noise.total, Some(1));
}
}
@@ -0,0 +1,226 @@
//! Atomic genre resolution for multi-value tags (OpenSubsonic `genres[]` first,
//! Navidrome-default string split as fallback).
use std::collections::HashSet;
use rusqlite::{params, Transaction};
use serde_json::Value;
const GENRE_SEPARATORS: [&str; 3] = [";", "/", ","];
/// Fallback split when the server sent no `genres[]` array (legacy Subsonic).
pub fn split_genre_tags(raw: &str) -> Vec<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Vec::new();
}
let mut parts = vec![trimmed.to_string()];
for sep in GENRE_SEPARATORS {
let mut next = Vec::new();
for part in parts {
for sub in part.split(sep) {
next.push(sub.to_string());
}
}
parts = next;
}
dedupe_genres(parts)
}
fn dedupe_genres(genres: Vec<String>) -> Vec<String> {
let mut seen = HashSet::new();
let mut out = Vec::new();
for g in genres {
let t = g.trim();
if t.is_empty() {
continue;
}
let key = t.to_ascii_lowercase();
if seen.insert(key) {
out.push(t.to_string());
}
}
out
}
fn parse_genres_array_value(value: &Value) -> Option<Vec<String>> {
let arr = value.as_array()?;
if arr.is_empty() {
return None;
}
let mut out = Vec::new();
for item in arr {
if let Some(name) = item.get("name").and_then(|v| v.as_str()) {
let t = name.trim();
if !t.is_empty() {
out.push(t.to_string());
}
} else if let Some(s) = item.as_str() {
let t = s.trim();
if !t.is_empty() {
out.push(t.to_string());
}
}
}
if out.is_empty() {
None
} else {
Some(dedupe_genres(out))
}
}
fn parse_genres_json_str(genres_json: &str) -> Option<Vec<String>> {
let trimmed = genres_json.trim();
if trimmed.is_empty() {
return None;
}
let value: Value = serde_json::from_str(trimmed).ok()?;
parse_genres_array_value(&value)
}
/// Source-priority resolver (§2.0): `genres[]` from parsed payload, else split `genre`.
pub fn genres_for_track_value(raw_json: &Value, genre: Option<&str>) -> Vec<String> {
if let Some(genres) = raw_json.get("genres").and_then(parse_genres_array_value) {
return genres;
}
genre
.map(str::trim)
.filter(|s| !s.is_empty())
.map(split_genre_tags)
.unwrap_or_default()
}
/// Backfill path: `genres_json` from `json_extract(raw_json, '$.genres')`.
pub fn genres_for_track_extracted(genres_json: Option<&str>, genre: Option<&str>) -> Vec<String> {
if let Some(json) = genres_json {
if let Some(genres) = parse_genres_json_str(json) {
return genres;
}
}
genre
.map(str::trim)
.filter(|s| !s.is_empty())
.map(split_genre_tags)
.unwrap_or_default()
}
pub fn genres_for_track_raw_json(raw_json: &str, genre: Option<&str>) -> Vec<String> {
if let Ok(value) = serde_json::from_str::<Value>(raw_json) {
return genres_for_track_value(&value, genre);
}
genre
.map(str::trim)
.filter(|s| !s.is_empty())
.map(split_genre_tags)
.unwrap_or_default()
}
pub fn replace_track_genre_rows(
tx: &Transaction<'_>,
server_id: &str,
track_id: &str,
album_id: Option<&str>,
library_id: Option<&str>,
genres: &[String],
) -> rusqlite::Result<()> {
tx.execute(
"DELETE FROM track_genre WHERE server_id = ?1 AND track_id = ?2",
params![server_id, track_id],
)?;
if genres.is_empty() {
return Ok(());
}
let mut insert = tx.prepare_cached(
"INSERT OR IGNORE INTO track_genre (server_id, track_id, genre, album_id, library_id) \
VALUES (?1, ?2, ?3, ?4, ?5)",
)?;
for genre in genres {
insert.execute(params![server_id, track_id, genre, album_id, library_id])?;
}
Ok(())
}
pub fn delete_track_genre_for_track(
conn: &rusqlite::Connection,
server_id: &str,
track_id: &str,
) -> rusqlite::Result<()> {
conn.execute(
"DELETE FROM track_genre WHERE server_id = ?1 AND track_id = ?2",
params![server_id, track_id],
)?;
Ok(())
}
pub fn delete_track_genre_for_server_tracks(
conn: &rusqlite::Connection,
server_id: &str,
track_ids: &[String],
) -> rusqlite::Result<()> {
if track_ids.is_empty() {
return Ok(());
}
for id in track_ids {
delete_track_genre_for_track(conn, server_id, id)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn split_separators_and_dedupe() {
assert_eq!(
split_genre_tags("Rock/Jazz"),
vec!["Rock".to_string(), "Jazz".to_string()]
);
assert_eq!(
split_genre_tags("Rock; Jazz, Electronic"),
vec![
"Rock".to_string(),
"Jazz".to_string(),
"Electronic".to_string()
]
);
assert_eq!(split_genre_tags("Rock/rock/ROCK"), vec!["Rock".to_string()]);
assert!(split_genre_tags("").is_empty());
}
#[test]
fn array_wins_over_genre_string() {
let raw = json!({
"genres": [{"name": "A"}, {"name": "B"}],
"genre": "A/B/C"
});
assert_eq!(
genres_for_track_value(&raw, Some("A/B/C")),
vec!["A".to_string(), "B".to_string()]
);
}
#[test]
fn bare_string_array_and_empty_array_fallback() {
let bare = json!({ "genres": ["A", "B"] });
assert_eq!(
genres_for_track_value(&bare, None),
vec!["A".to_string(), "B".to_string()]
);
let empty = json!({ "genres": [], "genre": "A/B" });
assert_eq!(
genres_for_track_value(&empty, Some("A/B")),
vec!["A".to_string(), "B".to_string()]
);
}
#[test]
fn extracted_json_matches_value_path() {
let genres_json = r#"[{"name":"Jazz"},{"name":"Rock"}]"#;
assert_eq!(
genres_for_track_extracted(Some(genres_json), Some("Noise/Metal")),
vec!["Jazz".to_string(), "Rock".to_string()]
);
}
}
@@ -0,0 +1,318 @@
//! One-time blocking backfill: populate `track_genre` from existing `track` rows.
use std::time::{SystemTime, UNIX_EPOCH};
use rusqlite::{params, Connection, OptionalExtension};
use tauri::{AppHandle, Emitter};
use crate::genre_tags::{genres_for_track_extracted, replace_track_genre_rows};
use crate::store::LibraryStore;
pub const GENRE_TAGS_MIGRATION_ID: &str = "genre_tags_v1";
const BATCH_SIZE: i64 = 10_000;
type BackfillTrackRow = (
i64,
String,
String,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
);
fn ensure_genre_tags_tables(conn: &mut Connection) -> rusqlite::Result<()> {
crate::store::ensure_genre_tags_schema(conn)
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GenreTagsInspectDto {
pub needed: bool,
pub total_tracks: u64,
pub done_tracks: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GenreTagsProgressEvent {
pub done: u64,
pub total: u64,
}
fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn migration_completed(conn: &Connection) -> Result<bool, rusqlite::Error> {
let completed: Option<Option<i64>> = conn
.query_row(
"SELECT completed_at FROM library_data_migration WHERE id = ?1",
params![GENRE_TAGS_MIGRATION_ID],
|r| r.get(0),
)
.optional()?;
Ok(completed.flatten().is_some())
}
fn count_live_tracks(conn: &Connection) -> Result<u64, rusqlite::Error> {
let n: i64 = conn.query_row(
"SELECT COUNT(*) FROM track WHERE deleted = 0",
[],
|r| r.get(0),
)?;
Ok(n.max(0) as u64)
}
fn cursor_rowid(conn: &Connection) -> Result<i64, rusqlite::Error> {
let rowid: Option<i64> = conn
.query_row(
"SELECT cursor_rowid FROM library_data_migration WHERE id = ?1",
params![GENRE_TAGS_MIGRATION_ID],
|r| r.get(0),
)
.optional()?;
Ok(rowid.unwrap_or(0))
}
pub fn inspect_genre_tags_backfill(store: &LibraryStore) -> Result<GenreTagsInspectDto, String> {
store.with_conn_mut("genre_tags.ensure_schema", ensure_genre_tags_tables)?;
store.with_read_conn(|conn| {
let total_tracks = count_live_tracks(conn)?;
if total_tracks == 0 {
return Ok(GenreTagsInspectDto {
needed: false,
total_tracks: 0,
done_tracks: 0,
});
}
if migration_completed(conn)? {
return Ok(GenreTagsInspectDto {
needed: false,
total_tracks,
done_tracks: total_tracks,
});
}
let cursor = cursor_rowid(conn)?;
let done: i64 = conn.query_row(
"SELECT COUNT(*) FROM track WHERE deleted = 0 AND rowid <= ?1",
params![cursor],
|r| r.get(0),
)?;
Ok(GenreTagsInspectDto {
needed: true,
total_tracks,
done_tracks: done.max(0) as u64,
})
})
}
fn emit_progress(app: &AppHandle, done: u64, total: u64) -> Result<(), String> {
app.emit(
"genre_tags:progress",
GenreTagsProgressEvent { done, total },
)
.map_err(|e| e.to_string())
}
pub fn run_genre_tags_backfill(store: &LibraryStore, app: &AppHandle) -> Result<(), String> {
run_genre_tags_backfill_impl(store, Some(app))
}
fn run_genre_tags_backfill_impl(
store: &LibraryStore,
app: Option<&AppHandle>,
) -> Result<(), String> {
let inspect = inspect_genre_tags_backfill(store)?;
if !inspect.needed {
return Ok(());
}
let total = inspect.total_tracks;
loop {
let (batch_done, finished) = store.with_conn_mut("genre_tags.backfill", |conn| {
if migration_completed(conn)? {
return Ok::<(i64, bool), rusqlite::Error>((total as i64, true));
}
conn.execute(
"INSERT INTO library_data_migration (id, cursor_rowid, started_at) \
VALUES (?1, 0, ?2) \
ON CONFLICT(id) DO UPDATE SET \
started_at = COALESCE(library_data_migration.started_at, excluded.started_at)",
params![GENRE_TAGS_MIGRATION_ID, now_unix()],
)?;
let cursor = cursor_rowid(conn)?;
let mut stmt = conn.prepare(
"SELECT rowid, server_id, id, genre, \
CASE WHEN json_valid(raw_json) THEN json_extract(raw_json, '$.genres') END, \
album_id, library_id \
FROM track \
WHERE deleted = 0 AND rowid > ?1 \
ORDER BY rowid \
LIMIT ?2",
)?;
let rows: Vec<BackfillTrackRow> =
stmt
.query_map(params![cursor, BATCH_SIZE], |r| {
Ok((
r.get(0)?,
r.get(1)?,
r.get(2)?,
r.get(3)?,
r.get(4)?,
r.get(5)?,
r.get(6)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
if rows.is_empty() {
conn.execute(
"UPDATE library_data_migration SET completed_at = ?2, cursor_rowid = \
(SELECT COALESCE(MAX(rowid), 0) FROM track WHERE deleted = 0) \
WHERE id = ?1",
params![GENRE_TAGS_MIGRATION_ID, now_unix()],
)?;
return Ok((total as i64, true));
}
let tx = conn.unchecked_transaction()?;
let mut last_rowid = cursor;
for (rowid, server_id, track_id, genre, genres_json, album_id, library_id) in rows {
let genres = genres_for_track_extracted(
genres_json.as_deref(),
genre.as_deref(),
);
replace_track_genre_rows(
&tx,
&server_id,
&track_id,
album_id.as_deref(),
library_id.as_deref(),
&genres,
)?;
last_rowid = rowid;
}
tx.commit()?;
conn.execute(
"UPDATE library_data_migration SET cursor_rowid = ?2 WHERE id = ?1",
params![GENRE_TAGS_MIGRATION_ID, last_rowid],
)?;
let done: i64 = conn.query_row(
"SELECT COUNT(*) FROM track WHERE deleted = 0 AND rowid <= ?1",
params![last_rowid],
|r| r.get(0),
)?;
Ok((done, false))
})?;
if let Some(app) = app {
emit_progress(app, batch_done.max(0) as u64, total)?;
}
if finished {
break;
}
}
// Belt-and-suspenders: all live tracks processed but `completed_at` not set
// (can happen when rowid gaps from soft-deletes make done == total early).
store.with_conn_mut("genre_tags.backfill.finalize", |conn| {
if migration_completed(conn)? {
return Ok(());
}
let cursor = cursor_rowid(conn)?;
let pending: i64 = conn.query_row(
"SELECT COUNT(*) FROM track WHERE deleted = 0 AND rowid > ?1",
params![cursor],
|r| r.get(0),
)?;
if pending == 0 {
conn.execute(
"UPDATE library_data_migration SET completed_at = ?2, cursor_rowid = \
(SELECT COALESCE(MAX(rowid), 0) FROM track WHERE deleted = 0) \
WHERE id = ?1",
params![GENRE_TAGS_MIGRATION_ID, now_unix()],
)?;
}
Ok(())
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::track::{TrackRepository, TrackRow};
use crate::store::LibraryStore;
fn track(server_id: &str, id: &str, genre: &str, deleted: bool) -> TrackRow {
TrackRow {
server_id: server_id.into(),
id: id.into(),
title: id.into(),
title_sort: None,
artist: Some("Artist".into()),
artist_id: None,
album: "Album".into(),
album_id: Some("al1".into()),
album_artist: None,
duration_sec: 100,
track_number: Some(1),
disc_number: Some(1),
year: None,
genre: Some(genre.into()),
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: Some("lib1".into()),
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn backfill_marks_complete_when_rowid_gaps_leave_pending_rows() {
let store = LibraryStore::open_in_memory();
let live: Vec<TrackRow> = (1..=5)
.map(|n| track("s1", &format!("t{n}"), "Rock", false))
.collect();
let mut batch = live;
for n in 6..=20 {
batch.push(track("s1", &format!("del{n}"), "Rock", true));
}
batch.push(track("s1", "t6", "Jazz", false));
TrackRepository::new(&store).upsert_batch(&batch).unwrap();
run_genre_tags_backfill_impl(&store, None).unwrap();
let inspect = inspect_genre_tags_backfill(&store).unwrap();
assert!(!inspect.needed, "backfill should complete despite rowid gaps");
}
}
@@ -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;
@@ -25,6 +27,8 @@ pub mod dto;
pub mod enrichment;
pub mod filter;
pub mod genre_album_browse;
pub mod genre_tags;
pub mod genre_tags_backfill;
pub mod mood_groups;
pub mod live_search;
pub mod lossless_albums;
@@ -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,
@@ -244,8 +245,9 @@ fn query_albums(
ORDER BY rank \
LIMIT ?\
) \
SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.year, \
t.genre, t.cover_art_id, t.starred_at, t.synced_at, MIN(h.rank) AS best_rank \
SELECT t.server_id, t.album_id, MAX(t.album), MAX(t.artist), MAX(t.album_artist), \
MAX(t.artist_id), MAX(t.year), MAX(t.genre), MAX(t.cover_art_id), \
MAX(t.starred_at), MAX(t.synced_at), MIN(h.rank) AS best_rank \
FROM fts_hits h \
JOIN track t ON t.rowid = h.rowid \
WHERE t.server_id = ? \
@@ -261,24 +263,29 @@ fn query_albums(
params.push(rusqlite::types::Value::Integer(LIVE_SEARCH_FTS_CANDIDATE_CAP));
params.push(rusqlite::types::Value::Text(server_id.to_string()));
append_library_scope(&mut sql, &mut params, library_scope);
sql.push_str(" GROUP BY t.album_id ORDER BY best_rank LIMIT ?");
sql.push_str(" GROUP BY t.server_id, t.album_id ORDER BY best_rank LIMIT ?");
params.push(rusqlite::types::Value::Integer(i64::from(limit)));
let mut stmt = conn.prepare(&sql)?;
let mut out = Vec::new();
for row in stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
let track_artist: Option<String> = r.get(3)?;
let album_artist: Option<String> = r.get(4)?;
Ok(LibraryAlbumDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
artist: r.get(3)?,
artist_id: r.get(4)?,
artist: crate::album_compilation_filter::pick_album_group_artist(
track_artist,
album_artist,
),
artist_id: r.get(5)?,
song_count: None,
duration_sec: None,
year: r.get(5)?,
genre: r.get(6)?,
cover_art_id: r.get(7)?,
starred_at: r.get(8)?,
synced_at: r.get(9)?,
year: r.get(6)?,
genre: r.get(7)?,
cover_art_id: r.get(8)?,
starred_at: r.get(9)?,
synced_at: r.get(10)?,
raw_json: serde_json::Value::Null,
})
})? {
@@ -44,12 +44,13 @@ pub fn list_lossless_albums(
}
let where_sql = where_clauses.join(" AND ");
let la_artist = crate::album_compilation_filter::sql_track_group_display_artist("la");
let sql = format!(
"SELECT \
la.server_id, \
la.album_id, \
COALESCE(a.name, la.album_name), \
COALESCE(a.artist, la.artist), \
COALESCE(a.artist, {la_artist}), \
COALESCE(a.artist_id, la.artist_id), \
COALESCE(a.song_count, la.track_count), \
COALESCE(a.duration_sec, la.duration_sec), \
@@ -65,6 +66,7 @@ pub fn list_lossless_albums(
t.album_id, \
MAX(t.album) AS album_name, \
MAX(t.artist) AS artist, \
MAX(t.album_artist) AS album_artist, \
MAX(t.artist_id) AS artist_id, \
MAX(t.year) AS year, \
MAX(t.genre) AS genre, \
@@ -0,0 +1,262 @@
//! `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");
}
#[test]
fn backfill_from_tracks_accepts_cjk_artist_display_name() {
use crate::artist_sort::DEFAULT_IGNORED_ARTICLES;
use crate::repos::{TrackRepository, TrackRow};
let store = LibraryStore::open_in_memory();
let cjk = "北村友香, 齋藤司, 桜庭統 & 鈴木伸嘉";
let row = TrackRow {
server_id: "s1".into(),
id: "tr_1".into(),
title: "Song".into(),
title_sort: None,
artist: Some(cjk.into()),
artist_id: Some("ar_cjk".into()),
album: "al_1".into(),
album_id: Some("al_1".into()),
album_artist: None,
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
};
TrackRepository::new(&store).upsert_batch(&[row]).unwrap();
let repo = ArtistRepository::new(&store);
let n = repo
.backfill_from_tracks("s1", DEFAULT_IGNORED_ARTICLES, 2)
.unwrap();
assert_eq!(n, 1);
let name_sort: String = store
.with_conn("misc", |c| {
c.query_row(
"SELECT name_sort FROM artist WHERE server_id = 's1' AND id = 'ar_cjk'",
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(name_sort, cjk.to_lowercase());
}
}
@@ -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;

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