Compare commits

...

170 Commits

Author SHA1 Message Date
Psychotoxical 901d260bf1 fix(orbit): stop the orphan sweep from deleting live session playlists
The sweep's name regex kept the trailing __ inside the optional _from_ group,
so a bare session name (__psyorbit_<sid>__) never matched and was pruned as
'corrupt' before the reconnect-breadcrumb guard ran. This was latent while
quitting deleted the session; once sessions survive quit (reconnect), the next
launch's sweep deleted the live session, so the reconnect prompt found nothing
to resume. The trailing __ is now anchored outside the group so both session
and outbox names match. Adds a regression test.
2026-06-07 23:03:35 +02:00
Psychotoxical ebef8aa061 fix(orbit): show the reconnect prompt under React StrictMode
The startup preflight set its one-shot ref before the async ran, so under
StrictMode's dev double-invoke (mount -> cleanup -> mount) the cancelled first
run blocked setCandidate while the second mount short-circuited on the ref, and
the prompt never appeared in dev builds. The decided-flag is now set only after
the cancellation guard so the second (real) mount completes the check. Adds a
regression test that renders the modal under StrictMode.
2026-06-07 22:51:16 +02:00
Psychotoxical 9de56bf638 fix(orbit): keep the session alive on app quit so reconnect can offer it
Quitting ran endOrbitSession / leaveOrbitSession, which deleted the session
server-side and wiped the reconnect breadcrumb — so the restart prompt never
had anything to resume. App exit now leaves the session and breadcrumb intact:
quitting suspends the session rather than ending it. A deliberate end stays the
session bar's End button; switching servers still tears the session down.
2026-06-07 22:39:59 +02:00
Psychotoxical 842e041a6e test(orbit): cover reconnect breadcrumb and host resume 2026-06-07 22:28:51 +02:00
Psychotoxical 0de488335e feat(orbit): prompt to rejoin a session after an app restart
On launch, if a breadcrumb points at a still-alive session on the active
server, a modal offers a one-click rejoin with a 30s auto-rejoin countdown
(host resumes hosting, guest rejoins via the normal path). Declining, Escape,
or a failed attempt wipes the breadcrumb. Copy added across all nine locales.
2026-06-07 22:28:45 +02:00
Psychotoxical 2c95a3499c feat(orbit): persist a reconnect breadcrumb and add host-resume
Sessions stay in-memory only; a tiny localStorage breadcrumb (session id,
playlist ids, role, server) is written on host-start / guest-join and wiped on
any clean exit, so a restart can offer to rejoin. Adds resumeOrbitSessionAsHost,
which rebinds a still-live session as host and rebuilds the in-memory
merged-suggestion set from the restored player queue so resuming never
re-enqueues already-queued tracks. The app-start orphan sweep now skips the
breadcrumb's session so a pending reconnect can't be pruned mid-flight.
2026-06-07 22:28:39 +02:00
Psychotoxical de191e45d5 fix(orbit): keep diagnostics popover inside the window on any size
The diagnostics popover used a fixed 920px width and no height cap, so it
overflowed the left window edge on narrow windows and could drop below the
viewport on short ones. Width and position are now computed inline: width is
clamped to the viewport, the right-anchored popover slides inward so neither
edge spills off-screen, and height is capped to the space below the anchor
with the log textarea flex-shrinking to fit.
2026-06-07 22:02:44 +02:00
Psychotoxical 49ad3618a8 fix(themes): show animated-theme warning on the thumbnail in My Themes (#1022) 2026-06-07 21:26:19 +02:00
cucadmuh 4148e063dd fix(home): sync Mainstage hero backdrop with album on fast nav (#1021)
* fix(home): sync Mainstage hero backdrop with album on fast nav

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: remove unused import in AlbumCard after enqueue refactor

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Covered by validateThemePackage tests for every rejection class.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(themes): granular tokens — track lists

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

* feat(themes): granular tokens — cards

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

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

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

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

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

* style(themes): make Spectrum demo brutally loud

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

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

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

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

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

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

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

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

* feat(themes): granular tokens — player bar

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

* chore(themes): remove empty theme stub files

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

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

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

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

* feat(themes): dedicated Themes settings tab

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

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

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

Add the Theme Store section to the Themes tab:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(rust): satisfy clippy too_many_arguments for CI

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(preview): allow clippy too_many_arguments on audio_preview_play

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

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

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

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

vitest's SSR env exposes a partial import.meta.hot (has .on, no .off), so
the cleanup threw and failed all WaveformSeek/PlayerBar tests. Feature-detect
both .on and .off before wiring the vite:afterUpdate listener.
2026-06-05 18:44:14 +02:00
Frank Stellmacher 1c23305887 feat(queue): add Timeline display mode (#1004)
* feat(queue): add Timeline display mode

A third queue display mode alongside Queue and Playlist. Timeline shows
the full queue anchored on the current track — played history above
(dimmed), upcoming below — with 'History' and 'Up next' dividers and the
current row auto-centered. It already follows shuffle order since the
queue is stored in play order. The header mode button now cycles
queue → timeline → playlist; Settings gains a third option.

* i18n(queue): Timeline mode strings (9 locales)

* docs(changelog): note Timeline queue mode (#1004)
2026-06-05 16:19:20 +02:00
Frank Stellmacher 41157ccaca chore(fullscreen-player): drop dead i18n keys and old CSS (#1002)
Follow-up cleanup after the old fullscreen player was removed (#1001):
- remove the 9 now-unused settings i18n keys (fsPlayerSection,
  fsShowArtistPortrait(+Desc), fsPortraitDim, fsLyricsStyle(+Rail/Apple
  +Descs)) across all 9 locales.
- reduce fullscreen-player-adaptive-portrait.css to the shared lyrics
  overlay styles (FsLyricsApple); drop the old player's .fs-*, the
  .fslm-* lyrics menu, the unused .fsa-fade-* and dead keyframes.
2026-06-05 14:45:03 +02:00
Frank Stellmacher dc4eef1a97 feat(fullscreen-player): rebuilt static fullscreen player (#1001)
* feat(player): static media-center fullscreen player (v1)

New lean fullscreen player: sharp full-bleed background (artist photo, cover
fallback), no blur / no continuous animations. Bottom-left big cover bottom-
flush with a full-width semi-transparent text bar (title with queue position,
artist, year/genre + rating stars, next-up); control row of transport · center
time · actions; full-width seekbar; live clock. Only the seekbar, time readout
and clock update at runtime, each owning its state (no per-tick re-render).
Reuses FsSeekbar/FsPlayBtn, the cover/artist hooks, idle-fade and queue
helpers. Wired in AppShell; old player kept for A/B.

* feat(fullscreen-player): true waveform seekbar instead of thin bar

Replace FsSeekbar with the real WaveformSeek (cucadmuh's idea). The taller
canvas grows the bottom cluster upward, shifting the info/control rows up.
Height clamped to 32-52px.

* feat(fullscreen-player): up-next popover + control-bar styling

- Queue button opens a semi-transparent 'Up next' popover anchored bottom
  right; clicking a row jumps to that queue item.
- Larger bottom-right action buttons.
- Control row gets its own darker semi-transparent bar (touches the info bar
  above) for contrast; play button matches the plain transport buttons
  (no white circle, same size).

* feat(fullscreen-player): high-res (2000px) background cover

Fetch the full-screen background cover at the 2000px tier via the existing
on-demand fullRes path (same getCoverArt fetch, saved as a high-res WebP
outside the backfill pipeline) instead of the low-res 500px pipeline tier.
usePlaybackCoverArt now forwards a fullRes option to useCoverArt.

* feat(fullscreen-player): scrolling lyrics overlay + control-bar polish

- Lyrics button next to Queue toggles a centered, dark semi-transparent
  scrolling-lyrics overlay (reuses FsLyricsApple).
- Control bar darkened to match the lyrics overlay (0.88).
- Close button sized down to harmonize with the clock.

* feat(fullscreen-player): make rating stars clickable

The rating stars next to the year were display-only. Wire them to
queueSongRating (same path as the player bar / context menu): click to
set, click the current value to clear, with a hover preview. Keeps the
lucide outline look to match the rest of the control bar.

* fix(fullscreen-player): stable, high-res album cover

The foreground cover was keyed per track, so Navidrome's per-track
`mf-<id>` coverArt re-triggered the distinct-disc heuristic and reloaded
the cover on every song change within the same album. Key it on albumId
(via useAlbumCoverRef) so it stays put while the album is unchanged.

It also used the low-res tier; reuse the fullRes 2000px cover already
fetched for the background so the foreground is crisp and both share a
single fetch/decode.

* refactor(fullscreen-player): remove the old player and its settings

The static rebuild is now the only fullscreen player. Delete the old
FullscreenPlayer component, its old-only parts (FsArt, FsPortrait,
FsSeekbar, FsLyricsRail, FsLyricsMenu, useFsDynamicAccent) and its test.
Remove the now-orphaned settings (showFullscreenLyrics, fsLyricsStyle,
showFsArtistPortrait, fsPortraitDim) along with the Appearance
'Fullscreen player' section and its search entry. The new player always
shows the artist photo with cover fallback and has its own lyrics toggle.

Shared building blocks used by the static player (FsLyricsApple,
FsPlayBtn, FsClock, FsTimeReadout, FsQueueModal, useFsArtistPortrait)
are kept.

* i18n(fullscreen-player): translate the new player's strings

Wire the static player's previously English-only strings through i18n
across all 9 locales: now-playing label, track position, up-next label,
queue/lyrics/shuffle controls, and the queue overlay (title, empty,
close). Reuses existing queue.title / common.close keys; adds six new
keys to the player namespace.

* docs(changelog): note fullscreen player rebuild (#1001)
2026-06-05 14:23:47 +02:00
cucadmuh c674e4515b feat(audio): migrate Symphonia 0.5 -> 0.6 (#999)
* feat(audio): migrate Symphonia 0.5 -> 0.6

Port the audio + analysis pipeline to the Symphonia 0.6 API:
- bump symphonia to 0.6 and symphonia-adapter-libopus to 0.3; drop rodio's
  symphonia-all feature to avoid a duplicate symphonia-core 0.5
- remove the local symphonia-format-isomp4 0.5 patch and rely on upstream 0.6
- switch codec registries to register_enabled_codecs / make_audio_decoder with
  AudioCodecParameters + AudioDecoderOptions
- rework decode.rs SizedDecoder and analysis decode loops for the new
  AudioDecoder trait, GenericAudioBufferRef, Time/Timestamp newtypes, and
  next_packet() -> Result<Option<Packet>>

Streaming regression fixes:
- ProbeSeekGate hides seekability during probe for non-MP4 progressive streams
  so Symphonia 0.6's trailing-metadata scan no longer forces a full download
  before ranged FLAC/MP3/OGG playback can start
- guard the streaming probe() with a 20s timeout on a worker thread so a stalled
  ranged source (e.g. right after a server switch) can no longer hang playback
  start until a player restart; add probe start/done diagnostics

* docs(changelog): note Symphonia 0.6 migration and streaming fixes (#999)

Add CHANGELOG entries (Changed + Fixed) and a CONTRIBUTORS line for the
Symphonia 0.6 migration, ranged-stream start-latency fix, and probe timeout.

* test(audio): cover streaming probe path and ProbeSeekGate

Add unit tests for SizedDecoder::new_streaming (success + garbage) and
ProbeSeekGate (seekability toggle, byte_len, read/seek passthrough) to
restore decode.rs above the 70% hot-path coverage gate (67.3% -> 79.4%).
2026-06-05 14:12:57 +03:00
Frank Stellmacher c39465dfad feat(sidebar): toggle to pin Now Playing to the top (#1000)
* feat(sidebar): add toggle to pin Now Playing to the top

The fixed Now Playing entry can now be pinned to the very top of the
sidebar (above the Library label) instead of sitting above the bottom
spacer. Adds a persisted `nowPlayingAtTop` setting (default off, so
existing layouts are unchanged) and a toggle in the sidebar customizer
next to the Mix-navigation split. The entry stays non-hideable and
keeps its playing indicator in both positions.

* i18n(settings): Now Playing-at-top toggle strings (9 locales)

* docs(changelog): note Now Playing top toggle (#1000)
2026-06-05 12:35:39 +02:00
Frank Stellmacher 5f345dc7aa fix(settings): restore full border on active server card (#998)
* fix(settings): restore full border on active server card

The active server card mixed the `border` shorthand with borderTop/
borderBottom longhands in one inline style object. React clears the
unset longhand sides on mount, so the top and bottom borders fell back
to the subtle base border while only the left/right accent border
showed. Drive the drag drop-target indicator via an inset box-shadow
instead, leaving the border shorthand to apply on all four sides.

* docs(changelog): note active server card border fix (#998)
2026-06-05 11:46:35 +02:00
cucadmuh d1320ea2c8 chore(deps): refresh npm and Rust dependencies (#997)
* chore(deps): bump npm patch/minor dependencies

Refresh frontend lockfile for React, Vite, Vitest, i18next, axios, Tauri
plugins, and related type packages within existing semver ranges.

* chore(deps): bump Rust patch dependencies

Update id3 to 1.17, reqwest to 0.13.4, and align root zbus with psysonic-audio 5.16.

* chore(deps): upgrade jsdom to v29

Major test-environment bump; all Vitest suites still pass and npm audit is clean.

* chore(deps): upgrade sysinfo 0.39 and zip 8

Align root sysinfo with psysonic-syncfs and migrate backup archives to zip 8.
mach2 0.6 deferred — cpal/rodio still require ^0.5.

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

* docs(changelog): note dependency refresh (PR #997)

* docs(changelog): move deps note to [1.48.0] section

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-05 12:17:42 +03:00
Frank Stellmacher 8ca16c1971 Update CHANGELOG.md (#994) 2026-06-05 01:40:23 +02:00
Frank Stellmacher 7a2f937984 docs(changelog): thank the Discord community in 1.47.0 notes (#993)
Add a thank-you note at the top of the 1.47.0 release notes for the
Discord community's support, quality checks, bug reports and general
collaboration, with the Discord invite link.
2026-06-05 01:38:04 +02:00
github-actions[bot] 76dd5c2087 chore(release): bump main to 1.48.0-dev (#992)
* chore(release): bump main to 1.48.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-05 01:25:52 +02:00
Frank Stellmacher 2f50a1bade fix(tray): stable tray icon id (no KDE duplicate pile-up on toggle) (#991)
* fix(tray): stable tray icon id so KDE toggle stops piling up duplicates

TrayIconBuilder::new() assigns a fresh id on every rebuild. On KDE
(StatusNotifierItem) each new id registers a new item and the stale ones
linger in the hidden-icons list, so toggling the tray icon off/on stacked
up duplicate entries. Build with a constant id so every rebuild reuses the
same item.

* docs: changelog for tray icon duplicate fix (#991)
2026-06-04 22:17:02 +02:00
Frank Stellmacher ec2bee1400 fix(audio): cap pipewire-alsa client latency on Linux (#862) (#990)
* fix(audio): cap pipewire-alsa client latency on Linux (#862)

On some Linux setups the pipewire-alsa bridge negotiates a multi-second
output buffer, so play/pause/seek/volume only take effect once it drains
(~10-20 s). cpal's buffer-size clamp is ignored by those bridges. Set
PIPEWIRE_LATENCY=256/48000 before the audio stream opens to cap the client
node latency — the reporter confirmed this makes the controls instant.
Linux-only, no-op without PipeWire, and a user-set value is left untouched.

* docs: changelog for pipewire latency fix (#990)
2026-06-04 21:56:15 +02:00
cucadmuh ca502ad833 fix(player): stable playbar clocks when showing remaining time (#987)
* chore: restore PR order in CHANGELOG [1.47.0] Fixed section

Re-sort ### blocks ascending by PR number per release-note placement rules after out-of-order Discord-fix batch inserts.

* fix(player): stable playbar clocks when showing remaining time

Pad seekbar time strings to a fixed width so ticking remaining time does not resize WaveformSeek via ResizeObserver; tighten clock-to-waveform spacing and keep the toggle icon inline.

* docs: CHANGELOG and credits for playbar remaining-time fix (PR #987)

* docs: CHANGELOG entry for playbar remaining-time waveform fix (#987)

* chore: drop settingsCredits entry for minor playbar fix (#987)

* docs: move #987 CHANGELOG entry to end of [1.47.0] Fixed section
2026-06-04 17:53:36 +03:00
cucadmuh 6da98e476b fix(ui): New badge flicker on mainstage album rail hover (#986)
* fix(ui): stop New badge flicker on mainstage album rail first hover

Dim cover via ::before under badges; keep play overlay transparent and
avoid visibility toggles on rail action buttons.

* docs: CHANGELOG for mainstage New badge hover fix (PR #986)

* fix(ui): keep New badge above rail cover zoom (match grid stacking)

Use contain:paint + translateZ layering like New Releases grid so scaled
cover art does not paint over the badge during hover animation.

* fix(ui): global album cover badge stacking for all rails and grids

Move img/badge/overlay z-index into album-card.css; stop album-grid from
resetting card transform or duplicating cover rules that hid New badges.

* docs: CHANGELOG — badge stacking applies to all album rails

* fix(ui): restore rail play buttons above scaled cover layer

Raise play overlay and badge z-index on horizontal rails so WebKit does not paint zoomed cover art over hover controls after the New badge stacking fix.
2026-06-04 16:09:54 +03:00
Frank Stellmacher 631330ebfa fix(search): load album cover for song results (#984)
* fix(search): load album cover for song results

Live Search song rows carried the per-track `mf-…` coverArt id from
search3, which the cover pipeline does not resolve, so the thumbnails went
blank. Album rows worked because they use the album-scoped `al-…` id. A
song's search thumbnail is its album cover anyway — derive it from the
albumId so it loads, and show a music glyph when there is no album.

* docs: changelog for search song cover fix (#984)
2026-06-04 13:05:42 +02:00
cucadmuh 19fdba006a fix(search): local index rejects FTS syntax in live search queries (#983)
* fix(search): reject FTS syntax chars in local index live search queries

FTS5 treats `=` and similar characters as query syntax, so tokens like
1=2 produced unrelated prefix hits. Skip FTS for unsafe tokens instead.

* docs: CHANGELOG for local index FTS syntax query fix (PR #983)

* fix(search): reject syntax junk in server search3 and live search race

Share FTS-safe token guard with search3; skip network invoke for ** and
similar queries; show empty dropdown without misleading source badge.

* fix(search): allow censorship stars in queries, reject wildcard-only tokens

Block ** and **** but keep ***Flawless-style title searches working
in local FTS and search3.
2026-06-04 13:37:11 +03:00
Frank Stellmacher d43a8c6691 fix(ui): square song-rail nav buttons (#982)
* fix(ui): square song-rail nav buttons to match other rails

The song rail's prev/next (and reroll) buttons were circular while every
other rail uses the square rounded-rect nav button. Align them.

* docs: changelog for square song-rail nav buttons (#982)
2026-06-04 11:26:57 +02:00
Frank Stellmacher b0f35eabc9 fix: usable layout when the window is small (#981)
* fix(layout): collapse browse toolbar to icons on compact width

On a narrow window the labelled filter buttons wrapped into several rows
and, being a fixed-height sticky header, starved the album grid below them
down to a clipped strip. Wrap each toolbar label in a hideable span and drop
to icon-only in compact (mobile) mode; icons keep their tooltip and
aria-label so the action stays discoverable.

* fix(window): raise minimum window size to 520x640

The old 360x480 floor let the window shrink far past the point the layout
holds together. Floor it at a laptop-friendly size where the compact layout
(icon toolbar + scrollable lists) still works.

* fix(player): scale mobile cover to fit short windows

The cover width was viewport-derived with no height bound, so on a short
window the square grew past its slot and overlapped the title. Bind it to
the shrinkable wrap via max-height and keep it square with auto sizing.

* docs: changelog for small-window layout fixes (#981)
2026-06-04 11:16:48 +02:00
Frank Stellmacher f3eb58c707 fix(playlist): suggestion rows match the normal track row (#980)
* fix(playlist): suggestion rows match the normal track row

The Suggested Songs table left the Favorite and Rating columns empty and
rendered only a single artist, so multi-artist tracks lost their split and
the blank columns read as a gap between Genre and Duration.

- Extract a shared PlaylistArtistCell that splits the OpenSubsonic artists
  array into individually navigable links; use it for both the playlist
  rows and the suggestions so a track reads the same before and after it is
  added.
- Show the real favorite heart and star rating in suggestion rows (global
  song operations), seeded from the song's own starred/userRating, removing
  the empty-column gap.

* docs: changelog for suggestion row parity (#980)
2026-06-04 10:34:25 +02:00
Frank Stellmacher a7c79ee210 fix: show album artist on featured compilation cards (#979)
* fix: show album artist on featured compilation cards

The 'Also featured on' cards are synthesised from search3 child songs,
which only read the flat `albumArtist` field. Compilation children leave
that empty — the album-artist credit lives in OpenSubsonic's structured
`albumArtists` / `displayAlbumArtist` — so the card fell back to the '—'
placeholder. Carry the structured credit (and the display string) onto
the synthesised album so it resolves a name (and stays navigable when the
server supplies an id).

* docs: changelog for featured-compilation artist credit (#979)
2026-06-04 09:54:10 +02:00
Frank Stellmacher 47e16ebfef fix: RC3 queue link underline, genre album artist split, Artists "Other" bucket (#977)
* fix: RC3 queue link underline, genre album artist split, Artists "Other" bucket

Three zunoz reports, one change:

- Queue now-playing card: the artist and album links now underline on hover
  (not just recolour), matching clickable names everywhere else. The album
  link sits on .queue-current-sub itself; artist links are nested .is-link
  spans — both selectors covered.

- Genre album cards split multi-artist credits into individual links like the
  rest of the app. Root cause was the local-index genre query hardcoding NULL
  for the album raw_json column, so OpenSubsonic artists[] never reached the
  card and it fell back to the flat "A • B" single link. Select a.raw_json
  instead (parity with the All Albums / advanced-search album queries).

- Artists page alphabet index: # is now digits-only, and a new "Other" bucket
  collects accented Latin (Æ/Ø/Å…) and non-Latin scripts (CJK, Cyrillic, …)
  that previously fell into the # catch-all. Adds artistBucketKey/compareBuckets
  helpers (unit-tested), an OTHER bucket sorted last, and the artists.other
  label across 9 locales.

* docs(changelog): queue link hover, genre card artist split, Artists Other bucket (#977)
2026-06-04 03:13:45 +02:00
Frank Stellmacher 88df194808 fix(tracks): RC3 layout regressions + multi-artist links (#976)
* fix(tracks): RC3 layout regressions + multi-artist links

- Restore vertical rhythm on the Tracks hub: the header/hero/rails/browse
  sections sat two wrapper divs below .tracks-page so its flex gap never
  reached them and they collapsed together. New .tracks-hub-stack re-applies
  the gap, fixing the tagline-touching-hero and Random-Pick-overlapping-hero
  spacing (the rail's nav buttons no longer ride into the box above).
- Stop the sticky browse-table header going transparent on hover: its base
  background became opaque but the :hover rule still forced transparent, so
  rows bled through. Header now keeps its card background on hover.
- Widen the Duration column (56px -> 72px; 56 -> 64 on mobile) so the
  "DURATION" header no longer clips/overruns.
- Split multi-artist tracks into individually clickable artist links in both
  the "Track of the moment" hero and the browse list rows (OpenSubsonic
  artists[] with single-artist fallback), matching the album tracklist.

* docs(changelog): Tracks RC3 spacing, Duration, header hover, multi-artist (#976)
2026-06-04 02:28:13 +02:00
Frank Stellmacher 908c349cfd fix(mainstage): rename Home Page setting, start-page fallback + empty state (#975)
* fix(mainstage): rename Home Page setting, add start-page fallback + empty state

- Rename "Home Page" personalisation section to "Mainstage" across 9 locales
  so the heading matches the sidebar entry; add "mainstage" search keyword.
- Index route "/" now redirects to the first visible library item when the
  Mainstage sidebar entry is hidden, instead of stranding the app on a blank
  page. New pure resolveStartRoute() mirrors sidebar order + nav-mode gating.
- Show a guided empty state on Mainstage when every section is toggled off,
  with a CTA into Settings -> Personalisation.
- Unit tests for resolveStartRoute.

* docs(changelog): Mainstage rename, start-page fallback + empty state (#975)
2026-06-04 02:12:52 +02:00
Frank Stellmacher 3be8c367dd Fix queue handle cursor and Favorites column sorting (#974)
* fix(ui): pointer cursor on the queue collapse handle

The round handle is a click-to-collapse button but showed the col-resize
cursor like the seam strip. Use a pointer on the handle; the seam keeps
col-resize and a real drag still switches the body cursor to col-resize.

* fix(favorites): make Plays, Last Played and BPM columns sortable

The header marked these columns sortable (pointer cursor) but handleSortClick
gated on a separate, narrower column set, so clicks did nothing. Both the
comparator and the header now share one SORTABLE_COLUMNS source, so the
affordance and the behaviour can't drift apart again.

* docs(changelog): queue handle cursor + favorites column sorting (#974)
2026-06-04 01:36:24 +02:00
Frank Stellmacher 0d479f3bfa Fix Random Mix audiobook exclusion: click area and false matches (#973)
* fix(random-mix): limit exclusion toggle click area to checkbox and title

The audiobook exclusion was a full-width label wrapping the checkbox,
title and description, so clicking empty space or the description text
toggled it. Make only the checkbox and its title clickable; the
description and surrounding space are no longer hit targets.

* fix(random-mix): stop excluding Thriller and Fantasy as audiobook genres

These keywords match regular music (e.g. Trance/Metal genre tags, a track
titled "Thriller") because the exclusion checks genre, title, album and
artist by substring, dropping a few legit songs per mix. Remove both from
the audiobook keyword list.

* docs(changelog): random mix audiobook exclusion fixes (#973)
2026-06-04 01:18:52 +02:00
Frank Stellmacher c119a32277 Unify button tooltips across the app (#972)
* feat(tooltip): 2s open delay and shared tooltipAttrs helper

Add a 2s hover open delay in TooltipPortal (single behaviour source) so
tooltips no longer flash on quick pointer passes; hiding stays immediate.
Add tooltipAttrs() to pair data-tooltip with a matching aria-label for
buttons touched in the unification work. Covered by Vitest.

* feat(tooltip): lower open delay to 1s

2s felt too long in testing; 1s gives the same anti-flash behaviour
without making intentional hovers wait.

* feat(tooltip): action tooltips on the artist overview

Add tooltips describing the action to Last.fm, Wikipedia, Play All,
Shuffle and Radio. Shuffle/Radio now show a tooltip on desktop too,
not just mobile. Strings added to all 9 locales.

* feat(tooltip): action tooltips on the album overview

Add tooltips describing the action to the desktop Play, Artist Bio and
Download (ZIP) buttons, matching the mobile layout. Strings added to all
9 locales.

* feat(tooltip): action tooltips on the All Albums toolbar

Add tooltips describing the action to the sort, year and genre filter
buttons. SortDropdown gains an optional tooltip prop; the year and genre
filter components carry their own, so the tooltips also appear on the
other browse pages that reuse them. Strings added to all 9 locales.

* feat(tooltip): action tooltips on song-list rows

Add Play and Add-to-queue tooltips to the per-row icons in SongRow, used
by the Tracks browse list, Search and Advanced Search. Also localizes the
aria-labels, which were hardcoded English. New common.addToQueue in all 9
locales.

* fix(tooltip): uniform tooltip placement on the Artists toolbar

The favourite and multi-select buttons forced tooltips below while the
view-mode buttons auto-flipped above, so the row looked inconsistent.
Pin the view-mode buttons below too, matching the rest of the row and the
Albums toolbar.

* feat(tooltip): clarify and align the Advanced Search scope row

Add a leading "Search in:" label and per-chip tooltips so the
All/Artists/Albums/Songs row reads as a scope limiter. Drop the forced
below-placement on the small star filter (used only here) so the
favourites chip flips with the others instead of sitting alone below.
Strings added to all 9 locales.

* docs(changelog): tooltip unification (#972)
2026-06-04 00:53:22 +02:00
cucadmuh 82c414d7bc fix(playlists): Smart Playlist editor theme, toggles, and exclude-all-genres (#970)
* fix(playlists): Smart Playlist editor theme, toggles, and exclude-all-genres

Replace native sort select with CustomSelect, fix mode-button layout shift,
color-code included vs excluded genres, collapse exclude-all to untagged rule,
and handle empty smart playlists without false "not found".

* docs: CHANGELOG and credits for Smart Playlist editor fix (PR #970)

* chore(credits): drop minor fix entries from PR #958 onward
2026-06-04 00:22:29 +03:00
cucadmuh c9b2d140d9 fix(ui): shrink-wrap floating player bar instead of full-width strip (#969)
* fix(ui): shrink-wrap floating player bar instead of full-width strip

The bar used fixed left and right insets, which stretched its background
across the whole main column. Center it with max-content width so only
the pill-shaped controls are painted, and soften the drop shadow.

* docs: note PR #969 in changelog and settings credits
2026-06-04 00:03:24 +03:00
cucadmuh e8962c21ab fix(settings): improve in-page search matching and coverage (#968)
* fix(settings): improve in-page search matching and coverage

Index AudioMuse and individual shortcut rows, tighten fuzzy matching so
junk queries return no hits, and scroll to the parent subsection when a
shortcut result is selected.

* docs: note PR #968 in changelog and settings credits
2026-06-03 23:56:54 +03:00
cucadmuh cd47a4b0fa fix(player): clamp custom delay input and align preview with armed timer (#967)
* fix(player): clamp custom delay input and align preview with armed timer

Reject absurd custom minute values that overflow setTimeout, share one
delay helper between the modal preview and schedule actions, and refresh
countdown ticks when a new deadline is armed.

* docs: note PR #967 in changelog and settings credits
2026-06-03 23:37:17 +03:00
cucadmuh f3a0b3f7af fix(artist-detail): Last.fm/Wikipedia/Favorite hover keeps button border (#966)
* fix(artist-detail): keep ext-link border visible on hover

Hover used --border-subtle, which on Catppuccin matches --bg-card and
visually erased the rim while only the fill changed. Match btn-surface:
--ctp-surface1 border, --ctp-overlay0 on hover.

* docs(changelog): credit zunoz on Psysonic Discord for PR #966

* fix(playlists): tooltips on Play/Add Songs and song count pluralization

Add data-tooltip to Play and Add Songs in playlist hero; switch
playlists.songs to count-based _one/_other forms (was {{n}} without
i18next plural suffix, breaking spacing and singular).

* fix(playlists): render BPM and optional cols in Suggested Songs rows

PlaylistSuggestions shared column headers with the main tracklist but
its row switch omitted bpm, genre, playCount, and lastPlayed.
2026-06-03 23:19:51 +03:00
cucadmuh 5990d84f5a fix(random-mix): keyword blocks and scoped genre list on Build a Mix (#965)
* fix(random-mix): honor keyword blocks and scope genre list to library

Keyword filter was gated behind the audiobook exclusion checkbox, so
blocked artists still appeared after Remix. Genre Mix now loads genres
via fetchGenreCatalog (scoped index / library filter) instead of raw
getGenres; show empty state when all tracks are filtered out.

* docs(changelog): credit zunoz on Psysonic Discord for PR #965
2026-06-03 23:06:50 +03:00
cucadmuh e76dac87ae fix(home): scope Because you listened rail to sidebar library (#964)
* fix(home): scope Because you listened rail to sidebar library

Similar-artist album picks used getArtist without library filtering;
session cache also ignored musicLibraryFilterVersion. Filter picks to
the scoped album set and key cache/reserve by filter version.

* docs(changelog): credit zunoz on Psysonic Discord for PR #964

* test: satisfy SubsonicAlbum required fields in new unit tests

Fix tsc --noEmit CI: songCount and duration are required on SubsonicAlbum.
2026-06-03 22:57:35 +03:00
cucadmuh be21f7834f fix(composers): hide performer-only artists with zero composer credits (#963)
* fix(composers): drop Navidrome role rows with zero composer albums

Navidrome can list performer-only artists under role=composer with
stats.composer.albumCount 0; filter them out of the Composers catalog
so search no longer surfaces ghost entries like Apollo 440.

* docs(changelog): credit zunoz on Psysonic Discord for PR #963
2026-06-03 22:45:52 +03:00
cucadmuh c683b5e37b fix(cards): selection ring clipping on browse grids (WebKitGTK) (#962)
* fix(artists): inset selection ring on grid cards, stop composer hover clip

Artist multi-select used a positive outline-offset that clipped in the
first grid row and sat outside the card border on hover. Match album
cards with an inset ring; drop composer-card hover translateY that
sheared the top edge in the in-page scrollport.

Reported by zunoz (v1.47.0-rc.3).

* fix(cards): selection ring via inset ::after overlay on WebKitGTK grids

Replace outline-based multi-select rings on album/artist/playlist cards
with the same inset ::after box-shadow pattern used for card focus rings
(card.css) — avoids clipping and the 1px gap vs the inner border on
overflow:hidden tiles in All Albums and related browse grids.

* docs: note browse grid selection ring fix in CHANGELOG (PR #962)

* docs(changelog): credit zunoz on Psysonic Discord for PR #962
2026-06-03 22:38:57 +03:00
cucadmuh a07e8e9593 fix(composers): keep role-split names in page-local search (#961)
* fix(composers): keep role-split names in page-local search

Composers browse already loads the Navidrome role-scoped catalog; scoped
search now filters that list instead of replacing it with generic artist
index/search3 hits that merge split credits into one joined name.

Reported by zunoz on the Psysonic Discord (v1.47.0-rc.3).

* docs: note Composers scoped-search fix in CHANGELOG (PR #961)
2026-06-03 22:23:11 +03:00
cucadmuh 4c70408bd6 fix(now-playing): split artist links and About the Artist tabs (#960)
* fix(now-playing): split artist links and About the Artist tabs

OpenSubsonic artists[] now drives per-artist navigation on Now Playing
hero and the queue current-track row (matching player bar). About the
Artist loads bio for each performer via tabs when a track has multiple
artist ids; queue Info uses the primary ref for bio/tour fetch.

Reported by zunoz on the Psysonic Discord (v1.47.0-rc.3).

* docs: note Now Playing multi-artist fix in CHANGELOG (PR #960)
2026-06-03 22:16:06 +03:00
cucadmuh a142bb1dab fix(albums): scope All Albums genre filter to selected music library (#959)
* fix(albums): scope All Albums genre filter to selected music library

When the sidebar narrows to one Subsonic library, the genre popover fell back
to server-wide getGenres() instead of the scoped local index catalog. Load
genre options from libraryGetGenreAlbumCounts for library-only scope and
enable the catalog path whenever the index is on and a library is selected.

* docs: credit All Albums genre filter fix (PR #959, report zunoz)

* chore: drop settingsCredits entry for small genre-filter fix
2026-06-03 22:05:35 +03:00
cucadmuh 75e2c7f9c6 fix(player): persist player prefs outside quota-bound queue blob (#958)
* fix(player): persist volume/repeat in dedicated localStorage key

Player prefs were bundled with the full queue blob in psysonic-player; after
thin-state #872 a large queue can exceed the quota and safeStorage silently
drops every write, so volume and repeat mode stopped surviving restarts.
Move them to psysonic_player_prefs, gate main-blob writes until rehydrate,
and sync volume to the Rust engine on startup.

* fix(player): split queue visibility and Last.fm cache from main persist blob

Move isQueueVisible and lastfmLovedCache to dedicated localStorage keys so
they keep saving when psysonic-player hits the quota on large queues. Include
the new keys in settings backup export.

* docs: note player prefs persist fix in CHANGELOG and credits (PR #958)
2026-06-03 21:55:20 +03:00
Frank Stellmacher 40932d28e2 UI/CSS fixes: focus rings, search fields, column dropdown, theme accordion (#954)
* fix(focus): keyboard focus ring no longer clipped by overflow or cover

The global :focus-visible ring used a positive outline-offset, so it was
drawn outside the element and clipped by any ancestor with overflow:hidden
or a scroll container (cards, rails, player bar, queue strip). Draw it inset
instead via shared --focus-ring-* variables (single source). Cards draw the
ring as an overlay above the cover, since a cover's own stacking context
(transform/contain for render stability) would otherwise paint over an inset
outline. Dracula now only sets --focus-ring-color.

* refactor(focus): fold scattered focus-ring overrides onto shared knob

Six components declared their own :focus-visible outline (mostly an exact copy
of the old global ring). Remove the redundant ones so they inherit the global
inset ring; keep the custom-coloured ones (genre pill, playback-delay modal)
but source width/offset from --focus-ring-*; move the because-card ring to the
central card focus ring (it has a cover and needs the lifted treatment).

* fix(settings): round theme accordion inner box to match its section

The theme picker's inner accordion had square corners, so the first (open)
group header ran flush into the rounded Theme card. Give .theme-accordion a
border-radius + overflow:hidden so its top/bottom corners continue the parent
card's rounding, matching the other settings sections.

* fix(search): unify search fields to one look

Live-search, Help and Settings search differed (pill vs rounded-rect, glow vs
plain border-change, mismatched backgrounds). Align them on the canonical input
look: radius-md, ctp-base background, accent border + soft accent-dim focus
glow. Drop the live-search pill radius, and suppress the input's own inset ring
so only the outer cluster glow shows (no double ring).

* fix(tracklist): column picker menu no longer clipped on short lists

The column-visibility dropdown was an absolutely-positioned menu inside the
tracklist, so a short list (e.g. a one-song Favorites view) clipped it via the
ancestor's overflow box. Render the menu in a portal to <body> with fixed
positioning anchored to the trigger (flips above when there's no room below),
following on scroll/resize. Outside-click + Escape close now live in the shared
TracklistColumnPicker (the menu is portalled out of the wrapper, so the old
wrapper-only check would have closed it on every in-menu click). Fixes albums,
playlists and favorites in one shared place. Adds a behaviour test.

* docs(changelog): UI/CSS fixes pass (#954)
2026-06-02 21:43:09 +02:00
Frank Stellmacher cc04a0c93d Distinct circular song cards with jump-to-album badge (#953)
* feat(tracks): distinct circular song cards with jump-to-album badge

Single-track cards looked identical to album tiles, so clicking the body
read as album behaviour even though it starts playback. Give them a round
vinyl-style cover (square stays album-only) via a shared .cover-circle
utility, and add a 'To album' badge under the artist that navigates to the
track's album. Card click still plays; the badge is the explicit nav path.

* i18n(tracks): add toAlbum label across 9 locales

* docs(changelog): track-card redesign + to-album badge (#953)
2026-06-02 20:34:54 +02:00
cucadmuh 47832632fd fix(cover): follow connect-URL flips in library cover backfill (#952)
* fix(cover): follow connect-URL flips in library cover backfill

The native cover backfill was configured once with a snapshot of the runtime
connect URL. When a laptop moved off the LAN, the smart endpoint switch flipped
the sticky connect URL to the public address (playback/UI covers rebuild it per
request and followed it), but backfill kept fetching from the now-unreachable
local address — flooding the log with "error sending request" failures.

Make the connect cache observable (notify on effective flips) and have the
backfill hook reconfigure when the resolved URL changes, forcing a pass so the
.fetch-failed backoff from the stale address is cleared and those covers retry
on the reachable endpoint.

* docs(changelog): note cover backfill endpoint-switch fix (PR #952)

* fix(cover): abort stale backfill pass + rerun on connect-URL flip

Frontend reconfigure alone didn't fully cover the boot case: at startup the
first backfill pass starts on the primary (LAN) URL before the reachability
probe resolves, so when the probe flips to public the forced rerun was dropped
by the pass_running guard and the slow LAN pass (every cover timing out) ran to
completion with nothing re-running on the reachable address.

set_session now bumps a session generation; the running pass checks it on every
focus gate and abandons promptly when the URL flips (same server_index_key, new
rest_base_url). try_schedule_full_pass records a rerun when a pass is in flight
and drains it once the abandoned pass returns, so a fresh forced pass runs on
the new address.

* fix(cover-backfill): resolve connect URL per fetch instead of baking it into the queue

The backfill worklist no longer carries a URL. Each cover fetch reads the
current reachable address live from a single worker cell, so a LAN↔public flip
is honoured even by the pass already in flight — its remaining covers download
against the new endpoint without aborting/rebuilding the worklist.

- Drop rest_base_url from CoverBackfillSession; add live base_url cell read in
  ensure_one. Remove the session-generation abort machinery (no longer needed).
- New lightweight library_cover_backfill_set_base_url command pushes the URL on
  every connect-cache flip; a real change clears the stale .fetch-failed backoff
  and runs a forced pass so covers that timed out on the old address retry.
- Split useLibraryCoverBackfill into a configure effect (server/creds/strategy)
  and a flip effect that only pushes the URL.
- Keep a single rerun_pending flag for the boot case (flip mid-pass), since the
  finished pass re-arms the idle gate.
2026-06-02 16:01:01 +03:00
cucadmuh 81f900c7a6 perf(analysis): measure tpm over trailing 5s window (#948)
* perf(analysis): measure tpm over trailing 5s instead of full minute

Mirror the cover cpm change: a 60s rolling average added too much inertia and
flattened real bursts/stalls. Count completions in the trailing 5s window and
extrapolate to per-minute, so analysis tpm reacts promptly and decays to 0
within the window when idle. Retention stays at 60s.

* docs(changelog): note trailing-window throughput rate (PR #948)
2026-06-02 12:17:10 +03:00
cucadmuh 2224ddbe78 feat(perf): add on-demand (ui) throughput to cover pipeline cpm (#947)
* feat(perf): add on-demand (ui) throughput to cover pipeline cpm

Cover cpm previously measured only the native backfill (lib) via the
cover:library-progress done delta. Add a parallel UI series: every completed
on-demand Rust cover ensure (grid / now-playing) records a timestamp, surfaced
as a covers-per-minute rate. Both are exposed in the live cover diag, shown as
separate Backfill (lib) / On-demand (ui) cards in the Monitor tab (each pinnable
to the overlay) and as lib/ui rows in the Cover pipeline overlay block.

* docs(changelog): note cover on-demand (ui) throughput (PR #947)

Add CHANGELOG entry and credits line for the UI cover cpm metric.

* fix(perf): source on-demand (ui) cpm from backend produced-cover count

The JS ensure-queue counter never tracked: produced covers return hit:true
(only misses/errors are hit:false), and ensure-queue dedup/HMR made client
counting unreliable. Count on-demand covers natively in ensure_inner on the
produce path (non-bulk, past the cache-hit gate), expose a cumulative
uiEnsuredTotal in the pipeline stats, and derive the per-minute rate on the
frontend from polled deltas — mirroring the lib backfill series.

* perf(cover): measure cpm over trailing 5s instead of full minute

A 60s rolling average added too much inertia, flattening real bursts and
stalls in both the lib backfill and on-demand (ui) cover throughput. Compute
the rate from the trailing 5s of samples (still extrapolated to per-minute),
so the figure reacts promptly and decays to 0 within the window when idle.
2026-06-02 12:11:22 +03:00
cucadmuh 975bb6d9af feat(perf): live runtime logs tab in Performance Probe (#946)
* feat(perf): live runtime logs tab in Performance Probe

Add a Logs tab that streams the backend runtime log ring buffer in-app, so the
stdout/stderr console (unreachable on Windows without exporting) can be read
live. The buffer now tracks a monotonic seq; a new tail_runtime_logs command
returns lines incrementally and get_logging_mode reports the current depth.

The tab has a depth switch (off/normal/debug) mirroring app settings, a line cap
(500-5000), pause/clear, auto-follow, and an ordered comma-separated word filter
where a plain word includes and a -word excludes, applied left to right as
layers (sequence matters).

* docs(changelog): note Performance Probe logs tab (PR #946)

Add CHANGELOG entry and credits line for the live runtime logs tab.

* fix(perf): pin log view position when scrolled up

Auto-scroll keeps the logs tab at the tail, but once the user scrolls up the
view now stays put — the previously-topmost line is re-pinned each tick while
the log keeps appending below for further scrolling. History under the viewport
is no longer trimmed while scrolled up (kept up to the ring-buffer ceiling); the
cap is re-applied when following resumes. Buffer overflow is shown in the status
line instead of an injected marker row.

* fix(perf): scope logs tab to its own internal scroll

The whole probe body scrolled (controls + filter + log) because the log
container sized via height:100%, which WebKitGTK does not resolve against the
flex body. Make the body a flex column with hidden overflow on the Logs tab and
let the log view flex-fill, so depth/keep/pause/clear and the filter stay fixed
while only the log lines scroll.
2026-06-02 11:40:36 +03:00
cucadmuh c6df05e576 feat(perf): cover pipeline throughput (cpm) in performance probe (#945)
* feat(perf): cover pipeline throughput (cpm) in performance probe

Mirror the analysis pipeline's tpm for covers. A new coverPerfStore samples the
backfill `done` progress from cover:library-progress events and derives a rolling
one-minute covers-per-minute rate. Surfaced as a live diag in perfLiveStore, a
pinnable "Cover backfill" throughput card in the Monitor tab, and a cpm row in
the Cover pipeline overlay block.

* docs(changelog): note cover pipeline cpm metric (PR #945)

Add CHANGELOG entry and credits line for the cover-pipeline covers-per-minute
throughput metric in the Performance Probe.
2026-06-02 11:05:28 +03:00
cucadmuh 42aec6720c fix(cover): stop per-song over-fetch + log failed cover downloads (#944)
* fix(cover): stop per-song cover over-fetch (album/mf-* explosion)

album_has_distinct_disc_covers returned true as soon as two tracks on the same
disc had different cover ids. On Navidrome every song has its own mf-<id>
coverArt, so almost every album was flagged "distinct disc covers" and backfill
warmed one cover per track (~520k for ~170k tracks), filling album/ with mf-*
dirs instead of ~one cover per album.

Treat a release as having distinct disc covers only when each disc has a single
consistent cover that differs across discs (genuine box set); per-song ids now
collapse to one cover per album. Mirror the same fix in the TS
albumHasDistinctDiscCovers used by on-demand warming. Adds regression tests on
both sides.

* docs(changelog): record per-song cover over-fetch fix (PR #944)

Give the album/mf-* over-fetch fix its own [1.47.0] Fixed entry and a
settingsCredits line under PR #944.

* feat(cover): log failed cover downloads with album/artist name

A non-200 (or network-failed) getCoverArt download was swallowed silently. Now
the failure is logged with the resolved album/artist name and the server error,
so a server refusing covers under backfill load (5xx/429/timeouts) is visible.

- cover_resolve: describe_cover_entity() resolves a human label from the local
  index (album "Name" — Artist / artist "Name"), best-effort with id fallback.
- cover_cache: log_cover_fetch_failure() in ensure_inner logs on the download
  error path; threads optional library_server_id through CoverCacheEnsureArgs so
  the name lookup happens only on failure. Backfill logs at normal level,
  on-demand misses at debug level.
2026-06-02 10:58:39 +03:00
cucadmuh a63ba3c9cb fix(cover-backfill): kill idle CPU spin and offline-cache menu re-walks (#943)
* fix(cover-backfill): snapshot-diff worklist and live-tunable parallelism

Aggressive cover backfill pegged one tokio worker at ~100% on large,
fully-synced libraries while the download queues stayed empty.

- Take two snapshots once per pass — the DB catalog (single GROUP BY) and
  the on-disk cover bucket (one directory walk) — and download the
  set-difference. No per-row `stat` syscalls and no re-scan loop; the empty
  cache case (heavy backfill) costs zero per-item disk hits.
- Replace the front-loaded enumeration with a producer/consumer pipeline:
  the producer streams the catalog in chunks and feeds misses into a bounded
  channel; a fixed consumer pool keeps the download/encode pools saturated.
- Make cover backfill parallelism runtime-tunable from the Performance Probe
  (threads slider + "Run full pass now"); HTTP download and CPU encode
  semaphores resize live. Not surfaced in app settings.
- Add a "nothing changed" idle gate (catalog signature) so a settled pass is
  not re-run on every library:sync-idle, mirroring the analysis worker.
- Cancel promptly on switch to lazy: consumers bail on enabled/focus change
  and the producer feeds via try_send so a full channel cannot deadlock.
- Drop the per-item recursive disk walk from the ensure hot path.

* fix(cover-backfill): cheap idle gate, settle on 404s, transient retries

Follow-up to the snapshot-diff backfill: stop the periodic CPU spikes and the
89%-plateau wake storm on libraries whose covers can never reach 100%.

- Idle gate is now disk-free: compare only the catalog COUNT(DISTINCT) instead
  of walking ~all cover dirs on every sync-idle. "Did the server change?" never
  touches the filesystem. Clear-cache commands re-arm the gate (rearm_idle_gate)
  since a clear leaves the catalog total unchanged, and the settings UI wakes the
  active server after a clear.
- Settle the gate on any completed pass regardless of pending: remaining items
  are unfetchable-for-now (404), so the wake/sync-idle storm stops once the
  fetchable set is exhausted.
- Stop auto-clearing .fetch-failed markers every pass (it defeated the 30-min
  backoff and re-attempted 404s forever). The manual "Run full pass now" sends
  force=true to clear them and retry; wake/sync-idle/configure stay opportunistic.
- Rate-limit sync-idle passes (60s cooldown) as defence against chatty syncs.
- Retry cover downloads up to 3x with backoff on transient failures (5xx / 429 /
  network), but never on a real 4xx so missing covers don't hammer the server.

* fix(cover-cache): stop re-walking cover dirs from offline & cache menu

The settings cover-cache section polled disk usage + progress every 15s for
every server, each call doing a full recursive walk of the per-server cover
directory. On a fully populated cache this caused periodic CPU spikes whenever
that menu was open.

- mod.rs: add a 10s TTL memo around the per-server cover dir walk
  (cached_dir_usage_for_server), shared by cover_cache_stats_server and
  library_cover_progress; invalidate on clear (per-server and clear-all).
- CoverCacheStrategySection: recompute on entry only; rely on the
  cover:library-progress and cover:cache-cleared events for live updates;
  drop the per-cover cover:tier-ready refresh storm; turn the 15s loop into a
  5-minute safety net.

* fix(cover-backfill): keep emitting progress during the whole pass

The producer finishes enumerating the worklist long before the consumer pool
finishes downloading it, so progress was only emitted while feeding the channel
— the "offline & cache" menu and overlay then froze through the entire drain
phase. Replace the per-chunk emit with a 3s progress ticker that runs for the
lifetime of the pass and is aborted once the consumers drain (final accurate
emit still happens at settle).

* docs(changelog): record cover-backfill idle-CPU fix (PR #943)

Add [1.47.0] Fixed + Changed entries and a settingsCredits line for the
cover-backfill idle CPU / offline & cache menu work.
2026-06-02 04:56:34 +03:00
cucadmuh 5e977cfd49 fix(player-stats): exclude paused time from listened duration (#942)
* fix(player-stats): exclude paused time from listened duration

While paused, the Rust engine stops feeding active progress ticks to the
listen session, so the tick baseline (`lastTickMs`) stayed frozen at the
pause point. The first progress tick after resume then computed its
wall-clock delta against that stale timestamp and billed the entire
paused span as listened time, inflating Player stats.

Settle the partial segment played up to the pause and mark the session
paused; the first resumed progress tick rebaselines instead of counting
the gap. Wire the freeze into the single `pause()` transport action.

* docs(changelog): note paused-time player-stats fix (#942)
2026-06-02 01:50:46 +03:00
cucadmuh a73e9c4436 docs(changelog): restore PR order in [1.47.0] sections (#940)
Reorder Added/Changed/Fixed blocks by ascending PR number per team changelog policy — several recent entries had been appended at section tops instead of the bottom.
2026-06-01 18:46:33 +03:00
cucadmuh 08b6aeeb17 fix(perf): reduce idle Rust CPU and stabilize Performance Probe overlay (#939)
* fix(perf): skip Performance Probe CPU snapshot poll on Windows

Windows has no Rust CPU/RSS sampler, but the probe still invoked
performance_cpu_snapshot every 2s when the modal or overlay pins were
active. Skip the IPC on unsupported platforms and only poll JS-side
metrics; do not start overlay polling for CPU/memory pins alone.

* fix(analysis): park backfill coordinator until Advanced is configured

#881 started run_coordinator_forever at app init with a 2s sleep even when
disabled, waking tokio on every platform for no work. Park on Notify instead;
wake on configure (enable/disable) and library sync-idle. Long sleeps use
select with wake so sync-idle can interrupt COMPLETED_RECHECK waits.

Investigation branch — not for merge until periodic CPU root cause is confirmed.

* fix(perf): stop probe overlay flicker on live poll updates

Publish CPU samples only after a valid jiffies baseline, skip no-op
snapshot emits, and sync sparkline history atomically in the store.
Overlay uses wall-clock sparkline time and auto-scales low CPU values.

* fix(perf): cut idle Rust CPU from probe scan, cover prefetch, and storage poll

Move performance_cpu_snapshot /proc work to spawn_blocking so tokio
workers are not charged with probe sampling. Stop lazy cover strategy
from running route prefetch disk stats every 1.5s, and slow hot-cache
size refresh on Settings → Storage to 15s.

* fix(perf): stabilize probe sparkline clock between live poll ticks

Track sampleAt separately from updatedAt so CPU rate history and overlay
sparklines only advance on real % changes, not FPS re-renders or RSS-only
poll ticks. Hold CPU sparkline Y scale with a peak ref to avoid scale jumps.

* fix(cover): restore lazy route prefetch without idle disk stats poll

Re-enable lazy cover registry warm-up so cached WebP paths reach
diskSrcCache before cells mount. Skip cover_cache_stats on every 1.5s
tick — drain batches via ensure only, poll full disk usage every 30s
when the registry is idle.

* docs: CHANGELOG and credits for PR #939 idle CPU perf fix

* fix(cover): peek before route prefetch ensure to match main responsiveness

Route prefetch moved batch drain ahead of cover_cache_stats for idle CPU,
which removed the accidental throttle and flooded ensure invoke slots.
Use warmCoverDiskSrcBatch first (cached hits skip ensure), ensure misses
only, and yield while high-priority viewport work is queued.
2026-06-01 15:50:17 +03:00
cucadmuh 4ac373a65b feat(search): scoped live search on browse pages (#938)
* feat(artists): scoped live search badge replaces page filter

Move Artists browse text search into the header Live Search with a page
scope badge (Users icon), field-local undo, and double-click/backspace
to clear scope. Block the live-search dropdown while scoped so results
only filter the Artists grid; mobile overlay follows the same rules.

* fix(artists): plain grid for scoped search fixes broken card layout

Route the browse grid through VirtualCardGrid, switch to non-virtual CSS
grid when live search filters the catalog, reset scroll on filter changes,
and skip content-visibility on plain tiles to avoid blank/black cards.

* fix(search): scope badge double-Backspace and single clear control

Require two Backspaces on an empty scoped field after prior text input;
one Backspace still clears the badge when the field was never filled.
Move live-search clear/advanced controls inside the field pill, drop the
extra outer clear button, and use type=text to avoid native search clears.

* fix(search): drop duplicate outer live-search clear button

Keep the native in-field clear on type=search and the original pill layout;
remove only the extra × control outside the search border. Reset dropdown
state when the query is cleared via the native control.

* refactor(search): generic scoped browse query helper, drop dead code

Rename artistsBrowseSearchQuery to scopedBrowseSearchQuery with an
expectedScope argument; wire Artists via useScopedBrowseSearchQuery.
Remove unused liveSearchScoped dropdown helper (scoped mode blocks it).

* feat(search): ghost scope badge and single-click badge remove

After clearing the artists scope on /artists, show a faded ghost chip to
restore page-only search while keeping the global search placeholder.
Active badge removes on one click; tooltips and styles updated.

* feat(search): scoped live search for All Albums and New Releases

Wire albums and newReleases scope badges with debounced album title search
(local index title-only FTS + filtered search3). Plain grid, scroll reset,
and session query restore on album grid browse pages.

* fix(browse): preserve scroll restore after album/artist detail back

Only reset in-page scroll when filter resetKey changes, not when
isScrollRestorePending clears after session restore.

* feat(search): scoped live search for Tracks browse

Wire /tracks to header live search with wide title/artist/album FTS,
hide hero and discovery rails while search is active, and remove the
inline search field from the browse list.

* fix(search): clear header query when leaving scoped browse pages

Prevent global live search from firing on album/detail routes after a
scoped browse query; browse session stashes still restore on back.

* fix(tracks): restore scroll after back from detail during scoped search

Hold stashed song results across fetchSongPage churn, defer leave-stash
teardown past AppShell scroll reset, restore tracks scroll after the list
is ready, and save scroll snapshot when opening artist from song context menu.

* fix(tracks): hide discovery headings during scoped search

Hide the page subtitle and "Browse all tracks" section title when
tracks search is active, matching hero/rails chrome behavior.

* feat(search): scoped live search for Composers browse

Wire /composers to header live search with composers scope badge,
session stash, scroll restore, and plain grid/list during text filter.
Remove the in-page filter input; add i18n and navigation helpers.

* docs: CHANGELOG and credits for scoped browse live search (PR #938)
2026-06-01 13:04:36 +03:00
cucadmuh ddf10ee01d feat(genres): local index genre browse with Subsonic fallback (#937)
* feat(genres): genre detail browse via local index with aligned counts

Move genre detail albums/play/shuffle onto the local library index with
Albums-style in-page scroll, session restore, and genre-scoped stash. Unify
genre album totals between the cloud and detail pages via
library_get_genre_album_counts, and fix grouped browse totals to count
distinct albums rather than matching tracks.

* perf(genres): local genre browse with scoped counts cache

Add dedicated Rust genre album pagination and indexes, slice-mode grid
loading, library-filter-aware counts, and a long-lived in-memory catalog
cache invalidated on sync so genre pages avoid repeated full-library SQL.

* fix(genres): restore scroll after album back; play hold-to-shuffle

Pin restore display count in refs so clearing the return stash no longer
reloads the genre grid mid-restore. Load the first SQL page only (60 rows),
use long-press on Play for shuffle, and add genre play tooltips.

* fix(genres): fall back to Subsonic byGenre when local index unavailable

Genre detail album grid now matches All Albums: try library_list_albums_by_genre
first, then getAlbumsByGenre when the index is off, not ready, or errors.

* docs: add CHANGELOG and credits for PR #937
2026-06-01 04:20:18 +03:00
cucadmuh d3e5a6b704 feat: library browse navigation — restore filters, scroll, and search on back (#936)
* feat(albums): restore scroll position when returning from album detail

Save in-page scroll and grid depth when opening an album from All Albums,
then on browser back restore filters, preload enough rows, and apply scroll
before revealing the grid to avoid a visible jump from the top.

* feat(albums): smart back navigation and restore browse session on return

Remember the originating route when opening album detail, restore All Albums
filters/scroll on back (including explicit returnTo navigation), hide the grid
until scroll is applied, and fix filters being cleared after albumBrowseRestore
state is stripped from the location.

* feat(search): restore Advanced Search session when returning from album

Stash filters and results when leaving /search/advanced for album detail,
then restore them on back navigation (POP or returnTo with advancedSearchRestore).

* feat(search): restore Advanced Search album row scroll on return from album

Save horizontal scrollLeft when opening an album from Advanced Search and
reapply it via AlbumRow on return; keep main viewport at top. Add snapshot
helpers and session stash fields; extend AlbumRow with restoreScrollLeft.

* feat(search): restore Advanced Search session scroll and artist return path

Save filters, main scroll, and album-row scroll when leaving to album or
artist; restore without flash via hidden-until-ready. Add useNavigateToArtist,
restoreMainViewportScroll helper, and AppShell scroll reset only on pathname change.

* feat(search): speed up Advanced Search back restore and year-only queries

Reveal the page right after sync scroll instead of blocking on full viewport
and album-row restore. Retry local index without the ready gate during sync;
use open-ended byYear params on network fallback, matching All Albums browse.

* feat(search): restore Advanced Search artist row scroll on back

Save leave snapshot when opening artist from ArtistCardLocal, persist
artistRowScrollLeft in session stash, and keep row restore targets in refs
so horizontal scroll survives finishLeaveRestoreUi like vertical scrollTop.

* feat(nav): route mouse back on album/artist detail like UI back

Trap history popstate when returnTo is set and call navigateAlbumDetailBack
so browser/mouse back restores browse/search session the same way as the header button.

* feat(artists): restore browse filters and scroll on back from artist detail

Persist Artists page filters, view settings, and vertical scroll when opening
an artist and returning via UI or mouse back, matching All Albums behavior.

* feat(search): unify quick and advanced search; fix LiveSearch dismiss on Enter

Serve /search and /search/advanced from one page with shared session restore
and scroll snapshot. Reset live search overlay state when navigating to full
search so the dropdown does not linger or reopen.

* feat(tracks): unify with search session and restore scroll on back

Route /tracks through AdvancedSearch with shared leave snapshot, song
browse stash, and main-viewport scroll restore when returning from album
or artist detail. Wait for hero/rails layout before applying scroll.

* refactor(search): rename AdvancedSearch page to SearchBrowsePage

The shared route shell serves /search, /search/advanced, and /tracks;
rename the page component and refresh stale file references in comments.

* feat(albums): restore New Releases and Random Albums on back from detail

Unify album grid leave-restore with surface-scoped session stash, live scroll
snapshot sync, and in-page scroll for Random Albums. Keep the same random
batch when returning from album detail; Refresh fetches anew and scrolls up.

* docs: add CHANGELOG and credits for PR #936
2026-06-01 03:11:27 +03:00
cucadmuh 77ecc8ddfe fix(perf): keep probe monitor metrics visible on Windows (#933)
Stop replacing the whole Monitor tab when CPU/RSS sampling is unsupported;
show pipeline, UI rate, and analysis sections with an inline platform note.
Also compute UI diagRates when the Rust snapshot returns supported: false.
2026-05-31 02:55:34 +03:00
cucadmuh fc7964fb07 fix(perf): use mach2 for macOS host CPU tick Mach ports (#932)
Replace deprecated libc mach_host_self/mach_task_self with mach2 APIs
while keeping host_processor_info on libc (no mach2 binding).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.com>
2026-05-31 02:41:56 +03:00
cucadmuh ea63b35396 fix(perf): use Mach API for macOS host CPU ticks (#931)
* fix(perf): use Mach host_processor_info for macOS CPU ticks

KERN_CP_TIME and CPUSTATES are not exposed by libc on Darwin; switch
read_host_total_cpu_ticks to host_processor_info so aarch64-apple-darwin CI builds succeed.

* docs: CHANGELOG and credits for macOS perf CI fix (PR #931)

* Revert "docs: CHANGELOG and credits for macOS perf CI fix (PR #931)"

This reverts commit a217217c34.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.com>
2026-05-31 01:12:18 +03:00
Frank Stellmacher 2a88ca3248 fix(queue): pin queueServerId on auto-add paths so infinite + radio top-up refs resolve (#930)
* fix(queue): extend server-pin contract to auto-add paths

The infinite-queue top-up and radio top-up paths in nextAction.ts
read state.queueServerId directly inside their set callbacks. When
the queue was populated without a queue-replacing playTrack (single-
track enqueue from a SongRow + button, AdvancedSearch row, etc),
queueServerId stayed null, seedQueueResolver skipped its store-write
under the if (serverId) guard, and the auto-added refs landed with
an empty server key. Every auto-added row rendered as the resolver
placeholder (… / 0:00) until the next time something happened to
bind the server. Same symptom PR #892 fixed for the manual enqueue
surface, just on the auto-add paths.

Extract ensureQueueServerPinned() from the private helper in
queueMutationActions.ts into playbackServer.ts so it can be shared.
Call it before every set callback that appends or splices refs in
nextAction.ts — appendTracksAndPlayFirst, proactive infinite top-up,
proactive radio top-up. Helper returns the pinned canonical key so
the caller does not need a second store read.

Regression coverage in ensureQueueServerPinned.test.ts: pin on null
+ active server, idempotent on already-bound, empty-string fallback
when no active server, canonical-key return value matches what
toQueueItemRefs expects (not the raw auth uuid). Existing
b1QueueServerIdentity.test.ts continues to cover the manual
enqueue surface unchanged.

* docs(release): CHANGELOG for queue auto-top-up placeholder fix (PR #930)
2026-05-30 23:04:14 +02:00
Frank Stellmacher ae1572f370 docs(linux): clarify AppImage is the X11/XWayland channel (#928)
After #731 the .deb/.rpm/Nix packages follow the session display server,
but AppImage still pins GDK_BACKEND=x11 via its AppRun hook. Document the
asymmetry in the install guidance and complete the #731 changelog entry so
users know which package gives a native-Wayland launch.
2026-05-30 18:39:44 +02:00
cucadmuh 59a3261f3f fix(ci): refresh npmDepsHash before app-v* tag (#927)
* fix(ci): refresh npmDepsHash on channel branch before app-v* tag

Promote workflows push with GITHUB_TOKEN, so nix-npm-deps-hash-sync never
runs on the finalize commit. verify-nix ran after create-release and opened
a PR, leaving app-v* tags pointing at commits with stale npmDepsHash.

Move Nix hash/lock refresh into prepare-nix-sources (before tagging), commit
directly to the channel branch, and build from the prepared commit SHA.

* docs(changelog): note npmDepsHash CI fix (PR #927)
2026-05-30 14:07:59 +03:00
Frank Stellmacher e734a8fc43 feat(genre): play, shuffle and queue buttons on the genre view (#926)
* feat(genre): add paginated songs-by-genre API

Wraps the Subsonic getSongsByGenre endpoint plus a fetchAllSongsByGenre
helper that paginates until exhausted, capped to keep the queue and the
burst of requests bounded for very large genres.

* refactor(playback): extract shared bulk play/shuffle/enqueue helper

A single fetchTracks-driven core (loading flag, empty guard, canonical
shuffleArray) so async detail-page play buttons stop growing divergent
copies. Artist detail now reuses it, dropping its weaker sort-random
shuffle.

* feat(genre): play, shuffle and queue buttons on the genre view

Header buttons load the genre's songs and start ordered or shuffled
playback, or append them to the queue. The slice is bounded to stay
within the queue resolver's cache budget so every row resolves instead
of rendering as a placeholder. Strings added across all nine locales.

* docs(changelog): genre play/shuffle buttons (#926)
2026-05-30 12:59:44 +02:00
Frank Stellmacher 6c74cae0b7 fix(ui): center button label text (#925)
* fix(ui): center button label text

.btn was inline-flex with align-items:center but no justify-content, so
buttons wider than their content (min-width / flex:1) rendered the label
left-aligned — visible on the Advanced Search button (min-width 100).

* docs(changelog): centered button label (#925)
2026-05-30 02:48:12 +02:00
Frank Stellmacher b8fee84cd5 fix(radio): show ICY track in OS media controls (#816) (#924)
* fix(radio): show ICY track in OS media controls (#816)

Internet radio streams through the WebView <audio> element, for which
WebKitGTK registers its own MPRIS player — the one Linux desktops show.
souvlaki metadata pushes were overridden by it, so the OS overlay only
ever showed the app name. Feed the resolved ICY/AzuraCast metadata to
that player via navigator.mediaSession (and mirror to souvlaki), so the
overlay updates per track. Falls back to the station name when a stream
sends no metadata.

* docs(changelog): radio track info in OS media controls (#924)
2026-05-30 02:19:06 +02:00
cucadmuh a0980379fa fix(deps): bump tar to 0.4.46 (GHSA-3pv8-6f4r-ffg2) (#923)
* fix(deps): bump tar to 0.4.46 (GHSA-3pv8-6f4r-ffg2)

Transitive dependency via tauri-plugin-updater; closes Dependabot alert #16.

* docs(release): CHANGELOG and credits for tar security bump (PR #923)

* revert: drop CHANGELOG and credits for tar security bump
2026-05-30 02:55:31 +03:00
Frank Stellmacher 1de2b0e850 feat(queue): switchable queue display mode (Queue vs Playlist) (#922)
* feat(queue): add queueDisplayMode setting with rehydrate (default queue)

* feat(queue): render upcoming-only or full timeline by mode, with header toggle

* feat(settings): add queue display mode toggle to Personalisation

* i18n: queue display mode strings across all locales

* test(queue): display-mode rendering and absolute index mapping

* docs: changelog + credits for queue display mode (#922)
2026-05-30 00:26:07 +02:00
cucadmuh 7b06be5ba2 ci: make hot-path coverage gates required PR checks (#921)
* ci: make hot-path coverage gates required PR checks

Remove continue-on-error from frontend and Rust coverage jobs now that
the hot-path lists have stabilized; update docs and script headers.

* docs: note hard coverage gates in changelog and credits (PR #921)

* chore: drop credits entry for CI-only PR #921

Contributor credits are for user-visible work, not infra toggles.
2026-05-30 01:04:35 +03:00
cucadmuh 5377f3b737 fix(deps): bump zip to 4.6.1 with backup API fix (#920)
Complete the Dependabot #910 bump: update Cargo.toml and migrate backup
archive writes to SimpleFileOptions (zip 4.x API). Fixes lockfile drift
where cargo downgraded zip back to 0.6.6 on every dev build.
2026-05-30 00:26:29 +03:00
cucadmuh a7d533d580 chore(library): squash pre-RC migrations into single 001_initial baseline (#919)
* chore(library): squash pre-RC migrations into single 001_initial baseline

Library SQLite never shipped in a release, so fold migrations 002–008 into
001_initial.sql and drop the dev-only mood-facts purge (009). Sets
LIBRARY_DB_SCHEMA_VERSION to 1 for the RC baseline; analysis migrations
unchanged.

* docs: note library migration squash in CHANGELOG and credits (PR #919)

* fix(library): keep migration SQL files on disk after RC baseline squash

Restore 002–009 as historical dev migration scripts. Runner still ships
only 001 for fresh installs; existing DBs with applied versions are
unchanged. Drop credits/CHANGELOG note for this small internal change.
2026-05-30 00:08:05 +03:00
Frank Stellmacher 293672abbf fix(queue): pin queueServerId on first enqueue so refs resolve (#892)
* fix(queue): pin queueServerId on first enqueue so refs resolve (thin-state)

Adding a single track from a page that doesn't replace the queue (Advanced
Search row, SongRow + button, SongCard) left queueServerId null whenever
the app had not yet seen a queue-replacing playTrack. seedIncoming then
became a no-op, the new refs landed with an empty server key, and the
queue panel rendered every row as the resolver placeholder ("…" / 0:00)
until the next time something happened to bind the server.

Add an ensureQueueServerPinned step at the entry of every add-to-queue
mutation (enqueue / enqueueAt / playNext / enqueueRadio). It runs after
blockCrossServerEnqueue so a guarded cross-server enqueue still bails
without touching the pin, and after the undo snapshot so undo restores the
pre-pin baseline. Idempotent: no-op when already pinned or when no active
server is available to pin (e.g. unit tests without an authed store).

Regression cluster in b1QueueServerIdentity.test.ts covers enqueue /
enqueueAt / enqueueRadio cache-hit after pin, the no-active-server
fallback, and the already-pinned no-op.

* docs(release): CHANGELOG for queue placeholder fix (PR #892)
2026-05-29 22:47:15 +02:00
cucadmuh c0d7079e88 chore(deps): restrict Dependabot to security updates only (#918)
Disable scheduled version-update PRs (open-pull-requests-limit: 0). Keep
grouped security PRs per ecosystem; symphonia migration ignores unchanged.
2026-05-29 23:19:18 +03:00
cucadmuh 5e5f395d1d chore(deps): batch npm bumps (wave 2) (#917)
* chore(deps): batch npm bumps (vite, lucide, zustand, react-virtual, @types/react)

Supersedes Dependabot #905–#907, #909, #911 in one PR to avoid lockfile conflicts.

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

* chore(ci): retrigger required checks

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-29 23:17:35 +03:00
dependabot[bot] f32fe514f1 chore(deps): bump zip from 0.6.6 to 4.6.1 in /src-tauri (#910)
Bumps [zip](https://github.com/zip-rs/zip2) from 0.6.6 to 4.6.1.
- [Release notes](https://github.com/zip-rs/zip2/releases)
- [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zip-rs/zip2/commits/v4.6.1)

---
updated-dependencies:
- dependency-name: zip
  dependency-version: 4.6.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 23:09:17 +03:00
dependabot[bot] 061b97cb68 chore(deps): bump serde_json from 1.0.149 to 1.0.150 in /src-tauri (#914)
Bumps [serde_json](https://github.com/serde-rs/json) from 1.0.149 to 1.0.150.
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.149...v1.0.150)

---
updated-dependencies:
- dependency-name: serde_json
  dependency-version: 1.0.150
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 23:07:03 +03:00
dependabot[bot] f7c32e6954 chore(deps): bump sysinfo from 0.38.4 to 0.39.3 in /src-tauri (#912)
Bumps [sysinfo](https://github.com/GuillaumeGomez/sysinfo) from 0.38.4 to 0.39.3.
- [Changelog](https://github.com/GuillaumeGomez/sysinfo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/GuillaumeGomez/sysinfo/compare/v0.38.4...v0.39.3)

---
updated-dependencies:
- dependency-name: sysinfo
  dependency-version: 0.39.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 23:06:59 +03:00
dependabot[bot] 794ddf966e chore(deps): bump zbus from 5.15.0 to 5.16.0 in /src-tauri (#908)
Bumps [zbus](https://github.com/z-galaxy/zbus) from 5.15.0 to 5.16.0.
- [Release notes](https://github.com/z-galaxy/zbus/releases)
- [Changelog](https://github.com/z-galaxy/zbus/blob/main/release-plz.toml)
- [Commits](https://github.com/z-galaxy/zbus/compare/zbus-5.15.0...zbus-5.16.0)

---
updated-dependencies:
- dependency-name: zbus
  dependency-version: 5.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 23:06:54 +03:00
cucadmuh 90f86d3f87 chore(deps): bump rusqlite to 0.40 workspace-wide (#916)
* chore(deps): bump rusqlite to 0.40 workspace-wide

Align root src-tauri and workspace crates on rusqlite 0.40 so libsqlite3-sys
resolves to a single version (fixes Dependabot #913 links conflict).

* chore(nix): refresh flake.lock for rustc 1.95 dev shell

libsqlite3-sys 0.38 (rusqlite 0.40) needs cfg_select; nixpkgs pin was on
rustc 1.94. Bump workspace MSRV to 1.95 to match.
2026-05-29 23:06:00 +03:00
cucadmuh ad53b3f2d6 chore(deps): batch npm bumps and Dependabot Symphonia ignore (#904)
* chore(deps): batch remaining Dependabot npm bumps and ignore Symphonia 0.6

Bump @vitejs/plugin-react, @tauri-apps/cli, react-router-dom, and vitest;
configure Dependabot to skip symphonia >=0.6 and adapter-libopus >=0.3 until
the coordinated migration tracked in workdocs.

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

* chore: retrigger CI for PR checks

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-29 21:21:54 +03:00
dependabot[bot] 5365c77048 chore(deps): bump react-dom from 19.2.5 to 19.2.6 (#894)
* chore(deps): bump react-dom from 19.2.5 to 19.2.6

Bumps [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) from 19.2.5 to 19.2.6.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.6/packages/react-dom)

---
updated-dependencies:
- dependency-name: react-dom
  dependency-version: 19.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Maxim Isaev <im@friclub.ru>
2026-05-29 21:08:40 +03:00
dependabot[bot] 949c4d8921 chore(deps): bump tauri from 2.11.1 to 2.11.2 in /src-tauri (#899)
Bumps [tauri](https://github.com/tauri-apps/tauri) from 2.11.1 to 2.11.2.
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.11.1...tauri-v2.11.2)

---
updated-dependencies:
- dependency-name: tauri
  dependency-version: 2.11.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 21:02:25 +03:00
dependabot[bot] 26e9e4e6d8 chore(deps): bump tauri-plugin-global-shortcut in /src-tauri (#901)
Bumps [tauri-plugin-global-shortcut](https://github.com/tauri-apps/plugins-workspace) from 2.3.1 to 2.3.2.
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/os-v2.3.1...os-v2.3.2)

---
updated-dependencies:
- dependency-name: tauri-plugin-global-shortcut
  dependency-version: 2.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 21:02:19 +03:00
dependabot[bot] cb1c255645 chore(deps): bump tokio from 1.52.2 to 1.52.3 in /src-tauri (#902)
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.52.2 to 1.52.3.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.52.2...tokio-1.52.3)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.52.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 21:02:14 +03:00
cucadmuh be32792f5d chore: add SECURITY.md and Dependabot config (#893)
Document private vulnerability reporting and enable weekly npm/Cargo
dependency update PRs; link CONTRIBUTING to the new security policy.
2026-05-29 20:22:33 +03:00
cucadmuh 8ea0308dba feat(perf): explicit toggle for live thread-group CPU polling (#891)
* feat(perf): explicit toggle for live thread-group CPU polling

Replace implicit thread-group collection (section open / pin) with a persisted
checkbox so Linux /proc scans run only when the user opts in for diagnosis.

Fix IPC: pass includeThreadGroups (camelCase) so Tauri maps the flag to Rust;
reset the CPU baseline when the option changes so thread % deltas are valid.

* docs: CHANGELOG and credits for PR #891

* fix(perf): gate CHILD_RESCAN_EVERY to Linux/macOS only

Avoid dead_code warning on Windows where perf child-PID rescan is unused.
2026-05-29 19:18:21 +03:00
cucadmuh 9925771a86 feat(browse,cover,perf): lazy catalogs, cover pipeline, and Performance Probe (#890)
* fix(cover): per-server cache stats and cover pipeline perf probe

Stop count_cached_cover_ids from borrowing sibling bucket counts so
Settings progress no longer attributes one server's disk cache to another.

Add cover pipeline queue stats (ui ensure queue, ui vs lib HTTP/WebP
semaphores) to Performance Probe overlay, with clearer ui/lib labels.

* fix(browse): stabilize in-page infinite scroll and cap cover memory caches

Extract useInpageScrollSentinel for album grids and song lists so sentinel
reconnects do not spam loadMore during scroll. Harden useAlbumBrowseData with
sync loading refs, tighter root margin, and hasMore termination when dedupe
adds nothing. Pause middle-priority cover work during SQL pagination and bound
diskSrc/resolve/ensure tail maps on long cold-cache sessions.

* refactor(browse): unify in-page infinite scroll hooks and sentinel UI

Extract shared transport (viewport ref, async pagination guards, client slice)
and InpageScrollSentinel so Albums, New Releases, Artists, and song lists
use one pagination pattern instead of duplicated IntersectionObserver wiring.

* fix(browse): prioritize album SQL pagination over cover ensures

Pause the entire webview ensure pump during grid page fetches, resume after
SQL settles, add cover-queue backpressure before load-more, and re-probe the
sentinel when pagination finishes so cold-cache scroll does not stall.

* fix(browse): unblock covers, SQL spawn_blocking, and pagination retry

Pair grid-pagination hold begin/end on stale fetches, resume the ensure pump
after SQL, retry load-more when the cover backlog drains while the sentinel
stays visible, and run album browse SQL on spawn_blocking so Tokio stays
responsive during library_advanced_search.

* feat(browse): All Albums client-slice scroll on local index (Artists-style)

Load the filtered catalog once from SQLite when the library index is ready,
then grow the visible grid with useClientSliceInfiniteScroll instead of
offset SQL pagination per scroll. Network-only servers keep page mode.

* fix(browse): lazy local catalog chunks instead of full 50k SQL fetch

All Albums slice mode now loads 200 albums first, shows the grid immediately,
then appends catalog chunks in the background as the user scrolls. Avoids the
blocking library_advanced_search that hung the app on large libraries.

* fix(browse): keep album covers loading during active grid scroll

Pass high ensure priority and the in-page scroll root to AlbumCard on All
Albums, stop pausing cover traffic for background catalog chunks, and never
trim high-priority ensure jobs from the queue during scroll bursts.

* fix(cover): viewport priority tiers and unstick ensure invoke pump

All Albums uses IO-driven high/middle instead of blanket high; release
only on unmount so scroll-ahead jobs are not dropped on reprioritize.
Ensure queue shares one Rust flight per cover id, attaches duplicate
waiters without consuming invoke slots, and times out wedged calls.
Warm the first viewport slice on large grids; acquire CPU permits before
spawn_blocking in cover_cache to avoid blocking-thread deadlocks.

* fix(cover): wire in-page scroll root on New Releases and Lossless grids

AlbumCard IO uses the same viewport id as VirtualCardGrid so cover
ensure priority tracks visible in-page rows like All Albums.

* fix(browse): lazy local artist catalog in 200-row chunks

Replace runLocalBrowseAllArtists bulk fetch with paginated local-index
chunks so large libraries do not hang on open; preserve text search,
starred, letter filter, and client-slice scroll behavior.

* feat(perf): add RSS and thread CPU groups to Performance Probe

Extend performance_cpu_snapshot with process RSS (psysonic + WebKit
children) and in-process thread CPU breakdown. Classify tokio-rt-worker
and tokio-* workers separately from glib, audio/pipewire, reqwest, and
other misc threads (Linux /proc only).

* feat(perf): redesign Performance Probe with tabs, pins, and overlay layout

Split the probe into Monitor (live metric cards, per-metric overlay pins,
corner and opacity controls) and Toggles (diagnostic tree). Share live
polling via perfLiveStore; label analysis/cover pipeline blocks in the HUD.

* feat(perf): overlay sparklines, macOS CPU/memory, and sync fixes

Add 1-minute pinned-metric sparklines with right-aligned growth and a shared
poll clock. Enable macOS performance snapshots via sysinfo. Fix overlay infinite
loop from unstable history snapshots, bar/sparkline tick jitter, and probe bar
rescale flicker.

* docs: CHANGELOG and credits for PR #890

* perf(probe): scoped CPU poll, adjustable interval, lazy thread groups

Read only psysonic + WebKit children instead of the full process table;
macOS uses sysctl host CPU and refreshes cached child PIDs. Add 0.5–10s
poll slider (default 2s). Collect /proc thread groups only when the
Monitor section is open or a thread metric is pinned.

* feat(perf): three-way overlay mode switch (off / FPS / pinned)

Add Monitor control for overlay visibility: hidden, FPS-only, or pinned
metrics from Monitor. Live CPU poll runs only in pinned mode with live pins.
2026-05-29 04:40:31 +03:00
Frank Stellmacher 839c438a6d fix(cover): sanitize server_index_key so Windows :port URLs work (#889)
* fix(cover): sanitize server_index_key on disk so Windows accepts ":port" URLs

`serverIndexKeyFromUrl` (frontend) strips the URL scheme and leaves the rest of
the host as the index key — for a Navidrome instance running on the default
`:4533` port that is `host:4533/...`. On Linux/macOS the `:` is fine as a path
segment; on Windows `CreateDirectory` rejects the whole path with
`ERROR_INVALID_NAME` (os error 123). Result: every `cover_cache_ensure` and
`cover_cache_peek_batch` rejected its promise and the album / now-playing /
mainstage / lightbox surfaces stayed blank. Empirically verified — switching
the active server to a colon-free reverse-proxy URL made the covers load again
without any other change.

Centralize the fix in a new `cover_server_dir(root, key)` helper next to the
existing `cover_entity_relative_dir`: it runs `sanitize_path_segment` on the
server key the same way kind/entity ids are already cleaned, so `host:4533`
becomes `host_4533` and embedded URL paths collapse into one flat bucket
instead of nested directories. Every call site that wants the server bucket —
`cover_dir`, `count_cached_cover_ids`, `dir_usage_for_server`, the clear-server
command, and `clear_cover_fetch_failures` in the backfill worker — now goes
through it.

The on-disk layout changes (no more colons, no more nested URL paths), so bump
`LAYOUT_STAMP` to `canonical-segment-v4`. The existing stamp-mismatch sweep at
startup wipes the legacy buckets — users with a previously-working
(colon-free) layout rebuild the cache lazily as they browse. Library, offline,
and hot-cache data are not touched.

Adds two unit tests covering the sanitization on `cover_server_dir` and the
`cover_dir` passthrough.

Follow-up to #878 (which introduced `cover_cache_layout.rs` with
`sanitize_path_segment` applied only to kind/entity_id).

* chore(windows): silence dead_code warnings in debug taskbar_win build

`lib.rs` gates `taskbar_win::init` on `cfg(not(debug_assertions))` (PR #866 —
debug runs alongside an installed release instance and must not fight it for
the taskbar subclass). The `update_taskbar_icon` command still ships in debug
and early-returns until `init` populates the COM/HWND atomics, so the
init-only helpers (icon HICONs, button IDs, subclass plumbing, `make_buttons`,
`subclass_proc`, `init` itself) all look unused — 14 dead_code warnings on
every Windows debug `cargo build`.

File-level `#![cfg_attr(debug_assertions, allow(dead_code))]` suppresses those
warnings only in the debug profile. Release builds keep the strict dead-code
check, so a real removal would still surface there.

* docs(release): CHANGELOG for windows cover-cache server-key fix (PR #889)
2026-05-28 23:13:27 +02:00
ImAsra ae2e123a14 feat: add long press to shuffle with a wave animation (#888)
* feat: add long press to shuffle with a wave animation to singnify how long to press

* refactor: long-press shuffle cleanup

Follow-up on the long-press shuffle PR: shared hook/overlay, playback parity,
pointer events, broader surface coverage, locales, and tests.

* docs: credit ImAsra for long-press album shuffle (PR #888)

Add CHANGELOG entry and Settings credits for the hold-to-shuffle play
interaction shipped in Psychotoxical/psysonic#888.

* fix: restore playAlbumShuffled and long-press hook wiring

The follow-up merge dropped playAlbumShuffled and reverted the shared
long-press hook in album play buttons, breaking tsc and vitest on PR #888.

---------

Co-authored-by: cucadmuh <49571317+cucadmuh@users.noreply.github.com>
2026-05-28 23:59:49 +03:00
cucadmuh 8443b3d4be fix(artist): align top-track covers with album grid cover path (#886)
* fix(artist): align top-track covers with album grid cover path

Top tracks now resolve album.id + album.coverArt like AlbumCard, use
the same useAlbumCoverRef/CoverArtImage dense pipeline, and batch-warm
covers on page load instead of a custom sparse resolver.

* docs: CHANGELOG and credits for PR #886 artist top-track covers

* fix(artist): match All Albums cover warm tier and prefetch on detail page

Warm top-track and discography covers at dense grid tier (140px) instead of
32px thumb tier so disk peek hits cached WebP. Register high-priority dense
prefetch like All Albums and ensure top-track cells at high priority so dense
defer-until-visible does not stall visible thumbs.

* fix(artist): satisfy tsc for top-track cover warm helpers

Use optional coverArt access in pushAlbumWarmRow and align album pick
types with topSongAlbumForCover (id + name + coverArt).
2026-05-28 22:47:38 +03:00
Frank Stellmacher 455aec4def feat(discord): show track title in Discord member list (configurable name template) (#885)
* feat(discord): override activity name in member list with track title (configurable template)

The Discord member list and the collapsed Rich Presence card display the
activity's `name` field next to the music icon. Without an override
Discord falls back to the registered application name ("Psysonic"), so
the line reads "Psysonic" while every track plays instead of the actual
track title that comparable players show.

Add a fourth user-configurable template `discordTemplateName` (Settings ->
Integrations -> Discord Rich Presence). The Rust side passes it through
`Activity::new().name(...)` when the rendered template is non-empty; an
empty template falls back to Discord's default application name. Default
template is "{title}".

Tauri boundary: additive optional `nameTemplate` field on the existing
`discord_update_presence` command. Existing call sites that omit it keep
working -- the Rust handler applies the same "{title}" fallback.

* i18n(settings): discord name template label in all locales

Adds the discordTemplateName label across en, de, fr, es, nl, nb, ro,
zh, ru -- shown next to the new "User list line (name)" template input
under Discord Rich Presence.

* docs(release): CHANGELOG and credits for discord name template (PR #885)
2026-05-28 20:32:41 +02:00
Frank Stellmacher 403979b35d feat(input): opt-in WebKitGTK focus repaint workaround for Linux (#342, #782) (#884)
* feat(input): opt-in WebKitGTK focus repaint workaround for Linux (#342, #782)

Some Linux users on WebKitGTK 2.50.x report a freeze when clicking
text fields: the field receives focus (right-click paste still works)
but the canvas never redraws until the window is resized. Likely a
layer-flush / composition scheduling bug in 2.50.x's Skia rendering
pipeline.

Off by default. When enabled in Settings -> System -> Behavior, every
input/textarea focus triggers a sync reflow read plus a one-frame
translateZ(0) toggle on the input's parent so WebKit re-evaluates the
layer tree. Side-effect: search-icon siblings flicker briefly on
focus -- accepted trade-off, only paid by users who opt in.

The toggle row is gated on IS_LINUX and sits next to the existing
Linux WebKitGTK options. The effect subscribes to the auth store and
re-attaches the focusin handler whenever the flag flips, so toggling
off cleanly removes the listener.

* i18n(settings): linux input repaint toggle in all locales

Adds linuxWebkitInputForceRepaint label + description across en, de,
fr, es, nl, nb, ro, zh, ru.

* docs(release): CHANGELOG and credits for linux input freeze workaround (PR #884)
2026-05-28 20:01:25 +02:00
Frank Stellmacher 9fa086c428 feat(server): dual server address (LAN + public) per profile (#880)
* feat(server): ServerProfile.alternateUrl + shareUsesLocalUrl

Additive optional fields on ServerProfile to back the dual-address work.
'alternateUrl' is the optional second endpoint (e.g. LAN counterpart of a
public 'url' or vice versa); 'shareUsesLocalUrl' chooses which of the two
goes into Orbit / entity / magic-string shares when both are set.

Pure schema change — no runtime consumers yet. Single-address profiles
keep the same persisted shape; the optional fields are simply absent.

* feat(server): serverEndpoint module with LAN classification + IPv6

Introduces a single home for connect-/share-layer URL utilities that
the dual-address work will build on:

  * normalizeServerBaseUrl(raw) — aligned with serverProfileBaseUrl
  * isLanUrl(url) — IPv4 (unchanged) + IPv6: ::1 loopback, fe80::/10
    link-local, fc00::/7 ULA, and IPv4-mapped (both dot-decimal and the
    URL-API-normalized hex form, since new URL() rewrites ::ffff:1.2.3.4
    to ::ffff:HHHH:HHHH)
  * allNormalizedAddresses(profile) — deduped [url, alternateUrl?]
  * serverAddressEndpoints(profile) — LAN-first ServerEndpoint[]

isLanUrl moves out of useConnectionStatus.ts (the hook now imports +
re-exports it for backward compatibility); OrbitStartModal points at
the new path directly. 38 unit tests cover the IPv4/IPv6 matrix,
dedupe, ordering, and edge cases.

* feat(server): pickReachableBaseUrl with in-memory connect cache

Adds the runtime connect layer on top of serverEndpoint:

  * pickReachableBaseUrl(profile) — sequential LAN-first ping via the
    existing pingWithCredentials; first OK wins.
  * ensureConnectUrlResolved(profile) — boot / switch / online-event
    entry point (same mechanism, intent-named).
  * In-memory cache keyed by profileId; sticky behaviour tries the
    cached endpoint first, falls back to the natural order on miss.
  * invalidateReachableEndpointCache(profileId?) — single-profile and
    whole-cache flushes for profile-edit / credentials-change / online.
  * getCachedConnectBaseUrl(profileId) — sync getter for getBaseUrl
    fallback path (next commit).

The cache is **session-only**, never persisted. Single-address profiles
keep the same shape (one endpoint, one ping). 9 new unit tests cover
single + dual address, LAN-first preference, fallthrough, unreachable +
stale-cache clear, sticky hit, and the two invalidate flavours.

* feat(server): getBaseUrl reads connect cache, falls back to primary url

Dual-address profiles need 'getBaseUrl' to return the runtime-probed
connect URL (LAN at home, public elsewhere), not the literal primary
'url'. To keep the sync getter shape that ~60 call sites depend on,
the store now reads 'getCachedConnectBaseUrl(server.id)' from the
serverEndpoint cache.

If no probe has run yet (very early boot, before switchActiveServer
populates the cache), it falls back to the normalized primary URL —
identical to today's behaviour. Single-address profiles see no
difference; their cache entry equals serverProfileBaseUrl(url).

* feat(server): switch + connection-status probe via ensureConnectUrlResolved

Both surfaces that previously called pingWithCredentials(server.url, ...)
directly now route through the dual-address connect layer:

  * switchActiveServer awaits ensureConnectUrlResolved(server), reads the
    identity (type/serverVersion/openSubsonic) off the returned ping, and
    hands probe.baseUrl (not server.url) to scheduleInstantMixProbeForServer
    so the AudioMuse probe also hits the reachable endpoint.
  * useConnectionStatus.check() does the same on every 120 s tick — sticky
    cache fast-paths the steady state, and a network change naturally
    flips the active endpoint without a manual retry.
  * useConnectionStatus.retry() and the 'online' event flush the cached
    entry for the active profile first, so the next probe starts from the
    natural LAN-first order instead of revalidating a stale URL.

Single-address profiles behave identically: one endpoint in the list,
one ping per check. No behaviour change for them.

* feat(server): PickReachableResult.ping + connectBaseUrlForServer helper

Two additive extensions to the serverEndpoint surface that the upcoming
CONNECT migrations all need:

  * 'ping: PingWithCredentialsResult' on the OK branch of
    PickReachableResult so callers (switchActiveServer,
    useConnectionStatus) can read type/serverVersion/openSubsonic from
    the same probe instead of issuing a second pingWithCredentials.
  * connectBaseUrlForServer(server) — sync getter for the connect URL
    of *any* saved profile (active or not). Reads the cache, falls back
    to normalized primary url on miss. Becomes the canonical helper
    for non-active-server HTTP traffic (apiForServer, stream URLs,
    cover fetches, library bind session).

Tests: pingOk() fixture now returns identity fields; the single
toEqual-asserting test in pickReachableBaseUrl became a guarded read
to keep equality concise while still asserting on the new ping field.

* feat(connect): apiForServer + stream URLs route via connectBaseUrlForServer

Non-active-server HTTP traffic (the apiForServer entry point, used by
QueuePanel cross-server cover fetches and share-paste resolution) and
the stream-URL builders previously read 'server.url' directly,
bypassing the dual-address connect cache. Both now go through
connectBaseUrlForServer, which serves the cached LAN/public endpoint
when one exists and falls back to the normalized primary url
otherwise.

buildStreamUrl(id) now uses the baseUrl it already pulled from
getBaseUrl() (which is connect-cache aware as of 2a6d8283) instead of
re-normalizing server.url — same value for single-address profiles,
correctly dual-address for the rest. Single-address callers see no
behaviour change.

* feat(connect): cover fetch + cover-cache ensure use connect URL

Both cover-fetch surfaces previously read 'scope.url' / 'server.url'
straight into HTTP requests, which would freeze the cover pipeline on
the primary URL even when a LAN endpoint was reachable:

  * buildCoverArtFetchUrl(ref, tier) — for the 'server' scope (queue
    rows cached against a non-active profile) and the 'playback' scope
    (cross-server playback) now resolves the connect URL via
    connectBaseUrlForServer before handing off to
    buildCoverArtUrlForServer.
  * ensureArgsFromRef in coverCache.ts — restBaseUrl for the Tauri
    'cover_cache_ensure' invoke now uses the cached connect URL for
    both 'server' and 'active'/'playback' scopes.

Scope.url itself remains the index-stable primary URL — that's what
storageKeys (INDEX) consume. Only the HTTP base shifts to the connect
endpoint, exactly the split the spec calls out.

* feat(connect): library bind + server-test probe via ensureConnectUrlResolved

Two more 'rebind / re-test an existing saved server' paths still pinged
the literal primary URL — both go through the dual-address connect
layer now:

  * bindIndexedServer (librarySession.ts): used to ping server.url then
    pass serverProfileBaseUrl(server) as the bind 'baseUrl' to Rust.
    Now: one ensureConnectUrlResolved call covers both — probe.baseUrl
    feeds librarySyncBindSession directly, so Rust's per-server library
    sync uses whichever endpoint actually answered. Drops the
    pingWithCredentials and serverProfileBaseUrl imports.
  * testConnection (ServersTab.tsx): same shape — probe via the connect
    layer, read identity from probe.ping, hand probe.baseUrl to
    scheduleInstantMixProbeForServer so the AudioMuse probe also hits
    the connect endpoint. Single-address profiles still ping once.

Add/edit save handlers (handleAddServer / handleEditServer) keep the
direct pingWithCredentials against the user-entered data.url — that
flow is the dual-address verify hook, which PR 2 extends.

* feat(server): serverShareBaseUrl — public-by-default share URL picker

Adds the share-layer companion to connectBaseUrlForServer. Different
intent: connect picks the reachable endpoint for HTTP, share picks the
URL that goes into Orbit invites / entity share payloads / queue share
links / magic strings — where a guest opening the link is not on the
host's LAN.

Logic per spec §5.1:
  * Single-address profile → that one address (normalized).
  * Both set, default flag → public.
  * Both set, shareUsesLocalUrl flag → local.
  * Edge cases (both LAN, both public, missing one of the two): fall
    back to the first endpoint in the list so the function is total.

Empty profile returns the normalized url (possibly empty) — defensive,
never throws. 7 unit tests pin all six branches plus the empty case.

Call-site migration (Orbit, copyEntityShareLink, QueuePanel, magic
string srv field, findServerIdForShareUrl, Orbit LAN warning) lands
in the next commits.

* feat(share): Orbit + entity + queue shares use serverShareBaseUrl

Four share-encoding surfaces previously read 'getBaseUrl()' (connect)
or the raw 'server.url' (primary) when embedding the host into outgoing
share links — both wrong for a dual-address profile:

  * copyEntityShareLink (track / album / artist / composer) — was
    getBaseUrl(); now reads serverShareBaseUrl(active). Guests opening
    the link are off-LAN, so public is the right default.
  * OrbitStartModal — was raw server?.url for both 'buildOrbitShareLink'
    and the LAN warning. Now goes through serverShareBaseUrl; the LAN
    warning correspondingly reflects the address the guest will see,
    not the host's primary.
  * OrbitSharePopover — same fix on the host-only share popover.
  * QueuePanel.handleCopyQueueShare — was getBaseUrl(); same migration.

Single-address profiles return exactly the same string from
serverShareBaseUrl as serverProfileBaseUrl(server.url) did before, so
no behaviour change for the common case. shareUsesLocalUrl flips the
default to LAN for the rare 'share into a LAN-only group' use; that
checkbox lands with the form UI in the next sub-phase.

findServerIdForShareUrl + paste-side share matchers are migrated in
the next commit (read-side of the same contract).

* feat(share): paste-side resolves dual-address profiles + connect URL

Read-side counterpart to the encode-side migration:

  * findServerIdForShareUrl(servers, shareSrv) now matches a profile
    when shareSrv normalizes to either profile.url OR profile.alternateUrl.
    Without this, a paste of a link generated against the host's LAN
    address (shareUsesLocalUrl=true) would fail to find the local saved
    profile even though it's the same server.
  * resolveSharedSong / resolveShareSearchAlbum / resolveShareSearchArtist
    in enqueueShareSearchPayload now hand connectBaseUrlForServer(lookup.server)
    to the *WithCredentials HTTP calls instead of the raw lookup.server.url.
    The looked-up profile may be dual-address; this routes the song / album /
    artist fetch through whichever endpoint is currently reachable.

Indirect consumers (shareServerOriginLabel, shareQueueServerContext) read
the same findServerIdForShareUrl, so no code change needed there — they
now match both addresses transparently.

* feat(verify): serverFingerprint + same-server verification

Pure-logic core of dual-address verify. Three exports:

  * fetchServerFingerprint(baseUrl, user, pass) — one ping (envelope
    'version' extracted alongside type/serverVersion/openSubsonic) plus
    four soft-fail optional calls in parallel (getMusicFolders, getUser,
    getLicense, getIndexes). Optional failures collapse to null fields,
    not whole-fingerprint failure. Subsonic-generic — never branches on
    type === 'navidrome'. indexesDigest is a hash of letter-count plus
    sorted first 20 artist ids, so two probes against the same library
    see the same digest without comparing full payloads.

  * compareFingerprints(a, b) — strict on the ping triple
    (type case-insensitive, serverVersion exact, openSubsonic boolean);
    envelope apiVersion informational only. Body signals counted only
    when both sides have a non-null value; empty musicFolders [] on both
    sides still counts as a matching signal. Result: 'match' (>=1 common
    signal all agreeing) / 'mismatch' (any common differs) /
    'insufficient' (0 common). No 'save anyway' for insufficient in v1.

  * verifySameServerEndpoints(profile, user, pass) — single-address
    short-circuits to ok:true (nothing to verify). Otherwise parallel
    fingerprint probes, then pairwise compare. Ping-fail on any
    endpoint reports the offending host for the UI.

20 unit tests cover the compare matrix (every body signal in every
direction), Navidrome- vs minimal-Subsonic-shape probes, ping-fail,
and all four verify outcomes (ok / unreachable / mismatch /
insufficient). HTTP mocked via vi.stubGlobal('fetch') + plain-object
Response shape (avoiding any Response-polyfill dependency).

* feat(boundary): resolve_host_addresses Tauri command + TS wrapper

Adds one additive invoke for dual-address form hints. Spec §9.1
+ contracts.md §6.

Rust side (src-tauri/src/lib_commands/app_api/network.rs):
  * #[tauri::command] resolve_host_addresses(hostname: String) ->
    Result<Vec<String>, String>
  * tokio::net::lookup_host with a port suffix (':0'); discards the
    port from each SocketAddr, dedupes addresses via HashSet.
  * strip_port helper handles 'host:port', 'ipv4:port',
    '[ipv6]:port', '[ipv6]' (no port), and bare 'ipv6' (left as-is
    for lookup_host to wrap). 7 unit tests cover each shape.
  * Lookup failure → Ok(vec![]) so a DNS hiccup doesn't surface as
    an error toast or block the save flow — form-hint only.

Frontend wrapper (src/api/network.ts):
  * resolveHostAddresses(hostname) — trims, invokes, swallows errors
    to an empty array so consumers get a clean total function.

§04 boundary note: additive command, no breaking change. Added to
the invoke_handler! generate_handler list in lib.rs at the natural
alphabetical-ish slot near migration_run.

* feat(form): AddServerForm — second optional address + share flag + DNS hint

UI side of dual-address. AddServerForm now carries the four new
moving parts spec §6 calls out:

  * Second address field ('Second address (optional)') under the
    primary URL. Placeholder flips to suggest the opposite kind
    of address based on the primary's LAN classification.
  * Contextual hint under the second field when it's empty —
    'add a public address for outside-home use' (primary is LAN)
    or 'add a local address for faster home access' (primary is
    public). Hint disappears once the field has content.
  * Two-LAN client-side check on submit: when both addresses
    classify as LAN, save is blocked with a toast. Reverse case
    (both public) intentionally allowed per spec §6.3.
  * shareUsesLocalUrl checkbox — visibility rule per spec §5.3:
    hidden until the second address has content; shows with the
    persisted value on edit; cleared when the user empties the
    second address before save.

DNS hint: on primary-URL blur we call the Tauri
resolve_host_addresses command and classify the response by
isLanUrl. Literal IPs skip DNS (already classified locally). DNS
miss → no hint surfaces (never blocks save). Used only for the
hint text — connect still goes through pingWithCredentials.

i18n: 9 new keys in en + ru (serverAlternateUrl*, shareUsesLocalUrl*,
serverBothLanError). Other locales fall back to English.

onSave signature widened to 'void | Promise<void>' — ServersTab /
Login both accept that already. The save flow itself still calls
the legacy pingWithCredentials path; verify wiring lands in 2e.

* feat(verify): wire verifySameServerEndpoints into add + dual-edit flows

Save flow now blocks persisting a dual-address profile that fails the
same-server check. Spec §6.4 + §7.4.

handleAddServer:
  * When data.alternateUrl is non-empty, runs verifySameServerEndpoints
    BEFORE the existing ping. mismatch / insufficient / unreachable each
    surface a localized toast and abort the save (no addServer call).
  * Single-address adds keep the legacy single-ping path — no extra
    network round-trip when there's nothing to verify.

handleEditServer:
  * Unconditional save remains the default — but if the edit either
    introduces / changes alternateUrl OR changes url / username / password
    while alternateUrl is set, verify runs first and may block.
  * After persist, invalidates the reachable-endpoint cache for this
    profile id so any sticky cached connect URL from before the edit is
    re-probed on the next access (credentials may have changed, alternate
    may have appeared).

i18n: 4 new toast keys in en + ru (dualAddressVerifying placeholder for
future progress UI, plus mismatch / insufficient / unreachable). Other
locales fall back to English.

announceVerifyResult helper keeps the toast routing in one place so
handleAddServer and handleEditServer share the same surface.

* i18n(settings): dual-address keys across all 9 locales

Adds the 13 new dual-address keys to the remaining 7 locales:
de · fr · es · nl · nb · ro · zh.

  * serverAlternateUrl + placeholderPublic / placeholderLocal
  * serverAlternateUrlHintAddPublic / HintAddLocal (contextual hints
    under the second-address field)
  * serverBothLanError (client-side two-LAN validation)
  * dualAddressVerifying / Mismatch / Insufficient / Unreachable
    (toast strings; Unreachable carries the {{host}} interpolation)
  * shareUsesLocalUrl + Desc (checkbox label + short description)

Placeholders (example.com URL / 192.168.1.100:4533) kept identical
across all locales — same shape the existing serverUrlPlaceholder
already uses.

Single coordinated sweep so every supported language ships dual-
address fully translated rather than falling back to English.

* feat(cover): cover_cache_rename_server_bucket Tauri command

Adds the disk-side companion to the upcoming URL-change remigration
flow (dual-server-address spec §8.3). When a user edits the primary
url so the derived index key changes, the SQLite migration already
re-tags rows via the existing migration_run command — this command
moves the cover-cache bucket on disk so cached WebP tiles stay
reachable under the new key.

Behaviour:
  * old_key == new_key → no-op.
  * Old bucket missing → no-op success (nothing to migrate).
  * New bucket missing → simple fs::rename (fastest path).
  * Both exist → recursive merge with 'prefer existing' on file
    collision (the newer bucket wins; data is never lost).
  * Emits 'cover:bucket-renamed' with {oldKey, newKey} on success
    so the frontend disk-src cache can invalidate stale URLs.

Sanitization: rejects empty, backslash, and '..' path segments at
the FS boundary. Forward slashes are legitimate (a Navidrome
mounted at a subpath like 'music.example.com/navidrome' produces
an index key with one); they're handled by Path::join.

4 unit tests cover key sanitization (accepts real keys, rejects
traversal/backslash) and the merge semantics (unique-file move +
prefer-existing on collision). Registered in lib.rs invoke handler.

* feat(remap): rewriteFrontendStoreKeysForRemap — explicit oldKey→newKey path

Adds the URL-change remigration entry point next to the existing
UUID→indexKey migration. Same plumbing (offline store, hot cache,
analysis-strategy maps) but driven by explicit { oldKey, newKey }[]
mappings instead of being derived from the current servers list.

Also folds in the player-side cleanup the existing path didn't have:
  * queueServerId — if currently bound to a remapped index key,
    repoint to the new one so playback continues through the rename
    instead of looking unbound.
  * analysisStrategyStore — runs the same map-key swap inline (the
    'migrateServerOverrides' helper handles UUID→indexKey, not
    indexKey→indexKey).

Same prefer-existing-on-collision semantics as the disk-side
cover_cache_rename_server_bucket — if a destination key already
carries data, we keep it. 8 unit tests cover no-ops, the four
store rewrites, the queue repoint, the 'leave other servers
untouched' guarantee, and the collision case.

* feat(remap): serverUrlRemigration orchestrator — 4-stage pipeline

Single-file orchestrator that runs the full URL-change remigration when
a profile edit shifts the primary url's derived index key. Spec §8 +
contracts.md §4.

Two exports:
  * indexKeyRemapForUrlChange(prev, next): IndexKeyRemap | null
    — short-circuits scheme-only edits ('http://x' ↔ 'https://x'),
      trailing-slash differences, and alternateUrl-only changes by
      normalizing both sides through serverIndexKeyFromUrl and
      returning null when they match. Empty urls also return null.
  * runIndexKeyRemigration(remap): Promise<IndexKeyRemigrationResult>
    — runs inspect → run → frontend-rewrite → cover-rename in order,
      aborts on the first failure and reports the offending stage.

Failure ordering rationale:
  * inspect / run failures stop the destructive step — DB is
    untouched, the caller can retry safely.
  * frontend rewrite swallows errors (best-effort; zustand persist
    catches up on rehydrate next session).
  * cover-rename failure is reported but does NOT roll back the DB
    rows (that would be even more destructive); covers under the
    old key recover via the existing cover backfill on next access.

10 tests cover the detect logic (5 cases including the path-suffix
case) plus all four pipeline stages with mocked Tauri invoke —
including verifying the legacyId/indexKey mapping shape lands on
both inspect and run calls.

* feat(remap): wire URL-change remigration into ServersTab edit flow

handleEditServer now detects a primary-url index-key change BEFORE
any other save logic (verify, persist, etc.) and orchestrates the
full remigration when one is needed.

Flow:
  * indexKeyRemapForUrlChange(prev, next) — null for scheme-only
    edits / alternateUrl-only edits / no-op edits, so the common case
    falls straight through without prompting the user.
  * On a real remap → confirm modal via useConfirmModalStore.request
    (danger style, plain-language oldKey → newKey copy, explicit
    'cannot be undone'). User cancel → abort save entirely.
  * On confirm → runIndexKeyRemigration pipeline. Failure surfaces a
    stage-specific toast (inspect / run / cover-rename — wording per
    spec §8.4 + the recoverability matrix in serverUrlRemigration.ts).
  * On success → fall through to the existing dual-address verify,
    then auth.updateServer + invalidate cache + ping (unchanged).

i18n: 7 new keys (urlRemigrationTitle / Message with oldKey + newKey
interpolation / Confirm / Progress / FailureInspect / FailureRun /
FailureCoverRename) — full sweep across all 9 locales (en, de, ru,
fr, es, nl, nb, ro, zh).

Test fixup: the offline-store collision test in rewriteFrontendStoreKeys
needed an 'as unknown as' cast — the existing OfflineTrackMeta type
doesn't carry the test-only marker property, and a one-step cast
TS rejected as not overlapping enough.

* feat(magic): magic-string v2 encode/decode with dual-address fields

Adds the v2 wire format for server invites. Spec §10 + contracts.md §5.2.

Encode is opportunistic: payloads with alternateUrl set OR
shareUsesLocalUrl=true emit v2; everything else stays v1 byte-identical.
This means single-address profiles keep producing exactly the same
invite they did before — older receivers can't tell the difference.

v2 shape: { v: 2, url, alt?, shareLocal?, u, w, n? }
  * url — the host's share URL (public by default; LAN if shareLocal),
    NOT necessarily the host's primary URL. Receiver treats it as the
    primary of the new profile (their own index key).
  * alt — the host's alternate address (the other half of the pair).
  * shareLocal — mirrors the host's shareUsesLocalUrl preference so
    onward shares from the receiver behave the same way.

Decode accepts both v1 and v2. v2-only fields are left undefined when
decoding a v1 payload (no '|| undefined' shim — kept absent so zustand
persist diffs stay clean). Defensive: an empty alt on a v2 invite
decodes as if the field were absent, never as ''.

Test updates: 'rejects a payload with the wrong version' now targets
v: 3 (v: 2 became valid). 7 new tests cover the v1/v2 wire-format
choice, both v2 round-trip shapes, the v1-decode-into-v2-fields-
undefined backward-compat case, and the empty-alt edge.

* feat(magic): wire magic-string v2 through invite-encode + paste consumers

Five surfaces now produce / accept v2 invites with the dual-address
fields end-to-end:

Encode side (admin → user invite):
  * MagicStringModal — looks up the saved profile that matches the
    serverUrl prop (primary OR alternateUrl normalize-match) and
    encodes through serverShareBaseUrl + alternateUrl + the share
    flag. Falls back to plain v1 for single-address profiles.
  * UserForm — same lookup + encode plumbing on the in-form
    'Save & get magic string' flow.

Decode side (paste invite into form):
  * AddServerForm useEffect (initialInvite from outer state) +
    handleMagicStringChange (live paste) both pull alternateUrl
    and shareUsesLocalUrl off the decoded payload into form state,
    so the second-address field + checkbox surface immediately on a
    v2 paste.
  * Login form state gains hidden alternateUrl + shareUsesLocalUrl
    fields (not user-editable — Login stays single-address by
    design); v2 paste / initial-invite both populate them so they
    persist with the new profile via addServer. handleQuickConnect
    likewise forwards the existing dual-address shape for
    already-saved profiles.
  * attemptConnect signature widened with the two optional fields;
    addServer / updateServer call sites conditionally include them
    only when alternateUrl is non-empty so single-address profiles
    keep their lean persisted shape.

Both encode helpers share the same magicPayloadAddressFields lookup
(MagicStringModal + UserForm) — the duplication is acceptable for
two co-located small files but a single shared helper would be the
natural place to consolidate if a third encoder appears.

* fix(form): magic-string submit forwards decoded alternateUrl + share flag

AddServerForm.submit's magic-string branch was forwarding only
name/url/username/password from the decoded payload, silently dropping
alternateUrl + shareUsesLocalUrl. A v2 invite pasted into the
magic-string field and saved would land in the store as a single-
address profile even though handleMagicStringChange had already
populated the dual-address fields into form state for display.

Now the magic branch picks the dual-address fields off the decoded
payload (same conditional spread as the non-magic branch a few lines
down) so v2 invites round-trip end-to-end through the form.

* fix(verify): extractUserId drops username fallback to avoid false mismatches

Spec §7.2 footnote: 'fallback (normalized username) only if no id on
either side'. The old extract unconditionally fell back to the
authenticated username whenever user.id was empty — but the
comparator can't tell username-fallback apart from a server-supplied
id. So a server pair where endpoint A returns user.id='42' and
endpoint B only returns user.username='frank' would land in compare
with userId values '42' vs 'frank' and report mismatch on what is
actually the same user on the same server.

Now extractUserId returns null when user.id is absent. Both sides
returning null means compareFingerprints skips userId as a common
signal (correct behaviour per the §7.3 'both sides have a value'
rule). One new test pins it; the call site drops the redundant
fallbackUsername argument.

* fix(remap): rewriteFrontendStoreKeysForRemap migrates coverStrategyStore

Spec §8.2 lists 'analysis/cover strategy maps' as targets of the
URL-change remigration. The For-Remap path covered analysis but
missed cover — a user-set cover strategy override (e.g. 'aggressive'
for one server) would silently drop when the user edited that
profile's primary URL, because the key tagged under the old index
key never made it to the new one.

Same shape as the analysis remap inline: prefer-existing on
collision (newer key wins), delete the legacy entry afterwards.
One new test pins the move; setup adds a fresh useCoverStrategyStore
reset between cases.

* fix(cover): is_safe_index_key rejects absolute paths + drive letters

Defense-in-depth gap in cover_cache_rename_server_bucket. The old
sanitizer only rejected backslashes and '..' segments — '/etc/passwd'
on Unix and 'C:\Windows' on Windows both passed. Path::join with an
absolute argument REPLACES the base path, so root.join('/etc/passwd')
on Unix would walk out of cover-cache entirely.

Real index keys never reach this state — they come out of
serverIndexKeyFromUrl which strips schemes and trailing slashes, so
no leading separator and no drive-letter prefix is ever produced.
But the comment promises 'defense in depth' and that defense is
worth completing.

Now rejects:
  * empty keys (would join to the cover-cache root itself)
  * leading '/' or '\'
  * Windows drive-letter prefix 'X:' (case-insensitive ASCII letter)
  * backslashes anywhere (separators are forward-slash only)
  * '..' segments after split('/')

One new Rust test covers all four cases.

* fix(connection): isLan badge reflects active endpoint, not primary url

useConnectionStatus.isLan was reading isLanUrl(server.url) — the
*primary* address — so a dual-address profile that had fallen over to
its public alternate kept advertising 'LAN' in the badge until the
user looked at the server URL itself. Spec call-site-checklist line
25: 'active endpoint kind for badge (LAN vs extern)'.

Now: ensureConnectUrlResolved returns probe.endpoint.kind on success;
the hook tracks the latest kind in local state and surfaces that.
Pre-first-probe fall-through keeps the old primary-URL classification
so the badge has something to render at mount time.

Three tests pin the three states (LAN-active after probe, public-
active after fallback, primary-fallback before first probe completes);
plus one for the online-event handler that invalidates the cache and
re-probes.

* fix(server): dedupe concurrent pickReachableBaseUrl calls for same profile

Multiple call sites probe on roughly the same beat — useConnectionStatus
120-s tick + online handler + initial mount, switchActiveServer,
bindIndexedServer, and ServersTab.testConnection. Two near-simultaneous
probes for the same profile both observed an empty cache, both pinged
every endpoint, and raced to write the sticky URL with last-write-wins.
On a dual-address profile the slower probe could stomp the correct
LAN sticky a millisecond after it was set.

Now: an in-flight Map<profileId, Promise<PickReachableResult>>; a
second call for the same id during an active probe returns the same
promise instead of starting a fresh one. The finally-clear keeps the
dedup window narrow (one settled probe → next call probes fresh).
invalidateReachableEndpointCache deliberately doesn't touch the
in-flight map — the racing probe's own finally will clean up.

Three tests pin it: two concurrent calls share one ping; a fresh
call after the previous settled does ping again; plus the existing
shareBaseUrl two-LAN-with-flag-on test + the two-public-default test
for completeness.

* refactor(magic): extract magicPayloadAddressFields to single shared helper

The lookup that turns a serverUrl into the v2-encode-ready
{ url, alternateUrl?, shareUsesLocalUrl? } shape was duplicated
byte-identically between MagicStringModal.tsx and UserForm.tsx —
both Navidrome admin user-mgmt encode sites. Workdocs §03 / §1a
'one source of truth for behavior'.

Now lives in src/utils/server/serverMagicString.ts as a named
export. Both call sites import + pass the current servers list
(via useAuthStore.getState().servers) — keeps the helper pure so
it's straight to unit-test if a third encoder ever appears.

* fix(cover): wire cover:bucket-renamed listener for URL-change remigration

cover_cache_rename_server_bucket emits cover:bucket-renamed on
successful rename / merge, but no frontend listener was wiring it in.
After a URL-change remigration the in-memory disk-src cache still
held entries pointing at `{root}/{oldKey}/…/.webp` paths the disk
no longer carries — the next read would serve a stale URL until
the entry naturally aged out.

New: forgetDiskSrcForServer(serverIndexKey) in diskSrcCache drops
every cover entry under one server key in one pass (the existing
forgetDiskSrcPrefix needs a non-empty coverArtId and can't blanket-
clear a server). useCoverArtBridge subscribes to cover:bucket-
renamed and calls it with oldKey.

Tests: forgetDiskSrcForServer happy path, empty-key defensive
no-op, no-match no-op; plus a regression-pin on the existing
forgetDiskSrcPrefix so the two helpers don't drift in semantics.

* test(dual-server): fill the high-impact coverage gaps from the branch review

Adds the tests the review identified as missing on the hot-path UI
flows + the partial-fail / alt-only edges:

  * AddServerForm.test.tsx (new) — five component tests:
    single-address save / dual-address save / two-LAN block-with-
    toast / v2 magic-string paste forwarding alt + share flag /
    edit-flow stripping alt+flag when the field is emptied.
  * Login.test.tsx (new) — v2 invite paste persists alternateUrl +
    shareUsesLocalUrl onto the saved profile; v1 invite leaves
    those fields undefined.
  * shareLink.test.ts — alternateUrl-only match for
    findServerIdForShareUrl (the dual-address paste case where the
    host shared the LAN URL via shareUsesLocalUrl=true); 'first hit
    wins' across two profiles where both match.
  * serverFingerprint.test.ts — partial-fail mix (folders+user ok,
    license+indexes rejected) so a Promise.allSettled regression
    wouldn't go silent.
  * cover_cache/mod.rs — rename_bucket_inner extracted as a
    testable FS-only helper (the Tauri command wrapper now just
    locks state + calls it + emits the event), plus six tests
    covering empty/unsafe keys, no-op-when-missing, no-op-when-
    equal, simple-rename, merge-with-prefer-existing.

Also: every test fixture that I authored on this branch using
'frank' / 'frank@example.com' as a stand-in username/email
swapped to 'tester' / 'tester@example.com' — keeping personal
names out of test data is the codebase convention (see
factories.ts) and is now a memory rule.

* fix(test): typed mock access for pingWithCredentials in useConnectionStatus

vi.mocked(...) returns the mock typed properly — bare .mock fails
tsc because the imported function signature isn't a vi.Mock at
import time.

* docs(changelog): dual server address (PR #880)

* fix(test): align diskSrcCache tests with main cover storage key API

After rebase onto main, forgetDiskSrcPrefix takes a CoverArtRef-shaped
argument and storage keys include cacheKind; merge coverDiskUrl tests
from main with dual-address forgetDiskSrcForServer coverage.
2026-05-28 14:36:25 +03:00
cucadmuh 091e61f7a5 fix(analysis): decode Opus in waveform and loudness pipeline (#883)
* fix(analysis): decode Opus in waveform and loudness pipeline

Playback already registered symphonia-adapter-libopus; the analysis crate
used the default Symphonia codec registry without Opus, so .opus tracks
failed at decoder creation. Mirror the audio codec registry, pass format
hints from file suffix and OggS sniffing, and thread hints through the CPU
seed queue.

* docs: CHANGELOG and credits for PR #883 (Opus analysis decode)
2026-05-28 13:07:02 +03:00
cucadmuh 8004ec559c fix(analysis): persist library backfill scan phase across coordinator ticks (#882)
* fix(analysis): persist backfill scan phase and cursor across coordinator ticks

Keep HashBpmGaps progress when the candidate SQL page is empty only because
id > cursor; store scan phase in the native worker so each tick does not
restart from Candidates and rescan the first ~10k ready tracks.

* docs: CHANGELOG and credits for PR #882 backfill scan phase fix

* fix(analysis): move backfill tests below production code for clippy

Clippy items_after_test_module requires all non-test items before mod tests.
2026-05-28 11:26:24 +03:00
cucadmuh b24a7fc5cb fix(analysis): native library backfill coordinator for advanced strategy (#881)
* feat(analysis): native library backfill coordinator for advanced strategy

Move advanced analytics scheduling from the webview loop into a Rust
background worker (configure + spawn_blocking batch/enqueue), matching
cover backfill. Scan hash+BPM gap tracks instead of the full library;
suppress low-priority analysis UI events; limit loudness refresh IPC to
the playback window.

* fix(analysis): flat backfill configure IPC and restore probe track-perf

Use flattened Tauri args like cover backfill so release/prod invoke works;
keep emitting analysis:track-perf for library low-priority work so Performance
Probe tpm/last-track stats update while waveform/enrichment UI events stay suppressed.

* chore(analysis): fix clippy and drop duplicate TS backfill policy

Allow too_many_arguments on library_analysis_backfill_configure for CI;
remove unused frontend batch API and TS watermark helpers now owned in Rust;
clarify analysis_emits_ui_events comment for low-priority track-perf.

* docs: CHANGELOG and credits for PR #881 native analysis backfill
2026-05-28 10:49:31 +03:00
cucadmuh df3533bb5a fix(cover): Windows thumbnails, tier fallback, PNG decode, coverArt id (#878)
* fix(cover): tier fallback for sparse surfaces and Windows asset URLs

Sparse UI (player bar, queue) now reads disk covers via the same tier
ladder as dense grids, so a warm 800.webp satisfies a 128px request.
Reject non-asset convertFileSrc results on Windows, widen Tauri asset
scope, and seed ladder keys on cover:tier-ready. applyDiskPath uses
seedGridDiskSrcCache only to avoid notify/subscriber infinite loops.

* fix(artist): top-track thumb uses album coverArt already warm in grid

Song coverArt ids often differ from album cover ids (e.g. Octastorium in
the grid vs empty track thumb). Prefer the album row's coverArt on artist
pages and ensure high priority for 32px dense cells.

* fix(cover): albumId for playback/queue; no broken img until disk URL ready

Prefer albumId over track-id coverArt (Navidrome). Wire queue to CoverArtImage
with playback scope. CoverArtImage renders a placeholder div until asset src
exists to avoid the browser broken-image icon.

* fix(test): add song id to resolveArtistPageSongCoverArtId fixture

Pick<SubsonicSong, …> requires id; fixes tsc in CI/build.

* fix(cover): resolve albumId for Now Playing and artist top tracks

Prefer albumId when album.coverArt echoes track id; use sparse surface
on artist suggestion thumbs; apply resolveSubsonicSongCoverArtId across
playback surfaces (Now Playing, fullscreen, mobile, mini).

* fix(cover): decode PNG from Subsonic before WebP tier encode

Enable `png` in the image crate — some servers return PNG cover art;
failed decode left `.fetch-failed` and empty thumbs for those albums.

* refactor(cover): consolidate cover id resolution and align tests

Move resolveSubsonicSongCoverArtId helpers to src/cover/resolveCoverArtId.ts
with resolvePlaybackTrackCoverArtId for player surfaces; co-locate tests;
fix FullscreenPlayer expectations for albumId-first resolution.

* docs: CHANGELOG and credits for PR #878

* fix(cover): keep per-track coverArt when distinct from song id

Address PR #878 review (b): albumId only when coverArt is missing or
echoes track id; pin case with unit test; comment isRawFsPath symmetry.

* chore(cover): address PR #878 review nits (scope, tests, rename)

Narrow asset scope to cover-cache dirs only; add diskSrcCache Windows-path
tests; rename ArtistTopTrackCover; CHANGELOG symptom-first wording.

* fix(cover): restore asset scope to app data dirs (Windows regression)

$APPDATA/cover-cache/** did not match Tauri scope resolution — covers
were blocked after load. Use $APPDATA/** and $APPLOCALDATA/** (no $DATA).

* fix(cover): Windows asset URLs — restore DATA scope, path normalize

Regression after review nits: dropped $DATA/** and strict isAssetProtocolUrl
blocked valid http://asset.localhost URLs on Windows. Normalize C:/ paths
before convertFileSrc; CoverArtImage/Hero hide broken img on load error.

* fix(cover): disk peek fallbacks when cache folder id differs

Small surfaces resolve albumId while cover-cache often stores WebP under
track id or album.coverArt from the grid. Peek batch now tries legacy ids;
playback scope resolves server index key by URL key, not UUID-only lookup.

* fix(cover): Navidrome al-* vs mf-* disk id mismatch

UI used mf-* coverArtId while library backfill only cached al-* folders.
Prefer album id for display/peek when coverArt is mf-*; backfill now
queues both distinct album_id and cover_art_id values.

* fix(cover): mf→al disk peek when mf folder missing in cache

Navidrome Subsonic often returns mf-* coverArtId while backfill only
creates al-* folders. Peek mf first, then al-* from hints; load albumId
from library when Subsonic omits it; ensure fallback uses al-* id.

* feat(cover): CoverArtRef, segment disk layout, library-index backfill

Normalize cover caching around stable entity ids from the local library
and Navidrome fetch ids. Disk paths live in psysonic_core::cover_cache_layout
(album/<entityId>/); UI uses CoverArtRef with cacheEntityId + fetchCoverArtId.

- Remove SQLite/mf peek helpers (diskPeekIds, peekCoverOnDisk, mergeDiskIdHints)
- Backfill reads album/artist rows from library SQLite (bare Navidrome ids ok)
- Use stored cover_art_id for HTTP; per-disc dirs only when discs differ
- Migrate call sites to albumCoverRef / albumCoverRefForPlayback

* feat(cover): central CoverEntry resolver (artist, album, track)

Add resolveEntry.ts and Rust CoverEntry helpers as the single source of
truth for cache_entity_id vs fetch_cover_art_id. ref.ts delegates to them;
resolveCoverArtId becomes a thin compatibility shim.

* feat(cover): resolve cover entries from local library index

Add library_resolve_cover_entry IPC and cover_resolve.rs so album,
artist, and track covers use SQLite cover_art_id + disc detection.
TypeScript helpers in resolveEntryLibrary.ts prefer the index over
live API fields when rows exist.

* feat(cover): library-first hooks for grids and playback UI

Add useAlbumCoverRef, useArtistCoverRef, useTrackCoverRef, and
usePlaybackTrackCoverRef — sync fallback then SQLite index upgrade.
Wire album/artist cards, album header, song card, and all player
surfaces to resolve covers from the local library when indexed.

* feat(cover): complete library-first migration across all UI surfaces

Add Album/Artist/TrackCoverArtImage, useLibraryCoverPrefetch, and batch
resolve helpers. Migrate grids, search, home, playback sidecars, warm
peek, playlists, and share flows to hooks that upgrade from SQLite.
Backfill normalizes album rows through cover_resolve; document paths in
COVER_PATHS.md. Radio remains a deliberate non-library exception.

* fix(cover): stop render loop from unstable serverScope in library hooks

Default param `{ kind: 'active' }` created a new object every render, so
every grid cell re-ran library_resolve IPC and setState in a loop. Use
COVER_SCOPE_ACTIVE singleton, coverScopeKey deps, and guarded sync updates.

* chore(cover): remove COVER_PATHS.md from app tree (lives in workdocs)

Audit doc is team spec — see workdocs 2026-05-cover-art-pipeline/cover-paths-audit.md.

* fix(cover): unstick library backfill after route changes (PR #870 regression)

useCoverNavigationPriority cleanup called beginNavigation instead of end,
leaking navigationHoldDepth so ui_priority_hold never released and backfill
never downloaded. Also skip disk check after cover_resolve normalization.

* fix(cover): segment progress, cap backfill CPU, include artists in catalog

Progress and disk size now scan album/ and artist/ segments (canonical 800.webp).
Prune legacy flat server/al-* dirs on startup and backfill pass.

Backfill: max 2 concurrent ensures; JPEG decode and WebP encode run on the
blocking pool behind a shared 2-permit semaphore so Tokio workers stay cool.

Artists were missing because the catalog only read the empty artist table;
add distinct artist_id from track and album rows. Paginate with a composite
(kind, id) cursor so album and artist rows are not skipped.

* fix(cover): drop legacy prune; backfill per-disc and artist catalog

Remove prune_legacy_* and cover_cache_catalog_entry — layout is only
cover_dir (album|artist segments); stale flat dirs clear on LAYOUT_STAMP change.

Backfill: artists from track/album artist_id; expand albums to per-CD mf-* slots
when discs differ; fix resolve_album_cover_entry when album row is missing.

* fix(cover): reduce library IPC storms and fix multi-disc player art

Skip per-row library_resolve on live search and artist album grids; warm
grids from API coverArt after mount instead of blocking layout. Dedupe and
cap concurrent library_resolve calls. Restore per-disc cache keys in the
player and queue when track mf-* art differs from the album bucket.

* fix(cover): skip library resolve on advanced and full search rows

Use API coverArt for album/artist rails and lazy viewport artwork so
result pages do not fire hundreds of library_resolve IPC calls at once.

* fix(cover): default libraryResolve off for browse grids and rails

Skip per-card library_resolve on album/artist/song browse UI by default;
keep it on album/artist headers, playback queue rows, and orbit approval.

* fix(cover): split UI/backfill CPU pools and restore mainstage hero carousel

Library backfill no longer shares the 2-permit JPEG/WebP semaphore with
visible cover ensures. Hero initializes albums from props, re-binds scroll
visibility after mount, updates backdrop on slide change, and uses library
resolve for correct cover art on the banner.

* fix(analysis): resume full-library scan after candidates phase

Reset the SQL cursor when entering full-library mode so tracks with
partial analysis are not skipped. Tighten TS backfill completion and
CPU queue watermarking; align cover-cache key tests with album-scoped
storage keys.

* fix(library): remove useless map_err in cover_resolve (clippy)

CI treats clippy::useless-conversion as error on rusqlite optional() chains.

* fix(cover): satisfy clippy on cover_cache_ensure IPC args

Pass CoverCacheEnsureArgs as a single Tauri parameter instead of nine
positional fields; align frontend invoke payload with { args }.
2026-05-28 03:15:08 +03:00
cucadmuh ee5068c98c feat(artist): sort albums by year on artist detail (#877)
* feat(artist): year sort for albums section on artist detail

Add a sort dropdown next to "Albums by …" with release-type grouping (default),
newest-first, and oldest-first by album year.

* chore: note PR #877 in CHANGELOG and settings credits

* fix(artist): toggle year sort inside release groups, session per server

Replace dropdown with a click-to-toggle newest/oldest button. Keep release-type
blocks; sort albums by year within each group. Persist order in session store.
2026-05-27 13:04:00 +03:00
cucadmuh 06da15caf3 feat(albums): combined browse filters, favorites reconcile, and session restore (#876)
* feat(albums): persist browse sort and genre filter for the session

Keep Albums sort and genre selection in an in-memory Zustand store so
navigating into album detail and back no longer resets browse context.
Fixes #875 (partial).

* feat(albums): restore browse filters only when returning from album detail

Keep sort in the session store for the app lifetime. Stash genre, year,
compilation, starred, and lossless filters when leaving Albums for an
album page and restore them on POP (back). Clear the stash when opening
Albums from elsewhere via sidebar navigation.

* feat(albums): filter quick-clear chips; fix lossless A–Z sort

Add inline × on active toolbar filters (genre, year, favorites, lossless,
compilations) without opening the popover. Route lossless album browse through
advanced search with album sort clauses on Albums and Lossless Albums; client-sort
on the network fallback path.

* fix(albums): apply year filter when only from or to is set

Resolve open-ended year bounds with gte/lte on the local index and partial
fromYear/toYear on Subsonic. Update the year filter chip label for single-bound
ranges.

* refactor(albums): combine browse filters in one query (genre + year + lossless)

Replace mutually exclusive load/loadFiltered branches with fetchAlbumBrowsePage
that ANDs server-side filters on the local index (genre OR union). Network
fallback applies year bounds after genre fetch. Always show sort while a year
filter is active.

* fix(albums): load favorites filter server-side instead of scanning all albums

Starred on Albums was client-only: each page was filtered locally and
pendingClientFilterMatch kept paginating the full catalog. Query starred
albums via the local index or getAlbumList(starred); apply overrides only
for in-session star/unstar.

* feat(library): local album/artist favorites via patch-on-use

Mirror album- and artist-level stars into the library index (library_patch_album,
library_patch_artist, migration 010). Albums and Artists favorites browse use
entity starred_at only; normal album catalog stays track-derived so patch stubs
do not hide the library. Keep album year on favorite cards via track COALESCE,
patch metadata, and safer raw_json merge.

* fix(library): reconcile album/artist stars from server, drop stubs

Favorites browse uses getAlbumList/getStarred2 as source of truth.
library_reconcile_*_stars clears local stars removed elsewhere; patch-on-use
updates existing rows only (no stub INSERT). Reconcile on favorites load and
after star/unstar in-app.

* feat(albums): favorites reconcile, filter combos, and back-navigation fix

Album browse keeps filter state when returning from album detail (POP stash
read on mount, request-generation guard against stale loads). Favorites use
getStarred2 as source of truth: reconcile album.starred_at in the local index
(UPDATE only, no stub rows), with a small session cache for instant paint.

Combine favorites with lossless or genre via restrictAlbumIds in advanced
search. Remove album/artist patch-on-use and migration 010; artist favorites
stay network-only. Track patch-on-use unchanged.

* feat(albums): catalog year bounds and genre list narrowed by filters

Year filter spinners use min/max years from the local track index (not
1900); "from" starts at oldest, "to" at newest, values clamp to catalog.

When year, lossless, favorites, or compilation filters are active, the genre
picker lists only genres present on matching albums (other filters applied,
genre excluded). Adds library_get_catalog_year_bounds for the year UI.

* feat(albums): debounce year filter and show genre album counts

Debounce year range changes by 350ms before reloading browse. Genre picker
lists album counts per genre (from getGenres or from albums matching other
active filters) and sorts genres by count descending.

* fix(albums): compilation filter detection and scan cap

Recognize OpenSubsonic compilation flags (compilation, releaseTypes) so
client-side comp filters work on local index rows. Cap background pagination
at 500 albums when no matches are visible and show empty state instead of
spinning through the whole catalog.

* feat(albums): filter compilations via local library index

Add `compilation` to advanced search (album entity): reads OpenSubsonic
flags from album raw_json. Album browse passes compFilter into
library_advanced_search when the index is ready; network-only path keeps
client-side filtering with the existing scan cap.

* fix(albums): apply compilation filter on track-grouped index browse

Album browse uses track aggregation, so compilation clauses were skipped.
Filter track raw_json (same SQL as album), merge album flags at sync, and
always run the client-side compilation pass as a fallback.

* refactor(albums): split browse modules and extract browse_support commands

Move album browse fetch/filter logic into focused modules and useAlbumBrowseData;
register reconcile/year-bounds Tauri commands from browse_support. Trim dead helpers
and barrel exports; fix typecheck in compilation tests.

* chore: note PR #876 in CHANGELOG and settings credits

* fix(albums): show catalog min/max in partial year filter chip label

When only from or to year is set, the active chip now reads e.g. 1990–2020
instead of 1990– or –2025, using indexed catalog bounds when available.
2026-05-27 12:32:20 +03:00
cucadmuh fab6ff19bf fix(home): Discover Songs covers for local-index tracks (#874)
* fix(home): pre-warm and prefetch Discover Songs row covers

The Discover Songs raблин. il came out of the cover pipeline merge with two
gaps that left its cards stuck on the placeholder disc icon on cold
caches.

- `warmHomeMainstageCovers` walked `heroAlbums` / `recent` / `random`
  through `ensureAlbumCoverMisses` + `predecodeWarmAlbums` but skipped
  `discoverSongs`, so songs that were peeked but missed on disk had to
  wait for lazy per-card ensure
- `Home.tsx`'s `coverPrefetchRegister` lumped `songRefs` into a
  `cappedRest` slice already saturated by 48 album refs + 16 artist
  refs at a 24-entry cap, so the song row's background prefetch was
  discarded entirely

Fix: ensure + decode-warm the Discover Songs cells alongside the album
rails, and register the song refs in their own bucket with a sane cap
and `middle` priority. Both follow the same shape as the working album
rails — no behavior change for surfaces that were already painting.

* fix(library): resolve track cover art from albumId for local index songs

Discover Songs uses runLocalRandomSongs; trackToSong only mapped coverArtId,
so rows with empty cover_art_id but a valid album_id showed the disc
placeholder. Mirror Rust COALESCE(cover_art_id, album_id) and Live Search's
coverArt ?? albumId in trackToSong, SongCard, and Home prefetch.

* docs(release): note Discover Songs cover fix in CHANGELOG and credits (PR #874)

* docs(release): credit PR #874 to Psychotoxical and cucadmuh jointly

* chore(credits): drop PR #874 from settingsCredits — minor fix

---------

Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
2026-05-27 08:49:06 +03:00
Frank Stellmacher d353482ac5 fix(analysis): cap HTTP backfill on CPU-seed pipeline load (#873)
* fix(analysis): cap HTTP backfill on CPU-seed pipeline load

Aggressive library analytics with multiple workers grew RAM unbounded:
HTTP downloads finish in ~500 ms, but Symphonia decode + R128 loudness
take seconds, so decoded `Vec<u8>` track buffers piled up in the
CPU-seed queue while the HTTP worker kept fetching. On large libraries
the process eventually saturated memory and forced the system into swap.

Backpressure: the HTTP backfill worker now checks the CPU-seed pipeline
depth (queued + running) against a `workers * 2` cap before popping the
next job. High-priority (now-playing) jobs bypass the cap so playback
prefetch is never starved. The CPU-seed worker pings the HTTP queue
after every completed decode, so the gate releases the instant decode
catches up.

The frontend backfill loop already idles at its own watermark when the
HTTP queue is satisfied — this change keeps the pipeline self-limiting
end-to-end even when callers (library top-up, playlist enqueue) submit
faster than decode drains.

Tests cover cap scaling, the floor for workers=1, the idle decision,
and the high-priority bypass.

* docs(changelog): analytics aggressive scan no longer eats memory (#873)
2026-05-27 00:21:51 +02:00
Frank Stellmacher 45b9229ceb refactor(queue): thin-state refs as canonical, full Track via resolver (#872)
* refactor(queue): wire queue UI to the track resolver (thin-state phase 3)

cucadmuh's phase-3 steps:
- Selectors (useQueueTracks) read resolver-first: getCachedTrack → queue: Track[]
  fallback (until phase 4), F4 star/rating overrides merged on read.
- QueueList rows source their track from the resolver (queue fallback); rows show
  title/artist/duration only, so no override merge there.
- pendingStarSync star/rating success → invalidateQueueResolver so the cache
  reflects the synced value.
- queueResolverBridge re-seeds on queueIndex change too — the prefetch window
  travels with the playing track.

Additive: queue: Track[] stays canonical and behaviour is unchanged (rows
resolve to the same data). Phase 4 drops queue: Track[] and the fallbacks.

* docs(changelog): queue panel reads through track cache (#860)

* fix(queue): stop a render loop that froze the UI on long queues

A long virtualized queue + a track change could lock the WebView for ~2 min:
- useVirtualizer was handed a fresh `initialRect` object literal every render, so
  it kept re-initializing in a loop. Hoisted it to a stable module constant.
- getCachedTrack did an LRU bump (Map delete+set) during render — a render-time
  side effect. Made it a pure read; recency is set at write time in cacheSet.

* perf(mobile): virtualize the mobile player queue drawer

The mobile now-playing queue drawer rendered the full queue with .map; a
multi-thousand-track queue meant thousands of DOM nodes. Virtualize it with
@tanstack/react-virtual (uniform rows, stable initialRect) so the DOM stays at
O(visible rows), matching the desktop QueuePanel. Active track is centred on open
via scrollToIndex.

* perf(mini): virtualize the mini-player queue list

The mini-player queue rendered the full MiniSyncPayload queue with .map.
Virtualize it against the OverlayScrollArea viewport (stable initialRect) so the
mini window's DOM stays at O(visible rows). Drag-reorder is preserved: rows keep
data-mq-idx alongside the virtualizer's measureElement.

* refactor(queue): add resolveQueueTrack/getQueueTracksView helper (thin-state phase 4)

Render-safe ref→Track view for the phase-4 consumer migration off queue: Track[].
Resolver cache → caller fallback (legacy queue[idx] during dual-write) →
placeholder; ref queue-only flags carried, F4 overrides merged. Pure synchronous
read, no cache mutation (the freeze landmine), so it is safe in render.

* refactor(queue): keep queueItems as the canonical in-memory mirror (thin-state phase 4)

Step 1b: dual-write the thin queueItems ref list at every queue write site
(the 11 mutations, next/radio top-up, playTrack, undo/redo restore, instant-mix,
radio, server-queue init, lucky-mix rollback, and hydrate) so it tracks
queue: Track[] in memory, not only at persist time. Identity-preserving maps
(star/rating overrides) keep the same refs and are intentionally left untouched.

Resolves the restore double-role flagged for 1b: queueItemsIndex is now the
restore-pending sentinel that gates hydrateQueueFromIndex, while queueItems
stays canonical -- rebuilt from the whole queue after a full hydrate instead of
cleared. Normal mutations never set the sentinel, so it only fires on a fresh
cold-start restore, not on later server switches.

No behaviour change; queue: Track[] stays the source consumers read until
phase 3. tsc + full vitest suite (1119 tests) green.

* refactor(queue): mobile queue drawer reads through the track resolver (thin-state phase 4)

Step 2: the mobile now-playing queue drawer resolves each row's track from the
resolver cache (→ queue: Track[] fallback until phase 4), matching the desktop
QueueList wired in the phase-3 commit. Subscribes to the resolver version so
rows re-render as the cache fills. Structure (count, order, keys, the playTrack
arg) still comes from queue: Track[] until it is dropped in the final step.

The mobile drawer was the last queue display surface still reading track
metadata straight off the fat queue. tsc + full vitest suite green.

* refactor(queue): ref-native queue mutations + dual-write bridge (thin-state phase 4)

Step 3a: the 11 queueMutationActions now splice/filter/reorder QueueItemRef[]
(matching by trackId + the ref's queue-only flags) instead of Track[].
`bridgeQueueFromItems` rebuilds the dual-written queue: Track[] from the new
refs by id — purely structural (no resolver/override merge), so behaviour is
byte-identical and playerStore.queue.test.ts stays unchanged green. The working
ref list comes from `itemsOf(state)` (derived from queue: Track[] for now); the
final step swaps that one line to state.queueItems once the fat queue is gone.

enqueue / enqueueAt / enqueueRadio seed the resolver cache with incoming tracks
(seed-before-splice) so they resolve without a network round-trip after the fat
queue is dropped. Adds a DEV-only id-parity guardrail (queue vs queueItems);
dev-runtime only, silent in vitest and prod.

tsc + full vitest suite (1119) green; contract test unchanged.

* refactor(queue): ref-native radio/infinite top-ups (thin-state phase 4)

Step 3b: nextAction's proactive infinite-queue and radio top-ups build the new
queue as QueueItemRef[] and bridge back to queue: Track[] (same as the queue
mutations), and seed the resolver cache with the freshly fetched tracks so they
resolve without a network round-trip after the fat queue is dropped. The radio
top-up keeps its HISTORY_KEEP front-trim, now expressed on refs.

The exhausted-queue refill paths hand their new queue to playTrack, which keeps
its fat-queue handling until the final step (its no-arg case needs the resolver-
derived queue that lands with the queue: Track[] removal). tsc + full vitest
(1119) green; contract test unchanged.

* refactor(queue): undo snapshots store thin refs, not Track[] (thin-state phase 4)

Step 4: QueueUndoSnapshot.queue: Track[] becomes queueItems: QueueItemRef[],
killing the undo "hidden multiplier" — 32 snapshots of a 50k queue now cost
refs, not 32×50k full tracks. applyQueueHistorySnapshot rebuilds the display
queue from the refs via resolveQueueTrack: resolver cache → the live queue by id
(covers tracks the edit didn't remove) → placeholder. currentTrack stays a full
track in the snapshot and is restored to the engine unchanged.

The snapshot refs derive from queue: Track[] for now (so the undo/redo contract
cases, which seed only `queue`, stay green); the final step swaps that to
[...s.queueItems]. tsc + full vitest suite (1119) green.

* perf(mini): cap the mini-player queue snapshot to ±100 around the current track (thin-state phase 4)

Step 5: the mini bridge no longer serializes the full queue over IPC on every
push — a 50k Artist-Radio queue would otherwise re-encode in full on every track
advance. snapshot() sends a window of 100 tracks before/after the playing song;
queueIndex is made slice-relative. The mini component stays unchanged (slice-
relative); jump/reorder/remove control events are translated back to absolute
queue indices via the window offset captured on the last push.

tsc + full vitest suite (1119) green. Mini bridge has no unit tests — needs a
quick mini-player smoke (queue shows ±100, jump/reorder/remove land correctly).

* refactor(queue): make queueItems a required PlayerState field (thin-state phase 4)

Foundation for the final consumer migration off queue: Track[]: queueItems has
been written at every queue write site since phase 1b, so promoting it from
optional to required is a no-op at runtime (tsc confirms zero new errors) and
lets the upcoming reader migrations read state.queueItems without `?? []` noise.

* refactor(queue): migrate structural queue readers off queue: Track[] (thin-state phase 4)

First reader batch toward dropping queue: Track[]: the queue-length selectors
(usePlaybackServerId, usePlaybackCoverArt, useQueuePanelDrag, useMiniQueueDrag)
now read state.queueItems.length, and FullscreenPlayer's next-track cover prefetch
resolves through useQueueTrackAt instead of indexing the fat queue. All behaviour-
identical during dual-write (queueItems is in lockstep with queue). tsc + full
vitest suite (1119) green.

Note: getPlaybackServerId() (playbackServer.ts) deliberately stays on queue for
now — it is called from many partially-mocked test stores, so it migrates with
the final field removal where the seedQueue helper covers those tests.

* refactor(queue): QueuePanel save/share/playlist read queueItems (thin-state phase 4)

The id/length reads (save to playlist, share link, create playlist, empty-queue
guards, next-tracks divider) now read state.queueItems instead of the fat queue.
Behaviour-identical during dual-write; queue: Track[] stays for the rendered
QueueList + auto-scroll until the field is dropped. tsc + full suite (1119) green.

* refactor(queue): drop queue: Track[] — thin queueItems is the only queue (thin-state phase 4)

The store no longer holds the fat queue. `queueItems: QueueItemRef[]` is the sole
canonical queue; full `Track`s resolve on demand via the resolver (index batch →
getSong fallback, bounded LRU cache); only `currentTrack` stays a full Track. At
50k tracks the store holds ~hundreds of resolved tracks + the refs, not 50k Track
objects.

- **Persist:** partialize is refs-only (no windowed slice / PERSIST_QUEUE_HALF).
  A `merge` migrates every historical blob shape → `queueItems` (existing
  `queueItems` → legacy `queueRefs` → pre-ref windowed `queue: Track[]`) and drops
  the obsolete `queue` key, so saved queues survive the upgrade.
- **Restore (decision B):** `hydrateQueueFromIndex` eager-resolves the whole
  ref list into the cache on cold start (index → getSong, so an index-off queue
  still plays), clears the restore sentinel.
- **Resolver bridge:** keeps `[idx-50, idx+200]` warm via `resolveVisibleRange`.
- **Mutations / actions / playback:** operate on refs; the playing track is
  `currentTrack`, the next/neighbour tracks resolve from the cache. Navigation
  (next/previous/row-jump) keeps `queueItems` and only moves the index — no full
  resolve or queue rebuild per track change.
- **Persist tests** cover the three old-blob migrations; `seedQueue` test helper
  replaces the `setState({ queue })` seeds.

tsc + full vitest suite (1115) green. Behaviour-preserving by the test contract;
the gapless track change + cold-start restore + mini cap still want a live smoke
before merge.

* fix(queue): star/rating keeps the queue row resolved instead of blanking to "…" (thin-state)

Rating/starring a queue song flashed the row's title to the "…" placeholder
until the next track change. Root cause: on sync success pendingStarSync called
invalidateQueueResolver, which DROPPED the cached track — and with queue: Track[]
gone there's no fat fallback, so the row resolved to a placeholder until the
resolver bridge re-fetched the window.

Fix: add patchCachedTrack(trackId, patch) and use it on star/rating success to
update the cached entry in place (title kept, synced starred/userRating applied)
instead of dropping it. No placeholder flash, no re-fetch.

tsc + full vitest suite (1115) green.

* fix(player): quota-safe persist so a full localStorage can't kill playback

A very large queue (~50k refs) overflows the localStorage quota; the persist
write then threw QuotaExceededError from inside set(), which aborted playTrack
before audio_play — no audio output at all. Back the player persist with a
quota-safe storage wrapper so a failed write degrades to a no-op instead of
throwing. Restoring the full ref list at that ceiling (vs a windowed cap) is
left as a follow-up.

* polish(player): throttle the quota-skip persist warning to once per key

The quota-safe persist logs a skip on every failed write; on a huge queue that
floods the dev console once per mutation. Warn once per key per quota-exceeded
streak, re-armed when a write to that key next succeeds.

* fix(queue): port new cover-pipeline readers to thin-state

Main's cover pipeline (#870) reads s.queue.length and seeds the player
store with queue: [track] in its tests. Under thin-state, queue: Track[]
no longer exists — the canonical queue is queueItems: QueueItemRef[].
These four files were brought across in the merge but still spoke the
old shape; this commit aligns them with the thin-state contract.

- src/cover/usePlaybackCoverArt: queueLength = queueItems.length
- src/cover/usePlaybackCoverArt.test: seed via toQueueItemRefs
- src/api/coverCache.test: same
- src/hooks/useNowPlayingPrewarm.test: same (two test cases)

* fix(queue): canonicalize thin-state server identity for mixed-server queues

`QueueItemRef.serverId` and `PlayerState.queueServerId` are now written as
the URL-derived index key on every writer path, matching the library index
direction. Mixed-server queues with duplicate `trackId` across servers stay
unambiguous because the resolver cache, persistence, and playback bindings
all share one key shape.

- new `canonicalQueueServerKey()` helper (idempotent UUID-or-key normalizer)
- `toQueueItemRefs`, `bindQueueServerForPlayback`, `seedQueueResolver`, and
  `hydrateQueueFromIndex` emit canonical keys
- `getCachedTrack` falls back to the canonical lookup so refs persisted in
  the legacy UUID shape still resolve through the migration window
- persist `merge` rewrites `queueServerId` and every ref `serverId` on
  rehydrate, so the live store never holds mixed shapes
- `removeServer` compares against the resolved id so a profile delete still
  clears the matching queue binding
- the two `playbackServer.test.ts` asserts that hard-coded the UUID shape
  are updated to the canonical key (existing reader-tolerance is unchanged)

* fix(queue-undo): bind snapshot prepend to snapshot-canonical server identity

When `applyQueueHistorySnapshot` has to prepend the still-playing track
(the snapshot's queue does not contain it), the new ref must follow the
snapshot's playback server, not the live `queueServerId`. A server switch
racing the undo would otherwise stamp the prepended ref with the new
server, mis-resolving the playing track on the very next render.

- `QueueUndoSnapshot` now carries `queueServerId` (captured by
  `queueUndoSnapshotFromState`); older in-memory entries fall back through
  the snapshot's own refs and finally the live store value
- the prepend in `applyQueueHistorySnapshot` plus the post-restore
  `seedQueueResolver` both source the server identity from this snapshot
  context, run through `canonicalQueueServerKey` so cache bucket and ref
  shape stay in lockstep

* test(queue): regression cluster for mixed-server queues with duplicate trackId

Covers the four invariants the thin-state review called out:

- resolver correctness: same `trackId` on two servers maps to two distinct
  cache entries via canonical keys, and legacy UUID-shaped refs still read
  the same entries through the compat lookup path
- restore/hydrate: persist `merge` forward-migrates UUID-form blobs in
  three shapes (canonical `queueItems`, legacy `queueRefs`, mixed-server
  `queueItems`) to canonical keys
- undo snapshot application: prepended ref follows the snapshot's playback
  server even when the live queue has been rebound to a different one,
  with fallback to snapshot refs and live state for legacy entries
- queue sync id emission: `flushPlayQueuePosition` -> `savePlayQueue`
  passes plain track ids and the playback server out of band, no per-ref
  `serverId` ever leaks into the request body

Also asserts the write helpers (`toQueueItemRefs`,
`bindQueueServerForPlayback`) emit canonical keys directly.

* perf(queue-header): coalesce resolver burst updates and aggregate in one pass

`QueueHeader` recomputed total and remaining queue durations on every
resolver cache version bump via two separate full-queue reduces. A mass
resolve burst (queue restore, prefetch window slide) bumps the version
dozens of times in one frame, and very long queues turned that into
visible main-thread stutter.

- one pass: a single for-loop produces both total and future-tracks
  duration; a 50k-track queue costs one walk per recompute, not two
- `useDeferredValue(version)` coalesces the burst into a single
  low-priority commit so the cache version is only sampled once per
  React frame instead of once per cache write

* fix(queue): use stable artist seed for radio top-up

The proactive radio top-up in `runNext` seeded `getSimilarSongs2` and
`getTopSongs` from `resolveQueueTrack(nextRef)` metadata. When the next
ref is still cold in the resolver cache, the placeholder track has empty
artist fields, and the top-up would fire `getSimilarSongs2('')` -- silently
returning nothing and leaving the queue dry just before the radio rail
would have refilled.

- prefer the just-played `currentTrack` (always fully resolved in the
  player store) and the stored radio seed artist id
- fall back to the next-track metadata only when those are missing
- skip the top-up entirely when no stable seed is available, instead of
  emitting a non-deterministic empty request

* docs(changelog): queue mixed-server routing and quota-safe persist (#872)
2026-05-27 00:10:34 +02:00
cucadmuh a8cfff0b62 feat(library): local lossless index, filters, and conserve dedicated page (#871)
* feat(library): local lossless index, filters, and conserve dedicated page

Add SQLite-backed lossless album browse and advanced-search filtering,
wire All Albums and artist/album lossless drill-down mode, and hide the
standalone /lossless-albums nav entry from sidebar visibility settings
(conserved route, default off).

* docs(release): note lossless local index in CHANGELOG and credits (PR #871)
2026-05-27 00:02:46 +03:00
cucadmuh 418b25914a feat(cover): unify cover pipeline and stabilize mainstage/now-playing (#870)
* chore(cover): scaffold cover module and rust cover_cache stub

Wave 0: src/cover/ skeleton per contracts.md §12, stub IPC commands
in cover_cache/mod.rs (no-op returns until phase B).

* feat(cover): add unified cover module and tier resolver (phase A)

Wave 1A: tiers, storage keys, resolveJs with cold/sibling races,
useCoverArt, CoverArtImage, layoutSizes, playback scope helpers,
coverSiblings tier ladder, deprecated shims on subsonicStreamUrl.

* feat(cover): rust disk cache and tier-ready events (phase B)

Wave 1B: cover_cache module with WebP tier encode, HTTP canonical 800 fetch,
cover_cache_* commands, cover:tier-ready / cover:evicted events, disk layout tests.

* feat(cover): prefetch hook, tier-ready handoff, library backfill IPC (phase B/C)

Wave 2: useCoverArtPrefetch, cover:tier-ready/evicted bridge, one-time IDB
cover key clear, prefetch registry drain, MainApp wiring.

* feat(cover): migrate dense grids to CoverArtImage and prefetch (phase D)

Wave 3A: dense surfaces use layout-native displayCssPx, surface=dense,
coverPrefetchRegister on Home/Albums/search; AlbumCard cell width from grid.

* feat(cover): migrate sparse surfaces and integrations (phase E sparse)

Wave 3B: sparse CoverArtImage/useCoverArt, lightbox tier 2000, ArtistHeroCover,
MPRIS/Discord/export integrations, playback chrome and detail heroes.

* feat(cover): revalidation scheduler and disk pressure gate (phase E+)

Wave 4: coverCacheMaxMb settings (en/ru), StorageTab disk usage, cover_cache_configure,
useCoverRevalidateScheduler, playbackServer uses cover fetchUrl; pressure watermarks.

* docs: CHANGELOG and credits for cover art pipeline PR #869

* fix(cover): stop webview getCoverArt storm on dense grids (429)

Dense surfaces no longer put rotating getCoverArt URLs in img src; load
disk via Rust ensure + convertFileSrc. Tier-ready notifies listeners instead
of invalidating IDB. Throttle background prefetch and cap Home registry.

* fix(cover): omit empty img src until cover URL is ready

React 19 warns on src=""; CoverArtImage uses undefined until disk/IDB
resolves; queue current track shows placeholder when src is still empty.

* fix(cover): disk cache by host index key, parallel ensure, asset protocol

Bind cover storage to serverIndexKey (library host), rename cover IPC/events,
fix REST base URL and Tauri flat args, enable protocol-asset for disk paths,
add prioritized ensure queue, and wipe legacy profile-UUID cache once.
Limit Vite dep scan to index.html so research/target HTML is ignored.

* fix(cover): WebP tiers, disk peek, home cache, asset URLs for mainstage

Encode lossy WebP (~82), write only missing tiers, library cover backfill,
and cover_cache_peek_batch for fast paint from disk. diskSrcCache + CSP
asset protocol; no IDB fallback when server is up. Session Home feed cache
with warm peek on return; BecauseYouLike deduped cover hook and high prefetch.

* feat(cover): per-server cache strategy and native library backfill

Move cover disk cache settings to Offline & cache with Lazy/Aggressive
per server, per-server clear, and no size cap. Run full-catalog backfill
on the Rust runtime (sync-idle wake, bounded HTTP, bulk 800px writes
without flooding the webview). Drop global prefetch limits from auth store
and waveform clear from the offline storage block.

* fix(build): CSP connect-src for Subsonic API; quieter prod nix build

Prod webview blocked axios ping after cover CSP (missing connect-src).
Drop cargo tauri -v in flake build, raise Vite chunk limit, ignore tsbuildinfo.

* fix(cover): complete WebP ladder in library bulk backfill

Aggressive backfill now writes all derived tiers (128–800), skips IDs
only when the full ladder exists (not 800 alone), avoids fetch-failed
markers on bulk HTTP errors, and stops the pass when the active server
changes.

* fix(cover,home): navigation-priority backfill and Because You Like UX

Pause library cover backfill while navigating; split peek/ensure traffic
so grids and rails win over bulk work. Disk src lookup, grid warm hooks,
and non-blocking mainstage prime for faster visible covers.

Because You Like: session snapshot, staggered horizontal skeleton row,
text hidden until cover is ready, and layout aligned with loaded cards.

* feat(random-albums,library): local-first album fetch + cover art pipeline

Random Albums теперь запрашивает локальный SQLite-индекс (ORDER BY RANDOM()
LIMIT N) вместо сетевого запроса к серверу. При готовом индексе спиннер
исчезает практически мгновенно; сеть используется только как фолбэк.

- advanced_search.rs: добавляет `("random", _) => RANDOM()` в allowlist сортировок
- browseTextSearch.ts: runLocalRandomAlbums — SQLite-рандом для Albums
- RandomAlbums.tsx: doFetchRandomAlbums local-first для обоих путей (без жанра
  и с жанром через runLocalAlbumsByGenres + JS-shuffle); speculative reserve
  прогревает следующий батч в фоне после каждого Refresh

Также: обновление пайплайна обложек (coverTraffic, peekQueue, ensureQueue,
diskSrcLookup, warmDiskPeek, prefetchRegistry, useCoverArt, useWarmGridCovers,
useCoverNavigationPriority, resolveIntersectionScrollRoot и сопутствующие
компоненты/хуки).

* fix(random-albums): prevent double-load on Zustand rehydration

useEffect([selectedGenres, load]) fired twice on every visit: first with
default store values, then again ~50 ms later when Zustand rehydrated
mixMinRatingFilterEnabled/minAlbum/minArtist from localStorage.

Previously this was invisible because the first network fetch took ~1.5 s,
so loadingRef.current was still true on the second fire. With the new
local-first SQLite path the first load completes in ~50 ms, leaving the
guard cleared before rehydration triggers a second random batch.

Fix: ref-pattern — keep loadRef.current fresh on every render, effect
depends only on selectedGenres. Manual Refresh and genre-filter changes
still call the latest closure correctly.

* fix(random-albums): stop warmCoverDiskSrcBatch in fillReserve from causing visual flash

fillReserve вызывал warmCoverDiskSrcBatch для обложек резервного батча, что
вызывало bumpDiskSrcCache() для каждой новой обложки (~30+ вызовов). Это будило
всех подписчиков useCoverArt на текущей странице, провоцируя видимую перерисовку
примерно через ~1.5 с после загрузки (когда filterAlbumsByMixRatings делает
сетевые запросы к рейтингам артистов).

- fillReserve: убран warmCoverDiskSrcBatch — обложки прогреваются лениво при
  consume резерва через primeAlbumCoversForDisplay
- reserve-путь в load(): добавлен primeAlbumCoversForDisplay перед setAlbums
  (аналогично non-reserve пути; при уже прогретом кэше — мгновенно)

* feat(because-you-like): reserve-first pattern — instant display on return visits

Каждый визит на Mainstage после первого теперь отдаёт готовую заготовку
мгновенно, вместо spinner → сетевые запросы → контент.

Архитектура:
- resolvePicks / fetchBecauseYouLike вынесены на уровень модуля (выход из
  замыкания useEffect); читают текущий localStorage, возвращают
  { anchor, recs, nextAnchorHistory, nextPicksHistory }
- fillBecauseReserve — fire-and-forget фоновая функция: запускается сразу
  после отображения результата, кладёт следующий батч в _becauseReserve.
  Covers намеренно не прогреваются (bumpDiskSrcCache на текущей странице не
  нужен); они прогреваются через primeAlbumCoversForDisplay при consume.
- useLayoutEffect: если reserve готов — не сбрасывает стейт в skeleton
  (контент появляется без мигания)
- useEffect: reserve-first path — consume → primeCovers → setState → fill;
  full-fetch path сохранён как fallback при первом визите или промахе

Поведение:
- Визит 1: full fetch (как раньше) → показ → fillReserve R1
- Визит 2+: consume R1 → мгновенный показ → fillReserve R2
- При сетевом сбое: restore из session cache (как раньше)

* fix(because-you-like): initialise state from reserve — no skeleton flash on remount

При ремаунте компонент стартовал с refreshing=true/anchor=null/recs=[] и
показывал skeleton на один тик до того как useEffect отработает.

Теперь useState() использует lazy initializers, которые читают _becauseReserve
прямо в первом рендере: если reserve валиден — state сразу refreshing=false,
anchor=X, recs=[...] и skeleton не показывается вообще. Covers уже в diskSrcCache
(из предыдущего показа) и появляются без дополнительных запросов.

useLayoutEffect упрощён: вызывает hasValidReserve() и сбрасывает в skeleton
только если reserve отсутствует (для случая navigation без ремаунта).

* fix(because-you-like): apply reserve in useLayoutEffect to handle async pool arrival

Lazy initializers не могли применить reserve при первом рендере, потому что
mostPlayed/recentlyPlayed/starred приходят из Home.tsx асинхронно — pool=[]
на первом рендере, poolKey не совпадает с reserve.

useLayoutEffect теперь активно ставит стейт из reserve (а не просто не сбрасывает):
когда pool обновляется до реальных данных, useLayoutEffect срабатывает синхронно
до paint, проверяет reserve и сразу применяет anchor/recs/refreshing=false.
При отсутствии reserve — сбрасывает в skeleton как прежде.

* fix(because-you-like): reserve > cache > skeleton — eliminate skeleton flash on mount

Корневая причина: Home.tsx загружает mostPlayed асинхронно через useEffect,
поэтому на первом рендере pool=[], poolKey=''. Reserve хранится с реальным
poolKey → mismatch → lazy initializers запускали skeleton.

Теперь двухуровневый fallback без зависимости от poolKey:
1. reserve (serverId + poolKey совпадают) → мгновенный новый батч
2. becauseYouLikeCache (только serverId) → stale-while-revalidate, контент
   доступен сразу с mount, обновляется тихо в фоне
3. skeleton → только при полном отсутствии данных (первый визит)

Применяется одинаково в lazy useState initializers, useLayoutEffect и
full-fetch path useEffect (не сбрасывать в skeleton пока есть cached контент).

* fix(because-you-like): key reserve by serverId only; guard useEffect on empty pool

Проблема: reserve хранился с poolKey, но на первом рендере pool=[] → poolKey=''
→ mismatch → показывался кэш (предыдущий набор) ~500ms пока Home.tsx не загружал
mostPlayed.

Исправления:
- BecauseReserve: убран poolKey — reserve валиден для любого pool-состояния
  на том же сервере. Pool (топ-артисты) меняется медленно; один раз показать
  reserve с чуть устаревшим anchor лучше чем показывать предыдущий набор 500ms
- hasValidReserve: проверяет только serverId
- fillBecauseReserve: убран poolKey из сигнатуры и хранилища
- useEffect: guard pool.length === 0 → возврат без fetch/consume;
  effect перезапустится когда pool заполнится (реальные deps изменятся)
  → reserve применяется из useLayoutEffect ещё до pool, без стале-флэша

Итоговый порядок: reserve (instant, serverId) > cache (stale-while-revalidate)
> skeleton (только первый визит)

* fix(home): remove mix-rating deps from feed useEffect — prevent Zustand rehydration double-fetch

Корень: useAuthStore(mixMinRatingFilterEnabled/Album/Artist) были в deps
useEffect. Zustand persist реhydrates асинхронно — сначала activeServerId,
потом mix-rating значения. Это вызывало двойной запуск эффекта:
- Первый запуск: homeFeedCache hit → показывает набор предыдущего просмотра
- Второй запуск (после rehydration): cache miss или повторный fetch с
  реальными mix-настройками → ~500ms → новый набор

Итог: Hero, AlbumRow, BecauseYouLikeRail показывали предыдущий набор
первые ~500ms при каждом возврате на Mainstage.

Fix: убраны mixMinRatingFilterEnabled/Album/Artist из deps. getMixMinRatingsConfigFromAuth()
читается внутри эффекта через getState() — всегда актуальные значения без
пересоздания замыкания. Mix-настройки по-прежнему применяются при fetch,
но не вызывают двойной запуск при rehydration.

* feat(home): local-first discover songs via SQLite ORDER BY RANDOM()

Добавлена runLocalRandomSongs (аналог runLocalRandomAlbums для треков)
в browseTextSearch.ts — использует libraryAdvancedSearch с sort random,
field уже поддерживается Rust-кодом через wildcarded ("random", _) ветку.

В Home.tsx: discoverSongs теперь сначала пробует локальный индекс,
и только при недоступности (индекс не готов, ошибка) падает обратно
на getRandomSongs.view. Ускоряет первую загрузку Mainstage — треки
берутся из SSD вместо сети.

* fix(home): pre-populate state from cache at mount — eliminate empty-state flash on return visits

Причина: Home.tsx размонтируется при навигации. При возврате первый рендер
всегда с пустыми массивами (heroAlbums=[], mostPlayed=[] и т.д.), потом
useEffect читает homeFeedCache и заполняет state. Даже один кадр с пустым
состоянием вызывает перерисовку Hero и BecauseYouLikeRail (pool=[]).

Решение: getInitialHomeFeed() читает homeFeedCache синхронно через
useAuthStore.getState() (не hook) в lazy useState initializers. К моменту
повторного визита store уже rehydrated — все state получают кэшированные
данные до первого рендера.

Дополнительно: wasPrePopulated предотвращает повторный applyFeedSnapshot
в useEffect когда state уже заполнен — иначе новые ссылки на массивы
вызывали бы ненужные ре-рендеры дочерних компонентов с теми же данными.

* fix(mainstage): keep refresh without return flicker

Keep Home and Because You Like visually stable during a single visit while still refreshing data for the next re-enter. Improve mainstage cover warmup by ensuring and pre-decoding above-the-fold artwork so hero and top rails appear instantly after navigation.

* fix(mainstage): stabilize because rail and hero background framing

Measure Because You Like layout before first paint to avoid width snap flicker, and render hero background as centered cover-fit images so the frame no longer jumps from top to middle on mount.

* fix(now-playing): prewarm track data and prevent stale carry-over

Warm Now Playing fetch caches and playback cover art on track change so entering the page no longer waits on first-load requests. Gate key-based sections (top songs, tour, Last.fm) by the active track/artist keys to avoid briefly rendering values from the previous track.

* fix(cover,test): refresh playback scope and default tauri cover mocks

Recompute playback cover scope when queue/server context changes so now-playing art resolves against the correct server after handoffs. Add default cover-cache invoke handlers to the shared Tauri test harness to prevent unhandled rejections in suites that mount cover-aware UI.

* fix(cover,now-playing,test): align prewarm scopes and tighten tauri mocks

Make cover-cache invoke defaults opt-in for tests, align radio prewarm scope with active rendering scope, and add targeted hook tests for prewarm + playback-scope reactivity. Also harden Rust cover URL building to avoid panic on malformed base URLs.

* test(cover): hoist mocked useCoverArt and clean EOF whitespace

Fix the new playback-scope hook test to use a hoisted vi.mock-safe stub and keep branch-wide diff checks clean by removing an accidental trailing blank line.

* fix(cover): align playback ensure auth and harden backfill retry flow

Use playback-server credentials for playback-scoped cover ensures, persist fetch-failed markers for bulk library backfill failures, and avoid advancing backfill cursor when UI-priority hold interrupts a batch.

* fix(ci): resolve clippy lint and update frontend node runtime

Move fetch helper before the test module to satisfy clippy's items-after-test-module rule, and modernize frontend CI to setup-node v6 with lts/* instead of pinned Node 20.

* chore(settings): simplify cover and analytics strategy copy

Move strategy summaries below tables, simplify Lazy/Aggressive wording, keep analytics warning always visible, and localize Russian texts to plain language without technical jargon.
2026-05-26 19:35:08 +03:00
cucadmuh 91e7195e0f fix(library): scoped live search FTS, race, and multi-server advanced search (#868)
* fix(library): scoped live search FTS, race, and multi-server advanced search

Scope track_fts live search to active server_id so multi-server libraries
no longer show empty or wrong-server hits. Match Navidrome-style any-word
prefix matching and GROUP BY artist/album dedupe on track_fts only.

Frontend: parallel local vs search3 race (empty waits, 8s network timeout),
merge supplemental hits after both settle, debug via Settings → Logging →
Debug (frontend_debug_log). Advanced Search FTS subqueries use the same
server scope fix.

* docs: CHANGELOG and credits for PR #868 live search fix
2026-05-25 04:20:02 +03:00
cucadmuh bc85065316 fix(analysis): persist failed tracks and reconcile progress counts (#867)
* fix(analysis): persist failed-track suppression and reduce aggressive polling

Persist unsupported/broken analysis tracks as failed entries and expose them in Settings with track metadata, export, and targeted rescan actions. Also make aggressive-mode completion checks cheap by gating re-entry on live track-count changes with a startup seed and 5-minute recheck cadence.

* fix(analysis): mark unsupported decode tracks as failed in cpu-seed

When full-seed falls back to waveform-only (no EBU loudness) or enrichment decode fails, persist analysis_track status as failed so aggressive backfill does not requeue the same unsupported tracks indefinitely.

* fix(analysis): reconcile legacy ready tracks without loudness

Auto-mark legacy ready tracks that only miss loudness as failed during needs-work checks so analysis progress converges instead of staying permanently pending.

* docs(changelog): add failed-analysis recovery notes for PR 867

Document persistent failed-track handling, analytics strategy controls, and low-cost aggressive-mode recheck behavior in the 1.47.0 changelog.

* fix(i18n): align settings locale coverage across all languages

Sync missing Settings keys for all shipped locales and replace recent English fallbacks with localized strings so analytics and backup UI text stays consistent outside en/ru.
2026-05-25 01:37:15 +03:00
cucadmuh 820f71c421 feat(dev): run dev alongside release with shared app data (#866)
* feat(dev): run dev alongside release with shared app data

Skip tauri-plugin-single-instance in debug builds so `tauri dev` can run
while an installed release instance is open. Keep the same bundle identifier
and data directory; label the dev window "Psysonic (Dev)".

* fix(dev): gate on_second_instance behind release cfg

Avoid dead_code warning in debug builds where single-instance is skipped.

* feat(dev): red sidebar brand and monochrome titlebar chrome

Tag the document in Vite dev and style the logo header with a red
background plus gray window controls so dev is obvious at a glance.

* feat(dev): skip OS hotkeys and add mobile DEV markers

Debug builds no longer register global shortcuts, MPRIS, or Windows
taskbar media controls so release keeps system input when both run.
Flip the tray icon horizontally in dev and show a fixed DEV badge on
narrow layouts.

* docs(changelog): note PR #866 parallel dev alongside release

* fix(dev): satisfy clippy needless_return in debug-only paths
2026-05-24 23:39:57 +03:00
cucadmuh de6462cbd2 fix(now-playing): hide zero-valued track metadata badges (#865)
* fix(now-playing): hide zero-valued track metadata badges

Use explicit > 0 checks instead of truthy && so missing numeric fields
(bitDepth, bitRate, samplingRate, year, rating) no longer render as "0".

* chore: note PR #865 in changelog and credits

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

* fix(now-playing): null-safe numeric badge guards for tsc

Use (value ?? 0) > 0 so optional metadata fields satisfy strict TS
while still omitting zero-valued badges in the UI.
2026-05-24 21:27:21 +03:00
cucadmuh 11974e1438 feat(analysis): ship index-key rebuild, strategy controls, and playback/queue pipeline updates (#864)
* feat(analysis): align index settings and per-server strategies

Rebuild the local index UX to live under Servers with per-server analytics
strategies, and scope analysis queue hints/pruning by playback server so
priorities stay isolated across profiles.

* feat(analysis): add progress tracking and server analysis deletion functionality

Introduce new interfaces for tracking library analysis progress and reporting on server analysis deletions. Implement functions to retrieve analysis progress for a server and to delete all analysis data for a specified server, enhancing the analytics strategy section with real-time progress updates and management capabilities. Update relevant components and localization files to support these features.

* feat(server): implement server index key migration and enhance server ID resolution

Add functionality to migrate server index keys from legacy IDs to new URL-based keys, improving server ID resolution across the application. Introduce new types and commands for handling server key migrations in both analysis and library contexts. Update relevant functions to utilize the new server ID resolution logic, ensuring consistency and accuracy in server-related operations.

* refactor(library): simplify server ID handling in sync progress and idle subscriptions

Refactor the library sync progress and idle subscription functions to directly use the payload's server ID without additional mapping. Update related components to resolve server IDs using a new utility function, ensuring consistent server ID resolution across the application. This change enhances code clarity and maintains functionality.

* refactor(analytics): rename advanced strategy to aggressive and update descriptions

Refactor the AnalyticsStrategySection component to rename the 'advanced' strategy to 'aggressive' for clarity. Update related localization strings to reflect this change, enhancing the user experience by providing clearer descriptions of the analytics strategies. Additionally, remove unused strategy description functions to streamline the code.

* fix(audio): update server ID handling in audio progress functions

Refactor the audio progress handling to utilize the new `getPlaybackIndexKey` function for server ID resolution. This change ensures that the correct analysis server ID is used when processing audio progress, enhancing the accuracy of playback operations. Additionally, a minor update was made to the analysis cache to include a checkpoint after seeding from bytes. Update the library path in live search to reflect the new database structure.

* refactor(analysis): update server ID handling and drop legacy keys

Refactor server ID handling across analysis components to utilize scheme-less keys (host + optional path) instead of legacy scheme-based keys. Introduce SQL migrations to drop legacy analysis rows and library entries keyed by scheme URLs. Update relevant functions and tests to ensure consistent server ID resolution and remove references to the legacy '' scope, enhancing clarity and maintainability.

* refactor(migration): switch to strategy C dual-db flow

Replace destructive server-key migration paths with a blocking inspect/run pipeline that imports into v2 sqlite files, verifies data, then switches active databases with backup safety. Add frontend migration orchestration and post-switch key rewrites while preserving existing user settings behavior.

* fix(migration): harden runtime db switch and startup gate

Switch database promotion through live runtime store/cache connection swaps so migration cannot leave writers on old sqlite inodes, and tighten startup gating to block initialization until migration completes. Also fix empty-bucket warning detection and set the done flag only after a post-run inspect confirms no pending legacy rows.

* feat(migration): enhance migration reporting with skipped server rows tracking

Add new fields to migration interfaces and reports to track skipped rows for removed servers. Update relevant components to display warnings and log messages when such rows are encountered during migration processes, improving visibility and user awareness of migration status.

* fix(migration): avoid startup blocking modal on no-op runs

Keep migration gate completed by default after successful runs and perform done-flag inspections without forcing a blocking phase, so normal app startup no longer flashes migration preparation when no migration is needed.

* fix(migration): enforce startup precheck and purge unknown rows

Prevent stale done-flag bypass by starting migration state in idle and gating completion on orchestrator precheck, and delete unknown removed-server rows from v2 databases before switch so skipped rows are not carried into the new active DB.

* fix(migration): block UI during done-flag precheck

Set inspecting phase before the first migration inspection and treat idle as blocking in the migration gate, so startup precheck cannot render the app before migration status is confirmed.

* fix(migration): hide precheck modal when no migration is needed

Keep startup precheck in a non-blocking idle phase and show the migration modal only after inspect confirms real migration work, removing the recurring half-second migration flash for already-migrated users.

* fix(migration): cleanup legacy db files after path migration

Always remove legacy analysis and library sqlite files (including wal/shm sidecars) when the new database paths are active, so old-path artifacts from previous builds do not linger after migration.

* docs(changelog): add PR #864 release notes and contributor credit

Document the full index-key rebuild scope for 1.47.0 and add the
corresponding settings credit entry for PR #864.

* test(analysis): raise hot-path coverage for analysis cache

Add focused unit tests for analysis cache compute/store hot paths and edge branches so coverage regressions are caught before CI. Make AppHandle entrypoints runtime-generic and enable tauri test utilities in dev dependencies to cover no-cache and registered-cache execute paths.

* fix(migration): make rebind pass resilient to foreign key ordering

Run library and analysis server_id rebind operations inside a foreign-key-disabled transaction and validate with PRAGMA foreign_key_check after commit, so migrations from older databases do not fail on transient FK ordering during bulk updates.

* feat(backup): add dual-database backup flow and blocking UX

Extend backup/export and restore flows to handle library databases with unified archive detection and asynchronous backend execution. Improve backup UI with a global blocking modal and clearer localized copy so long operations do not look like app hangs.

* docs(changelog): add PR #864 backup notes and contributor credit

Update 1.47.0 release notes with backup/restore UX and archive-flow entries for PR #864, and add the matching settings credits contribution line for cucadmuh.

* docs(changelog): sort 1.47.0 entries from old to new

Reorder Added, Changed, and Fixed subsections in the 1.47.0 changelog so entries follow chronological PR order inside each block.

* fix(playback): align offline/hot cache lookup with indexKey scope

Use a canonical playback cache key based on indexKey with legacy UUID fallback so migrated offline and hot-cache entries are still resolved on normal play, resume, queue-undo, and prefetch paths. Refresh PR #864 changelog/credits text to reflect the full migration and backup scope.
2026-05-24 21:11:04 +03:00
cucadmuh 003b280a77 feat(enrichment): oximedia BPM/mood facts, mood search, and queue display (#863)
* feat(enrichment): oximedia BPM/mood facts, mood search, and queue UI

Run client-side oximedia analysis after CPU seed and persist BPM, mood JSON,
and searchable mood_tag facts. Add product mood groups (joy/sadness/dance/work/
romance) with Advanced Search filter on the local index, queue BPM/mood display,
migration 008 mood_tag index, and refreshed licenses for oximedia crates.

* fix(enrichment): keep mood_groups module comment in English

* feat(search): virtual mood groups, anger filter, and Advanced Search UX

Expand mood search via overlapping virtual groups (tag expansion only),
add anger/Злость group, skip album/artist shortcuts for track-only filters,
and simplify mood search UI (songs-only, hide type tabs). Fix CustomSelect
spurious scrollbar on short option lists.

* feat(analysis): unified track analysis plan and enqueue path

Add TrackAnalysisPlan (waveform, LUFS, enrichment) with a single
enqueue_track_analysis entry for all byte-backed triggers. Run enrichment
when cache is full but library facts are missing; route playback, cache,
and backfill through the planner. Fix browseTextSearch LocalSearchOpts tsc
gap and remove obsolete read_seed_bytes_if_needed helper.

* fix(analysis): wire playback dispatch, preload enrichment, and UI refresh

Route stream, gapless, preload, and local-file playback through analysis_dispatch
so BPM/mood enrichment runs when waveform/LUFS are already cached. Fix audio_preload
cache-hit and hot-cache paths, emit preload-cancelled for retry, and add
analysis:enrichment-updated plus content_cache_coverage key resolution.

* fix(audio): preload local files from disk and stop analysis retry loop

Seed hot/offline next tracks via LocalFilePlayback (512 MiB) instead of copying
into the RAM preload slot. Keep bytePreloadingId set after preload-ready so
progress ticks do not re-invoke audio_preload every second.

* fix(enrichment): clippy, album bpm filter routing, and queue mood display

Clippy-clean analysis_dispatch and engine imports; restrict track-derived album
routing to mood_group/mood_tag only so bpm is skipped on album queries. Log
enrichment plan errors with retry-all plan; filter queue mood labels to oximedia ids.

* fix(enrichment): simplify mood_tag backfill branch in plan_track_enrichment

Remove empty if-block; keep same behaviour when backfill fails and moods row exists.

* docs: CHANGELOG and credits for track enrichment PR #863

* docs(credits): track enrichment PR #863 contributor line

* chore(enrichment): clippy-clean plan branch and trim dead exports

Collapse mood_tag backfill if for clippy; remove unused moodGroupById and
OXIMEDIA_MOOD_LABELS re-exports; stop poll when server BPM is already known.

* fix(enrichment): close R2/S1–S3 limits and Song Info BPM fallback

Return TrackEnrichmentOutcome::Failed on oximedia errors so retries are not
masked as complete; extract mood Advanced Search SQL, unify top-3 mood tag
selection in mood_groups with TS invariant tests, and show measured BPM in
Song Info when tag BPM is missing or zero.

* fix(enrichment): restore offline coverage and show mood in Song Info

Add unit tests for offline download cancel/clear registry after the analysis
seed refactor dropped read_seed_bytes coverage; show localized mood labels in
Song Info when library enrichment facts exist.

* fix(enrichment): soft mood scoring and unblock offline cancel tests

Replace oximedia quadrant happy/excited mapping with valence/arousal
soft scores across all mood tags for display, storage, and backfill;
fix offline cancel unit tests that deadlocked by calling clear while
holding the global offline_cancel_flags mutex.

* fix(enrichment): dedupe joy cluster and cap mood display at two labels

Never show happy and excited together; pick one tag per V/A cluster,
tighten oximedia recalibration, and limit queue/Song Info to two moods
that pass a relative score floor.

* fix(enrichment): disable oximedia mood labels in UI and search tags

Oximedia 0.1.7 mood is a spectral energy heuristic, not independent mood
weights; valence correlates with loud/bright audio and false-labels metal
and lyrical tracks as happy. Hide queue/Song Info mood and stop writing
mood_tag facts until a reliable detector lands; keep V/A/moods JSON stored.

* fix(enrichment): disable oximedia mood analysis and add BPM advanced search

Stop planning, running, and storing oximedia mood facts; purge accumulated
mood rows via migration 009. Hide mood filters in Advanced Search, expose
BPM range filter with dual-storage resolution, and show a BPM column in song
results when that filter is active.

* feat(search): analysis BPM priority, source tooltip, and filter UX

Prefer analysis track_fact over file tags for BPM resolution; show source
in list tooltips. Validate BPM range on blur, add clear button, fix double tooltip.

* fix(enrichment): prefer analysis BPM in Song Info and queue tech row

Show measured track_fact BPM before file tags until analysis completes;
pick the highest-confidence analysis fact when several exist.
2026-05-23 18:54:04 +03:00
cucadmuh 67b1dc790b fix(library): sweep orphan tracks after successful full resync (#861)
* fix(library): sweep orphan tracks after successful full resync

Full resync only upserted server tracks and left server-deleted rows
live in SQLite. Add IS-7 mark-and-sweep via track.resync_gen: stamp
rows on ingest during a re-sync, soft-delete unstamped rows when IS-6
completes. Delta tombstone reconcile is unchanged.

* docs(release): CHANGELOG and credits for PR #861 resync orphan sweep

* fix(library): satisfy clippy unnecessary_min_or_max in orphan sweep

execute() returns usize; drop redundant max(0) so CI clippy -D warnings passes.
2026-05-23 00:58:37 +03:00
Frank Stellmacher 090a31bc82 refactor(queue): track resolver + selectors (thin-state phase 2) (#859)
* refactor(queue): add queue track resolver (thin-state phase 2a)

Standalone resolver: QueueItemRef → Track via index batch
(library_get_tracks_batch, ≤100/call) → network getSong fallback (P8), into a
bounded LRU cache. Holds raw tracks; session star/rating overrides (F4) merged
on read via applyQueueOverrides. Sync getCachedTrack + subscribeQueueResolver
for selectors; resolveVisibleRange prefetches a [-50, +200] window; carries
queue-only flags from refs; invalidate drops entries after a sync succeeds.

Not wired into the store/UI yet — phase 2b does that.

* refactor(queue): extract toQueueItemRefs helper

Pure helper deriving thin QueueItemRefs from a Track[] queue (per-item
serverId, queue-only flags), shared by the persist partialize and the
upcoming phase-2b resolver bridge. No behaviour change.

* refactor(queue): resolver bridge + queue selectors (thin-state phase 2b)

Seed the resolver cache from the canonical queue (queueResolverBridge, a
windowed [-50, +200] seed around the current index) and add the stable queue
selectors (useQueueTrackAt / useCurrentTrack / useQueueItems).

Additive: the store stays queue: Track[]-canonical; consumers migrate onto the
selectors in phase 3, and the selector impls move to the resolver once
queue: Track[] is dropped in phase 4. No mutation or persist change — the
persisted queueItems keeps its single restore role (no dual-role clash).

* docs(changelog): on-demand queue track loading groundwork (#859)
2026-05-22 23:30:49 +02:00
Frank Stellmacher d15e270499 refactor(queue): persist thin QueueItemRef list (thin-state phase 1) (#858)
* refactor(queue): persist thin QueueItemRef list (thin-state phase 1)

Introduce QueueItemRef ({ serverId, trackId, autoAdded?, radioAdded?,
playNextAdded? }) and persist the whole queue as a thin queueItems list,
superseding the queueRefs id list. Dual-write: the windowed queue: Track[]
stays as the index-off fallback; the in-memory store is unchanged.

hydrateQueueFromIndex now prefers queueItems (per-item serverId) and carries
the queue-only flags onto the hydrated tracks (the index doesn't store them),
with a queueRefs fallback for stores persisted before this change.

Phase 1 of the queue thin-state plan; no store/consumer changes yet.

* test(queue): cover legacy queueRefs-only upgrade in hydrate

* docs(changelog): queue section dividers kept on index restore (#858)
2026-05-22 22:43:41 +02:00
Frank Stellmacher 06213cb5d8 perf(queue): virtualize the queue list (#857)
* perf(queue): virtualize the queue list

Render the QueuePanel queue through @tanstack/react-virtual so the DOM stays
O(visible rows) instead of O(queue length) — a 10k+ Artist Radio queue no
longer creates tens of thousands of DOM nodes. Row logic (reorder DnD, radio/
auto dividers, lucky-mix loader, context menu) is unchanged, just wrapped in
the virtual rows. Auto-scroll to the next track moves out of useQueueAutoScroll
(which relied on every row being in the DOM) to a virtualizer scroll plus an
exact scrollIntoView on the real target row.

Phase 0 of the queue thin-state plan; no store/persist changes.

* docs(changelog): virtualized queue list (#857)
2026-05-22 22:17:32 +02:00
Frank Stellmacher 10c3d9a3ce chore(i18n): translate lyrics source/YouLyPlus strings for all locales (#856)
Add the new lyrics keys (youLyPlus toggle, source fallback/primary hints,
queue 'no sources' hint) to fr/es/nb/nl/ro/ru/zh and drop the now-dead
lyricsMode* keys, completing the i18n for #855 (was on en fallback).
2026-05-22 21:15:14 +02:00
Frank Stellmacher 02b2df1589 feat(lyrics): make lyrics fully disablable (independent YouLyPlus toggle) (#855)
* feat(lyrics): independent YouLyPlus toggle + all-sources-off state

Replace the binary lyricsMode ('standard' | 'lyricsplus') with an
independent youLyPlusEnabled flag so YouLyPlus and the standard sources
are no longer mutually exclusive — turning one off no longer forces the
other on. YouLyPlus (when on) is tried first with the enabled sources as
fallback; off uses only the enabled sources. When YouLyPlus is off and no
source is enabled, useLyrics fetches nothing (issue #810).

Fresh installs ship with every source off; the rehydrate migration only
restores the old on-by-default set for genuine upgrades, not new installs.

* feat(lyrics): YouLyPlus toggle UI + queue 'no sources' hint

Settings: single YouLyPlus toggle replacing the two mutually exclusive
mode switches; the source list is always visible with a context hint
(fallback vs primary). Queue lyric tab shows a hint when no source is
active. en + de strings; other locales fall back to en.

* docs(changelog): lyrics fully disablable (#855)
2026-05-22 21:06:04 +02:00
Frank Stellmacher cb4d331f99 fix(library): browse-all-tracks shares the Search song-list view (#854)
* fix(library): browse-all-tracks shares the Search song-list view (#841)

"Browse all tracks" rendered virtualized, transform-positioned rows inside
its own scroll box, so the sticky column header got painted over while
scrolling. Extract the Search / Advanced-Search song-list chrome (sticky
header + plain SongRows + IntersectionObserver sentinel paging) into a
shared PagedSongList and route all three through it; Browse now flows in the
page like the Search pages, so the header can no longer be overlapped.

Trade-off: Browse-all drops its bespoke row virtualization to match the
Search pages (DOM grows with scroll as they do); paging is unchanged. Also
removes the duplicated sentinel/observer in SearchResults and AdvancedSearch.

# Conflicts:
#	src/components/VirtualSongList.tsx
#	src/pages/SearchResults.tsx

* docs(changelog): browse-all-tracks sticky header fix (#854)
2026-05-22 19:53:22 +02:00
Frank Stellmacher cc8e6cc811 fix(playlist): column picker no longer clipped on short lists (#853)
* fix(playlist): columns dropdown no longer clipped on short lists (#839)

The column picker rendered inside `.tracklist` (overflow-x: auto, which
makes overflow-y compute to auto). On a 1-song playlist the downward popover
overflowed the short box → clipped behind suggestions, an extra scrollbar,
and the row vanishing when scrolling that inner bar (the virtualizer tracks
the main viewport, not the tracklist). Move the picker outside `.tracklist`
by reusing the shared TracklistColumnPicker (parametrized with allColumns);
fixes the same latent bug in the favorites tracklist and dedupes three
inline copies into one.

* docs(changelog): playlist/favorites column picker fix (#853)
2026-05-22 19:20:34 +02:00
cucadmuh e8e41752a7 feat(playback): global speed with three strategies (#852)
* feat(playback): global speed with three strategies

Add Settings → Audio and player-bar controls for global playback speed
(speed with auto pitch correction as default, varispeed, manual pitch shift).
Time-stretch runs on a background worker; Orbit sessions force 1.0× passthrough.

* fix(playback): align seekbar, seek, and progress on content timeline

Unify UI timebase across varispeed and preserve strategies: full-track
duration, speed-scaled progress for DSP paths, and content-timeline seeks
without varispeed scaling. Reset the sample counter after seek so clicks
land correctly; restart playback on strategy/enable changes instead of
fragile hot-switching.

* fix(ui): anchor playback speed popover like volume controls

Replace the centered EQ-style modal with a player-bar popover (outside
click, Escape, reposition on scroll). Show compact controls in the bar
and overflow menu; keep strategy hints and labels in Settings only.

* docs(release): CHANGELOG and credits for playback speed (PR #852)

* docs(changelog): add playback speed entry for PR #852

* fix(clippy): simplify raw_counter_samples branch for CI

Collapse duplicate if branches flagged by clippy::if-same-then-else.

* fix(ui): wheel on pitch slider adjusts pitch in speed popover

In compact player-bar controls, scroll over the pitch row changes pitch;
elsewhere in the panel changes speed. Stop propagation so overflow menu
wheel does not tweak volume.

* fix(playback): address PR #852 review and drop ineffective dynamic imports

Translate playback-rate strings for de/fr/es/zh/nb/nl/ro; restamp sample
counter on live preserve-path speed changes; use neutral rate atomics for
radio progress; static-import playerStore in playListenSession (move preview
volume sync to previewPlayerVolumeSync side-effect module).

* fix(i18n): translate playback-rate strategy labels in all locales

Replace leftover English Varispeed/Pitch strings in ru and other non-en
settings blocks so popover strategy buttons and hints read natively.

* fix(i18n): refine German varispeed label to "Tonhöhe folgt dem Tempo"

---------

Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
2026-05-22 18:59:28 +02:00
cucadmuh d54eceaf3b fix(statistics): keep player stats tab visible without local index (#851)
* fix(statistics): keep player stats tab visible without local index

Show the tab always and explain that player statistics require the local
library index, with a link to library settings, instead of hiding the tab.

* docs(release): CHANGELOG and credits for player stats tab UX (PR #851)

* docs(credits): drop minor library index and player stats tab fixes

Per team policy, small UX fixes (PR #850, #851) stay in CHANGELOG only.
2026-05-22 14:41:04 +03:00
cucadmuh 589afe9b7e Merge pull request #850 from Psychotoxical/fix/library-index-exclude-busy-ui
fix(settings): library index exclude/include busy feedback
2026-05-22 14:21:41 +03:00
Maxim Isaev 3825b01769 docs(release): CHANGELOG and credits for library index busy UI (PR #850) 2026-05-22 14:19:32 +03:00
Maxim Isaev 376edbfe18 fix(settings): show including state on library index include action
Mirror the exclude UX: flushSync before bootstrap, block repeat clicks,
show "Including…" on the row, and roll back exclusion if bind fails.
2026-05-22 14:16:19 +03:00
Maxim Isaev 60aad12cd4 fix(settings): show excluding state on library index exclude action
Flush UI before the async unbind, disable repeat clicks, cancel an active
sync when needed, and label the button "Excluding…" / localized equivalent.
2026-05-22 14:14:09 +03:00
cucadmuh 23f7ba02d6 feat(player-stats): local listening history tab with heatmap and summaries (#849)
* feat(player-stats): local listening history tab with heatmap and summaries

Record play sessions in library.sqlite when the library index is enabled,
add Rust read APIs and Tauri commands for year/day aggregates, and ship the
Player stats UI with session clustering, event-driven live refresh, and a
notice when some servers are excluded from indexing.

* test(player-stats): split play_session repo and expand test coverage

Move the repository into play_session/ (completion, cluster, integration tests),
add remap/purge/FK coverage in Rust, and cover ingestion gates plus live-refresh
hooks on the frontend per spec v0.3.

* docs(release): CHANGELOG and credits for player stats (PR #849)

* fix(player-stats): satisfy tsc and clippy CI gates

Use InternetRadioStation field names in the radio skip test and replace
manual month/day range checks with RangeInclusive::contains.
2026-05-22 14:07:38 +03:00
cucadmuh 7afddf7b84 feat(library): browse local index race and catalog paths (#847)
* feat(library): race local index vs network on browse text search

Wire Artists, Composers, Tracks, and SearchResults to parallel local FTS
and network search3 with graceful fallback when remote fails while the
index is ready.

* feat(library): local browse for albums/artists and dev race logging

Serve All Albums and Artists catalog from the local index when ready,
with network fallback. Log browse text-search race outcomes to DevTools
(`[psysonic][library] browse-race …`) including winner, timings, and hits.

* docs(changelog): note PR #847 browse local index race and catalog paths

* refactor(library): unify DevTools search log format

Live Search, Advanced Search, and browse races emit one-line
`search [surface] …` entries via formatLibrarySearchLine (DEV only).
2026-05-22 01:44:25 +02:00
Frank Stellmacher bd742c958c fix(playlist): sorting a column no longer snaps the viewport (#840) (#848)
* fix(playlist): sorting a column no longer snaps the viewport (#840)

Sorting flipped `isFiltered` (which means displayedSongs !== songs, so it
also goes true once a sort is active), and the scroll-to-list effect fired
on `[id, isFiltered]` → the viewport snapped down to the list. Drive that
effect from a dedicated `hasActiveFilter` (filter text only), so sorting
applies in place; filter and playlist-switch scrolling are unchanged.

* docs(changelog): playlist sort viewport fix (#848)
2026-05-22 01:18:29 +02:00
cucadmuh 5bf2441ccf feat(library): local library index and search (preview) (#846)
* feat(library): scaffold psysonic-library crate with v1 schema and store (#791)

Adds a new workspace crate that will host the unified track store and the
upcoming sync engine. PR-1a covers spec phases A1–A6:

- migrations/001_initial.sql: full v1 schema — sync_state, track, album,
  artist, track_fts (+ ai/ad/au triggers), track_extension, track_offline,
  track_id_history, track_fact, track_artifact, canonical_track,
  canonical_identity, track_canonical_link, canonical_enrichment_link, and
  all §5.2 partial indexes.
- store::LibraryStore: WAL + foreign_keys=ON SQLite connection rooted at
  app_data_dir/library.sqlite (distinct from the analysis cache, which
  uses app_config_dir). schema_migrations table + idempotent embedded
  migration runner; LIBRARY_DB_SCHEMA_VERSION = 1.
- repos::TrackRepository::upsert_batch: 35-column transactional upsert
  with ON CONFLICT(server_id, id) all-fields rewrite; FTS rows follow via
  the triggers.
- search::search_tracks: minimal bm25-ordered FTS5 helper scoped to a
  single server_id, filtering deleted rows.
- filter::FilterFieldRegistry: static v1 registry (text, genre, year,
  starred = V1; bpm = SchemaV1UiLater; user_rating/suffix/bit_rate =
  Planned). Entity routing is a silent skip per §5.13.3.

No Tauri commands, no frontend, no sync — those land in PR-2..PR-7.

PR-1b will follow with the migration-runner edge-case tests, the
initial_sync_cursor_json read/write API, and the breaking-migration hook
stub (P22).

* feat(library): A7 migration-runner safety net + initial-sync cursor API (PR-1b) (#792)

* feat(library): wire migration-runner safety net and initial-sync cursor API

PR-1b — Phase A7 infrastructure on top of PR-1a. Production behaviour
is unchanged at v1 launch; everything here is plumbing that PR-3 will
consume.

- store::run_migrations_with: testable entry point that takes an explicit
  migration slice, a min-compatible-version threshold, and a breaking-bump
  hook. The prod `run_migrations` fixes those to MIGRATIONS,
  LIBRARY_DB_MIN_COMPATIBLE_VERSION, and the no-op stub. The slice is now
  sorted defensively before applying.
- store::LIBRARY_DB_MIN_COMPATIBLE_VERSION: new public constant (currently
  equal to LIBRARY_DB_SCHEMA_VERSION). When a future release needs to
  invalidate v1 data, bumping this above the max applied version trips
  the hook on next open per spec §5.7 / P22.
- store::MigrationOutcome (Applied | BreakingBump): crate-internal signal
  callers can branch on. PR-1b consumers ignore it; PR-3 / Settings will
  surface the "library rebuilt after update" toast when it surfaces.
- store::handle_breaking_schema_bump: documented no-op stub. The drop +
  resync logic lands with the first real breaking bump.
- repos::SyncStateRepository: ensure(server_id, scope) idempotently
  inserts a default row; get_initial_sync_cursor / set_initial_sync_cursor
  read and write sync_state.initial_sync_cursor_json via
  serde_json::Value. The set uses ON CONFLICT … DO UPDATE scoped to the
  cursor column only, so phase / poll-stats / tier survive cursor writes
  intact.
- Tests cover: additive 002-style migration preserves prior data
  (spec §5.7 explicit integration test), runner sorts an unsorted source
  slice, breaking-bump hook fires when max applied < min_compatible,
  hook does not fire on a fresh DB, cursor round-trips a nested
  serde_json::Value, ON CONFLICT preserves sibling columns, library_scope
  separates rows per server.

End-to-end "kill mid-500k-sync → resume same cursor" stays out of scope
per the kickoff answer — it belongs to PR-3 / C2 where the
InitialSyncRunner lives.

* test(library): cover AC A3 — 500-row upsert_batch under perf budget

* feat(library): Subsonic REST client for the sync engine (Phase B, PR-2) (#793)

Phase B (B1-B9 per spec §10) — pure-Rust Subsonic client that the
library-sync engine (PR-3) will drive. No Tauri commands, no events;
the surface is added internally to psysonic-integration as a sibling
of the existing navidrome native-REST module.

- B1 — SubsonicClient + ping over /rest/{method}.view. Auth via the
  legacy salted-md5 token (spec v1.13+, advertised as 1.16.1). New
  SubsonicCredentials helper computes token = md5(password || salt)
  and ships a per-process unique salt nonce so back-to-back calls
  don't repeat.
- B2 — get_scan_status → ScanStatus { scanning, count, folder_count,
  last_scan }. Lightweight poll for the Huge-tier path (§6.2.2).
- B3 — get_album_list2(type, size, offset, musicFolderId?) +
  get_album(id). The two-call pattern the sync engine walks during
  initial ingest (§6.3).
- B4 — search3(query, songCount, songOffset, musicFolderId?). Empty
  query → all songs paged (Navidrome quirk, spec §2.4).
- B5 — get_indexes(musicFolderId?, ifModifiedSince?). Conditional
  fetch for file-tree fallback (S3 / §3.1).
- B6 — get_song(id). Error code 70 maps to the dedicated
  SubsonicError::NotFound variant so the tombstone reconciler can
  match on the variant instead of parsing strings.
- B8 — get_artists(musicFolderId?). ID3-path artist index; clients
  compare ArtistIndex.last_modified_ms against the local watermark
  to decide if a delta pass is needed (§2.2.1).
- B9 — fingerprint_sample helper picks every-Nth track id for the
  server-fingerprint verify pass. Sampling is deterministic so
  reruns probe the same tracks. The verify-and-compare glue itself
  is library-side (PR-3 territory, deps on the store).

Tests cover envelope parsing (status=ok/failed, code 70 → NotFound,
missing body key), credentials (md5 vectors, salt uniqueness across
1k rapid calls, salt differs per from_password call), each endpoint
end-to-end through wiremock with query-param matchers, OpenSubsonic
forward-compat (unknown fields ignored on Song), and the trailing-slash
base-URL normalisation.

Cargo.toml — adds query + form + multipart to psysonic-integration's
reqwest feature set. PR-2's client needs `query`; the other two were
already used by existing navidrome::covers / remote::lastfm code and
only worked via top-crate feature unification. Aligning the crate's
own deps means `cargo test -p psysonic-integration` now compiles
without depending on the workspace build.

Out of scope: capability detection (C1 / PR-3), Navidrome native
bulk path (uses existing psysonic-integration::navidrome::queries),
fixtures harness expansion (G1).

* feat(library): subsonic client follow-ups from PR-2 review (PR-2b) (#794)

Picks up the three non-blocking items from cucadmuh's PR-793 review
(handoffs/2026-05-19-pr-793-review.md) before PR-3 starts on top.

- Fresh `(token, salt)` per request. `SubsonicClient` now caches the
  plaintext username + password and derives a new `SubsonicCredentials`
  inside `send()` for every endpoint call — matches the frontend's
  `subsonicClient.ts` `getAuthParams()` lifecycle and follows Subsonic
  replay-resistance guidance. Test path keeps a `with_static_credentials`
  constructor so wiremock matchers stay deterministic. New
  `build_credentials` (`pub(crate)`) routes the two modes.
- `SUBSONIC_CLIENT_ID` now carries the crate version
  (`psysonic/<CARGO_PKG_VERSION>`) — aligns with the frontend's
  `psysonic/${version}` so Navidrome log lines correlate across the
  WebView and Rust sync paths.
- `Song.mbid_recording` gains the `musicBrainzId` serde alias (plus the
  schema-column spelling) so the OpenSubsonic field lands on the same
  hot column the §5.1 schema names. P13 strong-key matching can now key
  off it on ingest.
- `get_song_with_raw` / `get_album_with_raw` return both the typed
  projection and the raw `serde_json::Value` body sub-tree. PR-3 ingest
  will write that raw value verbatim into `track.raw_json`, so
  OpenSubsonic extensions (`contributors`, `replayGain`, future fields)
  survive without manual field mirroring. Internal `parse_envelope_body`
  extracts the validation + body-key lookup once; `parse_envelope` and
  the new `parse_envelope_with_raw` share it.

Tests cover: `from_password` produces unique salt/token across two
back-to-back calls (direct + over-the-wire via wiremock
`received_requests`), static mode returns the same triple,
`c` query param starts with `psysonic/` and equals `SUBSONIC_CLIENT_ID`,
`get_song_with_raw` preserves untyped fields (`replayGain`,
`contributors`) in the raw value, `get_album_with_raw` keeps per-track
extensions in `raw.song[i]`, error 70 still maps to `NotFound` on the
raw variant, and `Song` deserializes `musicBrainzId` and
`mbid_recording` interchangeably.

B9 fingerprint-verify glue and the wider raw-ingest call sites stay
with PR-3 / C2 as the review's §5 / §7 checklist directs.

* feat(library): capability probe + sync_state accessors (Phase C1+C7, PR-3a) (#795)

First sub-PR of Phase C (sync orchestrator). Lands the foundation that
PR-3b's InitialSyncRunner consumes — pure plumbing, no runners or
background tasks yet.

- C1 capability probe. `psysonic_library::sync::CapabilityProbe::run`
  drives the §6.1 probe chain: Subsonic ping (captures `ServerInfo`
  envelope metadata for server-type / OpenSubsonic detection), then
  best-effort probes for search3 / getScanStatus / getIndexes, plus an
  optional Navidrome native bulk probe (caller passes
  `NavidromeProbeCredentials`). `CapabilityFlags(u32)` matches the
  §6.1.1 bitfield: NavidromeNativeBulk / SubsonicSearch3Bulk /
  ScanStatusAvailable / OpenSubsonic / UnstableTrackIds / FileTreeBrowse.
- C7 sync_state accessors. `SyncStateRepository` gains get/set
  capability_flags, get/set sync_phase (idle / probing / initial_sync
  / ready / error), and column-scoped setters for server_last_scan_iso,
  indexes_last_modified_ms, artists_last_modified_ms, library_tier.
  Every setter uses `ON CONFLICT … DO UPDATE` scoped to its own column
  so concurrent watermark writes don't clobber each other.
- Supporting additions in `psysonic-integration`:
  - `subsonic::SubsonicClient::server_info()` extracts `ServerInfo`
    from the ping envelope (server_type, server_version, api_version,
    open_subsonic). Re-uses `send()` so auth lifecycle is the same.
  - `navidrome::probe::native_bulk_available(url, token)` does the
    `GET /api/song?_start=0&_end=1` Bearer-auth probe. Returns
    Ok(true) on 2xx, Ok(false) on 4xx (auth ok but endpoint missing),
    Err on 5xx. Probe-only — full nd_list_songs port is PR-3b.
- `psysonic-library/Cargo.toml` gains a `psysonic-integration`
  dependency (sync calls into Subsonic + Navidrome probes). DAG stays
  acyclic: integration does not depend on library.

Per cucadmuh's PR-3 kickoff answer (handoff `2026-05-19-pr3-kickoff.md`):
- Crate placement: option A — sync lives in `psysonic-library/src/sync/`,
  no new psysonic-sync crate.
- N1 gate: probe is `/api/song?_start=0&_end=1` only; `nd_list_artists_by_role`
  is NOT required (Q3 answer + N1 ingest port lands in PR-3b).
- UnstableTrackIds: set for Navidrome via `ServerInfo.server_type`,
  cleared for generic Subsonic.

Tests added: 23 across library/sync, library/repos/sync_state,
integration/subsonic, integration/navidrome/probe. Cover bitfield
contains/insert/remove + spec bit values, probe across mixed-capability
servers (full Navidrome, minimal Subsonic, broken endpoints), ping-failure
short-circuit, optional Navidrome creds gating N1, sync_state column-scoped
upserts (capability_flags / sync_phase / watermarks / library_tier),
cross-column independence (capability writes don't reset cursor),
ServerInfo extraction from ping envelope, Navidrome bulk probe across
2xx/4xx/5xx.

* feat(library): InitialSyncRunner + C12 backoff + C13 id remap (Phase C2/C12/C13, PR-3b) (#796)

Second sub-PR of Phase C — wires the actual ingest path on top of
PR-3a's capability + sync_state foundation. Runner is pure async Rust:
PR-3d will spawn it inside a tokio task and emit Tauri progress events
on top.

- C2 InitialSyncRunner. Drives spec §6.3 IS-1 → IS-6: probe-derived
  IngestStrategy (enum N1/S1/S2/S3, selector picks N1 → S1 → S2 chain
  per kickoff Q3), per-page upsert loop, cursor flush after every
  successful batch, IS-4 best-effort getArtists watermark, IS-5
  getScanStatus.lastScan capture, IS-6 phase=ready + cursor cleared.
  Resume is automatic: a non-empty initial_sync_cursor_json restarts
  at the persisted offset; a strategy mismatch between cursor and
  capability flags surfaces as SyncError::CursorIncompatible.
- C12 backoff. sync::backoff::Backoff implements the §6.8 schedule
  (2s → 4s → … cap 120s) with ±25% jitter via deterministic salt.
  retry_with_backoff wraps every endpoint call: transport / Navidrome
  failures retry up to MAX_ATTEMPTS_PER_BATCH (5), the cursor never
  advances on failure, success resets the counter. Cancellation
  AtomicBool is checked between attempts.
- C13 id remap. TrackRepository::upsert_batch_with_remap performs the
  §6.9 detect-and-rebind pass inside the same SQLite transaction as
  the upsert: a content_hash or server_path collision on a different
  existing id triggers UPDATE of child tables (track_offline,
  track_extension, track_fact, track_artifact, track_canonical_link),
  INSERT INTO track_id_history, DELETE old track row. Off when
  UnstableTrackIds is clear (generic Subsonic). New
  TrackIdHistoryRepository read-side helper for forward lookups
  (analysis cache reuse, Phase E).
- IngestStrategy enum + selector (sync::strategy) — N1 → S1 → S2;
  N1 requires Navidrome bearer credentials at runtime (skipped when
  None). S3 is enumerated for future file-tree fallback but returns
  StrategyUnsupported in v1 per kickoff Q3.
- InitialSyncCursor (sync::cursor) — JSON-serialisable
  { strategy, phase, library_scope, ingested_count, strategy_state }.
  StrategyState tagged enum: LinearOffset { offset } for N1/S1,
  AlbumCrawl { album_offset, current_album_id } for S2.
- mapping::subsonic_song_to_track_row + navidrome_song_to_track_row
  centralise the JSON → TrackRow projection. Subsonic path also reads
  replayGain.{trackGain,albumGain} from the raw value so PR-3b doesn't
  drop the columns that PR-2b reserves on TrackRow.
- Supporting bits in psysonic-integration:
  - subsonic types now derive Serialize so the runner can round-trip
    a typed Song back into raw JSON when feeding upsert.
  - navidrome::queries gains nd_list_songs_internal — pure async
    function (no #[tauri::command] decorator) that the N1 ingest
    loop calls directly. The existing Tauri command wraps it.

Tests added across sync::* and repos::track_id_history. Wiremock
covers S1 happy-path, mid-cursor resume from a persisted offset,
strategy mismatch → CursorIncompatible, 503 transient → retry-then-
succeed, AtomicBool cancellation → Cancelled, N1 paginated /api/song
ingest, S2 album crawl, and §6.9 remap firing under UnstableTrackIds
during an actual sync. Backoff schedule + jitter formula pinned.
TrackRepository remap path covered by content_hash collision,
server_path collision, hash+path-missing skip, identity-noop, and
remap-off compatibility with the existing upsert_batch contract.

Also fixes cucadmuh's PR-3a review minor 1: drops the dead
`mount_ok` scaffolding from sync::capability tests.

Out of scope per kickoff Q2:
- DeltaSyncRunner + tombstones → PR-3c
- Background task lifecycle, cancellation wiring, progress emit
  throttle, adaptive scheduler, request budget, bandwidth lane → PR-3d
- Tauri command surface for "sync now" / progress events → PR-5

* feat(library): search3 raw envelope fidelity for S1 ingest (PR-3b follow-up) (#797)

Picks up cucadmuh's PR-3b review minor 1: the S1 path in
InitialSyncRunner was reserialising the typed `Song` for
`track.raw_json`, dropping unknown OpenSubsonic extensions
(`replayGain`, `contributors`, …). N1 and S2 already carry the raw
sub-tree verbatim through `nd_list_songs_internal` and
`get_album_with_raw`; S1 now matches via the new
`SubsonicClient::search3_with_raw` mirror of the PR-2b pattern.

- subsonic::SubsonicClient::search3_with_raw — returns
  `(SearchResult, serde_json::Value)`; uses the existing
  `parse_envelope_with_raw` so error 70 / `Api { code, .. }` mapping
  stays consistent.
- sync::initial::run_s1 now calls `search3_with_raw` and feeds the
  per-song raw sub-tree (`raw_body.song[i]`) into
  `subsonic_song_to_track_row` instead of a typed reserialise.

Tests cover `search3_with_raw` round-trip on a payload with
`replayGain` + `contributors` (verifies the raw value preserves both)
and the empty-result case where the body is `searchResult3: {}`.
Plus an end-to-end S1 ingest test that asserts the persisted
`track.raw_json` column contains the OpenSubsonic extensions after a
full runner pass, and that `replay_gain_track_db` / `_album_db` still
land on the typed columns via the mapping helper.

Full review: psysonic-workdocs/internal/collaboration/handoffs/2026-05-19-pr-796-review.md

* feat(library): DeltaSyncRunner + TombstoneReconciler (Phase C3/C4, PR-3c) (#798)

Third sub-PR of Phase C — drives targeted delta passes on top of
PR-3a/b's foundation. Pure async; PR-3d will spawn it inside the
background scheduler.

- C3 DeltaSyncRunner. Walks spec §6.4 DS-0 … DS-9:
  - DS-0/1/2/3 cheap probe via `getArtists` (small/medium tier) or
    `getScanStatus` (huge tier when `ScanStatusAvailable`). Server
    watermark match → up_to_date short-circuit, scan-in-progress →
    deferred_scanning report; zero further requests in either case.
  - DS-4 targeted ingest. Strategy from capability_flags: N1-delta
    when NavidromeNativeBulk is set, otherwise S2-delta. S1 has no
    delta semantic so it's not used here.
    - N1-delta: GET /api/song _sort=updated_at _order=DESC, pages
      until rows fall under the local `MAX(server_updated_at)`
      watermark; out-of-band rows in the same page are dropped.
    - S2-delta: getAlbumList2 type=newest then type=recent, up to
      a small page cap; getAlbum is fetched only for album_ids the
      local store doesn't already have. Known albums are skipped
      so a play-bump under "recent" doesn't re-ingest the whole
      tracklist.
  - DS-6 id remap reuses TrackRepository::upsert_batch_with_remap.
  - DS-9 stamps next watermark (artists_last_modified_ms or
    server_last_scan_iso) + last_delta_sync_at.
  - DS-5 canonical matcher (Phase H) and DS-7 starred delta are out
    of scope for PR-3c.
- C4 TombstoneReconciler. Caller-driven streaming: each
  `reconcile_chunk(budget)` picks the next `budget` ids ordered by
  synced_at ASC, calls getSong, marks deleted=1 on code 70, and
  refreshes synced_at on every checked id so the queue rotates.
  Mode A (manual integrity) loops until checked == 0; Mode B
  (auto-threshold) tests `should_auto_reconcile(local, server, pct)`
  per delta tick and runs a small budgeted chunk. Memory bounded —
  no full local-id list ever held in RAM.

- SyncStateRepository: new getters for artists_last_modified_ms,
  server_last_scan_iso, library_tier; new
  set_last_delta_sync_at stamp helper. All column-scoped upserts
  preserve neighbouring fields.

Tests cover DS-2 short-circuit (watermark match), DS-3 defer
(scanning=true), N1-delta watermark cutoff (3 fresh + 2 stale rows →
only 3 upserted), S2-delta known-album skip (mock 404 on al_known
guards the assertion), DS-9 watermark + last_delta stamping,
should_auto_reconcile threshold cases (gap, tolerance, server=0,
local<=server), reconcile_chunk code-70 → deleted=1, budget +
ordering (oldest first, newest untouched), empty-store noop, and
cancellation.

PR-3d (background task, probe→flags wiring, progress emit, adaptive
scheduler, request budget, bandwidth throttle) lands next on the
same integration branch.

* feat(library): sync supervisor + progress channel + DS-8 wiring (Phase C5/C6, PR-3d1) (#799)

First half of PR-3d (cucadmuh-approved split per kickoff Q2).
Pure-Rust lifecycle + progress infrastructure on top of the runners
from PR-3a/b/c. Tauri events stay in the top crate (PR-5); this PR
only ships the channel the top crate will subscribe to.

- C5 SyncSupervisor. Spawns a sync workload inside a tokio task,
  owns the cancellation AtomicBool, and exposes a single-consumer
  mpsc receiver for ProgressEvent. join() returns the inner
  Result<(), SyncError>; panics surface as Storage so callers
  never need to know about tokio internals.
- C6 progress channel. New sync::progress module:
  - ProgressEvent enum — lean variants
    (PhaseChanged / IngestPage / Remapped / Tombstoned /
    Completed / Error). Server / scope context lives on the
    channel side (one supervisor = one scope).
  - Progress trait + NoopProgress default + ChannelProgress
    forwarding through tokio mpsc. Throttle is the simple
    last-emit-timestamp gate; terminal events (Completed /
    Error) bypass it.
- InitialSyncRunner + DeltaSyncRunner gain with_progress(...)
  builders. IS-1 / IS-6 emit PhaseChanged + Completed; delta
  emits PhaseChanged at strategy pick, Tombstoned at DS-8, and
  Completed at DS-9. Defaults to NoopProgress so existing call
  sites keep working.
- DS-8 wired. DeltaSyncRunner::with_tombstone_budget(n) drives
  TombstoneReconciler::reconcile_chunk(n) after DS-4 ingest;
  shares the runner's cancellation flag + sleep override. The
  DeltaSyncReport gains tombstones_checked / tombstones_deleted
  so callers can act on the counts.
- capability::probe_and_persist helper. Chains
  CapabilityProbe::run with sync_state writes: sets phase to
  "probing" before the probe, persists capability_flags, then
  drops back to "idle". PR-3d2 (the scheduler) will call this in
  front of every initial / delta run so the stored flags reflect
  the live server.

Tests cover: ChannelProgress throttle (zero-interval pass-through,
terminal bypass, non-terminal collapse, sender alive after
receiver drop), SyncSupervisor task completion + cancel +
panic-as-Storage + receiver-take-once, probe_and_persist
round-trip through SyncStateRepository (flags persisted, phase ends
at "idle"), DS-8 reconcile-after-ingest landing tombstones on
code 70 returns.

PR-3d2 follows with the adaptive scheduler (C8), request budget
(C9), poll EWMA (C10), and the bandwidth / queue priority lane
(C11).

* feat(library): adaptive scheduler + request budget + EWMA poll + bandwidth (Phase C8/C9/C10/C11, PR-3d2) (#800)

Second half of PR-3d per cucadmuh's kickoff-Q2 split. Wraps the
runners + supervisor from PR-3a/b/c/d1 into a tick-driven background
scheduler. Top crate (PR-5) plumbs the timer.

- C8 BackgroundScheduler. Tick-based — caller drives the interval,
  scheduler decides whether the tick should run. is_due(now_ms)
  checks sync_state.next_poll_at; tick(now_ms) either skips
  (not due / PrefetchActive pause), or runs a DeltaSyncRunner with
  the right budget + tombstone trigger, then stamps the next
  poll_at via the adaptive formula. No tokio task ownership —
  tests stay deterministic, PR-5 plugs spawn behaviour to taste.
- C9 RequestBudget. PassKind enum (PollTick / DeltaLight /
  DeltaMismatch / InitialSync) with caps per spec §6.2.5
  (1 / 50 / 200 / unlimited). RequestBudget::has_room(used) gates
  the runner; PR-3d2 ships the data type, runner enforcement of
  the cap is a future tightening (DeltaSyncRunner already has its
  own page cap so the soft cap mostly informs Settings).
- C10 PollStats EWMA. New sync::poll_stats with PollStats
  (artist_count, ewma_bytes, ewma_duration_ms, library_tier),
  observe()/set_artist_count()/reclassify() helpers, the §6.2.2
  tier table (<2k / 2k-15k / >15k or ewma_bytes >2MB), and
  next_interval_ms following the spec formula
  (base * load_factor * artist_factor, load_factor clamped
  [1, 10]).
- C11 PlaybackHint + ParallelismBudget. PlaybackHint enum
  (Idle / Playing / PrefetchActive) resolved to a
  ParallelismBudget { max_concurrent, min_request_gap_ms }.
  PrefetchActive pauses bulk (`max_concurrent = 0`) per
  §6.2.4; the scheduler honours it via tick short-circuit.
- Auto-tombstone wire. Before running the DeltaSyncRunner the
  scheduler tests `should_auto_reconcile(local, server, pct)`
  against the persisted counts; on threshold trip it sets
  `with_tombstone_budget(200)` (the §6.2.5 DeltaMismatch cap).
- SyncStateRepository gains poll_stats_json get/set,
  next_poll_at get/set, local_track_count get/set, and
  server_track_count get/set — all column-scoped upserts.

Tests: ~30 new across poll_stats / budget / bandwidth / scheduler.
EWMA seed + smoothing, tier-classification edges (artist + size
overrides), next-interval formula bounds (idle base, slow-network
load_factor clamp), RequestBudget caps per pass, ParallelismBudget
resolution, scheduler is_due (no schedule / future schedule),
tick short-circuit (not due, PrefetchActive pause), tick runs
delta and persists next_poll_at, auto-tombstone trigger above
5 % threshold, PollStats round-trip through SQLite.

Together with PR-3d1 this finishes Phase C — Tauri command surface
(D1-D4) lands with PR-5.

* feat(library): read-only Tauri command surface (Phase D1 part 1, PR-5a) (#801)

First sub-PR of Phase D per cucadmuh's kickoff Q1 split. Lands the
LibraryRuntime Tauri State plus the 8 read-only library commands
from spec §7.1. No SyncSupervisor spawn, no sync lifecycle commands,
no credentials store — those land in PR-5b.

- New psysonic_library::runtime::LibraryRuntime — Tauri State
  wrapping Arc<LibraryStore>. Top crate's lib.rs setup() now calls
  LibraryStore::init(app), wraps the result in the runtime, and
  app.manage's it. Mirrors the AnalysisCache wiring above it.
- New psysonic_library::dto module — camelCase wire DTOs per
  src-tauri/CLAUDE.md: SyncStateDto, LibraryTrackDto (flat
  projection over the track hot columns + raw_json sub-tree),
  LibraryTracksEnvelope, TrackArtifactDto, TrackFactDto,
  OfflinePathDto, TrackRefDto. local_tracks_max_updated_ms helper
  surfaces the implicit N1-delta watermark on the SyncStateDto.
- New psysonic_library::payload module — pure
  ProgressEvent → LibrarySyncProgressPayload mapper (the
  payload Tauri events carry once PR-5b plugs the supervisor's
  mpsc receiver into AppHandle::emit). Constants for the event
  names too. Unit-testable without Tauri runtime.
- New psysonic_library::commands module with 8 #[tauri::command]
  handlers:
  - library_get_status — joins the sync_state row + the
    track-watermark MAX query into one SyncStateDto.
  - library_search — FTS5 via the existing search_tracks helper,
    paginated; hydrates hits to full LibraryTrackDto.
  - library_get_track — single SELECT through new
    TrackRepository::find_one.
  - library_get_tracks_batch — capped at 100 refs/call per spec,
    preserves caller-supplied order, drops unknowns silently.
  - library_get_tracks_by_album — ordered by
    disc/track/id via new TrackRepository::find_by_album.
  - library_get_artifact — flexible WHERE over track_artifact
    (artifact_kind required, source/format optional), latest
    fetched_at wins.
  - library_get_facts — fact_kinds filter optional;
    returns all rows for the (server_id, track_id) pair when
    none specified, sorted by fact_kind + fetched_at DESC.
  - library_get_offline_path — returns local_path with a
    `missing: true` flag when the row is absent.
- TrackRepository gains find_one / find_batch / find_by_album
  with a shared row-to-TrackRow mapper. SQL constants pinned next
  to the existing UPSERT_SQL so a schema change touches one file.
- src-tauri/src/lib.rs: LibraryStore::init in setup(), the eight
  command handlers added to invoke_handler!.

Tests cover: DTO field-name camelCase (IPC contract guard),
LibraryTrackDto round-trip through TrackRow, raw_json fallback to
Value::Null on bad input, local_tracks_max_updated_ms ignores
deleted rows, TrackRepository::find_one / find_batch / find_by_album
ordering + unknown-ref drop, ProgressEvent mapper across all six
variants + serialization keys camelCase. Library tests at 166;
workspace stays green.

Out of scope per kickoff Q1:
- Mutating commands (library_sync_*, library_patch_*,
  library_put_*, library_purge_*, library_delete_*) → PR-5b
- SyncSupervisor spawn + background scheduler tick loop +
  progress emit → PR-5b
- library_sync_bind_session / clear_session credentials → PR-5b
- TS wrappers + Settings UI + server-remove modal → PR-5c
- library_advanced_search / library_search_cross_server SQL
  builders → PR-5d

* feat(library): sync lifecycle + mutate + purge Tauri surface (Phase D1 part 2, PR-5b) (#802)

Second sub-PR of Phase D per cucadmuh's kickoff Q1 split. Adds the
mutating side of §7.1 plus the SyncSession credentials store, the
PlaybackHint setter, the orchestrator that runs InitialSyncRunner /
DeltaSyncRunner under a Tauri AppHandle and emits library:sync-progress
and library:sync-idle events, and the top-crate scheduler tick task
that sweeps every bound session through BackgroundScheduler::tick.

- LibraryRuntime extended per kickoff Q2: sync_sessions HashMap,
  playback_hint cell, current_job (cancel handle + identity), and
  scheduler_cancel flag the tick task watches. Kickoff sketch said
  Mutex<Option<SyncSupervisor>> — supervisor's join() consumes self,
  so holding it in the mutex would block library_sync_cancel behind
  the orchestrator's join; CurrentJob carries the Arc<AtomicBool>
  cancel + metadata instead, orchestrator task owns supervisor /
  receiver / join.
- New commands (spec §7.1):
  - library_sync_bind_session — caches Subsonic creds in memory,
    tries navidrome_token once for bearer cache, runs
    probe_and_persist so capability_flags reflect the live server.
  - library_sync_clear_session — drops cached credentials.
  - library_set_playback_hint — JS pushes idle / playing /
    prefetch_active from existing audio listeners.
  - library_sync_start — dispatches InitialSyncRunner (mode='full')
    or DeltaSyncRunner (mode='delta', with auto-tombstone budget
    when local/server count gap exceeds threshold). Spawns runner
    + orchestrator task that drains the progress mpsc into
    library:sync-progress emits and emits library:sync-idle when
    the runner exits.
  - library_sync_cancel — trips the current job's cancel flag.
  - library_patch_track — sparse JSON patch (starredAt, userRating,
    playCount, playedAt) per §6.5.
  - library_put_artifact / library_put_fact — upserts with
    ON CONFLICT scoped to the PK so lyrics / BPM writes survive
    re-fetches.
  - library_purge_server — transactional DELETE across the v1
    schema tables for this server_id. include_offline (default
    false) controls track_offline + bytes_freed.
  - library_delete_server_data — alias that always purges offline
    too (logout flow).
- src-tauri/src/lib.rs setup() spawns a 30 s
  MissedTickBehavior::Skip task that snapshots bound sessions and
  drives BackgroundScheduler::tick(now_ms) for each. Honours
  runtime.scheduler_cancel + the current PlaybackHint. Background
  ticks stay silent (NoopProgress) — Tauri emit for the
  scheduler path lands when Settings (PR-5c) surfaces it.
- psysonic-integration::navidrome re-exports navidrome_token so the
  bind_session command can drive the bearer cache without making
  the client module pub.

Tests cover: LibraryRuntime session round-trip (set/get/clear
scopes per server), playback_hint default + setter, snapshot
returns clones so callers can mutate freely. Existing library tests
stay green (171 → 171; new code paths under the Tauri command
surface — devtools integration smoke is PR-5c's job).

Out of scope per kickoff Q1:
- src/library/ TS wrappers + Settings UI subsection + server-remove
  modal → PR-5c
- library_advanced_search / library_search_cross_server SQL
  builders → PR-5d
- Background-tick Tauri emit (NoopProgress today) → PR-5c
- analysis_cache cross-purge in library_purge_server → PR-6

* feat(library): typed invoke wrappers + verify_integrity command (Phase D2 + part of D1, PR-5c) (#803)

Frontend-facing slice of Phase D. Ships the typed src/api/library.ts
wrapper layer that any Settings / browse code will import from, plus
the manual-integrity backend command PR-5b's review §5 note 2 called
out as missing.

Scope cut from cucadmuh's PR-5 kickoff Q1 split: that proposal had
PR-5c = D2 + D3 + D4 (wrappers + Settings subsection + server-remove
modal). The Settings UI + server-remove + audio playback-hint
wiring + authStore extensions + i18n strings turn into a thick frontend
patch in their own right; landing them in one PR with the wrappers
would mix Tauri-surface review with Settings UX review. The split:

- PR-5c (this PR) — D2 wrappers + library_sync_verify_integrity.
- PR-5c-ui (follow-up) — D3 Library Settings subsection, D4
  server-remove modal contract, playback hint feed, authStore /
  i18n.

Per kickoff exit clause ("Do not split 5c unless review size
forces it"). Reviewable as a clean Tauri-surface vs UX boundary.

- Backend: `library_sync_verify_integrity { serverId, libraryScope? }`
  command — same dispatch shape as `library_sync_start { mode:'delta' }`
  but always forces the full `DELTA_MISMATCH_CAP` tombstone budget
  regardless of the local/server count gap. Spec §6.7 Mode A user-
  initiated full reconcile bypasses the threshold check that
  governs background ticks.
  `library_sync_start` itself is refactored to delegate to a private
  `library_sync_start_inner(force_full_tombstone)` so both entry
  points share the runner-spawn + orchestrator + emit code.

- Frontend `src/api/library.ts`: full typed wrapper layer over the
  19 `library_*` Tauri commands. DTO mirrors carry the camelCase
  wire shape (`SyncStateDto`, `LibraryTrackDto`, `TrackArtifactDto`,
  `TrackFactDto`, `OfflinePathDto`, `PurgeReportDto`, `SyncJobDto`,
  `TrackRefDto`, `ArtifactInputDto`, `FactInputDto`). Plus the
  `LibrarySyncProgressPayload` / `LibrarySyncIdlePayload` interfaces
  and `subscribeLibrarySyncProgress` / `subscribeLibrarySyncIdle`
  helpers that wrap `@tauri-apps/api/event` listen. PlaybackHint
  literal type lives here too (`'idle' | 'playing' | 'prefetch_active'`)
  so the audio listeners in PR-5c-ui can import a single source of
  truth.

- `src-tauri/src/lib.rs` adds the new verify_integrity handler to
  the `invoke_handler!` aggregate.

Tests: library tests stay at 171 — verify_integrity is exercised
through the existing `sync_start_inner` paths; the wrapper layer is
trivial passthrough that TypeScript types already check. Vitest
coverage for the typed wrappers belongs with PR-5c-ui where there
are real consumers (LibraryTab) to drive integration tests.

PR-5c-ui (next) lands:
- Library Settings subsection (§7.3 minus advanced toggles)
- ServerRemoveModal extension (keep vs delete local index per §5.6)
- authStore: libraryIndexEnabledByServer + auto-reconcile toggle
- src/store/audioListenerSetup audio:playing / ended /
  setDeferHotCachePrefetch → library_set_playback_hint
- i18n keys for the new strings

* feat(library): Settings library index UI + playback hint + purge-on-remove (Phase D3/D4, PR-5c-ui) (#804)

* feat(library): Settings library index UI + playback hint + purge-on-remove (Phase D3/D4, PR-5c-ui)

Frontend half of Phase D, on top of PR-5c's typed wrappers. Wires the
Settings → Library subsection (§7.3), the audio playback-hint feed
(§6.2.4), and the server-remove keep-vs-delete choice (§5.6).

- New libraryIndexStore (Zustand, persisted) — per-server enable flag
  + auto-reconcile toggle. Kept out of authStore so the index feature
  evolves independently and the persisted blob stays small.
- New LibraryIndexSection in Settings → Library:
  - Per-server "Enable local library index" toggle → binds /
    clears the Rust sync session with the active server's
    credentials. Off by default (P6).
  - Read-only status (Idle / Checking / Initial sync / Ready (n) /
    Error) polled from library_get_status every 3 s, overlaid with
    live library:sync-progress events.
  - Sync now / Verify integrity / Cancel buttons. Verify runs one
    §6.7 pass (budget 200) per click; the status line shows the
    checked/removed counts so large libraries can be continued with
    another click (auto-resume loop is a follow-up).
  - Auto-reconcile toggle.
  - Subscribes to library:sync-progress + library:sync-idle for the
    active server; errors surface as a toast.
- Audio playback hint: handleAudioPlaying → 'playing',
  handleAudioEnded → 'idle' via notifyLibraryPlaybackHint, which
  gates on the per-server index toggle + dedupes repeated hints so
  the IPC boundary isn't spammed on every progress tick.
- ServersTab delete flow: when a server with an enabled index is
  removed, a second confirm offers keep-vs-delete of the local
  library cache (OK = library_delete_server_data, Cancel = retain
  for offline). Always clears the sync session.
- i18n: en + de keys for the new strings; other locales fall back
  to en via i18next (later sweep).

Per PR-803 review §5: verify-integrity resume UX is one-pass-per-click
with a visible counter; sync_start idempotency (replaces in-flight)
is surfaced via the Cancel button appearing while busy.

Out of scope:
- VirtualSongList / playerStore local-mode consumers → PR-7 (F1/F3/F5)
- library_advanced_search / cross-server UI → PR-5d + PR-7 F2
- Auto-resume loop for very large verify-integrity runs → follow-up
- Search-all-servers + threshold input (advanced §7.3) → later

* fix(library): normalize server base URL before bind probe

The bind toggle threw "subsonic transport: builder error | relative
URL without a base" — `server.url` is stored bare (e.g.
`nas.example.com`) and reqwest needs a scheme. Two-sided fix:

- Frontend: LibraryIndexSection passes `authStore.getBaseUrl()`
  (adds http:// + strips trailing slash) instead of the raw
  `server.url`, matching the existing `subsonic.ts` convention.
- Backend: `library_sync_bind_session` normalizes the incoming
  `base_url` defensively so the stored session + every downstream
  caller (sync_start, scheduler tick, navidrome_token) gets a
  scheme-qualified URL regardless of what the WebView sends.

Tests: normalize_base_url covers bare host, trailing slash, existing
http/https scheme, and whitespace.

* fix(library): re-bind sync session on startup + server switch

"Library sync failed: no bound session" — the per-server index toggle
persists in localStorage but the Rust sync session (credentials +
bearer) lives in process memory and is gone after an app restart, so
the toggle showed "on" while no session existed. Per PR-5 kickoff Q5
("on server connect if index already on").

- New `ensureActiveServerSessionBound()` helper: re-binds the active
  server's session when its index toggle is enabled. Best-effort —
  silent on failure (Settings surfaces the real error on explicit
  toggle).
- MainApp re-binds on every `activeServerId` change (covers app
  startup + server switch — `setActiveServer` drives the effect).
- LibraryIndexSection re-binds on mount before the first status poll,
  so Sync now / Verify integrity work immediately even when the
  toggle was already on from a previous run.

* fix(library): trigger initial full sync on first enable (PR-804 review §5.1)

cucadmuh's PR-804 review flagged this as release-blocking: the toggle
only bound the session and «Sync now» / the background tick ran
delta-only, so a fresh enable left the index empty — delta can't
populate a never-synced library.

- On first enable, after bind, fetch status and dispatch
  `library_sync_start { mode: 'full' }` when `lastFullSyncAt` is null
  (matches spec §6.2 "initial sync always background").
- «Sync now» now picks mode adaptively: `full` until a full sync has
  completed, `delta` afterward — so the button works both for the
  initial population and incremental updates.

Other PR-804 review notes (auto-reconcile toggle → backend wiring,
prefetch_active hint, clear-old-session-on-switch) stay as documented
non-blocking follow-ups.

* feat(library): advanced search + cross-server SQL builders (Phase D-search, PR-5d) (#806)

* feat(library): advanced search + cross-server SQL builders + commands (Phase D-search, PR-5d)

- FilterFieldRegistry SQL resolution: SqlFragment, compare_fragment, validate_for_entity (§5.13.5)
- Advanced Search builder: per-entity track/album/artist queries; genre (case-insensitive), year, starred, bpm filters; bpm dual-storage resolution (§5.13.4); libraryScope; sort allowlist; full-match totals
- Cross-server FTS union (§5.5B / §5.9 A') with canonical-id dedup
- library_advanced_search + library_search_cross_server commands, registered in the shell

* feat(library): typed advanced search / cross-server invoke wrappers (PR-5d)

Mirror request/response DTOs and add libraryAdvancedSearch / librarySearchCrossServer
in src/api/library.ts. UI parity (AdvancedSearch.tsx) stays PR-7.

* fix(library): self-heal stale/unreadable initial-sync cursor instead of bricking (#807)

The initial-sync cursor records the ingest strategy it was created under.
When a re-probe later selects a different strategy (e.g. the Navidrome
native bearer is briefly unavailable, downgrading N1->S2), the cursor guard
returned a hard error — and since nothing clears the cursor, every later
full sync failed with no recovery path.

Reset the stale (or unreadable) cursor and start fresh under the selected
strategy instead of erroring. Re-ingest is idempotent (upsert); the
tombstone pass reconciles leftovers.

* fix(library): emit per-batch progress during initial sync (#808)

The initial-sync runner only emitted PhaseChanged (start) and Completed
(end), so the Settings status sat at "initial_sync" with no count for the
entire ingest — looking stuck on large libraries even while rows landed.

Emit IngestPage per batch from the N1/S1/S2 loops with the running ingested
total; the existing <=2 Hz throttle paces it. The frontend already renders
the count from these events.

* feat(library): Advanced Search reads the local index when ready (Phase F2, PR-7a) (#811)

When the active server's index is fully synced, Advanced Search serves
query / genre / year / result-type from library_advanced_search (instant +
offline) and pages songs locally. On not-ready or any failure it falls back
to the existing network path unchanged (spec 5.13.6). Results map from each
entity's stored Subsonic rawJson, with the flat hot columns as a fallback.

* feat(library): canonical matcher — link tracks by ISRC/MBID on ingest (Phase H1/H2, PR-4a) (#812)

Adds the strong-key cross-server matcher (spec §5.5A): on every track upsert,
link (server_id, track_id) to a canonical id derived from its ISRC (preferred)
or MBID recording. Deterministic id (`{kind}:{value}`) keeps it O(1) and
idempotent — no lookup-then-create race, no fuzzy loop on the bulk path.
Tracks without a strong key stay standalone (fuzzy/search-time matching is H3).

* feat(library): cross-server fuzzy fallback in search (Phase H3, PR-4b) (#813)

library_search_cross_server now returns a `fuzzy` list alongside the exact
FTS `hits` (spec §5.9): per-server `title LIKE %query%` for matches the exact
pass missed (diacritics, partial words), capped per server, excluding exact
hits and deduped by canonical id against them. Shared `like_contains` moved
to the `search` module.

* feat(library): FactRepository with TTL + provenance rules (Phase E4, PR-6a) (#814)

Typed CRUD over track_fact behind library_get_facts / library_put_fact
(spec §5.12): get lazily deletes the track's expired facts then returns the
survivors (no background GC, P34); a `user` bpm fact also writes the hot
track.bpm column so the override wins and survives a resync (R6-3.4). The
commands now delegate here instead of inlining the SQL.

* feat(library): ArtifactRepository with TTL + 512KB cap (Phase E4, PR-6b) (#815)

* fix(integration): decode OpenSubsonic isrc string-array on Song (#818)

OpenSubsonic types `isrc` as `string[]`; Navidrome 0.61.2 ships it as
`isrc: []` or `["USRC…"]`. The typed `Song.isrc: Option<String>` could
not decode either form, which broke the S1 (`search3`) and S2
(`getAlbum`) ingest paths on real Navidrome libraries — initial sync
could not complete past the first array-valued track.

Add a tolerant `de_string_or_seq` deserializer: plain string →
`Some`, non-empty array → first usable value (string element, or an
object element's `name` for the `[{ "name": … }]` shape), `[]`/null →
`None`. The full multi-value set still survives verbatim in
`track.raw_json` (ADR-7). Applied to `Song.isrc`.

Per maintainer policy R7-15 (workdocs question
2026-05-20-large-library-ingest-client-only, checklist item 1):
treat Navidrome as a black box, harden the client decode.

Tests cover `isrc: []` → None, populated array, and the legacy
single-string form.

* feat(library): large-library ingest strategy — S1 over N1 (R7-15) (#819)

Per maintainer policy R7-15 (large-library ingest, client-only): very
large Navidrome catalogs must not start initial sync on N1 — its native
`/api/song` returns HTTP 500 beyond a deep offset and can never finish.
S1 (`search3`) does not hit that wall.

- Add `IngestStrategy::select_initial_strategy(flags, server_track_count,
  n1_bulk_unreliable)`. Large libraries (count > LARGE_LIBRARY_THRESHOLD,
  default 40_000) or servers flagged `n1_bulk_unreliable` route to S1 — or
  S2 when search3 bulk is absent. Normal-size libraries keep the cheapest
  N1 → S1 → S2 chain unchanged.
- Persist the learned per-server `n1_bulk_unreliable` flag on `sync_state`
  (additive migration 002, DEFAULT 0). The mid-run N1→S1 fallback that
  sets it lands in a follow-up.
- Capture `getScanStatus.count` in the capability probe and persist it as
  the `server_track_count` watermark, so the threshold applies from the
  first sync rather than only after N1 hits the wall once. A count-less
  probe never clobbers a watermark from a prior run.
- The initial-sync runner now selects via the new policy.

Tests: selector table (all branches incl. threshold boundary and the
search3-absent fallback), repo flag roundtrip, probe count capture +
watermark-preservation, migration head-version bookkeeping.

* feat(library): freeze ingest strategy on resume (R7-15 Q3) (#820)

A persisted initial-sync cursor that has already made progress must resume
under its own strategy and ignore what a fresh capability probe would now
pick. Previously any strategy mismatch reset the cursor to a fresh one — so
a flapping Navidrome bearer (N1 flag toggling between probes) restarted
ingest from offset 0 on every launch, which is why large initial syncs
never completed across restarts.

`load_or_init_cursor` now:
- resumes the cursor's strategy when it has progress (`ingested_count > 0`
  or `phase != Ingest`), regardless of the re-selected strategy;
- adopts the freshly-selected strategy only when there is no resumable
  progress (offset 0), where re-selecting costs nothing;
- still resets a corrupt/unreadable cursor rather than hard-erroring.

One guarded exception: a cursor still on N1 after the server was learned
`n1_bulk_unreliable` is known-broken and re-selects onto the non-N1 path
instead of resuming a wall-bound N1 loop (the mid-run N1→S1 fallback that
preserves progress lands next).

Tests: resume-with-progress freezes strategy and keeps the count;
no-progress cursor adopts the re-selected strategy; known-broken N1 cursor
re-selects; unreadable cursor still resets.

* feat(library): one-way N1→S1 fallback on deep-offset 500 (R7-15 Q5) (#821)

When the N1 ingest loop hits a persistent HTTP 500 at or beyond the
deep-offset safety line (`N1_DEEP_OFFSET_SAFE`, 50_000) it now treats it
as Navidrome's server-side deep-offset wall rather than a transient error:
it learns `n1_bulk_unreliable` for the server and finishes the sync on S1.

- `run_n1` catches the wall after retry exhaustion (`n1_hit_deep_offset_wall`:
  HTTP 500 AND offset >= the safety line) and hands off to `fall_back_n1_to_s1`.
  A 500 below the line stays a propagated error — no silent downgrade.
- The fallback flags the server, then restarts S1 from offset 0. N1 (`id ASC`)
  and S1 (`search3` default order) don't share an offset space, so resuming
  from the N1 offset would skip songs; re-ingest is idempotent (PK upsert),
  duplicate work over the rows N1 already wrote is acceptable for v1. The
  cursor is rewritten in place, never zeroed.
- One-way only: S1 never flips back to N1 mid-run. Combined with the
  persisted flag and the resume freeze, a future sync selects S1 directly.
- `N1_DEEP_OFFSET_SAFE` is overridable on the runner so the fallback is
  testable without 50k rows of fixture data.

Tests: deep-offset 500 falls back to S1, ingests the full set without
duplicating N1's rows, and persists the flag; a shallow 500 propagates and
does not flag the server.

* feat(library): cache + retry Navidrome bearer, keep N1 flag on transient loss (R7-15 Q3) (#822)

A flaky `/auth/login` previously stripped N1 for a whole bind: the bearer
was fetched once, best-effort, and a single miss dropped to Subsonic-only.
Per R7-15 Q3 a transient `navidrome_token` failure must not drop the
`NavidromeNativeBulk` capability.

- `bind_session` fetches the bearer with `navidrome_token_with_retry`
  (3 attempts, short backoff); if it still fails, it keeps the bearer
  cached from a prior bind instead of overwriting it with `None`. The token
  / credentials are never logged.
- `probe_and_persist` preserves a previously-learned `NavidromeNativeBulk`
  flag when it probes without a token — the server still supports
  `/api/song`; only the bearer is missing this bind. The capability is a
  stable server property, so a token-less probe must not clear it.
- `library_sync_start_inner` masks `NavidromeNativeBulk` from *this run's*
  strategy selection when the session has no token, so the run proceeds
  Subsonic-only (S1/S2) instead of selecting N1 with no creds. The
  persisted capability stays intact for a later bind that recovers the
  token. The in-flight cursor is already protected by the resume freeze.

Tests: token retry yields the token on success and `None` after exhausting
attempts; the probe keeps a learned N1 flag across a token-less re-probe.

* feat(library): mid-run S1→S2 fallback on persistent S1 failure (R7-15 Q8) (#823)

The N1→S1 fallback (#821) had no analogue when S1 itself fails on a server.
Per R7-15 Q8, a persistent S1 failure (C12 retries already exhausted) must
fall back to the universal S2 album crawl — no new artist-walk strategy.

- `run_s1` catches a persistent fetch failure from the `search3` retry loop
  (`is_fetch_failure`: transport / HTTP / decode / Subsonic API / not-found)
  and hands off to `fall_back_s1_to_s2`. Cancellation and storage errors
  propagate untouched.
- The fallback restarts S2 from scratch. S1 (`search3` order) and S2
  (album-list order) don't share an offset space, so resuming from the S1
  offset would skip songs; re-ingest is idempotent (PK upsert). The cursor is
  rewritten in place, never zeroed — the resume freeze then keeps the run on
  S2 across restarts.

This completes the ingest fallback chain N1→S1→S2 from the §6.3 strategy
order; the start-time "no search3 → S2" selection was already covered (#819).

Tests: a persistent S1 500 falls back to S2 and the album crawl ingests the
track.

* fix(library): resume interrupted initial sync on startup (#824)

* fix(library): resume interrupted initial sync on startup

An initial sync killed mid-run (app restart) sat at `idle` until the user
clicked «Sync now» — the background scheduler is delta-only and the
auto-full-sync only fired on the index toggle, not on the startup re-bind.

`resumeInitialSyncIfIncomplete` runs after the active server's session is
re-bound (startup + server switch): if no full sync has completed yet
(`!lastFullSyncAt`) it dispatches `library_sync_start { mode: 'full' }`,
which resumes from the persisted cursor instead of restarting from zero.
Once a full sync has landed it is a no-op, so delta stays the scheduler's
job. Best-effort — errors stay silent (Settings surfaces them on explicit
action).

Tests: starts a full sync when none has completed, no-ops once a full sync
has landed, stays silent when the status lookup fails.

* fix(library): silence cancelled-sync toast, de-dupe startup resume

Two rough edges from the startup resume:

- A cancelled sync surfaced as «Library sync failed: sync cancelled». The
  orchestrator emitted the runner's `Cancelled` result as an error on the
  sync-idle event. Cancellation is expected — the user cancelled, or a newer
  `library_sync_start` superseded the job (server switch / startup resume) —
  and is documented as silent. `sync_outcome_to_result` now maps
  `SyncError::Cancelled` to a clean idle, only real errors toast.
- `resumeInitialSyncIfIncomplete` is now de-duped per server. React
  StrictMode fires the startup effect twice, so a second `library_sync_start`
  cancelled the first (`set_current_job` is cancel-and-replace) — harmless
  with the fix above, but the dedupe avoids the wasted job + probe entirely.

Tests: `sync_outcome_to_result` keeps `Cancelled` silent and forwards real
errors; concurrent resume calls start a single full sync.

* fix(library): run DB read commands off the main thread (async) (#825)

The 10 library read commands were synchronous (`pub fn`). Per the Tauri v2
docs, commands without `async` run on the main thread — so a read that
blocks freezes the UI. During an initial sync the runner holds the single
`Mutex<Connection>` for a whole batch write (500 rows × per-row remap on
Navidrome + upsert + FTS, one transaction), and the Settings library
section polls `library_get_status` on an interval. Each batch write blocked
that polled read on the main thread → the window greyed out until the batch
finished, with the freeze growing as the DB grew.

Make the DB-touching read commands `async` so they run off the main thread:
`library_get_status`, `library_search`, `library_get_track`,
`library_get_tracks_batch`, `library_get_tracks_by_album`,
`library_get_artifact`, `library_get_facts`, `library_get_offline_path`,
`library_advanced_search`, `library_search_cross_server`. Reads still
serialize behind the writer (the single connection is intentional — the
schema mirrors `analysis_cache`, spec §5.1), but the wait no longer blocks
the UI. State-only commands stay sync. Invoke names / payloads are
unchanged, so the frontend is unaffected.

Spec §15 R7-15 follow-up — surfaced in live QA on a 170k library.

* feat(library): scope analysis cache by server_id (E1, schema only) (#826)

Add a versioned migration to audio-analysis.sqlite so waveform/loudness
rows are keyed per server. This is the schema-only step (PR-6c-1): every
existing row migrates to server_id='' and behaviour is unchanged. The
server_id write/read wiring, legacy fallback and lazy re-tag follow in 6c-2.

- migrations 001 (baseline = the pre-versioning schema) + 002 (rebuild the
  three tables with server_id; PK (server_id, track_id, md5_16kb), loudness
  + target_lufs)
- versioned runner mirroring the library store; each migration commits its
  schema change and version marker in one transaction, so a failure or crash
  rolls the whole migration back and retries cleanly
- VACUUM INTO snapshot before the table rewrite as a safety net beyond the
  transaction (disk-full at COMMIT, FS corruption)
- TrackKey gains server_id; all callers pass "" for now

* feat(library): analysis cache server_id wiring (E1, 6c-2) (#827)

* feat(library): scope analysis cache writes/reads/deletes by server_id (E1 wiring)

Build on the 6c-1 schema migration: thread the playback server scope
(playbackServerId ?? activeServerId) through the analysis cache so a server
switch can no longer surface another server's waveform/loudness for the same
bare track_id.

- Write: seed_from_bytes_* and the CPU-seed / HTTP-backfill queues carry a
  server_id; every audio write path (in-memory, ranged, legacy stream, local
  file, spill, preload), the syncfs offline/hot caches, and the backfill
  command write under the playback server (empty = legacy '').
- Read: get_latest_*_for_track and the exact-key lookup try the server scope
  first, then fall back to the legacy '' rows; a legacy hit is re-tagged onto
  the server scope (INSERT OR IGNORE, never clobbers a precise row). No bulk
  backfill — existing caches re-tag lazily on play, so they are not
  re-analysed wholesale.
- The backend gain-resolution path (loudness normalization, replay-gain
  updates, device resume) is scoped via a pinned current_playback_server_id on
  the audio engine, so normalization keeps working for server-scoped rows.
- Delete: delete_*_for_track_id scope to (server + legacy ''); reseed on one
  server no longer wipes another server's analysis. delete_all_waveforms stays
  global (Settings -> Storage).

Tauri boundary: analysis_get_waveform(_for_track), analysis_get_loudness_for_track,
analysis_delete_waveform/loudness_for_track and analysis_enqueue_seed_from_url
gain an optional serverId; audio_play and audio_preload gain an optional
serverId. All additive (absent = legacy '').

* feat(library): pass playback serverId to analysis IPC (E1 wiring)

Send getPlaybackServerId() (queueServerId ?? activeServerId) with every
analysis-cache call so reads/writes/deletes scope to the right server:

- audio_play / audio_preload (playTrack, resume, queue-undo restore, gapless
  byte-preload)
- analysis_get_waveform_for_track / analysis_get_loudness_for_track (waveform +
  loudness refresh)
- analysis_delete_waveform/loudness_for_track + analysis_enqueue_seed_from_url
  (reseed + loudness backfill)

Absent serverId stays backward-compatible (legacy '' scope).

* feat(library): content_hash from playback (E2, 6d) (#828)

* feat(library): record playback content_hash into the track store (E2)

Bridge the playback-derived md5_16kb into library `track.content_hash` (R7-16 Q4)
so id-remap can rebind a track when the server reassigns ids (§6.9).

- New `ContentHashSink` port in psysonic-core (closure handle, mirrors
  PlaybackQueryHandle): keeps psysonic-analysis decoupled from psysonic-library.
- `seed_from_bytes_into_cache` returns the computed md5; `seed_from_bytes_execute`
  fires the sink after a successful seed (Upserted or cache-hit) when a real
  server is known. The shell crate registers the sink to patch the library.
- `patch_content_hash` + `library_patch_track`'s new optional `contentHash`
  field write it; both no-op when the library has no row for (server_id, id),
  i.e. the index is off for that server.
- Sync upsert no longer clobbers it: `content_hash = COALESCE(NULLIF(
  excluded.content_hash,''), track.content_hash)` — a sync (which passes NULL)
  preserves the playback hash, a non-empty incoming hash still wins.

No schema migration — the `content_hash` column already exists. Tauri boundary:
`library_patch_track` gains optional `contentHash` (additive).

* feat(library): expose contentHash on libraryPatchTrack wrapper (E2)

Add optional `contentHash` to the `libraryPatchTrack` patch type so the TS
contract matches the extended Rust command. Normally written by the Rust
analysis bridge; exposed for completeness.

* feat(library): enrichment summary on library_get_track (E3, 6e) (#829)

* feat(library): enrichment summary on library_get_track (E3)

Add an optional `enrichment { waveformReady, loudnessReady, lyricsCached }` to
the single-track `library_get_track` read (R7-16 Q5). Read-only, per-server,
never blocks on the network; list/batch projections leave it unset.

- New `AnalysisReadinessQuery` port in psysonic-core (closure handle, mirrors
  ContentHashSink) keeps psysonic-library decoupled from psysonic-analysis. The
  shell crate registers it to probe the analysis cache by exact
  (server_id, track_id, content_hash) key with legacy '' fallback — read-only,
  no re-tag. waveform/loudness readiness is gated on a known content_hash (E2).
- `lyricsCached` from a new pure-read `ArtifactRepository::lyrics_cached`
  (valid, non-expired, non-not_found lyrics row).
- `library_purge_server`'s `includeAnalysis` documented as a deliberate v1
  no-op (R7-16 Q7): analysis is never deleted on purge / server remove.

Tauri boundary: `LibraryTrackDto` gains optional `enrichment` (additive).

* feat(library): mirror enrichment on LibraryTrackDto wrapper (E3)

Add `TrackEnrichmentDto` + optional `enrichment` to the TS `LibraryTrackDto` so
the contract matches the extended `library_get_track` response.

* feat(library): VirtualSongList browses the local index when ready (F1) (#830)

The all-songs browse now serves pages from the local library index when it is
ready for the active server, falling back to the unchanged network path
otherwise.

- `runLocalSongBrowse` (reuses the F2 local-read adapters): empty-query
  browse-all via `library_advanced_search`, whose default track order
  (`t.title COLLATE NOCASE ASC`) matches the network `ndListSongs('title','ASC')`
  path, so paging stays coherent across a local↔network boundary.
- Gated per page on `libraryIsReady` + `source === 'local'`; any miss / failure
  returns null → VirtualSongList uses the existing browse path unchanged.
- Search (non-empty query) stays on the network path for now; rich search is
  already covered by Advanced Search (F2).

* feat(library): patch-on-use for star/rating/scrobble (PR-7 F3) (#831)

* feat(library): library_patch_track clears nullable fields on explicit null (F3)

Extract the patch logic into a testable `apply_track_patch`. Nullable integer
fields (`starredAt` / `userRating` / `playCount` / `playedAt`) now distinguish an
absent key (leave untouched) from an explicit `null` (clear the column), so
`unstar` ({ starredAt: null }) actually un-stars the local row. `.map` keeps the
present/absent distinction; `as_i64()` yields the value or `None` → bound as SQL
NULL. F3 is the first caller that sends null, so no existing behaviour changes.

* feat(library): patch-on-use wiring for star / rating / scrobble (F3)

After a successful star/unstar, setRating, or play scrobble, mirror the change
into the local library index via `library_patch_track` so its reads (browse F1,
advanced search F2) reflect the action immediately — no stale list after a rate,
no full resync.

- `patchLibraryTrackOnUse` helper: fire-and-forget, gated on the index being
  enabled for the server; the Rust command additionally no-ops when no row
  matches (album/artist id, or index off).
- Wired at the central API chokepoints: `star`/`unstar` (song only) →
  `starredAt`, `setRating` → `userRating`, `scrobbleSong` → `playedAt`.
- `play_count` is left to the next sync (the patch sets absolute values; a
  correct increment needs the current base).

F4 (deprecating the player-store override maps) is intentionally separate —
removing them would break instant star feedback when the index is off.

* feat(library): full-queue restore from the index on startup (PR-7 F5) (#832)

Persist the whole queue as a lightweight ref list and rehydrate it from the
local index on startup, so the entire queue survives a restart instead of only
the windowed slice (R7-17 / §8.6).

- Persist adds `queueRefs` (full ordered ids) + `queueRefsIndex` alongside the
  existing windowed `queue`. Ids are tiny; the windowed objects stay as the
  no-index fallback.
- `hydrateQueueFromIndex` (startup, after session bind): when the library index
  is ready for the queue's server, hydrate the full queue via
  `library_get_tracks_batch` (batched ≤100), map `songToTrack ∘ trackToSong`,
  re-locate the current track so `queueIndex` stays aligned, then clear the refs.
- Index not ready / missing rows / current track not found → keep the windowed
  fallback (queue never empty when the index is off, the P6 default). Old
  persisted shape without refs loads unchanged.
- `trackToSong` exported from the F2 local-read adapters (one mapper).

Kept the windowed-objects persist (did not drop the cap per R7-17 note): the
index-off default needs the embedded fallback or the queue would restore empty.

* feat(library): pending-sync for song star/rating (PR-7 F4) (#833)

* feat(library): central pending-sync helper for song star/rating (PR-7 F4)

`queueSongStar` / `queueSongRating` (spec §6.5 / R7-18): set the player-store
override optimistically, retry the Subsonic API with exponential backoff (flush
on `online` / window focus), and on success clear the override + patch the
in-memory Track so the UI stays correct without it. The F3 index patch-on-use
runs inside the API layer, unchanged.

- No rollback on the first network error (the override survives until the retry
  succeeds or the app restarts; overrides are session-only, not persisted).
- Latest-toggle-wins coalescing + an identity guard so a fast re-toggle while a
  request is in flight can't retire the newer task.
- v1: songs only.

* feat(library): route song star/rating through the pending-sync helper (PR-7 F4)

Replace the scattered optimistic-set + API-call + rollback logic with the single
`queueSongStar` / `queueSongRating` helper across cucadmuh's named v1 surfaces:
PlayerBar, FullscreenPlayer, MobilePlayerView, both context menus (song + queue
row), both shortcut paths, the song-rating hook + player-bar stars, skip→1★, and
AlbumDetail (song star + rating). The 30+ override read sites are unchanged —
they already read `override ?? track`, and the override now clears on success.

Standalone page toggles (Favorites, RandomMix, NowPlaying star) and the separate
mini-player webview keep their existing path — no regression (a non-migrated
override simply lingers as before) — and move to a follow-up.

* feat(library): route remaining song star/rating sites through pending-sync (F4 follow-up) (#834)

Migrate the three standalone song write sites left out of #833 onto the
central queueSongStar / queueSongRating helper:

- Favorites: handleRate + removeSong (un-star)
- RandomMix: toggleSongStar (drops local try/catch rollback per no-rollback policy)
- useNowPlayingStarLove: toggleStar (keeps local view state, helper owns override + retried sync)

MiniContextMenu stays on its direct path (separate webview, no shared store).
No behaviour change for album/artist rating paths.

* feat(library): route playlist song star/rating through pending-sync (F4 follow-up) (#835)

The playlist-detail star/rating hook was the last shared-store song write
site still calling the Subsonic API directly. Route handleRate +
handleToggleStar through queueSongRating / queueSongStar, matching the
Favorites and RandomMix follow-ups; keep the local ratings/starredSongs
view state, drop the inline override.

MiniContextMenu remains on its direct path (separate webview).

* feat(library): BPM range filter UI in Advanced Search (PR-7 F6) (#836)

* feat(library-sync): parallel initial ingest (S2 + N1/S1 prefetch)

Wire C11 ParallelismBudget (max 4 when idle) into InitialSyncRunner:
parallel getAlbum for S2, up to 4 in-flight pages for N1/S1, and persist
S2 cursor once per album-list page instead of per album.

* fix(library-sync): defer scheduler during initial sync and improve ingest diagnostics

Background delta/tombstone ticks every 30s were competing with IS-3 bulk ingest
for the write mutex (20–60s lock waits on large libraries). Skip scheduler while
sync_phase is initial_sync/probing or bulk ingest is active.

Serialize ingest batch metrics as camelCase for DevTools, add bulk-ingest FTS/index
suspension, combined cursor persist, write-op tracing, live local search, and
library dev logging helpers.

* fix(library-search): scoped FTS, cancel stale live search, skip 1-char queries

Use column-scoped FTS for artists/albums/songs, min two graphemes for local
FTS, capped match counts in Advanced Search, and title browse index (m004).

Live Search aborts superseded network requests, passes requestEpoch to drop
stale Rust FTS, and avoids search3 fallback for too-short queries.

* fix(library-search): prefix FTS, fast subquery joins, hide BPM in Advanced Search

Live and Advanced Search now use FTS5 prefix tokens ("metal"*) and limit
bm25 ranking inside rowid subqueries so large libraries stay in the ms range.
Advanced Search BPM filter is removed from the UI until enrichment ships.

* feat(library-search): race local index vs search3, show first result

Live Search and Advanced Search text queries run library and network
backends in parallel; the faster source wins. Adds searchRace helper
and search_race dev logging.

* feat(library-index): multi-server UI, serial sync queue, scoped local search

Add master library index toggle with per-server rows, offline retry, and a
frontend sync queue so initial ingest runs one server at a time. Scope Live
Search and Advanced Search to the sidebar music library filter via library_id
and raw_json fallbacks; coerce numeric libraryId on ingest. Promote idle sync
state to ready when a full sync stamp exists and block cross-server initial
sync starts in Rust.

* chore(library-store): compliance — clippy, i18n, CHANGELOG

Fix clippy/tsc blockers (request structs, IngestPageCtx, type aliases),
add library index strings to all 9 locales, and document the preview
feature in CHANGELOG [1.47.0] with Psychotoxical + cucadmuh attribution.

* docs(credits): library index preview contributions

Credit Psychotoxical for the local library store foundation and cucadmuh
for multi-server UI, scoped search, and i18n. Drop removed scan-trigger
wording from PR #780 entry.

* docs(release): link library index preview to PR #846

* docs(changelog): sort [1.47.0] entries by ascending PR number

* docs(changelog): mark library index as Added in [1.47.0]

* docs(changelog): restructure [1.47.0] into Added/Changed/Fixed

Match 1.46.0 layout: new features in Added (incl. library index),
enhancements in Changed, bug fixes in Fixed — PR ascending within each block.

* fix(library): address PR #846 review — delta guard + FTS order

Skip background scheduler delta when LibraryRuntime already has a
foreground sync job for the same server. Preserve bm25 rowid ordering
in live search track/artist/album fetches.

* fix(library): address remaining PR #846 review items

S2 resume persists current_album_id per album; same-server resync awaits
the previous runner. N1 delta watermark uses strict less-than; Navidrome
HTTP 500 detection is structured. Adds genre/year indexes, backoff jitter
salt, LiveSearch failure toast, and user-facing search badge copy.

* fix(library): close resync notify race and tighten FTS trigger test

Use notify_one() so an early runner completion cannot lose the drain
signal before same-server full resync awaits. FTS test now compares
normalized trigger bodies from migration vs suspend/restore roundtrip.

---------

Co-authored-by: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com>
2026-05-22 00:33:09 +02:00
Frank Stellmacher 9019041592 docs(changelog): drop removed scan triggers, add bulk add-to-playlist fix (#845)
The unreleased 1.47.0 block still advertised the server-card Quick/Full
scan buttons (#780); those were removed before release (#843), so drop
that line — the edit-existing-profile half of #780 stays. Add the album
bulk "Add to playlist" selection fix (#844) under Fixed.
2026-05-21 23:01:43 +02:00
Frank Stellmacher 8fc1fb6929 fix(album): bulk "add to playlist" no longer wipes the selection (#842) (#844)
The selection's outside-click handler ran on every mousedown outside the
.tracklist, and the bulk-action toolbar is a DOM sibling of it. Clicking
"Add to playlist" fired mousedown -> clearAll() -> inSelectMode=false, so
the toolbar unmounted before the button's onClick could open the picker:
selection vanished, no dialog.

Skip the clear when the mousedown lands inside .album-track-toolbar (filter,
add-to-playlist picker, clear button) — that UI belongs to the selection.
Clicks elsewhere (header, empty page) still clear as before.
2026-05-21 22:55:26 +02:00
Frank Stellmacher 1a7a2a0bfc chore(servers): remove quick/full scan buttons from server cards (#843)
Drops the Quick/Full scan actions and all supporting logic — they are no
longer needed. Removes the ServerScanActions component (incl. the unused
compact variant), the subsonicScan API, the scanStore, and the app-root
useScanPolling hook (no more background scan polling). Cleans up the
.server-scan-* CSS and the settings.scan i18n block across all 9 locales.

440 deletions, no new code; tsc + bundle clean.
2026-05-21 22:43:46 +02:00
Frank Stellmacher f9f96f024f fix(settings): remove plaintext password reveal from server/user forms (#837) 2026-05-21 16:31:39 +02:00
‮Artem cb9445eaad feat(favorites): virtualize songs tracklist + memoize rows (#805)
* feat(favorites): virtualize songs tracklist + memoize rows

10k+ starred songs no longer mount every row into the DOM. Same fix
shape as playlist virtualization: @tanstack/react-virtual windowing,
memoized FavoriteSongRow with stable callback bundle, scrollMargin
ResizeObserver anchored to .content-body. visibleTracks memoized once
per visibleSongs ref to avoid O(n) songToTrack on every click.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(favorites): review follow-ups for virtualization PR #805

Add maintainer comments for scrollMargin layout coupling and bulk-bar
useLayoutEffect dep. Record PR #805 in CHANGELOG and settings credits.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:00:13 +03:00
Frank Stellmacher d8a7c5b9e3 docs: add TELEMETRY.md and link it from the README Privacy section (#790)
TELEMETRY.md captures the standing no-telemetry stance as a standalone
document next to PRIVACY.md. The README Privacy section now links
both: TELEMETRY.md for the policy itself, PRIVACY.md for how each
opt-in integration handles data.
2026-05-19 00:45:38 +02:00
Frank Stellmacher 6d06d3d15f fix(servers): drop scan icons from switcher dropdown, check back on the right (#789)
The compact ServerScanActions rendered inside the server-switcher
dropdown crowded each row with two extra icons. Drop the scan controls
from the dropdown — they remain available on the Settings server
cards (variant="card") — and restore the single-button row layout with
the check / spinner pinned to the right edge.

Partial revert of #780 for the switcher only; Settings card actions
unchanged.
2026-05-19 00:23:37 +02:00
Frank Stellmacher 4b239957c4 fix(ui): restore round play and enqueue buttons on cards and rails (#788)
Album-card play and enqueue overlay buttons revert to a pill shape
(var(--radius-full)); the small play button next to track numbers in
the album track list / artist top-tracks reverts to a circle (50%).
The square shape from the consistency sweep felt off against the round
covers — back to the original round/pill family for these specific
icons. All other surfaces touched in #745 stay on var(--radius-sm).
2026-05-19 00:17:34 +02:00
cucadmuh 64a8a0ed8a fix(playback): Lucky Mix after switching Subsonic servers (#785)
* fix(playback): Lucky Mix after switching Subsonic servers

Hand off unpinned legacy queues to the browsed server, start the mix lock
before async work, skip cross-server enqueue toasts while rolling, bind
queue server before batch enqueues, and restore queueServerId on failed builds.

* chore(release): CHANGELOG and credits for PR #785
2026-05-18 23:38:47 +03:00
cucadmuh d7b23b3c08 fix(ui): stable play/enqueue hover on album rails and mainstage cards (#787)
* fix(ui): stable play/enqueue hover on album rails and mainstage cards

Horizontal Home rails (Discover): drop content-visibility:auto and scroll-snap,
pin controls to cover hover, dim overlay via background. Grids and song cards:
pointer-events on overlays so WebKitGTK/Wayland GPU does not lose :hover.

* chore: CHANGELOG and credits for PR #787
2026-05-18 23:35:08 +03:00
Frank Stellmacher d7abf9be3b fix(radio): card polish and paused streams stop auto-resuming (#786)
* refactor(playback): extract fadeOut helper

Used by playAlbum.ts today; about to be reused for the radio delete
fade-out so it lives in its own module instead of being copy-pasted.

* fix(player): disable Repeat button while a radio stream is active

Matches the Prev / Next buttons that already gate on isRadio — repeat
has no meaning for a live stream, and the live tooltip plus accent
colour suggested it was interactive.

* fix(radio): fade out before deleting the playing station

Mirrors the 700 ms fade-out playAlbum uses on track changes — beats
the abrupt cut when the deleted station is the one currently on air.

* fix(radio): add Play/Stop tooltip to the cover-overlay button

Cast / X icon now describes itself, matching the favourite and delete
buttons that already had tooltips.

* fix(radio): use Square stop icon on the active card

The play-overlay and delete buttons both used X, making it ambiguous
which click stopped the stream and which deleted the station. Stop
gets a filled Square; delete keeps its X.

* fix(radio): cancel auto-reconnect when the user pauses a stream

Closes #779.

Root cause: a 'stalled' event during a paused stream still scheduled a
4 s reconnect timer that called play(), and the timer was not cancelled
on pauseRadio(). On macOS WKWebView the underlying TCP socket droops
roughly a minute after pause, the browser fires 'stalled', and the
stream resumes against the user's intent. The store's isPlaying flag
stays false because nothing on the reconnect path syncs it, so the
play/pause button stops matching reality.

Fix: skip the schedule when radioAudio.paused, re-check inside the
timer callback (covers a pause within the 4 s window), and clear any
pending timer in pauseRadio().

* docs: changelog for radio card polish + paused-stream fix (#786)
2026-05-18 22:10:04 +02:00
cucadmuh 99c78d8567 chore(release): sync Cargo.lock workspace versions on promote (#784)
Extend sync-tauri-version-from-package.js to align psysonic* crate
version fields in Cargo.lock with package.json. Include the lockfile in
promote and post-release main-bump commits. Fixes drift where lock stayed
on the previous -dev while Cargo.toml already matched the channel bump.
2026-05-18 21:35:04 +03:00
cucadmuh f6fe1484a9 fix(ui): in-page browse virtual lists and cover load priority (#783)
* fix(ui): in-page browse virtual lists and cover load priority

Align virtualizer scrollMargin with the in-page scroll viewport so
artist/album rows do not vanish on deep scroll after #731. Resolve
CachedImage IntersectionObserver root to the nearest scrolling pane so
network fetch slots favor visible covers; throttle Artists infinite scroll
to one page per visibleCount update.

* chore(release): CHANGELOG and credits for PR #783
2026-05-18 21:30:36 +03:00
cucadmuh 70c2fdfbf9 Linux: session-native GDK/WebKit mitigations and in-page browse scroll (#731)
* feat(linux): session GDK defaults, nvidia-quirk, optional x11-legacy wrap

Ship PSYSONIC_ALLOW_NATIVE_GDK from Nix/AUR instead of pinning WEBKIT_DISABLE_*
and GDK x11. Add flake psysonic-x11-legacy for the old wrap; alias gdk-session
to psysonic. Startup uses webkit2gtk-nvidia-quirk and Wayland-aware compositing;
refresh Help (a45) and nixos-install docs.

* fix(linux): session GDK and nvidia-quirk only; drop wrapper env heuristics

Remove PSYSONIC_ALLOW_NATIVE_GDK and devShell GDK/WEBKIT exports; stop
synthesizing GDK/WebKit vars in main.rs. Update Nix/AUR wrappers, install
docs, CHANGELOG, and help FAQ with practical user-facing workarounds.

* fix(linux): X11-pinned GDK uses DMABUF quirk path, not Wayland explicit-sync

When GDK_BACKEND is forced to x11 on a wayland user session, webkit2gtk-nvidia-quirk
would still apply __NV_DISABLE_EXPLICIT_SYNC and gray out the webview. Map that case
to WEBKIT_DISABLE_DMABUF_RENDERER like native X11.

* fix(ui): stabilize WebKitGTK/Wayland hover paint for nav and media cards

Sidebar nav links avoid transition:all and promote icons with translateZ(0).
Artist rows and album/artist/song cards use compositing hints; card shadows
and borders no longer interpolate so cover zoom can stay smooth without jitter.

* fix(ui): isolate artist/album card text and cover paint on WebKitGTK

Promote cover blocks with contain/paint and text stacks with translateZ(0);
use artist-card-info on the artists grid for the same layout as other cards.

* feat(artists): in-page overlay scroll and locked main viewport

Move list/grid into an inner OverlayScrollArea, stop sticky toolbar from
owning the route scroll, align the rail with the main panel edge, and skip
the main-route overlay thumb when the viewport cannot scroll vertically.

* feat(browse): extend in-page overlay scroll to more library routes

Reuse the locked main viewport pattern from Artists for Albums, Composers,
Lossless albums, and New releases; wire VirtualCardGrid and scroll chrome
to the matching in-page viewport ids.

* fix(linux): improve Wayland GPU compositing text clarity in WebKitGTK

Use on-demand hardware acceleration on main and mini webviews when the
session is Wayland and compositing stays on; gate subpixel body AA on the
same conditions via new Tauri probes. Document PSYSONIC_SKIP_WAYLAND_FONT_TUNING
for opt-out and changelog.

* fix(rust): satisfy clippy needless_return in Linux webkit helpers

* fix(linux): tune Wayland text rendering with HW policy env and CSS

Allow PSYSONIC_WEBKIT_WAYLAND_HW_POLICY to select WebKit hardware
acceleration policy (never/always vs default on-demand). Extend Wayland
font CSS to #root with geometricPrecision and text-size-adjust on html.

* feat(linux): Wayland text presets in settings, safe WebKit apply, CPU default

Persist profile to app config; apply WebKit policy at startup/mini only to
avoid WebKitGTK hangs on live toggles. UI + CSS preview stays live; default
preset is sharp (CPU-friendly).

* fix(linux): map Wayland sharp preset to OnDemand WebKit policy

HardwareAccelerationPolicy::Never at startup broke main-viewport wheel
scrolling on WebKitGTK+Wayland; sharp vs balanced remains a CSS AA path.
Use PSYSONIC_WEBKIT_WAYLAND_HW_POLICY for a true Never policy.

* fix(rust): gate Linux-only Wayland WebKit helpers for Windows builds

Re-export startup helpers only under cfg(linux) and drop non-Linux stubs so
Windows compiles without unused-import and dead-code warnings.

* chore(release): CHANGELOG + credits for Linux session/WebKit work (PR #731)

Consolidate scattered incremental changelog notes into two [1.47.0]
entries with PR link; remove duplicate Linux blocks from [1.46.0] Fixed.
Append settings credit line for cucadmuh.
2026-05-18 21:00:46 +03:00
Frank Stellmacher b4782aeedb feat(ui): scale the whole window with Interface Scale (#781)
* experiment(zoom): allow setting webview zoom via core capability

* experiment(zoom): drive uiScale through Tauri's native webview zoom

Replace the CSS `zoom: uiScale` on `.main-content-zoom` (which only
scaled the main content column, leaving the sidebar, queue panel,
player bar and portaled overlays at 1.0) with a `setZoom` call on the
current webview. That scales everything inside the window the same way
Ctrl+/− does in a browser, including portals and the queue panel.

Effect runs whenever `uiScale` changes and once on mount, so the
persisted setting is reapplied on launch.

* docs: changelog + credits for interface scale (#781)
2026-05-18 17:52:27 +02:00
Frank Stellmacher bca45d5a80 feat(servers): scan actions + edit existing server profiles (#780)
* feat(server-scan): plumbing for triggering library scans

Adds `startScan` / `getScanStatus` against the Subsonic API
(`fullScan=true` is Navidrome's extension), a small per-server scan
store, and a global polling hook (2 s cadence) that emits a toast when
each scan finishes. Scans can run on any configured server, including
inactive ones, by reusing `apiForServer`.

UI surfaces follow in the next commit.

* feat(server-scan): expose Quick / Full Scan in switcher + settings cards

Adds a `ServerScanActions` component with two variants (compact for the
server-switcher dropdown, card for the Settings server cards) backed by
the scan store from the previous commit. Full Scan requires a second
click within 3 s to confirm, matching the playlist-delete pattern.
Status slot shows a spinner with running track count while scanning, a
green check when finished, and a red icon on error.

The switcher row is converted from a single button to a flex container
so per-server scan controls don't hijack the server-switch click.
i18n added across all 9 locales.

* fix(server-scan): reorder switcher row to check / name / scan actions

Moves the check / spinner slot from the right edge to the left so the
spinner pop-in on server switch doesn't sit next to the scan icons.
Removes the layout shift that briefly hovered the Quick scan button
when the row re-rendered.

* feat(servers): edit existing server profiles in Settings → Servers

* Pencil-button on each server card opens an inline edit form that
  replaces the card (prefilled name / URL / username / password).
* `AddServerForm` reused with an `editingServer` prop — title flips to
  "Edit Server", submit label to "Save", magic-string field hidden (the
  edit scope is manual fields; magic-string remains an add-time invite
  shortcut).
* Edit saves unconditionally — ping runs post-save as a status indicator
  (analog to the existing Test button) instead of gating the save. Lets
  users update a profile when the server is currently unreachable.
* Translations across all 9 locales (`editServer`, `editServerTitle`).

* fix(servers): submit Add/Edit Server form on Enter

Wrapped the form body in a real <form>, made the submit button
type="submit", marked Cancel as type="button" so Enter no longer
cancels. Add-Mode now also responds to Enter — same flow, consistent
across both modes.

* fix(servers): collapse card action buttons to icon-only on narrow screens

* Quick-Scan / Full-Scan / Test buttons in each server card hide their
  text label below 1100px viewport via the .server-card-btn-label class
  and a single media query in connection-indicator.css.
* Labels remain accessible via data-tooltip and aria-label so screen
  readers + hover both keep working in the collapsed state.
* No content reflow above the breakpoint — pure additive CSS.

* fix(servers): include Use button in icon-only narrow-screen collapse

The Use ("Verwenden") button on inactive server cards lacked the
.server-card-btn-label wrapper, so its text stayed visible at narrow
viewports and pushed Edit/Delete off-screen. Added a Power icon and
wrapped the label so it collapses alongside the other action buttons.

* docs(changelog,credits): #780 server scan + edit
2026-05-18 16:32:40 +02:00
Frank Stellmacher 562218f447 feat(settings): dim disabled toggle rows + rename Clear → Clear queue (#778)
* feat(settings): dim disabled toggle rows + rename Clear → Clear queue

* Settings toggle rows (`.settings-toggle-row`, `.sidebar-customizer-row`) dim
  their non-toggle content to 0.6 opacity when the switch is off. Driven by a
  single `:has(.toggle-switch input:not(:checked):not(:disabled))` rule so it
  applies across every Settings tab without per-row markup. Mutex-disabled
  toggles (Crossfade/Gapless) are excluded to avoid stacking with the existing
  inline 0.45 dim on their row.
* Queue toolbar "Clear" button now reads "Clear queue" in all 9 locales for
  parity with "Shuffle queue" and to distinguish from playlist clear/delete.

Adopted from @kveld9's PR #558 — cherry-picked the spirit, rewrote the dim as
a single global selector instead of three inline-styled customizer rows, and
extended the rename to the Romanian locale that landed after #558 was opened.

Co-Authored-By: kveld9 <179108235+kveld9@users.noreply.github.com>

* docs(changelog,credits): credit @kveld9 for #778

Co-Authored-By: kveld9 <179108235+kveld9@users.noreply.github.com>

---------

Co-authored-by: kveld9 <179108235+kveld9@users.noreply.github.com>
2026-05-18 15:21:45 +02:00
Frank Stellmacher 9f10d8fafb chore(aur): bump PKGBUILD to 1.46.0 (#777) 2026-05-18 14:21:21 +02:00
Frank Stellmacher 0993051bd7 Update CHANGELOG.md (#774) 2026-05-18 13:48:34 +02:00
github-actions[bot] f290896a32 chore(release): bump main to 1.47.0-dev (#772)
* chore(release): bump main to 1.47.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-05-18 13:46:26 +02:00
1427 changed files with 111697 additions and 33723 deletions
+46
View File
@@ -0,0 +1,46 @@
# Dependabot: security updates only (no scheduled version bumps).
#
# Version updates are disabled via open-pull-requests-limit: 0. GitHub still opens
# PRs when Dependabot/npm/cargo audit reports a vulnerability (and related
# transitive fixes in the same bump). Routine minor/patch upgrades are manual.
#
# Requires "Dependabot security updates" enabled in repo Settings → Code security.
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 0
labels:
- dependencies
- security
groups:
npm-security:
applies-to: security-updates
patterns:
- "*"
- package-ecosystem: cargo
directory: /src-tauri
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 0
labels:
- dependencies
- security
groups:
cargo-security:
applies-to: security-updates
patterns:
- "*"
ignore:
# Symphonia 0.6 is a coordinated migration (API break + isomp4 patch port).
# See workdocs: internal/collaboration/tasks/2026-05-symphonia-0.6-migration/
- dependency-name: symphonia
versions: [">= 0.6"]
- dependency-name: symphonia-adapter-libopus
versions: [">= 0.3"]
+2 -7
View File
@@ -3,13 +3,8 @@
# Mirrors `.github/hot-path-files.txt` for the Rust crates. Each entry is a
# workspace-relative path; the gate script (`scripts/check-frontend-hot-path-
# coverage.sh`) reads `coverage/coverage-summary.json` produced by
# `vitest run --coverage` and warns when a listed file drops below the floor.
#
# Soft today (warnings only — the workflow carries `continue-on-error: true`).
# Flip to a hard PR-blocker by removing `continue-on-error` from the
# `frontend-tests` workflow at the start of M4 in the pre-refactor testing
# plan (2026-05-11), once Phase 13 characterization tests have proven the
# gate stable on a handful of real PRs.
# `vitest run --coverage` and fails the frontend-tests coverage job when a
# listed file drops below the floor.
#
# Curation rule (mirrors the backend list): a file belongs here when its
# hot-path code dominates the file and ≥70 % is a reasonable floor. Files
+1 -2
View File
@@ -20,8 +20,7 @@
#
# Each line is a path relative to the workspace root (so `src-tauri/...`).
# `#` for comments. CI runs cargo-llvm-cov + this gate; PRs that drop
# any listed file below the threshold get a warning annotation today
# and (after watching it run cleanly) eventually a hard fail.
# any listed file below the threshold fail the rust-tests coverage job.
# ── psysonic-syncfs ──────────────────────────────────────────────────
src-tauri/crates/psysonic-syncfs/src/cache/fs_utils.rs
+7 -12
View File
@@ -38,9 +38,9 @@ jobs:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '20'
node-version: 'lts/*'
cache: 'npm'
- run: npm ci
- name: vitest
@@ -51,9 +51,9 @@ jobs:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '20'
node-version: 'lts/*'
cache: 'npm'
- run: npm ci
- name: tsc
@@ -61,24 +61,19 @@ jobs:
coverage:
name: vitest --coverage (baseline + hot-path file gate)
# Two-layer gate: the script exits 1 when any listed file drops below the
# threshold (warning annotations show in the PR checks panel), but
# `continue-on-error: true` keeps it from BLOCKING merges. Drop the flag
# to flip the gate hard once we've watched a few PRs run cleanly.
runs-on: ubuntu-24.04
continue-on-error: true
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '20'
node-version: 'lts/*'
cache: 'npm'
- name: install jq
run: sudo apt-get update && sudo apt-get install -y jq
- run: npm ci
- name: vitest run --coverage
run: npx vitest run --coverage
- name: hot-path file coverage soft gate
- name: hot-path file coverage gate
run: bash scripts/check-frontend-hot-path-coverage.sh
- uses: actions/upload-artifact@v4
with:
+1 -1
View File
@@ -75,7 +75,7 @@ jobs:
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/tauri.conf.json
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/tauri.conf.json
if git diff --cached --quiet; then
echo "No version bump changes to commit."
exit 0
@@ -47,7 +47,7 @@ jobs:
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/tauri.conf.json
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/tauri.conf.json
if git diff --cached --quiet; then
echo "No finalization changes to commit."
exit 0
+69 -68
View File
@@ -35,7 +35,70 @@ on:
type: boolean
jobs:
# Refresh npmDepsHash + flake.lock on the channel branch *before* tagging.
# Promote workflows push with GITHUB_TOKEN (no downstream workflow runs), and the
# old post-tag verify-nix PR landed after app-v* tags were already cut.
prepare-nix-sources:
if: ${{ inputs.verify_nix }}
runs-on: ubuntu-24.04
permissions:
contents: write
outputs:
source_commit_sha: ${{ steps.final-sha.outputs.value }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
ref: ${{ inputs.target_branch }}
- name: install Nix
uses: DeterminateSystems/nix-installer-action@v15
- name: configure Cachix (managed signing)
uses: cachix/cachix-action@v15
with:
name: psysonic
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: compute npmDepsHash from package-lock.json
id: npm-hash
run: |
set -euo pipefail
HASH="$(nix run nixpkgs/nixos-unstable#prefetch-npm-deps -- package-lock.json)"
echo "hash=$HASH" >> "$GITHUB_OUTPUT"
echo "Computed npmDepsHash: $HASH"
- name: write npmDepsHash into nix/upstream-sources.json
run: |
set -euo pipefail
HASH='${{ steps.npm-hash.outputs.hash }}'
jq --arg h "$HASH" '.npmDepsHash = $h' nix/upstream-sources.json > nix/upstream-sources.json.new
mv nix/upstream-sources.json.new nix/upstream-sources.json
- name: refresh flake.lock (nixpkgs pin)
run: nix flake update --accept-flake-config
- name: verify nix build + push to Cachix
run: |
set -euo pipefail
nix build .#psysonic --accept-flake-config --no-link --print-build-logs
nix path-info --recursive .#psysonic | cachix push psysonic
- name: commit and push refreshed lock and hash (if changed)
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add flake.lock nix/upstream-sources.json
if git diff --cached --quiet; then
echo "flake.lock / nix/upstream-sources.json unchanged — nothing to commit."
exit 0
fi
VERSION="$(node -p 'require("./package.json").version')"
git commit -m "chore(nix): refresh lock + npmDepsHash for v${VERSION}"
git push origin "HEAD:${{ inputs.target_branch }}"
- name: capture source commit sha
id: final-sha
run: |
set -euo pipefail
echo "value=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
create-release:
needs: prepare-nix-sources
if: ${{ !cancelled() && !failure() && (needs.prepare-nix-sources.result == 'success' || needs.prepare-nix-sources.result == 'skipped') }}
permissions:
contents: write
runs-on: ubuntu-latest
@@ -47,7 +110,7 @@ jobs:
steps:
- uses: actions/checkout@v5
with:
ref: ${{ inputs.source_ref }}
ref: ${{ inputs.verify_nix && needs.prepare-nix-sources.outputs.source_commit_sha || inputs.source_ref }}
- name: setup node
uses: actions/setup-node@v5
with:
@@ -194,7 +257,7 @@ jobs:
steps:
- uses: actions/checkout@v5
with:
ref: ${{ inputs.source_ref }}
ref: ${{ needs.create-release.outputs.source_commit_sha }}
- name: setup node
uses: actions/setup-node@v5
with:
@@ -268,7 +331,7 @@ jobs:
steps:
- uses: actions/checkout@v5
with:
ref: ${{ inputs.source_ref }}
ref: ${{ needs.create-release.outputs.source_commit_sha }}
- name: generate latest.json
env:
VERSION: ${{ needs.create-release.outputs.package_version }}
@@ -290,7 +353,7 @@ jobs:
steps:
- uses: actions/checkout@v5
with:
ref: ${{ inputs.source_ref }}
ref: ${{ needs.create-release.outputs.source_commit_sha }}
- name: install dependencies
run: |
sudo apt-get update
@@ -325,68 +388,6 @@ jobs:
\( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \) \
| xargs gh release upload "$RELEASE_TAG" --clobber
verify-nix:
if: ${{ inputs.verify_nix }}
needs: create-release
runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
ref: ${{ inputs.target_branch }}
- name: install Nix
uses: DeterminateSystems/nix-installer-action@v15
- name: configure Cachix (managed signing)
uses: cachix/cachix-action@v15
with:
name: psysonic
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: compute npmDepsHash from package-lock.json
id: npm-hash
run: |
set -euo pipefail
HASH="$(nix run nixpkgs/nixos-unstable#prefetch-npm-deps -- package-lock.json)"
echo "hash=$HASH" >> "$GITHUB_OUTPUT"
echo "Computed npmDepsHash: $HASH"
- name: write npmDepsHash into nix/upstream-sources.json
run: |
set -euo pipefail
HASH='${{ steps.npm-hash.outputs.hash }}'
jq --arg h "$HASH" '.npmDepsHash = $h' nix/upstream-sources.json > nix/upstream-sources.json.new
mv nix/upstream-sources.json.new nix/upstream-sources.json
- name: refresh flake.lock (nixpkgs pin)
run: nix flake update --accept-flake-config
- name: verify nix build + push to Cachix
run: |
set -euo pipefail
nix build .#psysonic --accept-flake-config --no-link --print-build-logs
nix path-info --recursive .#psysonic | cachix push psysonic
- name: open + auto-merge PR with refreshed lock and hash (if changed)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add flake.lock nix/upstream-sources.json
if git diff --cached --quiet; then
echo "flake.lock / nix/upstream-sources.json unchanged — nothing to commit."
exit 0
fi
VERSION="${{ needs.create-release.outputs.package_version }}"
BRANCH="chore/nix-lock-refresh-${{ inputs.target_branch }}-v${VERSION}"
git checkout -b "$BRANCH"
git commit -m "chore(nix): refresh lock + npmDepsHash for v${VERSION}"
git push origin "$BRANCH"
gh pr create \
--base "${{ inputs.target_branch }}" \
--head "$BRANCH" \
--title "chore(nix): refresh lock + npmDepsHash for v${VERSION}" \
--body "Auto-generated for the \`${{ inputs.channel }}\` channel after v${VERSION}: refreshes \`flake.lock\` and \`nix/upstream-sources.json\`."
bump-main-to-next-dev:
if: ${{ inputs.channel == 'release' }}
needs: create-release
@@ -447,7 +448,7 @@ jobs:
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/tauri.conf.json
git add package.json package-lock.json src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/tauri.conf.json
if git diff --cached --quiet; then
echo "No dev version bump required."
exit 0
@@ -463,4 +464,4 @@ jobs:
--base main \
--head "$BRANCH" \
--title "chore(release): bump main to ${VERSION}" \
--body "Auto-generated after stable release: updates \`package.json\`, \`package-lock.json\`, \`src-tauri/Cargo.toml\`, and \`src-tauri/tauri.conf.json\` to the next development version."
--body "Auto-generated after stable release: updates \`package.json\`, \`package-lock.json\`, \`src-tauri/Cargo.toml\`, \`src-tauri/Cargo.lock\`, and \`src-tauri/tauri.conf.json\` to the next development version."
+1 -7
View File
@@ -59,13 +59,7 @@ jobs:
coverage:
name: cargo llvm-cov (baseline + hot-path file gate)
# Layered: the gate script exits 1 when any hot-path file drops below
# threshold (see scripts/check-hot-path-coverage.sh) — the failure is
# visible in the PR's checks panel. `continue-on-error: true` keeps it
# from BLOCKING merges. Drop continue-on-error to flip the gate to a
# PR-blocker once we've watched a few PRs run cleanly.
runs-on: ubuntu-24.04
continue-on-error: true
steps:
- uses: actions/checkout@v5
- name: install Linux build dependencies
@@ -89,7 +83,7 @@ jobs:
mkdir -p target/llvm-cov
cargo llvm-cov --workspace --lcov --output-path lcov.info
cargo llvm-cov --workspace --json --output-path target/llvm-cov/cov.json
- name: hot-path function coverage soft gate
- name: hot-path file coverage gate
run: bash scripts/check-hot-path-coverage.sh
- uses: actions/upload-artifact@v4
with:
+1
View File
@@ -63,3 +63,4 @@ result-*
dev.sh
shell.nix
prod.sh
tsconfig.tsbuildinfo
+1135 -8
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -41,7 +41,7 @@ Open pull requests against `main`. `next` and `release` are maintainer-driven pr
- **AUR packaging problems** — follow the AUR links in [README](README.md); those packages are maintained separately from this repository.
- **Large features or UX overhauls** — consider discussing in chat or opening an issue early so effort aligns with product direction.
- **Changes to the Tauri boundary** — read [The Rust ↔ frontend (Tauri) contract](#the-rust--frontend-tauri-contract) before opening a PR; reviewers will ask for a clear justification.
- **Security issues** — please do **not** open a public issue. Reach a maintainer privately via Discord or Telegram first; we'll coordinate disclosure from there.
- **Security issues** — please do **not** open a public issue. See [SECURITY.md](SECURITY.md) for how to report vulnerabilities privately (Discord or Telegram).
---
@@ -117,7 +117,7 @@ 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.
- **Rust** (`src-tauri/**`): `cargo test --workspace --all-targets`, `cargo clippy --workspace --all-targets -- -D warnings`, then coverage.
Hot-path coverage gates are currently **soft** (warnings only — the workflow carries `continue-on-error: true`). They will be flipped to required when the floors stabilise; 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 the current state of each list.
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.
---
+4 -3
View File
@@ -32,9 +32,6 @@ More translations are added over time.
---
> [!WARNING]
> Psysonic is under active development. Bugs and rough edges can happen, and features may change as the project evolves.
## What is Psysonic?
Psysonic is a desktop music client for self-hosted music libraries. It is designed for people who want the freedom of their own server without giving up the comfort, polish and speed of a modern music app.
@@ -153,6 +150,8 @@ curl -fsSL https://raw.githubusercontent.com/Psychotoxical/psysonic/main/scripts
Linux builds are also available through GitHub Releases, AUR and Cachix/Nix.
> **AppImage runs under X11/XWayland** — it pins `GDK_BACKEND=x11` for a stable WebKitGTK stack. For a native-Wayland launch, use the `.deb`, `.rpm`, AUR, or Nix packages, which follow your session's display server.
## Windows
Download the latest installer from the [GitHub Releases](https://github.com/Psychotoxical/psysonic/releases/latest).
@@ -191,6 +190,8 @@ Psysonic is built for self-hosted music collections. Your library is yours.
* No analytics harvesting
* No hidden tracking
See [TELEMETRY.md](TELEMETRY.md) for the telemetry stance and [PRIVACY.md](PRIVACY.md) for how each opt-in integration handles data.
---
# Community & Support
+1 -1
View File
@@ -21,7 +21,7 @@ Direct push to these branches is not part of normal human workflow. Use PRs and
## 2) Versioning rules (mandatory)
Version is authoritative in `package.json` and `package-lock.json`.
Version is authoritative in `package.json` and `package-lock.json`. Promotion workflows run `scripts/sync-tauri-version-from-package.js`, which also aligns `[workspace.package]` in `src-tauri/Cargo.toml`, `tauri.conf.json`, and the `psysonic*` workspace crate version fields in `src-tauri/Cargo.lock` (local `cargo build` alone does not commit that lock metadata).
- `main` version format: `X.Y.Z-dev`
- `next` version format: `X.Y.Z-rc.N`
+28
View File
@@ -0,0 +1,28 @@
# Security policy
## Reporting a vulnerability
**Please do not open a public GitHub issue for security problems.**
Report them privately so we can investigate and coordinate a fix before details are public:
- [Discord](https://discord.gg/AMnDRErm4u) — reach a maintainer directly
- [Telegram](https://t.me/+GLBx1_xeH28xYTJi) — same
Include what you can: affected version, platform (Windows / macOS / Linux), steps to reproduce, and impact if known.
## What to expect
- We will acknowledge your report as soon as we can.
- We will work with you on verification and timing of any public disclosure.
- We do not offer a paid bug-bounty program; credit in the changelog or release notes is given when reporters want it and when it fits the fix.
## Scope notes
- **This repository** — Psysonic desktop application source.
- **AUR packages** ([`psysonic`](https://aur.archlinux.org/packages/psysonic), [`psysonic-bin`](https://aur.archlinux.org/packages/psysonic-bin)) are maintained separately; packaging issues there should go through the AUR unless they reflect a vulnerability in the upstream app itself.
- **Your music server** (Navidrome, Gonic, etc.) is outside this project's scope; report server-side issues to those projects.
## Secure development
Pull requests are reviewed on `main`. **Dependency security updates** are tracked via Dependabot (PRs only for reported vulnerabilities and their fix paths—not routine version bumps). For general contribution expectations, see [CONTRIBUTING.md](CONTRIBUTING.md).
+65
View File
@@ -0,0 +1,65 @@
# Privacy & Telemetry
Privacy is not an afterthought in Psysonic. It has been part of the projects foundation from the very beginning.
Psysonic was built with the clear intention of giving users control over their own music experience without silently observing what they do. We believe that people should be able to use software on their own systems without being monitored by the developers behind it.
## No Telemetry
Psysonic does **not** collect telemetry.
That means:
- no usage statistics
- no background tracking
- no hidden analytics
- no hardware or system profiling
- no automatic crash or behavior reporting
- no data harvesting of any kind
Psysonic itself does not collect, store, sell, analyze, or transmit personal usage data.
## A Conscious Decision
We are aware that telemetry can make software development easier.
Anonymous usage statistics, crash reports, platform information, and diagnostic data can help developers find bugs faster, understand which systems are used most often, and prioritize fixes more efficiently.
However, we made a conscious decision not to build Psysonic around that model.
For us, respecting user privacy is more important than collecting additional data for convenience. Modern software already tracks more than enough, often by default and often without users fully understanding what is being collected. Psysonic intentionally takes a different approach.
## External Services Are Opt-In
Some Psysonic features can communicate with external services, such as:
- Last.fm
- Bandsintown
- Discord Rich Presence
These integrations are optional and clearly presented as opt-in features. They are never required for using Psysonic.
If you enable one of these integrations, data may be transmitted to the respective external service provider as part of how that service works. Psysonic itself does not collect or process that data for its own analytics.
You decide which integrations you want to use.
## Community Feedback Instead of Silent Tracking
Instead of telemetry, Psysonic relies on direct community feedback.
Bug reports, feature requests, platform issues, and general discussions happen openly through community channels such as Discord and Telegram. Additional contact options, including WhatsApp and Facebook groups, are planned for the future.
We prefer talking to users directly over silently collecting information in the background.
This approach may sometimes require more communication, but it also keeps the relationship between the project and its users transparent and respectful.
## Our Position
Psysonic is built for people who want to enjoy their own music collection on their own terms.
No hidden tracking.
No telemetry by default.
No analytics quietly running in the background.
This is intentional, and it is not planned to change.
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1776877367,
"narHash": "sha256-EHq1/OX139R1RvBzOJ0aMRT3xnWyqtHBRUBuO1gFzjI=",
"lastModified": 1779560665,
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0726a0ecb6d4e08f6adced58726b95db924cef57",
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"type": "github"
},
"original": {
+34 -17
View File
@@ -3,17 +3,19 @@
Psysonic for NixOS / nixpkgs: installable app + dev shell.
Packages:
nix build .#psysonic # or .#default desktop app (.desktop + icon); GDK_BACKEND=x11 (default, fewer WebKit surprises)
nix build .#psysonic-gdk-session # same app, no forced GDK x11 optional; can misbehave on some stacks (see nixos-install.md)
nix build .#psysonic # or .#default desktop app; GDK follows session (no wrapper pin)
nix build .#psysonic-gdk-session # same derivation (back-compat alias); see nixos-install.md
nix build .#psysonic-x11-legacy # legacy: GDK_BACKEND=x11 wrapper (old default)
nix profile install .#psysonic
Run (after build, or from any clone with flake):
nix run .#psysonic
nix run .#psysonic-gdk-session
nix run .#psysonic-gdk-session # identical to psysonic
nix run .#psysonic-x11-legacy # GDK x11 pinned (former default wrap)
nix run github:Psychotoxical/psysonic
Development:
nix develop # mkShell (Rust/Node/WebKit deps + hooks)
nix develop # mkShell (Rust/Node/WebKit deps); same GDK idea as installable (no GDK pin)
nix shell .#devShells.default # same environment without entering subshell semantics
Local cargo output: .build-local/ (gitignored; not copied into flake source tarball)
@@ -87,9 +89,6 @@
export GIO_EXTRA_MODULES="${pkgs.glib-networking}/lib/gio/modules''${GIO_EXTRA_MODULES:+:$GIO_EXTRA_MODULES}"
export LLVM_COV="${pkgs.llvmPackages.llvm}/bin/llvm-cov"
export LLVM_PROFDATA="${pkgs.llvmPackages.llvm}/bin/llvm-profdata"
export GDK_BACKEND=x11
export WEBKIT_DISABLE_COMPOSITING_MODE=1
export WEBKIT_DISABLE_DMABUF_RENDERER=1
unset CI
'';
@@ -106,28 +105,38 @@
inherit upstreamMeta;
};
psysonicGdkSessionFor =
# Same app with GDK_BACKEND pinned to X11 — previous default wrapper behaviour (see nixos-install.md).
psysonicX11LegacyFor =
system:
nixpkgs.legacyPackages.${system}.callPackage ./nix/psysonic.nix {
src = self;
inherit upstreamMeta;
forceGdkX11 = false;
forceGdkX11 = true;
};
in
{
devShells = forSystem (system: { default = mkShellFor system; });
packages = forSystem (system: {
psysonic = psysonicFor system;
psysonic-gdk-session = psysonicGdkSessionFor system;
default = psysonicFor system;
});
packages = forSystem (
system:
let
p = psysonicFor system;
pX11 = psysonicX11LegacyFor system;
in
{
psysonic = p;
psysonic-gdk-session = p;
psysonic-x11-legacy = pX11;
default = p;
}
);
apps = forSystem (
system:
let
p = psysonicFor system;
pGdk = psysonicGdkSessionFor system;
pX11 = psysonicX11LegacyFor system;
in
{
default = {
@@ -140,9 +149,17 @@
};
psysonic-gdk-session = {
type = "app";
program = lib.getExe pGdk;
program = lib.getExe p;
meta = {
inherit (pGdk.meta) description homepage license;
inherit (p.meta) description homepage license;
mainProgram = "psysonic";
};
};
psysonic-x11-legacy = {
type = "app";
program = lib.getExe pX11;
meta = {
inherit (pX11.meta) description homepage license;
mainProgram = "psysonic";
};
};
+6 -10
View File
@@ -35,9 +35,9 @@
gst_all_1,
src,
upstreamMeta,
# When true (default), wrapProgram sets GDK_BACKEND=x11 for WebKit stability on many setups.
# When false, GDK follows the session (e.g. native Wayland) — often better HiDPI sizing.
forceGdkX11 ? true,
# When true, wrapProgram sets GDK_BACKEND=x11 (legacy conservative stack).
# When false (default), only lib paths — GDK follows session; binary applies webkit2gtk-nvidia-quirk only.
forceGdkX11 ? false,
}:
let
@@ -138,7 +138,7 @@ stdenv.mkDerivation (finalAttrs: {
buildPhase = ''
runHook preBuild
export HOME=$(mktemp -d)
(cd src-tauri && cargo tauri build --no-bundle -v)
(cd src-tauri && cargo tauri build --no-bundle)
runHook postBuild
'';
@@ -163,10 +163,7 @@ stdenv.mkDerivation (finalAttrs: {
postFixup =
let
gdkX11Wrap = lib.optionalString forceGdkX11 ''
--set GDK_BACKEND x11 \
'';
allowNativeGdkWrap = lib.optionalString (!forceGdkX11) ''
--set PSYSONIC_ALLOW_NATIVE_GDK 1 \
--set GDK_BACKEND x11
'';
in
''
@@ -174,8 +171,7 @@ stdenv.mkDerivation (finalAttrs: {
--prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath [ libayatana-appindicator ]}" \
--prefix GST_PLUGIN_PATH : "${gstPluginPath}" \
--prefix GIO_EXTRA_MODULES : "${glib-networking}/lib/gio/modules" \
${gdkX11Wrap}${allowNativeGdkWrap}--set WEBKIT_DISABLE_COMPOSITING_MODE 1 \
--set WEBKIT_DISABLE_DMABUF_RENDERER 1
${gdkX11Wrap}
'';
meta = {
+1 -1
View File
@@ -1,3 +1,3 @@
{
"npmDepsHash": "sha256-zcd6mudbopF0hlcJnFxwUOKpPt6IamfLmabSZ5rN7HI="
"npmDepsHash": "sha256-7BvKeTkZzAQoBVm2vw2oZRsRKWN/Du1pn89U1rtc47k="
}
+10 -10
View File
@@ -85,25 +85,27 @@ environment.systemPackages = with pkgs; [
];
```
### Linux wrapper: default vs gdk-session
### Linux wrapper (default vs legacy X11)
The flake exposes **two** installable packages on Linux. They are the same build; only the **wrapped runtime environment** differs:
The flake exposes **three** Linux attributes (two are the **same derivation**):
| Flake attribute | Wrapper behaviour |
|----------------|-------------------|
| **`psysonic`** (and **`default`**) | Sets **`GDK_BACKEND=x11`** together with the usual WebKit / GStreamer / AppIndicator paths. This is the **recommended default**: it matches the dev shell assumptions and avoids many WebKitGTK + Wayland edge cases. |
| **`psysonic-gdk-session`** | **Does not** set `GDK_BACKEND`; GTK follows the session (e.g. native Wayland when available). Can improve **HiDPI sizing** on some desktops, but may cause **black window, broken scrolling, or tray quirks** on other GPU/compositor stacks—the same class of issues described under Linux / WebKit in the in-app Help. **Not default** on purpose. |
| **`psysonic`**, **`default`**, **`psysonic-gdk-session`** | Wrappers prefix **libraries only** (**GStreamer**, **AppIndicator**); **`GDK_BACKEND`** is **not** pinned. The binary invokes **`webkit2gtk-nvidia-quirk`** early on Linux (unless **`PSYSONIC_WEBKIT_GPU_ACCEL`** is set); no extra **`WEBKIT_DISABLE_*`** heuristics in **`main.rs`**. Override with **`GDK_BACKEND`**, **`WEBKIT_DISABLE_*`**, etc. whenever you want. |
| **`psysonic-x11-legacy`** | Former default: **`GDK_BACKEND=x11`** pinned in the wrapper. Use if you relied on **XWayland-ish** stability on messy stacks. Same binary as **`psysonic`**. |
Use the alternate package when you understand that trade-off:
`psysonic-gdk-session` remains a **back-compat alias** for **`psysonic`** (identical store path).
### Example: legacy X11 wrap
```nix
inputs.psysonic.packages.${system}.psysonic-gdk-session
inputs.psysonic.packages.${system}.psysonic-x11-legacy
```
Or one-shot (quote the URL in **zsh** — `?` / `#` are special):
```bash
nix run 'github:Psychotoxical/psysonic#psysonic-gdk-session' -- --help
nix run 'github:Psychotoxical/psysonic#psysonic-x11-legacy' -- --help
```
### Pinning a revision, branch, or tag
@@ -142,7 +144,7 @@ From any machine with flakes:
nix run 'github:Psychotoxical/psysonic'
```
Same as `nix build` / `packages.<system>.default` (the **x11-wrapped** binary); uses the flake `apps` output. For the session-GDK variant, use `'github:Psychotoxical/psysonic#psysonic-gdk-session'` (see [Linux wrapper](#linux-wrapper-default-vs-gdk-session) above). With a branch pin, keep the **whole** `github:…?ref=…#…` string in **single quotes** under **zsh**.
Same as `nix build` / `packages.<system>.default` (session-native **GDK**); uses the flake `apps` output. For an **X11-pinned** launcher (old default), use `'github:Psychotoxical/psysonic#psysonic-x11-legacy'` (see [Linux wrapper](#linux-wrapper-default-vs-legacy-x11) above). `psysonic-gdk-session` is an **alias**—same as **`psysonic`**. With a branch pin, keep the **whole** `github:…?ref=…#…` string in **single quotes** under **zsh**.
### Apply configuration
@@ -179,8 +181,6 @@ From a **flake-enabled** clone of the repo:
The flake **`devShell`** uses the same **`nixpkgs`** input as **`packages.psysonic`** (see **`flake.nix`**).
Optional **local-only** helpers (`dev.sh`, `shell.nix`, `prod.sh`) are **gitignored** — not part of the upstream tree; keep your own copies if you use them (e.g. a small `dev.sh` that runs `nix develop` and `npm run tauri:dev`).
## Desktop entry
The flake package installs a **`.desktop`** file and icon via `copyDesktopItems`; after `nixos-rebuild switch` (or a Home Manager activation that includes the package), Psysonic should appear in your application launcher like any other desktop app.
+558 -511
View File
File diff suppressed because it is too large Load Diff
+13 -13
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.46.0-dev",
"version": "1.48.0-dev",
"private": true,
"scripts": {
"check:css-imports": "node scripts/check-css-import-graph.mjs",
@@ -30,7 +30,7 @@
"@fontsource-variable/space-grotesk": "^5.2.10",
"@fontsource-variable/unbounded": "^5.2.8",
"@fontsource/opendyslexic": "^5.2.5",
"@tanstack/react-virtual": "^3.13.24",
"@tanstack/react-virtual": "^3.13.26",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
@@ -42,31 +42,31 @@
"@tauri-apps/plugin-window-state": "^2.4.1",
"axios": "^1.16.0",
"i18next": "^26.0.8",
"lucide-react": "^1.14.0",
"lucide-react": "^1.17.0",
"md5": "^2.3.0",
"papaparse": "^5.5.3",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.6",
"react-router-dom": "^7.15.0",
"zustand": "^5.0.13"
"react-router-dom": "^7.16.0",
"zustand": "^5.0.14"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@tauri-apps/cli": "^2.11.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/md5": "^2.3.6",
"@types/node": "^25.6.0",
"@types/papaparse": "^5.5.2",
"@types/react": "^19.2.14",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.5",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.8",
"esbuild": "^0.28.0",
"jsdom": "^26.1.0",
"jsdom": "^29.1.1",
"typescript": "^6.0.3",
"vite": "^8.0.10",
"vitest": "^4.1.5"
"vite": "^8.0.14",
"vitest": "^4.1.8"
}
}
+2 -5
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.45.0
pkgver=1.46.0
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
@@ -53,12 +53,9 @@ package() {
# Binary (in /usr/lib to make room for the wrapper)
install -Dm755 "src-tauri/target/release/psysonic" "$pkgdir/usr/lib/psysonic/psysonic"
# Wrapper script that sets necessary env vars for WebKitGTK on Wayland
# Wrapper: thin exec (path hygiene only); GDK/session + WebKit mitigations come from main.rs / quirk (no GDK pin).
install -Dm755 /dev/stdin "$pkgdir/usr/bin/psysonic" <<EOF
#!/bin/sh
export GDK_BACKEND=x11
export WEBKIT_DISABLE_COMPOSITING_MODE=1
export WEBKIT_DISABLE_DMABUF_RENDERER=1
exec /usr/lib/psysonic/psysonic "\$@"
EOF
+2 -4
View File
@@ -1,13 +1,11 @@
#!/usr/bin/env bash
#
# Hot-path file coverage gate — frontend, soft mode.
# Hot-path file coverage gate — frontend.
#
# Mirrors `scripts/check-hot-path-coverage.sh` for the Rust workspace. For
# each source file listed in `.github/frontend-hot-path-files.txt`, verifies
# that line coverage is at least $THRESHOLD %. Emits GitHub Actions warning
# annotations for files below the floor; exits 1 when any file is below, but
# the wrapping CI job carries `continue-on-error: true` so it doesn't block
# merges yet (drop that flag once we've watched a few PRs run cleanly).
# annotations for files below the floor and exits 1 when any file is below.
#
# Why files instead of per-function: v8 coverage's per-function data is
# fragile under React Compiler / Vite minification — file-level line
+3 -11
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env bash
#
# Hot-path file coverage gate — soft mode.
# Hot-path file coverage gate.
#
# For each source file listed in `.github/hot-path-files.txt`, verifies
# that line coverage is at least $THRESHOLD %. Emits GitHub Actions
# warning annotations for files below the floor; never sets a non-zero
# exit code (soft gate).
# warning annotations for files below the floor and exits 1 when any
# file is below.
#
# Why files instead of per-function: cargo-llvm-cov's per-function
# region data is unreliable for async state-machines (most regions live
@@ -104,14 +104,6 @@ echo "Checked: $TOTAL hot-path file(s)"
echo "Below threshold: $BELOW"
echo "Not found: $NOT_FOUND"
# Two-layer gate:
# - This script exits 1 when any hot-path file regresses below the
# threshold. That gives an unambiguous CI signal in the workflow log.
# - The `coverage` job in `.github/workflows/rust-tests.yml` carries
# `continue-on-error: true`, so the failing exit is visible in the
# PR's checks panel but does NOT block merges yet.
# - Flip to a hard PR-blocker by removing `continue-on-error` from the
# workflow once we've watched a few PRs run cleanly.
if [[ "$BELOW" -gt 0 ]]; then
exit 1
fi
+20 -1
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env node
/**
* Align src-tauri/Cargo.toml and src-tauri/tauri.conf.json with package.json "version".
* Align src-tauri/Cargo.toml, src-tauri/tauri.conf.json, and workspace entries in
* src-tauri/Cargo.lock with package.json "version".
* Used after npm version in promote workflows so bundle names match release semver.
*/
const fs = require('fs');
@@ -28,3 +29,21 @@ const conf = JSON.parse(fs.readFileSync(confPath, 'utf8'));
conf.version = version;
fs.writeFileSync(confPath, JSON.stringify(conf, null, 2) + '\n');
console.log(`tauri.conf.json -> ${version}`);
/** @param {string} lockText */
function syncCargoLockWorkspaceVersions(lockText, targetVersion) {
return lockText.replace(
/^(name = "psysonic[^"]*"\nversion = ")[^"]+"/gm,
`$1${targetVersion}"`,
);
}
const lockPath = path.join(root, 'src-tauri', 'Cargo.lock');
let lock = fs.readFileSync(lockPath, 'utf8');
const updatedLock = syncCargoLockWorkspaceVersions(lock, version);
if (updatedLock !== lock) {
fs.writeFileSync(lockPath, updatedLock);
console.log(`Cargo.lock workspace crates -> ${version}`);
} else {
console.log(`Cargo.lock workspace crates already at ${version}`);
}
+814 -150
View File
File diff suppressed because it is too large Load Diff
+26 -18
View File
@@ -3,9 +3,10 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "1.46.0-dev"
version = "1.48.0-dev"
edition = "2021"
rust-version = "1.89"
rust-version = "1.95"
license = "GPL-3.0-or-later"
[workspace.dependencies]
tempfile = "3"
@@ -18,7 +19,7 @@ name = "psysonic"
version.workspace = true
description = "Psysonic Desktop Music Player"
authors = []
license = ""
license.workspace = true
repository = ""
default-run = "psysonic"
edition.workspace = true
@@ -39,9 +40,10 @@ tauri-build = { version = "2", features = [] }
psysonic-core = { path = "crates/psysonic-core" }
psysonic-analysis = { path = "crates/psysonic-analysis" }
psysonic-audio = { path = "crates/psysonic-audio" }
psysonic-library = { path = "crates/psysonic-library" }
psysonic-syncfs = { path = "crates/psysonic-syncfs" }
psysonic-integration = { path = "crates/psysonic-integration" }
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] }
tauri-plugin-single-instance = "2"
tauri-plugin-shell = "2"
tauri-plugin-global-shortcut = "2"
@@ -50,8 +52,8 @@ tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rodio = { version = "0.22", default-features = false, features = ["playback", "symphonia-all"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
rodio = { version = "0.22", default-features = false, features = ["playback"] }
symphonia = { version = "0.6", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm", "all-meta"] }
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "multipart", "query", "form", "rustls", "blocking", "gzip", "brotli"] }
futures-util = "0.3"
md5 = "0.8"
@@ -66,20 +68,27 @@ discord-rich-presence = "1.1"
url = "2"
thread-priority = "3"
lofty = "0.24"
sysinfo = { version = "0.38", default-features = false, features = ["disk"] }
id3 = "1.16.4"
symphonia-adapter-libopus = "0.2.9"
rusqlite = { version = "0.39", features = ["bundled"] }
sysinfo = { version = "0.39", default-features = false, features = ["disk", "system"] }
id3 = "1.17"
symphonia-adapter-libopus = "0.3"
rusqlite = { version = "0.40", features = ["bundled"] }
ebur128 = "0.1"
dasp_sample = "0.11.0"
zip = "8"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
webp = "0.3"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "macos")'.dependencies]
mach2 = "0.5"
[target.'cfg(target_os = "linux")'.dependencies]
zbus = { version = "5.15", default-features = false, features = ["blocking-api", "async-io"] }
zbus = { version = "5.16", default-features = false, features = ["blocking-api", "async-io"] }
# Match wry/tauris WebKitGTK stack — used only to turn off kinetic wheel scrolling.
webkit2gtk = { version = "2.0", default-features = false, features = ["v2_40"] }
webkit2gtk-nvidia-quirk = "1.3"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = [
@@ -94,11 +103,10 @@ windows = { version = "0.62", features = [
"Win32_UI_WindowsAndMessaging",
] }
[patch.crates-io]
# Local patch for Symphonia's isomp4 demuxer:
# - Fixes descriptor.unwrap() panic on malformed esds atoms (older iTunes M4A)
# - Tolerates SL predefined=0x01 (null) used by some older iTunes-purchased files
# - Gracefully skips malformed trak atoms (e.g. MJPEG cover-art streams) instead
# of failing the entire probe
symphonia-format-isomp4 = { path = "patches/symphonia-format-isomp4" }
# NOTE: The local `symphonia-format-isomp4` path patch (0.5-based) was removed for
# the Symphonia 0.6 migration. Symphonia 0.6 upstream already covers the esds
# missing-descriptor and SL predefined=null fixes. The malformed-trak skip and
# moov-at-end tail scan are validated against the fixture corpus on stock 0.6; if a
# case regresses, re-create the patch from the 0.6 isomp4 source and re-add the
# [patch.crates-io] entry here. See workdocs task 2026-05-symphonia-0.6-migration.
+2 -1
View File
@@ -21,6 +21,8 @@ accepted = [
"OpenSSL",
"BSL-1.0",
"CDLA-Permissive-2.0",
"GPL-3.0-or-later",
"bzip2-1.0.6",
]
# Skip the build host's own platform-pinning; we want a list across all targets
@@ -38,5 +40,4 @@ targets = [
ignore-build-dependencies = false
ignore-dev-dependencies = true
ignore-transitive-dependencies = false
filter-noassertion = false
workarounds = ["ring"]
+1
View File
@@ -38,6 +38,7 @@
"core:window:allow-create",
"core:window:allow-set-size",
"core:webview:allow-create-webview-window",
"core:webview:allow-set-webview-zoom",
"process:allow-restart",
"updater:default"
]
@@ -3,6 +3,7 @@ name = "psysonic-analysis"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -16,5 +17,10 @@ reqwest = { version = "0.13", default-features = false, features = ["stream", "r
futures-util = "0.3"
ebur128 = "0.1"
md5 = "0.8"
rusqlite = { version = "0.39", features = ["bundled"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
rusqlite = { version = "0.40", features = ["bundled"] }
symphonia = { version = "0.6", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm", "all-meta"] }
symphonia-adapter-libopus = "0.3"
oximedia-mir = { version = "0.1.7", default-features = false, features = ["tempo", "mood"] }
[dev-dependencies]
tauri = { version = "2", features = ["test"] }
@@ -0,0 +1,44 @@
-- Baseline: the pre-versioning analysis cache schema.
--
-- This is the exact shape every existing user DB already carries (created by
-- the old `CREATE TABLE IF NOT EXISTS` bootstrap). `IF NOT EXISTS` keeps it a
-- no-op on those DBs and creates the tables on a fresh one, so "migration 1
-- applied" means "the schema that shipped before versioned migrations".
--
-- Server-scoping (server_id) is added additively in 002.
CREATE TABLE IF NOT EXISTS analysis_track (
track_id TEXT NOT NULL,
md5_16kb TEXT NOT NULL,
status TEXT NOT NULL,
waveform_algo_version INTEGER NOT NULL,
loudness_algo_version INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (track_id, md5_16kb)
);
CREATE TABLE IF NOT EXISTS waveform_cache (
track_id TEXT NOT NULL,
md5_16kb TEXT NOT NULL,
bins BLOB NOT NULL,
bin_count INTEGER NOT NULL,
is_partial INTEGER NOT NULL,
known_until_sec REAL NOT NULL,
duration_sec REAL NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (track_id, md5_16kb)
);
CREATE TABLE IF NOT EXISTS loudness_cache (
track_id TEXT NOT NULL,
md5_16kb TEXT NOT NULL,
integrated_lufs REAL NOT NULL,
true_peak REAL NOT NULL,
recommended_gain_db REAL NOT NULL,
target_lufs REAL NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (track_id, md5_16kb, target_lufs)
);
CREATE INDEX IF NOT EXISTS idx_analysis_track_status
ON analysis_track(status);
@@ -0,0 +1,73 @@
-- Add `server_id` to the analysis cache so waveform/loudness rows are scoped
-- per server (E1 / R7-16). SQLite cannot change a PRIMARY KEY in place, so each
-- table is rebuilt: create the v2 shape, copy every row with server_id = '',
-- drop the old table, rename. Existing rows become legacy ('') rows that the
-- read path still finds (server -> legacy -> lazy re-tag, added in 6c-2).
--
-- Atomicity: the migration runner wraps this whole file plus the
-- schema_migrations marker in one transaction, so any failure or crash rolls
-- everything back to the original tables — DROP never runs unless the copy
-- before it succeeded. No BEGIN/COMMIT here (that would nest).
--
-- These three tables have no foreign keys between them or from any other table,
-- so the drop/rename needs no `PRAGMA foreign_keys` toggle (which is a no-op
-- inside a transaction anyway).
-- analysis_track
CREATE TABLE analysis_track_v2 (
server_id TEXT NOT NULL DEFAULT '',
track_id TEXT NOT NULL,
md5_16kb TEXT NOT NULL,
status TEXT NOT NULL,
waveform_algo_version INTEGER NOT NULL,
loudness_algo_version INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (server_id, track_id, md5_16kb)
);
INSERT INTO analysis_track_v2
(server_id, track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at)
SELECT '', track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at
FROM analysis_track;
DROP TABLE analysis_track;
ALTER TABLE analysis_track_v2 RENAME TO analysis_track;
CREATE INDEX IF NOT EXISTS idx_analysis_track_status
ON analysis_track(status);
-- waveform_cache
CREATE TABLE waveform_cache_v2 (
server_id TEXT NOT NULL DEFAULT '',
track_id TEXT NOT NULL,
md5_16kb TEXT NOT NULL,
bins BLOB NOT NULL,
bin_count INTEGER NOT NULL,
is_partial INTEGER NOT NULL,
known_until_sec REAL NOT NULL,
duration_sec REAL NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (server_id, track_id, md5_16kb)
);
INSERT INTO waveform_cache_v2
(server_id, track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at)
SELECT '', track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at
FROM waveform_cache;
DROP TABLE waveform_cache;
ALTER TABLE waveform_cache_v2 RENAME TO waveform_cache;
-- loudness_cache
CREATE TABLE loudness_cache_v2 (
server_id TEXT NOT NULL DEFAULT '',
track_id TEXT NOT NULL,
md5_16kb TEXT NOT NULL,
integrated_lufs REAL NOT NULL,
true_peak REAL NOT NULL,
recommended_gain_db REAL NOT NULL,
target_lufs REAL NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (server_id, track_id, md5_16kb, target_lufs)
);
INSERT INTO loudness_cache_v2
(server_id, track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at)
SELECT '', track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at
FROM loudness_cache;
DROP TABLE loudness_cache;
ALTER TABLE loudness_cache_v2 RENAME TO loudness_cache;
@@ -2,14 +2,18 @@ use std::io::Cursor;
use std::time::Instant;
use ebur128::{EbuR128, Mode as Ebur128Mode};
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::{Decoder, DecoderOptions, CODEC_TYPE_NULL};
use symphonia::core::codecs::audio::{AudioDecoder, AudioDecoderOptions};
use symphonia::core::errors::Error as SymphoniaError;
use symphonia::core::formats::{FormatOptions, FormatReader};
use symphonia::core::formats::probe::Hint;
use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo, TrackType};
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
use tauri::Manager;
use symphonia::core::units::Time;
use tauri::{Manager, Runtime};
use psysonic_core::track_enrichment::TrackEnrichmentOutcome;
use crate::analysis_perf::AnalysisSeedTimings;
use crate::codec::make_decoder;
use super::store::{now_unix_ts, AnalysisCache, LoudnessEntry, TrackKey, WaveformEntry};
@@ -38,54 +42,120 @@ pub enum SeedFromBytesOutcome {
/// Full Symphonia + (optional) EBU decode for waveform + loudness. Call only from the
/// single CPU-seed worker in `lib.rs` (`spawn_blocking`) so at most one heavy decode runs.
pub fn seed_from_bytes_execute(
app: &tauri::AppHandle,
pub fn seed_from_bytes_execute<R: Runtime>(
app: &tauri::AppHandle<R>,
server_id: &str,
track_id: &str,
bytes: &[u8],
) -> Result<SeedFromBytesOutcome, String> {
format_hint: Option<&str>,
notify_ui: bool,
) -> Result<(SeedFromBytesOutcome, AnalysisSeedTimings), String> {
let seed_started = Instant::now();
let Some(cache) = app.try_state::<AnalysisCache>() else {
crate::app_deprintln!(
"[analysis][waveform] build skip track_id={} reason=no_analysis_cache bytes={}",
track_id,
bytes.len()
);
return Ok(SeedFromBytesOutcome::SkippedNoAnalysisCache);
return Ok((
SeedFromBytesOutcome::SkippedNoAnalysisCache,
AnalysisSeedTimings::default(),
));
};
seed_from_bytes_into_cache(&cache, track_id, bytes)
let (outcome, md5_16kb) =
seed_from_bytes_into_cache(&cache, server_id, track_id, bytes, format_hint)?;
let seed_ms = seed_started.elapsed().as_millis() as u64;
// E2 bridge (analysis → library content_hash): once the playback-derived
// md5_16kb is known — whether freshly written or already cached — record it
// as `track.content_hash` via the registered sink. Decoupled from
// psysonic-library through the psysonic-core port; a no-op when the library
// has no row for this (server_id, track_id). Skipped when no server is known.
if !server_id.is_empty()
&& matches!(
outcome,
SeedFromBytesOutcome::Upserted | SeedFromBytesOutcome::SkippedWaveformCacheHit
)
{
if let Some(sink) = app.try_state::<psysonic_core::ports::ContentHashSink>() {
sink.record_content_hash(server_id, track_id, &md5_16kb);
}
}
let bpm_ms = if !server_id.is_empty() {
let bpm_started = Instant::now();
let enrichment_outcome = crate::track_enrichment::run_track_enrichment_if_needed(
app,
server_id,
track_id,
bytes,
notify_ui,
);
if matches!(enrichment_outcome, TrackEnrichmentOutcome::Failed) {
let key = TrackKey {
server_id: server_id.to_string(),
track_id: track_id.to_string(),
md5_16kb: md5_16kb.clone(),
};
let _ = cache.touch_track_status(&key, "failed");
}
if matches!(outcome, SeedFromBytesOutcome::Upserted) {
if let Ok(coverage) = cache.content_cache_coverage(server_id, track_id, &md5_16kb) {
if !coverage.has_loudness {
let key = TrackKey {
server_id: server_id.to_string(),
track_id: track_id.to_string(),
md5_16kb: md5_16kb.clone(),
};
let _ = cache.touch_track_status(&key, "failed");
}
}
}
bpm_started.elapsed().as_millis() as u64
} else {
0
};
Ok((
outcome,
AnalysisSeedTimings { seed_ms, bpm_ms },
))
}
/// AppHandle-free entry point for [`seed_from_bytes_execute`]: takes the cache
/// directly, runs the same Symphonia → waveform → EBU R128 pipeline, and
/// upserts the rows. Called from `seed_from_bytes_execute` in production and
/// from tests against an in-memory cache.
/// Returns the outcome plus the computed `md5_16kb` (the content fingerprint),
/// so the AppHandle-aware caller can bridge it to the library `content_hash`
/// (E2) without re-reading the bytes.
pub fn seed_from_bytes_into_cache(
cache: &AnalysisCache,
server_id: &str,
track_id: &str,
bytes: &[u8],
) -> Result<SeedFromBytesOutcome, String> {
format_hint: Option<&str>,
) -> Result<(SeedFromBytesOutcome, String), String> {
let started = Instant::now();
// Write under the playback server's scope.
let key = TrackKey {
server_id: server_id.to_string(),
track_id: track_id.to_string(),
md5_16kb: md5_first_16kb(bytes),
};
if let Some(existing) = cache.get_waveform(&key)? {
if !existing.bins.is_empty() {
if cache.loudness_row_exists_for_key(&key)? {
crate::app_deprintln!(
"[analysis][waveform] build skip track_id={} reason=waveform_cache_hit md5_16kb={} bins_len={} elapsed_ms={}",
track_id,
key.md5_16kb,
existing.bins.len(),
started.elapsed().as_millis()
);
return Ok(SeedFromBytesOutcome::SkippedWaveformCacheHit);
}
crate::app_deprintln!(
"[analysis][waveform] waveform cache hit but loudness missing — full re-analysis track_id={} md5_16kb={}",
track_id,
key.md5_16kb
);
}
let coverage = cache.content_cache_coverage(server_id, track_id, &key.md5_16kb)?;
if coverage.complete() {
crate::app_deprintln!(
"[analysis][waveform] build skip track_id={} reason=waveform_cache_hit md5_16kb={} elapsed_ms={}",
track_id,
key.md5_16kb,
started.elapsed().as_millis()
);
return Ok((SeedFromBytesOutcome::SkippedWaveformCacheHit, key.md5_16kb.clone()));
}
if coverage.has_waveform && !coverage.has_loudness {
crate::app_deprintln!(
"[analysis][waveform] waveform cache hit but loudness missing — full re-analysis track_id={} md5_16kb={}",
track_id,
key.md5_16kb
);
}
let mib = bytes.len() as f64 / (1024.0 * 1024.0);
crate::app_deprintln!(
@@ -101,7 +171,8 @@ pub fn seed_from_bytes_into_cache(
let build = (|| -> Result<(bool, usize), String> {
cache.touch_track_status(&key, "queued")?;
let (wf_bins, loudness_opt, used_pcm_decode) = match analyze_loudness_and_waveform(bytes, -16.0, 500) {
let (wf_bins, loudness_opt, used_pcm_decode) =
match analyze_loudness_and_waveform(bytes, -16.0, 500, format_hint) {
Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs, bins)) => {
(
bins,
@@ -134,6 +205,7 @@ pub fn seed_from_bytes_into_cache(
}
cache.touch_track_status(&key, "ready")?;
let _ = cache.checkpoint_wal("analysis.seed");
Ok((used_pcm_decode, bins_len))
})();
@@ -154,6 +226,7 @@ pub fn seed_from_bytes_into_cache(
);
}
Err(e) => {
let _ = cache.touch_track_status(&key, "failed");
crate::app_deprintln!(
"[analysis] full-track analysis failed track_id={} elapsed_ms={} err={}",
track_id,
@@ -164,12 +237,12 @@ pub fn seed_from_bytes_into_cache(
}
match build {
Ok(_) => Ok(SeedFromBytesOutcome::Upserted),
Ok(_) => Ok((SeedFromBytesOutcome::Upserted, key.md5_16kb.clone())),
Err(e) => Err(e),
}
}
fn md5_first_16kb(bytes: &[u8]) -> String {
pub fn md5_first_16kb(bytes: &[u8]) -> String {
let n = bytes.len().min(16 * 1024);
format!("{:x}", md5::compute(&bytes[..n]))
}
@@ -206,15 +279,16 @@ fn analyze_loudness_and_waveform(
bytes: &[u8],
target_lufs: f64,
bin_count: usize,
format_hint: Option<&str>,
) -> Option<(f64, f64, f64, f64, Vec<u8>)> {
if bytes.is_empty() || bin_count == 0 {
return None;
}
let (decoded_frames, timeline_hint) = count_mono_frames_from_audio_bytes(bytes)?;
let (decoded_frames, timeline_hint) = count_mono_frames_from_audio_bytes(bytes, format_hint)?;
if decoded_frames == 0 {
return None;
}
let scanned = decode_scan_pcm(bytes, bin_count, decoded_frames, timeline_hint, Some(target_lufs))?;
let scanned = decode_scan_pcm(bytes, bin_count, decoded_frames, timeline_hint, Some(target_lufs), format_hint)?;
let (i, t, r, tgt) = scanned.loudness?;
Some((i, t, r, tgt, scanned.bins))
}
@@ -224,34 +298,60 @@ fn analyze_loudness_and_waveform(
/// when the container reports total track length.
struct DecodeSession {
format: Box<dyn FormatReader>,
decoder: Box<dyn Decoder>,
decoder: Box<dyn AudioDecoder>,
track_id: u32,
timeline_hint: Option<u64>,
}
fn open_decode_session(bytes: &[u8]) -> Option<DecodeSession> {
fn format_hint_from_bytes(bytes: &[u8]) -> Option<String> {
if bytes.len() < 4 {
return None;
}
if bytes[0..4] == *b"OggS" {
return Some("ogg".into());
}
if bytes.len() >= 4 && bytes[0..4] == *b"fLaC" {
return Some("flac".into());
}
if bytes.len() >= 12 && bytes[0..4] == *b"RIFF" && bytes[8..12] == *b"WAVE" {
return Some("wav".into());
}
let scan = bytes.len().min(4096).saturating_sub(4);
for i in 0..=scan {
if bytes[i..i + 4] == *b"ftyp" {
return Some("m4a".into());
}
}
None
}
fn open_decode_session(bytes: &[u8], format_hint: Option<&str>) -> Option<DecodeSession> {
let source = Box::new(Cursor::new(bytes.to_vec()));
let mss = MediaSourceStream::new(source, Default::default());
let hint = Hint::new();
let probed = symphonia::default::get_probe()
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
let sniffed = format_hint_from_bytes(bytes);
let mut hint = Hint::new();
if let Some(ext) = format_hint.or(sniffed.as_deref()) {
hint.with_extension(ext);
}
let format = symphonia::default::get_probe()
.probe(&hint, mss, FormatOptions::default(), MetadataOptions::default())
.ok()?;
let format = probed.format;
// Prefer an audio track that reports both sample rate and channels; fall back to
// the first audio track with a known codec (skips e.g. MJPEG cover-art tracks).
let track = format
.default_track()
.filter(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.or_else(|| {
format.tracks().iter().find(|t| {
t.codec_params.codec != CODEC_TYPE_NULL
&& t.codec_params.sample_rate.is_some()
&& t.codec_params.channels.is_some()
})
.tracks()
.iter()
.find(|t| {
t.codec_params
.as_ref()
.and_then(|c| c.audio())
.is_some_and(|a| a.sample_rate.is_some() && a.channels.is_some())
})
.or_else(|| format.tracks().iter().find(|t| t.codec_params.codec != CODEC_TYPE_NULL))?;
.or_else(|| format.first_track_known_codec(TrackType::Audio))?;
let track_id = track.id;
let timeline_hint = track.codec_params.n_frames.filter(|&n| n > 0);
let codec_params = track.codec_params.clone();
let decoder = match symphonia::default::get_codecs().make(&codec_params, &DecoderOptions::default()) {
let timeline_hint = track.num_frames.filter(|&n| n > 0);
let audio_params = track.codec_params.as_ref()?.audio()?.clone();
let decoder = match make_decoder(&audio_params, &AudioDecoderOptions::default().gapless(false)) {
Ok(v) => v,
Err(e) => {
crate::app_deprintln!("[analysis] decoder make failed: {}", e);
@@ -265,14 +365,15 @@ fn open_decode_session(bytes: &[u8]) -> Option<DecodeSession> {
/// `codec_params.n_frames` when the container reports total track length — used
/// as a **fixed** waveform time axis so partial decodes do not remap every bin
/// when the buffer grows.
fn count_mono_frames_from_audio_bytes(bytes: &[u8]) -> Option<(u64, Option<u64>)> {
fn count_mono_frames_from_audio_bytes(bytes: &[u8], format_hint: Option<&str>) -> Option<(u64, Option<u64>)> {
let DecodeSession { mut format, mut decoder, track_id, timeline_hint } =
open_decode_session(bytes)?;
open_decode_session(bytes, format_hint)?;
let mut total: u64 = 0;
let mut loop_i: u32 = 0;
while let Ok(packet) = format.next_packet() {
if packet.track_id() != track_id {
let mut samples_buf: Vec<f32> = Vec::new();
while let Ok(Some(packet)) = format.next_packet() {
if packet.track_id != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
@@ -281,14 +382,12 @@ fn count_mono_frames_from_audio_bytes(bytes: &[u8]) -> Option<(u64, Option<u64>)
Err(SymphoniaError::ResetRequired) => break,
Err(_) => break,
};
let spec = *decoded.spec();
let n_ch = spec.channels.count();
let n_ch = decoded.spec().channels().count();
if n_ch == 0 {
continue;
}
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
samples.copy_interleaved_ref(decoded);
let n = samples.samples().len();
decoded.copy_to_vec_interleaved(&mut samples_buf);
let n = samples_buf.len();
if n < n_ch || !n.is_multiple_of(n_ch) {
continue;
}
@@ -330,8 +429,9 @@ fn decode_scan_pcm(
decoded_frames: u64,
timeline_hint: Option<u64>,
loudness_target_lufs: Option<f64>,
format_hint: Option<&str>,
) -> Option<PcmScanResult> {
let DecodeSession { mut format, mut decoder, track_id, .. } = open_decode_session(bytes)?;
let DecodeSession { mut format, mut decoder, track_id, .. } = open_decode_session(bytes, format_hint)?;
let mut bin_max = vec![0.0f32; bin_count];
let mut bin_sum = vec![0.0f32; bin_count];
@@ -358,8 +458,9 @@ fn decode_scan_pcm(
}
let bin_grid_frames = decoded_frames.max(1);
while let Ok(packet) = format.next_packet() {
if packet.track_id() != track_id {
let mut samples_buf: Vec<f32> = Vec::new();
while let Ok(Some(packet)) = format.next_packet() {
if packet.track_id != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
@@ -369,15 +470,14 @@ fn decode_scan_pcm(
Err(_) => break,
};
let spec = *decoded.spec();
let n_ch = spec.channels.count();
let n_ch = decoded.spec().channels().count();
if n_ch == 0 {
continue;
}
if loudness_target_lufs.is_some() && ebu.is_none() {
let ch = spec.channels.count() as u32;
let sr = spec.rate;
let ch = decoded.spec().channels().count() as u32;
let sr = decoded.spec().rate();
match EbuR128::new(ch, sr, Ebur128Mode::I | Ebur128Mode::TRUE_PEAK) {
Ok(v) => {
ebu = Some(v);
@@ -395,9 +495,8 @@ fn decode_scan_pcm(
}
}
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
samples.copy_interleaved_ref(decoded);
let slice = samples.samples();
decoded.copy_to_vec_interleaved(&mut samples_buf);
let slice = samples_buf.as_slice();
if slice.len() < n_ch || !slice.len().is_multiple_of(n_ch) {
continue;
}
@@ -429,7 +528,7 @@ fn decode_scan_pcm(
if loudness_target_lufs.is_some() {
if let Some(e) = ebu.as_mut() {
match e.add_frames_f32(samples.samples()) {
match e.add_frames_f32(&samples_buf) {
Ok(_) => fed_any_frames = true,
Err(err) => {
crate::app_deprintln!("[analysis] loudness add_frames failed: {}", err);
@@ -503,6 +602,171 @@ fn decode_scan_pcm(
Some(PcmScanResult { bins, loudness })
}
/// PCM window for short MIR-style analysis (typically 60 s from track center).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PcmAnalysisWindow {
pub start_sec: f64,
pub duration_sec: f64,
}
/// Pick a centered analysis window, or the full track when shorter than `window_sec`.
pub fn analysis_pcm_window(total_duration_sec: f64, window_sec: f64) -> PcmAnalysisWindow {
let total = total_duration_sec.max(0.0);
let window = window_sec.max(0.1);
if total <= window || !total.is_finite() {
return PcmAnalysisWindow {
start_sec: 0.0,
duration_sec: if total > 0.0 { total } else { window },
};
}
let start = ((total - window) / 2.0).max(0.0);
PcmAnalysisWindow {
start_sec: start,
duration_sec: window,
}
}
/// Best-effort container duration from codec metadata (seconds).
pub fn audio_duration_from_bytes(bytes: &[u8]) -> Option<f64> {
let session = open_decode_session(bytes, None)?;
let sample_rate = session
.format
.default_track(TrackType::Audio)
.or_else(|| session.format.tracks().first())
.and_then(|t| t.codec_params.as_ref())
.and_then(|c| c.audio())
.and_then(|a| a.sample_rate)
.filter(|&sr| sr > 0)?;
let frames = session.timeline_hint?;
Some(frames as f64 / sample_rate as f64)
}
/// Decode mono PCM for a time window. Seeks when `start_sec > 0`.
pub fn decode_mono_pcm_window(
bytes: &[u8],
start_sec: f64,
window_sec: f64,
) -> Result<(Vec<f32>, f32), String> {
if bytes.is_empty() {
return Err("empty audio buffer".to_string());
}
let DecodeSession {
mut format,
mut decoder,
track_id,
..
} = open_decode_session(bytes, None).ok_or_else(|| "failed to open audio decode session".to_string())?;
if start_sec.is_finite() && start_sec > 0.0 {
let time = Time::try_from_secs_f64(start_sec.max(0.0))
.ok_or_else(|| "pcm window: invalid seek time".to_string())?;
format
.seek(
SeekMode::Accurate,
SeekTo::Time {
time,
track_id: Some(track_id),
},
)
.map_err(|e| format!("pcm window seek failed: {e}"))?;
}
decode_mono_pcm_from_session(&mut format, &mut decoder, track_id, Some(window_sec))
}
/// Decode audio bytes to mono f32 PCM, optionally capped at `max_seconds`.
pub fn decode_mono_pcm_limited(
bytes: &[u8],
max_seconds: Option<f64>,
) -> Result<(Vec<f32>, f32), String> {
if bytes.is_empty() {
return Err("empty audio buffer".to_string());
}
let DecodeSession {
mut format,
mut decoder,
track_id,
..
} = open_decode_session(bytes, None).ok_or_else(|| "failed to open audio decode session".to_string())?;
decode_mono_pcm_from_session(&mut format, &mut decoder, track_id, max_seconds)
}
fn decode_mono_pcm_from_session(
format: &mut Box<dyn FormatReader>,
decoder: &mut Box<dyn AudioDecoder>,
track_id: u32,
max_seconds: Option<f64>,
) -> Result<(Vec<f32>, f32), String> {
let mut mono = Vec::new();
let mut sample_rate = 0_f32;
let mut max_frames: Option<u64> = None;
let mut loop_i: u32 = 0;
let mut samples_buf: Vec<f32> = Vec::new();
while let Ok(Some(packet)) = format.next_packet() {
if packet.track_id != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
Ok(buf) => buf,
Err(SymphoniaError::DecodeError(_)) => continue,
Err(SymphoniaError::ResetRequired) => break,
Err(_) => break,
};
let n_ch = decoded.spec().channels().count();
if n_ch == 0 {
continue;
}
if sample_rate <= 0.0 {
sample_rate = decoded.spec().rate() as f32;
if sample_rate <= 0.0 {
return Err("invalid sample rate".to_string());
}
max_frames = max_seconds.and_then(|sec| {
if sec.is_finite() && sec > 0.0 {
Some((sec * sample_rate as f64).max(1.0) as u64)
} else {
None
}
});
}
decoded.copy_to_vec_interleaved(&mut samples_buf);
let slice = samples_buf.as_slice();
if slice.len() < n_ch || !slice.len().is_multiple_of(n_ch) {
continue;
}
let frames = slice.len() / n_ch;
for f in 0..frames {
if let Some(limit) = max_frames {
if mono.len() as u64 >= limit {
break;
}
}
let base = f * n_ch;
let mut acc = 0.0_f32;
for c in 0..n_ch {
acc += slice[base + c];
}
mono.push(acc / (n_ch as f32));
}
if max_frames.is_some_and(|limit| mono.len() as u64 >= limit) {
break;
}
loop_i = loop_i.wrapping_add(1);
if loop_i.is_multiple_of(128) {
std::thread::yield_now();
}
}
if mono.is_empty() {
return Err("no PCM frames decoded".to_string());
}
Ok((mono, sample_rate))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -535,6 +799,20 @@ mod tests {
assert_eq!(huge_down, -24.0);
}
#[test]
fn analysis_pcm_window_uses_center_for_long_tracks() {
let w = analysis_pcm_window(180.0, 60.0);
assert!((w.start_sec - 60.0).abs() < 1e-9);
assert!((w.duration_sec - 60.0).abs() < 1e-9);
}
#[test]
fn analysis_pcm_window_uses_full_track_when_short() {
let w = analysis_pcm_window(45.0, 60.0);
assert_eq!(w.start_sec, 0.0);
assert!((w.duration_sec - 45.0).abs() < 1e-9);
}
// ── md5_first_16kb ────────────────────────────────────────────────────────
#[test]
@@ -681,7 +959,7 @@ mod tests {
#[test]
fn count_mono_frames_returns_decoded_length_for_synthetic_wav() {
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100);
let (frames, _hint) = count_mono_frames_from_audio_bytes(&wav)
let (frames, _hint) = count_mono_frames_from_audio_bytes(&wav, None)
.expect("WAV decode must succeed");
// 1 second × 44.1 kHz mono = 44 100 frames; allow ±1 packet tolerance.
assert!(
@@ -692,18 +970,18 @@ mod tests {
#[test]
fn count_mono_frames_returns_none_for_garbage_bytes() {
assert!(count_mono_frames_from_audio_bytes(b"not an audio file").is_none());
assert!(count_mono_frames_from_audio_bytes(b"not an audio file", None).is_none());
}
#[test]
fn count_mono_frames_returns_none_for_empty_bytes() {
assert!(count_mono_frames_from_audio_bytes(&[]).is_none());
assert!(count_mono_frames_from_audio_bytes(&[], None).is_none());
}
#[test]
fn analyze_loudness_and_waveform_returns_loudness_for_synthetic_sine() {
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.5), 44_100);
let result = analyze_loudness_and_waveform(&wav, -14.0, 100)
let result = analyze_loudness_and_waveform(&wav, -14.0, 100, None)
.expect("WAV decode must succeed");
let (integrated_lufs, true_peak, recommended_gain_db, target_lufs, bins) = result;
assert_eq!(bins.len(), 200, "bins layout is peak_u8 + mean_u8 = 2 * bin_count");
@@ -728,24 +1006,26 @@ mod tests {
#[test]
fn analyze_loudness_returns_none_for_zero_bin_count() {
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 0.5), 44_100);
assert!(analyze_loudness_and_waveform(&wav, -14.0, 0).is_none());
assert!(analyze_loudness_and_waveform(&wav, -14.0, 0, None).is_none());
}
#[test]
fn analyze_loudness_returns_none_for_empty_bytes() {
assert!(analyze_loudness_and_waveform(&[], -14.0, 100).is_none());
assert!(analyze_loudness_and_waveform(&[], -14.0, 100, None).is_none());
}
#[test]
fn seed_from_bytes_into_cache_upserts_waveform_and_loudness_for_wav() {
let cache = AnalysisCache::open_in_memory();
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.5), 44_100);
let outcome = seed_from_bytes_into_cache(&cache, "wav-track", &wav).unwrap();
let (outcome, md5) = seed_from_bytes_into_cache(&cache, "server-a", "wav-track", &wav, None).unwrap();
assert_eq!(outcome, SeedFromBytesOutcome::Upserted);
assert_eq!(md5, md5_first_16kb(&wav), "outcome carries the content fingerprint");
// Both a waveform AND a loudness row must exist after a successful
// PCM decode + EBU R128 analysis.
let key = TrackKey {
server_id: "server-a".to_string(),
track_id: "wav-track".to_string(),
md5_16kb: md5_first_16kb(&wav),
};
@@ -755,13 +1035,34 @@ mod tests {
assert!(cache.loudness_row_exists_for_key(&key).unwrap());
}
#[test]
fn seed_from_bytes_into_cache_writes_under_the_given_server_scope() {
let cache = AnalysisCache::open_in_memory();
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.5), 44_100);
seed_from_bytes_into_cache(&cache, "server-x", "scoped-track", &wav, None).unwrap();
let md5 = md5_first_16kb(&wav);
let scoped = TrackKey {
server_id: "server-x".to_string(),
track_id: "scoped-track".to_string(),
md5_16kb: md5.clone(),
};
assert!(cache.get_waveform(&scoped).unwrap().is_some(), "row lands under server scope");
let other = TrackKey {
server_id: "server-y".to_string(),
track_id: "scoped-track".to_string(),
md5_16kb: md5,
};
assert!(cache.get_waveform(&other).unwrap().is_none(), "row stays under the exact server");
}
#[test]
fn seed_from_bytes_into_cache_returns_skipped_on_second_call() {
let cache = AnalysisCache::open_in_memory();
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100);
let first = seed_from_bytes_into_cache(&cache, "wav-track-2", &wav).unwrap();
let (first, _) = seed_from_bytes_into_cache(&cache, "server-a", "wav-track-2", &wav, None).unwrap();
assert_eq!(first, SeedFromBytesOutcome::Upserted);
let second = seed_from_bytes_into_cache(&cache, "wav-track-2", &wav).unwrap();
let (second, _) = seed_from_bytes_into_cache(&cache, "server-a", "wav-track-2", &wav, None).unwrap();
assert_eq!(
second,
SeedFromBytesOutcome::SkippedWaveformCacheHit,
@@ -775,10 +1076,11 @@ mod tests {
// Garbage bytes — Symphonia probe fails, the pipeline falls back to
// `derive_waveform_bins` (no loudness row gets cached).
let bytes = vec![0xAAu8; 8 * 1024];
let outcome = seed_from_bytes_into_cache(&cache, "garbage", &bytes).unwrap();
let (outcome, _) = seed_from_bytes_into_cache(&cache, "server-a", "garbage", &bytes, None).unwrap();
assert_eq!(outcome, SeedFromBytesOutcome::Upserted);
let key = TrackKey {
server_id: "server-a".to_string(),
track_id: "garbage".to_string(),
md5_16kb: md5_first_16kb(&bytes),
};
@@ -789,4 +1091,204 @@ mod tests {
"byte-envelope fallback must not cache loudness"
);
}
#[test]
fn audio_duration_from_bytes_reports_duration_for_wav() {
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 2.0), 44_100);
let duration = audio_duration_from_bytes(&wav).expect("duration must be available");
assert!(
(1.8..=2.2).contains(&duration),
"expected ~2s duration, got {duration}"
);
}
#[test]
fn audio_duration_from_bytes_returns_none_for_garbage() {
assert!(audio_duration_from_bytes(b"not audio").is_none());
}
#[test]
fn decode_mono_pcm_limited_decodes_and_respects_limit() {
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(48_000, 2.0), 48_000);
let (full_pcm, sr_full) = decode_mono_pcm_limited(&wav, None).expect("full decode");
assert_eq!(sr_full, 48_000.0);
assert!(
full_pcm.len() >= 95_000,
"2 seconds at 48kHz should decode close to 96k samples"
);
let (limited_pcm, sr_limited) =
decode_mono_pcm_limited(&wav, Some(0.25)).expect("limited decode");
assert_eq!(sr_limited, 48_000.0);
assert!(
(11_500..=12_500).contains(&limited_pcm.len()),
"0.25 seconds at 48kHz should decode ~12k samples, got {}",
limited_pcm.len()
);
assert!(limited_pcm.len() < full_pcm.len());
}
#[test]
fn decode_mono_pcm_limited_rejects_empty_buffer() {
let err = decode_mono_pcm_limited(&[], Some(1.0)).unwrap_err();
assert!(err.contains("empty audio buffer"));
}
#[test]
fn decode_mono_pcm_window_decodes_center_slice() {
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 2.0), 44_100);
let (window_pcm, sr) = decode_mono_pcm_window(&wav, 0.75, 0.5).expect("window decode");
assert_eq!(sr, 44_100.0);
assert!(
(20_000..=24_000).contains(&window_pcm.len()),
"0.5 seconds at 44.1kHz should decode ~22k samples, got {}",
window_pcm.len()
);
}
#[test]
fn decode_mono_pcm_window_rejects_invalid_bytes() {
let err = decode_mono_pcm_window(b"not-audio", 0.0, 1.0).unwrap_err();
assert!(
err.contains("failed to open audio decode session"),
"unexpected error: {err}"
);
}
#[test]
fn decode_scan_pcm_supports_waveform_only_mode_without_loudness() {
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100);
let (frames, hint) = count_mono_frames_from_audio_bytes(&wav, None).expect("frame counting");
let scanned = decode_scan_pcm(&wav, 64, frames, hint, None, None).expect("scan must succeed");
assert_eq!(scanned.bins.len(), 128);
assert!(scanned.loudness.is_none());
}
#[test]
fn decode_scan_pcm_with_loudness_target_returns_loudness_tuple() {
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100);
let (frames, hint) = count_mono_frames_from_audio_bytes(&wav, None).expect("frame counting");
let scanned = decode_scan_pcm(&wav, 64, frames, hint, Some(-14.0), None).expect("scan must succeed");
assert_eq!(scanned.bins.len(), 128);
let (integrated_lufs, true_peak, recommended_gain_db, target_lufs) =
scanned.loudness.expect("loudness tuple must be present");
assert!(integrated_lufs.is_finite());
assert!(true_peak.is_finite());
assert!((-24.0..=24.0).contains(&recommended_gain_db));
assert_eq!(target_lufs, -14.0);
}
#[test]
fn decode_scan_pcm_returns_none_for_non_audio_input() {
assert!(decode_scan_pcm(b"nope", 32, 10, None, Some(-14.0), None).is_none());
}
#[test]
fn seed_from_bytes_reanalyzes_when_waveform_exists_without_loudness() {
let cache = AnalysisCache::open_in_memory();
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100);
let md5 = md5_first_16kb(&wav);
let key = TrackKey {
server_id: "server-a".to_string(),
track_id: "track-reseed".to_string(),
md5_16kb: md5,
};
cache.touch_track_status(&key, "ready").unwrap();
cache
.upsert_waveform(
&key,
&WaveformEntry {
bins: vec![8u8; 1000],
bin_count: 500,
is_partial: false,
known_until_sec: 0.0,
duration_sec: 0.0,
updated_at: now_unix_ts(),
},
)
.unwrap();
assert!(!cache.loudness_row_exists_for_key(&key).unwrap());
let (outcome, _) =
seed_from_bytes_into_cache(&cache, "server-a", "track-reseed", &wav, None).unwrap();
assert_eq!(outcome, SeedFromBytesOutcome::Upserted);
assert!(cache.loudness_row_exists_for_key(&key).unwrap());
}
#[test]
fn analysis_pcm_window_handles_negative_and_non_finite_durations() {
let neg = analysis_pcm_window(-42.0, 60.0);
assert_eq!(neg.start_sec, 0.0);
assert_eq!(neg.duration_sec, 60.0);
let inf = analysis_pcm_window(f64::INFINITY, 60.0);
assert_eq!(inf.start_sec, 0.0);
assert!(!inf.duration_sec.is_finite());
}
#[test]
fn decode_mono_pcm_window_rejects_empty_buffer() {
let err = decode_mono_pcm_window(&[], 0.0, 1.0).unwrap_err();
assert!(err.contains("empty audio buffer"));
}
#[test]
fn decode_mono_pcm_limited_rejects_invalid_bytes() {
let err = decode_mono_pcm_limited(b"not-audio", Some(0.5)).unwrap_err();
assert!(err.contains("failed to open audio decode session"));
}
#[test]
fn decode_mono_pcm_limited_ignores_non_positive_or_non_finite_cap() {
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100);
let (full_a, _) = decode_mono_pcm_limited(&wav, None).unwrap();
let (full_b, _) = decode_mono_pcm_limited(&wav, Some(0.0)).unwrap();
let (full_c, _) = decode_mono_pcm_limited(&wav, Some(f64::NAN)).unwrap();
assert_eq!(full_a.len(), full_b.len());
assert_eq!(full_a.len(), full_c.len());
}
#[test]
fn decode_scan_pcm_returns_none_when_no_frames_decoded() {
let wav = build_mono_pcm16_wav(&[], 44_100);
assert!(analyze_loudness_and_waveform(&wav, -14.0, 64, None).is_none());
}
#[test]
fn decode_scan_pcm_ignores_oversized_timeline_hint() {
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100);
let (frames, _hint) = count_mono_frames_from_audio_bytes(&wav, None).expect("frame counting");
let scanned = decode_scan_pcm(&wav, 64, frames, Some(frames * 10), None, None).unwrap();
assert_eq!(scanned.bins.len(), 128);
}
#[test]
fn seed_from_bytes_execute_returns_no_cache_without_registered_state() {
let app = tauri::test::mock_app();
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 0.25), 44_100);
let handle = app.handle().clone();
let (outcome, timings) = seed_from_bytes_execute(&handle, "s", "t", &wav, None, true)
.expect("seed execute should return a graceful skip");
assert_eq!(outcome, SeedFromBytesOutcome::SkippedNoAnalysisCache);
assert_eq!(timings.seed_ms, 0);
assert_eq!(timings.bpm_ms, 0);
}
#[test]
fn seed_from_bytes_execute_runs_with_registered_cache() {
let app = tauri::test::mock_app();
app.manage(AnalysisCache::open_in_memory());
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 0.5), 44_100);
let handle = app.handle().clone();
let (first, timings_first) =
seed_from_bytes_execute(&handle, "server-a", "track-exec", &wav, None, true).unwrap();
assert_eq!(first, SeedFromBytesOutcome::Upserted);
assert!(timings_first.seed_ms <= 30_000);
let (second, timings_second) =
seed_from_bytes_execute(&handle, "server-a", "track-exec", &wav, None, true).unwrap();
assert_eq!(second, SeedFromBytesOutcome::SkippedWaveformCacheHit);
assert!(timings_second.seed_ms <= 30_000);
}
}
@@ -2,7 +2,11 @@ mod compute;
mod store;
pub use compute::{
recommended_gain_for_target, seed_from_bytes_execute, seed_from_bytes_into_cache,
SeedFromBytesOutcome,
analysis_pcm_window, audio_duration_from_bytes, decode_mono_pcm_limited,
decode_mono_pcm_window, md5_first_16kb, recommended_gain_for_target,
seed_from_bytes_execute, seed_from_bytes_into_cache, PcmAnalysisWindow, SeedFromBytesOutcome,
};
pub use store::{
AnalysisCache, AnalysisDeleteServerReport, FailedTrackEntry, LoudnessEntry, TrackKey,
WaveformEntry,
};
pub use store::{AnalysisCache, LoudnessEntry, TrackKey, WaveformEntry};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
//! Per-track analysis timing events for the Performance Probe overlay.
use tauri::{AppHandle, Emitter};
#[derive(Debug, Clone, Copy, Default)]
pub struct AnalysisSeedTimings {
pub seed_ms: u64,
pub bpm_ms: u64,
}
#[derive(Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisTrackPerfPayload {
pub track_id: String,
pub fetch_ms: u64,
pub seed_ms: u64,
pub bpm_ms: u64,
pub total_ms: u64,
}
pub fn emit_analysis_track_perf(
app: &AppHandle,
track_id: &str,
fetch_ms: u64,
seed_ms: u64,
bpm_ms: u64,
) {
let total_ms = fetch_ms.saturating_add(seed_ms).saturating_add(bpm_ms);
if total_ms == 0 {
return;
}
let _ = app.emit(
"analysis:track-perf",
AnalysisTrackPerfPayload {
track_id: track_id.to_string(),
fetch_ms,
seed_ms,
bpm_ms,
total_ms,
},
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
//! Symphonia codec registry — mirrors `psysonic-audio::codec` (Opus via libopus).
use std::sync::OnceLock;
use symphonia::core::codecs::audio::{AudioCodecParameters, AudioDecoder, AudioDecoderOptions};
use symphonia::core::codecs::registry::CodecRegistry;
pub(crate) fn psysonic_codec_registry() -> &'static CodecRegistry {
static REGISTRY: OnceLock<CodecRegistry> = OnceLock::new();
REGISTRY.get_or_init(|| {
let mut registry = CodecRegistry::new();
symphonia::default::register_enabled_codecs(&mut registry);
registry.register_audio_decoder::<symphonia_adapter_libopus::OpusDecoder>();
registry
})
}
pub(crate) fn make_decoder(
params: &AudioCodecParameters,
opts: &AudioDecoderOptions,
) -> Result<Box<dyn AudioDecoder>, symphonia::core::errors::Error> {
psysonic_codec_registry().make_audio_decoder(params, opts)
}
@@ -5,14 +5,10 @@
use std::collections::HashSet;
use tauri::Manager;
use psysonic_core::ports::PlaybackQueryHandle;
use crate::analysis_cache;
use crate::analysis_runtime::{
analysis_backfill_is_current_track, analysis_backfill_shared, prune_analysis_queues,
AnalysisBackfillEnqueueKind,
analysis_backfill_queue_stats, analysis_pipeline_queue_stats, enqueue_seed_from_url,
prune_analysis_queues, AnalysisBackfillPriority, PlaybackPriorityHints,
};
#[derive(serde::Serialize)]
@@ -49,43 +45,92 @@ pub struct LoudnessCachePayload {
pub updated_at: i64,
}
/// AppHandle-free helper: looks up a waveform by exact `(track_id, md5_16kb)`
/// key and converts the `WaveformEntry` into the JSON-serialisable
/// `WaveformCachePayload`. Pulled out of [`analysis_get_waveform`] so it can
/// be tested with `AnalysisCache::open_in_memory()` and direct upserts.
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisDeleteServerReportDto {
pub analysis_tracks: u64,
pub waveforms: u64,
pub loudness: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisFailedTrackDto {
pub track_id: String,
pub md5_16kb: String,
pub updated_at: i64,
}
impl From<analysis_cache::AnalysisDeleteServerReport> for AnalysisDeleteServerReportDto {
fn from(value: analysis_cache::AnalysisDeleteServerReport) -> Self {
Self {
analysis_tracks: value.analysis_tracks,
waveforms: value.waveforms,
loudness: value.loudness,
}
}
}
impl From<analysis_cache::FailedTrackEntry> for AnalysisFailedTrackDto {
fn from(value: analysis_cache::FailedTrackEntry) -> Self {
Self {
track_id: value.track_id,
md5_16kb: value.md5_16kb,
updated_at: value.updated_at,
}
}
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisServerKeyMigrationDto {
pub legacy_id: String,
pub index_key: String,
}
/// AppHandle-free helper: looks up a waveform by exact `(server_id, track_id,
/// md5_16kb)` key. Converts the `WaveformEntry` into the JSON-serialisable
/// `WaveformCachePayload`. Pulled out of [`analysis_get_waveform`] so it can be
/// tested with `AnalysisCache::open_in_memory()` and direct upserts.
pub fn get_waveform_payload(
cache: &analysis_cache::AnalysisCache,
server_id: &str,
track_id: &str,
md5_16kb: &str,
) -> Result<Option<WaveformCachePayload>, String> {
let key = analysis_cache::TrackKey {
let exact = analysis_cache::TrackKey {
server_id: server_id.to_string(),
track_id: track_id.to_string(),
md5_16kb: md5_16kb.to_string(),
};
Ok(cache.get_waveform(&key)?.map(WaveformCachePayload::from))
}
/// AppHandle-free helper: looks up the latest waveform for `track_id`
/// across all id variants (bare ↔ `stream:` prefix). See [`get_waveform_payload`].
pub fn get_waveform_payload_for_track(
cache: &analysis_cache::AnalysisCache,
track_id: &str,
) -> Result<Option<WaveformCachePayload>, String> {
Ok(cache
.get_latest_waveform_for_track(track_id)?
.get_waveform(&exact)?
.map(WaveformCachePayload::from))
}
/// AppHandle-free helper: looks up the latest loudness row for `track_id`
/// and recomputes `recommended_gain_db` against the optional requested target
/// (clamped to [-30, -8]). When `target_lufs` is `None`, the cached row's own
/// target is used.
/// AppHandle-free helper: looks up the latest waveform for `(server_id, track_id)`
/// across all id variants (bare ↔ `stream:` prefix). See [`get_waveform_payload`].
pub fn get_waveform_payload_for_track(
cache: &analysis_cache::AnalysisCache,
server_id: &str,
track_id: &str,
) -> Result<Option<WaveformCachePayload>, String> {
Ok(cache
.get_latest_waveform_for_track(server_id, track_id)?
.map(WaveformCachePayload::from))
}
/// AppHandle-free helper: looks up the latest loudness row for `(server_id,
/// track_id)` and recomputes `recommended_gain_db`
/// against the optional requested target (clamped to [-30, -8]). When
/// `target_lufs` is `None`, the cached row's own target is used.
pub fn get_loudness_payload_for_track(
cache: &analysis_cache::AnalysisCache,
server_id: &str,
track_id: &str,
target_lufs: Option<f64>,
) -> Result<Option<LoudnessCachePayload>, String> {
Ok(cache.get_latest_loudness_for_track(track_id)?.map(|v| {
Ok(cache.get_latest_loudness_for_track(server_id, track_id)?.map(|v| {
let requested_target = target_lufs.unwrap_or(v.target_lufs).clamp(-30.0, -8.0);
let recommended_gain_db = analysis_cache::recommended_gain_for_target(
v.integrated_lufs,
@@ -106,9 +151,11 @@ pub fn get_loudness_payload_for_track(
pub fn analysis_get_waveform(
track_id: String,
md5_16kb: String,
server_id: Option<String>,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<Option<WaveformCachePayload>, String> {
let result = get_waveform_payload(cache.inner(), &track_id, &md5_16kb);
let server_id = server_id.unwrap_or_default();
let result = get_waveform_payload(cache.inner(), &server_id, &track_id, &md5_16kb);
if let Ok(ref payload) = result {
match payload {
Some(v) => crate::app_deprintln!(
@@ -127,9 +174,11 @@ pub fn analysis_get_waveform(
#[tauri::command]
pub fn analysis_get_waveform_for_track(
track_id: String,
server_id: Option<String>,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<Option<WaveformCachePayload>, String> {
let result = get_waveform_payload_for_track(cache.inner(), &track_id);
let server_id = server_id.unwrap_or_default();
let result = get_waveform_payload_for_track(cache.inner(), &server_id, &track_id);
if let Ok(ref payload) = result {
match payload {
Some(v) => crate::app_deprintln!(
@@ -146,25 +195,29 @@ pub fn analysis_get_waveform_for_track(
pub fn analysis_get_loudness_for_track(
track_id: String,
target_lufs: Option<f64>,
server_id: Option<String>,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<Option<LoudnessCachePayload>, String> {
get_loudness_payload_for_track(cache.inner(), &track_id, target_lufs)
let server_id = server_id.unwrap_or_default();
get_loudness_payload_for_track(cache.inner(), &server_id, &track_id, target_lufs)
}
#[tauri::command]
pub fn analysis_delete_loudness_for_track(
track_id: String,
server_id: Option<String>,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<u64, String> {
cache.delete_loudness_for_track_id(&track_id)
cache.delete_loudness_for_track_id(&server_id.unwrap_or_default(), &track_id)
}
#[tauri::command]
pub fn analysis_delete_waveform_for_track(
track_id: String,
server_id: Option<String>,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<u64, String> {
cache.delete_waveform_for_track_id(&track_id)
cache.delete_waveform_for_track_id(&server_id.unwrap_or_default(), &track_id)
}
#[tauri::command]
@@ -174,70 +227,146 @@ pub fn analysis_delete_all_waveforms(
cache.delete_all_waveforms()
}
#[tauri::command]
pub fn analysis_delete_all_for_server(
server_id: String,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<AnalysisDeleteServerReportDto, String> {
if server_id.trim().is_empty() {
return Err("server_id required".to_string());
}
let report = cache.delete_all_for_server(&server_id)?;
Ok(report.into())
}
#[tauri::command]
pub fn analysis_get_failed_track_count(
server_id: String,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<i64, String> {
let server_id = server_id.trim().to_string();
if server_id.is_empty() {
return Ok(0);
}
cache.count_failed_tracks(&server_id)
}
#[tauri::command]
pub fn analysis_list_failed_tracks(
server_id: String,
limit: Option<u32>,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<Vec<AnalysisFailedTrackDto>, String> {
let server_id = server_id.trim().to_string();
if server_id.is_empty() {
return Ok(Vec::new());
}
let limit = limit
.map(|v| usize::try_from(v).unwrap_or(usize::MAX))
.map(|v| v.clamp(1, 5_000));
let rows = cache.list_failed_tracks(&server_id, limit)?;
Ok(rows.into_iter().map(AnalysisFailedTrackDto::from).collect())
}
#[tauri::command]
pub fn analysis_clear_failed_tracks(
server_id: String,
track_ids: Option<Vec<String>>,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<u64, String> {
let server_id = server_id.trim().to_string();
if server_id.is_empty() {
return Err("server_id required".to_string());
}
let track_ids = track_ids
.unwrap_or_default()
.into_iter()
.map(|id| id.trim().to_string())
.filter(|id| !id.is_empty())
.collect::<Vec<_>>();
cache.clear_failed_tracks(&server_id, &track_ids)
}
#[tauri::command]
pub fn analysis_migrate_server_index_keys(
mappings: Vec<AnalysisServerKeyMigrationDto>,
_cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<(), String> {
for mapping in mappings {
let _ = (mapping.legacy_id, mapping.index_key);
}
Ok(())
}
#[tauri::command]
pub fn analysis_enqueue_seed_from_url(
track_id: String,
url: String,
force: Option<bool>,
server_id: Option<String>,
priority: Option<String>,
app: tauri::AppHandle,
) -> Result<(), String> {
if track_id.trim().is_empty() || url.trim().is_empty() {
return Ok(());
}
let force = force.unwrap_or(false);
if !force {
if let Some(playback) = app.try_state::<PlaybackQueryHandle>() {
if playback.ranged_loudness_backfill_should_defer(&track_id) {
crate::app_deprintln!(
"[analysis] backfill skip track_id={} reason=ranged_playback_will_seed",
track_id
);
return Ok(());
}
}
}
if !force {
if let Some(cache) = app.try_state::<analysis_cache::AnalysisCache>() {
if cache.get_latest_loudness_for_track(&track_id)?.is_some() {
crate::app_deprintln!(
"[analysis] backfill skip (already cached): {}",
track_id
);
return Ok(());
}
}
}
let tid_log = track_id.clone();
let high_priority = analysis_backfill_is_current_track(&app, &track_id);
let shared = analysis_backfill_shared(&app);
let kind = {
let mut st = shared
.state
.lock()
.map_err(|_| "analysis backfill lock poisoned".to_string())?;
st.enqueue(track_id, url, high_priority)
};
match kind {
AnalysisBackfillEnqueueKind::NewBack | AnalysisBackfillEnqueueKind::NewFront => {
shared.ping_worker();
crate::app_deprintln!(
"[analysis] backfill enqueued: track_id={} position={}",
tid_log,
if high_priority { "front" } else { "back" }
);
}
AnalysisBackfillEnqueueKind::ReorderedFront => {
shared.ping_worker();
crate::app_deprintln!(
"[analysis] backfill bumped to front (current track) track_id={}",
tid_log
);
}
AnalysisBackfillEnqueueKind::DuplicateSkipped | AnalysisBackfillEnqueueKind::RunningSkipped => {}
}
let explicit = AnalysisBackfillPriority::from_optional_str(priority.as_deref());
enqueue_seed_from_url(
&app,
&track_id,
&url,
server_id.as_deref(),
explicit,
force.unwrap_or(false),
)
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisPriorityHintDto {
pub server_id: String,
pub track_id: String,
}
#[tauri::command]
pub fn analysis_set_playback_priority_hints(
middle_track_refs: Vec<AnalysisPriorityHintDto>,
hints: tauri::State<'_, PlaybackPriorityHints>,
) -> Result<(), String> {
let pairs = middle_track_refs
.into_iter()
.map(|r| (r.server_id, r.track_id));
hints.set_middle_track_ids(pairs);
Ok(())
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisBackfillQueueStatsDto {
pub queued: usize,
pub in_progress_count: usize,
pub in_progress_track_id: Option<String>,
}
#[tauri::command]
pub fn analysis_set_pipeline_parallelism(workers: u32) -> Result<(), String> {
crate::analysis_runtime::analysis_set_pipeline_parallelism(workers as usize);
Ok(())
}
#[tauri::command]
pub fn analysis_get_pipeline_queue_stats() -> Result<crate::analysis_runtime::AnalysisPipelineQueueStatsDto, String> {
Ok(analysis_pipeline_queue_stats())
}
#[tauri::command]
pub fn analysis_get_backfill_queue_stats() -> Result<AnalysisBackfillQueueStatsDto, String> {
let (queued, in_progress_count, in_progress_track_id) =
analysis_backfill_queue_stats();
Ok(AnalysisBackfillQueueStatsDto {
queued,
in_progress_count,
in_progress_track_id,
})
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AnalysisPrunePendingResult {
@@ -253,6 +382,7 @@ pub struct AnalysisPrunePendingResult {
#[tauri::command]
pub fn analysis_prune_pending_to_track_ids(
track_ids: Vec<String>,
server_id: String,
) -> Result<AnalysisPrunePendingResult, String> {
let mut normalized: Vec<String> = Vec::with_capacity(track_ids.len());
let mut seen = HashSet::new();
@@ -267,8 +397,10 @@ pub fn analysis_prune_pending_to_track_ids(
}
let keep_track_ids: HashSet<&str> = normalized.iter().map(|s| s.as_str()).collect();
let server_id = server_id.trim().to_string();
let server_filter = if server_id.is_empty() { None } else { Some(server_id.as_str()) };
let (http_removed, cpu_removed_jobs, cpu_removed_waiters) =
prune_analysis_queues(&keep_track_ids)?;
prune_analysis_queues(&keep_track_ids, server_filter)?;
if http_removed > 0 || cpu_removed_jobs > 0 {
crate::app_deprintln!(
@@ -297,6 +429,7 @@ mod tests {
fn key(track_id: &str, md5: &str) -> TrackKey {
TrackKey {
server_id: "server-a".to_string(),
track_id: track_id.to_string(),
md5_16kb: md5.to_string(),
}
@@ -342,7 +475,7 @@ mod tests {
#[test]
fn get_waveform_payload_returns_none_for_unknown_key() {
let cache = AnalysisCache::open_in_memory();
let payload = get_waveform_payload(&cache, "missing", "deadbeef").unwrap();
let payload = get_waveform_payload(&cache, "server-a", "missing", "deadbeef").unwrap();
assert!(payload.is_none());
}
@@ -351,7 +484,7 @@ mod tests {
let cache = AnalysisCache::open_in_memory();
let bins: Vec<u8> = (0..8u8).collect();
upsert_waveform(&cache, "abc", "deadbeef", bins.clone());
let payload = get_waveform_payload(&cache, "abc", "deadbeef")
let payload = get_waveform_payload(&cache, "server-a", "abc", "deadbeef")
.unwrap()
.expect("payload exists");
assert_eq!(payload.bins, bins);
@@ -367,8 +500,8 @@ mod tests {
let cache = AnalysisCache::open_in_memory();
upsert_waveform(&cache, "abc", "aaaa", vec![0u8; 8]);
upsert_waveform(&cache, "abc", "bbbb", vec![0xFFu8; 8]);
let p1 = get_waveform_payload(&cache, "abc", "aaaa").unwrap().unwrap();
let p2 = get_waveform_payload(&cache, "abc", "bbbb").unwrap().unwrap();
let p1 = get_waveform_payload(&cache, "server-a", "abc", "aaaa").unwrap().unwrap();
let p2 = get_waveform_payload(&cache, "server-a", "abc", "bbbb").unwrap().unwrap();
assert_ne!(p1.bins, p2.bins);
}
@@ -380,7 +513,7 @@ mod tests {
// matching is the whole point of get_latest_waveform_for_track.
let cache = AnalysisCache::open_in_memory();
upsert_waveform(&cache, "stream:abc", "deadbeef", vec![1u8; 8]);
let payload = get_waveform_payload_for_track(&cache, "abc")
let payload = get_waveform_payload_for_track(&cache, "server-a", "abc")
.unwrap()
.expect("bare-id lookup must hit the stream-prefixed row");
assert_eq!(payload.bin_count, 4);
@@ -389,7 +522,7 @@ mod tests {
#[test]
fn get_waveform_for_track_returns_none_for_unknown_track() {
let cache = AnalysisCache::open_in_memory();
assert!(get_waveform_payload_for_track(&cache, "phantom").unwrap().is_none());
assert!(get_waveform_payload_for_track(&cache, "server-a", "phantom").unwrap().is_none());
}
// ── get_loudness_payload_for_track ────────────────────────────────────────
@@ -400,7 +533,7 @@ mod tests {
upsert_loudness(&cache, "abc", "deadbeef", -14.0);
// Cached row: integrated -14, target -14 → gain 0. Request target -10 →
// recommended gain = -10 - (-14) = +4 dB (capped by true-peak guard).
let payload = get_loudness_payload_for_track(&cache, "abc", Some(-10.0))
let payload = get_loudness_payload_for_track(&cache, "server-a", "abc", Some(-10.0))
.unwrap()
.expect("loudness row exists");
assert_eq!(payload.target_lufs, -10.0);
@@ -415,7 +548,7 @@ mod tests {
fn get_loudness_for_track_uses_cached_target_when_request_is_none() {
let cache = AnalysisCache::open_in_memory();
upsert_loudness(&cache, "abc", "deadbeef", -16.0);
let payload = get_loudness_payload_for_track(&cache, "abc", None)
let payload = get_loudness_payload_for_track(&cache, "server-a", "abc", None)
.unwrap()
.unwrap();
assert_eq!(payload.target_lufs, -16.0);
@@ -426,11 +559,11 @@ mod tests {
let cache = AnalysisCache::open_in_memory();
upsert_loudness(&cache, "abc", "deadbeef", -14.0);
// Out-of-range target gets clamped to [-30, -8].
let too_high = get_loudness_payload_for_track(&cache, "abc", Some(0.0))
let too_high = get_loudness_payload_for_track(&cache, "server-a", "abc", Some(0.0))
.unwrap()
.unwrap();
assert_eq!(too_high.target_lufs, -8.0);
let too_low = get_loudness_payload_for_track(&cache, "abc", Some(-100.0))
let too_low = get_loudness_payload_for_track(&cache, "server-a", "abc", Some(-100.0))
.unwrap()
.unwrap();
assert_eq!(too_low.target_lufs, -30.0);
@@ -439,7 +572,7 @@ mod tests {
#[test]
fn get_loudness_for_track_returns_none_for_unknown_track() {
let cache = AnalysisCache::open_in_memory();
assert!(get_loudness_payload_for_track(&cache, "phantom", None)
assert!(get_loudness_payload_for_track(&cache, "server-a", "phantom", None)
.unwrap()
.is_none());
}
@@ -6,8 +6,12 @@
//! - `analysis_runtime` — backfill queue, CPU-seed queue, queue snapshot loop
pub mod analysis_cache;
pub mod analysis_perf;
pub mod analysis_runtime;
mod codec;
pub mod commands;
pub mod track_analysis_plan;
pub mod track_enrichment;
// Re-export logging facade so submodules can write `crate::app_eprintln!()`
// the same way they did when they lived in the top crate.
@@ -0,0 +1,285 @@
//! Plan what a track still needs: waveform, LUFS, enrichment (BPM/mood), …
//!
//! All byte-backed enqueue paths should call [`crate::analysis_runtime::enqueue_track_analysis`],
//! which uses this module to decide full CPU seed vs enrichment-only vs no-op.
use psysonic_core::track_analysis::TrackAnalysisPlan;
use psysonic_core::track_enrichment::TrackEnrichmentPort;
use tauri::{AppHandle, Manager};
use crate::analysis_cache::{AnalysisCache, TrackKey};
pub fn plan_track_analysis(
app: &AppHandle,
server_id: &str,
track_id: &str,
content_hash: &str,
) -> TrackAnalysisPlan {
plan_track_analysis_offline_library(app, &[server_id], server_id, track_id, content_hash)
}
/// Offline/library download: waveform cache and enrichment facts may live under the
/// playback index key while library rows use the UUID — try every scope before seeding.
pub fn plan_track_analysis_offline_library(
app: &AppHandle,
cache_server_ids: &[&str],
_enrichment_server_id: &str,
track_id: &str,
content_hash: &str,
) -> TrackAnalysisPlan {
let (need_waveform, need_loudness) =
cache_gaps_multi(app, cache_server_ids, track_id, content_hash);
let enrichment = enrichment_plan_multi(app, cache_server_ids, track_id, content_hash);
TrackAnalysisPlan {
need_waveform,
need_loudness,
enrichment,
}
}
/// Plan from the latest cached fingerprint when bytes are not available yet (HTTP backfill gate).
pub fn plan_track_analysis_from_cache(
app: &AppHandle,
server_id: &str,
track_id: &str,
) -> Result<TrackAnalysisPlan, String> {
let Some(cache) = app.try_state::<AnalysisCache>() else {
return Ok(TrackAnalysisPlan {
need_waveform: true,
need_loudness: true,
enrichment: Default::default(),
});
};
let Some(md5) = cache.get_latest_md5_16kb_for_track(server_id, track_id)? else {
return Ok(TrackAnalysisPlan {
need_waveform: true,
need_loudness: true,
enrichment: Default::default(),
});
};
Ok(plan_track_analysis(app, server_id, track_id, &md5))
}
pub fn track_analysis_needs_work(
app: &AppHandle,
server_id: &str,
track_id: &str,
) -> Result<bool, String> {
if let Some(cache) = app.try_state::<AnalysisCache>() {
let latest_status = cache.get_latest_status_for_track(server_id, track_id)?;
if latest_status
.as_ref()
.is_some_and(|(status, _)| status == "failed")
{
return Ok(false);
}
let plan = plan_track_analysis_from_cache(app, server_id, track_id)?;
if !plan.any() {
return Ok(false);
}
// Legacy reconciliation: some old rows are persisted as `ready` with
// waveform present but no loudness (typically unsupported decode path).
// Those tracks spin forever in pending without converging. Promote to
// terminal `failed` so scheduler/progress can converge.
if latest_status
.as_ref()
.is_some_and(|(status, _)| status == "ready")
&& plan.need_loudness
&& !plan.need_waveform
{
if let Some(md5) = cache.get_latest_md5_16kb_for_track(server_id, track_id)? {
let key = TrackKey {
server_id: server_id.to_string(),
track_id: track_id.to_string(),
md5_16kb: md5,
};
let _ = cache.touch_track_status(&key, "failed");
}
return Ok(false);
}
return Ok(plan.any());
}
Ok(plan_track_analysis_from_cache(app, server_id, track_id)?.any())
}
fn cache_gaps(
app: &AppHandle,
server_id: &str,
track_id: &str,
content_hash: &str,
) -> (bool, bool) {
cache_gaps_for_content(
app.try_state::<AnalysisCache>().as_deref(),
server_id,
track_id,
content_hash,
)
}
fn cache_gaps_multi(
app: &AppHandle,
server_ids: &[&str],
track_id: &str,
content_hash: &str,
) -> (bool, bool) {
let mut need_waveform = true;
let mut need_loudness = true;
for &server_id in server_ids {
if server_id.is_empty() {
continue;
}
let (nw, nl) = cache_gaps(app, server_id, track_id, content_hash);
if !nw {
need_waveform = false;
}
if !nl {
need_loudness = false;
}
if !need_waveform && !need_loudness {
break;
}
}
(need_waveform, need_loudness)
}
fn enrichment_plan(
app: &AppHandle,
server_id: &str,
track_id: &str,
content_hash: &str,
) -> psysonic_core::track_enrichment::TrackEnrichmentPlan {
if server_id.is_empty() {
return Default::default();
}
app.try_state::<TrackEnrichmentPort>()
.map(|port| port.plan(server_id, track_id, content_hash))
.unwrap_or_default()
}
fn enrichment_plan_multi(
app: &AppHandle,
server_ids: &[&str],
track_id: &str,
content_hash: &str,
) -> psysonic_core::track_enrichment::TrackEnrichmentPlan {
let mut need_bpm = true;
let mut need_valence = true;
let mut need_arousal = true;
let mut need_moods = true;
for &server_id in server_ids {
if server_id.is_empty() {
continue;
}
let plan = enrichment_plan(app, server_id, track_id, content_hash);
if !plan.need_bpm {
need_bpm = false;
}
if !plan.need_valence {
need_valence = false;
}
if !plan.need_arousal {
need_arousal = false;
}
if !plan.need_moods {
need_moods = false;
}
if !need_bpm && !need_valence && !need_arousal && !need_moods {
break;
}
}
psysonic_core::track_enrichment::TrackEnrichmentPlan {
need_bpm,
need_valence,
need_arousal,
need_moods,
}
}
fn cache_gaps_for_content(
cache: Option<&AnalysisCache>,
server_id: &str,
track_id: &str,
content_hash: &str,
) -> (bool, bool) {
let Some(cache) = cache else {
return (true, true);
};
match cache.content_cache_coverage(server_id, track_id, content_hash) {
Ok(coverage) => (!coverage.has_waveform, !coverage.has_loudness),
Err(_) => (true, true),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::analysis_cache::{LoudnessEntry, TrackKey, WaveformEntry};
fn seed_waveform_loudness(cache: &AnalysisCache, server_id: &str, track_id: &str, md5: &str) {
let key = TrackKey {
server_id: server_id.to_string(),
track_id: track_id.to_string(),
md5_16kb: md5.to_string(),
};
cache.touch_track_status(&key, "ready").unwrap();
cache
.upsert_waveform(
&key,
&WaveformEntry {
bins: vec![0u8; 1000],
bin_count: 500,
is_partial: false,
known_until_sec: 0.0,
duration_sec: 0.0,
updated_at: 1,
},
)
.unwrap();
cache
.upsert_loudness(
&key,
&LoudnessEntry {
integrated_lufs: -14.0,
true_peak: 1.0,
recommended_gain_db: 0.0,
target_lufs: -14.0,
updated_at: 1,
},
)
.unwrap();
}
#[test]
fn cache_gaps_true_when_empty() {
let cache = AnalysisCache::open_in_memory();
let (wf, ld) = cache_gaps_for_content(Some(&cache), "s1", "t1", "abc");
assert!(wf && ld);
}
#[test]
fn cache_gaps_false_when_fingerprint_present() {
let cache = AnalysisCache::open_in_memory();
seed_waveform_loudness(&cache, "s1", "t1", "abc");
let (wf, ld) = cache_gaps_for_content(Some(&cache), "s1", "t1", "abc");
assert!(!wf && !ld);
}
#[test]
fn cache_gaps_finds_stream_prefix_row_for_bare_track_id() {
let cache = AnalysisCache::open_in_memory();
seed_waveform_loudness(&cache, "s1", "stream:t1", "abc");
let (wf, ld) = cache_gaps_for_content(Some(&cache), "s1", "t1", "abc");
assert!(!wf && !ld, "bare id should resolve stream: cached fingerprint");
}
#[test]
fn playback_index_cache_row_not_visible_under_library_uuid_only() {
let cache = AnalysisCache::open_in_memory();
seed_waveform_loudness(&cache, "navidrome.test:4533", "t1", "abc");
let (wf, ld) = cache_gaps_for_content(Some(&cache), "library-uuid", "t1", "abc");
assert!(wf && ld, "library uuid alone should miss playback-scoped cache");
let (wf2, ld2) = cache_gaps_for_content(Some(&cache), "navidrome.test:4533", "t1", "abc");
assert!(!wf2 && !ld2, "playback index key should hit the cached row");
}
}
@@ -0,0 +1,130 @@
//! Client-side track enrichment — oximedia BPM + mood into library facts.
use oximedia_mir::{mood, tempo, MirConfig};
use psysonic_core::track_enrichment::{
TrackEnrichmentFacts, TrackEnrichmentIntFact, TrackEnrichmentOutcome, TrackEnrichmentPort,
TrackEnrichmentPlan, TrackEnrichmentRealFact,
};
use tauri::{AppHandle, Emitter, Manager, Runtime};
use crate::analysis_cache::{
analysis_pcm_window, audio_duration_from_bytes, decode_mono_pcm_window, md5_first_16kb,
};
pub const ENRICHMENT_WINDOW_SEC: f64 = 60.0;
#[derive(Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EnrichmentUpdatedPayload {
pub track_id: String,
pub server_id: String,
}
fn emit_enrichment_updated<R: Runtime>(app: &AppHandle<R>, server_id: &str, track_id: &str) {
let _ = app.emit(
"analysis:enrichment-updated",
EnrichmentUpdatedPayload {
track_id: track_id.to_string(),
server_id: server_id.to_string(),
},
);
}
pub fn run_track_enrichment_if_needed<R: Runtime>(
app: &AppHandle<R>,
server_id: &str,
track_id: &str,
bytes: &[u8],
notify_ui: bool,
) -> TrackEnrichmentOutcome {
if server_id.is_empty() {
return TrackEnrichmentOutcome::SkippedNoServer;
}
let Some(port) = app.try_state::<TrackEnrichmentPort>() else {
return TrackEnrichmentOutcome::SkippedNoPort;
};
let content_hash = md5_first_16kb(bytes);
let plan = port.plan(server_id, track_id, &content_hash);
if !plan.any() {
return TrackEnrichmentOutcome::SkippedComplete;
}
match analyze_and_store(&port, server_id, track_id, &content_hash, bytes, plan) {
Ok(()) => {
crate::app_deprintln!(
"[analysis][enrichment] applied track_id={} server_id={} hash={}",
track_id,
server_id,
content_hash
);
if notify_ui {
emit_enrichment_updated(app, server_id, track_id);
}
TrackEnrichmentOutcome::Applied
}
Err(e) => {
crate::app_eprintln!(
"[analysis][enrichment] failed track_id={} server_id={}: {}",
track_id,
server_id,
e
);
TrackEnrichmentOutcome::Failed
}
}
}
fn analyze_and_store(
port: &TrackEnrichmentPort,
server_id: &str,
track_id: &str,
content_hash: &str,
bytes: &[u8],
plan: TrackEnrichmentPlan,
) -> Result<(), String> {
let total_duration = audio_duration_from_bytes(bytes).unwrap_or(0.0);
let window = analysis_pcm_window(total_duration, ENRICHMENT_WINDOW_SEC);
let (mono, sample_rate) =
decode_mono_pcm_window(bytes, window.start_sec, window.duration_sec)?;
if mono.is_empty() || sample_rate <= 0.0 {
return Err("empty PCM window".to_string());
}
let config = MirConfig::default();
let mut facts = TrackEnrichmentFacts::default();
if plan.need_bpm {
let detector = tempo::TempoDetector::new(sample_rate, config.min_tempo, config.max_tempo);
let tempo = detector.detect(&mono).map_err(|e| format!("tempo: {e}"))?;
let bpm = tempo.bpm.round().clamp(20.0, 999.0) as i64;
facts.bpm = Some(TrackEnrichmentIntFact {
value: bpm,
confidence: tempo.confidence,
});
}
if plan.need_valence || plan.need_arousal || plan.need_moods {
let detector = mood::MoodDetector::new(sample_rate);
let mood = detector.detect(&mono).map_err(|e| format!("mood: {e}"))?;
let confidence = mood.intensity.clamp(0.0, 1.0);
if plan.need_valence {
facts.valence = Some(TrackEnrichmentRealFact {
value: mood.valence as f64,
confidence,
});
}
if plan.need_arousal {
facts.arousal = Some(TrackEnrichmentRealFact {
value: mood.arousal as f64,
confidence,
});
}
if plan.need_moods && !mood.moods.is_empty() {
facts.moods = Some(
serde_json::to_string(&mood.moods).map_err(|e| format!("moods json: {e}"))?,
);
}
}
port.store(server_id, track_id, content_hash, &facts)
}
+7 -5
View File
@@ -3,6 +3,7 @@ name = "psysonic-audio"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -15,9 +16,9 @@ serde_json = "1"
tokio = { version = "1", features = ["rt", "time", "sync"] }
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "rustls", "blocking", "gzip", "brotli"] }
futures-util = "0.3"
rodio = { version = "0.22", default-features = false, features = ["playback", "symphonia-all"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
symphonia-adapter-libopus = "0.2.9"
rodio = { version = "0.22", default-features = false, features = ["playback"] }
symphonia = { version = "0.6", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm", "all-meta"] }
symphonia-adapter-libopus = "0.3"
ringbuf = "0.5"
biquad = "0.6"
dasp_sample = "0.11.0"
@@ -25,13 +26,14 @@ md5 = "0.8"
url = "2"
thread-priority = "3"
lofty = "0.24"
id3 = "1.16.4"
id3 = "1.17"
pitch_shift = "2.1"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
zbus = { version = "5.15", default-features = false, features = ["blocking-api", "async-io"] }
zbus = { version = "5.16", default-features = false, features = ["blocking-api", "async-io"] }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = [
@@ -0,0 +1,288 @@
//! Unified playback → track analysis dispatch.
//!
//! Stream completion, hot/offline files, gapless chain, preload, and in-memory
//! replay all funnel through here before [`psysonic_analysis::analysis_runtime::enqueue_track_analysis`].
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tauri::{AppHandle, Manager};
use psysonic_analysis::analysis_runtime::AnalysisBackfillPriority;
use crate::engine::{analysis_track_id_is_current_playback, AudioEngine};
use crate::helpers::{analysis_cache_track_id, current_playback_server_id_str};
use url::Url;
use crate::state::ChainedInfo;
use crate::stream::{LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES, TRACK_STREAM_PROMOTE_MAX_BYTES};
/// Where playback obtained the bytes — used for logging and size caps only.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TrackAnalysisOrigin {
InMemoryReplay,
StreamDownloadComplete,
LocalFilePlayback,
StreamSpillFile,
PrefetchOrCacheFile,
GaplessChainReady,
GaplessTransition,
}
fn max_bytes_for_origin(origin: TrackAnalysisOrigin) -> usize {
match origin {
TrackAnalysisOrigin::LocalFilePlayback => LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES,
_ => TRACK_STREAM_PROMOTE_MAX_BYTES,
}
}
/// Playback server scope: explicit IPC value, else pinned engine scope.
pub(crate) fn resolve_analysis_server_id(
explicit: Option<&str>,
engine: Option<&AudioEngine>,
) -> String {
if let Some(engine) = engine {
if let Some(url) = engine
.current_playback_url
.lock()
.ok()
.and_then(|g| (*g).clone())
{
if let Some(derived) = server_id_from_playback_url(&url) {
return derived;
}
}
}
explicit
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.unwrap_or_else(|| engine.map(current_playback_server_id_str).unwrap_or_default())
}
fn server_id_from_playback_url(url_raw: &str) -> Option<String> {
if url_raw.starts_with("psysonic-local://") {
return None;
}
let parsed = Url::parse(url_raw).ok()?;
let host = parsed.host_str()?;
let mut base_path = parsed.path().to_string();
if let Some(idx) = base_path.find("/rest") {
base_path.truncate(idx);
}
while base_path.ends_with('/') {
base_path.pop();
}
let mut base = host.to_string();
if let Some(port) = parsed.port() {
base.push_str(&format!(":{port}"));
}
if !base_path.is_empty() {
base.push_str(&base_path);
}
Some(base)
}
fn resolve_analysis_priority(
app: &AppHandle,
engine: Option<&AudioEngine>,
server_id: &str,
track_id: &str,
explicit: Option<AnalysisBackfillPriority>,
) -> AnalysisBackfillPriority {
if let Some(priority) = explicit {
return priority;
}
if psysonic_analysis::analysis_runtime::analysis_backfill_is_current_track(app, track_id)
|| engine.is_some_and(|e| analysis_track_id_is_current_playback(e, track_id))
{
return AnalysisBackfillPriority::High;
}
psysonic_analysis::analysis_runtime::analysis_backfill_resolve_priority(
app,
server_id,
track_id,
None,
)
}
/// Resolve `(server_id, priority)` when the caller has live engine state.
pub(crate) fn prepare_playback_analysis(
app: &AppHandle,
engine: &AudioEngine,
explicit_server_id: Option<&str>,
track_id: &str,
priority: Option<AnalysisBackfillPriority>,
) -> (String, AnalysisBackfillPriority) {
let sid = resolve_analysis_server_id(explicit_server_id, Some(engine));
let resolved = resolve_analysis_priority(app, Some(engine), &sid, track_id, priority);
(sid, resolved)
}
pub(crate) fn resolve_server_id_for_app(
app: &AppHandle,
explicit: Option<&str>,
) -> String {
let engine = app.try_state::<AudioEngine>();
resolve_analysis_server_id(explicit, engine.as_deref())
}
pub(crate) fn analysis_priority_for_app(
app: &AppHandle,
server_id: &str,
track_id: &str,
explicit: Option<AnalysisBackfillPriority>,
) -> AnalysisBackfillPriority {
let engine = app.try_state::<AudioEngine>();
resolve_analysis_priority(app, engine.as_deref(), server_id, track_id, explicit)
}
/// Gapless boundary: chained track became audible — run unified analysis if needed.
pub(crate) fn spawn_gapless_transition_analysis(app: &AppHandle, info: &ChainedInfo) {
let track_id = analysis_cache_track_id(
info.analysis_track_id.as_deref(),
&info.url,
);
let Some(track_id) = track_id else {
return;
};
let engine = app.state::<AudioEngine>();
let (sid, priority) = prepare_playback_analysis(
app,
&engine,
info.server_id.as_deref(),
&track_id,
Some(AnalysisBackfillPriority::High),
);
let bytes = (*info.raw_bytes).clone();
spawn_track_analysis_bytes(
app.clone(),
TrackAnalysisOrigin::GaplessTransition,
sid,
track_id,
bytes,
priority,
None,
);
}
/// Byte-backed analysis — the single audio-side entry before the analysis crate planner.
pub(crate) async fn dispatch_track_analysis_bytes(
app: &AppHandle,
origin: TrackAnalysisOrigin,
server_id: &str,
track_id: &str,
bytes: Vec<u8>,
priority: AnalysisBackfillPriority,
) -> Result<(), String> {
let track_id = track_id.trim();
if track_id.is_empty() {
return Ok(());
}
if bytes.is_empty() {
return Ok(());
}
let max = max_bytes_for_origin(origin);
if bytes.len() > max {
crate::app_deprintln!(
"[analysis][dispatch] skip origin={origin:?} track_id={track_id} bytes={} max={max}",
bytes.len(),
);
return Ok(());
}
crate::app_deprintln!(
"[analysis][dispatch] origin={origin:?} track_id={track_id} server_id={} size_mib={:.2} priority={priority:?}",
if server_id.is_empty() { "''" } else { server_id },
bytes.len() as f64 / (1024.0 * 1024.0),
);
psysonic_analysis::analysis_runtime::enqueue_track_analysis(
app,
server_id,
track_id,
&bytes,
None,
priority,
)
.await
.map(|_| ())
}
/// Non-blocking wrapper with optional play-generation supersede guard.
pub(crate) fn spawn_track_analysis_bytes(
app: AppHandle,
origin: TrackAnalysisOrigin,
server_id: String,
track_id: String,
bytes: Vec<u8>,
priority: AnalysisBackfillPriority,
generation_guard: Option<(u64, Arc<AtomicU64>)>,
) {
if track_id.trim().is_empty() || bytes.is_empty() {
return;
}
tokio::spawn(async move {
if let Some((gen, gen_arc)) = generation_guard {
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
}
if let Err(e) = dispatch_track_analysis_bytes(
&app,
origin,
&server_id,
&track_id,
bytes,
priority,
)
.await
{
crate::app_eprintln!(
"[analysis][dispatch] failed origin={origin:?} track_id={track_id}: {e}"
);
}
});
}
pub(crate) fn spawn_track_analysis_file(
app: AppHandle,
origin: TrackAnalysisOrigin,
server_id: String,
track_id: String,
file_path: PathBuf,
priority: AnalysisBackfillPriority,
generation_guard: Option<(u64, Arc<AtomicU64>)>,
) {
if track_id.trim().is_empty() {
return;
}
tokio::spawn(async move {
if let Some((gen, gen_arc)) = &generation_guard {
if gen_arc.load(Ordering::SeqCst) != *gen {
return;
}
}
let bytes = match tokio::fs::read(&file_path).await {
Ok(b) if !b.is_empty() => b,
_ => return,
};
if let Some((gen, gen_arc)) = generation_guard {
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
}
if let Err(e) = dispatch_track_analysis_bytes(
&app,
origin,
&server_id,
&track_id,
bytes,
priority,
)
.await
{
crate::app_eprintln!(
"[analysis][dispatch] file failed origin={origin:?} track_id={track_id}: {e}"
);
}
});
}
+7 -6
View File
@@ -1,21 +1,22 @@
//! Symphonia codec registry (incl. Opus) and radio decoder factory.
use std::sync::OnceLock;
use symphonia::core::codecs::{CodecRegistry, DecoderOptions};
use symphonia::core::codecs::audio::{AudioCodecParameters, AudioDecoder, AudioDecoderOptions};
use symphonia::core::codecs::registry::CodecRegistry;
pub(crate) fn psysonic_codec_registry() -> &'static CodecRegistry {
static REGISTRY: OnceLock<CodecRegistry> = OnceLock::new();
REGISTRY.get_or_init(|| {
let mut registry = CodecRegistry::new();
symphonia::default::register_enabled_codecs(&mut registry);
registry.register_all::<symphonia_adapter_libopus::OpusDecoder>();
registry.register_audio_decoder::<symphonia_adapter_libopus::OpusDecoder>();
registry
})
}
pub(crate) fn try_make_radio_decoder(
params: &symphonia::core::codecs::CodecParameters,
opts: &DecoderOptions,
) -> Result<Box<dyn symphonia::core::codecs::Decoder>, symphonia::core::errors::Error> {
psysonic_codec_registry().make(params, opts)
params: &AudioCodecParameters,
opts: &AudioDecoderOptions,
) -> Result<Box<dyn AudioDecoder>, symphonia::core::errors::Error> {
psysonic_codec_registry().make_audio_decoder(params, opts)
}
@@ -19,6 +19,7 @@ use super::play_input::{
spawn_legacy_stream_start_when_armed, swap_in_new_sink, url_format_hint, BuildSourceArgs,
PlayInputContext, SinkSwapInputs,
};
use super::playback_rate::preserve_pitch_will_run;
use super::preview::preview_clear_for_new_main_playback;
use super::progress_task::spawn_progress_task;
use super::state::{ChainedInfo, PreloadedTrack};
@@ -29,6 +30,11 @@ use super::state::{ChainedInfo, PreloadedTrack};
/// cache to the track when playing `psysonic-local://` (hot/offline). Optional
/// for HTTP streams (`playback_identity` is used as fallback).
///
/// `server_id`: app id of the server that owns this track (`playbackServerId ??
/// activeServerId` on the frontend). Scopes the analysis-cache write key so a
/// later server switch can't surface another server's waveform for the same bare
/// `track_id`. Empty/absent falls back to the legacy `''` scope.
///
/// `stream_format_suffix`: Subsonic `song.suffix` (e.g. m4a); `stream.view` URLs have no
/// file extension, so this helps pick a Symphonia `format_hint` for ranged HTTP.
#[tauri::command]
@@ -45,6 +51,7 @@ pub async fn audio_play(
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
hi_res_enabled: bool, // false = safe 44.1 kHz mode; true = native rate (alpha)
analysis_track_id: Option<String>,
server_id: Option<String>,
stream_format_suffix: Option<String>,
app: AppHandle,
state: State<'_, AudioEngine>,
@@ -134,6 +141,11 @@ pub async fn audio_play(
.filter(|s| !s.is_empty());
*state.current_analysis_track_id.lock().unwrap() = logical_trim.clone();
let cache_id_for_tasks = analysis_cache_track_id(logical_trim.as_deref(), &url);
// Playback server scope for the analysis-cache write key (empty → legacy '').
let analysis_server_id = server_id.as_deref().map(str::trim).filter(|s| !s.is_empty());
// Pin it so the gain-resolution + replay-gain-update + device-resume reads
// scope to this server too (mirrors `current_analysis_track_id`).
*state.current_playback_server_id.lock().unwrap() = analysis_server_id.map(str::to_string);
let format_hint = url_format_hint(&url);
@@ -145,6 +157,7 @@ pub async fn audio_play(
stream_format_suffix: stream_format_suffix.as_deref(),
format_hint: format_hint.as_deref(),
cache_id_for_tasks: cache_id_for_tasks.as_deref(),
server_id: analysis_server_id,
reuse_chained_bytes,
},
&state,
@@ -239,6 +252,7 @@ pub async fn audio_play(
url: &url,
gen,
cache_id_for_tasks: cache_id_for_tasks.as_deref(),
server_id: analysis_server_id,
url_format_hint: format_hint.as_deref(),
stream_format_suffix: stream_format_suffix.as_deref(),
done_flag: done_flag.clone(),
@@ -334,7 +348,11 @@ pub async fn audio_play(
// we resume — the buffer is already full and the hardware gets its frames
// without an underrun on the very first period.
// Standard mode: no pre-fill needed — default 44.1/48 kHz quantum is small.
let needs_prefill = hi_res_enabled && output_rate > 48_000;
// Preserve-pitch phase vocoder runs on a worker thread; pre-fill gives the
// 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;
let defer_playback_start = !state.stream_playback_armed.load(Ordering::Relaxed);
if needs_prefill || defer_playback_start {
sink.pause();
@@ -374,12 +392,12 @@ pub async fn audio_play(
sink.append(source);
if needs_prefill {
// 500 ms lets rodio decode several seconds of hi-res audio into its
// internal buffer while the sink is paused. The hardware sees no gap
// because the output is held — it only starts draining after sink.play().
// 500 ms gives ~5 quanta of headroom at 8192-frame/88200 Hz quantum size,
// absorbing scheduler jitter and PipeWire graph wake-up latency.
tokio::time::sleep(Duration::from_millis(500)).await;
let prefill_ms = if needs_preserve_prefill {
800
} else {
500
};
tokio::time::sleep(Duration::from_millis(prefill_ms)).await;
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(()); // skipped during pre-fill — abort silently
}
@@ -419,6 +437,7 @@ pub async fn audio_play(
}
// ── Progress + ended detection ────────────────────────────────────────────
let analysis_app = app.clone();
spawn_progress_task(
gen,
state.generation.clone(),
@@ -428,12 +447,14 @@ pub async fn audio_play(
state.crossfade_secs.clone(),
done_flag,
app,
Some(analysis_app),
state.samples_played.clone(),
state.current_sample_rate.clone(),
state.current_channels.clone(),
state.gapless_switch_at.clone(),
state.current_playback_url.clone(),
state.stream_playback_armed.clone(),
state.playback_rate.clone(),
);
Ok(())
@@ -461,6 +482,7 @@ pub async fn audio_chain_preload(
fallback_db: f32,
hi_res_enabled: bool,
analysis_track_id: Option<String>,
server_id: Option<String>,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
@@ -523,6 +545,27 @@ pub async fn audio_chain_preload(
.as_ref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let analysis_server_id = server_id.as_deref().map(str::trim).filter(|s| !s.is_empty());
if let Some(track_id) = analysis_cache_track_id(logical_trim.as_deref(), &url) {
let (sid, priority) = crate::analysis_dispatch::prepare_playback_analysis(
&app,
&state,
analysis_server_id,
&track_id,
Some(psysonic_analysis::analysis_runtime::AnalysisBackfillPriority::Middle),
);
let bytes = (*raw_bytes).clone();
crate::analysis_dispatch::spawn_track_analysis_bytes(
app.clone(),
crate::analysis_dispatch::TrackAnalysisOrigin::GaplessChainReady,
sid,
track_id,
bytes,
priority,
None,
);
}
// Only `gain_linear` is needed — `effective_volume` is intentionally NOT
// applied to the Sink here. `audio_chain_preload` runs ~30 s before the
@@ -555,6 +598,7 @@ pub async fn audio_chain_preload(
state.eq_gains.clone(),
state.eq_enabled.clone(),
state.eq_pre_gain.clone(),
state.playback_rate.clone(),
done_next.clone(),
Duration::ZERO, // gapless: no fade-in — sample-accurate boundary, no click
chain_counter.clone(),
@@ -603,6 +647,8 @@ pub async fn audio_chain_preload(
*state.chained_info.lock().unwrap() = Some(ChainedInfo {
url,
analysis_track_id: logical_trim,
server_id: analysis_server_id.map(str::to_string),
raw_bytes,
duration_secs,
replay_gain_linear: gain_linear,
+346 -112
View File
@@ -1,22 +1,24 @@
//! Symphonia `SizedDecoder`, gapless trim, and `build_source` / `build_streaming_source`.
use std::io::{Cursor, Read, Seek};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use rodio::source::UniformSourceIterator;
use rodio::Source;
use symphonia::core::{
audio::{AudioBufferRef, SampleBuffer, SignalSpec},
codecs::{DecoderOptions, CODEC_TYPE_NULL},
audio::{AudioSpec, GenericAudioBufferRef},
codecs::audio::{AudioCodecParameters, AudioDecoder, AudioDecoderOptions},
formats::probe::Hint,
formats::{FormatOptions, FormatReader, SeekMode, SeekTo},
common::Limit,
io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions},
meta::MetadataOptions,
probe::Hint,
units::{self, Time},
units::{Time, Timestamp},
};
use super::codec::{psysonic_codec_registry, try_make_radio_decoder};
use super::playback_rate::{PlaybackRateAtomics, PlaybackRateSource};
use super::sources::*;
// ─── SizedCursorSource — correct byte_len for seekable in-memory sources ──────
@@ -51,6 +53,46 @@ impl MediaSource for SizedCursorSource {
fn byte_len(&self) -> Option<u64> { Some(self.len) }
}
// ─── ProbeSeekGate — temporarily hide seekability during probing ──────────────
//
// Symphonia 0.6's `Probe::probe` scans for *trailing* metadata (ID3v1/APEv2/…)
// whenever the source reports `is_seekable() == true` and a known `byte_len()`.
// That scan seeks to the end of the stream. For a progressive ranged-HTTP source
// this forces a download all the way to EOF before the first sample can play
// (FLAC/MP3/OGG regressed to "won't start until fully downloaded").
//
// These formats are demuxed sequentially from the start, and their seek paths
// re-check `is_seekable()` dynamically, so we can advertise the source as
// non-seekable for the duration of the probe (skipping the trailing scan) and
// flip it back to seekable afterwards to preserve scrubbing. MP4/ISO-BMFF is
// excluded because its demuxer captures seekability at construction and relies
// on seeking to locate `moov` (its tail is prefetched separately instead).
struct ProbeSeekGate {
inner: Box<dyn MediaSource>,
seekable: Arc<AtomicBool>,
}
impl Read for ProbeSeekGate {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.inner.read(buf)
}
}
impl Seek for ProbeSeekGate {
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
self.inner.seek(pos)
}
}
impl MediaSource for ProbeSeekGate {
fn is_seekable(&self) -> bool {
self.seekable.load(Ordering::Relaxed) && self.inner.is_seekable()
}
fn byte_len(&self) -> Option<u64> {
self.inner.byte_len()
}
}
// ─── SizedDecoder — symphonia decoder with correct byte_len ───────────────────
//
// Replaces rodio::Decoder::new() which wraps the source in ReadSeekSource
@@ -64,19 +106,19 @@ impl MediaSource for SizedCursorSource {
/// playback is genuinely lossless.
pub(crate) fn log_codec_resolution(
tag: &str,
params: &symphonia::core::codecs::CodecParameters,
params: &AudioCodecParameters,
container_hint: Option<&str>,
) {
let codec_name = symphonia::default::get_codecs()
.get_codec(params.codec)
.map(|d| d.short_name)
.get_audio_decoder(params.codec)
.map(|d| d.codec.info.short_name)
.unwrap_or("?");
let rate = params.sample_rate.map(|r| format!("{} Hz", r)).unwrap_or_else(|| "? Hz".into());
let bits = params.bits_per_sample
.or(params.bits_per_coded_sample)
.map(|b| format!("{}-bit", b))
.unwrap_or_else(|| "?-bit".into());
let ch = params.channels
let ch = params.channels.as_ref()
.map(|c| format!("{}ch", c.count()))
.unwrap_or_else(|| "?ch".into());
let lossless = codec_name.starts_with("pcm")
@@ -98,14 +140,20 @@ const DECODE_MAX_RETRIES: usize = 3;
/// this limit so a handful of corrupt MP3 frames never aborts an otherwise
/// playable track (VLC-style frame dropping).
const MAX_CONSECUTIVE_DECODE_ERRORS: usize = 100;
/// Wall-clock cap for the streaming `probe()` call. A ranged-HTTP source whose
/// download stalls (e.g. right after a server switch) can otherwise block the
/// probe — and therefore playback start — indefinitely. On timeout we abort with
/// an error so the player can recover/retry instead of hanging until a restart.
const STREAM_PROBE_TIMEOUT: Duration = Duration::from_secs(20);
pub(crate) struct SizedDecoder {
decoder: Box<dyn symphonia::core::codecs::Decoder>,
decoder: Box<dyn AudioDecoder>,
current_frame_offset: usize,
format: Box<dyn FormatReader>,
total_duration: Option<Time>,
buffer: SampleBuffer<f32>,
spec: SignalSpec,
/// Interleaved f32 samples of the currently decoded packet.
buffer: Vec<f32>,
spec: AudioSpec,
/// Counts consecutive DecodeErrors in the hot-path. Reset to 0 on every
/// successfully decoded frame. Used to detect fully undecodable streams.
consecutive_decode_errors: usize,
@@ -118,36 +166,38 @@ impl SizedDecoder {
inner: Cursor::new(data),
len: data_len,
};
// Symphonia 0.6 scans trailing metadata on seekable sources — hide
// seekability during probe (same as `new_streaming`) so preview does not
// read the entire in-memory file before the first sample.
let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint))
.then(|| Arc::new(AtomicBool::new(false)));
let media: Box<dyn MediaSource> = match &probe_seek_gate {
Some(gate) => Box::new(ProbeSeekGate {
inner: Box::new(source),
seekable: gate.clone(),
}),
None => Box::new(source),
};
// Hi-Res: 4 MB read-ahead so Symphonia demuxes fewer Read calls for
// high-bitrate files (88.2 kHz/24-bit FLAC ≈ 1800 kbps).
// Standard: 512 KB is plenty for MP3/AAC — larger buffers waste allocation
// and compete with the playback thread at track start.
let buf_len = if hi_res { 4 * 1024 * 1024 } else { 512 * 1024 };
let mss = MediaSourceStream::new(
Box::new(source) as Box<dyn MediaSource>,
MediaSourceStreamOptions { buffer_len: buf_len },
);
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: buf_len });
let mut hint = Hint::new();
if let Some(ext) = format_hint {
hint.with_extension(ext);
}
let format_opts = FormatOptions {
// Disable gapless parsing — Symphonia 0.5.5 crashes on `edts` atoms
// present in older iTunes-purchased M4A files.
enable_gapless: false,
..Default::default()
};
let format_opts = FormatOptions::default();
let meta_opts = symphonia::core::meta::MetadataOptions {
// Cap embedded cover art at 8 MiB so oversized MJPEG images in
// iTunes M4A files don't choke the parser.
limit_visual_bytes: symphonia::core::meta::Limit::Maximum(8 * 1024 * 1024),
..Default::default()
};
// Cap embedded cover art at 8 MiB so oversized MJPEG images in
// iTunes M4A files don't choke the parser.
let meta_opts =
MetadataOptions::default().limit_visual_bytes(Limit::Maximum(8 * 1024 * 1024));
let probed = symphonia::default::get_probe()
.format(&hint, mss, &format_opts, &meta_opts)
let mut format = symphonia::default::get_probe()
.probe(&hint, mss, format_opts, meta_opts)
.map_err(|e| {
let hint_str = format_hint.unwrap_or("unknown");
// Always print the raw Symphonia error to the terminal for diagnosis.
@@ -159,30 +209,49 @@ impl SizedDecoder {
}
})?;
let track = probed.format
if let Some(gate) = &probe_seek_gate {
gate.store(true, Ordering::Relaxed);
}
let track = format
.tracks()
.iter()
// Explicitly select only audio tracks: must have a valid codec and a
// Explicitly select only audio tracks: must have an audio codec and a
// sample_rate. This skips MJPEG cover-art streams that iTunes M4A
// files embed as a secondary video track.
.find(|t| {
t.codec_params.codec != CODEC_TYPE_NULL
&& t.codec_params.sample_rate.is_some()
t.codec_params
.as_ref()
.and_then(|c| c.audio())
.is_some_and(|a| a.sample_rate.is_some())
})
.ok_or_else(|| {
crate::app_eprintln!("[psysonic] no audio track found among {} tracks", probed.format.tracks().len());
crate::app_eprintln!("[psysonic] no audio track found among {} tracks", format.tracks().len());
"no playable audio track found in file".to_string()
})?;
let track_id = track.id;
let total_duration = track.codec_params.time_base
.zip(track.codec_params.n_frames)
.map(|(base, frames)| base.calc_time(frames));
// Encoder-delay-aware total duration (timebase units → Time).
let total_duration = track
.time_base
.zip(track.num_frames)
.and_then(|(base, frames)| {
Timestamp::try_from(frames).ok().and_then(|ts| base.calc_time(ts))
});
log_codec_resolution("bytes", &track.codec_params, format_hint);
let audio_params = track
.codec_params
.as_ref()
.and_then(|c| c.audio())
.ok_or_else(|| "selected track has no audio codec parameters".to_string())?
.clone();
log_codec_resolution("bytes", &audio_params, format_hint);
// Gapless trimming is performed by `build_source` (iTunSMPB), so disable
// the decoder's built-in trimming to avoid double-trimming.
let mut decoder = psysonic_codec_registry()
.make(&track.codec_params, &DecoderOptions::default())
.make_audio_decoder(&audio_params, &AudioDecoderOptions::default().gapless(false))
.map_err(|e| {
crate::app_eprintln!("[psysonic] codec init failed: {e}");
if e.to_string().to_lowercase().contains("unsupported") {
@@ -192,15 +261,15 @@ impl SizedDecoder {
}
})?;
let mut format = probed.format;
// Decode the first packet to initialise spec + buffer.
// DecodeErrors (e.g. "invalid main_data offset") are non-fatal: drop the
// frame and try the next packet up to MAX_CONSECUTIVE_DECODE_ERRORS times.
let mut decode_errors: usize = 0;
let decoded = loop {
let packet = match format.next_packet() {
Ok(p) => p,
Ok(Some(p)) => p,
// Clean EOF before any decodable packet.
Ok(None) => break decoder.last_decoded(),
Err(symphonia::core::errors::Error::IoError(_)) => {
break decoder.last_decoded();
}
@@ -209,8 +278,8 @@ impl SizedDecoder {
return Err(format!("could not read audio data: {e}"));
}
};
if packet.track_id() != track_id {
crate::app_eprintln!("[psysonic] skipping packet for track {} (want {})", packet.track_id(), track_id);
if packet.track_id != track_id {
crate::app_eprintln!("[psysonic] skipping packet for track {} (want {})", packet.track_id, track_id);
continue;
}
match decoder.decode(&packet) {
@@ -229,8 +298,8 @@ impl SizedDecoder {
}
};
let spec = decoded.spec().to_owned();
let buffer = Self::make_buffer(decoded, &spec);
let spec = decoded.spec().clone();
let buffer = Self::make_buffer(&decoded);
Ok(SizedDecoder {
decoder,
@@ -251,34 +320,107 @@ impl SizedDecoder {
format_hint: Option<&str>,
source_tag: &str,
) -> 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).
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 media: Box<dyn MediaSource> = match &probe_seek_gate {
Some(gate) => Box::new(ProbeSeekGate { inner: media, seekable: gate.clone() }),
None => media,
};
// Larger read-ahead buffer for the live streaming SPSC consumer — reduces
// read() call frequency into the ring buffer, easing I/O spikes.
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: 512 * 1024 });
let mut hint = Hint::new();
if let Some(ext) = format_hint { hint.with_extension(ext); }
let format_opts = FormatOptions { enable_gapless: false, ..Default::default() };
let probed = symphonia::default::get_probe()
.format(&hint, mss, &format_opts, &MetadataOptions::default())
.map_err(|e| format!("{source_tag}: format probe failed: {e}"))?;
let format_opts = FormatOptions::default();
let meta_opts = MetadataOptions::default();
let track = probed.format.tracks().iter()
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
crate::app_deprintln!(
"[stream] {source_tag}: probe start (hint={}, stream_len={})",
format_hint.unwrap_or("?"),
stream_len.map(|n| n.to_string()).unwrap_or_else(|| "?".into()),
);
let probe_start = std::time::Instant::now();
// Run the probe on a dedicated thread guarded by a timeout. If a ranged
// source stalls (download never reaches the bytes Symphonia needs), the
// probe blocks forever; without this guard playback start would hang until
// the user restarts the player. On timeout we abandon the worker thread
// (it unblocks once the underlying read errors/returns) and surface an
// error so the caller can retry.
let hint_ext = format_hint.map(|s| s.to_string());
let tag_owned = source_tag.to_string();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::Builder::new()
.name("symphonia-probe".into())
.spawn(move || {
let mut hint = Hint::new();
if let Some(ext) = &hint_ext {
hint.with_extension(ext);
}
let result = symphonia::default::get_probe()
.probe(&hint, mss, format_opts, meta_opts)
.map_err(|e| format!("{tag_owned}: format probe failed: {e}"));
// Receiver is gone if we already timed out — ignore the send error.
let _ = tx.send(result);
})
.map_err(|e| format!("{source_tag}: failed to spawn probe thread: {e}"))?;
let mut format = match rx.recv_timeout(STREAM_PROBE_TIMEOUT) {
Ok(Ok(format)) => format,
Ok(Err(e)) => return Err(e),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
crate::app_eprintln!(
"[stream] {source_tag}: probe timed out after {STREAM_PROBE_TIMEOUT:?} \
(stream stalled?) aborting so the player can retry"
);
return Err(format!(
"{source_tag}: format probe timed out after {STREAM_PROBE_TIMEOUT:?}"
));
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
return Err(format!("{source_tag}: probe thread ended unexpectedly"));
}
};
crate::app_deprintln!(
"[stream] {source_tag}: probe done in {} ms",
probe_start.elapsed().as_millis()
);
// Trailing-metadata scan is done; restore real seekability for scrubbing.
if let Some(gate) = &probe_seek_gate {
gate.store(true, Ordering::Relaxed);
}
let track = format.tracks().iter()
.find(|t| t.codec_params.as_ref().and_then(|c| c.audio()).is_some())
.ok_or_else(|| format!("{source_tag}: no audio track found"))?;
let track_id = track.id;
log_codec_resolution(source_tag, &track.codec_params, format_hint);
let audio_params = track
.codec_params
.as_ref()
.and_then(|c| c.audio())
.ok_or_else(|| format!("{source_tag}: track has no audio codec parameters"))?
.clone();
log_codec_resolution(source_tag, &audio_params, format_hint);
// Live streams have no known total frame count → total_duration = None.
let total_duration = None;
let mut decoder = try_make_radio_decoder(&track.codec_params, &DecoderOptions::default())
let mut decoder = try_make_radio_decoder(&audio_params, &AudioDecoderOptions::default().gapless(false))
.map_err(|e| format!("{source_tag}: codec init failed: {e}"))?;
let mut format = probed.format;
let mut errors = 0usize;
let decoded = loop {
let packet = match format.next_packet() {
Ok(p) => p,
Ok(Some(p)) => p,
Ok(None) => break decoder.last_decoded(),
Err(_) => break decoder.last_decoded(),
};
if packet.track_id() != track_id { continue; }
if packet.track_id != track_id { continue; }
match decoder.decode(&packet) {
Ok(d) => break d,
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
@@ -291,16 +433,15 @@ impl SizedDecoder {
Err(e) => return Err(format!("{source_tag}: decode error: {e}")),
}
};
let spec = decoded.spec().to_owned();
let buffer = Self::make_buffer(decoded, &spec);
let spec = decoded.spec().clone();
let buffer = Self::make_buffer(&decoded);
Ok(SizedDecoder { decoder, current_frame_offset: 0, format, total_duration, buffer, spec, consecutive_decode_errors: 0 })
}
#[inline]
fn make_buffer(decoded: AudioBufferRef, spec: &SignalSpec) -> SampleBuffer<f32> {
let duration = units::Duration::from(decoded.capacity() as u64);
let mut buffer = SampleBuffer::<f32>::new(duration, *spec);
buffer.copy_interleaved_ref(decoded);
fn make_buffer(decoded: &GenericAudioBufferRef<'_>) -> Vec<f32> {
let mut buffer = Vec::new();
decoded.copy_to_vec_interleaved(&mut buffer);
buffer
}
@@ -310,29 +451,43 @@ impl SizedDecoder {
&mut self,
seek_res: symphonia::core::formats::SeekedTo,
) -> Result<(), String> {
let mut samples_to_pass = seek_res.required_ts - seek_res.actual_ts;
// Number of frames between where the demuxer landed and the requested ts.
let mut samples_to_pass: u64 = seek_res
.required_ts
.get()
.saturating_sub(seek_res.actual_ts.get())
.max(0) as u64;
let packet = loop {
let candidate = self.format.next_packet()
.map_err(|e| format!("refine seek: {e}"))?;
if candidate.dur() > samples_to_pass {
let candidate = match self.format.next_packet()
.map_err(|e| format!("refine seek: {e}"))?
{
Some(p) => p,
// EOF while refining — nothing more to skip.
None => return Ok(()),
};
if candidate.dur.get() > samples_to_pass {
break candidate;
}
samples_to_pass -= candidate.dur();
samples_to_pass -= candidate.dur.get();
};
let mut decoded = self.decoder.decode(&packet);
for _ in 0..DECODE_MAX_RETRIES {
if decoded.is_err() {
let p = self.format.next_packet()
.map_err(|e| format!("refine retry: {e}"))?;
let p = match self.format.next_packet()
.map_err(|e| format!("refine retry: {e}"))?
{
Some(p) => p,
None => break,
};
decoded = self.decoder.decode(&p);
}
}
let decoded = decoded.map_err(|e| format!("refine decode: {e}"))?;
decoded.spec().clone_into(&mut self.spec);
self.buffer = Self::make_buffer(decoded, &self.spec);
self.current_frame_offset = samples_to_pass as usize * self.spec.channels.count();
self.spec = decoded.spec().clone();
self.buffer = Self::make_buffer(&decoded);
self.current_frame_offset = samples_to_pass as usize * self.spec.channels().count();
Ok(())
}
}
@@ -348,12 +503,12 @@ impl Iterator for SizedDecoder {
// drop the frame and advance to the next packet. IO errors and a
// clean end-of-stream both terminate the iterator normally.
loop {
let packet = self.format.next_packet().ok()?;
let packet = self.format.next_packet().ok()??;
match self.decoder.decode(&packet) {
Ok(decoded) => {
self.consecutive_decode_errors = 0;
decoded.spec().clone_into(&mut self.spec);
self.buffer = Self::make_buffer(decoded, &self.spec);
self.spec = decoded.spec().clone();
self.buffer = Self::make_buffer(&decoded);
self.current_frame_offset = 0;
break;
}
@@ -384,7 +539,7 @@ impl Iterator for SizedDecoder {
}
}
let sample = *self.buffer.samples().get(self.current_frame_offset)?;
let sample = *self.buffer.get(self.current_frame_offset)?;
self.current_frame_offset += 1;
Some(sample)
}
@@ -393,25 +548,24 @@ impl Iterator for SizedDecoder {
impl Source for SizedDecoder {
#[inline]
fn current_span_len(&self) -> Option<usize> {
Some(self.buffer.samples().len())
Some(self.buffer.len())
}
#[inline]
fn channels(&self) -> rodio::ChannelCount {
std::num::NonZeroU16::new(self.spec.channels.count() as u16)
std::num::NonZeroU16::new(self.spec.channels().count() as u16)
.unwrap_or(std::num::NonZeroU16::MIN)
}
#[inline]
fn sample_rate(&self) -> rodio::SampleRate {
std::num::NonZeroU32::new(self.spec.rate).unwrap_or(std::num::NonZeroU32::MIN)
std::num::NonZeroU32::new(self.spec.rate()).unwrap_or(std::num::NonZeroU32::MIN)
}
#[inline]
fn total_duration(&self) -> Option<Duration> {
self.total_duration.map(|Time { seconds, frac }| {
Duration::new(seconds, (frac * 1_000_000_000.0) as u32)
})
self.total_duration
.map(|t| Duration::from_secs_f64(t.as_secs_f64().max(0.0)))
}
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
@@ -419,20 +573,19 @@ impl Source for SizedDecoder {
.total_duration()
.is_some_and(|dur| dur.saturating_sub(pos).as_millis() < 1);
let time: Time = if seek_beyond_end {
let t = self.total_duration.unwrap_or(pos.as_secs_f64().into());
let target_secs = if seek_beyond_end {
// Step back a tiny bit — some demuxers can't seek to the exact end.
let mut secs = t.seconds;
let mut frac = t.frac - 0.0001;
if frac < 0.0 {
secs = secs.saturating_sub(1);
frac = 1.0 - frac;
}
Time { seconds: secs, frac }
let total = self
.total_duration
.map(|t| t.as_secs_f64())
.unwrap_or_else(|| pos.as_secs_f64());
(total - 0.0001).max(0.0)
} else {
pos.as_secs_f64().into()
pos.as_secs_f64()
};
let time = Time::try_from_secs_f64(target_secs).unwrap_or(Time::ZERO);
let to_skip = self.current_frame_offset % self.channels().get() as usize;
let seek_res = self
@@ -546,6 +699,7 @@ pub(crate) fn build_source(
eq_gains: Arc<[AtomicU32; 10]>,
eq_enabled: Arc<AtomicBool>,
eq_pre_gain: Arc<AtomicU32>,
playback_rate: PlaybackRateAtomics,
done_flag: Arc<AtomicBool>,
fade_in_dur: Duration,
sample_counter: Arc<AtomicU64>,
@@ -616,7 +770,9 @@ pub(crate) fn build_source(
let fadeout_trigger = Arc::new(AtomicBool::new(false));
let fadeout_samples = Arc::new(AtomicU64::new(0));
let eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled, eq_pre_gain);
let rate_src = PlaybackRateSource::new(dyn_src, playback_rate.clone());
let rate_dyn = DynSource::new(rate_src);
let eq_src = EqSource::new(rate_dyn, eq_gains, eq_enabled, eq_pre_gain);
let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
let notifying = NotifyingSource::new(fade_out, done_flag);
@@ -625,7 +781,7 @@ pub(crate) fn build_source(
Ok(BuiltSource {
source: boosted,
duration_secs: effective_dur,
duration_secs: crate::playback_rate::effective_duration_secs(effective_dur, &playback_rate),
output_rate,
output_channels: channels.get(),
fadeout_trigger,
@@ -643,6 +799,7 @@ pub(crate) fn build_streaming_source(
eq_gains: Arc<[AtomicU32; 10]>,
eq_enabled: Arc<AtomicBool>,
eq_pre_gain: Arc<AtomicU32>,
playback_rate: PlaybackRateAtomics,
done_flag: Arc<AtomicBool>,
fade_in_dur: Duration,
sample_counter: Arc<AtomicU64>,
@@ -682,7 +839,9 @@ pub(crate) fn build_streaming_source(
let fadeout_trigger = Arc::new(AtomicBool::new(false));
let fadeout_samples = Arc::new(AtomicU64::new(0));
let eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled, eq_pre_gain);
let rate_src = PlaybackRateSource::new(dyn_src, playback_rate.clone());
let rate_dyn = DynSource::new(rate_src);
let eq_src = EqSource::new(rate_dyn, eq_gains, eq_enabled, eq_pre_gain);
let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
let notifying = NotifyingSource::new(fade_out, done_flag);
@@ -694,7 +853,7 @@ pub(crate) fn build_streaming_source(
Ok(BuiltSource {
source: boosted,
duration_secs: effective_dur,
duration_secs: crate::playback_rate::effective_duration_secs(effective_dur, &playback_rate),
output_rate,
output_channels: channels.get(),
fadeout_trigger,
@@ -834,8 +993,8 @@ mod tests {
fn sized_decoder_constructs_from_synthetic_wav() {
let wav = synthetic_wav_bytes(0.5);
let decoder = SizedDecoder::new(wav, Some("wav"), false).expect("WAV decode setup");
assert_eq!(decoder.spec.rate, 44_100);
assert_eq!(decoder.spec.channels.count(), 1);
assert_eq!(decoder.spec.rate(), 44_100);
assert_eq!(decoder.spec.channels().count(), 1);
}
#[test]
@@ -850,21 +1009,84 @@ mod tests {
let _decoder = SizedDecoder::new(wav, Some("wav"), true).expect("WAV decode with hi-res");
}
// ── new_streaming + ProbeSeekGate ────────────────────────────────────────
fn seekable_source(bytes: Vec<u8>) -> Box<dyn MediaSource> {
let len = bytes.len() as u64;
Box::new(SizedCursorSource { inner: Cursor::new(bytes), len })
}
#[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");
assert_eq!(decoder.spec.rate(), 44_100);
assert_eq!(decoder.spec.channels().count(), 1);
// Live streams report no total duration.
assert!(decoder.total_duration.is_none());
}
#[test]
fn new_streaming_returns_err_for_garbage_input() {
let result = SizedDecoder::new_streaming(
seekable_source(vec![0x00u8; 64]),
None,
"test-stream",
);
assert!(result.is_err());
}
#[test]
fn probe_seek_gate_toggles_seekability() {
let wav = synthetic_wav_bytes(0.1);
let len = wav.len() as u64;
let flag = Arc::new(AtomicBool::new(false));
let gate = ProbeSeekGate {
inner: seekable_source(wav),
seekable: flag.clone(),
};
// Hidden during probe …
assert!(!gate.is_seekable());
// … restored afterwards.
flag.store(true, Ordering::Relaxed);
assert!(gate.is_seekable());
// byte_len always passes through to the inner source.
assert_eq!(gate.byte_len(), Some(len));
}
#[test]
fn probe_seek_gate_read_and_seek_pass_through() {
let bytes = vec![1u8, 2, 3, 4, 5, 6, 7, 8];
let mut gate = ProbeSeekGate {
inner: seekable_source(bytes),
seekable: Arc::new(AtomicBool::new(true)),
};
let mut buf = [0u8; 4];
let n = gate.read(&mut buf).expect("read");
assert_eq!(n, 4);
assert_eq!(&buf, &[1, 2, 3, 4]);
let pos = gate.seek(std::io::SeekFrom::Start(6)).expect("seek");
assert_eq!(pos, 6);
let n = gate.read(&mut buf).expect("read after seek");
assert_eq!(&buf[..n], &[7, 8]);
}
// ── log_codec_resolution ─────────────────────────────────────────────────
#[test]
fn log_codec_resolution_does_not_panic_for_valid_params() {
let mut params = symphonia::core::codecs::CodecParameters::new();
params.codec = symphonia::core::codecs::CODEC_TYPE_PCM_S16LE;
let mut params = AudioCodecParameters::new();
params.codec = symphonia::core::codecs::audio::well_known::CODEC_ID_PCM_S16LE;
params.sample_rate = Some(44_100);
params.bits_per_sample = Some(16);
params.channels = Some(symphonia::core::audio::Channels::FRONT_LEFT);
params.channels = Some(symphonia::core::audio::Channels::Discrete(1));
log_codec_resolution("test-tag", &params, Some("wav"));
}
#[test]
fn log_codec_resolution_handles_unknown_codec_gracefully() {
let params = symphonia::core::codecs::CodecParameters::new();
let params = AudioCodecParameters::new();
log_codec_resolution("unknown", &params, None);
}
}
@@ -915,21 +1137,29 @@ mod build_source_tests {
}
type EqGains = Arc<[AtomicU32; 10]>;
type SourceArgs = (EqGains, Arc<AtomicBool>, Arc<AtomicU32>, Arc<AtomicBool>, Arc<AtomicU64>);
type SourceArgs = (
EqGains,
Arc<AtomicBool>,
Arc<AtomicU32>,
PlaybackRateAtomics,
Arc<AtomicBool>,
Arc<AtomicU64>,
);
fn default_source_args() -> SourceArgs {
let eq_gains: Arc<[AtomicU32; 10]> =
Arc::new(std::array::from_fn(|_| AtomicU32::new(0f32.to_bits())));
let eq_enabled = Arc::new(AtomicBool::new(false));
let eq_pre_gain = Arc::new(AtomicU32::new(0f32.to_bits()));
let playback_rate = PlaybackRateAtomics::new();
let done_flag = Arc::new(AtomicBool::new(false));
let sample_counter = Arc::new(AtomicU64::new(0));
(eq_gains, eq_enabled, eq_pre_gain, done_flag, sample_counter)
(eq_gains, eq_enabled, eq_pre_gain, playback_rate, done_flag, sample_counter)
}
#[test]
fn build_source_succeeds_for_synthetic_wav() {
let (eq_gains, eq_enabled, eq_pre_gain, done_flag, sample_counter) = default_source_args();
let (eq_gains, eq_enabled, eq_pre_gain, playback_rate, done_flag, sample_counter) = default_source_args();
let wav = synthetic_wav_bytes_local(0.4);
let built = build_source(
wav,
@@ -937,6 +1167,7 @@ mod build_source_tests {
eq_gains,
eq_enabled,
eq_pre_gain,
playback_rate,
done_flag,
Duration::ZERO,
sample_counter,
@@ -952,13 +1183,14 @@ mod build_source_tests {
#[test]
fn build_source_returns_err_for_garbage_bytes() {
let (eq_gains, eq_enabled, eq_pre_gain, done_flag, sample_counter) = default_source_args();
let (eq_gains, eq_enabled, eq_pre_gain, playback_rate, done_flag, sample_counter) = default_source_args();
let result = build_source(
vec![0u8; 32],
0.0,
eq_gains,
eq_enabled,
eq_pre_gain,
playback_rate,
done_flag,
Duration::ZERO,
sample_counter,
@@ -971,7 +1203,7 @@ mod build_source_tests {
#[test]
fn build_streaming_source_succeeds_for_synthetic_wav() {
let (eq_gains, eq_enabled, eq_pre_gain, done_flag, sample_counter) = default_source_args();
let (eq_gains, eq_enabled, eq_pre_gain, playback_rate, done_flag, sample_counter) = default_source_args();
let wav = synthetic_wav_bytes_local(0.4);
let decoder = SizedDecoder::new(wav, Some("wav"), false).unwrap();
let built = build_streaming_source(
@@ -980,6 +1212,7 @@ mod build_source_tests {
eq_gains,
eq_enabled,
eq_pre_gain,
playback_rate,
done_flag,
Duration::ZERO,
sample_counter,
@@ -993,7 +1226,7 @@ mod build_source_tests {
#[test]
fn build_source_with_target_rate_resamples() {
let (eq_gains, eq_enabled, eq_pre_gain, done_flag, sample_counter) = default_source_args();
let (eq_gains, eq_enabled, eq_pre_gain, playback_rate, done_flag, sample_counter) = default_source_args();
let wav = synthetic_wav_bytes_local(0.3);
let built = build_source(
wav,
@@ -1001,6 +1234,7 @@ mod build_source_tests {
eq_gains,
eq_enabled,
eq_pre_gain,
playback_rate,
done_flag,
Duration::from_millis(5),
sample_counter,
@@ -143,6 +143,9 @@ pub(crate) async fn try_resume_after_device_change(
engine.samples_played.store(0, Ordering::Relaxed);
let hi_res_enabled = engine.current_sample_rate.load(Ordering::Relaxed) > 48_000;
// Resume re-plays the current track → scope its analysis writes to the
// pinned playback server (empty → legacy '').
let resume_server = crate::helpers::current_playback_server_id_str(&engine);
let ps: PlaybackSource = match build_playback_source_with_probe_fallback(
play_input,
@@ -150,6 +153,7 @@ pub(crate) async fn try_resume_after_device_change(
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(),
@@ -218,6 +222,15 @@ pub(crate) async fn try_resume_after_device_change(
let mut cur = engine.current.lock().unwrap();
cur.seek_offset = snap.current_time_secs;
cur.play_started = Some(Instant::now());
engine.samples_played.store(
crate::playback_rate::raw_counter_samples_for_content_position(
snap.current_time_secs,
engine.current_sample_rate.load(Ordering::Relaxed),
engine.current_channels.load(Ordering::Relaxed),
&engine.playback_rate,
),
Ordering::Relaxed,
);
}
Ok(Err(e)) => {
crate::app_eprintln!("[device-resume] seek failed: {e}");
@@ -232,6 +245,7 @@ pub(crate) async fn try_resume_after_device_change(
// Inform the frontend of the new duration (keeps seekbar range correct).
app.emit("audio:playing", ps.built.duration_secs).ok();
let analysis_app = app.clone();
spawn_progress_task(
gen,
engine.generation.clone(),
@@ -241,12 +255,14 @@ pub(crate) async fn try_resume_after_device_change(
engine.crossfade_secs.clone(),
done_flag,
app.clone(),
Some(analysis_app),
engine.samples_played.clone(),
engine.current_sample_rate.clone(),
engine.current_channels.clone(),
engine.gapless_switch_at.clone(),
engine.current_playback_url.clone(),
engine.stream_playback_armed.clone(),
engine.playback_rate.clone(),
);
crate::app_deprintln!(
@@ -4,7 +4,6 @@ use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant};
use rodio::Player;
use tauri::{AppHandle, Manager};
use super::state::{ChainedInfo, PreloadedTrack, StreamCompletedSpill};
@@ -32,6 +31,7 @@ pub struct AudioEngine {
pub eq_gains: Arc<[AtomicU32; 10]>,
pub eq_enabled: Arc<AtomicBool>,
pub eq_pre_gain: Arc<AtomicU32>,
pub playback_rate: crate::playback_rate::PlaybackRateAtomics,
pub(crate) preloaded: Arc<Mutex<Option<PreloadedTrack>>>,
/// Last fully downloaded manual-stream track bytes (same playback identity),
/// used to recover seek/replay without waiting for network again.
@@ -82,6 +82,11 @@ pub struct AudioEngine {
/// Subsonic song id last passed from JS with `audio_play` (trimmed). Used
/// for loudness/waveform cache when the URL is `psysonic-local://…`.
pub(crate) current_analysis_track_id: Arc<Mutex<Option<String>>>,
/// App server id (`playbackServerId ?? activeServerId`) of the current
/// playback, pinned by `audio_play`. Scopes analysis-cache reads (loudness
/// gain, replay-gain updates, device resume) to the right server so a switch
/// can't surface another server's blob for the same bare `track_id`.
pub(crate) current_playback_server_id: Arc<Mutex<Option<String>>>,
/// While a `RangedHttpSource` download task is filling the buffer for this
/// `(track_id, play_generation)`, skip `analysis_enqueue_seed_from_url` for the
/// same id — otherwise a parallel full GET + Symphonia competes with playback
@@ -361,6 +366,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
eq_gains: Arc::new(std::array::from_fn(|_| AtomicU32::new(0f32.to_bits()))),
eq_enabled: Arc::new(AtomicBool::new(false)),
eq_pre_gain: Arc::new(AtomicU32::new(0f32.to_bits())),
playback_rate: crate::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)),
@@ -381,6 +387,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
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)),
@@ -451,7 +458,3 @@ pub fn refresh_http_user_agent(state: &AudioEngine, ua: &str) {
*slot = client;
}
}
pub(crate) fn analysis_seed_high_priority_for_track(app: &AppHandle, track_id: &str) -> bool {
app.try_state::<AudioEngine>()
.is_some_and(|e| analysis_track_id_is_current_playback(&e, track_id))
}
+28 -112
View File
@@ -326,12 +326,14 @@ pub(crate) fn resolve_loudness_gain_from_cache(
url: &str,
target_lufs: f32,
logical_track_id: Option<&str>,
server_id: &str,
) -> Option<f32> {
resolve_loudness_gain_from_cache_impl(
app,
url,
target_lufs,
logical_track_id,
server_id,
ResolveLoudnessCacheOpts::default(),
)
}
@@ -341,6 +343,7 @@ pub(crate) fn resolve_loudness_gain_from_cache_impl(
url: &str,
target_lufs: f32,
logical_track_id: Option<&str>,
server_id: &str,
opts: ResolveLoudnessCacheOpts,
) -> Option<f32> {
// Only a SQLite loudness row counts here. Ephemeral JS hints (`analysis:loudness-partial`)
@@ -363,7 +366,7 @@ pub(crate) fn resolve_loudness_gain_from_cache_impl(
}
return None;
};
resolve_loudness_gain_with_cache(cache.inner(), &track_id, target_lufs, opts)
resolve_loudness_gain_with_cache(cache.inner(), server_id, &track_id, target_lufs, opts)
}
/// AppHandle-free core of [`resolve_loudness_gain_from_cache_impl`]. Looks up
@@ -376,15 +379,16 @@ pub(crate) fn resolve_loudness_gain_from_cache_impl(
/// connection's row cache is warm for the next IPC tick.
pub(crate) fn resolve_loudness_gain_with_cache(
cache: &psysonic_analysis::analysis_cache::AnalysisCache,
server_id: &str,
track_id: &str,
target_lufs: f32,
opts: ResolveLoudnessCacheOpts,
) -> Option<f32> {
if opts.touch_waveform {
// Bind / preload: verify waveform context exists alongside loudness lookup.
let _ = cache.get_latest_waveform_for_track(track_id);
let _ = cache.get_latest_waveform_for_track(server_id, track_id);
}
match cache.get_latest_loudness_for_track(track_id) {
match cache.get_latest_loudness_for_track(server_id, track_id) {
Ok(Some(row)) if row.integrated_lufs.is_finite() => {
let recommended = psysonic_analysis::analysis_cache::recommended_gain_for_target(
row.integrated_lufs,
@@ -493,6 +497,17 @@ pub(crate) struct TrackGainInputs {
/// Read engine state + resolve the loudness cache for a track that's about to
/// start playing. JS-supplied `loudness_gain_db` is **not** consulted at bind
/// time (only post-cache via `audio_update_replay_gain`).
/// Current playback server scope (`current_playback_server_id`, empty when
/// unset) for scoping analysis-cache reads on the gain-resolution path.
pub(crate) fn current_playback_server_id_str(state: &AudioEngine) -> String {
state
.current_playback_server_id
.lock()
.ok()
.and_then(|g| (*g).clone())
.unwrap_or_default()
}
pub(crate) fn resolve_track_gain_inputs(
state: &AudioEngine,
app: &AppHandle,
@@ -503,7 +518,9 @@ pub(crate) fn resolve_track_gain_inputs(
let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed));
let norm_mode = state.normalization_engine.load(Ordering::Relaxed);
let pre_analysis_db = loudness_pre_analysis_db_for_engine(state);
let cache_loudness_db = resolve_loudness_gain_from_cache(app, url, target_lufs, logical_track_id);
let server_id = current_playback_server_id_str(state);
let cache_loudness_db =
resolve_loudness_gain_from_cache(app, url, target_lufs, logical_track_id, &server_id);
let effective_loudness_db = if norm_mode == 2 {
loudness_gain_db_after_resolve(
cache_loudness_db,
@@ -730,113 +747,6 @@ pub(crate) async fn fetch_data(
Ok(Some(data))
}
/// When playback uses full track bytes already in RAM (gapless `reuse_chained_bytes`,
/// `preloaded`, or `stream_completed_cache` via `fetch_data`), the `psysonic-local`
/// disk-read seed path never runs. Submit the same full-buffer analysis via the cpu-seed queue so waveform /
/// loudness SQLite can fill **offline** without `analysis_enqueue_seed_from_url` HTTP.
pub(crate) fn spawn_analysis_seed_from_in_memory_bytes(
app: &AppHandle,
cache_track_id: Option<&str>,
gen: u64,
gen_arc: &Arc<AtomicU64>,
bytes: &[u8],
) {
let Some(track_id) = cache_track_id.map(str::trim).filter(|s| !s.is_empty()) else {
return;
};
if bytes.is_empty() || bytes.len() > crate::stream::TRACK_STREAM_PROMOTE_MAX_BYTES {
return;
}
let track_id = track_id.to_string();
let bytes = bytes.to_vec();
let app = app.clone();
let gen_arc = gen_arc.clone();
crate::app_deprintln!(
"[stream] in-memory play path: scheduling full-track analysis track_id={} size_mib={:.2}",
track_id,
bytes.len() as f64 / (1024.0 * 1024.0)
);
let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id);
tokio::spawn(async move {
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), track_id.clone(), bytes, high).await {
crate::app_eprintln!(
"[analysis] in-memory play path seed failed for {}: {}",
track_id,
e
);
}
});
}
/// Full-track analysis for a completed ranged stream spilled to disk (> RAM promote cap).
pub(crate) fn spawn_analysis_seed_from_spill_file(
app: &AppHandle,
track_id: &str,
spill_path: std::path::PathBuf,
gen: u64,
gen_arc: &Arc<AtomicU64>,
) {
let track_id = track_id.trim().to_string();
if track_id.is_empty() {
return;
}
let app = app.clone();
let gen_arc = gen_arc.clone();
let max_bytes = crate::stream::LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES;
tokio::spawn(async move {
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
let bytes = match tokio::fs::read(&spill_path).await {
Ok(b) if b.is_empty() => return,
Ok(b) if b.len() > max_bytes => {
crate::app_deprintln!(
"[stream] spill analysis skip track_id={} bytes={} max={}",
track_id,
b.len(),
max_bytes
);
return;
}
Ok(b) => b,
Err(e) => {
crate::app_eprintln!(
"[stream] spill analysis read failed track_id={}: {}",
track_id,
e
);
return;
}
};
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
crate::app_deprintln!(
"[stream] spill path: scheduling full-track analysis track_id={} size_mib={:.2}",
track_id,
bytes.len() as f64 / (1024.0 * 1024.0)
);
let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id);
if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(
app,
track_id.clone(),
bytes,
high,
)
.await
{
crate::app_eprintln!(
"[analysis] spill path seed failed for {}: {}",
track_id,
e
);
}
});
}
/// -1 dB headroom applied at full scale to prevent inter-sample clipping.
/// Modern masters are often at 0 dBFS; the EQ biquad chain and resampler
/// can produce inter-sample peaks slightly above ±1.0 → audible distortion.
@@ -1407,6 +1317,7 @@ mod tests {
fn upsert_loudness_row(cache: &AnalysisCache, track_id: &str, integrated: f64, target: f64) {
let k = TrackKey {
server_id: String::new(),
track_id: track_id.to_string(),
md5_16kb: "deadbeef".to_string(),
};
@@ -1430,6 +1341,7 @@ mod tests {
let cache = AnalysisCache::open_in_memory();
let g = resolve_loudness_gain_with_cache(
&cache,
"",
"no-such-track",
-14.0,
ResolveLoudnessCacheOpts::default(),
@@ -1444,6 +1356,7 @@ mod tests {
upsert_loudness_row(&cache, "abc", -23.0, -14.0);
let g = resolve_loudness_gain_with_cache(
&cache,
"",
"abc",
-14.0,
ResolveLoudnessCacheOpts::default(),
@@ -1468,6 +1381,7 @@ mod tests {
upsert_loudness_row(&cache, "stream:abc", -16.0, -14.0);
let g = resolve_loudness_gain_with_cache(
&cache,
"",
"abc",
-14.0,
ResolveLoudnessCacheOpts::default(),
@@ -1481,6 +1395,7 @@ mod tests {
upsert_loudness_row(&cache, "abc", -20.0, -14.0);
let g_quiet = resolve_loudness_gain_with_cache(
&cache,
"",
"abc",
-20.0,
ResolveLoudnessCacheOpts::default(),
@@ -1488,6 +1403,7 @@ mod tests {
.unwrap();
let g_loud = resolve_loudness_gain_with_cache(
&cache,
"",
"abc",
-10.0,
ResolveLoudnessCacheOpts::default(),
@@ -1508,7 +1424,7 @@ mod tests {
touch_waveform: false,
log_soft_misses: false,
};
let g = resolve_loudness_gain_with_cache(&cache, "abc", -14.0, opts);
let g = resolve_loudness_gain_with_cache(&cache, "", "abc", -14.0, opts);
assert!(g.is_some());
}
}
@@ -7,6 +7,7 @@
pub use psysonic_core::{app_deprintln, app_eprintln, logging};
pub mod autoeq_commands;
mod analysis_dispatch;
mod codec;
pub mod commands;
mod decode;
@@ -14,6 +15,8 @@ mod dev_io;
pub mod device_commands;
pub mod mix_commands;
mod play_input;
pub mod playback_rate;
mod preserve_worker;
pub mod preload_commands;
pub(crate) mod progress_task;
pub mod radio_commands;
@@ -50,12 +50,14 @@ pub fn audio_update_replay_gain(
.filter(|s| !s.is_empty());
// If `current_playback_url` is not pinned yet, still honour JS `loudness_gain_db`
// for the uncached path (`effective_loudness_db` / UI gain follow from `compute_gain`).
let server_for_loudness = crate::helpers::current_playback_server_id_str(&state);
let cache_loudness = url_for_loudness.as_deref().and_then(|u| {
resolve_loudness_gain_from_cache_impl(
&app,
u,
target_lufs,
logical_for_loudness.as_deref(),
&server_for_loudness,
ResolveLoudnessCacheOpts {
touch_waveform: false,
log_soft_misses: false,
@@ -141,6 +143,89 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
state.gapless_enabled.store(enabled, Ordering::Relaxed);
}
#[tauri::command]
pub fn audio_set_playback_rate(
enabled: bool,
strategy: String,
speed: f32,
pitch_semitones: f32,
state: State<'_, AudioEngine>,
) {
use crate::playback_rate::{
content_position_from_samples, is_effect_active, raw_counter_samples_for_content_position,
uses_preserve_dsp, STRATEGY_PRESERVE_PITCH, STRATEGY_SPEED_CORRECTED,
STRATEGY_VARISPEED,
};
let clamped_speed = speed.clamp(0.5, 2.0);
let clamped_pitch = pitch_semitones.clamp(-12.0, 12.0);
let old_enabled = state.playback_rate.enabled.load(Ordering::Relaxed);
let old_strat = state.playback_rate.load_strategy();
let old_speed = state.playback_rate.load_speed();
let was_active = is_effect_active(&state.playback_rate);
let new_strat = match strategy.as_str() {
"preserve_pitch" => STRATEGY_PRESERVE_PITCH,
"speed_corrected" => STRATEGY_SPEED_CORRECTED,
_ => STRATEGY_VARISPEED,
};
let speed_changed = (clamped_speed - old_speed).abs() > 0.001;
let restamp_content = if was_active
&& enabled == old_enabled
&& uses_preserve_dsp(old_strat)
&& new_strat == old_strat
&& speed_changed
{
let sample_rate = state.current_sample_rate.load(Ordering::Relaxed);
let channels = state.current_channels.load(Ordering::Relaxed);
if sample_rate > 0 && channels > 0 {
Some(content_position_from_samples(
state.samples_played.load(Ordering::Relaxed),
sample_rate,
channels,
&state.playback_rate,
))
} else {
None
}
} else {
None
};
state
.playback_rate
.enabled
.store(enabled, Ordering::Relaxed);
state
.playback_rate
.strategy
.store(new_strat, Ordering::Relaxed);
state
.playback_rate
.speed
.store(clamped_speed.to_bits(), Ordering::Relaxed);
state
.playback_rate
.pitch_semitones
.store(clamped_pitch.to_bits(), Ordering::Relaxed);
if let Some(content_secs) = restamp_content {
if is_effect_active(&state.playback_rate) {
let sample_rate = state.current_sample_rate.load(Ordering::Relaxed);
let channels = state.current_channels.load(Ordering::Relaxed);
state.samples_played.store(
raw_counter_samples_for_content_position(
content_secs,
sample_rate,
channels,
&state.playback_rate,
),
Ordering::Relaxed,
);
}
}
}
#[tauri::command]
pub fn audio_set_normalization(
engine: String,
@@ -9,19 +9,23 @@ use std::time::Duration;
use ringbuf::traits::Split;
use ringbuf::{HeapCons, HeapRb};
use symphonia::core::io::MediaSource;
use tauri::{AppHandle, Emitter, Manager, State};
use tauri::{AppHandle, Emitter, State};
use super::analysis_dispatch::{
prepare_playback_analysis, spawn_track_analysis_bytes, spawn_track_analysis_file,
TrackAnalysisOrigin,
};
use super::decode::{build_source, build_streaming_source, BuiltSource, SizedDecoder};
use super::engine::{audio_http_client, AudioEngine};
use super::helpers::{
content_type_to_hint, fetch_data, format_hint_from_content_disposition,
normalize_stream_suffix_for_hint, resolve_playback_format_hint, sniff_stream_format_extension,
spawn_analysis_seed_from_in_memory_bytes, same_playback_target,
same_playback_target,
STREAM_FORMAT_SNIFF_PROBE_BYTES,
};
use super::stream::{
ranged_download_task, track_download_task, AudioStreamReader,
LocalFileSource, RangedHttpSource, LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES,
LocalFileSource, RangedHttpSource,
TRACK_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_BUF_CAPACITY, TRACK_STREAM_MIN_BUF_CAPACITY,
};
@@ -54,11 +58,41 @@ pub(super) struct PlayInputContext<'a> {
pub stream_format_suffix: Option<&'a str>,
pub format_hint: Option<&'a str>,
pub cache_id_for_tasks: Option<&'a str>,
/// Playback server scope for the analysis-cache write key (empty/`None` →
/// legacy `''`). Rides alongside `cache_id_for_tasks` into every seed path.
pub server_id: Option<&'a str>,
/// `Some(bytes)` when manual-skip onto a pre-chained track reuses bytes
/// from the chained-info block.
pub reuse_chained_bytes: Option<Vec<u8>>,
}
fn spawn_playback_analysis_bytes(
app: &AppHandle,
state: &State<'_, AudioEngine>,
ctx: &PlayInputContext<'_>,
origin: TrackAnalysisOrigin,
bytes: Vec<u8>,
) {
let Some(track_id) = ctx
.cache_id_for_tasks
.map(str::trim)
.filter(|s| !s.is_empty())
else {
return;
};
let (sid, high) =
prepare_playback_analysis(app, state, ctx.server_id, track_id, None);
spawn_track_analysis_bytes(
app.clone(),
origin,
sid,
track_id.to_string(),
bytes,
high,
Some((ctx.gen, state.generation.clone())),
);
}
/// Resolves the play input for `audio_play` honouring (in priority order):
/// 1. Reused chained bytes — manual skip onto pre-chained track.
/// 2. `psysonic-local://` files — open as seekable LocalFileSource.
@@ -74,13 +108,23 @@ pub(super) async fn select_play_input(
app: &AppHandle,
) -> Result<Option<PlayInput>, String> {
if let Some(d) = ctx.reuse_chained_bytes {
spawn_analysis_seed_from_in_memory_bytes(
app,
ctx.cache_id_for_tasks,
ctx.gen,
&state.generation,
&d,
);
if let Some(track_id) = ctx
.cache_id_for_tasks
.map(str::trim)
.filter(|s| !s.is_empty())
{
let (sid, high) =
prepare_playback_analysis(app, state, ctx.server_id, track_id, None);
spawn_track_analysis_bytes(
app.clone(),
TrackAnalysisOrigin::InMemoryReplay,
sid,
track_id.to_string(),
d.clone(),
high,
Some((ctx.gen, state.generation.clone())),
);
}
return Ok(Some(PlayInput::Bytes(d)));
}
@@ -110,12 +154,12 @@ pub(super) async fn select_play_input(
Some(d) => d,
None => return Ok(None), // superseded while downloading
};
spawn_analysis_seed_from_in_memory_bytes(
spawn_playback_analysis_bytes(
app,
ctx.cache_id_for_tasks,
ctx.gen,
&state.generation,
&data,
state,
&ctx,
TrackAnalysisOrigin::InMemoryReplay,
data.clone(),
);
Ok(Some(PlayInput::Bytes(data)))
}
@@ -141,53 +185,17 @@ fn open_local_file_input(
local_hint
);
if let Some(seed_id) = ctx.cache_id_for_tasks {
let skip_cpu_seed = app
.try_state::<psysonic_analysis::analysis_cache::AnalysisCache>()
.map(|c| c.cpu_seed_redundant_for_track(seed_id).unwrap_or(false))
.unwrap_or(false);
if !skip_cpu_seed {
let path_owned = std::path::PathBuf::from(path);
let app_seed = app.clone();
let gen_seed = ctx.gen;
let gen_arc_seed = state.generation.clone();
let seed_id = seed_id.to_string();
tokio::spawn(async move {
if gen_arc_seed.load(Ordering::SeqCst) != gen_seed {
return;
}
let data = match tokio::fs::read(&path_owned).await {
Ok(d) => d,
Err(_) => return,
};
if gen_arc_seed.load(Ordering::SeqCst) != gen_seed {
return;
}
if data.is_empty() || data.len() > LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES {
crate::app_deprintln!(
"[stream] psysonic-local: skip analysis seed track_id={} bytes={} (over {} MiB cap)",
seed_id,
data.len(),
LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES / (1024 * 1024)
);
return;
}
crate::app_deprintln!(
"[stream] psysonic-local: file read complete track_id={} size_mib={:.2} — full-track analysis (cpu-seed queue)",
seed_id,
data.len() as f64 / (1024.0 * 1024.0)
);
let high = crate::engine::analysis_seed_high_priority_for_track(&app_seed, &seed_id);
if let Err(e) =
psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app_seed.clone(), seed_id.clone(), data, high).await
{
crate::app_eprintln!(
"[analysis] local-file seed failed for {}: {}",
seed_id,
e
);
}
});
}
let (sid, high) =
prepare_playback_analysis(app, state, ctx.server_id, seed_id, None);
spawn_track_analysis_file(
app.clone(),
TrackAnalysisOrigin::LocalFilePlayback,
sid,
seed_id.to_string(),
std::path::PathBuf::from(path),
high,
Some((ctx.gen, state.generation.clone())),
);
}
let reader = LocalFileSource { file, len };
Ok(PlayInput::SeekableMedia {
@@ -316,6 +324,7 @@ async fn open_ranged_or_streaming_input(
state.normalization_target_lufs.clone(),
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()),
loudness_hold_for_defer,
playback_armed,
stream_hint.clone(),
@@ -369,6 +378,7 @@ async fn open_ranged_or_streaming_input(
state.normalization_target_lufs.clone(),
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()),
playback_armed,
));
@@ -459,6 +469,7 @@ 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>,
@@ -686,6 +697,7 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
url,
gen,
cache_id_for_tasks,
server_id,
url_format_hint,
stream_format_suffix,
done_flag,
@@ -749,13 +761,22 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
effective_hint
);
}
spawn_analysis_seed_from_in_memory_bytes(
app,
cache_id_for_tasks,
gen,
&state.generation,
&data,
);
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,
@@ -832,6 +853,7 @@ pub(super) async fn build_source_from_play_input(
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(),
@@ -862,6 +884,7 @@ pub(super) async fn build_source_from_play_input(
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(),
@@ -882,6 +905,7 @@ pub(super) async fn build_source_from_play_input(
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(),
@@ -0,0 +1,662 @@
//! Global playback speed / pitch strategies (varispeed, speed-corrected, preserve pitch).
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::mpsc;
use std::sync::Arc;
use std::time::Duration;
use rodio::source::SeekError;
use rodio::{ChannelCount, SampleRate, Source};
use crate::preserve_worker::PreserveOffload;
pub const STRATEGY_VARISPEED: u32 = 0;
pub const STRATEGY_PRESERVE_PITCH: u32 = 1;
pub const STRATEGY_SPEED_CORRECTED: u32 = 2;
pub(crate) const PRESERVE_MAKEUP_GAIN: f32 = 1.35;
#[derive(Clone)]
pub struct PlaybackRateAtomics {
pub enabled: Arc<AtomicBool>,
pub strategy: Arc<AtomicU32>,
pub speed: Arc<AtomicU32>,
pub pitch_semitones: Arc<AtomicU32>,
}
impl Default for PlaybackRateAtomics {
fn default() -> Self {
Self {
enabled: Arc::new(AtomicBool::new(false)),
strategy: Arc::new(AtomicU32::new(STRATEGY_SPEED_CORRECTED)),
speed: Arc::new(AtomicU32::new(1.0f32.to_bits())),
pitch_semitones: Arc::new(AtomicU32::new(0.0f32.to_bits())),
}
}
}
impl PlaybackRateAtomics {
pub fn new() -> Self {
Self::default()
}
pub fn load_speed(&self) -> f32 {
f32::from_bits(self.speed.load(Ordering::Relaxed)).clamp(0.5, 2.0)
}
pub fn load_pitch(&self) -> f32 {
f32::from_bits(self.pitch_semitones.load(Ordering::Relaxed)).clamp(-12.0, 12.0)
}
pub fn load_strategy(&self) -> u32 {
match self.strategy.load(Ordering::Relaxed) {
STRATEGY_PRESERVE_PITCH => STRATEGY_PRESERVE_PITCH,
STRATEGY_SPEED_CORRECTED => STRATEGY_SPEED_CORRECTED,
_ => STRATEGY_VARISPEED,
}
}
}
pub fn uses_preserve_dsp(strategy: u32) -> bool {
strategy == STRATEGY_PRESERVE_PITCH || strategy == STRATEGY_SPEED_CORRECTED
}
pub fn effective_pitch(atomics: &PlaybackRateAtomics) -> f32 {
if atomics.load_strategy() == STRATEGY_PRESERVE_PITCH {
atomics.load_pitch()
} else {
0.0
}
}
pub fn is_effect_active(atomics: &PlaybackRateAtomics) -> bool {
if !atomics.enabled.load(Ordering::Relaxed) {
return false;
}
let speed = atomics.load_speed();
match atomics.load_strategy() {
STRATEGY_PRESERVE_PITCH => {
(speed - 1.0).abs() > 0.001 || atomics.load_pitch().abs() > 0.001
}
_ => (speed - 1.0).abs() > 0.001,
}
}
/// True when preserve-pitch DSP (background worker) should run for this track.
pub(crate) fn preserve_pitch_will_run(atomics: &PlaybackRateAtomics) -> bool {
atomics.enabled.load(Ordering::Relaxed)
&& uses_preserve_dsp(atomics.load_strategy())
&& is_effect_active(atomics)
}
/// Content timeline length for seek bar / duration labels (always the full track).
pub fn effective_duration_secs(base_secs: f64, _atomics: &PlaybackRateAtomics) -> f64 {
base_secs
}
/// Map counter-derived seconds to timeline position for UI / near-end checks.
pub fn effective_position_secs(raw_secs: f64, atomics: &PlaybackRateAtomics) -> f64 {
if !is_effect_active(atomics) {
return raw_secs;
}
if atomics.load_strategy() == STRATEGY_VARISPEED {
return raw_secs;
}
// Preserve DSP outputs at the base sample rate; scale to content timeline.
raw_secs * atomics.load_speed() as f64
}
/// Sample-counter position mapped to the content timeline (seek bar / labels).
pub(crate) fn content_position_from_samples(
samples: u64,
sample_rate_hz: u32,
channels: u32,
atomics: &PlaybackRateAtomics,
) -> f64 {
let divisor = (sample_rate_hz as f64 * channels as f64).max(1.0);
effective_position_secs(samples as f64 / divisor, atomics)
}
/// Counter value that matches `content_position_from_samples` after a content-timeline seek.
pub(crate) fn raw_counter_samples_for_content_position(
content_secs: f64,
sample_rate_hz: u32,
channels: u32,
atomics: &PlaybackRateAtomics,
) -> u64 {
let divisor = (sample_rate_hz as f64 * channels as f64).max(1.0);
let raw_secs = if is_effect_active(atomics)
&& atomics.load_strategy() != STRATEGY_VARISPEED
{
content_secs / atomics.load_speed().max(0.001) as f64
} else {
content_secs
};
(raw_secs * divisor).round() as u64
}
pub(crate) fn preserve_out_samples(speed: f32) -> usize {
(128.0f32 / speed.clamp(0.5, 2.0)).round() as usize
}
pub struct PlaybackRateSource<S: Source<Item = f32> + Send + 'static> {
inner: Option<S>,
base_sample_rate: SampleRate,
base_channels: ChannelCount,
atomics: PlaybackRateAtomics,
offload: Option<PreserveOffload>,
handback_rx: Option<mpsc::Receiver<S>>,
handback_requested: bool,
}
impl<S: Source<Item = f32> + Send + 'static> PlaybackRateSource<S> {
pub fn new(inner: S, atomics: PlaybackRateAtomics) -> Self {
let base_sample_rate = inner.sample_rate();
let base_channels = inner.channels();
Self {
inner: Some(inner),
base_sample_rate,
base_channels,
atomics,
offload: None,
handback_rx: None,
handback_requested: false,
}
}
fn poll_handback(&mut self) {
let Some(rx) = &self.handback_rx else {
return;
};
if let Ok(inner) = rx.try_recv() {
self.inner = Some(inner);
self.handback_rx = None;
self.handback_requested = false;
if let Some(offload) = self.offload.take() {
offload.join();
}
}
}
fn request_handback_if_needed(&mut self) {
if self.inner.is_some() || self.handback_requested {
return;
}
if let Some(offload) = &self.offload {
offload.request_handback();
self.handback_requested = true;
}
}
fn ensure_offload(&mut self) {
if self.offload.is_some() {
return;
}
if let Some(inner) = self.inner.take() {
let (handback_tx, handback_rx) = mpsc::sync_channel(1);
self.handback_rx = Some(handback_rx);
self.offload = Some(PreserveOffload::spawn(
inner,
self.atomics.clone(),
self.base_sample_rate.get(),
self.base_channels.get(),
handback_tx,
));
}
}
fn base_sample_rate(&self) -> SampleRate {
self.inner
.as_ref()
.map(Source::sample_rate)
.unwrap_or(self.base_sample_rate)
}
fn try_recover_inner_from_offload(&mut self) {
if self.inner.is_some() || self.offload.is_none() {
return;
}
self.request_handback_if_needed();
self.poll_handback();
}
fn next_from_inner_or_pad(&mut self) -> Option<f32> {
self.try_recover_inner_from_offload();
if let Some(inner) = self.inner.as_mut() {
return inner.next();
}
if self
.offload
.as_ref()
.is_some_and(|offload| !offload.is_done())
{
return Some(0.0);
}
None
}
}
impl<S: Source<Item = f32> + Send + 'static> Iterator for PlaybackRateSource<S> {
type Item = f32;
fn next(&mut self) -> Option<Self::Item> {
if !is_effect_active(&self.atomics) {
if let Some(offload) = self.offload.as_mut() {
if let Some(s) = offload.pop() {
return Some(s);
}
}
return self.next_from_inner_or_pad();
}
if uses_preserve_dsp(self.atomics.load_strategy()) {
self.ensure_offload();
if let Some(s) = self.offload.as_mut().and_then(|o| o.pop()) {
return Some(s);
}
if self
.offload
.as_ref()
.is_some_and(|offload| !offload.is_done())
{
return Some(0.0);
}
return None;
}
// Varispeed: decoder must stay in `inner` (never in the preserve worker).
if self.offload.is_some() {
self.try_recover_inner_from_offload();
}
self.next_from_inner_or_pad()
}
}
impl<S: Source<Item = f32> + Send + 'static> Source for PlaybackRateSource<S> {
fn current_span_len(&self) -> Option<usize> {
self.inner.as_ref()?.current_span_len()
}
fn channels(&self) -> ChannelCount {
self.base_channels
}
fn sample_rate(&self) -> SampleRate {
if is_effect_active(&self.atomics) && self.atomics.load_strategy() == STRATEGY_VARISPEED {
let factor = self.atomics.load_speed().max(0.001);
SampleRate::new((self.base_sample_rate().get() as f32 * factor).max(1.0) as u32)
.unwrap_or(self.base_sample_rate)
} else {
self.base_sample_rate()
}
}
fn total_duration(&self) -> Option<Duration> {
self.inner.as_ref()?.total_duration()
}
fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> {
// UI / transport always pass content-timeline seconds (0..full track).
if let Some(inner) = self.inner.as_mut() {
inner.try_seek(pos)?;
}
if let Some(offload) = self.offload.as_mut() {
offload.request_seek(pos);
offload.drain();
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use pitch_shift::{Shifter, TOTAL_F32};
#[test]
fn passthrough_when_disabled() {
let a = PlaybackRateAtomics::new();
assert!(!is_effect_active(&a));
}
#[test]
fn passthrough_at_unity() {
let a = PlaybackRateAtomics::new();
a.enabled.store(true, Ordering::Relaxed);
assert!(!is_effect_active(&a));
}
#[test]
fn active_when_speed_not_one() {
let a = PlaybackRateAtomics::new();
a.enabled.store(true, Ordering::Relaxed);
a.speed.store(1.5f32.to_bits(), Ordering::Relaxed);
assert!(is_effect_active(&a));
}
#[test]
fn effective_duration_is_content_timeline() {
let a = PlaybackRateAtomics::new();
a.enabled.store(true, Ordering::Relaxed);
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
for strat in [
STRATEGY_VARISPEED,
STRATEGY_SPEED_CORRECTED,
STRATEGY_PRESERVE_PITCH,
] {
a.strategy.store(strat, Ordering::Relaxed);
assert!(
(effective_duration_secs(200.0, &a) - 200.0).abs() < 0.001,
"strategy {strat}"
);
}
}
#[test]
fn effective_position_varispeed_uses_counter() {
let a = PlaybackRateAtomics::new();
a.enabled.store(true, Ordering::Relaxed);
a
.strategy
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
assert!((effective_position_secs(20.0, &a) - 20.0).abs() < 0.001);
}
#[test]
fn effective_position_preserve_scales_with_speed() {
let a = PlaybackRateAtomics::new();
a.enabled.store(true, Ordering::Relaxed);
a
.strategy
.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
assert!((effective_position_secs(10.0, &a) - 20.0).abs() < 0.001);
}
#[test]
fn effective_position_inactive_is_raw() {
let a = PlaybackRateAtomics::new();
assert!((effective_position_secs(15.0, &a) - 15.0).abs() < 0.001);
}
#[test]
fn raw_counter_samples_roundtrip_content_timeline() {
let a = PlaybackRateAtomics::new();
a.enabled.store(true, Ordering::Relaxed);
a
.strategy
.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
let samples = raw_counter_samples_for_content_position(120.0, 44_100, 2, &a);
let back = content_position_from_samples(samples, 44_100, 2, &a);
assert!((back - 120.0).abs() < 0.05, "roundtrip at 2x preserve");
}
#[test]
fn raw_counter_samples_roundtrip_varispeed() {
let a = PlaybackRateAtomics::new();
a.enabled.store(true, Ordering::Relaxed);
a
.strategy
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
let samples = raw_counter_samples_for_content_position(90.0, 44_100, 2, &a);
let back = content_position_from_samples(samples, 44_100, 2, &a);
assert!((back - 90.0).abs() < 0.05, "roundtrip at 2x varispeed");
}
#[test]
fn varispeed_seek_uses_content_timeline() {
use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
use std::sync::Arc;
struct SeekSpy {
rate: SampleRate,
last_seek_secs: Arc<AtomicU64>,
remaining: usize,
}
impl Iterator for SeekSpy {
type Item = f32;
fn next(&mut self) -> Option<f32> {
if self.remaining == 0 {
return None;
}
self.remaining -= 1;
Some(0.0)
}
}
impl Source for SeekSpy {
fn current_span_len(&self) -> Option<usize> {
Some(self.remaining)
}
fn channels(&self) -> ChannelCount {
ChannelCount::new(1).unwrap()
}
fn sample_rate(&self) -> SampleRate {
self.rate
}
fn total_duration(&self) -> Option<Duration> {
Some(Duration::from_secs(200))
}
fn try_seek(&mut self, pos: Duration) -> Result<(), SeekError> {
self.last_seek_secs
.store(pos.as_secs_f64().to_bits(), AtomicOrdering::Relaxed);
Ok(())
}
}
let last = Arc::new(AtomicU64::new(f64::NAN.to_bits()));
let spy = SeekSpy {
rate: SampleRate::new(44_100).unwrap(),
last_seek_secs: last.clone(),
remaining: 44_100,
};
let a = PlaybackRateAtomics::new();
a.enabled.store(true, Ordering::Relaxed);
a
.strategy
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
a.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
let mut src = PlaybackRateSource::new(spy, a);
src.try_seek(Duration::from_secs(120)).unwrap();
let got = f64::from_bits(last.load(AtomicOrdering::Relaxed));
assert!(
(got - 120.0).abs() < 0.001,
"varispeed seek must not scale content position, got {got}"
);
}
#[test]
fn preserve_out_samples_clamped() {
assert_eq!(preserve_out_samples(2.0), 64);
assert_eq!(preserve_out_samples(0.5), 256);
}
struct FixedRateSource {
rate: u32,
remaining: usize,
}
impl Iterator for FixedRateSource {
type Item = f32;
fn next(&mut self) -> Option<f32> {
if self.remaining == 0 {
return None;
}
self.remaining -= 1;
Some(0.0)
}
}
impl Source for FixedRateSource {
fn current_span_len(&self) -> Option<usize> {
Some(self.remaining)
}
fn channels(&self) -> ChannelCount {
std::num::NonZero::new(1).unwrap()
}
fn sample_rate(&self) -> SampleRate {
SampleRate::new(self.rate).unwrap()
}
fn total_duration(&self) -> Option<Duration> {
Some(Duration::from_secs(1))
}
}
#[test]
fn speed_corrected_uses_preserve_dsp_path() {
let atomics = PlaybackRateAtomics::new();
atomics.enabled.store(true, Ordering::Relaxed);
atomics
.strategy
.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
atomics.speed.store(1.5f32.to_bits(), Ordering::Relaxed);
assert!(uses_preserve_dsp(atomics.load_strategy()));
assert!(is_effect_active(&atomics));
assert_eq!(effective_pitch(&atomics), 0.0);
}
#[test]
fn preserve_pitch_respects_manual_pitch() {
let atomics = PlaybackRateAtomics::new();
atomics.enabled.store(true, Ordering::Relaxed);
atomics
.strategy
.store(STRATEGY_PRESERVE_PITCH, Ordering::Relaxed);
atomics.pitch_semitones.store(3.0f32.to_bits(), Ordering::Relaxed);
assert!(is_effect_active(&atomics));
assert_eq!(effective_pitch(&atomics), 3.0);
}
#[test]
fn strategy_switch_preserve_to_varispeed_does_not_end_early() {
let atomics = PlaybackRateAtomics::new();
atomics.enabled.store(true, Ordering::Relaxed);
atomics
.strategy
.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
atomics.speed.store(1.5f32.to_bits(), Ordering::Relaxed);
let mut src = PlaybackRateSource::new(
FixedRateSource {
rate: 44_100,
remaining: 50_000,
},
atomics.clone(),
);
for _ in 0..5_000 {
assert!(src.next().is_some());
}
atomics
.strategy
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
let mut got = 0usize;
for _ in 0..2_000 {
if src.next().is_some() {
got += 1;
} else {
break;
}
}
assert!(
got > 100,
"varispeed should continue after preserve strategy switch, got {got} samples"
);
}
#[test]
fn varispeed_scales_reported_sample_rate() {
let atomics = PlaybackRateAtomics::new();
atomics.enabled.store(true, Ordering::Relaxed);
atomics
.strategy
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
atomics.speed.store(1.5f32.to_bits(), Ordering::Relaxed);
let src = PlaybackRateSource::new(
FixedRateSource {
rate: 44_100,
remaining: 1,
},
atomics,
);
assert_eq!(src.sample_rate().get(), 66_150);
}
#[test]
fn varispeed_propagates_through_dyn_source() {
use crate::sources::DynSource;
let atomics = PlaybackRateAtomics::new();
atomics.enabled.store(true, Ordering::Relaxed);
atomics
.strategy
.store(STRATEGY_VARISPEED, Ordering::Relaxed);
atomics.speed.store(2.0f32.to_bits(), Ordering::Relaxed);
let rate_src = PlaybackRateSource::new(
FixedRateSource {
rate: 48_000,
remaining: 1,
},
atomics,
);
let dyn_src = DynSource::new(rate_src);
assert_eq!(dyn_src.sample_rate().get(), 96_000);
}
fn rms_f32(samples: &[f32]) -> f32 {
if samples.is_empty() {
return 0.0;
}
(samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32).sqrt()
}
#[test]
fn preserve_pitch_makeup_keeps_level_reasonable() {
let sr = 44_100f32;
let mut input = [0.0f32; 128];
for (i, s) in input.iter_mut().enumerate() {
*s = (i as f32 * 0.12).sin() * 0.75;
}
let in_rms = rms_f32(&input);
let mut shifter: Shifter<Box<[f32; TOTAL_F32]>> =
Shifter::new(Box::new([0.0; TOTAL_F32]));
for _ in 0..24 {
shifter.shift(&input, 4.0, 128, sr);
}
let dry = shifter.shift(&input, 4.0, 128, sr);
let boosted: Vec<f32> = dry
.iter()
.map(|&s| (s * PRESERVE_MAKEUP_GAIN).clamp(-1.0, 1.0))
.collect();
let out_rms = rms_f32(&boosted);
assert!(out_rms > in_rms * 0.8, "out_rms={out_rms} in_rms={in_rms}");
assert!(out_rms < in_rms * 1.25, "out_rms={out_rms} in_rms={in_rms}");
}
#[test]
fn live_speed_change_represerves_content_position() {
let atomics = PlaybackRateAtomics::new();
atomics.enabled.store(true, Ordering::Relaxed);
atomics
.strategy
.store(STRATEGY_SPEED_CORRECTED, Ordering::Relaxed);
atomics.speed.store(1.5f32.to_bits(), Ordering::Relaxed);
let samples = raw_counter_samples_for_content_position(30.0, 44_100, 2, &atomics);
let content = content_position_from_samples(samples, 44_100, 2, &atomics);
assert!((content - 30.0).abs() < 0.05);
atomics.speed.store(1.8f32.to_bits(), Ordering::Relaxed);
let restamped =
raw_counter_samples_for_content_position(content, 44_100, 2, &atomics);
let after = content_position_from_samples(restamped, 44_100, 2, &atomics);
assert!((after - 30.0).abs() < 0.05);
}
}
@@ -3,65 +3,215 @@
//! (which constructs the gapless source chain) and `audio_play` (which
//! starts playback). All three live in this audio submodule.
use std::path::PathBuf;
use std::sync::atomic::Ordering;
use std::time::Duration;
use serde::Serialize;
use tauri::{AppHandle, Emitter, State};
use psysonic_analysis::analysis_runtime::AnalysisBackfillPriority;
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::helpers::{analysis_cache_track_id, same_playback_target};
use super::state::PreloadedTrack;
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct PreloadEventPayload {
url: String,
track_id: Option<String>,
}
async fn seed_preload_analysis_bytes(
app: &AppHandle,
state: &State<'_, AudioEngine>,
url: &str,
data: &[u8],
analysis_track_id: Option<&str>,
server_id: Option<&str>,
) {
let Some(track_id) = analysis_cache_track_id(analysis_track_id, url) else {
return;
};
let (sid, priority) = prepare_playback_analysis(
app,
state,
server_id,
&track_id,
Some(AnalysisBackfillPriority::Middle),
);
if let Err(e) = dispatch_track_analysis_bytes(
app,
TrackAnalysisOrigin::PrefetchOrCacheFile,
&sid,
&track_id,
data.to_vec(),
priority,
)
.await
{
crate::app_eprintln!("[analysis] preload seed failed for {track_id}: {e}");
}
}
fn seed_preload_analysis_file(
app: &AppHandle,
state: &State<'_, AudioEngine>,
url: &str,
file_path: PathBuf,
analysis_track_id: Option<&str>,
server_id: Option<&str>,
) {
let Some(track_id) = analysis_cache_track_id(analysis_track_id, url) else {
return;
};
let (sid, priority) = prepare_playback_analysis(
app,
state,
server_id,
&track_id,
Some(AnalysisBackfillPriority::Middle),
);
crate::app_deprintln!(
"[stream] audio_preload: local file analysis track_id={} path={}",
track_id,
file_path.display()
);
spawn_track_analysis_file(
app.clone(),
TrackAnalysisOrigin::LocalFilePlayback,
sid,
track_id,
file_path,
priority,
None,
);
}
fn emit_preload_ready(app: &AppHandle, url: String, track_id: Option<String>) {
let _ = app.emit(
"audio:preload-ready",
PreloadEventPayload {
url,
track_id,
},
);
}
fn emit_preload_cancelled(app: &AppHandle, url: String, track_id: Option<String>) {
let _ = app.emit(
"audio:preload-cancelled",
PreloadEventPayload {
url,
track_id,
},
);
}
#[tauri::command]
pub async fn audio_preload(
url: String,
duration_hint: f64,
analysis_track_id: Option<String>,
server_id: Option<String>,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
let logical_trim = analysis_track_id
.as_ref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let track_id_for_events = logical_trim.clone();
let is_local = url.starts_with("psysonic-local://");
// Hot/offline cache: playback reads from disk — seed analysis from the file
// (512 MiB cap) without copying into the RAM preload slot.
if is_local {
let path = PathBuf::from(url.strip_prefix("psysonic-local://").unwrap());
if !path.is_file() {
crate::app_deprintln!(
"[stream] audio_preload: local file missing path={}",
path.display()
);
emit_preload_cancelled(&app, url, track_id_for_events);
return Ok(());
}
seed_preload_analysis_file(
&app,
&state,
&url,
path,
logical_trim.as_deref(),
server_id.as_deref(),
);
emit_preload_ready(&app, url, track_id_for_events);
return Ok(());
}
// Remote URL — reuse in-memory bytes when a prior HTTP preload finished.
{
let preloaded = state.preloaded.lock().unwrap();
if preloaded.as_ref().is_some_and(|p| same_playback_target(&p.url, &url)) {
let _ = app.emit("audio:preload-ready", url.clone());
let cached = {
let preloaded = state.preloaded.lock().unwrap();
preloaded
.as_ref()
.filter(|p| same_playback_target(&p.url, &url))
.map(|p| p.data.clone())
};
if let Some(data) = cached {
if !data.is_empty() {
seed_preload_analysis_bytes(
&app,
&state,
&url,
&data,
logical_trim.as_deref(),
server_id.as_deref(),
)
.await;
}
return Ok(());
}
}
let _ = duration_hint; // kept in API for compatibility
// 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.
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(());
}
let data: Vec<u8> = if let Some(path) = url.strip_prefix("psysonic-local://") {
tokio::fs::read(path).await.map_err(|e| e.to_string())?
} else {
let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Ok(());
}
response.bytes().await.map_err(|e| e.to_string())?.into()
};
let _ = duration_hint; // kept in API for compatibility
let logical_trim = analysis_track_id
.as_ref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if let Some(track_id) = analysis_cache_track_id(logical_trim.as_deref(), &url) {
crate::app_deprintln!(
"[stream] audio_preload: bytes ready track_id={} size_mib={:.2} — invoking full-track analysis",
track_id,
data.len() as f64 / (1024.0 * 1024.0)
);
let high = crate::engine::analysis_track_id_is_current_playback(&state, &track_id);
if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), track_id.clone(), data.clone(), high).await {
crate::app_eprintln!("[analysis] preload seed failed for {}: {}", track_id, e);
}
let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
emit_preload_cancelled(&app, url, track_id_for_events);
return Ok(());
}
let data: Vec<u8> = response.bytes().await.map_err(|e| e.to_string())?.into();
if !data.is_empty() {
seed_preload_analysis_bytes(
&app,
&state,
&url,
&data,
logical_trim.as_deref(),
server_id.as_deref(),
)
.await;
}
let url_for_emit = url.clone();
*state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data });
let _ = app.emit("audio:preload-ready", url_for_emit);
emit_preload_ready(&app, url_for_emit, track_id_for_events);
Ok(())
}
@@ -0,0 +1,467 @@
//! Background worker for preserve-pitch DSP (phase vocoder is too heavy for cpal callback).
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, RecvTimeoutError, SyncSender};
use std::sync::Arc;
use std::thread::{self, JoinHandle};
use std::time::Duration;
use pitch_shift::{Shifter, TOTAL_F32};
use ringbuf::traits::{Consumer, Observer, Producer, Split};
use ringbuf::{HeapCons, HeapProd, HeapRb};
use rodio::Source;
use crate::playback_rate::{
effective_pitch, is_effect_active, preserve_out_samples, PlaybackRateAtomics, PRESERVE_MAKEUP_GAIN,
uses_preserve_dsp,
};
const FRAME_BLOCK: usize = 128;
const PRESERVE_OUT_MAX: usize = 1023;
const PRESERVE_PARAM_EPS_PITCH: f32 = 0.05;
const PRESERVE_PARAM_EPS_SPEED: f32 = 0.001;
const RB_MIN_CAPACITY: usize = 44_100 * 2 * 2; // ~2 s stereo @ 44.1 kHz
const RB_TARGET_FILL: f32 = 0.6;
const RB_FILL_HIGH: f32 = 0.88;
const FORWARD_BATCH: usize = 4096;
const WORKER_IDLE_SLEEP: Duration = Duration::from_millis(1);
enum WorkerCmd {
Seek(Duration),
Handback,
Shutdown,
}
struct PreserveWorkerEnv {
atomics: PlaybackRateAtomics,
sample_rate: u32,
channels: u16,
capacity: usize,
stop: Arc<AtomicBool>,
done: Arc<AtomicBool>,
cmd_rx: mpsc::Receiver<WorkerCmd>,
}
pub(crate) struct PreserveOffload {
cons: HeapCons<f32>,
stop: Arc<AtomicBool>,
done: Arc<AtomicBool>,
cmd_tx: SyncSender<WorkerCmd>,
thread: Option<JoinHandle<()>>,
}
impl PreserveOffload {
pub(crate) fn spawn<S: Source<Item = f32> + Send + 'static>(
inner: S,
atomics: PlaybackRateAtomics,
sample_rate: u32,
channels: u16,
handback_tx: SyncSender<S>,
) -> Self {
let cap = ((sample_rate as f32 * channels as f32 * 2.5) as usize).max(RB_MIN_CAPACITY);
let rb = HeapRb::<f32>::new(cap);
let (prod, cons) = rb.split();
let stop = Arc::new(AtomicBool::new(false));
let done = Arc::new(AtomicBool::new(false));
let (cmd_tx, cmd_rx) = mpsc::sync_channel::<WorkerCmd>(8);
let stop_worker = stop.clone();
let done_worker = done.clone();
let thread = thread::Builder::new()
.name("psysonic-preserve-pitch".into())
.spawn(move || {
worker_main(
inner,
prod,
PreserveWorkerEnv {
atomics,
sample_rate,
channels,
capacity: cap,
stop: stop_worker,
done: done_worker,
cmd_rx,
},
handback_tx,
);
})
.expect("spawn preserve-pitch worker");
Self {
cons,
stop,
done,
cmd_tx,
thread: Some(thread),
}
}
pub(crate) fn pop(&mut self) -> Option<f32> {
self.cons.try_pop()
}
pub(crate) fn is_done(&self) -> bool {
self.done.load(Ordering::Acquire)
}
pub(crate) fn request_seek(&self, pos: Duration) {
let _ = self.cmd_tx.send(WorkerCmd::Seek(pos));
}
pub(crate) fn request_handback(&self) {
let _ = self.cmd_tx.send(WorkerCmd::Handback);
}
pub(crate) fn drain(&mut self) {
while self.cons.try_pop().is_some() {}
}
pub(crate) fn join(mut self) {
self.stop.store(true, Ordering::Release);
let _ = self.cmd_tx.send(WorkerCmd::Shutdown);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
impl Drop for PreserveOffload {
fn drop(&mut self) {
self.stop.store(true, Ordering::Release);
let _ = self.cmd_tx.send(WorkerCmd::Shutdown);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
struct PreserveChannelState {
shifter: Shifter<Box<[f32; TOTAL_F32]>>,
frame: Vec<f32>,
}
impl PreserveChannelState {
fn new() -> Self {
Self {
shifter: Shifter::new(Box::new([0.0; TOTAL_F32])),
frame: Vec::with_capacity(FRAME_BLOCK),
}
}
fn reset(&mut self) {
self.shifter = Shifter::new(Box::new([0.0; TOTAL_F32]));
self.frame.clear();
}
fn reset_shifter(&mut self) {
self.shifter = Shifter::new(Box::new([0.0; TOTAL_F32]));
}
}
struct PreserveState {
channels: Vec<PreserveChannelState>,
pending: VecDeque<f32>,
channel_idx: usize,
last_pitch: f32,
last_speed: f32,
}
impl PreserveState {
fn for_channels(count: u16) -> Self {
let n = count.max(1) as usize;
Self {
channels: (0..n).map(|_| PreserveChannelState::new()).collect(),
pending: VecDeque::new(),
channel_idx: 0,
last_pitch: f32::NAN,
last_speed: f32::NAN,
}
}
fn reset(&mut self, channels: u16) {
let n = channels.max(1) as usize;
if self.channels.len() != n {
self.channels = (0..n).map(|_| PreserveChannelState::new()).collect();
} else {
for ch in &mut self.channels {
ch.reset();
}
}
self.pending.clear();
self.channel_idx = 0;
self.last_pitch = f32::NAN;
self.last_speed = f32::NAN;
}
fn reset_if_params_changed(&mut self, pitch: f32, speed: f32) {
if self.last_pitch.is_nan() {
self.last_pitch = pitch;
self.last_speed = speed;
return;
}
if (pitch - self.last_pitch).abs() > PRESERVE_PARAM_EPS_PITCH
|| (speed - self.last_speed).abs() > PRESERVE_PARAM_EPS_SPEED
{
for ch in &mut self.channels {
ch.reset_shifter();
}
self.pending.clear();
self.last_pitch = pitch;
self.last_speed = speed;
}
}
fn process_block(&mut self, speed: f32, pitch: f32, sample_rate: f32) {
self.reset_if_params_changed(pitch, speed);
let out_n = preserve_out_samples(speed).clamp(1, PRESERVE_OUT_MAX);
let ch_count = self.channels.len();
let mut outs: Vec<&[f32]> = Vec::with_capacity(ch_count);
for ch in &mut self.channels {
if ch.frame.len() == FRAME_BLOCK {
let out = ch.shifter.shift(&ch.frame, pitch, out_n, sample_rate);
outs.push(out);
ch.frame.clear();
}
}
if outs.len() != ch_count {
return;
}
for i in 0..out_n {
for out_slice in &outs {
if let Some(&sample) = out_slice.get(i) {
self.pending
.push_back((sample * PRESERVE_MAKEUP_GAIN).clamp(-1.0, 1.0));
}
}
}
}
}
fn ring_fill(prod: &HeapProd<f32>, capacity: usize) -> f32 {
1.0 - prod.vacant_len() as f32 / capacity as f32
}
fn push_pending(prod: &mut HeapProd<f32>, pending: &mut VecDeque<f32>, stop: &AtomicBool) {
while let Some(&s) = pending.front() {
if stop.load(Ordering::Acquire) {
return;
}
if prod.try_push(s).is_ok() {
pending.pop_front();
} else {
return;
}
}
}
fn forward_passthrough<S: Source<Item = f32>>(
inner: &mut S,
prod: &mut HeapProd<f32>,
capacity: usize,
stop: &AtomicBool,
) -> bool {
let target = (capacity as f32 * RB_TARGET_FILL) as usize;
let mut pushed = 0usize;
while prod.occupied_len() < target && pushed < FORWARD_BATCH {
if stop.load(Ordering::Acquire) {
return false;
}
let Some(s) = inner.next() else {
return false;
};
if prod.try_push(s).is_err() {
break;
}
pushed += 1;
}
true
}
fn worker_main<S: Source<Item = f32> + Send>(
mut inner: S,
mut prod: HeapProd<f32>,
env: PreserveWorkerEnv,
handback_tx: SyncSender<S>,
) {
let PreserveWorkerEnv {
atomics,
sample_rate,
channels,
capacity,
stop,
done,
cmd_rx,
} = env;
let ch_count = channels.max(1) as usize;
let mut preserve = PreserveState::for_channels(channels);
let sr = sample_rate as f32;
'run: while !stop.load(Ordering::Acquire) {
if let Ok(cmd) = cmd_rx.try_recv() {
match cmd {
WorkerCmd::Shutdown => break,
WorkerCmd::Handback => {
push_pending(&mut prod, &mut preserve.pending, &stop);
let _ = handback_tx.send(inner);
done.store(true, Ordering::Release);
return;
}
WorkerCmd::Seek(pos) => {
let _ = inner.try_seek(pos);
preserve.reset(channels);
}
}
}
let use_preserve = atomics.enabled.load(Ordering::Relaxed)
&& uses_preserve_dsp(atomics.load_strategy())
&& is_effect_active(&atomics);
if !use_preserve {
preserve.reset(channels);
push_pending(&mut prod, &mut preserve.pending, &stop);
let fill = ring_fill(&prod, capacity);
if fill >= RB_FILL_HIGH {
match cmd_rx.recv_timeout(WORKER_IDLE_SLEEP) {
Ok(WorkerCmd::Shutdown) => break 'run,
Ok(WorkerCmd::Handback) => {
push_pending(&mut prod, &mut preserve.pending, &stop);
let _ = handback_tx.send(inner);
done.store(true, Ordering::Release);
return;
}
Ok(WorkerCmd::Seek(pos)) => {
let _ = inner.try_seek(pos);
preserve.reset(channels);
}
Err(RecvTimeoutError::Timeout) => continue,
Err(RecvTimeoutError::Disconnected) => break 'run,
}
}
if !forward_passthrough(&mut inner, &mut prod, capacity, &stop) {
break;
}
continue;
}
let fill = ring_fill(&prod, capacity);
if fill >= RB_FILL_HIGH {
match cmd_rx.recv_timeout(WORKER_IDLE_SLEEP) {
Ok(WorkerCmd::Shutdown) => break 'run,
Ok(WorkerCmd::Handback) => {
push_pending(&mut prod, &mut preserve.pending, &stop);
let _ = handback_tx.send(inner);
done.store(true, Ordering::Release);
return;
}
Ok(WorkerCmd::Seek(pos)) => {
let _ = inner.try_seek(pos);
preserve.reset(channels);
}
Err(RecvTimeoutError::Timeout) => continue,
Err(RecvTimeoutError::Disconnected) => break 'run,
}
}
push_pending(&mut prod, &mut preserve.pending, &stop);
if !preserve.pending.is_empty() {
continue;
}
match inner.next() {
Some(s) => {
let ch = preserve.channel_idx;
preserve.channels[ch].frame.push(s);
preserve.channel_idx = (ch + 1) % ch_count;
if preserve
.channels
.iter()
.all(|c| c.frame.len() >= FRAME_BLOCK)
{
preserve.process_block(
atomics.load_speed(),
effective_pitch(&atomics),
sr,
);
}
}
None => break,
}
}
push_pending(&mut prod, &mut preserve.pending, &stop);
done.store(true, Ordering::Release);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::playback_rate::STRATEGY_PRESERVE_PITCH;
use rodio::{ChannelCount, SampleRate};
use std::time::Duration as StdDuration;
struct SineSource {
remaining: usize,
rate: u32,
}
impl Iterator for SineSource {
type Item = f32;
fn next(&mut self) -> Option<f32> {
if self.remaining == 0 {
return None;
}
self.remaining -= 1;
Some(0.25)
}
}
impl Source for SineSource {
fn current_span_len(&self) -> Option<usize> {
Some(self.remaining)
}
fn channels(&self) -> ChannelCount {
std::num::NonZero::new(2).unwrap()
}
fn sample_rate(&self) -> SampleRate {
SampleRate::new(self.rate).unwrap()
}
fn total_duration(&self) -> Option<StdDuration> {
Some(StdDuration::from_secs(1))
}
}
#[test]
fn worker_prefills_ring_before_done() {
let atomics = PlaybackRateAtomics::new();
atomics.enabled.store(true, Ordering::Relaxed);
atomics
.strategy
.store(STRATEGY_PRESERVE_PITCH, Ordering::Relaxed);
atomics.speed.store(1.25f32.to_bits(), Ordering::Relaxed);
let src = SineSource {
remaining: 44_100 * 2,
rate: 44_100,
};
let (tx, _rx) = mpsc::sync_channel(1);
let mut offload = PreserveOffload::spawn(src, atomics, 44_100, 2, tx);
std::thread::sleep(Duration::from_millis(150));
let mut got = 0usize;
for _ in 0..10_000 {
if let Some(s) = offload.pop() {
got += 1;
if got > 500 {
break;
}
let _ = s;
} else if offload.is_done() {
break;
} else {
std::thread::sleep(Duration::from_millis(1));
}
}
assert!(got > 500, "expected prefetched samples, got {got}");
}
}
+298 -44
View File
@@ -1,6 +1,6 @@
//! Short preview playback on a secondary sink (same output stream).
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use rodio::Player;
@@ -9,8 +9,17 @@ use tauri::{AppHandle, Emitter, State};
use super::decode::SizedDecoder;
use super::engine::{audio_http_client, AudioEngine};
use super::helpers::MASTER_HEADROOM;
use super::helpers::{
content_type_to_hint, format_hint_from_content_disposition, normalize_stream_suffix_for_hint,
resolve_playback_format_hint, sniff_stream_format_extension, STREAM_FORMAT_SNIFF_PROBE_BYTES,
MASTER_HEADROOM,
};
use super::play_input::url_format_hint;
use super::sources::PriorityBoostSource;
use super::stream::{
mp4_needs_tail_prefetch, ranged_download_task, wait_for_ranged_mp4_probe_ready,
RangedHttpSource, RangedMp4ProbeGate,
};
// ────────────────────────────────────────────────────────────────────────────
// Preview engine — secondary Sink on the same OutputStream, fed by Symphonia.
@@ -90,26 +99,246 @@ pub(crate) fn preview_resume_main(state: &AudioEngine) {
}
}
/// Format hint inferred from a Subsonic stream URL. The frontend always passes
/// a `format=flac` query param for `.opus` files (server transcodes); for
/// everything else we guess from the URL's `format=` value or fall back to None.
/// `format=` query param on Subsonic stream URLs (transcode targets).
pub(crate) fn preview_format_hint_from_url(url: &str) -> Option<String> {
url.split('?')
.nth(1)?
.split('&')
.find_map(|kv| {
let (k, v) = kv.split_once('=')?;
if k.eq_ignore_ascii_case("format") { Some(v.to_string()) } else { None }
if k.eq_ignore_ascii_case("format") {
Some(v.to_string())
} else {
None
}
})
}
/// Symphonia container hint for preview downloads — mirrors main playback:
/// Content-Type / Content-Disposition, URL tail, Subsonic suffix, magic-byte sniff.
pub(crate) fn resolve_preview_format_hint(
url: &str,
content_type: Option<&str>,
content_disposition: Option<&str>,
stream_suffix: Option<&str>,
bytes: &[u8],
) -> Option<String> {
let media_hint = content_type
.and_then(content_type_to_hint)
.or_else(|| {
content_disposition.and_then(format_hint_from_content_disposition)
});
let url_hint = preview_format_hint_from_url(url).or_else(|| url_format_hint(url));
resolve_playback_format_hint(
url_hint.as_deref(),
stream_suffix,
media_hint.as_deref(),
Some(bytes),
)
}
fn preview_http_client(state: &AudioEngine) -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.use_rustls_tls()
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.build()
.unwrap_or_else(|_| audio_http_client(state))
}
/// Open a preview decoder — ranged HTTP when the server supports it (starts
/// after ~384 KiB buffered), otherwise falls back to a full in-memory download.
async fn open_preview_decoder(
url: &str,
format_suffix: Option<&str>,
gen: u64,
state: &AudioEngine,
app: &AppHandle,
) -> Result<Option<SizedDecoder>, String> {
let preview_http = preview_http_client(state);
let response = preview_http
.get(url)
.send()
.await
.map_err(|e| format!("preview: connection failed: {e}"))?
.error_for_status()
.map_err(|e| format!("preview: HTTP {e}"))?;
let mut stream_hint = content_type_to_hint(
response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or(""),
)
.or_else(|| {
response
.headers()
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.and_then(format_hint_from_content_disposition)
})
.or_else(|| normalize_stream_suffix_for_hint(format_suffix))
.or_else(|| preview_format_hint_from_url(url))
.or_else(|| url_format_hint(url));
let supports_range = response
.headers()
.get(reqwest::header::ACCEPT_RANGES)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.to_ascii_lowercase().contains("bytes"));
let total_size = response.content_length();
if stream_hint.is_none() && supports_range {
if let Some(total_u64) = total_size.filter(|&t| t > 0) {
let last = total_u64
.saturating_sub(1)
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
if let Ok(pr) = preview_http
.get(url)
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
.send()
.await
{
let stat = pr.status();
let ok = stat == reqwest::StatusCode::PARTIAL_CONTENT
|| stat == reqwest::StatusCode::OK;
if ok {
if let Ok(bytes) = pr.bytes().await {
if !bytes.is_empty() {
stream_hint = sniff_stream_format_extension(&bytes).or(stream_hint);
}
}
}
}
}
}
if let (true, Some(total), true) = (supports_range, total_size, stream_hint.is_some()) {
if state.preview_gen.load(Ordering::SeqCst) != gen {
return Ok(None);
}
let total_usize = total as usize;
crate::app_deprintln!(
"[preview] ranged open — total={} KB, hint={:?}",
total_usize / 1024,
stream_hint
);
let buf = Arc::new(Mutex::new(vec![0u8; total_usize]));
let downloaded_to = Arc::new(AtomicUsize::new(0));
let done = Arc::new(AtomicBool::new(false));
let playback_armed = Arc::new(AtomicBool::new(false));
let tail_ready = Arc::new(AtomicBool::new(false));
let tail_filled_from = Arc::new(AtomicU64::new(0));
let tail_prefetch = mp4_needs_tail_prefetch(&[], stream_hint.as_deref());
let mp4_probe_gate = tail_prefetch.then(|| RangedMp4ProbeGate {
tail_ready: tail_ready.clone(),
buf: buf.clone(),
downloaded_to: downloaded_to.clone(),
gen_arc: state.preview_gen.clone(),
gen,
format_hint: stream_hint.clone(),
});
tokio::spawn(ranged_download_task(
gen,
state.preview_gen.clone(),
preview_http,
app.clone(),
0.0,
url.to_string(),
response,
buf.clone(),
downloaded_to.clone(),
done.clone(),
state.stream_completed_cache.clone(),
state.stream_completed_spill.clone(),
state.normalization_engine.clone(),
state.normalization_target_lufs.clone(),
state.loudness_pre_analysis_attenuation_db.clone(),
None,
None,
None,
playback_armed,
stream_hint.clone(),
tail_ready.clone(),
tail_filled_from.clone(),
));
if let Some(ref gate) = mp4_probe_gate {
wait_for_ranged_mp4_probe_ready(gate).await?;
if state.preview_gen.load(Ordering::SeqCst) != gen {
return Ok(None);
}
}
let reader = RangedHttpSource {
buf,
downloaded_to,
tail_ready,
tail_filled_from,
total_size: total,
pos: 0,
done,
gen_arc: state.preview_gen.clone(),
gen,
};
let hint = stream_hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream")
})
.await
.map_err(|e| format!("preview: decoder thread: {e}"))??;
return Ok(Some(decoder));
}
crate::app_deprintln!(
"[preview] buffered download — accept-ranges={}, content-length={:?}, hint={:?}",
supports_range,
total_size,
stream_hint
);
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let content_disposition = response
.headers()
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let bytes = response
.bytes()
.await
.map_err(|e| format!("preview: read body: {e}"))?
.to_vec();
if state.preview_gen.load(Ordering::SeqCst) != gen {
return Ok(None);
}
let hint = resolve_preview_format_hint(
url,
content_type.as_deref(),
content_disposition.as_deref(),
format_suffix,
&bytes,
);
let bytes_for_blocking = bytes;
let hint_for_blocking = hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false)
})
.await
.map_err(|e| format!("preview: decoder thread: {e}"))??;
Ok(Some(decoder))
}
#[tauri::command]
#[allow(clippy::too_many_arguments)] // Tauri IPC — args map 1:1 to the JS invoke payload.
pub async fn audio_preview_play(
id: String,
url: String,
start_sec: f64,
duration_sec: f64,
volume: f32,
format_suffix: Option<String>,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
@@ -134,48 +363,24 @@ pub async fn audio_preview_play(
preview_pause_main(&state);
}
// ── Download ─────────────────────────────────────────────────────────────
// Dedicated client with a generous timeout. The shared `audio_http_client`
// caps at 30 s, which aborts mid-download on multi-hundred-megabyte
// uncompressed files (e.g. 18-min Hi-Res WAV ~600 MB) — those need
// ~60120 s on a typical home LAN. The watchdog (30 s wall-clock) still
// bounds how long the preview plays once the bytes are in memory, so a
// long download just means a longer "loading" spinner before audio starts.
let preview_http = reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.use_rustls_tls()
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.build()
.unwrap_or_else(|_| audio_http_client(&state));
let bytes = preview_http
.get(&url)
.send()
.await
.map_err(|e| format!("preview: connection failed: {e}"))?
.error_for_status()
.map_err(|e| format!("preview: HTTP {e}"))?
.bytes()
.await
.map_err(|e| format!("preview: read body: {e}"))?
.to_vec();
// ── Open decoder (ranged stream when possible) ───────────────────────────
let decoder = match open_preview_decoder(
&url,
format_suffix.as_deref(),
gen,
&state,
&app,
)
.await?
{
Some(d) => d,
None => return Ok(()),
};
if state.preview_gen.load(Ordering::SeqCst) != gen {
// A newer preview started while we were downloading — bail.
return Ok(());
}
// ── Decode ───────────────────────────────────────────────────────────────
let hint = preview_format_hint_from_url(&url);
let bytes_for_blocking = bytes;
let hint_for_blocking = hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false)
})
.await
.map_err(|e| format!("preview: decoder thread: {e}"))??;
if state.preview_gen.load(Ordering::SeqCst) != gen { return Ok(()); }
// ── Build source pipeline ────────────────────────────────────────────────
// Seek FIRST on the bare decoder, THEN cap with take_duration. Capping
// before the seek made take_duration's wall-clock counter tick from
@@ -271,6 +476,55 @@ pub async fn audio_preview_play(
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_preview_format_hint_sniffs_flac_from_bytes() {
let hint = resolve_preview_format_hint(
"https://host/rest/stream.view?id=1",
None,
None,
None,
b"fLaC\x00\x00\x00\x22",
);
assert_eq!(hint.as_deref(), Some("flac"));
}
#[test]
fn resolve_preview_format_hint_prefers_content_type_over_sniff() {
let hint = resolve_preview_format_hint(
"https://host/rest/stream.view?id=1",
Some("audio/mpeg"),
None,
None,
b"fLaC\x00\x00\x00\x22",
);
assert_eq!(hint.as_deref(), Some("mp3"));
}
#[test]
fn resolve_preview_format_hint_uses_subsonic_suffix() {
let hint = resolve_preview_format_hint(
"https://host/rest/stream.view?id=1",
None,
None,
Some("flac"),
&[0x00, 0x01, 0x02, 0x03],
);
assert_eq!(hint.as_deref(), Some("flac"));
}
#[test]
fn preview_format_hint_from_url_reads_format_query_param() {
assert_eq!(
preview_format_hint_from_url("https://h/stream.view?format=opus&id=x"),
Some("opus".into())
);
}
}
#[tauri::command]
pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) {
preview_stop_inner(&app, &state, true);
@@ -12,6 +12,7 @@ use tauri::{AppHandle, Emitter, Runtime};
use super::engine::AudioCurrent;
use super::helpers::{ramp_sink_volume, ProgressPayload, MASTER_HEADROOM};
use super::playback_rate::{effective_duration_secs, effective_position_secs, PlaybackRateAtomics};
use super::state::ChainedInfo;
/// Sink for the three progress events the task emits. Production wraps an
@@ -62,12 +63,14 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
crossfade_secs_arc: Arc<AtomicU32>,
initial_done: Arc<AtomicBool>,
emitter: E,
analysis_app: Option<AppHandle>,
samples_played: Arc<AtomicU64>,
sample_rate_arc: Arc<AtomicU32>,
channels_arc: Arc<AtomicU32>,
gapless_switch_at: Arc<AtomicU64>,
current_playback_url: Arc<Mutex<Option<String>>>,
stream_playback_armed: Arc<AtomicBool>,
playback_rate: PlaybackRateAtomics,
) {
// Keep progress aligned with audible output (ALSA/PipeWire/Pulse queue) on
// Linux; mirrors the quantum policy used for stream open/reopen plus a small
@@ -132,6 +135,10 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
let chained = chained_arc.lock().unwrap().take();
if let Some(info) = chained {
if let Some(app) = analysis_app.clone() {
crate::analysis_dispatch::spawn_gapless_transition_analysis(&app, &info);
}
// Swap to the chained source's done flag.
current_done = info.source_done;
@@ -196,10 +203,11 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
// Read playback snapshot under a single lock to minimize contention
// with seek/play/pause commands that also touch `current`.
let (dur, paused_at) = {
let (base_dur, paused_at) = {
let cur = current_arc.lock().unwrap();
(cur.duration_secs, cur.paused_at)
};
let dur = effective_duration_secs(base_dur, &playback_rate);
let is_paused = paused_at.is_some();
let pos_raw = if !stream_playback_armed.load(Ordering::Relaxed) {
@@ -207,7 +215,8 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
} else if let Some(p) = paused_at {
p
} else {
(samples / divisor).min(dur.max(0.001))
effective_position_secs(samples / divisor, &playback_rate)
.min(dur.max(0.001))
};
let progress_latency = if is_paused {
0.0
@@ -333,6 +342,7 @@ mod tests {
gapless_switch_at: Arc<AtomicU64>,
playback_url: Arc<Mutex<Option<String>>>,
stream_playback_armed: Arc<AtomicBool>,
playback_rate: PlaybackRateAtomics,
}
impl TaskHarness {
@@ -362,6 +372,7 @@ mod tests {
gapless_switch_at: Arc::new(AtomicU64::new(0)),
playback_url: Arc::new(Mutex::new(None)),
stream_playback_armed: Arc::new(AtomicBool::new(true)),
playback_rate: PlaybackRateAtomics::new(),
}
}
@@ -375,12 +386,14 @@ mod tests {
self.crossfade_secs.clone(),
self.done.clone(),
emitter,
None,
self.samples_played.clone(),
self.sample_rate.clone(),
self.channels.clone(),
self.gapless_switch_at.clone(),
self.playback_url.clone(),
self.stream_playback_armed.clone(),
self.playback_rate.clone(),
);
}
}
@@ -511,6 +524,8 @@ mod tests {
let chained_samples = Arc::new(AtomicU64::new(0));
*h.chained.lock().unwrap() = Some(ChainedInfo {
url: chain_url.clone(),
analysis_track_id: None,
server_id: None,
raw_bytes: Arc::new(Vec::new()),
duration_secs: 200.0,
replay_gain_linear: 1.0,
@@ -11,6 +11,7 @@ use rodio::{Player, Source};
use tauri::{AppHandle, Emitter, State};
use super::decode::SizedDecoder;
use super::playback_rate::PlaybackRateAtomics;
use super::engine::{audio_http_client, AudioEngine};
use super::helpers::{content_type_to_hint, MASTER_HEADROOM};
use super::progress_task::spawn_progress_task;
@@ -184,12 +185,14 @@ pub async fn audio_play_radio(
state.crossfade_secs.clone(),
done_flag,
app,
None,
state.samples_played.clone(),
state.current_sample_rate.clone(),
state.current_channels.clone(),
state.gapless_switch_at.clone(),
state.current_playback_url.clone(),
state.stream_playback_armed.clone(),
PlaybackRateAtomics::default(),
);
Ok(())
+10 -11
View File
@@ -14,7 +14,6 @@ const EQ_CHECK_INTERVAL: usize = 1024;
pub(crate) struct EqSource<S: Source<Item = f32>> {
inner: S,
sample_rate: rodio::SampleRate,
channels: rodio::ChannelCount,
gains: Arc<[AtomicU32; 10]>,
enabled: Arc<AtomicBool>,
@@ -47,7 +46,7 @@ impl<S: Source<Item = f32>> EqSource<S> {
})
});
Self {
inner, sample_rate, channels, gains, enabled, pre_gain,
inner, channels, gains, enabled, pre_gain,
filters,
current_gains: [0.0; 10],
sample_counter: 0,
@@ -57,14 +56,15 @@ impl<S: Source<Item = f32>> EqSource<S> {
#[allow(clippy::needless_range_loop)]
fn refresh_if_needed(&mut self) {
let sample_rate = self.inner.sample_rate();
for band in 0..10 {
let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed));
if (gain_db - self.current_gains[band]).abs() > 0.01 {
self.current_gains[band] = gain_db;
let freq = EQ_BANDS_HZ[band].clamp(20.0, (self.sample_rate.get() as f32 / 2.0) - 100.0);
let freq = EQ_BANDS_HZ[band].clamp(20.0, (sample_rate.get() as f32 / 2.0) - 100.0);
if let Ok(coeffs) = Coefficients::<f32>::from_params(
FilterType::PeakingEQ(gain_db),
(self.sample_rate.get() as f32).hz(),
(sample_rate.get() as f32).hz(),
freq.hz(),
EQ_Q,
) {
@@ -109,19 +109,20 @@ impl<S: Source<Item = f32>> Iterator for EqSource<S> {
impl<S: Source<Item = f32>> Source for EqSource<S> {
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
fn channels(&self) -> rodio::ChannelCount { self.channels }
fn sample_rate(&self) -> rodio::SampleRate { self.sample_rate }
fn sample_rate(&self) -> rodio::SampleRate { self.inner.sample_rate() }
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
#[allow(clippy::needless_range_loop)]
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
let sample_rate = self.inner.sample_rate();
// Reset biquad filter state to avoid glitches after seek.
for band in 0..10 {
let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed));
self.current_gains[band] = gain_db;
let freq = EQ_BANDS_HZ[band].clamp(20.0, (self.sample_rate.get() as f32 / 2.0) - 100.0);
let freq = EQ_BANDS_HZ[band].clamp(20.0, (sample_rate.get() as f32 / 2.0) - 100.0);
if let Ok(coeffs) = Coefficients::<f32>::from_params(
FilterType::PeakingEQ(gain_db),
(self.sample_rate.get() as f32).hz(),
(sample_rate.get() as f32).hz(),
freq.hz(),
EQ_Q,
) {
@@ -144,14 +145,12 @@ impl<S: Source<Item = f32>> Source for EqSource<S> {
pub(crate) struct DynSource {
inner: Box<dyn Source<Item = f32> + Send>,
channels: rodio::ChannelCount,
sample_rate: rodio::SampleRate,
}
impl DynSource {
pub(crate) fn new(src: impl Source<Item = f32> + Send + 'static) -> Self {
let channels = src.channels();
let sample_rate = src.sample_rate();
Self { inner: Box::new(src), channels, sample_rate }
Self { inner: Box::new(src), channels }
}
}
@@ -163,7 +162,7 @@ impl Iterator for DynSource {
impl Source for DynSource {
fn current_span_len(&self) -> Option<usize> { self.inner.current_span_len() }
fn channels(&self) -> rodio::ChannelCount { self.channels }
fn sample_rate(&self) -> rodio::SampleRate { self.sample_rate }
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> {
self.inner.try_seek(pos)
@@ -18,6 +18,10 @@ pub(crate) struct StreamCompletedSpill {
pub(crate) struct ChainedInfo {
/// The URL that was chained — used by audio_play to detect a pre-chain hit.
pub(crate) url: String,
/// Subsonic track id for analysis dispatch (from `audio_chain_preload`).
pub(crate) analysis_track_id: Option<String>,
/// Playback server scope for analysis writes.
pub(crate) server_id: Option<String>,
/// Raw file bytes (shared with the chained decoder). Lets manual skip reuse
/// them instead of re-downloading after dropping the Sink queue.
pub(crate) raw_bytes: Arc<Vec<u8>>,
@@ -26,9 +26,11 @@ use super::{
RADIO_YIELD_MS, TRACK_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_RECONNECTS,
TRACK_STREAM_PROMOTE_MAX_BYTES,
};
use crate::helpers::{
install_stream_completed_spill, spawn_analysis_seed_from_spill_file, write_stream_spill_file,
use crate::analysis_dispatch::{
dispatch_track_analysis_bytes, analysis_priority_for_app, resolve_server_id_for_app,
spawn_track_analysis_file, TrackAnalysisOrigin,
};
use crate::helpers::{install_stream_completed_spill, write_stream_spill_file};
use crate::state::StreamCompletedSpill;
/// Clears `AudioEngine::ranged_loudness_seed_hold` only if it still matches this play.
@@ -460,6 +462,8 @@ pub(crate) async fn ranged_download_task(
normalization_target_lufs: Arc<AtomicU32>,
loudness_pre_analysis_attenuation_db: Arc<AtomicU32>,
cache_track_id: Option<String>,
// Playback server scope for the analysis-cache write key (empty/`None` → legacy '').
server_id: Option<String>,
// 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>,
@@ -642,9 +646,19 @@ pub(crate) async fn ranged_download_task(
);
}
if let Some(track_id) = cache_track_id {
let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id);
if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), track_id.clone(), data.clone(), high).await {
crate::app_eprintln!("[analysis] ranged seed failed for {}: {}", track_id, e);
let sid = resolve_server_id_for_app(&app, server_id.as_deref());
let priority = analysis_priority_for_app(&app, &sid, &track_id, None);
if let Err(e) = dispatch_track_analysis_bytes(
&app,
TrackAnalysisOrigin::StreamDownloadComplete,
&sid,
&track_id,
data.clone(),
priority,
)
.await
{
crate::app_eprintln!("[analysis] ranged seed failed for {track_id}: {e}");
}
}
if gen_arc.load(Ordering::SeqCst) != gen {
@@ -676,12 +690,16 @@ pub(crate) async fn ranged_download_task(
return;
}
install_stream_completed_spill(&spill_cache_slot, url, path.clone());
spawn_analysis_seed_from_spill_file(
&app,
&track_id,
let sid = resolve_server_id_for_app(&app, server_id.as_deref());
let priority = analysis_priority_for_app(&app, &sid, &track_id, None);
spawn_track_analysis_file(
app.clone(),
TrackAnalysisOrigin::StreamSpillFile,
sid,
track_id,
path,
gen,
&gen_arc,
priority,
Some((gen, gen_arc.clone())),
);
}
Err(e) => {
@@ -35,6 +35,8 @@ pub(crate) async fn track_download_task(
normalization_target_lufs: Arc<AtomicU32>,
loudness_pre_analysis_attenuation_db: Arc<AtomicU32>,
cache_track_id: Option<String>,
// Playback server scope for the analysis-cache write key (empty/`None` → legacy '').
server_id: Option<String>,
playback_armed: Arc<AtomicBool>,
) {
let mut downloaded: u64 = 0;
@@ -165,11 +167,22 @@ pub(crate) async fn track_download_task(
track_id,
capture.len() as f64 / (1024.0 * 1024.0)
);
let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id);
if let Err(e) =
psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), track_id.clone(), capture.clone(), high).await
let sid = crate::analysis_dispatch::resolve_server_id_for_app(
&app,
server_id.as_deref(),
);
let priority = crate::analysis_dispatch::analysis_priority_for_app(&app, &sid, &track_id, None);
if let Err(e) = crate::analysis_dispatch::dispatch_track_analysis_bytes(
&app,
crate::analysis_dispatch::TrackAnalysisOrigin::StreamDownloadComplete,
&sid,
&track_id,
capture.clone(),
priority,
)
.await
{
crate::app_eprintln!("[analysis] track seed failed for {}: {}", track_id, e);
crate::app_eprintln!("[analysis] track seed failed for {track_id}: {e}");
}
}
if gen_arc.load(Ordering::SeqCst) != gen {
@@ -11,6 +11,9 @@ use ringbuf::HeapRb;
use tauri::{AppHandle, State};
use super::engine::{audio_http_client, AudioEngine};
use super::playback_rate::{
content_position_from_samples, raw_counter_samples_for_content_position,
};
use super::preview::preview_clear_for_new_main_playback;
use super::stream::{radio_download_task, RADIO_BUF_CAPACITY};
@@ -19,9 +22,15 @@ pub fn audio_pause(state: State<'_, AudioEngine>) {
let mut cur = state.current.lock().unwrap();
if let Some(sink) = &cur.sink {
if !sink.is_paused() {
let pos = cur.position();
let pos = content_position_from_samples(
state.samples_played.load(Ordering::Relaxed),
state.current_sample_rate.load(Ordering::Relaxed),
state.current_channels.load(Ordering::Relaxed),
&state.playback_rate,
)
.min(cur.duration_secs.max(0.001));
sink.pause();
cur.paused_at = Some(pos);
cur.paused_at = Some(pos);
cur.play_started = None;
}
}
@@ -108,6 +117,7 @@ pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) {
state.generation.fetch_add(1, Ordering::SeqCst);
*state.current_playback_url.lock().unwrap() = None;
*state.current_analysis_track_id.lock().unwrap() = None;
*state.current_playback_server_id.lock().unwrap() = None;
*state.chained_info.lock().unwrap() = None;
// Keep `stream_completed_cache`: natural track end often calls `audio_stop` when the
// queue is exhausted; clearing here dropped the full ranged buffer and forced a
@@ -124,6 +134,7 @@ pub fn audio_stop(state: State<'_, AudioEngine>, app: AppHandle) {
#[tauri::command]
pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> {
let state = state.inner();
const AUDIO_SEEK_TIMEOUT_MS: u64 = 700;
const AUDIO_SEEK_LOCK_TIMEOUT_MS: u64 = 40;
// Ghost-command guard: reject seeks within 500 ms of a gapless auto-advance.
@@ -168,10 +179,12 @@ pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), Str
};
// Seeking back invalidates any pending gapless chain.
let cur_pos = {
let cur = lock_current_with_timeout(AUDIO_SEEK_LOCK_TIMEOUT_MS)?;
cur.position()
};
let cur_pos = content_position_from_samples(
state.samples_played.load(Ordering::Relaxed),
state.current_sample_rate.load(Ordering::Relaxed),
state.current_channels.load(Ordering::Relaxed),
&state.playback_rate,
);
if seconds < cur_pos - 1.0 {
*state.chained_info.lock().unwrap() = None;
}
@@ -218,5 +231,14 @@ pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), Str
cur.seek_offset = seek_seconds;
cur.play_started = Some(Instant::now());
}
state.samples_played.store(
raw_counter_samples_for_content_position(
seek_seconds,
state.current_sample_rate.load(Ordering::Relaxed),
state.current_channels.load(Ordering::Relaxed),
&state.playback_rate,
),
Ordering::Relaxed,
);
Ok(())
}
@@ -3,6 +3,7 @@ name = "psysonic-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -0,0 +1,301 @@
//! Cover disk cache layout — **single place** to change directory naming.
//!
//! Callers pass `cache_kind` (`album` | `artist`) and `cache_entity_id` (server ids:
//! Navidrome `album.id` is often a bare hash/snowflake; `coverArt` may use `al-*`.
//! Rarely `mf-*` / `dc-*` on disk when UI enables per-disc art. Path shape:
//!
//! `{root}/{server_segment}/{kind}/{entity_id}/128.webp`
//!
//! `server_segment` is derived from the frontend's `serverIndexKeyFromUrl` (host + path,
//! no scheme). On Windows that key would otherwise drop a `:` straight into the filesystem
//! whenever the user runs Navidrome on a `:port` URL — `CreateDirectory` then rejects the
//! whole path with `ERROR_INVALID_NAME`. [`cover_server_dir`] sanitizes the key before it
//! hits disk; every caller that wants a server-scoped cover directory goes through it.
//!
//! Bump [`LAYOUT_STAMP`] when the on-disk format changes (app wipes legacy dirs on startup).
use std::path::{Path, PathBuf};
/// Written to `{cover_root}/.storage-layout` — mismatch triggers cache reset.
pub const LAYOUT_STAMP: &str = "canonical-segment-v4";
/// True for ids that are only valid as `getCoverArt` targets, not library entity keys.
pub fn is_fetch_only_cover_id(id: &str) -> bool {
let id = id.trim();
id.starts_with("mf-")
|| id.starts_with("tr-")
|| id.starts_with("pl-")
|| id.starts_with("dc-")
|| id.starts_with("ra-")
}
/// Windows reserved device names (case-insensitive) — invalid as path components.
const WINDOWS_RESERVED_NAMES: &[&str] = &[
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
/// Sanitize a single path segment for Windows / Unix (Navidrome ids are usually already safe).
/// Also used for media layout artist/album/title segments from server metadata.
pub fn sanitize_path_segment(segment: &str) -> String {
const FORBIDDEN: &[char] = &['\\', '/', ':', '*', '?', '"', '<', '>', '|'];
let trimmed = segment.trim().trim_end_matches(['.', ' ']).to_string();
if trimmed.is_empty() {
return "_".to_string();
}
let cleaned: String = trimmed
.chars()
.map(|c| {
if c.is_control() || FORBIDDEN.contains(&c) {
'_'
} else {
c
}
})
.collect();
if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
return "_".to_string();
}
let upper = cleaned.to_ascii_uppercase();
if WINDOWS_RESERVED_NAMES.contains(&upper.as_str()) {
return format!("_{cleaned}");
}
cleaned
}
/// Relative path under `{root}/{server_segment}/` — change format here only.
pub fn cover_entity_relative_dir(cache_kind: &str, cache_entity_id: &str) -> PathBuf {
let kind = sanitize_path_segment(cache_kind);
let entity = sanitize_path_segment(cache_entity_id);
PathBuf::from(kind).join(entity)
}
/// Per-server cache root (`{root}/{server_segment}/`). Sanitizes the index key so
/// `host:port` and embedded URL paths survive on Windows. Every caller that wants the
/// server bucket — list/count/clear/backfill — must go through this helper.
pub fn cover_server_dir(root: &Path, server_index_key: &str) -> PathBuf {
root.join(sanitize_path_segment(server_index_key))
}
/// Absolute directory for one cover entity (`…/album/al-…/` or `…/artist/ar-…/`).
pub fn cover_dir(
root: &Path,
server_index_key: &str,
cache_kind: &str,
cache_entity_id: &str,
) -> PathBuf {
cover_server_dir(root, server_index_key)
.join(cover_entity_relative_dir(cache_kind, cache_entity_id))
}
/// Resolved cover identity — keep in sync with TS `src/cover/resolveEntry.ts`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoverEntry {
pub cache_kind: &'static str,
pub cache_entity_id: String,
pub fetch_cover_art_id: String,
}
/// Album — one disk slot per album; per-disc ids only when `distinct_disc_covers`.
pub fn resolve_album_cover(
album_id: &str,
cover_art_id: Option<&str>,
distinct_disc_covers: bool,
) -> Option<CoverEntry> {
let album = album_id.trim();
if album.is_empty() {
return None;
}
let fetch = cover_art_id
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(album);
let cache_entity_id = if distinct_disc_covers && fetch != album {
fetch.to_string()
} else {
album.to_string()
};
Some(CoverEntry {
cache_kind: "album",
cache_entity_id,
fetch_cover_art_id: fetch.to_string(),
})
}
/// Segment roots under `{server_index_key}/` (canonical layout).
pub const SEGMENT_KINDS: [&str; 2] = ["album", "artist"];
/// Progress / backfill “done” heuristic — matches `LIBRARY_COVER_CANONICAL_TIER` in the library crate.
pub const CANONICAL_PROGRESS_TIER: u32 = 800;
fn tier_webp_ready(path: &Path) -> bool {
path.is_file() && path.metadata().map(|m| m.len() > 0).unwrap_or(false)
}
/// True when `{entity_dir}/{CANONICAL_PROGRESS_TIER}.webp` exists and is non-empty.
pub fn entity_dir_has_canonical_tier(entity_dir: &Path) -> bool {
tier_webp_ready(&entity_dir.join(format!("{CANONICAL_PROGRESS_TIER}.webp")))
}
/// Distinct album/artist entity dirs with canonical tier (segment layout only).
pub fn count_entities_with_canonical_tier(server_dir: &Path) -> i64 {
let mut n = 0i64;
for kind in SEGMENT_KINDS {
let kind_dir = server_dir.join(kind);
let Ok(entries) = std::fs::read_dir(&kind_dir) else {
continue;
};
for ent in entries.flatten() {
if ent.path().is_dir() && entity_dir_has_canonical_tier(&ent.path()) {
n += 1;
}
}
}
n
}
fn sum_webp_bytes_rec(dir: &Path) -> u64 {
let mut bytes = 0u64;
let Ok(entries) = std::fs::read_dir(dir) else {
return bytes;
};
for ent in entries.flatten() {
let p = ent.path();
if p.is_dir() {
bytes += sum_webp_bytes_rec(&p);
} else if p.extension().and_then(|s| s.to_str()) == Some("webp") {
if let Ok(meta) = ent.metadata() {
bytes += meta.len();
}
}
}
bytes
}
/// All `.webp` bytes under one server bucket + entity count (canonical tier, segment dirs).
pub fn server_cover_disk_usage(server_dir: &Path) -> (u64, u64) {
(
sum_webp_bytes_rec(server_dir),
count_entities_with_canonical_tier(server_dir) as u64,
)
}
/// Sum usage across every server subdirectory under `cover_root`.
pub fn cover_root_disk_usage(cover_root: &Path) -> (u64, u64) {
let mut bytes = 0u64;
let mut count = 0u64;
let Ok(entries) = std::fs::read_dir(cover_root) else {
return (0, 0);
};
for ent in entries.flatten() {
let fname = ent.file_name();
let name = fname.to_string_lossy();
if name == ".storage-layout" || !ent.path().is_dir() {
continue;
}
let (b, c) = server_cover_disk_usage(&ent.path());
bytes += b;
count += c;
}
(bytes, count)
}
/// Artist — one disk slot per artist id.
pub fn resolve_artist_cover(artist_id: &str, cover_art_id: Option<&str>) -> Option<CoverEntry> {
let artist = artist_id.trim();
if artist.is_empty() {
return None;
}
let fetch = cover_art_id
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(artist);
Some(CoverEntry {
cache_kind: "artist",
cache_entity_id: artist.to_string(),
fetch_cover_art_id: fetch.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layout_paths_use_kind_and_entity_id() {
let root = Path::new("/tmp/cover");
let dir = cover_dir(root, "srv", "album", "al-1");
assert_eq!(dir, root.join("srv").join("album").join("al-1"));
}
#[test]
fn server_segment_sanitizes_port_colon_and_url_path() {
let root = Path::new("/tmp/cover");
// Typical LAN URL key from `serverIndexKeyFromUrl`: `host:port/path`.
// The `:` is invalid on Windows; the `/` would otherwise create a
// nested directory rather than one bucket per server.
let dir = cover_server_dir(root, "192.168.1.10:4533/music");
assert_eq!(dir, root.join("192.168.1.10_4533_music"));
}
#[test]
fn cover_dir_passes_server_key_through_sanitizer() {
let root = Path::new("/tmp/cover");
let dir = cover_dir(root, "host:4533", "album", "al-1");
assert_eq!(dir, root.join("host_4533").join("album").join("al-1"));
}
#[test]
fn album_and_artist_segments_differ() {
let al = cover_entity_relative_dir("album", "al-1");
let ar = cover_entity_relative_dir("artist", "ar-1");
assert_ne!(al, ar);
}
#[test]
fn per_disc_mf_entity_gets_own_dir() {
let d = cover_entity_relative_dir("album", "mf-disc2_abc");
assert_eq!(d, PathBuf::from("album").join("mf-disc2_abc"));
}
#[test]
fn resolve_album_bare_navidrome_id() {
let e = resolve_album_cover("0DurV2S7arIOBQVEknOPWX", Some("al-0Dur_abc"), false).unwrap();
assert_eq!(e.cache_entity_id, "0DurV2S7arIOBQVEknOPWX");
assert_eq!(e.fetch_cover_art_id, "al-0Dur_abc");
}
#[test]
fn resolve_album_per_disc_changes_cache_entity() {
let e = resolve_album_cover("al-box", Some("mf-d2"), true).unwrap();
assert_eq!(e.cache_entity_id, "mf-d2");
}
fn test_server_dir(label: &str) -> std::path::PathBuf {
let base = std::env::temp_dir().join(format!("psysonic-cover-layout-{label}"));
let _ = std::fs::remove_dir_all(&base);
base
}
#[test]
fn sanitize_rejects_dot_dot_and_reserved_names() {
assert_eq!(sanitize_path_segment(".."), "_");
assert_eq!(sanitize_path_segment("CON"), "_CON");
assert_eq!(sanitize_path_segment(" trailing. "), "trailing");
}
#[test]
fn segment_disk_usage_counts_canonical_only() {
let server = test_server_dir("usage");
let entity = server.join("album").join("al-1");
std::fs::create_dir_all(&entity).unwrap();
std::fs::write(entity.join("128.webp"), b"x").unwrap();
assert_eq!(count_entities_with_canonical_tier(&server), 0);
std::fs::write(entity.join("800.webp"), b"yy").unwrap();
assert_eq!(count_entities_with_canonical_tier(&server), 1);
let (bytes, count) = server_cover_disk_usage(&server);
assert_eq!(count, 1);
assert!(bytes >= 3);
let _ = std::fs::remove_dir_all(&server);
}
}
@@ -4,6 +4,10 @@
//! macros) and the cross-crate port traits used to break dependency cycles
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
pub mod cover_cache_layout;
pub mod media_layout;
pub mod logging;
pub mod ports;
pub mod track_analysis;
pub mod track_enrichment;
pub mod user_agent;
+71 -9
View File
@@ -8,7 +8,7 @@
use std::collections::VecDeque;
use std::io::Write;
use std::sync::{Mutex, OnceLock};
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
@@ -21,8 +21,31 @@ pub enum LoggingMode {
static LOGGING_MODE: AtomicU8 = AtomicU8::new(LoggingMode::Normal as u8);
const LOG_BUFFER_MAX_LINES: usize = 20_000;
fn log_buffer() -> &'static Mutex<VecDeque<String>> {
static LOG_BUFFER: OnceLock<Mutex<VecDeque<String>>> = OnceLock::new();
/// Monotonic sequence assigned to each appended line; lets the UI tail
/// incrementally (request only lines newer than the last seq it has seen).
static LOG_SEQ: AtomicU64 = AtomicU64::new(0);
/// A single buffered log line plus its monotonic sequence number.
#[derive(Clone, Debug)]
pub struct LogLine {
pub seq: u64,
pub text: String,
}
/// Result of an incremental tail request.
#[derive(Clone, Debug, Default)]
pub struct LogTail {
pub lines: Vec<LogLine>,
/// Sequence to pass back on the next request (highest seq known, even if no
/// new lines were returned).
pub last_seq: u64,
/// True when the caller's `after_seq` predates the retained window, i.e. some
/// lines were dropped from the ring buffer before they could be delivered.
pub dropped: bool,
}
fn log_buffer() -> &'static Mutex<VecDeque<LogLine>> {
static LOG_BUFFER: OnceLock<Mutex<VecDeque<LogLine>>> = OnceLock::new();
LOG_BUFFER.get_or_init(|| Mutex::new(VecDeque::with_capacity(LOG_BUFFER_MAX_LINES)))
}
@@ -52,6 +75,15 @@ pub fn set_logging_mode_from_str(mode: &str) -> Result<(), String> {
Ok(())
}
/// Current logging mode as a stable lowercase string for the UI.
pub fn current_mode_str() -> &'static str {
match current_mode() {
LoggingMode::Off => "off",
LoggingMode::Normal => "normal",
LoggingMode::Debug => "debug",
}
}
fn current_mode() -> LoggingMode {
match LOGGING_MODE.load(Ordering::Acquire) {
0 => LoggingMode::Off,
@@ -69,12 +101,14 @@ pub fn should_log_debug() -> bool {
}
pub fn append_log_line(line: String) {
let mut buf = log_buffer().lock().unwrap();
if buf.len() >= LOG_BUFFER_MAX_LINES {
buf.pop_front();
let seq = LOG_SEQ.fetch_add(1, Ordering::Relaxed) + 1;
{
let mut buf = log_buffer().lock().unwrap();
if buf.len() >= LOG_BUFFER_MAX_LINES {
buf.pop_front();
}
buf.push_back(LogLine { seq, text: line.clone() });
}
buf.push_back(line.clone());
drop(buf);
let path = cli_log_channel_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
@@ -84,13 +118,41 @@ pub fn append_log_line(line: String) {
}
}
/// Return retained log lines with `seq > after_seq`, capped to `max` (most
/// recent kept). Pass `after_seq = None` to fetch the latest `max` lines.
pub fn tail_logs(after_seq: Option<u64>, max: usize) -> LogTail {
let max = max.clamp(1, LOG_BUFFER_MAX_LINES);
let buf = log_buffer().lock().unwrap();
let last_seq = buf.back().map(|l| l.seq).unwrap_or(0);
let earliest_seq = buf.front().map(|l| l.seq).unwrap_or(0);
let after = after_seq.unwrap_or(0);
// A gap occurred if the caller already saw `after` lines but the buffer no
// longer holds the line right after it (it scrolled out of the window).
let dropped = after_seq.is_some()
&& after > 0
&& earliest_seq > 0
&& after + 1 < earliest_seq;
let mut lines: Vec<LogLine> = buf
.iter()
.filter(|l| l.seq > after)
.cloned()
.collect();
if lines.len() > max {
lines.drain(0..lines.len() - max);
}
LogTail { lines, last_seq, dropped }
}
pub fn export_logs_to_file(path: &str) -> Result<usize, String> {
let snapshot = {
let buf = log_buffer().lock().unwrap();
if buf.is_empty() {
String::new()
} else {
let mut s = buf.iter().cloned().collect::<Vec<_>>().join("\n");
let mut s = buf.iter().map(|l| l.text.clone()).collect::<Vec<_>>().join("\n");
s.push('\n');
s
}
@@ -0,0 +1,364 @@
//! Local playback disk layout — artist/album/track paths from library-index fields.
//!
//! Mirrors the contract in `implementation-spec.md` (local playback unification).
//! `server_segment` uses [`cover_cache_layout::sanitize_path_segment`] on the URL
//! index key; artist/album/filename segments are derived from track metadata only.
use std::path::{Component, Path, PathBuf};
use crate::cover_cache_layout::sanitize_path_segment;
/// Max length for a single path component after sanitization (Windows budget).
pub const MAX_SEGMENT_LEN: usize = 120;
/// Inputs required to build hierarchical media paths (library index row projection).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrackPathInput {
pub artist: Option<String>,
pub album_artist: Option<String>,
pub album: String,
pub title: String,
pub track_number: Option<i64>,
pub disc_number: Option<i64>,
pub suffix: Option<String>,
/// When set, used to detect compilation albums from `raw_json` (OpenSubsonic).
pub raw_json: Option<String>,
}
/// Tier subdirectory under the media root (`cache/`, `library/`, or `favorites/`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocalTier {
Ephemeral,
Library,
/// Auto-synced starred favorites — separate from user-pinned `library/`.
Favorites,
}
impl LocalTier {
pub fn subdir(self) -> &'static str {
match self {
Self::Ephemeral => "cache",
Self::Library => "library",
Self::Favorites => "favorites",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"ephemeral" | "cache" => Some(Self::Ephemeral),
"library" => Some(Self::Library),
"favorites" | "favorite-auto" | "favorite_auto" => Some(Self::Favorites),
_ => None,
}
}
}
/// Stable fingerprint for invalidation when library metadata changes (§8 spec).
pub fn layout_fingerprint(input: &TrackPathInput) -> String {
let artist_seg = artist_folder_segment(input);
let album_seg = album_folder_segment(&input.album);
let stem = track_filename_stem(input);
let suffix = input
.suffix
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("");
let track_n = input.track_number.unwrap_or(0);
let disc_n = input.disc_number.unwrap_or(0);
format!(
"artist={artist_seg}|album_artist={}|album={album_seg}|title={}|track={track_n}|disc={disc_n}|stem={stem}|suffix={suffix}",
input
.album_artist
.as_deref()
.map(str::trim)
.unwrap_or(""),
input.title.trim(),
)
}
/// Relative path under `{tier}/{server_segment}/`: `{artist}/{album}/{file}.{suffix}`.
pub fn relative_path_for_track(
server_index_key: &str,
input: &TrackPathInput,
suffix: &str,
) -> PathBuf {
let server_segment = sanitize_path_segment(server_index_key);
let artist = artist_folder_segment(input);
let album = album_folder_segment(&input.album);
let stem = track_filename_stem(input);
let ext = suffix.trim().trim_start_matches('.');
let filename = if ext.is_empty() {
sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN)
} else {
format!(
"{}.{}",
sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN),
sanitize_path_segment(ext)
)
};
PathBuf::from(server_segment)
.join(artist)
.join(album)
.join(filename)
}
/// Absolute file path: `{media_root}/{tier}/…relative_path…`.
pub fn absolute_track_path(
media_root: &Path,
tier: LocalTier,
server_index_key: &str,
input: &TrackPathInput,
suffix: &str,
) -> PathBuf {
media_root
.join(tier.subdir())
.join(relative_path_for_track(server_index_key, input, suffix))
}
/// Defense-in-depth: resolved paths must stay under `{media_root}/{tier}/`.
pub fn ensure_track_path_within_tier(
media_root: &Path,
tier: LocalTier,
absolute: &Path,
) -> Result<(), String> {
let tier_root = media_root.join(tier.subdir());
let Ok(rel) = absolute.strip_prefix(&tier_root) else {
return Err(format!(
"path `{}` escapes tier root `{}`",
absolute.display(),
tier_root.display()
));
};
for comp in rel.components() {
if matches!(comp, Component::ParentDir | Component::RootDir | Component::Prefix(_)) {
return Err(format!(
"path `{}` contains forbidden component `{comp:?}`",
absolute.display()
));
}
}
Ok(())
}
fn artist_folder_segment(input: &TrackPathInput) -> String {
let artist = input.artist.as_deref().map(str::trim).unwrap_or("");
let album_artist = input.album_artist.as_deref().map(str::trim).unwrap_or("");
let chosen = if artist.is_empty() || track_is_compilation(input) {
if !album_artist.is_empty() {
album_artist
} else {
"Various Artists"
}
} else {
artist
};
sanitize_and_truncate_segment(chosen, MAX_SEGMENT_LEN)
}
fn album_folder_segment(album: &str) -> String {
let trimmed = album.trim();
let fallback = if trimmed.is_empty() { "Unknown Album" } else { trimmed };
sanitize_and_truncate_segment(fallback, MAX_SEGMENT_LEN)
}
fn track_filename_stem(input: &TrackPathInput) -> String {
let title = input.title.trim();
let title = if title.is_empty() { "Unknown Title" } else { title };
let track_n = input.track_number.unwrap_or(0).max(0) as u32;
let disc_n = input.disc_number.unwrap_or(1).max(0) as u32;
if disc_n > 1 {
format!("{disc_n:02}-{track_n:02} - {title}")
} else {
format!("{track_n:02} - {title}")
}
}
fn track_is_compilation(input: &TrackPathInput) -> bool {
if various_artists_label(input.artist.as_deref().unwrap_or("")) {
return true;
}
let Some(raw) = input.raw_json.as_deref().filter(|s| !s.is_empty()) else {
return false;
};
raw_json_marks_compilation(raw)
}
/// Best-effort probe aligned with `album_compilation_filter::compilation_raw_json_sql`.
fn raw_json_marks_compilation(raw: &str) -> bool {
let lower = raw.to_ascii_lowercase();
lower.contains("\"iscompilation\":true")
|| lower.contains("\"iscompilation\": true")
|| lower.contains("\"compilation\":true")
|| lower.contains("\"compilation\": true")
|| lower.contains("\"compilation\":1")
|| lower.contains("\"releaseTypes\"") && lower.contains("compilation")
}
fn various_artists_label(s: &str) -> bool {
let lower = s.trim().to_ascii_lowercase();
lower.contains("various artists")
}
fn sanitize_and_truncate_segment(segment: &str, max_len: usize) -> String {
let sanitized = sanitize_path_segment(segment);
// Code points — keep in sync with `[...sanitized].length` in `mediaLayout.ts`.
if sanitized.chars().count() <= max_len {
return sanitized;
}
let hash = short_hash(segment);
let keep = max_len.saturating_sub(1 + hash.len());
let mut out = sanitized.chars().take(keep).collect::<String>();
out.push('_');
out.push_str(&hash);
out
}
/// Keep in sync with `shortHash` in `src/utils/media/mediaLayout.ts` (UTF-16 code units).
fn short_hash(s: &str) -> String {
let mut h: u32 = 0;
for unit in s.encode_utf16() {
h = h.wrapping_mul(31).wrapping_add(unit as u32);
}
format!("{:08x}", h)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_input() -> TrackPathInput {
TrackPathInput {
artist: Some("Radiohead".to_string()),
album_artist: None,
album: "OK Computer".to_string(),
title: "Paranoid Android".to_string(),
track_number: Some(6),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
}
}
#[test]
fn relative_path_uses_library_segments() {
let rel = relative_path_for_track("host:4533", &sample_input(), "mp3");
assert_eq!(
rel,
PathBuf::from("host_4533")
.join("Radiohead")
.join("OK Computer")
.join("06 - Paranoid Android.mp3")
);
}
#[test]
fn multi_disc_adds_disc_prefix() {
let mut input = sample_input();
input.disc_number = Some(2);
let rel = relative_path_for_track("srv", &input, "flac");
assert!(rel
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("02-06 - Paranoid Android.flac")));
}
#[test]
fn compilation_uses_album_artist_folder() {
let input = TrackPathInput {
artist: Some("Various Artists".to_string()),
album_artist: Some("Original Soundtrack".to_string()),
album: "Film Score".to_string(),
title: "Main Theme".to_string(),
track_number: Some(1),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
};
let rel = relative_path_for_track("srv", &input, "mp3");
assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Original Soundtrack"));
}
#[test]
fn empty_artist_falls_back_to_various_artists() {
let input = TrackPathInput {
artist: None,
album_artist: None,
album: "Comp".to_string(),
title: "Song".to_string(),
track_number: Some(1),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
};
let rel = relative_path_for_track("srv", &input, "mp3");
assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Various Artists"));
}
#[test]
fn layout_fingerprint_is_stable() {
let a = layout_fingerprint(&sample_input());
let b = layout_fingerprint(&sample_input());
assert_eq!(a, b);
assert!(a.contains("Radiohead"));
assert!(a.contains("OK Computer"));
}
#[test]
fn tier_subdirs_are_fixed() {
assert_eq!(LocalTier::Ephemeral.subdir(), "cache");
assert_eq!(LocalTier::Library.subdir(), "library");
assert_eq!(LocalTier::Favorites.subdir(), "favorites");
assert_eq!(LocalTier::parse("ephemeral"), Some(LocalTier::Ephemeral));
assert_eq!(LocalTier::parse("library"), Some(LocalTier::Library));
assert_eq!(LocalTier::parse("favorite-auto"), Some(LocalTier::Favorites));
}
#[test]
fn absolute_path_includes_tier() {
let root = Path::new("/media");
let path = absolute_track_path(root, LocalTier::Library, "srv", &sample_input(), "mp3");
assert!(path.starts_with(root.join("library")));
}
#[test]
fn dot_dot_metadata_does_not_escape_tier_root() {
let input = TrackPathInput {
artist: Some("..".to_string()),
album_artist: None,
album: "..".to_string(),
title: "Song".to_string(),
track_number: Some(1),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
};
let root = Path::new("/media");
let path = absolute_track_path(root, LocalTier::Library, "srv", &input, "mp3");
assert!(path.starts_with(root.join("library")));
ensure_track_path_within_tier(root, LocalTier::Library, &path).unwrap();
}
#[test]
fn short_hash_matches_ts_imul31_utf16() {
// "Radiohead" — same as mediaLayout.test parity anchor.
assert_eq!(short_hash("Radiohead"), "3da68c3b");
}
#[test]
fn sanitize_and_truncate_uses_code_point_threshold() {
let cyrillic_a = '\u{0430}';
let hundred: String = std::iter::repeat_n(cyrillic_a, 100).collect();
assert!(hundred.len() > MAX_SEGMENT_LEN);
assert_eq!(hundred.chars().count(), 100);
assert_eq!(
sanitize_and_truncate_segment(&hundred, MAX_SEGMENT_LEN),
hundred
);
let long: String = std::iter::repeat_n(cyrillic_a, 130).collect();
let truncated = sanitize_and_truncate_segment(&long, MAX_SEGMENT_LEN);
assert!(truncated.ends_with("_eef20600"));
assert_eq!(truncated.chars().count(), MAX_SEGMENT_LEN);
}
}
@@ -49,3 +49,88 @@ impl PlaybackQueryHandle {
(self.should_defer_backfill)(track_id)
}
}
/// Bridge for the analysis→library back-edge (E2 content_hash): when the
/// analysis pipeline has the playback-derived `md5_16kb` for a track, it records
/// it as `track.content_hash` in the library DB. `psysonic-analysis` must not
/// depend on `psysonic-library`, so the shell crate registers a closure that
/// captures an `AppHandle` and patches the library; analysis looks this handle
/// up via `try_state::<…>()` and fires it after a successful seed.
///
/// The patch is a no-op when the library has no row for `(server_id, track_id)`
/// (index off for that server), so the sink is safe to call unconditionally.
type RecordContentHashFn = Arc<dyn Fn(&str, &str, &str) + Send + Sync + 'static>;
#[derive(Clone)]
pub struct ContentHashSink {
record: RecordContentHashFn,
}
impl ContentHashSink {
pub fn new<F>(record: F) -> Self
where
F: Fn(&str, &str, &str) + Send + Sync + 'static,
{
Self { record: Arc::new(record) }
}
/// Record `md5_16kb` as the library `content_hash` for `(server_id, track_id)`.
/// Best-effort: the registered closure swallows errors and no-ops when the
/// library has no matching row.
pub fn record_content_hash(&self, server_id: &str, track_id: &str, md5_16kb: &str) {
(self.record)(server_id, track_id, md5_16kb)
}
}
/// Library→analysis readiness probe (E3 enrichment): given `(server_id,
/// track_id, md5_16kb)`, returns `(waveform_ready, loudness_ready)` from the
/// analysis cache. `psysonic-library` must not depend on `psysonic-analysis`, so
/// the shell crate registers a closure that captures an `AppHandle`, looks up the
/// `AnalysisCache`, and probes the exact key with a legacy `''` fallback —
/// **read-only, no lazy re-tag**. Library looks this handle up via
/// `try_state::<…>()`; absent handle ⇒ `(false, false)`.
type QueryReadinessFn = Arc<dyn Fn(&str, &str, &str) -> (bool, bool) + Send + Sync + 'static>;
#[derive(Clone)]
pub struct AnalysisReadinessQuery {
query: QueryReadinessFn,
}
impl AnalysisReadinessQuery {
pub fn new<F>(query: F) -> Self
where
F: Fn(&str, &str, &str) -> (bool, bool) + Send + Sync + 'static,
{
Self { query: Arc::new(query) }
}
/// `(waveform_ready, loudness_ready)` for `(server_id, track_id, md5_16kb)`.
pub fn readiness(&self, server_id: &str, track_id: &str, md5_16kb: &str) -> (bool, bool) {
(self.query)(server_id, track_id, md5_16kb)
}
}
type NeedsWorkFn = Arc<dyn Fn(&str, &str) -> Result<bool, String> + Send + Sync + 'static>;
/// Library→analysis plan probe: does `(server_id, track_id)` still need waveform,
/// loudness, or enrichment work? Wired in the shell crate so `psysonic-library`
/// can batch-scan without depending on `psysonic-analysis`.
#[derive(Clone)]
pub struct TrackAnalysisNeedsWorkQuery {
query: NeedsWorkFn,
}
impl TrackAnalysisNeedsWorkQuery {
pub fn new<F>(query: F) -> Self
where
F: Fn(&str, &str) -> Result<bool, String> + Send + Sync + 'static,
{
Self {
query: Arc::new(query),
}
}
pub fn needs_work(&self, server_id: &str, track_id: &str) -> Result<bool, String> {
(self.query)(server_id, track_id)
}
}
@@ -0,0 +1,34 @@
//! Unified client-side track analysis plan (waveform / LUFS / enrichment facts).
//!
//! Planning logic lives in `psysonic-analysis::track_analysis_plan`; this module
//! holds the shared outcome type so future analysis modes can extend the plan
//! without pulling analysis-cache types into every crate.
use crate::track_enrichment::TrackEnrichmentPlan;
/// What still needs to be computed for a track at the current content fingerprint.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct TrackAnalysisPlan {
/// Waveform bins missing for `(server_id, track_id)` at the current algo version.
pub need_waveform: bool,
/// Integrated LUFS / true-peak row missing.
pub need_loudness: bool,
/// Oximedia BPM + mood facts (`track_fact` via library enrichment port).
pub enrichment: TrackEnrichmentPlan,
}
impl TrackAnalysisPlan {
pub fn any(self) -> bool {
self.need_waveform || self.need_loudness || self.enrichment.any()
}
/// Symphonia full-file decode (waveform and/or EBU R128 loudness).
pub fn needs_full_cpu_seed(self) -> bool {
self.need_waveform || self.need_loudness
}
/// Oximedia 60 s center window only — waveform + loudness already cached.
pub fn needs_enrichment_only(self) -> bool {
!self.needs_full_cpu_seed() && self.enrichment.any()
}
}
@@ -0,0 +1,95 @@
//! Shared types for client-side track enrichment (oximedia BPM / mood).
/// Which analysis facts still need to be computed for the current content hash.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct TrackEnrichmentPlan {
pub need_bpm: bool,
pub need_valence: bool,
pub need_arousal: bool,
/// Raw oximedia mood scores JSON (`{"calm":0.4,...}`).
pub need_moods: bool,
}
impl TrackEnrichmentPlan {
pub fn any(self) -> bool {
self.need_bpm || self.need_valence || self.need_arousal || self.need_moods
}
}
#[derive(Debug, Clone, Copy)]
pub struct TrackEnrichmentIntFact {
pub value: i64,
pub confidence: f32,
}
#[derive(Debug, Clone, Copy)]
pub struct TrackEnrichmentRealFact {
pub value: f64,
pub confidence: f32,
}
/// Facts produced by oximedia for persistence via the library port.
#[derive(Debug, Clone, Default)]
pub struct TrackEnrichmentFacts {
pub bpm: Option<TrackEnrichmentIntFact>,
pub valence: Option<TrackEnrichmentRealFact>,
pub arousal: Option<TrackEnrichmentRealFact>,
/// Oximedia `MoodResult.moods` serialized as JSON object (label → score).
pub moods: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrackEnrichmentOutcome {
Applied,
/// Nothing to compute for the current content hash.
SkippedComplete,
/// Oximedia analysis or persistence failed; facts were not stored (retry on next seed).
Failed,
SkippedNoServer,
SkippedNoPort,
}
type PlanFn = std::sync::Arc<
dyn Fn(&str, &str, &str) -> TrackEnrichmentPlan + Send + Sync + 'static,
>;
type StoreFn = std::sync::Arc<
dyn Fn(&str, &str, &str, &TrackEnrichmentFacts) -> Result<(), String> + Send + Sync + 'static,
>;
/// Library↔analysis port: plan missing facts and store computed results without
/// pulling `psysonic-library` into `psysonic-analysis`.
#[derive(Clone)]
pub struct TrackEnrichmentPort {
plan: PlanFn,
store: StoreFn,
}
impl TrackEnrichmentPort {
pub fn new<P, S>(plan: P, store: S) -> Self
where
P: Fn(&str, &str, &str) -> TrackEnrichmentPlan + Send + Sync + 'static,
S: Fn(&str, &str, &str, &TrackEnrichmentFacts) -> Result<(), String>
+ Send
+ Sync
+ 'static,
{
Self {
plan: std::sync::Arc::new(plan),
store: std::sync::Arc::new(store),
}
}
pub fn plan(&self, server_id: &str, track_id: &str, content_hash: &str) -> TrackEnrichmentPlan {
(self.plan)(server_id, track_id, content_hash)
}
pub fn store(
&self,
server_id: &str,
track_id: &str,
content_hash: &str,
facts: &TrackEnrichmentFacts,
) -> Result<(), String> {
(self.store)(server_id, track_id, content_hash, facts)
}
}
@@ -3,6 +3,7 @@ name = "psysonic-integration"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -12,7 +13,7 @@ tauri = { version = "2" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt", "time", "sync"] }
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "rustls", "blocking", "gzip", "brotli"] }
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "multipart", "query", "form", "rustls", "blocking", "gzip", "brotli"] }
futures-util = "0.3"
discord-rich-presence = "1.1"
url = "2"
@@ -262,12 +262,13 @@ fn apply_template(template: &str, title: &str, artist: &str, album: Option<&str>
/// Bundled output of [`compute_discord_text_fields`].
pub(crate) struct DiscordTextFields {
pub name: String,
pub details: String,
pub state: String,
pub large_text: String,
}
/// Pure helper: resolve all three configurable Discord text fields, applying
/// Pure helper: resolve all four configurable Discord text fields, applying
/// the supplied templates (or falling back to documented defaults).
pub(crate) fn compute_discord_text_fields(
title: &str,
@@ -276,7 +277,9 @@ pub(crate) fn compute_discord_text_fields(
details_template: Option<&str>,
state_template: Option<&str>,
large_text_template: Option<&str>,
name_template: Option<&str>,
) -> DiscordTextFields {
let name = apply_template(name_template.unwrap_or("{title}"), title, artist, album);
let details = apply_template(
details_template.unwrap_or("{artist} - {title}"),
title,
@@ -291,6 +294,7 @@ pub(crate) fn compute_discord_text_fields(
album,
);
DiscordTextFields {
name,
details,
state,
large_text,
@@ -318,6 +322,10 @@ pub(crate) fn compute_discord_start_timestamp(elapsed_secs: f64, now_unix_secs:
/// Supported placeholders: {title}, {artist}, {album}
/// - `large_text_template`: template string for the large image tooltip. Default: "{album}".
/// Supported placeholders: {title}, {artist}, {album}
/// - `name_template`: template string overriding Discord's default application name in the
/// user list (e.g. "🎵 Bohemian Rhapsody" instead of "🎵 Psysonic"). Default: "{title}".
/// Empty string falls back to the registered Discord application name.
/// Supported placeholders: {title}, {artist}, {album}
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn discord_update_presence(
@@ -332,6 +340,7 @@ pub async fn discord_update_presence(
details_template: Option<String>,
state_template: Option<String>,
large_text_template: Option<String>,
name_template: Option<String>,
) -> Result<(), String> {
// Resolve artwork on a dedicated blocking thread — reqwest::blocking must not
// run on the Tokio async executor directly.
@@ -377,6 +386,7 @@ pub async fn discord_update_presence(
details_template.as_deref(),
state_template.as_deref(),
large_text_template.as_deref(),
name_template.as_deref(),
);
let assets = if let Some(ref url) = artwork_url {
@@ -402,8 +412,11 @@ pub async fn discord_update_presence(
}
// Only reach here when playing
let activity = Activity::new()
.activity_type(ActivityType::Listening)
let mut activity = Activity::new().activity_type(ActivityType::Listening);
if !texts.name.is_empty() {
activity = activity.name(texts.name.as_str());
}
let activity = activity
.details(&texts.details)
.state(&texts.state)
.assets(assets)
@@ -545,7 +558,9 @@ mod tests {
#[test]
fn text_fields_use_documented_defaults_when_templates_are_none() {
let f = compute_discord_text_fields("Song", "Artist", Some("Album"), None, None, None);
let f =
compute_discord_text_fields("Song", "Artist", Some("Album"), None, None, None, None);
assert_eq!(f.name, "Song");
assert_eq!(f.details, "Artist - Song");
assert_eq!(f.state, "Album");
assert_eq!(f.large_text, "Album");
@@ -560,7 +575,9 @@ mod tests {
Some("{title} | {album}"),
Some("by {artist}"),
Some("{album} ({artist})"),
Some("{title} ({artist})"),
);
assert_eq!(f.name, "Song (Artist)");
assert_eq!(f.details, "Song | Album");
assert_eq!(f.state, "by Artist");
assert_eq!(f.large_text, "Album (Artist)");
@@ -568,8 +585,9 @@ mod tests {
#[test]
fn text_fields_substitute_empty_for_missing_album() {
let f = compute_discord_text_fields("Song", "Artist", None, None, None, None);
let f = compute_discord_text_fields("Song", "Artist", None, None, None, None, None);
// {album} placeholder → empty, but the surrounding template stays.
assert_eq!(f.name, "Song");
assert_eq!(f.details, "Artist - Song");
assert_eq!(f.state, "");
assert_eq!(f.large_text, "");
@@ -584,7 +602,9 @@ mod tests {
Some("{artist} {title}"),
None,
None,
None,
);
assert_eq!(f.name, "Bohemian Rhapsody");
assert_eq!(f.details, "Queen Bohemian Rhapsody");
}
@@ -4,6 +4,7 @@
//! Domains:
//! - `discord` — Discord Rich Presence (album artwork via iTunes)
//! - `navidrome` — Navidrome's native REST API (admin: users/playlists/covers/queries)
//! - `subsonic` — Subsonic REST surface for the library-sync engine
//! - `remote` — radio-browser, last.fm, ICY-meta probe, generic CORS proxy
//! - `bandsintown` — bandsintown events for an artist
@@ -15,3 +16,4 @@ pub mod bandsintown;
pub mod discord;
pub mod navidrome;
pub mod remote;
pub mod subsonic;
@@ -7,5 +7,8 @@
mod client;
pub mod covers;
pub mod playlists;
pub mod probe;
pub mod queries;
pub mod users;
pub use client::navidrome_token;
@@ -0,0 +1,116 @@
//! Navidrome-side probes for the library-sync capability detection.
//!
//! Lives next to `client.rs` / `queries.rs` so the existing native-REST
//! auth shape (`Authorization: Bearer …`) is reused. PR-3a only needs
//! one probe — does the server expose the paginated `/api/song` bulk
//! 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};
/// 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
/// disabled) and 5xx surfaces as `Err`. The body is intentionally not
/// inspected — empty libraries still respond with `[]` and a 200.
///
/// 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> {
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 status = resp.status();
if status.is_success() {
return Ok(true);
}
if status.is_client_error() {
// 401/403/404 — endpoint genuinely unavailable for this token /
// build. Treat as "no native bulk" and fall back to Subsonic.
return Ok(false);
}
Err(format!("HTTP {status}"))
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{header, method as wm_method, path as wm_path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test(flavor = "multi_thread")]
async fn native_bulk_available_returns_true_on_200() {
let server = MockServer::start().await;
Mock::given(wm_method("GET"))
.and(wm_path("/api/song"))
.and(query_param("_start", "0"))
.and(query_param("_end", "1"))
.and(header("X-ND-Authorization", "Bearer tok-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.mount(&server)
.await;
let ok = native_bulk_available(&server.uri(), "tok-123").await.unwrap();
assert!(ok);
}
#[tokio::test(flavor = "multi_thread")]
async fn native_bulk_available_returns_false_on_404() {
// Server is reachable, auth might be ok, but the endpoint just
// doesn't exist (older Navidrome, mod_rewrite mishap, …).
let server = MockServer::start().await;
Mock::given(wm_method("GET"))
.and(wm_path("/api/song"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let ok = native_bulk_available(&server.uri(), "tok").await.unwrap();
assert!(!ok);
}
#[tokio::test(flavor = "multi_thread")]
async fn native_bulk_available_returns_false_on_401_auth_failure() {
let server = MockServer::start().await;
Mock::given(wm_method("GET"))
.and(wm_path("/api/song"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let ok = native_bulk_available(&server.uri(), "bad").await.unwrap();
assert!(!ok, "401 reads as `endpoint not available for this caller`");
}
#[tokio::test(flavor = "multi_thread")]
async fn native_bulk_available_surfaces_5xx_as_error() {
let server = MockServer::start().await;
Mock::given(wm_method("GET"))
.and(wm_path("/api/song"))
.respond_with(ResponseTemplate::new(503))
.mount(&server)
.await;
let err = native_bulk_available(&server.uri(), "tok").await.unwrap_err();
assert!(err.contains("503"));
}
#[tokio::test(flavor = "multi_thread")]
async fn native_bulk_available_strips_trailing_slash() {
let server = MockServer::start().await;
Mock::given(wm_method("GET"))
.and(wm_path("/api/song"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.mount(&server)
.await;
let with_slash = format!("{}/", server.uri());
assert!(native_bulk_available(&with_slash, "tok").await.unwrap());
}
}
@@ -4,14 +4,15 @@
use super::client::{navidrome_token, nd_err, nd_http_client, nd_retry};
/// GET `/api/song?_sort=...&_order=...&_start=...&_end=...` — paginated song list.
/// Available to any authenticated user (no admin required). Returns raw JSON array.
#[tauri::command]
pub async fn nd_list_songs(
server_url: String,
token: String,
sort: String,
order: String,
/// 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.
pub async fn nd_list_songs_internal(
server_url: &str,
token: &str,
sort: &str,
order: &str,
start: u32,
end: u32,
) -> Result<serde_json::Value, String> {
@@ -22,7 +23,7 @@ pub async fn nd_list_songs(
let resp = nd_retry(|| {
nd_http_client()
.get(&url)
.header("X-ND-Authorization", format!("Bearer {}", token))
.header("X-ND-Authorization", format!("Bearer {token}"))
.send()
}).await?;
if !resp.status().is_success() {
@@ -31,6 +32,20 @@ pub async fn nd_list_songs(
resp.json::<serde_json::Value>().await.map_err(nd_err)
}
/// Tauri-visible variant — owned-String arguments to keep the IPC
/// surface unchanged for existing call sites in the WebView.
#[tauri::command]
pub async fn nd_list_songs(
server_url: String,
token: String,
sort: String,
order: String,
start: u32,
end: u32,
) -> Result<serde_json::Value, String> {
nd_list_songs_internal(&server_url, &token, &sort, &order, start, end).await
}
/// Build the `_filters` JSON for native-API list calls. Optionally narrows the
/// query to a single library — `library_id` is the same scope key the Navidrome
/// web UI sends, and it matches the Subsonic `musicFolderId` we store per server.
@@ -0,0 +1,91 @@
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
/// Subsonic credentials in the legacy salted-md5 shape (spec v1.13+):
/// the client sends `u` + `t = md5(password || salt)` + `s = salt`,
/// never the plaintext password. The salt is per-request to defeat
/// replay, but doesn't need to be cryptographically random — Subsonic's
/// API treats it as an opaque uniqueness nonce.
#[derive(Debug, Clone)]
pub struct SubsonicCredentials {
pub username: String,
pub token: String,
pub salt: String,
}
impl SubsonicCredentials {
/// Derive a credentials triple from a plaintext password. Generates a
/// fresh salt and computes `md5(password || salt)`.
pub fn from_password(username: impl Into<String>, password: &str) -> Self {
let salt = fresh_salt();
let token = md5_hex(&format!("{password}{salt}"));
Self { username: username.into(), token, salt }
}
/// Use a caller-supplied salt + token. Intended for tests and for
/// callers that already cache the derivation result.
pub fn with_static(username: impl Into<String>, token: impl Into<String>, salt: impl Into<String>) -> Self {
Self { username: username.into(), token: token.into(), salt: salt.into() }
}
}
/// Per-process monotonically-advancing nonce mixed into the salt so the
/// hot loop of `from_password` calls doesn't repeat itself even at the
/// same nanosecond.
static SALT_COUNTER: AtomicU64 = AtomicU64::new(0);
fn fresh_salt() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let counter = SALT_COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id() as u64;
format!("{:016x}{:08x}", nanos ^ pid.rotate_left(13), counter)
}
fn md5_hex(input: &str) -> String {
let digest = md5::compute(input.as_bytes());
format!("{digest:x}")
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn from_password_computes_md5_token() {
// Force the salt by going through with_static for a deterministic check.
let salt = "abc123";
let password = "sesame";
let expected = md5_hex(&format!("{password}{salt}"));
let creds = SubsonicCredentials::with_static("user", &expected, salt);
assert_eq!(creds.token, expected);
assert_eq!(creds.token.len(), 32, "md5 hex must be 32 chars");
}
#[test]
fn fresh_salt_is_unique_across_rapid_calls() {
let mut seen: HashSet<String> = HashSet::new();
for _ in 0..1000 {
assert!(seen.insert(fresh_salt()), "fresh_salt repeated");
}
}
#[test]
fn from_password_produces_different_salts_per_call() {
let a = SubsonicCredentials::from_password("u", "pw");
let b = SubsonicCredentials::from_password("u", "pw");
assert_ne!(a.salt, b.salt);
assert_ne!(a.token, b.token, "different salt → different token");
}
#[test]
fn md5_hex_matches_known_vector() {
// md5("") = d41d8cd98f00b204e9800998ecf8427e
assert_eq!(md5_hex(""), "d41d8cd98f00b204e9800998ecf8427e");
// md5("abc") = 900150983cd24fb0d6963f7d28e17f72
assert_eq!(md5_hex("abc"), "900150983cd24fb0d6963f7d28e17f72");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,76 @@
use std::fmt;
/// Errors surfaced by `SubsonicClient`. Designed for the sync engine
/// (PR-3): `NotFound` exists as a first-class variant so the tombstone
/// reconciler can match Subsonic error code 70 without parsing strings.
///
/// Spec §2.6 — code 70 = "The requested data was not found".
#[derive(Debug)]
pub enum SubsonicError {
/// Transport failure (DNS, TCP, TLS, body read). Wraps the flattened
/// reqwest error chain so toasts can surface the real cause.
Transport(String),
/// Server replied with a non-2xx HTTP status before the JSON envelope
/// was inspectable.
HttpStatus(reqwest::StatusCode),
/// Subsonic-level failure (`status = "failed"` in the envelope).
Api { code: i32, message: String },
/// Convenience for the common error code 70. Equivalent to
/// `Api { code: 70, .. }` and produced by the same parser.
NotFound,
/// Response body wasn't the expected JSON shape.
Decode(String),
}
impl fmt::Display for SubsonicError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SubsonicError::Transport(m) => write!(f, "subsonic transport: {m}"),
SubsonicError::HttpStatus(s) => write!(f, "subsonic http status: {s}"),
SubsonicError::Api { code, message } => {
write!(f, "subsonic api error {code}: {message}")
}
SubsonicError::NotFound => write!(f, "subsonic: not found (code 70)"),
SubsonicError::Decode(m) => write!(f, "subsonic decode: {m}"),
}
}
}
impl std::error::Error for SubsonicError {}
/// Flatten a `reqwest::Error` source chain into one readable string —
/// mirrors `psysonic-integration::navidrome::nd_err` so the two clients
/// surface comparable diagnostic text.
pub(crate) fn flatten_reqwest_error(e: reqwest::Error) -> String {
let mut msg = e.to_string();
let mut src: Option<&(dyn std::error::Error + 'static)> = std::error::Error::source(&e);
while let Some(s) = src {
msg.push_str(" | ");
msg.push_str(&s.to_string());
src = s.source();
}
msg
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_includes_error_code_for_api_variant() {
let e = SubsonicError::Api { code: 40, message: "Wrong username or password".into() };
let s = e.to_string();
assert!(s.contains("40"));
assert!(s.contains("Wrong username"));
}
#[test]
fn not_found_renders_with_code_70_for_log_search() {
let s = SubsonicError::NotFound.to_string();
assert!(s.contains("70"), "got {s}");
}
}
@@ -0,0 +1,20 @@
//! Subsonic REST client — read-only endpoints the library-sync engine
//! consumes (phase B per spec §10). See `client::SubsonicClient` for the
//! entry point.
pub mod auth;
pub mod client;
pub mod error;
pub mod stream_url;
pub mod types;
pub use auth::SubsonicCredentials;
pub use client::{
fingerprint_sample, SubsonicClient, SUBSONIC_API_VERSION, SUBSONIC_CLIENT_ID,
};
pub use stream_url::{build_stream_view_url, rest_base_from_url};
pub use error::SubsonicError;
pub use types::{
Album, AlbumSummary, ArtistIndex, ArtistRef, IndexBucket, ScanStatus, SearchResult, ServerInfo,
Song,
};
@@ -0,0 +1,61 @@
//! Subsonic `stream.view` URLs for native library analysis backfill.
use url::Url;
use super::auth::SubsonicCredentials;
use super::client::{SUBSONIC_API_VERSION, SUBSONIC_CLIENT_ID};
/// `{origin}/rest` — mirrors frontend `restBaseFromUrl`.
pub fn rest_base_from_url(server_url: &str) -> String {
let trimmed = server_url.trim().trim_end_matches('/');
let base = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
trimmed.to_string()
} else {
format!("http://{trimmed}")
};
format!("{base}/rest")
}
/// Authenticated `stream.view` URL for a library track id.
pub fn build_stream_view_url(
server_url: &str,
username: &str,
password: &str,
track_id: &str,
) -> String {
let creds = SubsonicCredentials::from_password(username, password);
let base = rest_base_from_url(server_url);
let mut url =
Url::parse(&format!("{base}/stream.view")).unwrap_or_else(|_| Url::parse("http://invalid/rest/stream.view").unwrap());
{
let mut q = url.query_pairs_mut();
q.append_pair("id", track_id);
q.append_pair("u", &creds.username);
q.append_pair("t", &creds.token);
q.append_pair("s", &creds.salt);
q.append_pair("v", SUBSONIC_API_VERSION);
q.append_pair("c", SUBSONIC_CLIENT_ID);
q.append_pair("f", "json");
}
url.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stream_url_contains_track_and_auth_params() {
let url = build_stream_view_url(
"https://music.example",
"alice",
"secret",
"tr-42",
);
assert!(url.contains("stream.view"));
assert!(url.contains("id=tr-42"));
assert!(url.contains("u=alice"));
assert!(url.contains("&t="));
assert!(url.contains("&s="));
}
}
@@ -0,0 +1,403 @@
//! Response structs for the Subsonic REST API surface PR-2 needs.
//!
//! Only the hot fields the sync engine reads are typed; everything else
//! survives via the raw JSON the client also returns (PR-3 wires the
//! `raw_json` column on `track`). Unknown fields are simply ignored on
//! deserialize — additive OpenSubsonic extensions never break parsing.
use serde::{Deserialize, Serialize};
/// Deserialize a field Navidrome/OpenSubsonic may return either as a plain
/// string or as a JSON array. OpenSubsonic types `isrc` as `string[]`;
/// Navidrome ships `isrc: []` / `["USRC…"]`, which breaks a plain
/// `Option<String>`. Take the first usable value; the full set survives
/// verbatim in `track.raw_json` (ADR-7).
fn de_string_or_seq<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
Ok(match value {
Some(serde_json::Value::String(s)) => Some(s),
Some(serde_json::Value::Array(arr)) => first_tag_value(&arr),
_ => None,
})
}
/// Navidrome often ships library ids as JSON numbers; Subsonic uses strings.
fn de_string_or_number<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
Ok(match value {
Some(serde_json::Value::String(s)) => Some(s),
Some(serde_json::Value::Number(n)) => Some(n.to_string()),
_ => None,
})
}
/// First usable value in a multi-valued array: a string element, or an
/// object element's `name` (the OpenSubsonic `[{ "name": … }]` shape).
fn first_tag_value(arr: &[serde_json::Value]) -> Option<String> {
arr.iter().find_map(|el| match el {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Object(map) => map
.get("name")
.and_then(serde_json::Value::as_str)
.map(str::to_owned),
_ => None,
})
}
/// Envelope-level metadata returned by `#ping` (and present on every
/// other response too). Read by the capability probe to detect the
/// server family (Navidrome vs generic Subsonic) and the OpenSubsonic
/// flag. Filled in from the `subsonic-response` object itself, not
/// from a body key — these fields sit at the same level as `status`.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ServerInfo {
/// Server software family — Navidrome reports `"navidrome"`, generic
/// Subsonic implementations report their own label. `None` when the
/// server omits the field (older Subsonic).
pub server_type: Option<String>,
/// Server build version, e.g. `"0.55.2"` on Navidrome.
pub server_version: Option<String>,
/// Subsonic API protocol level the server advertises.
pub api_version: Option<String>,
/// `true` when the server advertises OpenSubsonic extensions
/// (`isrc`, `played`, `bpm`, contributor arrays, …).
pub open_subsonic: bool,
}
/// `#getScanStatus` (since 1.15.0). `lastScan` is an ISO-8601 string on
/// Navidrome (`responses.go` `ScanStatus.LastScan`); other servers may
/// omit it during an active scan.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct ScanStatus {
pub scanning: bool,
#[serde(default)]
pub count: Option<i64>,
#[serde(rename = "folderCount", default)]
pub folder_count: Option<i64>,
#[serde(rename = "lastScan", default)]
pub last_scan: Option<String>,
}
/// `#getIndexes` (file-structure browse) and `#getArtists` (ID3 browse)
/// share the same shape on the wire: a top-level `lastModified` watermark
/// plus a list of letter buckets.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct ArtistIndex {
/// `lastModified` is ms since epoch (spec §2.2 — response metadata,
/// not a request param).
#[serde(rename = "lastModified", default)]
pub last_modified_ms: Option<i64>,
#[serde(rename = "ignoredArticles", default)]
pub ignored_articles: Option<String>,
#[serde(default)]
pub index: Vec<IndexBucket>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct IndexBucket {
pub name: String,
#[serde(default)]
pub artist: Vec<ArtistRef>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct ArtistRef {
pub id: String,
pub name: String,
#[serde(rename = "albumCount", default)]
pub album_count: Option<i64>,
#[serde(rename = "coverArt", default)]
pub cover_art: Option<String>,
}
/// `#getAlbumList2` — page of album summaries (no song list).
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct AlbumSummary {
pub id: String,
pub name: String,
#[serde(default)]
pub artist: Option<String>,
#[serde(rename = "artistId", default)]
pub artist_id: Option<String>,
#[serde(rename = "songCount", default)]
pub song_count: Option<i64>,
#[serde(default)]
pub duration: Option<i64>,
#[serde(default)]
pub year: Option<i64>,
#[serde(default)]
pub genre: Option<String>,
#[serde(rename = "coverArt", default)]
pub cover_art: Option<String>,
#[serde(default)]
pub starred: Option<String>,
}
/// `#getAlbum` — album + its full song list.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct Album {
pub id: String,
pub name: String,
#[serde(default)]
pub artist: Option<String>,
#[serde(rename = "artistId", default)]
pub artist_id: Option<String>,
#[serde(rename = "songCount", default)]
pub song_count: Option<i64>,
#[serde(default)]
pub duration: Option<i64>,
#[serde(default)]
pub year: Option<i64>,
#[serde(default)]
pub genre: Option<String>,
#[serde(rename = "coverArt", default)]
pub cover_art: Option<String>,
#[serde(default)]
pub song: Vec<Song>,
}
/// `#search3` — three parallel lists, any of which may be empty.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Default)]
pub struct SearchResult {
#[serde(default)]
pub artist: Vec<ArtistRef>,
#[serde(default)]
pub album: Vec<AlbumSummary>,
#[serde(default)]
pub song: Vec<Song>,
}
/// `#getSong` / nested in `#getAlbum`. Only the hot columns from
/// spec §5.1 are typed; everything else (OpenSubsonic extensions, contributor
/// arrays, …) is ignored at this layer and recovered from `raw_json` later.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct Song {
pub id: String,
pub title: String,
#[serde(default)]
pub artist: Option<String>,
#[serde(rename = "artistId", default)]
pub artist_id: Option<String>,
#[serde(default)]
pub album: Option<String>,
#[serde(rename = "albumId", default)]
pub album_id: Option<String>,
#[serde(rename = "albumArtist", default)]
pub album_artist: Option<String>,
/// Subsonic reports `duration` in whole seconds.
#[serde(default)]
pub duration: Option<i64>,
#[serde(rename = "track", default)]
pub track_number: Option<i64>,
#[serde(rename = "discNumber", default)]
pub disc_number: Option<i64>,
#[serde(default)]
pub year: Option<i64>,
#[serde(default)]
pub genre: Option<String>,
#[serde(default)]
pub suffix: Option<String>,
#[serde(rename = "bitRate", default)]
pub bit_rate: Option<i64>,
/// Server reports `size` in bytes.
#[serde(default)]
pub size: Option<i64>,
#[serde(rename = "coverArt", default)]
pub cover_art: Option<String>,
#[serde(default)]
pub starred: Option<String>,
#[serde(rename = "userRating", default)]
pub user_rating: Option<i64>,
#[serde(rename = "playCount", default)]
pub play_count: Option<i64>,
#[serde(default)]
pub played: Option<String>,
/// Server-side relative path (Navidrome populates; some servers don't).
#[serde(default)]
pub path: Option<String>,
/// `libraryId` (Navidrome native) or `musicFolderId` (Subsonic generic).
/// We accept both keys — Navidrome uses `libraryId` on OpenSubsonic
/// responses, generic Subsonic stays on `musicFolderId`.
#[serde(
default,
alias = "libraryId",
alias = "musicFolderId",
deserialize_with = "de_string_or_number"
)]
pub library_id: Option<String>,
// OpenSubsonic types `isrc` as `string[]` — Navidrome returns
// `isrc: []` / `["USRC…"]`, which breaks a plain `Option<String>`.
#[serde(default, deserialize_with = "de_string_or_seq")]
pub isrc: Option<String>,
/// MusicBrainz recording id. Subsonic / OpenSubsonic uses the
/// `musicBrainzId` JSON key; the schema column is `mbid_recording`
/// (spec §5.1). The alias keeps both spellings deserializable so
/// future API revisions don't break ingest.
#[serde(default, alias = "musicBrainzId", alias = "mbid_recording")]
pub mbid_recording: Option<String>,
#[serde(default)]
pub bpm: Option<i64>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn song_deserialize_accepts_minimal_navidrome_payload() {
let payload = r#"{
"id": "tr_1",
"title": "Hello",
"artist": "World",
"duration": 240,
"track": 3,
"year": 2024,
"suffix": "flac",
"bitRate": 1000,
"size": 32000000,
"coverArt": "cv_1",
"libraryId": "1",
"isrc": "USRC17607839"
}"#;
let song: Song = serde_json::from_str(payload).unwrap();
assert_eq!(song.id, "tr_1");
assert_eq!(song.title, "Hello");
assert_eq!(song.duration, Some(240));
assert_eq!(song.track_number, Some(3));
assert_eq!(song.library_id.as_deref(), Some("1"));
assert_eq!(song.isrc.as_deref(), Some("USRC17607839"));
}
#[test]
fn song_alias_falls_back_to_music_folder_id() {
// Generic Subsonic still ships `musicFolderId`, not Navidrome's
// `libraryId` — make sure we don't lose it.
let payload = r#"{"id":"a","title":"t","musicFolderId":"7"}"#;
let song: Song = serde_json::from_str(payload).unwrap();
assert_eq!(song.library_id.as_deref(), Some("7"));
}
#[test]
fn song_deserialize_library_id_from_number() {
let payload = r#"{"id":"a","title":"t","libraryId":3}"#;
let song: Song = serde_json::from_str(payload).unwrap();
assert_eq!(song.library_id.as_deref(), Some("3"));
}
#[test]
fn song_picks_up_music_brainz_id_from_either_alias() {
// OpenSubsonic shape — `musicBrainzId`.
let from_subsonic: Song = serde_json::from_str(
r#"{"id":"a","title":"t","musicBrainzId":"abc-123"}"#,
)
.unwrap();
assert_eq!(from_subsonic.mbid_recording.as_deref(), Some("abc-123"));
// Schema-column shape — direct `mbid_recording`. Lets callers
// round-trip a row through `serde_json` without renaming.
let from_schema: Song = serde_json::from_str(
r#"{"id":"a","title":"t","mbid_recording":"xyz-789"}"#,
)
.unwrap();
assert_eq!(from_schema.mbid_recording.as_deref(), Some("xyz-789"));
}
#[test]
fn song_ignores_unknown_open_subsonic_fields() {
// OpenSubsonic ships extras like `played`, `replayGain`, `artists`
// (contributor list), etc. We don't type them; they must not error.
let payload = r#"{
"id": "tr_1",
"title": "Hello",
"replayGain": { "trackGain": -1.2, "albumGain": -0.8 },
"artists": [{ "id": "ar_1", "name": "W" }],
"contributors": []
}"#;
let song: Song = serde_json::from_str(payload).unwrap();
assert_eq!(song.id, "tr_1");
assert!(song.artist.is_none());
}
#[test]
fn album_with_songs_round_trips() {
let payload = r#"{
"id": "al_1",
"name": "Test Album",
"artist": "Artist",
"artistId": "ar_1",
"songCount": 2,
"song": [
{"id": "tr_1", "title": "One", "track": 1},
{"id": "tr_2", "title": "Two", "track": 2}
]
}"#;
let album: Album = serde_json::from_str(payload).unwrap();
assert_eq!(album.name, "Test Album");
assert_eq!(album.song.len(), 2);
assert_eq!(album.song[1].title, "Two");
}
#[test]
fn song_isrc_accepts_opensubsonic_string_array() {
// OpenSubsonic `isrc` is `string[]`. Navidrome ships `isrc: []`
// (the album !Brincamos! repro) or a populated array — both must
// decode, plus the legacy single-string form.
let empty: Song = serde_json::from_str(r#"{"id":"a","title":"t","isrc":[]}"#).unwrap();
assert!(empty.isrc.is_none());
let arr: Song =
serde_json::from_str(r#"{"id":"a","title":"t","isrc":["USRC17607839"]}"#).unwrap();
assert_eq!(arr.isrc.as_deref(), Some("USRC17607839"));
let legacy: Song =
serde_json::from_str(r#"{"id":"a","title":"t","isrc":"USRC17607839"}"#).unwrap();
assert_eq!(legacy.isrc.as_deref(), Some("USRC17607839"));
}
#[test]
fn artist_index_parses_last_modified_watermark() {
let payload = r#"{
"lastModified": 1716840000000,
"ignoredArticles": "The El La",
"index": [
{"name": "A", "artist": [
{"id": "ar_1", "name": "Anna"},
{"id": "ar_2", "name": "Alex", "albumCount": 3}
]},
{"name": "B", "artist": []}
]
}"#;
let ai: ArtistIndex = serde_json::from_str(payload).unwrap();
assert_eq!(ai.last_modified_ms, Some(1716840000000));
assert_eq!(ai.index.len(), 2);
assert_eq!(ai.index[0].artist.len(), 2);
assert_eq!(ai.index[0].artist[1].album_count, Some(3));
}
#[test]
fn search_result_defaults_empty_lists() {
// search3 with no hits returns just `{"searchResult3": {}}`.
let sr: SearchResult = serde_json::from_str("{}").unwrap();
assert!(sr.artist.is_empty());
assert!(sr.album.is_empty());
assert!(sr.song.is_empty());
}
#[test]
fn scan_status_parses_navidrome_shape() {
let payload = r#"{
"scanning": false,
"count": 12345,
"folderCount": 100,
"lastScan": "2024-06-01T12:00:00Z"
}"#;
let s: ScanStatus = serde_json::from_str(payload).unwrap();
assert!(!s.scanning);
assert_eq!(s.count, Some(12345));
assert_eq!(s.last_scan.as_deref(), Some("2024-06-01T12:00:00Z"));
}
}
@@ -0,0 +1,22 @@
[package]
name = "psysonic-library"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
psysonic-core = { path = "../psysonic-core" }
psysonic-integration = { path = "../psysonic-integration" }
tauri = { version = "2" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.40", features = ["bundled"] }
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls", "gzip", "brotli"] }
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "macros", "time"] }
[dev-dependencies]
tokio = { version = "1", features = ["rt", "macros", "rt-multi-thread", "test-util"] }
wiremock = { workspace = true }
@@ -0,0 +1,295 @@
-- psysonic-library v1 schema — see implementation-spec.ru.md §5.1–§5.3
-- Tables are ordered so FK targets exist before their referrers.
-- Migration-runner bookkeeping (§5.7). Defined here so the schema file is
-- self-describing — `LibraryStore::run_migrations` also creates this table
-- defensively before applying migrations, which keeps both paths idempotent.
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL
);
CREATE TABLE canonical_track (
id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE canonical_identity (
canonical_id TEXT NOT NULL,
kind TEXT NOT NULL,
value TEXT NOT NULL,
confidence REAL NOT NULL DEFAULT 1.0,
PRIMARY KEY (kind, value),
FOREIGN KEY (canonical_id) REFERENCES canonical_track(id)
);
CREATE TABLE sync_state (
server_id TEXT NOT NULL,
library_scope TEXT NOT NULL DEFAULT '',
normalized_base_url TEXT NOT NULL DEFAULT '',
server_fingerprint_ok INTEGER,
fingerprint_checked_at INTEGER,
capability_flags INTEGER NOT NULL DEFAULT 0,
last_full_sync_at INTEGER,
last_delta_sync_at INTEGER,
server_last_scan_iso TEXT,
indexes_last_modified_ms INTEGER,
artists_last_modified_ms INTEGER,
server_track_count INTEGER,
local_track_count INTEGER,
artist_count INTEGER,
library_tier TEXT NOT NULL DEFAULT 'unknown',
poll_stats_json TEXT NOT NULL DEFAULT '{}',
next_poll_at INTEGER,
initial_sync_cursor_json TEXT NOT NULL DEFAULT '{}',
sync_phase TEXT NOT NULL DEFAULT 'idle',
last_error TEXT,
n1_bulk_unreliable INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, library_scope)
);
CREATE TABLE artist (
server_id TEXT NOT NULL,
id TEXT NOT NULL,
name TEXT NOT NULL,
album_count INTEGER,
synced_at INTEGER NOT NULL,
raw_json TEXT,
PRIMARY KEY (server_id, id)
);
CREATE TABLE album (
server_id TEXT NOT NULL,
id TEXT NOT NULL,
name TEXT NOT NULL,
artist TEXT,
artist_id TEXT,
song_count INTEGER,
duration_sec INTEGER,
year INTEGER,
genre TEXT,
cover_art_id TEXT,
starred_at INTEGER,
synced_at INTEGER NOT NULL,
raw_json TEXT,
PRIMARY KEY (server_id, id)
);
CREATE TABLE track (
server_id TEXT NOT NULL,
id TEXT NOT NULL,
title TEXT NOT NULL,
title_sort TEXT,
artist TEXT,
artist_id TEXT,
album TEXT NOT NULL DEFAULT '',
album_id TEXT,
album_artist TEXT,
duration_sec INTEGER NOT NULL DEFAULT 0,
track_number INTEGER,
disc_number INTEGER,
year INTEGER,
genre TEXT,
suffix TEXT,
bit_rate INTEGER,
size_bytes INTEGER,
cover_art_id TEXT,
starred_at INTEGER,
user_rating INTEGER,
play_count INTEGER,
played_at INTEGER,
server_path TEXT,
library_id TEXT,
isrc TEXT,
mbid_recording TEXT,
bpm INTEGER,
replay_gain_track_db REAL,
replay_gain_album_db REAL,
content_hash TEXT,
server_updated_at INTEGER,
server_created_at INTEGER,
resync_gen INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
synced_at INTEGER NOT NULL,
raw_json TEXT NOT NULL,
PRIMARY KEY (server_id, id)
);
CREATE VIRTUAL TABLE track_fts USING fts5(
title, artist, album, album_artist, genre,
content='track', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
);
CREATE TRIGGER track_ai AFTER INSERT ON track BEGIN
INSERT INTO track_fts(rowid, title, artist, album, album_artist, genre)
VALUES (new.rowid, new.title, new.artist, new.album, new.album_artist, new.genre);
END;
CREATE TRIGGER track_ad AFTER DELETE ON track BEGIN
INSERT INTO track_fts(track_fts, rowid, title, artist, album, album_artist, genre)
VALUES ('delete', old.rowid, old.title, old.artist, old.album, old.album_artist, old.genre);
END;
CREATE TRIGGER track_au AFTER UPDATE ON track BEGIN
INSERT INTO track_fts(track_fts, rowid, title, artist, album, album_artist, genre)
VALUES ('delete', old.rowid, old.title, old.artist, old.album, old.album_artist, old.genre);
INSERT INTO track_fts(rowid, title, artist, album, album_artist, genre)
VALUES (new.rowid, new.title, new.artist, new.album, new.album_artist, new.genre);
END;
CREATE TABLE track_extension (
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
kind TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
payload BLOB NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (server_id, track_id, kind),
FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id)
);
-- NO FK to track: survives library purge when user keeps the cached file (§5.14).
CREATE TABLE track_offline (
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
local_path TEXT NOT NULL,
file_size_bytes INTEGER,
suffix TEXT,
content_hash TEXT NOT NULL DEFAULT '',
server_path TEXT,
cached_at INTEGER NOT NULL,
last_verified_at INTEGER,
PRIMARY KEY (server_id, track_id)
);
CREATE TABLE track_id_history (
server_id TEXT NOT NULL,
old_id TEXT NOT NULL,
new_id TEXT NOT NULL,
content_hash TEXT,
server_path TEXT,
remapped_at INTEGER NOT NULL,
PRIMARY KEY (server_id, old_id)
);
CREATE TABLE track_fact (
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
fact_kind TEXT NOT NULL,
value_real REAL,
value_int INTEGER,
value_text TEXT,
unit TEXT,
source_kind TEXT NOT NULL,
source_id TEXT NOT NULL,
source_detail TEXT,
confidence REAL NOT NULL DEFAULT 1.0,
content_hash TEXT,
fetched_at INTEGER NOT NULL,
expires_at INTEGER,
PRIMARY KEY (server_id, track_id, fact_kind, source_kind, source_id),
FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id)
);
CREATE TABLE track_artifact (
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
artifact_kind TEXT NOT NULL,
format TEXT NOT NULL,
language TEXT,
source_kind TEXT NOT NULL,
source_id TEXT NOT NULL,
content_text TEXT,
content_blob BLOB,
content_bytes INTEGER NOT NULL DEFAULT 0,
not_found INTEGER NOT NULL DEFAULT 0,
content_hash TEXT,
fetched_at INTEGER NOT NULL,
expires_at INTEGER,
PRIMARY KEY (server_id, track_id, artifact_kind, source_kind, source_id, format),
FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id)
);
CREATE TABLE track_canonical_link (
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
canonical_id TEXT NOT NULL,
match_method TEXT NOT NULL,
confidence REAL NOT NULL,
linked_at INTEGER NOT NULL,
PRIMARY KEY (server_id, track_id),
FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id),
FOREIGN KEY (canonical_id) REFERENCES canonical_track(id)
);
CREATE TABLE canonical_enrichment_link (
canonical_id TEXT NOT NULL,
enrichment_kind TEXT NOT NULL,
owner_server_id TEXT NOT NULL,
owner_track_id TEXT NOT NULL,
share_policy TEXT NOT NULL DEFAULT 'isrc_match',
linked_at INTEGER NOT NULL,
PRIMARY KEY (canonical_id, enrichment_kind, owner_server_id, owner_track_id),
FOREIGN KEY (canonical_id) REFERENCES canonical_track(id)
);
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_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;
CREATE INDEX idx_track_starred ON track(server_id, starred_at) WHERE deleted = 0 AND starred_at IS NOT NULL;
CREATE INDEX idx_track_library ON track(server_id, library_id) WHERE deleted = 0;
CREATE INDEX idx_track_bpm ON track(server_id, bpm) WHERE deleted = 0 AND bpm IS NOT NULL;
CREATE INDEX idx_track_isrc ON track(isrc) WHERE deleted = 0 AND isrc IS NOT NULL;
CREATE INDEX idx_track_fact_lookup ON track_fact(server_id, track_id, fact_kind);
CREATE INDEX idx_track_artifact_lookup ON track_artifact(server_id, track_id, artifact_kind);
CREATE INDEX idx_track_offline_hash ON track_offline(server_id, content_hash);
CREATE INDEX idx_track_id_history_new ON track_id_history(server_id, new_id);
CREATE INDEX idx_canonical_identity_lookup ON canonical_identity(kind, value);
CREATE INDEX idx_track_remap_path
ON track(server_id, server_path)
WHERE deleted = 0 AND server_path IS NOT NULL AND server_path != '';
CREATE INDEX idx_track_remap_hash
ON track(server_id, content_hash)
WHERE deleted = 0 AND content_hash IS NOT NULL AND content_hash != '';
CREATE INDEX idx_track_title
ON track(server_id, title COLLATE NOCASE)
WHERE deleted = 0;
CREATE INDEX idx_track_genre
ON track(server_id, genre COLLATE NOCASE)
WHERE deleted = 0 AND genre IS NOT NULL;
CREATE INDEX idx_track_year
ON track(server_id, year)
WHERE deleted = 0 AND year IS NOT NULL;
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);
CREATE INDEX idx_track_fact_mood_tag
ON track_fact(server_id, fact_kind, value_text, track_id)
WHERE fact_kind = 'mood_tag';
@@ -0,0 +1,6 @@
-- 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;
@@ -0,0 +1,10 @@
-- 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 != '';
@@ -0,0 +1,4 @@
-- 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;
@@ -0,0 +1,8 @@
-- 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;
@@ -0,0 +1,22 @@
-- 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);
@@ -0,0 +1,4 @@
-- 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;
@@ -0,0 +1,4 @@
-- 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';
@@ -0,0 +1,3 @@
-- Oximedia mood heuristics were misleading; drop accumulated mood facts.
DELETE FROM track_fact
WHERE fact_kind IN ('mood_tag', 'moods', 'valence', 'arousal', 'mood_labels');
@@ -0,0 +1,8 @@
-- 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 != '';
@@ -0,0 +1,8 @@
-- 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 != '';
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,120 @@
//! Mood filter SQL for Advanced Search (`mood_group` / `mood_tag` clauses).
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
use crate::dto::LibraryFilterClause;
use crate::filter::{self, FilterOp, SqlFragment};
use crate::mood_groups;
pub fn resolve_mood_clause(c: &LibraryFilterClause) -> Result<Option<SqlFragment>, String> {
match c.field.as_str() {
"mood_group" => {
let group_ids = json_to_string_list(&c.field, c.op, c.value.as_ref())?;
mood_groups::normalize_mood_groups(&group_ids).map_err(|detail| {
filter::FilterError::BadValue {
field: c.field.clone(),
detail,
}
.to_string()
})?;
let tags = mood_groups::expand_mood_groups(&group_ids).map_err(|detail| {
filter::FilterError::BadValue {
field: c.field.clone(),
detail,
}
.to_string()
})?;
Ok(Some(mood_tag_exists_fragment(&tags)))
}
"mood_tag" => {
let tag_ids = json_to_string_list(&c.field, c.op, c.value.as_ref())?;
let tags = mood_groups::normalize_mood_tags(&tag_ids).map_err(|detail| {
filter::FilterError::BadValue {
field: c.field.clone(),
detail,
}
.to_string()
})?;
Ok(Some(mood_tag_exists_fragment(&tags)))
}
_ => unreachable!("resolve_mood_clause called for non-mood field"),
}
}
fn mood_tag_exists_fragment(tags: &[String]) -> SqlFragment {
let placeholders = (0..tags.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
SqlFragment {
sql: format!(
"EXISTS (SELECT 1 FROM track_fact mf \
WHERE mf.server_id = t.server_id AND mf.track_id = t.id \
AND mf.fact_kind = 'mood_tag' AND mf.value_text IN ({placeholders}))"
),
params: tags
.iter()
.map(|t| SqlValue::Text(t.clone()))
.collect(),
}
}
fn json_to_string_list(
field: &str,
op: FilterOp,
v: Option<&Value>,
) -> Result<Vec<String>, String> {
match op {
FilterOp::Eq => {
let s = json_to_text(field, v)?;
Ok(vec![match s {
SqlValue::Text(t) => t,
_ => unreachable!(),
}])
}
FilterOp::In => match v {
Some(Value::Array(items)) => {
if items.is_empty() {
return Err(filter::FilterError::BadValue {
field: field.to_string(),
detail: "operator `in` requires a non-empty array".to_string(),
}
.to_string());
}
let mut out = Vec::with_capacity(items.len());
for item in items {
match item {
Value::String(s) => out.push(s.clone()),
_ => {
return Err(filter::FilterError::BadValue {
field: field.to_string(),
detail: "expected an array of strings".to_string(),
}
.to_string());
}
}
}
Ok(out)
}
_ => Err(filter::FilterError::BadValue {
field: field.to_string(),
detail: "operator `in` requires an array value".to_string(),
}
.to_string()),
}
_ => Err(filter::FilterError::UnsupportedOp {
field: field.to_string(),
op: op.as_str(),
}
.to_string()),
}
}
fn json_to_text(field: &str, v: Option<&Value>) -> Result<SqlValue, String> {
match v {
Some(Value::String(s)) => Ok(SqlValue::Text(s.clone())),
_ => Err(filter::FilterError::BadValue {
field: field.to_string(),
detail: "expected a string value".to_string(),
}
.to_string()),
}
}
@@ -0,0 +1,30 @@
//! OpenSubsonic compilation flag in entity `raw_json` (Navidrome: `compilation`,
//! `isCompilation`, or `releaseTypes` containing `Compilation`).
/// SQL predicate on any row with a `raw_json` column (album or track).
pub fn compilation_raw_json_sql(table_alias: &str) -> String {
let a = table_alias;
// `NULL IN (...)` is unknown in SQL — wrap each probe in EXISTS so non-comp rows stay false.
format!(
"(EXISTS ( \
SELECT 1 WHERE json_extract({a}.raw_json, '$.compilation') IN (1, '1', 'true', 'TRUE') \
) OR EXISTS ( \
SELECT 1 WHERE json_extract({a}.raw_json, '$.isCompilation') IN (1, '1', 'true', 'TRUE') \
) OR EXISTS ( \
SELECT 1 FROM json_each(COALESCE(json_extract({a}.raw_json, '$.releaseTypes'), '[]')) AS rt \
WHERE lower(rt.value) = 'compilation' \
))"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sql_mentions_json_paths() {
let sql = compilation_raw_json_sql("t");
assert!(sql.contains("$.compilation"));
assert!(sql.contains("$.releaseTypes"));
}
}

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