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.
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.
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.
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.
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.
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.
* 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)
* 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
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.
* 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
* 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.
* 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.
* 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.
* 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.
* 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.
* 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.
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.
* 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%.
* 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.
* 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)
* 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.
* 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.
* 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)
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.
* 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)
* 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%).
* 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)
* 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)
* 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>
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.
* 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)
* 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)
* 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
* 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.
* 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)
* 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.
* 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)
* 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)
* 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)
* 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)
* 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)
* 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)
* 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)
* 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)
* 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)
* 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)
* 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
* 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
* 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
* 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
* 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.
* 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
* 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.
* 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
* 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
* 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)
* 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)
* 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
* 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)
* 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)
* 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)
* 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.
* 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)
* 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.
* 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.
* 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.
* 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.
* 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.
* 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)
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.
* 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.
* 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)
* 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
* 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
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.
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>
* 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>
* 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)
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.
* 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)
* 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)
* 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)
* 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)
* 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
* 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.
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.
* 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.
* 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)
* 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.
* 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>
* 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.
* 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.
* 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)
* 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>
* 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).
* 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)
* 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)
* 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.
* 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)
* 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.
* 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
* 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 }.
* 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.
* 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.
* 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>
* 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)
* 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)
* 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)
* 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.
* 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
* 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.
* 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
* 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.
* 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.
* 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.
* 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.
* 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)
* 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)
* 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)
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).
* 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)
* 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)
* 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)
* 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>
* 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.
* 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.
* 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).
* 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)
* 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>
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.
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.
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.
* 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>
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.
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.
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).
* 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
* 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
* 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)
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.
* 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
* 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.
* 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)
* 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
* 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>
--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."
@@ -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.
@@ -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.
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.
@@ -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).
- **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).
Privacy is not an afterthought in Psysonic. It has been part of the project’s 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.
@@ -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).
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.
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.