Compare commits

..

28 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: remove unused import in AlbumCard after enqueue refactor

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Covered by validateThemePackage tests for every rejection class.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(themes): granular tokens — track lists

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

* feat(themes): granular tokens — cards

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

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

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

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

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

* style(themes): make Spectrum demo brutally loud

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

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

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

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

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

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

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

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

* feat(themes): granular tokens — player bar

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

* chore(themes): remove empty theme stub files

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

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

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

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

* feat(themes): dedicated Themes settings tab

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

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

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

Add the Theme Store section to the Themes tab:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(rust): satisfy clippy too_many_arguments for CI

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(preview): allow clippy too_many_arguments on audio_preview_play

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs(changelog): note fullscreen player rebuild (#1001)
2026-06-05 14:23:47 +02:00
747 changed files with 21649 additions and 33137 deletions
+84
View File
@@ -21,6 +21,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fullscreen player — rebuilt for much lower CPU/RAM
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1001](https://github.com/Psychotoxical/psysonic/pull/1001)**
* The previous fullscreen player was a heavy CPU and memory consumer — constant repaints from animated/blurred backgrounds and effects kept the GPU and a CPU core busy the whole time it was open. It has been **completely replaced** by a static, low-overhead screen: only the seekbar, elapsed time, and clock update live; everything else stays still.
* Features: sharp high-res background, large album cover, true waveform seekbar, up-next queue popover, scrolling synced lyrics, clickable rating stars, and an on-screen clock.
* The artist photo now always shows as the background (album cover as fallback); the old **Appearance → "Fullscreen player"** settings were removed.
### Queue — Timeline display mode
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1004](https://github.com/Psychotoxical/psysonic/pull/1004), suggested by [@Legislate3030](https://github.com/Legislate3030)**
* New third queue display mode (cycle the header button, or pick it in **Settings → Personalisation → Queue display**). Timeline keeps the current track centered with played history above and upcoming tracks below — both visible at once — so it's easy to follow playback and jump back to earlier songs.
* The up-next order respects shuffle, and a "History" / "Up next" divider marks the boundary.
### Offline — unified local playback, library index join, and favorites sync
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1008](https://github.com/Psychotoxical/psysonic/pull/1008)**
* All local audio bytes live under one **`media/`** tree: `cache/` (ephemeral hot-cache), `library/` (user-pinned offline), and `favorites/` (auto-synced stars). Paths use library-index metadata and the URL-derived server index key so two profiles on the same server share one bucket.
* **`localPlaybackStore`** replaces the split hot-cache / offline metadata stores — one index drives prefetch, promotion, eviction, and `psysonic-local://` playback resolution.
* **Offline Library** lists pinned and favorites-tier tracks by joining that index with the SQLite library catalog (no duplicate offline album cards). Pin album, playlist, or artist from browse; disk usage shown in the Offline Library header.
* **Favorites auto-sync** keeps starred tracks on disk in `media/favorites/` with a compact toggle, cross-server reconcile, and cancel-on-unstar so orphaned files are not left behind.
* **Cached offline pins stay in sync** — manually pinned **albums** and **artist discographies** reconcile after a library index sync (delta/full); **regular playlists** reconcile hourly and when edited in-app. Added tracks download and removed ones are pruned. **Smart playlists** (`psy-smart-…`) are excluded — their contents refresh from server rules automatically.
* Mixed-server queues play offline with correct per-track server scope; network guards skip Subsonic when local bytes exist.
* Startup migration from legacy `psysonic-offline/` layout; Settings → Storage uses a single **media directory** picker and a live hot-cache track count.
### Themes — community Theme Store
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1009](https://github.com/Psychotoxical/psysonic/pull/1009), [#1011](https://github.com/Psychotoxical/psysonic/pull/1011), [#1012](https://github.com/Psychotoxical/psysonic/pull/1012), [#1013](https://github.com/Psychotoxical/psysonic/pull/1013), [#1014](https://github.com/Psychotoxical/psysonic/pull/1014), [#1015](https://github.com/Psychotoxical/psysonic/pull/1015), [#1016](https://github.com/Psychotoxical/psysonic/pull/1016), [#1018](https://github.com/Psychotoxical/psysonic/pull/1018), [#1020](https://github.com/Psychotoxical/psysonic/pull/1020)**
* New **Settings → Themes** tab: pick a theme, set the day/night scheduler, and browse a built-in **Theme Store** to install, update and uninstall community themes — with search, a dark/light filter, and full-size thumbnail previews.
* The app now bundles six core themes (Catppuccin Mocha & Latte, Kanagawa Wave, Stark HUD, and the colour-blind-safe Vision Dark / Vision Navy); every other palette installs on demand from the [psysonic-themes](https://github.com/Psysonic/psysonic-themes) repo. Installed themes are saved locally and apply instantly at startup, even offline.
* **Import a theme from a local `.zip`** (manifest.json + theme.css): the package is validated, you confirm its name and author, then it installs like any other community theme.
* Themes are **free-form** — beyond recolouring, they can add their own styling and animations and react to playback / fullscreen / sidebar / lyrics state. A safety floor (no network, no scripts) is always enforced; store themes are reviewed, and imported themes install at your own risk.
* The store paginates large catalogues, and refreshing the list no longer jumps back to the top.
* Upgrading from an older build: an active or scheduled theme that has moved to the store and isn't installed falls back to Mocha (dark) / Latte (light).
### Offline — local-bytes browse when the server is down
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1017](https://github.com/Psychotoxical/psysonic/pull/1017)**
* When the active server is unreachable, browse and detail pages read from **local playback bytes** and the **library index** instead of Subsonic — albums, artists, tracks, cached playlists, and cross-server favorites.
* Single integration contract: `offlineBrowseContext`, `offlineActionPolicy`, and `resolveAlbum` / `resolveArtist` / `resolvePlaylist` resolvers; context menus and detail toolbars block server mutations offline.
* Disconnect navigation forks by offline capability (stay on page, stay-reload, or redirect); Home reuses the last cached feed snapshot; DEV offline toggle simulates full disconnect for testing.
* PlayerBar hides star rating and favorite controls while offline browse is active.
## Changed
### Dependencies — npm and Rust refresh
@@ -41,6 +98,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Playback — Preload Next Track setting removed
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1007](https://github.com/Psychotoxical/psysonic/pull/1007)**
* The **Preload Next Track** toggle and timing modes under **Settings → Storage → Buffering** are gone — ranged streaming now starts playback without that extra RAM prefetch.
* Gapless and crossfade still prefetch the next track internally when Hot Cache is off; Hot Cache is unchanged.
## Fixed
### Servers — complete border on the active server card
@@ -60,6 +126,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Track preview — Symphonia 0.6 format hints and fast stream start
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1006](https://github.com/Psychotoxical/psysonic/pull/1006)**
* Preview resolves container format from HTTP headers, Subsonic `suffix`, and magic-byte sniff so Symphonia 0.6 no longer fails with `.unknown` demuxer errors.
* Preview opens via ranged HTTP when the server supports byte ranges — audio starts after ~384 KiB buffered instead of waiting for a full-file download; buffered fallback uses the same probe seek-gate as main playback.
* Player bar cover guard while preview metadata loads; progress ring leaves the loading spinner once the engine emits `audio:preview-start`.
### Mainstage — hero backdrop stays in sync when skipping albums quickly
**By [@cucadmuh](https://github.com/cucadmuh), reported by Asra on the Psysonic Discord, PR [#1021](https://github.com/Psychotoxical/psysonic/pull/1021)**
* Rapid prev/next clicks on the Mainstage hero no longer leave the blurred cover-art background on the previous album while the foreground cover and metadata already show the next one.
## [1.47.0]
> **🙏 Thank you to our amazing Discord community.** This release would not have been possible without your tireless support, quality checks, bug reports and all-round collaboration. Every report, every repro and every bit of feedback shaped what shipped here — thank you. Come join us: [discord.gg/AMnDRErm4u](https://discord.gg/AMnDRErm4u)
-3
View File
@@ -32,9 +32,6 @@ More translations are added over time.
---
> [!WARNING]
> Psysonic is under active development. Bugs and rough edges can happen, and features may change as the project evolves.
## What is Psysonic?
Psysonic is a desktop music client for self-hosted music libraries. It is designed for people who want the freedom of their own server without giving up the comfort, polish and speed of a modern music app.
+1 -20
View File
@@ -4252,8 +4252,6 @@ dependencies = [
"serde_json",
"tauri",
"tokio",
"twox-hash",
"unicode-normalization",
"wiremock",
]
@@ -4268,6 +4266,7 @@ dependencies = [
"psysonic-analysis",
"psysonic-audio",
"psysonic-core",
"psysonic-library",
"reqwest",
"serde",
"serde_json",
@@ -6603,15 +6602,6 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "twox-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
dependencies = [
"rand 0.9.4",
]
[[package]]
name = "typed-path"
version = "0.12.3"
@@ -6694,15 +6684,6 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-segmentation"
version = "1.13.2"
+2 -1
View File
@@ -6,6 +6,7 @@ resolver = "2"
version = "1.48.0-dev"
edition = "2021"
rust-version = "1.95"
license = "GPL-3.0-or-later"
[workspace.dependencies]
tempfile = "3"
@@ -18,7 +19,7 @@ name = "psysonic"
version.workspace = true
description = "Psysonic Desktop Music Player"
authors = []
license = ""
license.workspace = true
repository = ""
default-run = "psysonic"
edition.workspace = true
+2 -1
View File
@@ -21,6 +21,8 @@ accepted = [
"OpenSSL",
"BSL-1.0",
"CDLA-Permissive-2.0",
"GPL-3.0-or-later",
"bzip2-1.0.6",
]
# Skip the build host's own platform-pinning; we want a list across all targets
@@ -38,5 +40,4 @@ targets = [
ignore-build-dependencies = false
ignore-dev-dependencies = true
ignore-transitive-dependencies = false
filter-noassertion = false
workarounds = ["ring"]
@@ -3,6 +3,7 @@ name = "psysonic-analysis"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -291,7 +291,7 @@ impl AnalysisBackfillQueueState {
}
}
/// Frontend-maintained set of queue-neighbour track ids (next ~5 + preload next).
/// Frontend-maintained set of queue-neighbour track ids (next ~5 in queue).
#[derive(Default)]
pub struct PlaybackPriorityHints {
middle_track_ids: Mutex<HashSet<String>>,
@@ -516,6 +516,145 @@ pub async fn enqueue_track_analysis_from_file(
enqueue_track_analysis(app, server_id, track_id, &bytes, format_hint.as_deref(), priority).await
}
/// Library-tier offline pin: reuse waveform/LUFS cached under the playback index key,
/// plan enrichment under the library UUID, and skip work when both scopes are complete.
pub async fn enqueue_offline_library_analysis_from_file(
app: &tauri::AppHandle,
server_index_key: &str,
library_server_id: &str,
track_id: &str,
file_path: &std::path::Path,
explicit_priority: Option<AnalysisBackfillPriority>,
) -> Result<(), String> {
use tokio::io::AsyncReadExt;
use crate::track_analysis_plan::plan_track_analysis_offline_library;
let mut file = tokio::fs::File::open(file_path)
.await
.map_err(|e| e.to_string())?;
let mut prefix = vec![0u8; 16384];
let n = file.read(&mut prefix).await.map_err(|e| e.to_string())?;
prefix.truncate(n);
if prefix.is_empty() {
return Ok(());
}
let content_hash = analysis_cache::md5_first_16kb(&prefix);
let plan = plan_track_analysis_offline_library(
app,
&[server_index_key, library_server_id],
library_server_id,
track_id,
&content_hash,
);
if !plan.any() {
crate::app_deprintln!(
"[analysis] offline library seed skip (complete) track_id={} index={} library={}",
track_id,
server_index_key,
library_server_id,
);
return Ok(());
}
let bytes = tokio::fs::read(file_path)
.await
.map_err(|e| e.to_string())?;
let format_hint = file_path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.filter(|e| !e.is_empty());
let priority = explicit_priority.unwrap_or_else(|| {
analysis_backfill_resolve_priority(app, server_index_key, track_id, None)
});
enqueue_track_analysis_offline_library_with_plan(OfflineLibraryAnalysisEnqueue {
app,
cache_server_id: server_index_key,
enrichment_server_id: library_server_id,
track_id,
bytes: &bytes,
format_hint: format_hint.as_deref(),
priority,
plan,
fetch_ms: 0,
})
.await?;
Ok(())
}
struct OfflineLibraryAnalysisEnqueue<'a> {
app: &'a tauri::AppHandle,
cache_server_id: &'a str,
enrichment_server_id: &'a str,
track_id: &'a str,
bytes: &'a [u8],
format_hint: Option<&'a str>,
priority: AnalysisBackfillPriority,
plan: psysonic_core::track_analysis::TrackAnalysisPlan,
fetch_ms: u64,
}
async fn enqueue_track_analysis_offline_library_with_plan(
args: OfflineLibraryAnalysisEnqueue<'_>,
) -> Result<EnqueueTrackAnalysisOutcome, String> {
if args.bytes.is_empty() || !args.plan.any() {
return Ok(EnqueueTrackAnalysisOutcome::Complete);
}
let content_hash = analysis_cache::md5_first_16kb(args.bytes);
if args.plan.needs_full_cpu_seed() {
crate::app_deprintln!(
"[analysis] queue full seed track_id={} hash={} need_waveform={} need_loudness={} need_enrichment={}",
args.track_id,
content_hash,
args.plan.need_waveform,
args.plan.need_loudness,
args.plan.enrichment.any()
);
submit_analysis_cpu_seed(
args.app.clone(),
args.cache_server_id.to_string(),
args.track_id.to_string(),
args.bytes.to_vec(),
args.format_hint.map(str::to_string),
args.priority,
args.fetch_ms,
)
.await?;
return Ok(EnqueueTrackAnalysisOutcome::QueuedFullSeed);
}
if args.plan.needs_enrichment_only() {
crate::app_deprintln!(
"[analysis] enrichment-only track_id={} hash={}",
args.track_id,
content_hash
);
let bpm_started = std::time::Instant::now();
let outcome = run_track_enrichment_from_bytes(
args.app,
args.enrichment_server_id,
args.track_id,
args.bytes,
analysis_emits_ui_events(args.priority),
)
.await;
if matches!(outcome, TrackEnrichmentOutcome::Failed) {
if let Some(cache) = args.app.try_state::<analysis_cache::AnalysisCache>() {
let key = analysis_cache::TrackKey {
server_id: args.cache_server_id.to_string(),
track_id: args.track_id.to_string(),
md5_16kb: content_hash.clone(),
};
let _ = cache.touch_track_status(&key, "failed");
}
return Err("track enrichment failed".to_string());
}
let bpm_ms = bpm_started.elapsed().as_millis() as u64;
emit_analysis_track_perf(args.app, args.track_id, args.fetch_ms, 0, bpm_ms);
return Ok(EnqueueTrackAnalysisOutcome::RanEnrichmentOnly);
}
Ok(EnqueueTrackAnalysisOutcome::Complete)
}
/// Decode `bytes` for `track_id` via the cpu-seed queue. Prefer [`enqueue_track_analysis`].
pub async fn enqueue_analysis_seed(
app: &tauri::AppHandle,
@@ -15,8 +15,21 @@ pub fn plan_track_analysis(
track_id: &str,
content_hash: &str,
) -> TrackAnalysisPlan {
let (need_waveform, need_loudness) = cache_gaps(app, server_id, track_id, content_hash);
let enrichment = enrichment_plan(app, server_id, track_id, content_hash);
plan_track_analysis_offline_library(app, &[server_id], server_id, track_id, content_hash)
}
/// Offline/library download: waveform cache and enrichment facts may live under the
/// playback index key while library rows use the UUID — try every scope before seeding.
pub fn plan_track_analysis_offline_library(
app: &AppHandle,
cache_server_ids: &[&str],
_enrichment_server_id: &str,
track_id: &str,
content_hash: &str,
) -> TrackAnalysisPlan {
let (need_waveform, need_loudness) =
cache_gaps_multi(app, cache_server_ids, track_id, content_hash);
let enrichment = enrichment_plan_multi(app, cache_server_ids, track_id, content_hash);
TrackAnalysisPlan {
need_waveform,
need_loudness,
@@ -103,6 +116,32 @@ fn cache_gaps(
)
}
fn cache_gaps_multi(
app: &AppHandle,
server_ids: &[&str],
track_id: &str,
content_hash: &str,
) -> (bool, bool) {
let mut need_waveform = true;
let mut need_loudness = true;
for &server_id in server_ids {
if server_id.is_empty() {
continue;
}
let (nw, nl) = cache_gaps(app, server_id, track_id, content_hash);
if !nw {
need_waveform = false;
}
if !nl {
need_loudness = false;
}
if !need_waveform && !need_loudness {
break;
}
}
(need_waveform, need_loudness)
}
fn enrichment_plan(
app: &AppHandle,
server_id: &str,
@@ -117,6 +156,45 @@ fn enrichment_plan(
.unwrap_or_default()
}
fn enrichment_plan_multi(
app: &AppHandle,
server_ids: &[&str],
track_id: &str,
content_hash: &str,
) -> psysonic_core::track_enrichment::TrackEnrichmentPlan {
let mut need_bpm = true;
let mut need_valence = true;
let mut need_arousal = true;
let mut need_moods = true;
for &server_id in server_ids {
if server_id.is_empty() {
continue;
}
let plan = enrichment_plan(app, server_id, track_id, content_hash);
if !plan.need_bpm {
need_bpm = false;
}
if !plan.need_valence {
need_valence = false;
}
if !plan.need_arousal {
need_arousal = false;
}
if !plan.need_moods {
need_moods = false;
}
if !need_bpm && !need_valence && !need_arousal && !need_moods {
break;
}
}
psysonic_core::track_enrichment::TrackEnrichmentPlan {
need_bpm,
need_valence,
need_arousal,
need_moods,
}
}
fn cache_gaps_for_content(
cache: Option<&AnalysisCache>,
server_id: &str,
@@ -194,4 +272,14 @@ mod tests {
assert!(!wf && !ld, "bare id should resolve stream: cached fingerprint");
}
#[test]
fn playback_index_cache_row_not_visible_under_library_uuid_only() {
let cache = AnalysisCache::open_in_memory();
seed_waveform_loudness(&cache, "navidrome.test:4533", "t1", "abc");
let (wf, ld) = cache_gaps_for_content(Some(&cache), "library-uuid", "t1", "abc");
assert!(wf && ld, "library uuid alone should miss playback-scoped cache");
let (wf2, ld2) = cache_gaps_for_content(Some(&cache), "navidrome.test:4533", "t1", "abc");
assert!(!wf2 && !ld2, "playback index key should hit the cached row");
}
}
@@ -3,6 +3,7 @@ name = "psysonic-audio"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
+17 -4
View File
@@ -166,15 +166,24 @@ impl SizedDecoder {
inner: Cursor::new(data),
len: data_len,
};
// Symphonia 0.6 scans trailing metadata on seekable sources — hide
// seekability during probe (same as `new_streaming`) so preview does not
// read the entire in-memory file before the first sample.
let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint))
.then(|| Arc::new(AtomicBool::new(false)));
let media: Box<dyn MediaSource> = match &probe_seek_gate {
Some(gate) => Box::new(ProbeSeekGate {
inner: Box::new(source),
seekable: gate.clone(),
}),
None => Box::new(source),
};
// Hi-Res: 4 MB read-ahead so Symphonia demuxes fewer Read calls for
// high-bitrate files (88.2 kHz/24-bit FLAC ≈ 1800 kbps).
// Standard: 512 KB is plenty for MP3/AAC — larger buffers waste allocation
// and compete with the playback thread at track start.
let buf_len = if hi_res { 4 * 1024 * 1024 } else { 512 * 1024 };
let mss = MediaSourceStream::new(
Box::new(source) as Box<dyn MediaSource>,
MediaSourceStreamOptions { buffer_len: buf_len },
);
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: buf_len });
let mut hint = Hint::new();
if let Some(ext) = format_hint {
@@ -200,6 +209,10 @@ impl SizedDecoder {
}
})?;
if let Some(gate) = &probe_seek_gate {
gate.store(true, Ordering::Relaxed);
}
let track = format
.tracks()
.iter()
+215 -57
View File
@@ -1,6 +1,6 @@
//! Short preview playback on a secondary sink (same output stream).
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use rodio::Player;
@@ -10,11 +10,16 @@ use tauri::{AppHandle, Emitter, State};
use super::decode::SizedDecoder;
use super::engine::{audio_http_client, AudioEngine};
use super::helpers::{
content_type_to_hint, format_hint_from_content_disposition, resolve_playback_format_hint,
content_type_to_hint, format_hint_from_content_disposition, normalize_stream_suffix_for_hint,
resolve_playback_format_hint, sniff_stream_format_extension, STREAM_FORMAT_SNIFF_PROBE_BYTES,
MASTER_HEADROOM,
};
use super::play_input::url_format_hint;
use super::sources::PriorityBoostSource;
use super::stream::{
mp4_needs_tail_prefetch, ranged_download_task, wait_for_ranged_mp4_probe_ready,
RangedHttpSource, RangedMp4ProbeGate,
};
// ────────────────────────────────────────────────────────────────────────────
// Preview engine — secondary Sink on the same OutputStream, fed by Symphonia.
@@ -132,7 +137,201 @@ pub(crate) fn resolve_preview_format_hint(
)
}
fn preview_http_client(state: &AudioEngine) -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.use_rustls_tls()
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.build()
.unwrap_or_else(|_| audio_http_client(state))
}
/// Open a preview decoder — ranged HTTP when the server supports it (starts
/// after ~384 KiB buffered), otherwise falls back to a full in-memory download.
async fn open_preview_decoder(
url: &str,
format_suffix: Option<&str>,
gen: u64,
state: &AudioEngine,
app: &AppHandle,
) -> Result<Option<SizedDecoder>, String> {
let preview_http = preview_http_client(state);
let response = preview_http
.get(url)
.send()
.await
.map_err(|e| format!("preview: connection failed: {e}"))?
.error_for_status()
.map_err(|e| format!("preview: HTTP {e}"))?;
let mut stream_hint = content_type_to_hint(
response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or(""),
)
.or_else(|| {
response
.headers()
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.and_then(format_hint_from_content_disposition)
})
.or_else(|| normalize_stream_suffix_for_hint(format_suffix))
.or_else(|| preview_format_hint_from_url(url))
.or_else(|| url_format_hint(url));
let supports_range = response
.headers()
.get(reqwest::header::ACCEPT_RANGES)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.to_ascii_lowercase().contains("bytes"));
let total_size = response.content_length();
if stream_hint.is_none() && supports_range {
if let Some(total_u64) = total_size.filter(|&t| t > 0) {
let last = total_u64
.saturating_sub(1)
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
if let Ok(pr) = preview_http
.get(url)
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
.send()
.await
{
let stat = pr.status();
let ok = stat == reqwest::StatusCode::PARTIAL_CONTENT
|| stat == reqwest::StatusCode::OK;
if ok {
if let Ok(bytes) = pr.bytes().await {
if !bytes.is_empty() {
stream_hint = sniff_stream_format_extension(&bytes).or(stream_hint);
}
}
}
}
}
}
if let (true, Some(total), true) = (supports_range, total_size, stream_hint.is_some()) {
if state.preview_gen.load(Ordering::SeqCst) != gen {
return Ok(None);
}
let total_usize = total as usize;
crate::app_deprintln!(
"[preview] ranged open — total={} KB, hint={:?}",
total_usize / 1024,
stream_hint
);
let buf = Arc::new(Mutex::new(vec![0u8; total_usize]));
let downloaded_to = Arc::new(AtomicUsize::new(0));
let done = Arc::new(AtomicBool::new(false));
let playback_armed = Arc::new(AtomicBool::new(false));
let tail_ready = Arc::new(AtomicBool::new(false));
let tail_filled_from = Arc::new(AtomicU64::new(0));
let tail_prefetch = mp4_needs_tail_prefetch(&[], stream_hint.as_deref());
let mp4_probe_gate = tail_prefetch.then(|| RangedMp4ProbeGate {
tail_ready: tail_ready.clone(),
buf: buf.clone(),
downloaded_to: downloaded_to.clone(),
gen_arc: state.preview_gen.clone(),
gen,
format_hint: stream_hint.clone(),
});
tokio::spawn(ranged_download_task(
gen,
state.preview_gen.clone(),
preview_http,
app.clone(),
0.0,
url.to_string(),
response,
buf.clone(),
downloaded_to.clone(),
done.clone(),
state.stream_completed_cache.clone(),
state.stream_completed_spill.clone(),
state.normalization_engine.clone(),
state.normalization_target_lufs.clone(),
state.loudness_pre_analysis_attenuation_db.clone(),
None,
None,
None,
playback_armed,
stream_hint.clone(),
tail_ready.clone(),
tail_filled_from.clone(),
));
if let Some(ref gate) = mp4_probe_gate {
wait_for_ranged_mp4_probe_ready(gate).await?;
if state.preview_gen.load(Ordering::SeqCst) != gen {
return Ok(None);
}
}
let reader = RangedHttpSource {
buf,
downloaded_to,
tail_ready,
tail_filled_from,
total_size: total,
pos: 0,
done,
gen_arc: state.preview_gen.clone(),
gen,
};
let hint = stream_hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream")
})
.await
.map_err(|e| format!("preview: decoder thread: {e}"))??;
return Ok(Some(decoder));
}
crate::app_deprintln!(
"[preview] buffered download — accept-ranges={}, content-length={:?}, hint={:?}",
supports_range,
total_size,
stream_hint
);
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let content_disposition = response
.headers()
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let bytes = response
.bytes()
.await
.map_err(|e| format!("preview: read body: {e}"))?
.to_vec();
if state.preview_gen.load(Ordering::SeqCst) != gen {
return Ok(None);
}
let hint = resolve_preview_format_hint(
url,
content_type.as_deref(),
content_disposition.as_deref(),
format_suffix,
&bytes,
);
let bytes_for_blocking = bytes;
let hint_for_blocking = hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false)
})
.await
.map_err(|e| format!("preview: decoder thread: {e}"))??;
Ok(Some(decoder))
}
#[tauri::command]
#[allow(clippy::too_many_arguments)] // Tauri IPC — args map 1:1 to the JS invoke payload.
pub async fn audio_preview_play(
id: String,
url: String,
@@ -164,65 +363,24 @@ pub async fn audio_preview_play(
preview_pause_main(&state);
}
// ── Download ─────────────────────────────────────────────────────────────
// Dedicated client with a generous timeout. The shared `audio_http_client`
// caps at 30 s, which aborts mid-download on multi-hundred-megabyte
// uncompressed files (e.g. 18-min Hi-Res WAV ~600 MB) — those need
// ~60120 s on a typical home LAN. The watchdog (30 s wall-clock) still
// bounds how long the preview plays once the bytes are in memory, so a
// long download just means a longer "loading" spinner before audio starts.
let preview_http = reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.use_rustls_tls()
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.build()
.unwrap_or_else(|_| audio_http_client(&state));
let response = preview_http
.get(&url)
.send()
.await
.map_err(|e| format!("preview: connection failed: {e}"))?
.error_for_status()
.map_err(|e| format!("preview: HTTP {e}"))?;
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let content_disposition = response
.headers()
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let bytes = response
.bytes()
.await
.map_err(|e| format!("preview: read body: {e}"))?
.to_vec();
// ── Open decoder (ranged stream when possible) ───────────────────────────
let decoder = match open_preview_decoder(
&url,
format_suffix.as_deref(),
gen,
&state,
&app,
)
.await?
{
Some(d) => d,
None => return Ok(()),
};
if state.preview_gen.load(Ordering::SeqCst) != gen {
// A newer preview started while we were downloading — bail.
return Ok(());
}
// ── Decode ───────────────────────────────────────────────────────────────
let hint = resolve_preview_format_hint(
&url,
content_type.as_deref(),
content_disposition.as_deref(),
format_suffix.as_deref(),
&bytes,
);
let bytes_for_blocking = bytes;
let hint_for_blocking = hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false)
})
.await
.map_err(|e| format!("preview: decoder thread: {e}"))??;
if state.preview_gen.load(Ordering::SeqCst) != gen { return Ok(()); }
// ── Build source pipeline ────────────────────────────────────────────────
// Seek FIRST on the bare decoder, THEN cap with take_duration. Capping
// before the seek made take_duration's wall-clock counter tick from
@@ -3,6 +3,7 @@ name = "psysonic-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -29,17 +29,38 @@ pub fn is_fetch_only_cover_id(id: &str) -> bool {
|| id.starts_with("ra-")
}
/// Windows reserved device names (case-insensitive) — invalid as path components.
const WINDOWS_RESERVED_NAMES: &[&str] = &[
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
/// Sanitize a single path segment for Windows / Unix (Navidrome ids are usually already safe).
/// Also used for media layout artist/album/title segments from server metadata.
pub fn sanitize_path_segment(segment: &str) -> String {
const FORBIDDEN: &[char] = &['\\', '/', ':', '*', '?', '"', '<', '>', '|'];
let trimmed = segment.trim();
let trimmed = segment.trim().trim_end_matches(['.', ' ']).to_string();
if trimmed.is_empty() {
return "_".to_string();
}
trimmed
let cleaned: String = trimmed
.chars()
.map(|c| if FORBIDDEN.contains(&c) { '_' } else { c })
.collect()
.map(|c| {
if c.is_control() || FORBIDDEN.contains(&c) {
'_'
} else {
c
}
})
.collect();
if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
return "_".to_string();
}
let upper = cleaned.to_ascii_uppercase();
if WINDOWS_RESERVED_NAMES.contains(&upper.as_str()) {
return format!("_{cleaned}");
}
cleaned
}
/// Relative path under `{root}/{server_segment}/` — change format here only.
@@ -256,6 +277,13 @@ mod tests {
base
}
#[test]
fn sanitize_rejects_dot_dot_and_reserved_names() {
assert_eq!(sanitize_path_segment(".."), "_");
assert_eq!(sanitize_path_segment("CON"), "_CON");
assert_eq!(sanitize_path_segment(" trailing. "), "trailing");
}
#[test]
fn segment_disk_usage_counts_canonical_only() {
let server = test_server_dir("usage");
@@ -5,6 +5,7 @@
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
pub mod cover_cache_layout;
pub mod media_layout;
pub mod logging;
pub mod ports;
pub mod track_analysis;
@@ -0,0 +1,364 @@
//! Local playback disk layout — artist/album/track paths from library-index fields.
//!
//! Mirrors the contract in `implementation-spec.md` (local playback unification).
//! `server_segment` uses [`cover_cache_layout::sanitize_path_segment`] on the URL
//! index key; artist/album/filename segments are derived from track metadata only.
use std::path::{Component, Path, PathBuf};
use crate::cover_cache_layout::sanitize_path_segment;
/// Max length for a single path component after sanitization (Windows budget).
pub const MAX_SEGMENT_LEN: usize = 120;
/// Inputs required to build hierarchical media paths (library index row projection).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrackPathInput {
pub artist: Option<String>,
pub album_artist: Option<String>,
pub album: String,
pub title: String,
pub track_number: Option<i64>,
pub disc_number: Option<i64>,
pub suffix: Option<String>,
/// When set, used to detect compilation albums from `raw_json` (OpenSubsonic).
pub raw_json: Option<String>,
}
/// Tier subdirectory under the media root (`cache/`, `library/`, or `favorites/`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocalTier {
Ephemeral,
Library,
/// Auto-synced starred favorites — separate from user-pinned `library/`.
Favorites,
}
impl LocalTier {
pub fn subdir(self) -> &'static str {
match self {
Self::Ephemeral => "cache",
Self::Library => "library",
Self::Favorites => "favorites",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"ephemeral" | "cache" => Some(Self::Ephemeral),
"library" => Some(Self::Library),
"favorites" | "favorite-auto" | "favorite_auto" => Some(Self::Favorites),
_ => None,
}
}
}
/// Stable fingerprint for invalidation when library metadata changes (§8 spec).
pub fn layout_fingerprint(input: &TrackPathInput) -> String {
let artist_seg = artist_folder_segment(input);
let album_seg = album_folder_segment(&input.album);
let stem = track_filename_stem(input);
let suffix = input
.suffix
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("");
let track_n = input.track_number.unwrap_or(0);
let disc_n = input.disc_number.unwrap_or(0);
format!(
"artist={artist_seg}|album_artist={}|album={album_seg}|title={}|track={track_n}|disc={disc_n}|stem={stem}|suffix={suffix}",
input
.album_artist
.as_deref()
.map(str::trim)
.unwrap_or(""),
input.title.trim(),
)
}
/// Relative path under `{tier}/{server_segment}/`: `{artist}/{album}/{file}.{suffix}`.
pub fn relative_path_for_track(
server_index_key: &str,
input: &TrackPathInput,
suffix: &str,
) -> PathBuf {
let server_segment = sanitize_path_segment(server_index_key);
let artist = artist_folder_segment(input);
let album = album_folder_segment(&input.album);
let stem = track_filename_stem(input);
let ext = suffix.trim().trim_start_matches('.');
let filename = if ext.is_empty() {
sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN)
} else {
format!(
"{}.{}",
sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN),
sanitize_path_segment(ext)
)
};
PathBuf::from(server_segment)
.join(artist)
.join(album)
.join(filename)
}
/// Absolute file path: `{media_root}/{tier}/…relative_path…`.
pub fn absolute_track_path(
media_root: &Path,
tier: LocalTier,
server_index_key: &str,
input: &TrackPathInput,
suffix: &str,
) -> PathBuf {
media_root
.join(tier.subdir())
.join(relative_path_for_track(server_index_key, input, suffix))
}
/// Defense-in-depth: resolved paths must stay under `{media_root}/{tier}/`.
pub fn ensure_track_path_within_tier(
media_root: &Path,
tier: LocalTier,
absolute: &Path,
) -> Result<(), String> {
let tier_root = media_root.join(tier.subdir());
let Ok(rel) = absolute.strip_prefix(&tier_root) else {
return Err(format!(
"path `{}` escapes tier root `{}`",
absolute.display(),
tier_root.display()
));
};
for comp in rel.components() {
if matches!(comp, Component::ParentDir | Component::RootDir | Component::Prefix(_)) {
return Err(format!(
"path `{}` contains forbidden component `{comp:?}`",
absolute.display()
));
}
}
Ok(())
}
fn artist_folder_segment(input: &TrackPathInput) -> String {
let artist = input.artist.as_deref().map(str::trim).unwrap_or("");
let album_artist = input.album_artist.as_deref().map(str::trim).unwrap_or("");
let chosen = if artist.is_empty() || track_is_compilation(input) {
if !album_artist.is_empty() {
album_artist
} else {
"Various Artists"
}
} else {
artist
};
sanitize_and_truncate_segment(chosen, MAX_SEGMENT_LEN)
}
fn album_folder_segment(album: &str) -> String {
let trimmed = album.trim();
let fallback = if trimmed.is_empty() { "Unknown Album" } else { trimmed };
sanitize_and_truncate_segment(fallback, MAX_SEGMENT_LEN)
}
fn track_filename_stem(input: &TrackPathInput) -> String {
let title = input.title.trim();
let title = if title.is_empty() { "Unknown Title" } else { title };
let track_n = input.track_number.unwrap_or(0).max(0) as u32;
let disc_n = input.disc_number.unwrap_or(1).max(0) as u32;
if disc_n > 1 {
format!("{disc_n:02}-{track_n:02} - {title}")
} else {
format!("{track_n:02} - {title}")
}
}
fn track_is_compilation(input: &TrackPathInput) -> bool {
if various_artists_label(input.artist.as_deref().unwrap_or("")) {
return true;
}
let Some(raw) = input.raw_json.as_deref().filter(|s| !s.is_empty()) else {
return false;
};
raw_json_marks_compilation(raw)
}
/// Best-effort probe aligned with `album_compilation_filter::compilation_raw_json_sql`.
fn raw_json_marks_compilation(raw: &str) -> bool {
let lower = raw.to_ascii_lowercase();
lower.contains("\"iscompilation\":true")
|| lower.contains("\"iscompilation\": true")
|| lower.contains("\"compilation\":true")
|| lower.contains("\"compilation\": true")
|| lower.contains("\"compilation\":1")
|| lower.contains("\"releaseTypes\"") && lower.contains("compilation")
}
fn various_artists_label(s: &str) -> bool {
let lower = s.trim().to_ascii_lowercase();
lower.contains("various artists")
}
fn sanitize_and_truncate_segment(segment: &str, max_len: usize) -> String {
let sanitized = sanitize_path_segment(segment);
// Code points — keep in sync with `[...sanitized].length` in `mediaLayout.ts`.
if sanitized.chars().count() <= max_len {
return sanitized;
}
let hash = short_hash(segment);
let keep = max_len.saturating_sub(1 + hash.len());
let mut out = sanitized.chars().take(keep).collect::<String>();
out.push('_');
out.push_str(&hash);
out
}
/// Keep in sync with `shortHash` in `src/utils/media/mediaLayout.ts` (UTF-16 code units).
fn short_hash(s: &str) -> String {
let mut h: u32 = 0;
for unit in s.encode_utf16() {
h = h.wrapping_mul(31).wrapping_add(unit as u32);
}
format!("{:08x}", h)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_input() -> TrackPathInput {
TrackPathInput {
artist: Some("Radiohead".to_string()),
album_artist: None,
album: "OK Computer".to_string(),
title: "Paranoid Android".to_string(),
track_number: Some(6),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
}
}
#[test]
fn relative_path_uses_library_segments() {
let rel = relative_path_for_track("host:4533", &sample_input(), "mp3");
assert_eq!(
rel,
PathBuf::from("host_4533")
.join("Radiohead")
.join("OK Computer")
.join("06 - Paranoid Android.mp3")
);
}
#[test]
fn multi_disc_adds_disc_prefix() {
let mut input = sample_input();
input.disc_number = Some(2);
let rel = relative_path_for_track("srv", &input, "flac");
assert!(rel
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("02-06 - Paranoid Android.flac")));
}
#[test]
fn compilation_uses_album_artist_folder() {
let input = TrackPathInput {
artist: Some("Various Artists".to_string()),
album_artist: Some("Original Soundtrack".to_string()),
album: "Film Score".to_string(),
title: "Main Theme".to_string(),
track_number: Some(1),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
};
let rel = relative_path_for_track("srv", &input, "mp3");
assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Original Soundtrack"));
}
#[test]
fn empty_artist_falls_back_to_various_artists() {
let input = TrackPathInput {
artist: None,
album_artist: None,
album: "Comp".to_string(),
title: "Song".to_string(),
track_number: Some(1),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
};
let rel = relative_path_for_track("srv", &input, "mp3");
assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Various Artists"));
}
#[test]
fn layout_fingerprint_is_stable() {
let a = layout_fingerprint(&sample_input());
let b = layout_fingerprint(&sample_input());
assert_eq!(a, b);
assert!(a.contains("Radiohead"));
assert!(a.contains("OK Computer"));
}
#[test]
fn tier_subdirs_are_fixed() {
assert_eq!(LocalTier::Ephemeral.subdir(), "cache");
assert_eq!(LocalTier::Library.subdir(), "library");
assert_eq!(LocalTier::Favorites.subdir(), "favorites");
assert_eq!(LocalTier::parse("ephemeral"), Some(LocalTier::Ephemeral));
assert_eq!(LocalTier::parse("library"), Some(LocalTier::Library));
assert_eq!(LocalTier::parse("favorite-auto"), Some(LocalTier::Favorites));
}
#[test]
fn absolute_path_includes_tier() {
let root = Path::new("/media");
let path = absolute_track_path(root, LocalTier::Library, "srv", &sample_input(), "mp3");
assert!(path.starts_with(root.join("library")));
}
#[test]
fn dot_dot_metadata_does_not_escape_tier_root() {
let input = TrackPathInput {
artist: Some("..".to_string()),
album_artist: None,
album: "..".to_string(),
title: "Song".to_string(),
track_number: Some(1),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
};
let root = Path::new("/media");
let path = absolute_track_path(root, LocalTier::Library, "srv", &input, "mp3");
assert!(path.starts_with(root.join("library")));
ensure_track_path_within_tier(root, LocalTier::Library, &path).unwrap();
}
#[test]
fn short_hash_matches_ts_imul31_utf16() {
// "Radiohead" — same as mediaLayout.test parity anchor.
assert_eq!(short_hash("Radiohead"), "3da68c3b");
}
#[test]
fn sanitize_and_truncate_uses_code_point_threshold() {
let cyrillic_a = '\u{0430}';
let hundred: String = std::iter::repeat_n(cyrillic_a, 100).collect();
assert!(hundred.len() > MAX_SEGMENT_LEN);
assert_eq!(hundred.chars().count(), 100);
assert_eq!(
sanitize_and_truncate_segment(&hundred, MAX_SEGMENT_LEN),
hundred
);
let long: String = std::iter::repeat_n(cyrillic_a, 130).collect();
let truncated = sanitize_and_truncate_segment(&long, MAX_SEGMENT_LEN);
assert!(truncated.ends_with("_eef20600"));
assert_eq!(truncated.chars().count(), MAX_SEGMENT_LEN);
}
}
@@ -3,6 +3,7 @@ name = "psysonic-integration"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
+1 -2
View File
@@ -3,6 +3,7 @@ name = "psysonic-library"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -15,8 +16,6 @@ serde_json = "1"
rusqlite = { version = "0.40", features = ["bundled"] }
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls", "gzip", "brotli"] }
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "macros", "time"] }
twox-hash = "2"
unicode-normalization = "0.1"
[dev-dependencies]
tokio = { version = "1", features = ["rt", "macros", "rt-multi-thread", "test-util"] }
@@ -1,10 +0,0 @@
-- Plain All Albums browse: read from `album` with ORDER BY name (not track GROUP BY).
CREATE INDEX IF NOT EXISTS idx_album_server_name_browse
ON album(server_id, name COLLATE NOCASE);
-- Scoped album EXISTS probes: (server, album, library) on live tracks.
CREATE INDEX IF NOT EXISTS idx_track_server_album_library_browse
ON track(server_id, album_id, library_id)
WHERE deleted = 0
AND album_id IS NOT NULL
AND album_id != '';
@@ -22,68 +22,11 @@ use crate::filter::{self, EntityKind, FilterOp, SqlFragment};
use crate::repos;
use crate::search::{
aliased_track_columns, aliased_track_columns_resolved_bpm, bpm_resolved_expr,
fts_album_prefix_any_token_match_query, fts_album_title_prefix_any_token_match_query,
fts_column_prefix_query, fts_query_meets_min_len, fts_track_prefix_match_query,
library_scope_filter_sql, like_any_token_contains_clause, like_contains, like_name_tokens,
PAGE_LIMIT_MAX,
fts_album_prefix_match_query, fts_album_title_prefix_match_query, fts_column_prefix_query, fts_query_meets_min_len,
fts_track_prefix_match_query, library_scope_equals_sql, like_contains, PAGE_LIMIT_MAX,
};
use crate::store::LibraryStore;
fn effective_library_scope_ids(req: &LibraryAdvancedSearchRequest) -> Vec<String> {
if let Some(ids) = &req.library_scope_ids {
let trimmed: Vec<_> = ids
.iter()
.filter(|s| !s.trim().is_empty())
.cloned()
.collect();
if !trimmed.is_empty() {
return trimmed;
}
}
trimmed_nonempty(req.library_scope.as_deref())
.map(|s| vec![s.to_string()])
.unwrap_or_default()
}
fn push_library_scope_where(w: &mut WhereBuilder, table: &str, scope_ids: &[String]) {
if scope_ids.is_empty() {
return;
}
let (sql, params) = library_scope_filter_sql(table, scope_ids);
if let Some(clause) = sql {
if params.len() == 1 {
w.push_param(&clause, params[0].clone());
} else {
w.push_params(&clause, params);
}
}
}
/// `album` rows have no `library_id`; scope is enforced via matching tracks.
fn push_album_table_library_scope(
w: &mut WhereBuilder,
album_alias: &str,
scope_ids: &[String],
) {
if scope_ids.is_empty() {
return;
}
let (clause, params) = library_scope_filter_sql("t_scope", scope_ids);
let Some(scope_clause) = clause else {
return;
};
w.push_params(
&format!(
"EXISTS (SELECT 1 FROM track t_scope \
WHERE t_scope.server_id = {album_alias}.server_id \
AND t_scope.album_id = {album_alias}.id \
AND t_scope.deleted = 0 \
AND {scope_clause})"
),
params,
);
}
/// `bpm` dual-storage resolution (§5.13.4): prefer analysis `track_fact(bpm)`,
/// then hot `track.bpm` tag, then other fact sources.
fn bpm_resolved_sql() -> String {
@@ -118,8 +61,8 @@ fn fts_candidate_pool_size(limit: u32, offset: u32) -> i64 {
need.saturating_mul(20).clamp(256, 10_000)
}
/// FTS rowid pick scoped to the active server (and optional library folders).
fn scoped_fts_rowid_subquery_sql(pool: i64, scope_ids: &[String]) -> String {
/// FTS rowid pick scoped to the active server (and optional library folder).
fn scoped_fts_rowid_subquery_sql(pool: i64, library_scope: Option<&str>) -> String {
let alias = "t_fts";
let mut sql = format!(
"SELECT f.rowid FROM track_fts f \
@@ -128,21 +71,20 @@ fn scoped_fts_rowid_subquery_sql(pool: i64, scope_ids: &[String]) -> String {
AND {alias}.server_id = ? \
AND {alias}.deleted = 0"
);
if let (Some(clause), _) = library_scope_filter_sql(alias, scope_ids) {
if library_scope.is_some() {
sql.push_str(" AND ");
sql.push_str(&clause);
sql.push_str(&library_scope_equals_sql(alias));
}
sql.push_str(&format!(" ORDER BY bm25(track_fts) LIMIT {pool}"));
sql
}
fn scoped_fts_pick_join_sql(pool: i64, scope_ids: &[String]) -> String {
fn scoped_fts_pick_join_sql(pool: i64, library_scope: Option<&str>) -> String {
let alias = "t_fts";
let scope_sql = if let (Some(clause), _) = library_scope_filter_sql(alias, scope_ids) {
format!(" AND {clause}")
} else {
String::new()
};
let mut scope_sql = String::new();
if library_scope.is_some() {
scope_sql = format!(" AND {}", library_scope_equals_sql(alias));
}
format!(
"track t INNER JOIN (\
SELECT f.rowid, bm25(track_fts) AS fts_rank \
@@ -157,10 +99,14 @@ fn scoped_fts_pick_join_sql(pool: i64, scope_ids: &[String]) -> String {
)
}
fn scoped_fts_subquery_bind(server_id: &str, scope_ids: &[String]) -> Vec<SqlValue> {
fn scoped_fts_subquery_bind(
server_id: &str,
library_scope: Option<&str>,
) -> Vec<SqlValue> {
let mut params = vec![SqlValue::Text(server_id.to_string())];
let (_, scope_params) = library_scope_filter_sql("t_fts", scope_ids);
params.extend(scope_params);
if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) {
params.push(SqlValue::Text(scope.to_string()));
}
params
}
@@ -271,7 +217,10 @@ fn build_track(
let mut w = WhereBuilder::new();
w.push_raw("t.deleted = 0");
w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone()));
push_library_scope_where(&mut w, "t", &effective_library_scope_ids(req));
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
let clause = library_scope_equals_sql("t");
w.push_param(&clause, SqlValue::Text(scope));
}
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Track)? {
applied.insert(c.field.clone());
@@ -297,8 +246,8 @@ fn build_track(
if let Some(q) = text.and_then(fts_track_prefix_match_query) {
applied.insert("text".to_string());
let pool = fts_candidate_pool_size(limit, offset);
let scope_ids = effective_library_scope_ids(req);
let from = scoped_fts_pick_join_sql(pool, &scope_ids);
let scope = trimmed_nonempty(req.library_scope.as_deref());
let from = scoped_fts_pick_join_sql(pool, scope.as_deref());
let order = order_clause(&req.sort, EntityKind::Track)
.unwrap_or_else(|| "ORDER BY fts_pick.fts_rank".to_string());
return query_rows_fts(
@@ -306,7 +255,7 @@ fn build_track(
&cols,
&from,
&q,
&scoped_fts_subquery_bind(&req.server_id, &scope_ids),
&scoped_fts_subquery_bind(&req.server_id, scope.as_deref()),
&w,
&order,
limit,
@@ -358,208 +307,12 @@ fn server_has_indexed_tracks(store: &LibraryStore, server_id: &str) -> Result<bo
fn fts_album_text_match_query(req: &LibraryAdvancedSearchRequest, text: &str) -> Option<String> {
if req.query_album_title_only == Some(true) {
fts_album_title_prefix_any_token_match_query(text)
fts_album_title_prefix_match_query(text)
} else {
fts_album_prefix_any_token_match_query(text)
fts_album_prefix_match_query(text)
}
}
fn push_album_name_like_any_token(
w: &mut WhereBuilder,
column: &str,
text: &str,
applied: &mut BTreeSet<String>,
) {
let Some((sql, params)) = like_any_token_contains_clause(column, text) else {
return;
};
w.push_params(
&sql,
params.into_iter().map(SqlValue::Text).collect(),
);
applied.insert("text".to_string());
}
/// `album` row or any child track tag may carry the searchable title.
fn push_album_table_text_match(
w: &mut WhereBuilder,
text: &str,
applied: &mut BTreeSet<String>,
) {
let tokens = like_name_tokens(text);
if tokens.is_empty() {
return;
}
let mut parts = Vec::new();
let mut params = Vec::new();
for token in tokens {
let pat = like_contains(&token);
parts.push(
"(a.name COLLATE NOCASE LIKE ? ESCAPE '\\' \
OR EXISTS (SELECT 1 FROM track t_mt \
WHERE t_mt.server_id = a.server_id AND t_mt.album_id = a.id \
AND t_mt.deleted = 0 \
AND t_mt.album COLLATE NOCASE LIKE ? ESCAPE '\\'))".to_string(),
);
params.push(SqlValue::Text(pat.clone()));
params.push(SqlValue::Text(pat));
}
w.push_params(&format!("({})", parts.join(" OR ")), params);
applied.insert("text".to_string());
}
/// Track group match: hot `t.album` tag or synced `album.name`.
fn push_track_group_text_match(
w: &mut WhereBuilder,
text: &str,
applied: &mut BTreeSet<String>,
) {
let tokens = like_name_tokens(text);
if tokens.is_empty() {
return;
}
let mut parts = Vec::new();
let mut params = Vec::new();
for token in tokens {
let pat = like_contains(&token);
parts.push(
"(t.album COLLATE NOCASE LIKE ? ESCAPE '\\' \
OR EXISTS (SELECT 1 FROM album a_mt \
WHERE a_mt.server_id = t.server_id AND a_mt.id = t.album_id \
AND a_mt.name COLLATE NOCASE LIKE ? ESCAPE '\\'))".to_string(),
);
params.push(SqlValue::Text(pat.clone()));
params.push(SqlValue::Text(pat));
}
w.push_params(&format!("({})", parts.join(" OR ")), params);
applied.insert("text".to_string());
}
/// Synced `album` rows + scope filters — plain browse only (no free-text query).
fn try_build_album_from_table(
store: &LibraryStore,
req: &LibraryAdvancedSearchRequest,
scalar: &[&LibraryFilterClause],
limit: u32,
offset: u32,
skip_totals: bool,
applied: &mut BTreeSet<String>,
) -> Result<Option<(Vec<LibraryAlbumDto>, u32)>, String> {
if scalar_requires_lossless_track_grouping(scalar)
|| scalar_requires_track_derived_entities(scalar)
{
return Ok(None);
}
if !crate::album_browse::album_table_usable(store, &req.server_id)? {
return Ok(None);
}
let table = build_album_from_table(store, req, None, scalar, limit, offset, skip_totals, applied)?;
if !table.0.is_empty() || table.1 > 0 {
return Ok(Some(table));
}
Ok(None)
}
fn album_text_hit_key(a: &LibraryAlbumDto) -> (String, String) {
(a.server_id.clone(), a.id.clone())
}
/// Prefer synced `album` rows; fill gaps from FTS / track-derived groups.
fn merge_album_text_hits(
table: Vec<LibraryAlbumDto>,
fts: Vec<LibraryAlbumDto>,
tracks: Vec<LibraryAlbumDto>,
) -> Vec<LibraryAlbumDto> {
let mut by_key: std::collections::HashMap<(String, String), LibraryAlbumDto> =
std::collections::HashMap::new();
for a in tracks {
by_key.entry(album_text_hit_key(&a)).or_insert(a);
}
for a in fts {
by_key.entry(album_text_hit_key(&a)).or_insert(a);
}
for a in table {
by_key.insert(album_text_hit_key(&a), a);
}
let mut out: Vec<LibraryAlbumDto> = by_key.into_values().collect();
out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
out
}
/// All Albums text search — union `album` LIKE, track FTS, and track GROUP BY
/// (substring + prefix). The `album`-table fast path alone misses titles that
/// only appear on track rows (Navidrome parity / Live Search).
#[allow(clippy::too_many_arguments)]
fn build_album_text_search(
store: &LibraryStore,
req: &LibraryAdvancedSearchRequest,
text: &str,
scalar: &[&LibraryFilterClause],
limit: u32,
offset: u32,
skip_totals: bool,
applied: &mut BTreeSet<String>,
) -> Result<(Vec<LibraryAlbumDto>, u32), String> {
applied.insert("text".to_string());
let fetch = limit.saturating_add(offset).clamp(1, PAGE_LIMIT_MAX);
let scope_ids = effective_library_scope_ids(req);
let mut scratch = BTreeSet::new();
let mut table = Vec::new();
// Match `list_albums`: multi-folder scope uses track GROUP BY, not album+EXISTS.
if scope_ids.len() <= 1
&& !scalar_requires_lossless_track_grouping(scalar)
&& !scalar_requires_track_derived_entities(scalar)
&& crate::album_browse::album_table_usable(store, &req.server_id)?
{
table = build_album_from_table(
store,
req,
Some(text),
scalar,
fetch,
0,
true,
&mut scratch,
)?
.0;
}
let mut fts = Vec::new();
let mut tracks = Vec::new();
if server_has_indexed_tracks(store, &req.server_id)? {
if fts_query_meets_min_len(text) {
if let Some(q) = fts_album_text_match_query(req, text) {
fts = build_album_from_fts(
store, req, &q, scalar, fetch, 0, true, &mut scratch,
)?
.0;
}
}
tracks = build_album_from_tracks(
store,
req,
Some(text),
scalar,
fetch,
0,
true,
&mut scratch,
true,
)?
.0;
}
let merged = merge_album_text_hits(table, fts, tracks);
let total = merged.len() as u32;
let page = merged
.into_iter()
.skip(offset as usize)
.take(limit as usize)
.collect();
Ok((page, if skip_totals { 0 } else { total }))
}
#[allow(clippy::too_many_arguments)]
fn build_album(
store: &LibraryStore,
@@ -581,19 +334,12 @@ fn build_album(
if req.starred_only == Some(true) {
return build_album_from_table(store, req, text, scalar, limit, offset, skip_totals, applied);
}
if let Some(t) = text {
return build_album_text_search(
store, req, t, scalar, limit, offset, skip_totals, applied,
);
}
if let Some(table) = try_build_album_from_table(
store, req, scalar, limit, offset, skip_totals, applied,
)? {
return Ok(table);
}
if server_has_indexed_tracks(store, &req.server_id)? {
if let Some(q) = text.and_then(|t| fts_album_text_match_query(req, t)) {
return build_album_from_fts(store, req, &q, scalar, limit, offset, skip_totals, applied);
}
return build_album_from_tracks(
store, req, None, scalar, limit, offset, skip_totals, applied, false,
store, req, text, scalar, limit, offset, skip_totals, applied, false,
);
}
if !scalar_requires_track_derived_entities(scalar) {
@@ -621,12 +367,13 @@ fn build_album_from_table(
skip_totals: bool,
applied: &mut BTreeSet<String>,
) -> Result<(Vec<LibraryAlbumDto>, u32), String> {
// `album` has no `library_id`; scope is enforced via EXISTS on `track`.
// `album` has no `library_id` / `deleted` columns, so `libraryScope` is
// a track-only filter (P20) and does not narrow album-table results.
let mut w = WhereBuilder::new();
w.push_param("a.server_id = ?", SqlValue::Text(req.server_id.clone()));
push_album_table_library_scope(&mut w, "a", &effective_library_scope_ids(req));
if let Some(t) = text {
push_album_table_text_match(&mut w, t, applied);
w.push_param("a.name LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t)));
applied.insert("text".to_string());
}
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Album)? {
@@ -687,9 +434,13 @@ fn build_album_from_tracks(
AND a.id = t.album_id AND a.song_count IS NOT NULL)",
);
}
push_library_scope_where(&mut w, "t", &effective_library_scope_ids(req));
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
let clause = library_scope_equals_sql("t");
w.push_param(&clause, SqlValue::Text(scope));
}
if let Some(t) = text {
push_track_group_text_match(&mut w, t, applied);
w.push_param("t.album LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t)));
applied.insert("text".to_string());
}
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Track)? {
@@ -708,9 +459,7 @@ fn build_album_from_tracks(
applied,
);
let select = "t.server_id, t.album_id, \
MAX(COALESCE((SELECT a2.name FROM album a2 WHERE a2.server_id = t.server_id AND a2.id = t.album_id), t.album)), \
MAX(t.artist), MAX(t.artist_id), \
let select = "t.server_id, t.album_id, MAX(t.album), MAX(t.artist), MAX(t.artist_id), \
COUNT(*), SUM(t.duration_sec), MAX(t.year), MAX(t.genre), MAX(t.cover_art_id), \
MAX(t.starred_at), MAX(t.synced_at)";
let order = album_order_from_track_groups(&req.sort).unwrap_or_else(|| {
@@ -809,7 +558,10 @@ fn build_artist_from_tracks(
w.push_raw(
"NOT EXISTS (SELECT 1 FROM artist ar WHERE ar.server_id = t.server_id AND ar.id = t.artist_id)",
);
push_library_scope_where(&mut w, "t", &effective_library_scope_ids(req));
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
let clause = library_scope_equals_sql("t");
w.push_param(&clause, SqlValue::Text(scope));
}
if let Some(t) = text {
w.push_param("t.artist LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t)));
applied.insert("text".to_string());
@@ -853,94 +605,110 @@ fn build_album_from_fts(
applied: &mut BTreeSet<String>,
) -> Result<(Vec<LibraryAlbumDto>, u32), String> {
applied.insert("text".to_string());
let fetch = limit.saturating_add(offset).clamp(1, PAGE_LIMIT_MAX);
let pool = fts_candidate_pool_size(fetch, 0);
let scope_ids = effective_library_scope_ids(req);
let need = limit.saturating_add(offset) as i64;
let pool = (need.saturating_mul(8)).clamp(64, 2_000);
let scope = trimmed_nonempty(req.library_scope.as_deref());
let mut extra = WhereBuilder::new();
extra.push_raw("t.deleted = 0");
extra.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone()));
extra.push_raw("t.album_id IS NOT NULL AND t.album_id != ''");
push_library_scope_where(&mut extra, "t", &scope_ids);
let mut w = WhereBuilder::new();
w.push_params(
&format!(
"t.rowid IN ({})",
scoped_fts_rowid_subquery_sql(pool, scope.as_deref())
),
{
let mut p = vec![SqlValue::Text(fts.to_string())];
p.extend(scoped_fts_subquery_bind(&req.server_id, scope.as_deref()));
p
},
);
w.push_raw("t.deleted = 0");
w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone()));
w.push_raw("t.album_id IS NOT NULL AND t.album_id != ''");
if let Some(scope) = scope {
let clause = library_scope_equals_sql("t");
w.push_param(&clause, SqlValue::Text(scope));
}
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Track)? {
applied.insert(c.field.clone());
extra.push(frag);
w.push(frag);
}
}
if req.starred_only == Some(true) {
extra.push_raw("t.starred_at IS NOT NULL");
w.push_raw("t.starred_at IS NOT NULL");
applied.insert("starred".to_string());
}
push_album_id_allowlist(
&mut extra,
&mut w,
"t.album_id",
req.restrict_album_ids.as_deref(),
applied,
);
let extra_sql = extra.where_sql();
let scope_tail = if let (Some(clause), _) = library_scope_filter_sql("t_fts", &scope_ids) {
format!(" AND {clause}")
} else {
String::new()
};
let where_sql = w.where_sql();
store.with_read_conn(|conn| {
let sql = format!(
"SELECT t.server_id, t.album_id, \
COALESCE(a.name, t.album), t.artist, t.artist_id, t.year, \
"SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.year, \
t.genre, t.cover_art_id, t.starred_at, t.synced_at \
FROM (\
SELECT f.rowid, bm25(track_fts) AS fts_rank \
FROM track_fts f \
JOIN track t_fts ON t_fts.rowid = f.rowid \
WHERE track_fts MATCH ? \
AND t_fts.server_id = ? \
AND t_fts.deleted = 0{scope_tail} \
ORDER BY fts_rank \
LIMIT {pool}\
) fts_pick \
JOIN track t ON t.rowid = fts_pick.rowid \
LEFT JOIN album a ON a.server_id = t.server_id AND a.id = t.album_id \
WHERE {extra_sql} \
GROUP BY t.album_id \
ORDER BY MIN(fts_pick.fts_rank) \
LIMIT ? OFFSET ?",
FROM track t \
WHERE {where_sql}"
);
let mut params: Vec<SqlValue> = Vec::new();
params.push(SqlValue::Text(fts.to_string()));
params.extend(scoped_fts_subquery_bind(&req.server_id, &scope_ids));
params.extend(extra.params);
params.push(SqlValue::Integer(fetch as i64));
params.push(SqlValue::Integer(offset as i64));
let params = w.params.clone();
let mut stmt = conn.prepare(&sql)?;
let albums: Vec<LibraryAlbumDto> = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok(LibraryAlbumDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
artist: r.get(3)?,
artist_id: r.get(4)?,
song_count: None,
duration_sec: None,
year: r.get(5)?,
genre: r.get(6)?,
cover_art_id: r.get(7)?,
starred_at: r.get(8)?,
synced_at: r.get(9)?,
raw_json: Value::Null,
})
let rows: Vec<AlbumBrowseTrackRow> =
stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok((
r.get(0)?,
r.get(1)?,
r.get(2)?,
r.get(3)?,
r.get(4)?,
r.get(5)?,
r.get(6)?,
r.get(7)?,
r.get(8)?,
r.get(9)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let mut seen = HashSet::new();
let mut deduped: Vec<LibraryAlbumDto> = Vec::new();
for (server_id, album_id, album, artist, artist_id, year, genre, cover_art_id, starred_at, synced_at) in rows {
if !seen.insert(album_id.clone()) {
continue;
}
deduped.push(LibraryAlbumDto {
server_id,
id: album_id,
name: album,
artist,
artist_id,
song_count: None,
duration_sec: None,
year,
genre,
cover_art_id,
starred_at,
synced_at,
raw_json: Value::Null,
});
if deduped.len() >= need as usize {
break;
}
}
let total = if skip_totals {
0
} else {
albums.len() as u32
deduped.len() as u32
};
Ok((albums, total))
let page = deduped
.into_iter()
.skip(offset as usize)
.take(limit as usize)
.collect();
Ok((page, total))
})
}
@@ -959,24 +727,27 @@ fn build_artist_from_fts(
applied.insert("text".to_string());
let need = limit.saturating_add(offset) as i64;
let pool = (need.saturating_mul(8)).clamp(64, 2_000);
let scope_ids = effective_library_scope_ids(req);
let scope = trimmed_nonempty(req.library_scope.as_deref());
let mut w = WhereBuilder::new();
w.push_params(
&format!(
"t.rowid IN ({})",
scoped_fts_rowid_subquery_sql(pool, &scope_ids)
scoped_fts_rowid_subquery_sql(pool, scope.as_deref())
),
{
let mut p = vec![SqlValue::Text(fts.to_string())];
p.extend(scoped_fts_subquery_bind(&req.server_id, &scope_ids));
p.extend(scoped_fts_subquery_bind(&req.server_id, scope.as_deref()));
p
},
);
w.push_raw("t.deleted = 0");
w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone()));
w.push_raw("t.artist_id IS NOT NULL AND t.artist_id != ''");
push_library_scope_where(&mut w, "t", &scope_ids);
if let Some(scope) = scope {
let clause = library_scope_equals_sql("t");
w.push_param(&clause, SqlValue::Text(scope));
}
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Track)? {
applied.insert(c.field.clone());
@@ -1624,17 +1395,6 @@ mod tests {
.unwrap();
}
fn mark_album_catalog_row(store: &LibraryStore, server: &str, id: &str) {
store
.with_conn("misc", |c| {
c.execute(
"UPDATE album SET song_count = 1 WHERE server_id = ?1 AND id = ?2",
rusqlite::params![server, id],
)
})
.unwrap();
}
fn insert_artist(store: &LibraryStore, server: &str, id: &str, name: &str) {
store
.with_conn("misc", |c| {
@@ -1651,7 +1411,6 @@ mod tests {
LibraryAdvancedSearchRequest {
server_id: server.into(),
library_scope: None,
library_scope_ids: None,
query: None,
entity_types: entities.to_vec(),
filters: Vec::new(),
@@ -1716,8 +1475,6 @@ mod tests {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al1", "Aurora Nights", None, None);
insert_album(&store, "s1", "al2", "Other", None, None);
mark_album_catalog_row(&store, "s1", "al1");
mark_album_catalog_row(&store, "s1", "al2");
insert_artist(&store, "s1", "ar1", "Aurora Quartet");
let mut r = req("s1", &[EntityKind::Album, EntityKind::Artist]);
r.query = Some("aurora".into());
@@ -1728,169 +1485,6 @@ mod tests {
assert_eq!(resp.artists[0].id, "ar1");
}
#[test]
fn album_title_search_matches_any_query_word_via_like() {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al_moon", "The Dark Side of the Moon", None, None);
insert_album(&store, "s1", "al_other", "Wish You Were Here", None, None);
mark_album_catalog_row(&store, "s1", "al_moon");
mark_album_catalog_row(&store, "s1", "al_other");
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("moon side".into());
r.query_album_title_only = Some(true);
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_moon");
}
#[test]
fn album_title_search_matches_later_word_only() {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al_moon", "The Dark Side of the Moon", None, None);
mark_album_catalog_row(&store, "s1", "al_moon");
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("moon".into());
r.query_album_title_only = Some(true);
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_moon");
}
#[test]
fn album_title_search_matches_synced_name_when_track_tag_differs() {
let store = LibraryStore::open_in_memory();
insert_album(
&store,
"s1",
"al_kerrang",
"Kerrang! Metallica Master of Puppets Revisited",
None,
None,
);
mark_album_catalog_row(&store, "s1", "al_kerrang");
let mut tr = track("s1", "t1", "A", "Various", "Master of Puppets Revisited");
tr.album_id = Some("al_kerrang".into());
TrackRepository::new(&store).upsert_batch(&[tr]).unwrap();
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("metallica".into());
r.query_album_title_only = Some(true);
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_kerrang");
}
fn track_with_lib(
server: &str,
id: &str,
album_id: &str,
album: &str,
library_id: Option<&str>,
) -> TrackRow {
let mut t = track(server, id, "A", "art-1", album);
t.album_id = Some(album_id.into());
t.library_id = library_id.map(str::to_string);
t
}
#[test]
fn album_title_text_search_respects_single_library_scope() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track_with_lib("s1", "t1", "al-in", "Metallica", Some("lib-a")),
track_with_lib("s1", "t2", "al-out", "Metallica Covers", Some("lib-b")),
])
.unwrap();
insert_album(&store, "s1", "al-in", "Metallica", None, None);
insert_album(&store, "s1", "al-out", "Metallica Covers", None, None);
mark_album_catalog_row(&store, "s1", "al-in");
mark_album_catalog_row(&store, "s1", "al-out");
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("metallica".into());
r.query_album_title_only = Some(true);
r.library_scope_ids = Some(vec!["lib-a".into()]);
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al-in");
}
#[test]
fn album_title_text_search_unions_multi_library_scope() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track_with_lib("s1", "t1", "al-a", "Alpha Metallica", Some("lib-a")),
track_with_lib("s1", "t2", "al-b", "Beta Metallica", Some("lib-b")),
track_with_lib("s1", "t3", "al-c", "Gamma Other", Some("lib-c")),
])
.unwrap();
insert_album(&store, "s1", "al-a", "Alpha Metallica", None, None);
insert_album(&store, "s1", "al-b", "Beta Metallica", None, None);
mark_album_catalog_row(&store, "s1", "al-a");
mark_album_catalog_row(&store, "s1", "al-b");
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("metallica".into());
r.query_album_title_only = Some(true);
r.library_scope_ids = Some(vec!["lib-a".into(), "lib-b".into()]);
let resp = run_advanced_search(&store, &r).unwrap();
let ids: Vec<&str> = resp.albums.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids.len(), 2, "expected {ids:?}");
assert!(ids.contains(&"al-a"));
assert!(ids.contains(&"al-b"));
}
#[test]
fn album_title_text_search_unions_sparse_table_and_track_catalog() {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al_self", "Metallica", None, None);
store
.with_conn("misc", |c| {
c.execute(
"UPDATE album SET song_count = 10 WHERE server_id = 's1' AND id = 'al_self'",
[],
)
})
.unwrap();
let mut plays = track("s1", "t1", "A", "Apocalyptica", "Plays Metallica Vol. 2");
plays.album_id = Some("al_plays".into());
let mut blacklist = track("s1", "t2", "B", "Various", "The Metallica Blacklist");
blacklist.album_id = Some("al_black".into());
let mut other = track("s1", "t3", "C", "Pink Floyd", "Wish You Were Here");
other.album_id = Some("al_wish".into());
TrackRepository::new(&store)
.upsert_batch(&[plays, blacklist, other])
.unwrap();
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("metallica".into());
r.query_album_title_only = Some(true);
let resp = run_advanced_search(&store, &r).unwrap();
let ids: Vec<&str> = resp.albums.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids.len(), 3);
assert!(ids.contains(&"al_self"));
assert!(ids.contains(&"al_plays"));
assert!(ids.contains(&"al_black"));
}
#[test]
fn album_title_search_fts_matches_any_word_on_track_catalog() {
let store = LibraryStore::open_in_memory();
let mut moon = track("s1", "t1", "Breathe", "Pink Floyd", "The Dark Side of the Moon");
moon.album_id = Some("al_moon".into());
let mut other = track("s1", "t2", "Shine", "Pink Floyd", "Wish You Were Here");
other.album_id = Some("al_wish".into());
TrackRepository::new(&store)
.upsert_batch(&[moon, other])
.unwrap();
let mut r = req("s1", &[EntityKind::Album]);
r.query = Some("wish moon".into());
r.query_album_title_only = Some(true);
let resp = run_advanced_search(&store, &r).unwrap();
let ids: Vec<&str> = resp.albums.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids.len(), 2);
assert!(ids.contains(&"al_moon"));
assert!(ids.contains(&"al_wish"));
}
#[test]
fn text_query_derives_album_and_artist_from_tracks_when_tables_empty() {
let store = LibraryStore::open_in_memory();
@@ -1,499 +0,0 @@
//! Paginated All Albums browse from the local index (plain, no filters).
//!
//! Prefers the synced `album` table (≈5k rows) with LIMIT/OFFSET. Falls back to
//! track `GROUP BY` only when the album catalog is not populated (N1 ingest).
use crate::dto::{
LibraryAlbumBrowseRequest, LibraryAlbumBrowseResponse, LibraryAlbumDto, LibrarySortClause,
SortDir,
};
use crate::search::library_scope_filter_sql;
use crate::store::LibraryStore;
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
fn trimmed_nonempty(s: Option<&str>) -> Option<String> {
s.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
}
fn effective_scope_ids(req: &LibraryAlbumBrowseRequest) -> Vec<String> {
if let Some(ids) = &req.library_scope_ids {
let trimmed: Vec<_> = ids
.iter()
.filter(|s| !s.trim().is_empty())
.cloned()
.collect();
if !trimmed.is_empty() {
return trimmed;
}
}
trimmed_nonempty(req.library_scope.as_deref())
.map(|s| vec![s])
.unwrap_or_default()
}
fn album_table_order_sql(sort: &[LibrarySortClause]) -> String {
let mut keys: Vec<String> = Vec::new();
for s in sort {
let col = match s.field.as_str() {
"name" => "a.name COLLATE NOCASE",
"artist" => "a.artist COLLATE NOCASE",
"year" => "a.year",
_ => continue,
};
let dir = match s.dir {
SortDir::Asc => "ASC",
SortDir::Desc => "DESC",
};
keys.push(format!("{col} {dir}"));
}
if keys.is_empty() {
keys.push("a.name COLLATE NOCASE ASC".to_string());
}
keys.push("a.id ASC".to_string());
format!("ORDER BY {}", keys.join(", "))
}
fn track_group_order_sql(sort: &[LibrarySortClause]) -> String {
let mut keys: Vec<String> = Vec::new();
for s in sort {
let col = match s.field.as_str() {
"name" => "COALESCE(a.name, la.album_name) COLLATE NOCASE",
"artist" => "COALESCE(a.artist, la.artist) COLLATE NOCASE",
"year" => "COALESCE(a.year, la.year)",
_ => continue,
};
let dir = match s.dir {
SortDir::Asc => "ASC",
SortDir::Desc => "DESC",
};
keys.push(format!("{col} {dir}"));
}
if keys.is_empty() {
keys.push("COALESCE(a.name, la.album_name) COLLATE NOCASE ASC".to_string());
}
keys.push("la.album_id ASC".to_string());
format!("ORDER BY {}", keys.join(", "))
}
fn push_album_id_allowlist(
where_clauses: &mut Vec<String>,
params: &mut Vec<SqlValue>,
column: &str,
ids: Option<&[String]>,
) {
let Some(ids) = ids else {
return;
};
if ids.is_empty() {
where_clauses.push("1 = 0".to_string());
return;
}
let placeholders = (0..ids.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
where_clauses.push(format!("{column} IN ({placeholders})"));
for id in ids {
params.push(SqlValue::Text(id.clone()));
}
}
/// `album` rows have no `library_id`; scope is enforced via matching tracks.
fn push_album_table_library_scope(
where_clauses: &mut Vec<String>,
params: &mut Vec<SqlValue>,
scope_ids: &[String],
) {
if scope_ids.is_empty() {
return;
}
let (clause, scope_params) = library_scope_filter_sql("t_scope", scope_ids);
let Some(scope_clause) = clause else {
return;
};
where_clauses.push(format!(
"EXISTS (SELECT 1 FROM track t_scope \
WHERE t_scope.server_id = a.server_id \
AND t_scope.album_id = a.id \
AND t_scope.deleted = 0 \
AND {scope_clause})"
));
params.extend(scope_params);
}
fn map_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryAlbumDto> {
let raw: Option<String> = r.get(12)?;
Ok(LibraryAlbumDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
artist: r.get(3)?,
artist_id: r.get(4)?,
song_count: r.get(5)?,
duration_sec: r.get(6)?,
year: r.get(7)?,
genre: r.get(8)?,
cover_art_id: r.get(9)?,
starred_at: r.get(10)?,
synced_at: r.get(11)?,
raw_json: raw
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(Value::Null),
})
}
pub(crate) fn album_table_usable(store: &LibraryStore, server_id: &str) -> Result<bool, String> {
store
.with_read_conn(|c| {
c.query_row(
"SELECT EXISTS(
SELECT 1 FROM album
WHERE server_id = ?1 AND song_count IS NOT NULL
LIMIT 1
)",
rusqlite::params![server_id],
|r| r.get(0),
)
})
.map_err(|e| e.to_string())
}
fn list_albums_from_table(
store: &LibraryStore,
req: &LibraryAlbumBrowseRequest,
) -> Result<LibraryAlbumBrowseResponse, String> {
let limit = req.limit.max(1);
let offset = req.offset;
let order_sql = album_table_order_sql(&req.sort);
let mut where_clauses = vec!["a.server_id = ?1".to_string()];
let mut params: Vec<SqlValue> = vec![SqlValue::Text(req.server_id.clone())];
let scope_ids = effective_scope_ids(req);
push_album_table_library_scope(&mut where_clauses, &mut params, &scope_ids);
push_album_id_allowlist(
&mut where_clauses,
&mut params,
"a.id",
req.restrict_album_ids.as_deref(),
);
let where_sql = where_clauses.join(" AND ");
let sql = format!(
"SELECT \
a.server_id, \
a.id, \
a.name, \
a.artist, \
a.artist_id, \
a.song_count, \
a.duration_sec, \
a.year, \
a.genre, \
a.cover_art_id, \
a.starred_at, \
a.synced_at, \
a.raw_json \
FROM album a \
WHERE {where_sql} \
{order_sql} \
LIMIT ? OFFSET ?"
);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let albums: Vec<LibraryAlbumDto> = store
.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
.map_err(|e| e.to_string())?;
let has_more = albums.len() as u32 == limit;
Ok(LibraryAlbumBrowseResponse {
albums,
has_more,
source: "local".to_string(),
})
}
fn list_albums_from_tracks(
store: &LibraryStore,
req: &LibraryAlbumBrowseRequest,
) -> Result<LibraryAlbumBrowseResponse, String> {
let limit = req.limit.max(1);
let offset = req.offset;
let order_sql = track_group_order_sql(&req.sort);
let mut where_clauses = vec![
"t.deleted = 0".to_string(),
"t.server_id = ?1".to_string(),
"t.album_id IS NOT NULL AND t.album_id != ''".to_string(),
];
let mut params: Vec<SqlValue> = vec![SqlValue::Text(req.server_id.clone())];
let scope_ids = effective_scope_ids(req);
if let (Some(clause), scope_params) = library_scope_filter_sql("t", &scope_ids) {
where_clauses.push(clause);
params.extend(scope_params);
}
push_album_id_allowlist(
&mut where_clauses,
&mut params,
"t.album_id",
req.restrict_album_ids.as_deref(),
);
let where_sql = where_clauses.join(" AND ");
let sql = format!(
"SELECT \
la.server_id, \
la.album_id, \
COALESCE(a.name, la.album_name), \
COALESCE(a.artist, la.artist), \
COALESCE(a.artist_id, la.artist_id), \
COALESCE(a.song_count, la.track_count), \
COALESCE(a.duration_sec, la.duration_sec), \
COALESCE(a.year, la.year), \
COALESCE(a.genre, la.genre), \
COALESCE(a.cover_art_id, la.cover_art_id), \
COALESCE(a.starred_at, la.starred_at), \
COALESCE(a.synced_at, la.synced_at), \
a.raw_json \
FROM ( \
SELECT \
t.server_id, \
t.album_id, \
MAX(t.album) AS album_name, \
MAX(t.artist) AS artist, \
MAX(t.artist_id) AS artist_id, \
MAX(t.year) AS year, \
MAX(t.genre) AS genre, \
MAX(t.cover_art_id) AS cover_art_id, \
MAX(t.starred_at) AS starred_at, \
MAX(t.synced_at) AS synced_at, \
COUNT(*) AS track_count, \
COALESCE(SUM(t.duration_sec), 0) AS duration_sec \
FROM track t \
WHERE {where_sql} \
GROUP BY t.server_id, t.album_id \
) la \
LEFT JOIN album a ON a.server_id = la.server_id AND a.id = la.album_id \
{order_sql} \
LIMIT ? OFFSET ?"
);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let albums: Vec<LibraryAlbumDto> = store
.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
.map_err(|e| e.to_string())?;
let has_more = albums.len() as u32 == limit;
Ok(LibraryAlbumBrowseResponse {
albums,
has_more,
source: "local".to_string(),
})
}
fn browse_is_scoped(req: &LibraryAlbumBrowseRequest) -> bool {
!effective_scope_ids(req).is_empty()
|| req
.restrict_album_ids
.as_ref()
.is_some_and(|ids| !ids.is_empty())
}
pub fn list_albums(
store: &LibraryStore,
req: &LibraryAlbumBrowseRequest,
) -> Result<LibraryAlbumBrowseResponse, String> {
let scope_ids = effective_scope_ids(req);
// Unscoped, or a single library: `album` table + EXISTS (fast on ~5k rows).
// Multi-library union: filter tracks by `library_id IN (...)` then GROUP BY.
if album_table_usable(store, &req.server_id)?
&& (!browse_is_scoped(req) || scope_ids.len() == 1)
{
return list_albums_from_table(store, req);
}
if !crate::dto::track_index_nonempty(store, &req.server_id)? {
return Ok(LibraryAlbumBrowseResponse {
albums: Vec::new(),
has_more: false,
source: "local".to_string(),
});
}
list_albums_from_tracks(store, req)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::{TrackRepository, TrackRow};
fn track(server: &str, id: &str, album_id: &str, album: &str, library_id: Option<&str>) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: format!("{album} track"),
title_sort: None,
artist: Some("Band".into()),
artist_id: Some("art-1".into()),
album: album.into(),
album_id: Some(album_id.into()),
album_artist: Some("Band".into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: Some(2020),
genre: None,
suffix: Some("mp3".into()),
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: library_id.map(String::from),
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
fn req(server: &str, limit: u32, offset: u32) -> LibraryAlbumBrowseRequest {
LibraryAlbumBrowseRequest {
server_id: server.into(),
library_scope: None,
library_scope_ids: None,
sort: Vec::new(),
restrict_album_ids: None,
limit,
offset,
}
}
fn seed_album(
store: &LibraryStore,
server_id: &str,
id: &str,
name: &str,
song_count: i64,
) {
store
.with_conn("misc", |c| {
c.execute(
"INSERT INTO album (server_id, id, name, artist, song_count, duration_sec, synced_at, raw_json) \
VALUES (?1, ?2, ?3, 'Band', ?4, 400, 1, '{}')",
rusqlite::params![server_id, id, name, song_count],
)
})
.unwrap();
}
#[test]
fn lists_albums_grouped_from_tracks() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "al-1", "Alpha", None),
track("s1", "t2", "al-2", "Beta", None),
])
.unwrap();
let resp = list_albums(&store, &req("s1", 50, 0)).unwrap();
assert_eq!(resp.albums.len(), 2);
assert!(!resp.has_more);
}
#[test]
fn prefers_album_table_when_synced_catalog_exists() {
let store = LibraryStore::open_in_memory();
seed_album(&store, "s1", "al-1", "Alpha", 10);
seed_album(&store, "s1", "al-2", "Beta", 8);
let resp = list_albums(&store, &req("s1", 50, 0)).unwrap();
assert_eq!(resp.albums.len(), 2);
assert_eq!(resp.albums[0].name, "Alpha");
assert_eq!(resp.albums[1].name, "Beta");
}
#[test]
fn library_scope_narrows_results() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "al-1", "In", Some("lib-a")),
track("s1", "t2", "al-2", "Out", Some("lib-b")),
])
.unwrap();
seed_album(&store, "s1", "al-1", "In", 1);
seed_album(&store, "s1", "al-2", "Out", 1);
let mut scoped = req("s1", 50, 0);
scoped.library_scope_ids = Some(vec!["lib-a".into()]);
let resp = list_albums(&store, &scoped).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al-1");
}
#[test]
fn multi_library_scope_unions_albums() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "al-1", "Alpha", Some("lib-a")),
track("s1", "t2", "al-2", "Beta", Some("lib-b")),
track("s1", "t3", "al-3", "Gamma", Some("lib-c")),
])
.unwrap();
seed_album(&store, "s1", "al-1", "Alpha", 1);
seed_album(&store, "s1", "al-2", "Beta", 1);
seed_album(&store, "s1", "al-3", "Gamma", 1);
let mut scoped = req("s1", 50, 0);
scoped.library_scope_ids = Some(vec!["lib-a".into(), "lib-b".into()]);
let resp = list_albums(&store, &scoped).unwrap();
assert_eq!(resp.albums.len(), 2);
assert_eq!(resp.albums[0].id, "al-1");
assert_eq!(resp.albums[1].id, "al-2");
}
#[test]
fn multi_library_scope_includes_track_only_albums() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "al-1", "Alpha", Some("lib-a")),
track("s1", "t2", "al-2", "Zulu", Some("lib-b")),
])
.unwrap();
seed_album(&store, "s1", "al-1", "Alpha", 1);
let mut scoped = req("s1", 50, 0);
scoped.library_scope_ids = Some(vec!["lib-a".into(), "lib-b".into()]);
let resp = list_albums(&store, &scoped).unwrap();
assert_eq!(resp.albums.len(), 2);
assert_eq!(resp.albums[0].id, "al-1");
assert_eq!(resp.albums[1].id, "al-2");
}
}
@@ -6,7 +6,7 @@ use tauri::State;
use crate::dto::CatalogYearBoundsDto;
use crate::dto::GenreAlbumCountDto;
use crate::runtime::LibraryRuntime;
use crate::search::library_scope_filter_sql;
use crate::search::library_scope_equals_sql;
use crate::store::LibraryStore;
#[derive(Debug, Clone, serde::Deserialize)]
@@ -106,34 +106,11 @@ pub fn library_get_catalog_year_bounds(
catalog_year_bounds_for_server(&runtime.store, &server_id)
}
fn effective_genre_count_scope_ids(
library_scope: Option<&str>,
library_scope_ids: Option<&[String]>,
) -> Vec<String> {
if let Some(ids) = library_scope_ids {
let trimmed: Vec<_> = ids
.iter()
.filter(|s| !s.trim().is_empty())
.cloned()
.collect();
if !trimmed.is_empty() {
return trimmed;
}
}
library_scope
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| vec![s.to_string()])
.unwrap_or_default()
}
pub(crate) fn genre_album_counts_for_server(
store: &LibraryStore,
server_id: &str,
library_scope: Option<&str>,
library_scope_ids: Option<&[String]>,
) -> Result<Vec<GenreAlbumCountDto>, String> {
let scope_ids = effective_genre_count_scope_ids(library_scope, library_scope_ids);
store
.with_read_conn(|conn| {
let mut sql = String::from(
@@ -145,9 +122,9 @@ pub(crate) fn genre_album_counts_for_server(
);
let mut params: Vec<rusqlite::types::Value> =
vec![rusqlite::types::Value::Text(server_id.to_string())];
if let (Some(clause), scope_params) = library_scope_filter_sql("t", &scope_ids) {
sql.push_str(&format!(" AND {clause}"));
params.extend(scope_params);
if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) {
sql.push_str(&format!(" AND {}", library_scope_equals_sql("t")));
params.push(rusqlite::types::Value::Text(scope.to_string()));
}
sql.push_str(
" GROUP BY t.genre COLLATE NOCASE \
@@ -174,13 +151,11 @@ pub fn library_get_genre_album_counts(
runtime: State<'_, LibraryRuntime>,
server_id: String,
library_scope: Option<String>,
library_scope_ids: Option<Vec<String>>,
) -> Result<Vec<GenreAlbumCountDto>, String> {
genre_album_counts_for_server(
&runtime.store,
&server_id,
library_scope.as_deref(),
library_scope_ids.as_deref(),
)
}
@@ -332,7 +307,7 @@ mod tests {
.upsert_batch(&rock_one)
.unwrap();
let counts = genre_album_counts_for_server(&store, "s1", None, None).unwrap();
let counts = genre_album_counts_for_server(&store, "s1", None).unwrap();
assert_eq!(counts.len(), 2);
assert_eq!(counts[0].value, "Rock");
assert_eq!(counts[0].album_count, 2);
@@ -355,7 +330,7 @@ mod tests {
.upsert_batch(&[scoped, other])
.unwrap();
let counts = genre_album_counts_for_server(&store, "s1", Some("lib1"), None).unwrap();
let counts = genre_album_counts_for_server(&store, "s1", Some("lib1")).unwrap();
assert_eq!(counts.len(), 1);
assert_eq!(counts[0].value, "Rock");
assert_eq!(counts[0].album_count, 1);
+36 -292
View File
@@ -20,16 +20,10 @@ use crate::cover_resolve::CoverEntryDto;
use crate::cross_server;
use crate::dto::{
count_local_tracks, local_tracks_max_updated_ms, track_index_nonempty, ArtifactInputDto,
FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse,
LibraryClusterAdvancedSearchRequest, LibraryClusterListTracksRequest, LibraryClusterResolveRequest,
LibraryClusterResolveResponse, LibraryClusterAlbumsResponse, LibraryClusterArtistsResponse,
LibraryClusterScopeRequest, LibraryClusterPlayerStatsRequest, LibraryClusterPlayerStatsDayDetailRequest,
LibraryClusterEntityDetailRequest, LibraryClusterAlbumDetailResponse,
LibraryClusterArtistDetailResponse,
FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse,
LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse, LibraryTrackDto,
LibraryTracksEnvelope, OfflinePathDto, PlaySessionDayDetailDto, PlaySessionHeatmapDayDto,
PlaySessionInputDto, PlaySessionMostPlayedDto, PlaySessionRecentDayDto, PlaySessionYearBoundsDto,
PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
TrackArtifactDto, TrackFactDto, TrackRefDto,
};
use crate::live_search;
@@ -402,6 +396,40 @@ pub async fn library_get_tracks_by_album(
Ok(rows.iter().map(LibraryTrackDto::from_row).collect())
}
/// Upsert Subsonic API song payloads into the library index so pin/download can
/// build `media/library/…` paths before a full sync has ingested the rows.
#[tauri::command]
pub fn library_upsert_songs_from_api(
runtime: State<'_, LibraryRuntime>,
server_id: String,
songs: Vec<serde_json::Value>,
) -> Result<u32, String> {
use crate::sync::subsonic_song_to_track_row;
use psysonic_integration::subsonic::Song;
if songs.is_empty() {
return Ok(0);
}
let synced_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| e.to_string())?
.as_secs() as i64;
let repo = TrackRepository::new(&runtime.store);
let mut rows = Vec::with_capacity(songs.len());
for raw in songs {
let song: Song = serde_json::from_value(raw.clone()).map_err(|e| e.to_string())?;
rows.push(subsonic_song_to_track_row(
&server_id,
&song,
&raw,
synced_at,
None,
));
}
repo.upsert_batch(&rows)?;
Ok(rows.len() as u32)
}
#[tauri::command]
pub async fn library_get_artifact(
runtime: State<'_, LibraryRuntime>,
@@ -479,16 +507,6 @@ pub async fn library_advanced_search(
library_spawn_blocking(move || advanced_search::run_advanced_search(&store, &request)).await
}
#[tauri::command]
pub async fn library_cluster_advanced_search(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterAdvancedSearchRequest,
) -> Result<LibraryAdvancedSearchResponse, String> {
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || crate::server_cluster::run_cluster_advanced_search(&store, request))
.await
}
#[tauri::command]
pub async fn library_list_lossless_albums(
runtime: State<'_, LibraryRuntime>,
@@ -498,15 +516,6 @@ pub async fn library_list_lossless_albums(
library_spawn_blocking(move || crate::lossless_albums::list_lossless_albums(&store, &request)).await
}
#[tauri::command]
pub async fn library_list_albums(
runtime: State<'_, LibraryRuntime>,
request: crate::dto::LibraryAlbumBrowseRequest,
) -> Result<crate::dto::LibraryAlbumBrowseResponse, String> {
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || crate::album_browse::list_albums(&store, &request)).await
}
#[tauri::command]
pub async fn library_list_albums_by_genre(
runtime: State<'_, LibraryRuntime>,
@@ -571,265 +580,6 @@ pub async fn library_search_cross_server(
cross_server::run_cross_server_search(&runtime.store, &query, limit, servers.as_deref())
}
#[tauri::command]
pub async fn library_cluster_list_tracks(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterListTracksRequest,
) -> Result<LibraryTracksEnvelope, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let limit = request.limit.unwrap_or(100);
let offset = request.offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::list_merged_tracks(
&store,
&servers_ordered,
limit,
offset,
&request.library_scopes,
)
})
.await
}
#[tauri::command]
pub async fn library_cluster_list_albums(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterListTracksRequest,
) -> Result<LibraryClusterAlbumsResponse, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let limit = request.limit.unwrap_or(100);
let offset = request.offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::list_merged_albums(
&store,
&servers_ordered,
limit,
offset,
&request.library_scopes,
)
})
.await
}
#[tauri::command]
pub async fn library_cluster_list_artists(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterListTracksRequest,
) -> Result<LibraryClusterArtistsResponse, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let limit = request.limit.unwrap_or(100);
let offset = request.offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::list_merged_artists(
&store,
&servers_ordered,
limit,
offset,
&request.library_scopes,
)
})
.await
}
#[tauri::command]
pub async fn library_cluster_list_favorites(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterScopeRequest,
) -> Result<LibraryTracksEnvelope, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let limit = request.limit.unwrap_or(500);
let offset = request.offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::list_merged_favorite_tracks(&store, &servers_ordered, limit, offset)
})
.await
}
#[tauri::command]
pub async fn library_cluster_list_favorite_albums(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterScopeRequest,
) -> Result<LibraryClusterAlbumsResponse, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let limit = request.limit.unwrap_or(500);
let offset = request.offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::list_merged_favorite_albums(&store, &servers_ordered, limit, offset)
})
.await
}
#[tauri::command]
pub async fn library_cluster_list_favorite_artists(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterScopeRequest,
) -> Result<LibraryClusterArtistsResponse, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let limit = request.limit.unwrap_or(500);
let offset = request.offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::list_merged_favorite_artists(&store, &servers_ordered, limit, offset)
})
.await
}
#[tauri::command]
pub fn library_cluster_player_stats_year_summary(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterPlayerStatsRequest,
) -> Result<PlaySessionYearSummaryDto, String> {
crate::server_cluster::cluster_year_summary(
&runtime.store,
&request.servers_ordered,
request.year,
)
}
#[tauri::command]
pub fn library_cluster_player_stats_heatmap(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterPlayerStatsRequest,
) -> Result<Vec<PlaySessionHeatmapDayDto>, String> {
crate::server_cluster::cluster_heatmap(
&runtime.store,
&request.servers_ordered,
request.year,
)
}
#[tauri::command]
pub fn library_cluster_player_stats_day_detail(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterPlayerStatsDayDetailRequest,
) -> Result<PlaySessionDayDetailDto, String> {
crate::server_cluster::cluster_day_detail(
&runtime.store,
&request.servers_ordered,
&request.date_iso,
)
}
#[tauri::command]
pub fn library_cluster_player_stats_recent_days(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterScopeRequest,
) -> Result<Vec<PlaySessionRecentDayDto>, String> {
crate::server_cluster::cluster_recent_days(
&runtime.store,
&request.servers_ordered,
request.limit.unwrap_or(30),
)
}
#[tauri::command]
pub fn library_cluster_player_stats_most_played(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterScopeRequest,
) -> Result<Vec<PlaySessionMostPlayedDto>, String> {
crate::server_cluster::cluster_most_played(
&runtime.store,
&request.servers_ordered,
request.limit.unwrap_or(50),
)
}
#[tauri::command]
pub async fn library_cluster_resolve_candidates(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterResolveRequest,
) -> Result<LibraryClusterResolveResponse, String> {
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || {
if let Some(key) = request.cluster_key.filter(|k| !k.is_empty()) {
let candidates = crate::server_cluster::resolve_candidates_by_cluster_key(
&store,
&request.servers_ordered,
&key,
)?;
return Ok(LibraryClusterResolveResponse {
candidates,
cluster_key: Some(key),
});
}
let server_id = request
.server_id
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| "cluster_key or (server_id, track_id) required".to_string())?;
let track_id = request
.track_id
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| "cluster_key or (server_id, track_id) required".to_string())?;
let cluster_key =
crate::server_cluster::cluster_key_for_track(&store, server_id, track_id)?;
let candidates = crate::server_cluster::resolve_candidates_for_track(
&store,
&request.servers_ordered,
server_id,
track_id,
)?;
Ok(LibraryClusterResolveResponse {
candidates,
cluster_key,
})
})
.await
}
#[tauri::command]
pub async fn library_cluster_album_detail(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterEntityDetailRequest,
) -> Result<LibraryClusterAlbumDetailResponse, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let server_id = request.server_id;
let entity_id = request.entity_id;
library_spawn_blocking(move || {
crate::server_cluster::cluster_album_detail(&store, &servers_ordered, &server_id, &entity_id)
})
.await
}
#[tauri::command]
pub async fn library_cluster_artist_detail(
runtime: State<'_, LibraryRuntime>,
request: LibraryClusterEntityDetailRequest,
) -> Result<LibraryClusterArtistDetailResponse, String> {
let store = Arc::clone(&runtime.store);
let servers_ordered = request.servers_ordered;
let server_id = request.server_id;
let entity_id = request.entity_id;
library_spawn_blocking(move || {
crate::server_cluster::cluster_artist_detail(&store, &servers_ordered, &server_id, &entity_id)
})
.await
}
#[tauri::command]
pub async fn library_search_cluster(
runtime: State<'_, LibraryRuntime>,
query: String,
limit: Option<u32>,
offset: Option<u32>,
servers_ordered: Vec<String>,
) -> Result<LibraryCrossServerSearchResponse, String> {
let store = Arc::clone(&runtime.store);
let limit = limit.unwrap_or(100);
let offset = offset.unwrap_or(0);
library_spawn_blocking(move || {
crate::server_cluster::run_cluster_search(&store, &query, limit, offset, &servers_ordered)
})
.await
}
// ── helpers ──────────────────────────────────────────────────────────
fn hydrate_refs(
@@ -1197,12 +947,6 @@ async fn library_sync_start_inner(
};
if let Some(runtime) = app_for_emit.try_state::<LibraryRuntime>() {
let _ = runtime.store.checkpoint_wal("sync.checkpoint");
if outcome.ok {
let _ = crate::server_cluster::rebuild_cluster_keys_for_server(
&runtime.store,
&server_id_for_emit,
);
}
}
let _ = app_for_emit.emit(LibrarySyncProgressPayload::IDLE_EVENT_NAME, &outcome);
@@ -4,7 +4,6 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use crate::filter::{EntityKind, FilterOp};
use crate::repos::TrackRow;
@@ -366,14 +365,6 @@ pub struct PlaySessionRecentDayDto {
pub partial_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PlaySessionMostPlayedDto {
pub track: LibraryTrackDto,
pub track_play_count: u32,
pub total_listened_sec: f64,
}
/// Earliest/latest calendar years with at least one session (local TZ).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
@@ -399,38 +390,6 @@ pub struct GenreAlbumCountDto {
pub song_count: u32,
}
/// `library_list_albums` request — paginated plain All Albums browse (local index).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryAlbumBrowseRequest {
pub server_id: String,
#[serde(default)]
pub library_scope: Option<String>,
#[serde(default)]
pub library_scope_ids: Option<Vec<String>>,
#[serde(default)]
pub sort: Vec<LibrarySortClause>,
#[serde(default)]
pub restrict_album_ids: Option<Vec<String>>,
#[serde(default = "default_album_browse_limit")]
pub limit: u32,
#[serde(default)]
pub offset: u32,
}
fn default_album_browse_limit() -> u32 {
30
}
/// `library_list_albums` response.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryAlbumBrowseResponse {
pub albums: Vec<LibraryAlbumDto>,
pub has_more: bool,
pub source: String,
}
/// `library_list_albums_by_genre` request — paginated genre album browse (local index).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
@@ -440,8 +399,6 @@ pub struct LibraryGenreAlbumsRequest {
#[serde(default)]
pub library_scope: Option<String>,
#[serde(default)]
pub library_scope_ids: Option<Vec<String>>,
#[serde(default)]
pub sort: Vec<LibrarySortClause>,
#[serde(default = "default_genre_album_limit")]
pub limit: u32,
@@ -563,9 +520,6 @@ pub struct LibraryAdvancedSearchRequest {
pub server_id: String,
#[serde(default)]
pub library_scope: Option<String>,
/// Multiple music-folder ids (OR). Takes precedence over `library_scope` when non-empty.
#[serde(default)]
pub library_scope_ids: Option<Vec<String>>,
#[serde(default)]
pub query: Option<String>,
pub entity_types: Vec<EntityKind>,
@@ -651,14 +605,6 @@ pub struct LibraryLosslessAlbumsRequest {
pub server_id: String,
#[serde(default)]
pub library_scope: Option<String>,
/// Multiple music-folder ids (OR). Preferred over `library_scope` when length > 1.
#[serde(default)]
pub library_scope_ids: Option<Vec<String>>,
#[serde(default)]
pub sort: Vec<LibrarySortClause>,
/// Navidrome-scoped album ids from getAlbumList2 (authoritative when track `library_id` is sparse).
#[serde(default)]
pub restrict_album_ids: Option<Vec<String>>,
#[serde(default = "default_lossless_limit")]
pub limit: u32,
#[serde(default)]
@@ -712,158 +658,6 @@ pub struct LibraryCrossServerSearchResponse {
pub servers_searched: Vec<String>,
}
/// Cluster candidate row for playback / write fan-out resolution.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterCandidateDto {
pub server_id: String,
pub track_id: String,
pub duration_sec: i64,
pub priority_rank: u32,
pub is_winner: bool,
}
/// `library_cluster_list_tracks` request.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterListTracksRequest {
/// Ordered member server ids (index 0 = highest priority).
pub servers_ordered: Vec<String>,
#[serde(default)]
pub limit: Option<u32>,
#[serde(default)]
pub offset: Option<u32>,
/// Per-member music-folder scopes (`server_id` → folder ids). Omitted members = all libraries.
#[serde(default)]
pub library_scopes: HashMap<String, Vec<String>>,
}
/// `library_cluster_advanced_search` request.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterAdvancedSearchRequest {
/// Ordered member server ids (index 0 = highest priority).
pub servers_ordered: Vec<String>,
#[serde(default)]
pub query: Option<String>,
pub entity_types: Vec<EntityKind>,
#[serde(default)]
pub filters: Vec<LibraryFilterClause>,
#[serde(default)]
pub starred_only: Option<bool>,
#[serde(default)]
pub restrict_album_ids: Option<Vec<String>>,
/// Per-member album allowlists from getAlbumList2 (`server_id` → album ids).
#[serde(default)]
pub restrict_album_scopes: HashMap<String, Vec<String>>,
#[serde(default)]
pub query_album_title_only: Option<bool>,
#[serde(default)]
pub sort: Vec<LibrarySortClause>,
pub limit: u32,
#[serde(default)]
pub offset: u32,
#[serde(default)]
pub skip_totals: bool,
/// Per-member music-folder scopes (`server_id` → folder ids). Omitted members = all libraries.
#[serde(default)]
pub library_scopes: HashMap<String, Vec<String>>,
}
/// Merged album browse response for cluster scope.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterAlbumsResponse {
pub albums: Vec<LibraryAlbumDto>,
pub has_more: bool,
}
/// Merged artist browse response for cluster scope.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterArtistsResponse {
pub artists: Vec<LibraryArtistDto>,
pub has_more: bool,
}
/// Cluster player stats / favorites scope request.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterScopeRequest {
pub servers_ordered: Vec<String>,
#[serde(default)]
pub limit: Option<u32>,
#[serde(default)]
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterPlayerStatsRequest {
pub servers_ordered: Vec<String>,
pub year: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterPlayerStatsDayDetailRequest {
pub servers_ordered: Vec<String>,
pub date_iso: String,
}
/// `library_cluster_resolve_candidates` request — provide cluster_key OR seed track.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterResolveRequest {
pub servers_ordered: Vec<String>,
#[serde(default)]
pub cluster_key: Option<String>,
#[serde(default)]
pub server_id: Option<String>,
#[serde(default)]
pub track_id: Option<String>,
}
/// `library_cluster_resolve_candidates` response.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterResolveResponse {
pub candidates: Vec<LibraryClusterCandidateDto>,
#[serde(default)]
pub cluster_key: Option<String>,
}
/// `library_cluster_album_detail` request.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterEntityDetailRequest {
pub servers_ordered: Vec<String>,
pub server_id: String,
pub entity_id: String,
}
/// Virtual aggregate album detail (spec §4).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterAlbumDetailResponse {
pub album: LibraryAlbumDto,
pub tracks: Vec<LibraryTrackDto>,
pub owner_server_id: String,
pub related_albums: Vec<LibraryAlbumDto>,
}
/// Virtual aggregate artist detail (spec §4).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryClusterArtistDetailResponse {
pub artist: LibraryArtistDto,
pub albums: Vec<LibraryAlbumDto>,
pub top_tracks: Vec<LibraryTrackDto>,
pub owner_server_id: String,
#[serde(default)]
pub artist_key: Option<String>,
}
/// Read `MAX(server_updated_at)` for non-deleted tracks on this server
/// — used by `SyncStateDto` so callers can show "tracks watermark" in
/// Settings without a separate column.
@@ -7,7 +7,7 @@ use crate::dto::{
LibraryAlbumDto, LibraryGenreAlbumsRequest, LibraryGenreAlbumsResponse, LibrarySortClause,
SortDir,
};
use crate::search::library_scope_filter_sql;
use crate::search::library_scope_equals_sql;
use crate::store::LibraryStore;
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
@@ -18,22 +18,6 @@ fn trimmed_nonempty(s: Option<&str>) -> Option<String> {
.map(String::from)
}
fn effective_genre_scope_ids(req: &LibraryGenreAlbumsRequest) -> Vec<String> {
if let Some(ids) = &req.library_scope_ids {
let trimmed: Vec<_> = ids
.iter()
.filter(|s| !s.trim().is_empty())
.cloned()
.collect();
if !trimmed.is_empty() {
return trimmed;
}
}
trimmed_nonempty(req.library_scope.as_deref())
.map(|s| vec![s])
.unwrap_or_default()
}
fn genre_album_order_sql(sort: &[LibrarySortClause]) -> String {
let mut keys: Vec<String> = Vec::new();
for s in sort {
@@ -133,10 +117,9 @@ pub fn list_albums_by_genre(
SqlValue::Text(genre.to_string()),
];
let scope_ids = effective_genre_scope_ids(req);
if let (Some(clause), scope_params) = library_scope_filter_sql("t", &scope_ids) {
where_clauses.push(clause);
params.extend(scope_params);
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
where_clauses.push(library_scope_equals_sql("t"));
params.push(SqlValue::Text(scope));
}
let where_sql = where_clauses.join(" AND ");
@@ -270,7 +253,6 @@ mod tests {
server_id: "s1".into(),
genre: "Rock".into(),
library_scope: Some("lib1".into()),
library_scope_ids: None,
sort: vec![LibrarySortClause {
field: "name".into(),
dir: SortDir::Asc,
@@ -290,7 +272,6 @@ mod tests {
server_id: "s1".into(),
genre: "Rock".into(),
library_scope: None,
library_scope_ids: None,
sort: vec![],
limit: 1,
offset: 0,
@@ -9,7 +9,6 @@
pub(crate) mod bulk_ingest;
pub mod advanced_search;
pub mod album_browse;
pub mod album_compilation_filter;
pub mod browse_support;
mod advanced_search_mood;
@@ -34,7 +33,6 @@ pub mod payload;
pub mod repos;
pub mod runtime;
pub mod search;
pub mod server_cluster;
pub mod store;
pub mod sync;
pub(crate) mod track_fts;
@@ -2,12 +2,9 @@
//!
//! Mirrors the frontend allowlist in `src/utils/library/losslessFormats.ts`.
use crate::dto::{
LibraryAlbumDto, LibraryLosslessAlbumsRequest, LibraryLosslessAlbumsResponse, LibrarySortClause,
SortDir,
};
use crate::dto::{LibraryAlbumDto, LibraryLosslessAlbumsRequest, LibraryLosslessAlbumsResponse};
use crate::lossless_formats::track_is_lossless_sql;
use crate::search::library_scope_filter_sql;
use crate::search::library_scope_equals_sql;
use crate::store::LibraryStore;
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
@@ -18,45 +15,6 @@ fn trimmed_nonempty(s: Option<&str>) -> Option<String> {
.map(String::from)
}
fn effective_lossless_scope_ids(req: &LibraryLosslessAlbumsRequest) -> Vec<String> {
if let Some(ids) = &req.library_scope_ids {
let trimmed: Vec<_> = ids
.iter()
.filter(|s| !s.trim().is_empty())
.cloned()
.collect();
if !trimmed.is_empty() {
return trimmed;
}
}
trimmed_nonempty(req.library_scope.as_deref())
.map(|s| vec![s])
.unwrap_or_default()
}
fn lossless_album_order(sort: &[LibrarySortClause]) -> String {
let mut keys: Vec<String> = Vec::new();
for s in sort {
let col = match s.field.as_str() {
"name" => "COALESCE(a.name, la.album_name) COLLATE NOCASE",
"artist" => "COALESCE(a.artist, la.artist) COLLATE NOCASE",
_ => continue,
};
let dir = match s.dir {
SortDir::Asc => "ASC",
SortDir::Desc => "DESC",
};
keys.push(format!("{col} {dir}"));
}
if keys.is_empty() {
return "ORDER BY la.max_bit_depth DESC, \
COALESCE(a.name, la.album_name) COLLATE NOCASE ASC, la.album_id ASC"
.to_string();
}
keys.push("la.album_id ASC".to_string());
format!("ORDER BY {}", keys.join(", "))
}
/// Paginated lossless albums for one server. Returns empty when the index has
/// no matching tracks — caller may fall back to the Navidrome song-stream walk.
pub fn list_lossless_albums(
@@ -79,25 +37,12 @@ pub fn list_lossless_albums(
];
let mut params: Vec<SqlValue> = vec![SqlValue::Text(req.server_id.clone())];
let scope_ids = effective_lossless_scope_ids(req);
if !scope_ids.is_empty() {
let match_expr = crate::search::library_scope_match_sql("t");
where_clauses.push(format!("({match_expr}) IS NOT NULL AND TRIM({match_expr}) != ''"));
if let (Some(clause), scope_params) = library_scope_filter_sql("t", &scope_ids) {
where_clauses.push(clause);
for p in scope_params {
params.push(p);
}
}
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
let clause = library_scope_equals_sql("t");
where_clauses.push(clause);
params.push(SqlValue::Text(scope));
}
push_album_id_allowlist(
&mut where_clauses,
&mut params,
"t.album_id",
req.restrict_album_ids.as_deref(),
);
let order_sql = lossless_album_order(&req.sort);
let where_sql = where_clauses.join(" AND ");
let sql = format!(
"SELECT \
@@ -136,7 +81,9 @@ pub fn list_lossless_albums(
GROUP BY t.server_id, t.album_id \
) la \
LEFT JOIN album a ON a.server_id = la.server_id AND a.id = la.album_id \
{order_sql} \
ORDER BY la.max_bit_depth DESC, \
COALESCE(a.name, la.album_name) COLLATE NOCASE ASC, \
la.album_id ASC \
LIMIT ? OFFSET ?"
);
@@ -159,26 +106,6 @@ pub fn list_lossless_albums(
})
}
fn push_album_id_allowlist(
where_clauses: &mut Vec<String>,
params: &mut Vec<SqlValue>,
column: &str,
ids: Option<&[String]>,
) {
let Some(ids) = ids else {
return;
};
if ids.is_empty() {
where_clauses.push("1 = 0".to_string());
return;
}
let placeholders = (0..ids.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
where_clauses.push(format!("{column} IN ({placeholders})"));
for id in ids {
params.push(SqlValue::Text(id.clone()));
}
}
fn empty_response() -> LibraryLosslessAlbumsResponse {
LibraryLosslessAlbumsResponse {
albums: Vec::new(),
@@ -276,9 +203,6 @@ mod tests {
LibraryLosslessAlbumsRequest {
server_id: server.into(),
library_scope: None,
library_scope_ids: None,
sort: Vec::new(),
restrict_album_ids: None,
limit,
offset,
}
@@ -347,79 +271,6 @@ mod tests {
assert_eq!(resp.albums[0].id, "al1");
}
#[test]
fn library_scope_ids_union_narrows_results() {
let store = LibraryStore::open_in_memory();
let mut a = track_with_suffix("s1", "t1", "al1", "A", "flac", 16);
a.library_id = Some("lib1".into());
let mut b = track_with_suffix("s1", "t2", "al2", "B", "flac", 16);
b.library_id = Some("lib2".into());
let mut c = track_with_suffix("s1", "t3", "al3", "C", "flac", 16);
c.library_id = Some("lib3".into());
TrackRepository::new(&store)
.upsert_batch(&[a, b, c])
.unwrap();
let mut scoped = req("s1", 50, 0);
scoped.library_scope_ids = Some(vec!["lib1".into(), "lib3".into()]);
let resp = list_lossless_albums(&store, &scoped).unwrap();
assert_eq!(resp.albums.len(), 2);
assert_eq!(resp.albums[0].id, "al1");
assert_eq!(resp.albums[1].id, "al3");
}
#[test]
fn name_sort_overrides_bit_depth_default() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track_with_suffix("s1", "t1", "al_z", "Zulu", "flac", 24),
track_with_suffix("s1", "t2", "al_a", "Alpha", "flac", 16),
])
.unwrap();
let mut sorted = req("s1", 50, 0);
sorted.sort = vec![LibrarySortClause {
field: "name".into(),
dir: SortDir::Asc,
}];
let resp = list_lossless_albums(&store, &sorted).unwrap();
assert_eq!(resp.albums[0].id, "al_a");
assert_eq!(resp.albums[1].id, "al_z");
}
#[test]
fn matches_suffix_from_raw_json_when_column_null() {
let store = LibraryStore::open_in_memory();
let mut row = track_with_suffix("s1", "t1", "al_json", "Json", "mp3", 0);
row.suffix = None;
row.raw_json = r#"{"suffix":"flac","bitDepth":24}"#.into();
TrackRepository::new(&store)
.upsert_batch(&[row])
.unwrap();
let resp = list_lossless_albums(&store, &req("s1", 50, 0)).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_json");
}
#[test]
fn restrict_album_ids_narrows_lossless_results() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track_with_suffix("s1", "t1", "al_keep", "Keep", "flac", 24),
track_with_suffix("s1", "t2", "al_drop", "Drop", "flac", 24),
])
.unwrap();
let mut restricted = req("s1", 50, 0);
restricted.restrict_album_ids = Some(vec!["al_keep".into()]);
let resp = list_lossless_albums(&store, &restricted).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_keep");
}
#[test]
fn pagination_sets_has_more() {
let store = LibraryStore::open_in_memory();
@@ -7,22 +7,14 @@ pub const LOSSLESS_SUFFIXES: &[&str] = &[
"flac", "wav", "wave", "aiff", "aif", "dsf", "dff", "ape", "wv", "shn", "tta",
];
/// Effective suffix — hot `track.suffix`, then Navidrome `raw_json.suffix`.
pub fn track_suffix_expr(table_alias: &str) -> String {
format!(
"LOWER(COALESCE(NULLIF({table_alias}.suffix, ''), \
CAST(json_extract({table_alias}.raw_json, '$.suffix') AS TEXT), ''))"
)
}
/// `track_suffix_expr IN ('flac', …)` for SQL WHERE clauses.
/// `LOWER(alias.suffix) IN ('flac', …)` for SQL WHERE clauses.
pub fn track_is_lossless_sql(table_alias: &str) -> String {
let list = LOSSLESS_SUFFIXES
.iter()
.map(|s| format!("'{s}'"))
.collect::<Vec<_>>()
.join(", ");
format!("{} IN ({list})", track_suffix_expr(table_alias))
format!("LOWER({table_alias}.suffix) IN ({list})")
}
/// Album has at least one indexed lossless track (same allowlist as browse).
@@ -58,6 +50,6 @@ mod tests {
let sql = track_is_lossless_sql("t");
assert!(sql.contains("'flac'"));
assert!(sql.contains("'tta'"));
assert!(sql.contains("json_extract(t.raw_json, '$.suffix')"));
assert!(sql.starts_with("LOWER(t.suffix) IN ("));
}
}
@@ -228,6 +228,18 @@ impl<'a> TrackRepository<'a> {
})
}
/// All live rows for a Subsonic track id (any server). Used when legacy offline
/// folders name the server by URL index key rather than profile UUID.
pub fn find_live_by_id(&self, track_id: &str) -> Result<Vec<TrackRow>, String> {
self.store.with_read_conn(|conn| {
let mut stmt = conn.prepare(SELECT_TRACK_BY_ID_ONLY)?;
let rows = stmt
.query_map(params![track_id], row_to_track_row)?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
})
}
/// Batch SELECT — `library_get_tracks_batch`. Caller-supplied refs
/// preserve their order in the result; unknown / deleted refs
/// are silently dropped (frontend reads `tracks.length` against
@@ -292,6 +304,26 @@ impl<'a> TrackRepository<'a> {
})
}
/// Legacy offline rows keyed by library `server_id` (index key scope).
pub fn list_offline_local_paths(
&self,
server_id: &str,
) -> Result<Vec<(String, String, Option<String>)>, String> {
self.store.with_read_conn(|conn| {
let mut stmt = conn.prepare(
"SELECT track_id, local_path, suffix FROM track_offline WHERE server_id = ?1",
)?;
let rows = stmt.query_map(params![server_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
))
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
}
/// Tracks with `content_hash` and an analysis BPM fact — may still lack waveform/LUFS.
/// Confirmed per id via [`TrackAnalysisNeedsWorkQuery`].
pub fn list_analysis_hash_bpm_ids_after(
@@ -583,6 +615,13 @@ const SELECT_TRACK_BY_ID: &str = "SELECT server_id, id, title, title_sort, artis
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
FROM track WHERE server_id = ?1 AND id = ?2 AND deleted = 0";
const SELECT_TRACK_BY_ID_ONLY: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
FROM track WHERE id = ?1 AND deleted = 0";
const SELECT_TRACKS_BY_ALBUM: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
@@ -171,11 +171,6 @@ pub(crate) fn fts_album_title_prefix_match_query(raw: &str) -> Option<String> {
fts_prefix_token_expr(raw).map(|tokens| format!("album : {tokens}"))
}
/// All Albums title search — any query word may prefix-match the album column.
pub(crate) fn fts_album_title_prefix_any_token_match_query(raw: &str) -> Option<String> {
fts_prefix_token_or_expr(raw).map(|tokens| format!("album : ({tokens})"))
}
/// Live Search album match — any query word may hit album or album_artist (Navidrome parity).
pub(crate) fn fts_album_prefix_any_token_match_query(raw: &str) -> Option<String> {
fts_prefix_token_or_expr(raw).map(|tokens| {
@@ -231,35 +226,6 @@ pub(crate) fn library_scope_equals_sql(table_alias: &str) -> String {
format!("{} = ?", library_scope_match_sql(table_alias))
}
/// `library_id` filter for one or more Navidrome music-folder scopes.
pub(crate) fn library_scope_filter_sql(
table_alias: &str,
scope_ids: &[String],
) -> (Option<String>, Vec<rusqlite::types::Value>) {
use rusqlite::types::Value as SqlValue;
if scope_ids.is_empty() {
return (None, Vec::new());
}
if scope_ids.len() == 1 {
return (
Some(library_scope_equals_sql(table_alias)),
vec![SqlValue::Text(scope_ids[0].clone())],
);
}
let match_sql = library_scope_match_sql(table_alias);
let placeholders = (0..scope_ids.len())
.map(|_| "?")
.collect::<Vec<_>>()
.join(", ");
(
Some(format!("{match_sql} IN ({placeholders})")),
scope_ids
.iter()
.map(|s| SqlValue::Text(s.clone()))
.collect(),
)
}
pub(crate) fn aliased_track_columns(alias: &str) -> String {
crate::repos::track_columns()
.split(',')
@@ -354,36 +320,6 @@ pub(crate) fn like_contains(raw: &str) -> String {
format!("%{escaped}%")
}
/// Whitespace-split tokens for substring LIKE (any non-empty segment).
pub(crate) fn like_name_tokens(raw: &str) -> Vec<String> {
raw.split_whitespace()
.map(str::trim)
.filter(|t| !t.is_empty())
.map(str::to_string)
.collect()
}
/// `(col LIKE ? OR …)` — any query word may match as a case-insensitive substring.
pub(crate) fn like_any_token_contains_clause(column: &str, raw: &str) -> Option<(String, Vec<String>)> {
let tokens = like_name_tokens(raw);
if tokens.is_empty() {
return None;
}
let col = format!("{column} COLLATE NOCASE");
if tokens.len() == 1 {
return Some((
format!("{col} LIKE ? ESCAPE '\\'"),
vec![like_contains(&tokens[0])],
));
}
let parts: Vec<String> = tokens
.iter()
.map(|_| format!("{col} LIKE ? ESCAPE '\\'"))
.collect();
let params: Vec<String> = tokens.iter().map(|t| like_contains(t)).collect();
Some((format!("({})", parts.join(" OR ")), params))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -526,23 +462,6 @@ mod tests {
);
}
#[test]
fn fts_album_title_prefix_any_token_match_query_or_words() {
assert_eq!(
fts_album_title_prefix_any_token_match_query("dark side").as_deref(),
Some("album : (\"dark\"* OR \"side\"*)")
);
}
#[test]
fn like_any_token_contains_clause_ors_words() {
let (sql, params) = like_any_token_contains_clause("a.name", "dark side").unwrap();
assert!(sql.contains(" OR "));
assert_eq!(params.len(), 2);
assert_eq!(params[0], "%dark%");
assert_eq!(params[1], "%side%");
}
#[test]
fn fts_track_match_query_or_across_display_columns() {
let q = fts_track_match_query("manowar").unwrap();
@@ -1,595 +0,0 @@
//! Cluster-scope advanced search: run per-server advanced search, then merge
//! winners by cluster identity keys with server-priority precedence.
use std::collections::{BTreeSet, HashMap, HashSet};
use rusqlite::types::Value as SqlValue;
use crate::advanced_search::run_advanced_search;
use crate::dto::{
LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse, LibraryAlbumDto, LibraryArtistDto,
LibraryClusterAdvancedSearchRequest, LibrarySearchTotals, LibraryTrackDto,
};
use crate::search::PAGE_LIMIT_MAX;
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
pub fn run_cluster_advanced_search(
store: &LibraryStore,
req: LibraryClusterAdvancedSearchRequest,
) -> Result<LibraryAdvancedSearchResponse, String> {
if req.servers_ordered.is_empty() {
return Ok(empty_response(req.skip_totals));
}
let page_limit = req.limit.clamp(1, PAGE_LIMIT_MAX);
let page_offset = req.offset as usize;
let per_server_limit = req
.limit
.saturating_add(req.offset)
.clamp(1, PAGE_LIMIT_MAX);
let mut all_tracks: Vec<LibraryTrackDto> = Vec::new();
let mut all_albums: Vec<LibraryAlbumDto> = Vec::new();
let mut all_artists: Vec<LibraryArtistDto> = Vec::new();
let mut applied_filters = BTreeSet::new();
for server_id in &req.servers_ordered {
let server_req = LibraryAdvancedSearchRequest {
server_id: server_id.clone(),
library_scope: None,
library_scope_ids: req
.library_scopes
.get(server_id)
.filter(|ids| !ids.is_empty())
.cloned(),
query: req.query.clone(),
entity_types: req.entity_types.clone(),
filters: req.filters.clone(),
starred_only: req.starred_only,
restrict_album_ids: req
.restrict_album_scopes
.get(server_id)
.filter(|ids| !ids.is_empty())
.cloned()
.or_else(|| req.restrict_album_ids.clone()),
query_album_title_only: req.query_album_title_only,
sort: req.sort.clone(),
limit: per_server_limit,
offset: 0,
skip_totals: true,
};
let resp = run_advanced_search(store, &server_req)?;
all_tracks.extend(resp.tracks);
all_albums.extend(resp.albums);
all_artists.extend(resp.artists);
applied_filters.extend(resp.applied_filters);
}
let merged_tracks = merge_tracks_by_cluster_key(store, all_tracks)?;
let merged_albums = if req
.query
.as_ref()
.is_some_and(|q| !q.trim().is_empty())
{
dedupe_album_search_hits(all_albums)
} else {
merge_albums_by_album_key(store, all_albums)?
};
let merged_artists = merge_artists_by_artist_key(store, all_artists)?;
let totals = if req.skip_totals {
LibrarySearchTotals::default()
} else {
LibrarySearchTotals {
artists: merged_artists.len() as u32,
albums: merged_albums.len() as u32,
tracks: merged_tracks.len() as u32,
}
};
Ok(LibraryAdvancedSearchResponse {
artists: merged_artists
.into_iter()
.skip(page_offset)
.take(page_limit as usize)
.collect(),
albums: merged_albums
.into_iter()
.skip(page_offset)
.take(page_limit as usize)
.collect(),
tracks: merged_tracks
.into_iter()
.skip(page_offset)
.take(page_limit as usize)
.collect(),
totals,
applied_filters: applied_filters.into_iter().collect(),
source: "local".to_string(),
})
}
fn empty_response(skip_totals: bool) -> LibraryAdvancedSearchResponse {
LibraryAdvancedSearchResponse {
artists: Vec::new(),
albums: Vec::new(),
tracks: Vec::new(),
totals: if skip_totals {
LibrarySearchTotals::default()
} else {
LibrarySearchTotals {
artists: 0,
albums: 0,
tracks: 0,
}
},
applied_filters: Vec::new(),
source: "local".to_string(),
}
}
fn merge_tracks_by_cluster_key(
store: &LibraryStore,
tracks: Vec<LibraryTrackDto>,
) -> Result<Vec<LibraryTrackDto>, String> {
let refs: Vec<(String, String)> = tracks
.iter()
.map(|t| (t.server_id.clone(), t.id.clone()))
.collect();
let key_map = lookup_track_cluster_keys(store, &refs)?;
let mut seen = HashSet::new();
let mut out = Vec::new();
for track in tracks {
let key = key_map
.get(&(track.server_id.clone(), track.id.clone()))
.and_then(|v| v.clone())
.unwrap_or_else(|| format!("solo:{}:{}", track.server_id, track.id));
if seen.insert(key) {
out.push(track);
}
}
Ok(out)
}
/// Text search — keep distinct `(server_id, album_id)` rows; do not collapse
/// same-server albums that share an `album_key` (tribute / variant titles).
fn dedupe_album_search_hits(albums: Vec<LibraryAlbumDto>) -> Vec<LibraryAlbumDto> {
let mut seen = HashSet::new();
let mut out = Vec::new();
for album in albums {
if seen.insert((album.server_id.clone(), album.id.clone())) {
out.push(album);
}
}
out
}
fn merge_albums_by_album_key(
store: &LibraryStore,
albums: Vec<LibraryAlbumDto>,
) -> Result<Vec<LibraryAlbumDto>, String> {
let refs: Vec<(String, String)> = albums
.iter()
.map(|a| (a.server_id.clone(), a.id.clone()))
.collect();
let key_map = lookup_album_keys(store, &refs)?;
let mut seen = HashSet::new();
let mut out = Vec::new();
for album in albums {
let key = key_map
.get(&(album.server_id.clone(), album.id.clone()))
.and_then(|v| v.clone())
.unwrap_or_else(|| format!("solo:{}:{}", album.server_id, album.id));
if seen.insert(key) {
out.push(album);
}
}
Ok(out)
}
fn merge_artists_by_artist_key(
store: &LibraryStore,
artists: Vec<LibraryArtistDto>,
) -> Result<Vec<LibraryArtistDto>, String> {
let refs: Vec<(String, String)> = artists
.iter()
.map(|a| (a.server_id.clone(), a.id.clone()))
.collect();
let key_map = lookup_artist_keys(store, &refs)?;
let mut seen = HashSet::new();
let mut out = Vec::new();
for artist in artists {
let key = key_map
.get(&(artist.server_id.clone(), artist.id.clone()))
.and_then(|v| v.clone())
.unwrap_or_else(|| format!("solo:{}:{}", artist.server_id, artist.id));
if seen.insert(key) {
out.push(artist);
}
}
Ok(out)
}
fn lookup_track_cluster_keys(
store: &LibraryStore,
refs: &[(String, String)],
) -> Result<HashMap<(String, String), Option<String>>, String> {
lookup_keys_with_values(
store,
refs,
&format!(
"SELECT w.server_id, w.entity_id, k.cluster_key
FROM wanted w
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = w.server_id AND k.track_id = w.entity_id"
),
)
}
fn lookup_album_keys(
store: &LibraryStore,
refs: &[(String, String)],
) -> Result<HashMap<(String, String), Option<String>>, String> {
lookup_keys_with_values(
store,
refs,
&format!(
"SELECT w.server_id, w.entity_id, MIN(k.album_key)
FROM wanted w
LEFT JOIN track t
ON t.server_id = w.server_id AND t.album_id = w.entity_id AND t.deleted = 0
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
GROUP BY w.server_id, w.entity_id"
),
)
}
fn lookup_artist_keys(
store: &LibraryStore,
refs: &[(String, String)],
) -> Result<HashMap<(String, String), Option<String>>, String> {
lookup_keys_with_values(
store,
refs,
&format!(
"SELECT w.server_id, w.entity_id, MIN(k.artist_key)
FROM wanted w
LEFT JOIN track t
ON t.server_id = w.server_id
AND COALESCE(NULLIF(t.artist_id, ''), t.artist) = w.entity_id
AND t.deleted = 0
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
GROUP BY w.server_id, w.entity_id"
),
)
}
fn lookup_keys_with_values(
store: &LibraryStore,
refs: &[(String, String)],
query_sql: &str,
) -> Result<HashMap<(String, String), Option<String>>, String> {
if refs.is_empty() {
return Ok(HashMap::new());
}
let values_sql = std::iter::repeat_n("(?, ?)", refs.len()).collect::<Vec<_>>().join(", ");
let sql = format!("WITH wanted(server_id, entity_id) AS (VALUES {values_sql}) {query_sql}");
let mut bind: Vec<SqlValue> = Vec::with_capacity(refs.len() * 2);
for (server_id, entity_id) in refs {
bind.push(SqlValue::Text(server_id.clone()));
bind.push(SqlValue::Text(entity_id.clone()));
}
store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let mut rows = stmt.query(rusqlite::params_from_iter(bind.iter()))?;
let mut out = HashMap::new();
while let Some(row) = rows.next()? {
let server_id: String = row.get(0)?;
let entity_id: String = row.get(1)?;
let key: Option<String> = row.get(2)?;
out.insert((server_id, entity_id), key);
}
Ok(out)
})
}
#[cfg(test)]
mod tests {
use crate::filter::EntityKind;
use crate::repos::{TrackRepository, TrackRow};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
use super::*;
fn track(server: &str, id: &str, artist: &str, artist_id: &str, album: &str, album_id: &str) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: "Song".into(),
title_sort: None,
artist: Some(artist.into()),
artist_id: Some(artist_id.into()),
album: album.into(),
album_id: Some(album_id.into()),
album_artist: Some(artist.into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: Some(2024),
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn merges_tracks_by_cluster_key_with_priority() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Band", "art-1", "LP", "alb-1"),
track("s2", "t2", "Band", "art-2", "LP", "alb-2"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = run_cluster_advanced_search(
&store,
LibraryClusterAdvancedSearchRequest {
servers_ordered: vec!["s1".into(), "s2".into()],
query: None,
entity_types: vec![EntityKind::Track],
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
restrict_album_scopes: HashMap::new(),
query_album_title_only: None,
sort: Vec::new(),
limit: 50,
offset: 0,
skip_totals: false,
library_scopes: HashMap::new(),
},
)
.unwrap();
assert_eq!(resp.tracks.len(), 1);
assert_eq!(resp.tracks[0].server_id, "s1");
assert_eq!(resp.totals.tracks, 1);
assert_eq!(resp.totals.albums, 0);
assert_eq!(resp.totals.artists, 0);
}
fn insert_album(store: &LibraryStore, server: &str, id: &str, name: &str) {
store
.with_conn("misc", |c| {
c.execute(
"INSERT INTO album (server_id, id, name, synced_at, raw_json) \
VALUES (?1, ?2, ?3, 1, '{}')",
rusqlite::params![server, id, name],
)
})
.unwrap();
}
#[test]
fn cluster_text_search_respects_per_member_library_scope() {
let store = LibraryStore::open_in_memory();
let mut in_scope = track("s1", "t1", "Band", "art-1", "Metallica", "alb-in");
in_scope.library_id = Some("lib-a".into());
let mut out_scope = track("s1", "t2", "Band", "art-2", "Metallica Tribute", "alb-out");
out_scope.library_id = Some("lib-b".into());
TrackRepository::new(&store)
.upsert_batch(&[in_scope, out_scope])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let mut scopes = HashMap::new();
scopes.insert("s1".into(), vec!["lib-a".into()]);
let resp = run_cluster_advanced_search(
&store,
LibraryClusterAdvancedSearchRequest {
servers_ordered: vec!["s1".into()],
query: Some("metallica".into()),
entity_types: vec![EntityKind::Album],
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
restrict_album_scopes: HashMap::new(),
query_album_title_only: Some(true),
sort: Vec::new(),
limit: 50,
offset: 0,
skip_totals: false,
library_scopes: scopes,
},
)
.unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "alb-in");
}
#[test]
fn cluster_metallica_text_search_unions_table_and_track_catalog() {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al_self", "Metallica");
store
.with_conn("misc", |c| {
c.execute(
"UPDATE album SET song_count = 10 WHERE server_id = 's1' AND id = 'al_self'",
[],
)
})
.unwrap();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Apocalyptica", "art-1", "Plays Metallica Vol. 2", "al_plays"),
track("s1", "t2", "Various", "art-2", "The Metallica Blacklist", "al_black"),
track("s1", "t3", "Pink Floyd", "art-3", "Wish You Were Here", "al_wish"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = run_cluster_advanced_search(
&store,
LibraryClusterAdvancedSearchRequest {
servers_ordered: vec!["s1".into()],
query: Some("metallica".into()),
entity_types: vec![EntityKind::Album],
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
restrict_album_scopes: HashMap::new(),
query_album_title_only: Some(true),
sort: Vec::new(),
limit: 50,
offset: 0,
skip_totals: false,
library_scopes: HashMap::new(),
},
)
.unwrap();
let ids: Vec<&str> = resp.albums.iter().map(|a| a.id.as_str()).collect();
assert_eq!(ids.len(), 3, "expected {ids:?}");
assert!(ids.contains(&"al_self"));
assert!(ids.contains(&"al_plays"));
assert!(ids.contains(&"al_black"));
}
#[test]
fn text_search_keeps_distinct_same_server_albums_with_shared_album_key() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Band", "art-1", "Metallica", "alb-1"),
track("s1", "t2", "Band", "art-2", "Plays Metallica Vol. 2", "alb-2"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = run_cluster_advanced_search(
&store,
LibraryClusterAdvancedSearchRequest {
servers_ordered: vec!["s1".into()],
query: Some("metallica".into()),
entity_types: vec![EntityKind::Album],
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
restrict_album_scopes: HashMap::new(),
query_album_title_only: Some(true),
sort: Vec::new(),
limit: 50,
offset: 0,
skip_totals: false,
library_scopes: HashMap::new(),
},
)
.unwrap();
assert_eq!(resp.albums.len(), 2);
}
#[test]
fn merges_albums_by_album_key_with_priority() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Band", "art-1", "LP", "alb-1"),
track("s2", "t2", "Band", "art-2", "LP", "alb-2"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = run_cluster_advanced_search(
&store,
LibraryClusterAdvancedSearchRequest {
servers_ordered: vec!["s1".into(), "s2".into()],
query: None,
entity_types: vec![EntityKind::Album],
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
restrict_album_scopes: HashMap::new(),
query_album_title_only: None,
sort: Vec::new(),
limit: 50,
offset: 0,
skip_totals: false,
library_scopes: HashMap::new(),
},
)
.unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].server_id, "s1");
}
#[test]
fn applies_offset_after_merge() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Band A", "art-a1", "LP A", "alb-a1"),
track("s2", "t2", "Band A", "art-a2", "LP A", "alb-a2"),
track("s1", "t3", "Band B", "art-b1", "LP B", "alb-b1"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = run_cluster_advanced_search(
&store,
LibraryClusterAdvancedSearchRequest {
servers_ordered: vec!["s1".into(), "s2".into()],
query: None,
entity_types: vec![EntityKind::Track],
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
restrict_album_scopes: HashMap::new(),
query_album_title_only: None,
sort: Vec::new(),
limit: 1,
offset: 1,
skip_totals: false,
library_scopes: HashMap::new(),
},
)
.unwrap();
assert_eq!(resp.tracks.len(), 1);
assert_eq!(resp.totals.tracks, 2);
}
}
@@ -1,171 +0,0 @@
//! Attached `library-cluster.db` schema and metadata.
use std::path::{Path, PathBuf};
use rusqlite::{params, Connection, OptionalExtension};
pub const CLUSTER_DB_FILENAME: &str = "library-cluster.db";
pub const ATTACH_ALIAS: &str = "cluster";
pub const NORM_VERSION: &str = "1";
pub fn cluster_db_path(library_db_path: &Path) -> PathBuf {
library_db_path.with_file_name(CLUSTER_DB_FILENAME)
}
fn escape_sqlite_path(path: &str) -> String {
path.replace('\'', "''")
}
fn init_cluster_on_attached(conn: &Connection) -> rusqlite::Result<()> {
conn.execute(
&format!(
"CREATE TABLE IF NOT EXISTS {ATTACH_ALIAS}.track_cluster_key (
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
cluster_key TEXT NOT NULL,
album_key TEXT,
artist_key TEXT,
duration_sec INTEGER,
PRIMARY KEY (server_id, track_id)
)"
),
[],
)?;
conn.execute(
&format!(
"CREATE INDEX IF NOT EXISTS {ATTACH_ALIAS}.idx_cluster_key \
ON track_cluster_key(cluster_key)"
),
[],
)?;
conn.execute(
&format!(
"CREATE INDEX IF NOT EXISTS {ATTACH_ALIAS}.idx_album_key \
ON track_cluster_key(album_key)"
),
[],
)?;
conn.execute(
&format!(
"CREATE INDEX IF NOT EXISTS {ATTACH_ALIAS}.idx_artist_key \
ON track_cluster_key(artist_key)"
),
[],
)?;
conn.execute(
&format!(
"CREATE TABLE IF NOT EXISTS {ATTACH_ALIAS}.cluster_meta (
key TEXT PRIMARY KEY,
value TEXT
)"
),
[],
)?;
init_cluster_meta(conn)?;
Ok(())
}
fn init_cluster_file(cluster_path: &Path) -> rusqlite::Result<()> {
let cluster_conn = Connection::open(cluster_path)?;
cluster_conn.execute_batch(
"CREATE TABLE IF NOT EXISTS track_cluster_key (
server_id TEXT NOT NULL,
track_id TEXT NOT NULL,
cluster_key TEXT NOT NULL,
album_key TEXT,
artist_key TEXT,
duration_sec INTEGER,
PRIMARY KEY (server_id, track_id)
);
CREATE INDEX IF NOT EXISTS idx_cluster_key ON track_cluster_key(cluster_key);
CREATE INDEX IF NOT EXISTS idx_album_key ON track_cluster_key(album_key);
CREATE INDEX IF NOT EXISTS idx_artist_key ON track_cluster_key(artist_key);
CREATE TABLE IF NOT EXISTS cluster_meta (
key TEXT PRIMARY KEY,
value TEXT
);",
)?;
cluster_conn.execute(
"INSERT OR IGNORE INTO cluster_meta (key, value) VALUES ('norm_version', ?1)",
params![NORM_VERSION],
)?;
Ok(())
}
/// Create or migrate the cluster file, then attach it to `conn`.
pub fn attach_cluster_database(conn: &Connection, cluster_path: &Path) -> rusqlite::Result<()> {
if let Some(parent) = cluster_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_IOERR),
Some(e.to_string()),
)
})?;
}
init_cluster_file(cluster_path)?;
let path_str = escape_sqlite_path(&cluster_path.to_string_lossy());
conn.execute_batch(&format!(
"ATTACH DATABASE '{path_str}' AS {ATTACH_ALIAS}"
))?;
Ok(())
}
/// In-memory cluster DB for tests — attach then init schema on the alias.
pub fn attach_cluster_database_uri(conn: &Connection, uri: &str) -> rusqlite::Result<()> {
let path_str = escape_sqlite_path(uri);
conn.execute_batch(&format!(
"ATTACH DATABASE '{path_str}' AS {ATTACH_ALIAS}"
))?;
init_cluster_on_attached(conn)
}
pub fn ensure_cluster_schema(conn: &Connection) -> rusqlite::Result<()> {
init_cluster_on_attached(conn)
}
pub fn init_cluster_meta(conn: &Connection) -> rusqlite::Result<()> {
conn.execute(
&format!(
"INSERT OR IGNORE INTO {ATTACH_ALIAS}.cluster_meta (key, value) VALUES ('norm_version', ?1)"
),
params![NORM_VERSION],
)?;
Ok(())
}
pub fn stored_norm_version(conn: &Connection) -> rusqlite::Result<Option<String>> {
conn.query_row(
&format!(
"SELECT value FROM {ATTACH_ALIAS}.cluster_meta WHERE key = 'norm_version'"
),
[],
|row| row.get(0),
)
.optional()
}
pub fn needs_norm_rebuild(conn: &Connection) -> rusqlite::Result<bool> {
Ok(stored_norm_version(conn)?.as_deref() != Some(NORM_VERSION))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_creates_cluster_tables_on_attach() {
let conn = Connection::open_in_memory().unwrap();
attach_cluster_database_uri(&conn, "file:cluster_mem?mode=memory&cache=shared").unwrap();
let count: i64 = conn
.query_row(
&format!(
"SELECT COUNT(*) FROM {ATTACH_ALIAS}.sqlite_master \
WHERE type='table' AND name='track_cluster_key'"
),
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,135 +0,0 @@
//! Derive `cluster_key`, `album_key`, and `artist_key` from track metadata.
use std::hash::{Hash, Hasher};
use twox_hash::XxHash64;
use super::norm::norm_field;
const FIELD_SEP: u8 = 0x1f;
/// Precomputed keys for one track. `None` when any required field normalizes empty.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrackClusterKeys {
pub cluster_key: String,
pub album_key: String,
pub artist_key: String,
}
fn effective_artist<'a>(artist: Option<&'a str>, album_artist: Option<&'a str>) -> Option<&'a str> {
artist
.filter(|s| !s.is_empty())
.or_else(|| album_artist.filter(|s| !s.is_empty()))
}
fn effective_album_artist<'a>(
album_artist: Option<&'a str>,
artist: Option<&'a str>,
) -> Option<&'a str> {
album_artist
.filter(|s| !s.is_empty())
.or_else(|| artist.filter(|s| !s.is_empty()))
}
fn hash_parts(parts: &[&str], sep: Option<u8>) -> String {
let mut hasher = XxHash64::with_seed(0);
for (i, part) in parts.iter().enumerate() {
if i > 0 {
if let Some(sep) = sep {
sep.hash(&mut hasher);
}
}
part.hash(&mut hasher);
}
format!("{:016x}", hasher.finish())
}
/// Compute cluster identity keys from raw track fields (spec §2.12.5).
pub fn compute_track_cluster_keys(
artist: Option<&str>,
album_artist: Option<&str>,
title: &str,
album: &str,
) -> Option<TrackClusterKeys> {
let artist_src = effective_artist(artist, album_artist)?;
let norm_artist = norm_field(artist_src);
let norm_title = norm_field(title);
let norm_album = norm_field(album);
if norm_artist.is_empty() || norm_title.is_empty() || norm_album.is_empty() {
return None;
}
let cluster_key = hash_parts(&[&norm_artist, &norm_title, &norm_album], Some(FIELD_SEP));
let album_artist_src = effective_album_artist(album_artist, artist).unwrap_or(artist_src);
let norm_album_artist = norm_field(album_artist_src);
let album_key = hash_parts(&[&norm_album_artist, &norm_album], None);
let artist_key = hash_parts(&[&norm_artist], None);
Some(TrackClusterKeys {
cluster_key,
album_key,
artist_key,
})
}
/// Stable cross-server artist merge key from display name alone (spec §2.5).
pub fn artist_key_from_display_name(name: &str) -> Option<String> {
let norm = norm_field(name);
if norm.is_empty() {
return None;
}
Some(hash_parts(&[&norm], None))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn same_metadata_yields_same_keys() {
let a = compute_track_cluster_keys(
Some("Artist"),
None,
"Title",
"Album",
)
.unwrap();
let b = compute_track_cluster_keys(
Some("artist"),
None,
"title",
"album",
)
.unwrap();
assert_eq!(a, b);
}
#[test]
fn artist_falls_back_to_album_artist_for_cluster_key() {
let with = compute_track_cluster_keys(None, Some("Band"), "Song", "LP").unwrap();
let direct = compute_track_cluster_keys(Some("Band"), None, "Song", "LP").unwrap();
assert_eq!(with.cluster_key, direct.cluster_key);
}
#[test]
fn empty_title_or_album_yields_none() {
assert!(compute_track_cluster_keys(Some("A"), None, "", "Album").is_none());
assert!(compute_track_cluster_keys(Some("A"), None, "Title", "").is_none());
assert!(compute_track_cluster_keys(None, None, "Title", "Album").is_none());
}
#[test]
fn artist_key_from_display_name_matches_track_derived_key() {
let from_track = compute_track_cluster_keys(Some("Pink Floyd"), None, "x", "y").unwrap();
let from_name = artist_key_from_display_name("Pink Floyd").unwrap();
assert_eq!(from_track.artist_key, from_name);
}
#[test]
fn punctuation_insensitive_cluster_key() {
let a = compute_track_cluster_keys(Some("Pink Floyd"), None, "Time", "Dark Side").unwrap();
let b = compute_track_cluster_keys(Some("Pink Floyd"), None, "Time!", "Dark Side.").unwrap();
assert_eq!(a.cluster_key, b.cluster_key);
}
}
@@ -1,89 +0,0 @@
//! Per-member music-folder (`library_scope`) filters for merged cluster queries.
use std::collections::HashMap;
use rusqlite::types::Value as SqlValue;
use crate::search::{library_scope_equals_sql, library_scope_match_sql};
/// `(sql_suffix, bind_params)` — AND ( (server + optional scope) OR … ).
pub(crate) fn scope_filter_sql_and_params(
table_alias: &str,
servers_ordered: &[String],
scopes: &HashMap<String, Vec<String>>,
) -> (String, Vec<SqlValue>) {
if scopes.is_empty() {
return (String::new(), Vec::new());
}
let mut parts = Vec::with_capacity(servers_ordered.len());
let mut params = Vec::new();
for sid in servers_ordered {
if let Some(scope_ids) = scopes.get(sid).filter(|v| !v.is_empty()) {
if scope_ids.len() == 1 {
let eq = library_scope_equals_sql(table_alias);
parts.push(format!("({table_alias}.server_id = ? AND {eq})"));
params.push(SqlValue::Text(sid.clone()));
params.push(SqlValue::Text(scope_ids[0].clone()));
} else {
let match_sql = library_scope_match_sql(table_alias);
let ph = (0..scope_ids.len())
.map(|_| "?")
.collect::<Vec<_>>()
.join(", ");
parts.push(format!(
"({table_alias}.server_id = ? AND {match_sql} IN ({ph}))"
));
params.push(SqlValue::Text(sid.clone()));
for id in scope_ids {
params.push(SqlValue::Text(id.clone()));
}
}
} else {
parts.push(format!("({table_alias}.server_id = ?)"));
params.push(SqlValue::Text(sid.clone()));
}
}
(format!(" AND ({})", parts.join(" OR ")), params)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::search::library_scope_filter_sql;
#[test]
fn scope_filter_empty_when_no_scopes() {
let (sql, params) = scope_filter_sql_and_params("t", &["s1".into()], &HashMap::new());
assert!(sql.is_empty());
assert!(params.is_empty());
}
#[test]
fn scope_filter_binds_scoped_and_unscoped_members() {
let mut scopes = HashMap::new();
scopes.insert("s1".into(), vec!["lib-a".into()]);
let (sql, params) = scope_filter_sql_and_params(
"t",
&["s1".into(), "s2".into()],
&scopes,
);
assert!(sql.contains("t.server_id = ?"));
assert_eq!(params.len(), 3);
}
#[test]
fn scope_filter_multi_folder_on_one_member() {
let mut scopes = HashMap::new();
scopes.insert("s1".into(), vec!["lib-a".into(), "lib-b".into()]);
let (sql, params) = scope_filter_sql_and_params("t", &["s1".into()], &scopes);
assert!(sql.contains(" IN ("));
assert_eq!(params.len(), 3);
}
#[test]
fn single_scope_uses_equals() {
let (sql, params) = library_scope_filter_sql("t", &["lib-a".into()]);
assert!(sql.unwrap().contains("= ?"));
assert_eq!(params.len(), 1);
}
}
@@ -1,204 +0,0 @@
//! Merged track listing for cluster scope (spec §4 Tier 1).
use rusqlite::types::Value as SqlValue;
use crate::dto::{LibraryTrackDto, LibraryTracksEnvelope};
use crate::repos;
use crate::search::{aliased_track_columns, PAGE_LIMIT_MAX};
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::library_scope::scope_filter_sql_and_params;
use super::merge::DURATION_TOLERANCE_SEC;
use super::priority::{in_list_sql, priority_case_sql};
/// List merged tracks — one row per `cluster_key` (priority winner), solo rows
/// for empty-key tracks and duration outliers.
pub fn list_merged_tracks(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
offset: u32,
library_scopes: &std::collections::HashMap<String, Vec<String>>,
) -> Result<LibraryTracksEnvelope, String> {
if servers_ordered.is_empty() {
return Ok(LibraryTracksEnvelope {
tracks: vec![],
total: 0,
});
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset.min(i32::MAX as u32) as i32;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let (scope_sql, mut scope_params) = scope_filter_sql_and_params("t", servers_ordered, library_scopes);
let cols = aliased_track_columns("t");
let sql = format!(
"WITH candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.id AS track_id,
k.cluster_key,
COALESCE(k.duration_sec, t.duration_sec) AS dur,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0 AND t.server_id IN ({in_placeholders}){scope_sql}
),
refs AS (
SELECT cluster_key, MIN(priority_rank) AS best_rank
FROM candidates
WHERE cluster_key IS NOT NULL
GROUP BY cluster_key
),
ref_dur AS (
SELECT c.cluster_key, c.dur AS ref_dur
FROM candidates c
JOIN refs r ON c.cluster_key = r.cluster_key AND c.priority_rank = r.best_rank
),
partitioned AS (
SELECT c.tid,
CASE
WHEN c.cluster_key IS NULL THEN 'solo:' || c.server_id || ':' || c.track_id
WHEN ABS(c.dur - rd.ref_dur) <= {tol} THEN c.cluster_key
ELSE 'solo:' || c.server_id || ':' || c.track_id
END AS merge_key,
c.priority_rank
FROM candidates c
LEFT JOIN ref_dur rd ON c.cluster_key = rd.cluster_key
),
winners AS (
SELECT tid,
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
FROM partitioned
)
SELECT {cols}
FROM winners w
JOIN track t ON t.rowid = w.tid
WHERE w.rn = 1
ORDER BY t.title COLLATE NOCASE, t.server_id, t.id
LIMIT ? OFFSET ?",
tol = DURATION_TOLERANCE_SEC,
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.append(&mut scope_params);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let tracks: Vec<LibraryTrackDto> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})?;
Ok(LibraryTracksEnvelope {
total: tracks.len() as u32,
tracks,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use crate::repos::{TrackRepository, TrackRow};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
fn track(server: &str, id: &str, title: &str, artist: &str, album: &str, dur: i64) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: title.into(),
title_sort: None,
artist: Some(artist.into()),
artist_id: None,
album: album.into(),
album_id: None,
album_artist: Some(artist.into()),
duration_sec: dur,
track_number: Some(1),
disc_number: Some(1),
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn merge_collapses_same_cluster_key_by_priority() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Song", "Band", "LP", 200),
track("s2", "t2", "Song", "Band", "LP", 201),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let env = list_merged_tracks(&store, &["s1".into(), "s2".into()], 50, 0, &HashMap::new()).unwrap();
assert_eq!(env.tracks.len(), 1);
assert_eq!(env.tracks[0].server_id, "s1");
}
#[test]
fn unavailable_priority_falls_through() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Song", "Band", "LP", 200),
track("s2", "t2", "Song", "Band", "LP", 201),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let env = list_merged_tracks(&store, &["s2".into()], 50, 0, &std::collections::HashMap::new()).unwrap();
assert_eq!(env.tracks.len(), 1);
assert_eq!(env.tracks[0].server_id, "s2");
}
#[test]
fn empty_key_tracks_never_merge() {
let store = LibraryStore::open_in_memory();
store
.with_conn_mut("misc", |c| {
c.execute(
"INSERT INTO track (server_id, id, title, artist, album, duration_sec, synced_at, raw_json) \
VALUES ('s1', 't1', 'A', '', 'X', 1, 1, '{}'), \
('s2', 't2', 'A', '', 'X', 1, 1, '{}')",
[],
)
})
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let env = list_merged_tracks(&store, &["s1".into(), "s2".into()], 50, 0, &HashMap::new()).unwrap();
assert_eq!(env.tracks.len(), 2);
}
}
@@ -1,285 +0,0 @@
//! Merged album listing for cluster scope (spec §4 Tier 1 — dedup by `album_key`).
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
use crate::dto::{LibraryAlbumDto, LibraryClusterAlbumsResponse};
use crate::search::PAGE_LIMIT_MAX;
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::library_scope::scope_filter_sql_and_params;
use super::merge::ALBUM_ROLLUP_AND_PARTITION_CTE;
use super::priority::{in_list_sql, priority_case_sql};
pub fn list_merged_albums(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
offset: u32,
library_scopes: &std::collections::HashMap<String, Vec<String>>,
) -> Result<LibraryClusterAlbumsResponse, String> {
if servers_ordered.is_empty() {
return Ok(LibraryClusterAlbumsResponse {
albums: vec![],
has_more: false,
});
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset.min(i32::MAX as u32) as i32;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let (scope_sql, mut scope_params) = scope_filter_sql_and_params("t", servers_ordered, library_scopes);
let sql = format!(
"WITH candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.album_id,
k.album_key,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0
AND t.server_id IN ({in_placeholders})
AND t.album_id IS NOT NULL AND t.album_id != ''{scope_sql}
),
{ALBUM_ROLLUP_AND_PARTITION_CTE}
winners AS (
SELECT tid,
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
FROM partitioned
)
SELECT
t.server_id,
t.album_id,
COALESCE(a.name, t.album),
COALESCE(a.artist, t.artist),
COALESCE(a.artist_id, t.artist_id),
COALESCE(a.song_count, (
SELECT COUNT(*) FROM track c
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0
)),
COALESCE(a.duration_sec, (
SELECT COALESCE(SUM(c.duration_sec), 0) FROM track c
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0
)),
COALESCE(a.year, t.year),
COALESCE(a.genre, t.genre),
COALESCE(a.cover_art_id, t.cover_art_id),
COALESCE(a.starred_at, t.starred_at),
COALESCE(a.synced_at, t.synced_at),
a.raw_json
FROM winners w
JOIN track t ON t.rowid = w.tid
LEFT JOIN album a ON a.server_id = t.server_id AND a.id = t.album_id
WHERE w.rn = 1
ORDER BY COALESCE(a.name, t.album) COLLATE NOCASE, t.server_id, t.album_id
LIMIT ? OFFSET ?",
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.append(&mut scope_params);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let albums: Vec<LibraryAlbumDto> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_album_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})?;
let has_more = albums.len() as u32 == limit;
Ok(LibraryClusterAlbumsResponse { albums, has_more })
}
fn map_album_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryAlbumDto> {
let raw: Option<String> = r.get(12)?;
Ok(LibraryAlbumDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
artist: r.get(3)?,
artist_id: r.get(4)?,
song_count: r.get(5)?,
duration_sec: r.get(6)?,
year: r.get(7)?,
genre: r.get(8)?,
cover_art_id: r.get(9)?,
starred_at: r.get(10)?,
synced_at: r.get(11)?,
raw_json: raw
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(Value::Null),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::{TrackRepository, TrackRow};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
fn track(
server: &str,
id: &str,
title: &str,
artist: &str,
album: &str,
album_id: &str,
) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: title.into(),
title_sort: None,
artist: Some(artist.into()),
artist_id: Some(format!("art-{server}")),
album: album.into(),
album_id: Some(album_id.into()),
album_artist: Some(artist.into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: Some(2020),
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn merge_collapses_same_album_key_by_priority() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "A", "Band", "LP", "alb1"),
track("s2", "t2", "B", "Band", "LP", "alb2"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = list_merged_albums(
&store,
&["s1".into(), "s2".into()],
50,
0,
&std::collections::HashMap::new(),
)
.unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].server_id, "s1");
}
#[test]
fn rollup_collapses_multiple_tracks_per_server_album() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "A", "Band", "LP", "alb1"),
track("s1", "t2", "B", "Band", "LP", "alb1"),
track("s1", "t3", "C", "Band", "LP", "alb1"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = list_merged_albums(
&store,
&["s1".into()],
50,
0,
&std::collections::HashMap::new(),
)
.unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "alb1");
}
fn track_no_key(server: &str, id: &str, album_id: &str) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: "".into(),
title_sort: None,
artist: Some("Band".into()),
artist_id: Some(format!("art-{server}")),
album: "LP".into(),
album_id: Some(album_id.into()),
album_artist: Some("Band".into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: Some(2020),
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn rollup_uses_album_key_when_some_tracks_lack_keys() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "A", "Band", "LP", "alb1"),
track_no_key("s1", "t2", "alb1"),
track("s2", "t3", "B", "Band", "LP", "alb2"),
track_no_key("s2", "t4", "alb2"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = list_merged_albums(
&store,
&["s1".into(), "s2".into()],
50,
0,
&std::collections::HashMap::new(),
)
.unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].server_id, "s1");
}
}
@@ -1,230 +0,0 @@
//! Merged artist listing for cluster scope (spec §4 Tier 1 — dedup by `artist_key`).
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
use crate::dto::{LibraryArtistDto, LibraryClusterArtistsResponse};
use crate::search::PAGE_LIMIT_MAX;
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::library_scope::scope_filter_sql_and_params;
use super::priority::{in_list_sql, priority_case_sql};
pub fn list_merged_artists(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
offset: u32,
library_scopes: &std::collections::HashMap<String, Vec<String>>,
) -> Result<LibraryClusterArtistsResponse, String> {
if servers_ordered.is_empty() {
return Ok(LibraryClusterArtistsResponse {
artists: vec![],
has_more: false,
});
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset.min(i32::MAX as u32) as i32;
let (in_placeholders, in_params) = in_list_sql(servers_ordered);
let (priority_sql, priority_params) = priority_case_sql("c.server_id", servers_ordered);
let (scope_sql, scope_params) = scope_filter_sql_and_params("t", servers_ordered, library_scopes);
// Artist-first catalog: one row per artist (not per track), then merge by
// `artist_key`. The previous track-scan + window over every row was O(tracks)
// with correlated album counts and blocked the Artists browse page on large libs.
let sql = format!(
"WITH artist_keys AS (
SELECT
t.server_id,
COALESCE(NULLIF(t.artist_id, ''), t.artist) AS artist_ref,
MIN(k.artist_key) AS artist_key
FROM track t
INNER JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0
AND t.server_id IN ({in_placeholders}){scope_sql}
AND k.artist_key IS NOT NULL
GROUP BY t.server_id, artist_ref
),
track_artists AS (
SELECT
t.server_id,
COALESCE(NULLIF(t.artist_id, ''), t.artist) AS id,
MAX(t.artist) AS name,
COUNT(DISTINCT CASE
WHEN t.album_id IS NOT NULL AND t.album_id != '' THEN t.album_id
END) AS album_count,
MAX(t.synced_at) AS synced_at,
CAST(NULL AS TEXT) AS raw_json
FROM track t
WHERE t.deleted = 0
AND t.server_id IN ({in_placeholders}){scope_sql}
AND COALESCE(t.artist, '') != ''
AND NOT EXISTS (
SELECT 1 FROM artist ar
WHERE ar.server_id = t.server_id
AND ar.id = COALESCE(NULLIF(t.artist_id, ''), t.artist)
)
GROUP BY t.server_id, COALESCE(NULLIF(t.artist_id, ''), t.artist)
),
catalog AS (
SELECT ar.server_id, ar.id, ar.name, ar.album_count, ar.synced_at, ar.raw_json
FROM artist ar
WHERE ar.server_id IN ({in_placeholders})
UNION ALL
SELECT server_id, id, name, album_count, synced_at, raw_json
FROM track_artists
),
candidates AS (
SELECT
c.server_id,
c.id,
c.name,
c.album_count,
c.synced_at,
c.raw_json,
({priority_sql}) AS priority_rank,
CASE
WHEN ak.artist_key IS NOT NULL THEN ak.artist_key
ELSE 'solo:' || c.server_id || ':' || c.id
END AS merge_key
FROM catalog c
LEFT JOIN artist_keys ak
ON ak.server_id = c.server_id AND ak.artist_ref = c.id
),
winners AS (
SELECT
server_id,
id,
name,
album_count,
synced_at,
raw_json,
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
FROM candidates
)
SELECT server_id, id, name, album_count, synced_at, raw_json
FROM winners
WHERE rn = 1
ORDER BY name COLLATE NOCASE, server_id
LIMIT ? OFFSET ?",
);
let mut params: Vec<SqlValue> = Vec::new();
params.extend(in_params.iter().cloned());
params.extend(scope_params.iter().cloned());
params.extend(in_params.iter().cloned());
params.extend(scope_params.iter().cloned());
params.extend(in_params.iter().cloned());
params.extend(priority_params);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let artists: Vec<LibraryArtistDto> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_artist_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})?;
let has_more = artists.len() as u32 == limit;
Ok(LibraryClusterArtistsResponse { artists, has_more })
}
fn map_artist_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryArtistDto> {
let raw: Option<String> = r.get(5)?;
Ok(LibraryArtistDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
album_count: r.get(3)?,
synced_at: r.get(4)?,
raw_json: raw
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(Value::Null),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::{TrackRepository, TrackRow};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
fn track(server: &str, id: &str, artist: &str) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: "Song".into(),
title_sort: None,
artist: Some(artist.into()),
artist_id: Some(format!("art-{server}")),
album: "LP".into(),
album_id: Some("alb1".into()),
album_artist: Some(artist.into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn merge_collapses_same_artist_key_by_priority() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", "Band"), track("s2", "t2", "Band")])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = list_merged_artists(&store, &["s1".into(), "s2".into()], 50, 0, &std::collections::HashMap::new()).unwrap();
assert_eq!(resp.artists.len(), 1);
assert_eq!(resp.artists[0].server_id, "s1");
}
#[test]
fn prefers_artist_table_album_count_when_present() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", "Band"), track("s2", "t2", "Band")])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
store
.with_conn("test", |conn| {
conn.execute(
"INSERT INTO artist (server_id, id, name, album_count, synced_at, raw_json) \
VALUES ('s1', 'art-s1', 'Band', 3, 1, '{}'), \
('s2', 'art-s2', 'Band', 2, 1, '{}')",
[],
)
})
.unwrap();
let resp = list_merged_artists(&store, &["s1".into(), "s2".into()], 50, 0, &std::collections::HashMap::new()).unwrap();
assert_eq!(resp.artists.len(), 1);
assert_eq!(resp.artists[0].server_id, "s1");
assert_eq!(resp.artists[0].album_count, Some(3));
}
}
@@ -1,452 +0,0 @@
//! Merged favorites — starred on any member counts (spec §4 Tier 2).
use rusqlite::types::Value as SqlValue;
use serde_json::Value;
use crate::dto::{
LibraryAlbumDto, LibraryArtistDto, LibraryClusterAlbumsResponse, LibraryClusterArtistsResponse, LibraryTrackDto,
LibraryTracksEnvelope,
};
use crate::repos;
use crate::search::{aliased_track_columns, PAGE_LIMIT_MAX};
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::merge::DURATION_TOLERANCE_SEC;
use super::priority::{in_list_sql, priority_case_sql};
/// Merged starred tracks — one row per merge group when **any** member is starred.
pub fn list_merged_favorite_tracks(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
offset: u32,
) -> Result<LibraryTracksEnvelope, String> {
if servers_ordered.is_empty() {
return Ok(LibraryTracksEnvelope {
tracks: vec![],
total: 0,
});
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset.min(i32::MAX as u32) as i32;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let cols = aliased_track_columns("t");
let sql = format!(
"WITH candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.id AS track_id,
k.cluster_key,
COALESCE(k.duration_sec, t.duration_sec) AS dur,
t.starred_at,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0 AND t.server_id IN ({in_placeholders})
),
refs AS (
SELECT cluster_key, MIN(priority_rank) AS best_rank
FROM candidates
WHERE cluster_key IS NOT NULL
GROUP BY cluster_key
),
ref_dur AS (
SELECT c.cluster_key, c.dur AS ref_dur
FROM candidates c
JOIN refs r ON c.cluster_key = r.cluster_key AND c.priority_rank = r.best_rank
),
partitioned AS (
SELECT c.tid,
CASE
WHEN c.cluster_key IS NULL THEN 'solo:' || c.server_id || ':' || c.track_id
WHEN ABS(c.dur - rd.ref_dur) <= {tol} THEN c.cluster_key
ELSE 'solo:' || c.server_id || ':' || c.track_id
END AS merge_key,
c.priority_rank
FROM candidates c
LEFT JOIN ref_dur rd ON c.cluster_key = rd.cluster_key
),
starred_merge AS (
SELECT DISTINCT p.merge_key
FROM partitioned p
JOIN candidates c ON c.tid = p.tid
WHERE c.starred_at IS NOT NULL
),
winners AS (
SELECT p.tid, p.merge_key,
ROW_NUMBER() OVER (PARTITION BY p.merge_key ORDER BY p.priority_rank) AS rn
FROM partitioned p
JOIN starred_merge s ON s.merge_key = p.merge_key
)
SELECT {cols}
FROM winners w
JOIN track t ON t.rowid = w.tid
WHERE w.rn = 1
ORDER BY t.title COLLATE NOCASE, t.server_id, t.id
LIMIT ? OFFSET ?",
tol = DURATION_TOLERANCE_SEC,
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let tracks: Vec<LibraryTrackDto> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})?;
Ok(LibraryTracksEnvelope {
total: tracks.len() as u32,
tracks,
})
}
/// Merged favorite albums — one row per album merge group when any member is starred.
pub fn list_merged_favorite_albums(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
offset: u32,
) -> Result<LibraryClusterAlbumsResponse, String> {
if servers_ordered.is_empty() {
return Ok(LibraryClusterAlbumsResponse {
albums: vec![],
has_more: false,
});
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset.min(i32::MAX as u32) as i32;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let sql = format!(
"WITH candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.album_id,
k.album_key,
COALESCE(a.starred_at, t.starred_at) AS starred_at,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
LEFT JOIN album a
ON a.server_id = t.server_id AND a.id = t.album_id
WHERE t.deleted = 0
AND t.server_id IN ({in_placeholders})
AND t.album_id IS NOT NULL AND t.album_id != ''
),
album_rollup AS (
SELECT
c.server_id,
c.album_id,
MIN(c.tid) AS tid,
MIN(c.priority_rank) AS priority_rank,
MAX(c.album_key) AS album_key,
MAX(c.starred_at) AS starred_at
FROM candidates c
GROUP BY c.server_id, c.album_id
),
partitioned AS (
SELECT
r.tid,
CASE
WHEN r.album_key IS NOT NULL THEN r.album_key
ELSE 'solo:' || r.server_id || ':' || r.album_id
END AS merge_key,
r.priority_rank,
r.starred_at
FROM album_rollup r
),
starred_merge AS (
SELECT DISTINCT merge_key
FROM partitioned
WHERE starred_at IS NOT NULL
),
winners AS (
SELECT p.tid,
ROW_NUMBER() OVER (PARTITION BY p.merge_key ORDER BY p.priority_rank) AS rn
FROM partitioned p
JOIN starred_merge s ON s.merge_key = p.merge_key
)
SELECT
t.server_id,
t.album_id,
COALESCE(a.name, t.album),
COALESCE(a.artist, t.artist),
COALESCE(a.artist_id, t.artist_id),
COALESCE(a.song_count, (
SELECT COUNT(*) FROM track c
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0
)),
COALESCE(a.duration_sec, (
SELECT COALESCE(SUM(c.duration_sec), 0) FROM track c
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0
)),
COALESCE(a.year, t.year),
COALESCE(a.genre, t.genre),
COALESCE(a.cover_art_id, t.cover_art_id),
COALESCE(a.starred_at, t.starred_at),
COALESCE(a.synced_at, t.synced_at),
a.raw_json
FROM winners w
JOIN track t ON t.rowid = w.tid
LEFT JOIN album a ON a.server_id = t.server_id AND a.id = t.album_id
WHERE w.rn = 1
ORDER BY COALESCE(a.name, t.album) COLLATE NOCASE, t.server_id, t.album_id
LIMIT ? OFFSET ?",
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let albums: Vec<LibraryAlbumDto> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_album_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})?;
Ok(LibraryClusterAlbumsResponse {
has_more: albums.len() as u32 == limit,
albums,
})
}
/// Merged favorite artists — one row per artist merge group when any member track is starred.
pub fn list_merged_favorite_artists(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
offset: u32,
) -> Result<LibraryClusterArtistsResponse, String> {
if servers_ordered.is_empty() {
return Ok(LibraryClusterArtistsResponse {
artists: vec![],
has_more: false,
});
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset.min(i32::MAX as u32) as i32;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let sql = format!(
"WITH candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
COALESCE(NULLIF(t.artist_id, ''), t.artist) AS artist_ref,
k.artist_key,
t.starred_at,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0
AND t.server_id IN ({in_placeholders})
AND COALESCE(t.artist, '') != ''
),
partitioned AS (
SELECT c.tid,
CASE
WHEN c.artist_key IS NULL THEN 'solo:' || c.server_id || ':' || c.artist_ref
ELSE c.artist_key
END AS merge_key,
c.priority_rank,
c.starred_at
FROM candidates c
),
starred_merge AS (
SELECT DISTINCT merge_key
FROM partitioned
WHERE starred_at IS NOT NULL
),
winners AS (
SELECT p.tid,
ROW_NUMBER() OVER (PARTITION BY p.merge_key ORDER BY p.priority_rank) AS rn
FROM partitioned p
JOIN starred_merge s ON s.merge_key = p.merge_key
)
SELECT
t.server_id,
COALESCE(NULLIF(t.artist_id, ''), t.artist),
COALESCE(ar.name, t.artist),
COALESCE(ar.album_count, (
SELECT COUNT(DISTINCT c.album_id) FROM track c
WHERE c.server_id = t.server_id
AND c.deleted = 0
AND c.album_id IS NOT NULL
AND (c.artist_id = t.artist_id OR c.artist = t.artist)
)),
COALESCE(ar.synced_at, t.synced_at),
ar.raw_json
FROM winners w
JOIN track t ON t.rowid = w.tid
LEFT JOIN artist ar ON ar.server_id = t.server_id
AND ar.id = COALESCE(NULLIF(t.artist_id, ''), t.artist)
WHERE w.rn = 1
ORDER BY COALESCE(ar.name, t.artist) COLLATE NOCASE, t.server_id
LIMIT ? OFFSET ?",
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
let artists: Vec<LibraryArtistDto> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_artist_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})?;
Ok(LibraryClusterArtistsResponse {
has_more: artists.len() as u32 == limit,
artists,
})
}
fn map_album_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryAlbumDto> {
let raw: Option<String> = r.get(12)?;
Ok(LibraryAlbumDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
artist: r.get(3)?,
artist_id: r.get(4)?,
song_count: r.get(5)?,
duration_sec: r.get(6)?,
year: r.get(7)?,
genre: r.get(8)?,
cover_art_id: r.get(9)?,
starred_at: r.get(10)?,
synced_at: r.get(11)?,
raw_json: raw
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(Value::Null),
})
}
fn map_artist_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryArtistDto> {
let raw: Option<String> = r.get(5)?;
Ok(LibraryArtistDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
album_count: r.get(3)?,
synced_at: r.get(4)?,
raw_json: raw
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(Value::Null),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::{TrackRepository, TrackRow};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
fn track(server: &str, id: &str, starred: bool) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: "Song".into(),
title_sort: None,
artist: Some("Band".into()),
artist_id: Some("a1".into()),
album: "LP".into(),
album_id: Some("alb1".into()),
album_artist: Some("Band".into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: if starred { Some(1) } else { None },
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn starred_on_lower_priority_still_surfaces_merged_row() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", false), track("s2", "t2", true)])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let env = list_merged_favorite_tracks(&store, &["s1".into(), "s2".into()], 50, 0).unwrap();
assert_eq!(env.tracks.len(), 1);
assert_eq!(env.tracks[0].server_id, "s1");
}
#[test]
fn unstarred_merge_group_excluded() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", false), track("s2", "t2", false)])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let env = list_merged_favorite_tracks(&store, &["s1".into(), "s2".into()], 50, 0).unwrap();
assert!(env.tracks.is_empty());
}
#[test]
fn favorite_albums_merge_when_any_member_starred() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", false), track("s2", "t2", true)])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp = list_merged_favorite_albums(&store, &["s1".into(), "s2".into()], 50, 0).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].server_id, "s1");
}
#[test]
fn favorite_artists_merge_when_any_member_starred() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", false), track("s2", "t2", true)])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let resp =
list_merged_favorite_artists(&store, &["s1".into(), "s2".into()], 50, 0).unwrap();
assert_eq!(resp.artists.len(), 1);
assert_eq!(resp.artists[0].server_id, "s1");
}
}
@@ -1,89 +0,0 @@
//! Duration guard and partition keys for cluster merge (spec §2.3).
pub const DURATION_TOLERANCE_SEC: i64 = 5;
/// Roll up per-track candidates to one row per `(server_id, album_id)` before
/// partitioning by `album_key` (spec §4 — album lists dedup by `album_key`).
pub const ALBUM_ROLLUP_AND_PARTITION_CTE: &str = "
album_rollup AS (
SELECT
c.server_id,
c.album_id,
MIN(c.tid) AS tid,
MIN(c.priority_rank) AS priority_rank,
MAX(c.album_key) AS album_key
FROM candidates c
GROUP BY c.server_id, c.album_id
),
partitioned AS (
SELECT
r.tid,
CASE
WHEN r.album_key IS NOT NULL THEN r.album_key
ELSE 'solo:' || r.server_id || ':' || r.album_id
END AS merge_key,
r.priority_rank
FROM album_rollup r
),
";
/// Synthetic partition for tracks without a `cluster_key` row (never merged).
pub fn solo_partition_key(server_id: &str, track_id: &str) -> String {
format!("solo:{server_id}:{track_id}")
}
/// Within one `cluster_key` group, split rows that fall outside ± tolerance of
/// the reference (priority-1 available candidate duration). Returns partition
/// keys: merged survivors share `cluster_key`; outliers get solo keys.
pub fn duration_partitions(
cluster_key: &str,
rows: &[(String, String, i64, u32)],
) -> Vec<(String, String, String)> {
// (server_id, track_id, duration_sec, priority_rank)
if rows.is_empty() {
return Vec::new();
}
let mut sorted = rows.to_vec();
sorted.sort_by_key(|(_, _, _, rank)| *rank);
let reference_duration = sorted[0].2;
let mut merged: Vec<&(String, String, i64, u32)> = Vec::new();
let mut outliers: Vec<&(String, String, i64, u32)> = Vec::new();
for row in &sorted {
if (row.2 - reference_duration).abs() <= DURATION_TOLERANCE_SEC {
merged.push(row);
} else {
outliers.push(row);
}
}
let mut out = Vec::new();
if !merged.is_empty() {
merged.sort_by_key(|(_, _, _, rank)| *rank);
let (sid, tid, _, _) = merged[0];
out.push((cluster_key.to_string(), sid.clone(), tid.clone()));
}
for (sid, tid, _, _) in outliers {
out.push((solo_partition_key(sid, tid), sid.clone(), tid.clone()));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn outlier_splits_to_solo_partition() {
let rows = vec![
("s1".into(), "t1".into(), 180, 0),
("s2".into(), "t2".into(), 182, 1),
("s3".into(), "t3".into(), 240, 2),
];
let parts = duration_partitions("ck1", &rows);
assert_eq!(parts.len(), 2);
assert_eq!(parts[0].0, "ck1");
assert_eq!(parts[0].1, "s1");
assert_eq!(parts[1].0, "solo:s3:t3");
}
}
@@ -1,44 +0,0 @@
//! Server cluster identity — derived `cluster_key` / `album_key` / `artist_key`
//! in a separate attached SQLite DB (`library-cluster.db`). Distinct from
//! `repos/play_session/cluster.rs` (listening-session time-gap grouping).
mod detail;
mod advanced_search;
mod db;
mod keys;
mod library_scope;
mod list;
mod list_albums;
mod list_artists;
mod list_favorites;
mod merge;
mod norm;
mod play_stats;
mod priority;
mod rebuild;
mod resolve;
mod search;
pub use detail::{cluster_album_detail, cluster_artist_detail};
pub use advanced_search::run_cluster_advanced_search;
pub use db::{
attach_cluster_database, attach_cluster_database_uri, cluster_db_path, ensure_cluster_schema,
init_cluster_meta, needs_norm_rebuild, ATTACH_ALIAS, CLUSTER_DB_FILENAME, NORM_VERSION,
};
pub use keys::{compute_track_cluster_keys, TrackClusterKeys};
pub use list::list_merged_tracks;
pub use list_albums::list_merged_albums;
pub use list_artists::list_merged_artists;
pub use list_favorites::list_merged_favorite_tracks;
pub use list_favorites::{list_merged_favorite_albums, list_merged_favorite_artists};
pub use merge::DURATION_TOLERANCE_SEC;
pub use play_stats::{
cluster_day_detail, cluster_heatmap, cluster_most_played, cluster_recent_days, cluster_year_summary,
};
pub use rebuild::{
rebuild_all_cluster_keys, rebuild_cluster_keys_for_server, rebuild_if_norm_version_stale,
};
pub use resolve::{
cluster_key_for_track, resolve_candidates_by_cluster_key, resolve_candidates_for_track,
};
pub use search::{run_cluster_random_tracks, run_cluster_search};
@@ -1,43 +0,0 @@
//! Cheap Unicode normalization for cluster identity keys (spec §2.2).
use unicode_normalization::UnicodeNormalization;
/// NFD → drop combining marks → lowercase → letters/digits only.
pub fn norm_field(raw: &str) -> String {
raw.nfd()
.filter(|c| !unicode_normalization::char::is_combining_mark(*c))
.flat_map(|c| c.to_lowercase())
.filter(|c| c.is_alphanumeric())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_diacritics_and_punctuation() {
assert_eq!(norm_field("Café"), "cafe");
assert_eq!(norm_field("Mötley Crüe"), "motleycrue");
assert_eq!(norm_field("Hello, World!"), "helloworld");
}
#[test]
fn lowercases_and_removes_whitespace() {
assert_eq!(norm_field(" Pink FLOYD "), "pinkfloyd");
assert_eq!(norm_field("The\tBeatles\n"), "thebeatles");
}
#[test]
fn preserves_unicode_letters_and_digits() {
assert_eq!(norm_field("Sigur Rós"), "sigurros");
assert_eq!(norm_field("Track 99"), "track99");
}
#[test]
fn empty_or_non_alnum_only_becomes_empty() {
assert_eq!(norm_field(""), "");
assert_eq!(norm_field(" "), "");
assert_eq!(norm_field("---"), "");
}
}
@@ -1,595 +0,0 @@
//! Cluster-scoped player statistics — aggregate `play_session` across members (spec §4 Tier 2).
use std::collections::HashMap;
use rusqlite::types::Value as SqlValue;
use crate::dto::{
PlaySessionDayDetailDto, PlaySessionDayTotalsDto, PlaySessionDayTrackDto, PlaySessionHeatmapDayDto,
PlaySessionMostPlayedDto, PlaySessionRecentDayDto, PlaySessionYearSummaryDto,
};
use crate::repos;
use crate::search::aliased_track_columns;
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::merge::DURATION_TOLERANCE_SEC;
use super::priority::in_list_sql;
use super::priority::priority_case_sql;
const RECENT_DAYS_LIMIT_MAX: u32 = 90;
const MOST_PLAYED_LIMIT_MAX: u32 = 200;
#[derive(Default)]
struct DayAgg {
total_listened_sec: f64,
track_play_count: u32,
full_count: u32,
partial_count: u32,
plays: Vec<(i64, f64)>,
}
fn server_filter_sql(servers_ordered: &[String]) -> Result<(String, Vec<SqlValue>), String> {
if servers_ordered.is_empty() {
return Err("servers_ordered required".into());
}
let (placeholders, params) = in_list_sql(servers_ordered);
Ok((format!("ps.server_id IN ({placeholders})"), params))
}
fn unique_track_expr() -> &'static str {
"COALESCE(k.cluster_key, ps.server_id || ':' || ps.track_id)"
}
fn count_listening_sessions(plays: &[(i64, f64)]) -> u32 {
const GAP_MS: i64 = 30 * 60 * 1000;
if plays.is_empty() {
return 0;
}
let mut sorted = plays.to_vec();
sorted.sort_by_key(|p| p.0);
let mut sessions = 1u32;
let mut prev_end = sorted[0].0 + (sorted[0].1 * 1000.0) as i64;
for (started, listened) in sorted.iter().skip(1) {
if *started - prev_end > GAP_MS {
sessions += 1;
}
let end = *started + (*listened * 1000.0) as i64;
prev_end = prev_end.max(end);
}
sessions
}
fn validate_date_iso(date_iso: &str) -> Result<(), String> {
if date_iso.len() != 10 || date_iso.as_bytes()[4] != b'-' || date_iso.as_bytes()[7] != b'-' {
return Err("dateIso must be YYYY-MM-DD".into());
}
let year: i32 = date_iso[0..4]
.parse()
.map_err(|_| "dateIso must be YYYY-MM-DD".to_string())?;
let month: u32 = date_iso[5..7]
.parse()
.map_err(|_| "dateIso must be YYYY-MM-DD".to_string())?;
let day: u32 = date_iso[8..10]
.parse()
.map_err(|_| "dateIso must be YYYY-MM-DD".to_string())?;
if year < 1970 || !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return Err("dateIso must be YYYY-MM-DD".into());
}
Ok(())
}
pub fn cluster_year_summary(
store: &LibraryStore,
servers_ordered: &[String],
year: i32,
) -> Result<PlaySessionYearSummaryDto, String> {
let (server_sql, mut params) = server_filter_sql(servers_ordered)?;
let year_str = year.to_string();
let unique = unique_track_expr();
store
.with_read_conn(|conn| {
let sql = format!(
"SELECT \
COALESCE(SUM(ps.listened_sec), 0.0), \
COUNT(*), \
COUNT(DISTINCT {unique}), \
COUNT(DISTINCT date(ps.started_at_ms / 1000, 'unixepoch', 'localtime')), \
COALESCE(SUM(CASE WHEN ps.completion = 'full' THEN 1 ELSE 0 END), 0), \
COALESCE(SUM(CASE WHEN ps.completion = 'partial' THEN 1 ELSE 0 END), 0) \
FROM play_session ps \
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k \
ON k.server_id = ps.server_id AND k.track_id = ps.track_id \
WHERE {server_sql} \
AND strftime('%Y', ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ?",
);
params.push(SqlValue::Text(year_str.clone()));
let totals = conn.query_row(
&sql,
rusqlite::params_from_iter(params.iter()),
|row| {
Ok((
row.get::<_, f64>(0)?,
row.get::<_, i64>(1)? as u32,
row.get::<_, i64>(2)? as u32,
row.get::<_, i64>(3)? as u32,
row.get::<_, i64>(4)? as u32,
row.get::<_, i64>(5)? as u32,
))
},
)?;
let plays_sql = format!(
"SELECT ps.started_at_ms, ps.listened_sec \
FROM play_session ps \
WHERE {server_sql} \
AND strftime('%Y', ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ? \
ORDER BY ps.started_at_ms ASC",
);
let mut play_params = params[..params.len() - 1].to_vec();
play_params.push(SqlValue::Text(year_str));
let mut stmt = conn.prepare(&plays_sql)?;
let plays = stmt
.query_map(rusqlite::params_from_iter(play_params.iter()), |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, f64>(1)?))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let (
total_listened_sec,
track_play_count,
unique_track_count,
listening_day_count,
full_count,
partial_count,
) = totals;
Ok(PlaySessionYearSummaryDto {
total_listened_sec,
session_count: count_listening_sessions(&plays),
track_play_count,
unique_track_count,
listening_day_count,
full_count,
partial_count,
})
})
.map_err(|e| e.to_string())
}
pub fn cluster_heatmap(
store: &LibraryStore,
servers_ordered: &[String],
year: i32,
) -> Result<Vec<PlaySessionHeatmapDayDto>, String> {
let (server_sql, mut params) = server_filter_sql(servers_ordered)?;
params.push(SqlValue::Text(year.to_string()));
store
.with_read_conn(|conn| {
let sql = format!(
"SELECT \
date(ps.started_at_ms / 1000, 'unixepoch', 'localtime') AS d, \
COUNT(*) AS n \
FROM play_session ps \
WHERE {server_sql} \
AND strftime('%Y', ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ? \
GROUP BY d \
ORDER BY d ASC",
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
Ok(PlaySessionHeatmapDayDto {
date: row.get(0)?,
track_play_count: row.get::<_, i64>(1)? as u32,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
})
.map_err(|e| e.to_string())
}
pub fn cluster_day_detail(
store: &LibraryStore,
servers_ordered: &[String],
date_iso: &str,
) -> Result<PlaySessionDayDetailDto, String> {
validate_date_iso(date_iso)?;
let (server_sql, mut params) = server_filter_sql(servers_ordered)?;
params.push(SqlValue::Text(date_iso.to_string()));
store
.with_read_conn(|conn| {
let totals_sql = format!(
"SELECT \
COALESCE(SUM(ps.listened_sec), 0.0), \
COUNT(*), \
COALESCE(SUM(CASE WHEN ps.completion = 'full' THEN 1 ELSE 0 END), 0), \
COALESCE(SUM(CASE WHEN ps.completion = 'partial' THEN 1 ELSE 0 END), 0) \
FROM play_session ps \
WHERE {server_sql} \
AND date(ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ?",
);
let (total_listened_sec, track_play_count, full_count, partial_count) = conn.query_row(
&totals_sql,
rusqlite::params_from_iter(params.iter()),
|row| {
Ok((
row.get::<_, f64>(0)?,
row.get::<_, i64>(1)? as u32,
row.get::<_, i64>(2)? as u32,
row.get::<_, i64>(3)? as u32,
))
},
)?;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let cols = aliased_track_columns("t");
let rows_sql = format!(
"WITH sessions AS (
SELECT
ps.started_at_ms,
ps.listened_sec,
ps.completion,
COALESCE(k.cluster_key, 'solo:' || ps.server_id || ':' || ps.track_id) AS merge_key
FROM play_session ps
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = ps.server_id AND k.track_id = ps.track_id
WHERE {server_sql}
AND date(ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ?
),
candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.id AS track_id,
k.cluster_key,
COALESCE(k.duration_sec, t.duration_sec) AS dur,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0 AND t.server_id IN ({in_placeholders})
),
refs AS (
SELECT cluster_key, MIN(priority_rank) AS best_rank
FROM candidates
WHERE cluster_key IS NOT NULL
GROUP BY cluster_key
),
ref_dur AS (
SELECT c.cluster_key, c.dur AS ref_dur
FROM candidates c
JOIN refs r ON c.cluster_key = r.cluster_key AND c.priority_rank = r.best_rank
),
partitioned AS (
SELECT c.tid,
CASE
WHEN c.cluster_key IS NULL THEN 'solo:' || c.server_id || ':' || c.track_id
WHEN ABS(c.dur - rd.ref_dur) <= {tol} THEN c.cluster_key
ELSE 'solo:' || c.server_id || ':' || c.track_id
END AS merge_key,
c.priority_rank
FROM candidates c
LEFT JOIN ref_dur rd ON c.cluster_key = rd.cluster_key
),
winners AS (
SELECT merge_key, tid,
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
FROM partitioned
)
SELECT {cols}, s.listened_sec, s.completion, s.started_at_ms
FROM sessions s
JOIN winners w ON w.merge_key = s.merge_key AND w.rn = 1
JOIN track t ON t.rowid = w.tid
ORDER BY s.started_at_ms DESC",
tol = DURATION_TOLERANCE_SEC,
);
let mut rows_params = params.clone();
rows_params.append(&mut priority_params);
rows_params.append(&mut in_params);
let track_col_count = repos::track_columns().split(',').count();
let mut stmt = conn.prepare(&rows_sql)?;
let tracks = stmt
.query_map(rusqlite::params_from_iter(rows_params.iter()), |row| {
let track = repos::row_to_track_row(row).map(|r| crate::dto::LibraryTrackDto::from_row(&r))?;
Ok(PlaySessionDayTrackDto {
server_id: track.server_id,
track_id: track.id,
title: track.title,
artist: track.artist,
listened_sec: row.get(track_col_count)?,
completion: row.get(track_col_count + 1)?,
started_at_ms: row.get(track_col_count + 2)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let plays: Vec<(i64, f64)> = tracks
.iter()
.map(|t| (t.started_at_ms, t.listened_sec))
.collect();
Ok(PlaySessionDayDetailDto {
totals: PlaySessionDayTotalsDto {
total_listened_sec,
session_count: count_listening_sessions(&plays),
track_play_count,
full_count,
partial_count,
},
tracks,
})
})
.map_err(|e| e.to_string())
}
pub fn cluster_recent_days(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
) -> Result<Vec<PlaySessionRecentDayDto>, String> {
let limit = limit.clamp(1, RECENT_DAYS_LIMIT_MAX);
let (server_sql, params) = server_filter_sql(servers_ordered)?;
store
.with_read_conn(|conn| {
let sql = format!(
"SELECT
date(ps.started_at_ms / 1000, 'unixepoch', 'localtime') AS d,
ps.started_at_ms,
ps.listened_sec,
ps.completion
FROM play_session ps
WHERE {server_sql}
ORDER BY d DESC, ps.started_at_ms ASC",
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, f64>(2)?,
row.get::<_, String>(3)?,
))
})?;
let mut by_day: HashMap<String, DayAgg> = HashMap::new();
for row in rows {
let (date, started_at_ms, listened_sec, completion) = row?;
let agg = by_day.entry(date).or_default();
agg.total_listened_sec += listened_sec;
agg.track_play_count += 1;
if completion == "full" {
agg.full_count += 1;
} else {
agg.partial_count += 1;
}
agg.plays.push((started_at_ms, listened_sec));
}
let mut out: Vec<PlaySessionRecentDayDto> = by_day
.into_iter()
.map(|(date, agg)| PlaySessionRecentDayDto {
date,
total_listened_sec: agg.total_listened_sec,
session_count: count_listening_sessions(&agg.plays),
track_play_count: agg.track_play_count,
full_count: agg.full_count,
partial_count: agg.partial_count,
})
.collect();
out.sort_by(|a, b| b.date.cmp(&a.date));
out.truncate(limit as usize);
Ok(out)
})
.map_err(|e| e.to_string())
}
pub fn cluster_most_played(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
) -> Result<Vec<PlaySessionMostPlayedDto>, String> {
let limit = limit.clamp(1, MOST_PLAYED_LIMIT_MAX);
let (server_sql, mut stats_params) = server_filter_sql(servers_ordered)?;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let cols = aliased_track_columns("t");
let col_count = repos::track_columns().split(',').count();
let sql = format!(
"WITH session_counts AS (
SELECT
COALESCE(k.cluster_key, 'solo:' || ps.server_id || ':' || ps.track_id) AS merge_key,
COUNT(*) AS track_play_count,
COALESCE(SUM(ps.listened_sec), 0.0) AS total_listened_sec
FROM play_session ps
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = ps.server_id AND k.track_id = ps.track_id
WHERE {server_sql}
GROUP BY merge_key
),
candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.id AS track_id,
k.cluster_key,
COALESCE(k.duration_sec, t.duration_sec) AS dur,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0 AND t.server_id IN ({in_placeholders})
),
refs AS (
SELECT cluster_key, MIN(priority_rank) AS best_rank
FROM candidates
WHERE cluster_key IS NOT NULL
GROUP BY cluster_key
),
ref_dur AS (
SELECT c.cluster_key, c.dur AS ref_dur
FROM candidates c
JOIN refs r ON c.cluster_key = r.cluster_key AND c.priority_rank = r.best_rank
),
partitioned AS (
SELECT c.tid,
CASE
WHEN c.cluster_key IS NULL THEN 'solo:' || c.server_id || ':' || c.track_id
WHEN ABS(c.dur - rd.ref_dur) <= {tol} THEN c.cluster_key
ELSE 'solo:' || c.server_id || ':' || c.track_id
END AS merge_key,
c.priority_rank
FROM candidates c
LEFT JOIN ref_dur rd ON c.cluster_key = rd.cluster_key
),
winners AS (
SELECT merge_key, tid,
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
FROM partitioned
)
SELECT {cols}, sc.track_play_count, sc.total_listened_sec
FROM session_counts sc
JOIN winners w ON w.merge_key = sc.merge_key AND w.rn = 1
JOIN track t ON t.rowid = w.tid
ORDER BY sc.track_play_count DESC, sc.total_listened_sec DESC, t.title COLLATE NOCASE ASC
LIMIT ?",
tol = DURATION_TOLERANCE_SEC,
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut stats_params);
params.append(&mut priority_params);
params.append(&mut in_params);
params.push(SqlValue::Integer(limit as i64));
store
.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |row| {
let track =
repos::row_to_track_row(row).map(|r| crate::dto::LibraryTrackDto::from_row(&r))?;
Ok(PlaySessionMostPlayedDto {
track,
track_play_count: row.get::<_, i64>(col_count)? as u32,
total_listened_sec: row.get(col_count + 1)?,
})
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::{TrackRepository, TrackRow};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
fn track(server: &str, id: &str, title: &str, artist: &str, album: &str) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: title.into(),
title_sort: None,
artist: Some(artist.into()),
artist_id: Some(format!("art-{server}")),
album: album.into(),
album_id: Some(format!("alb-{server}")),
album_artist: Some(artist.into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn day_detail_merges_track_identity_to_cluster_winner() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Song", "Band", "LP"),
track("s2", "t2", "Song", "Band", "LP"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
store
.with_conn_mut("test", |conn| {
conn.execute(
"INSERT INTO play_session (server_id, track_id, started_at_ms, listened_sec, position_max_sec, completion, end_reason)
VALUES (?1, ?2, ?3, 120.0, 120.0, 'full', 'ended')",
rusqlite::params!["s2", "t2", 1_000i64],
)?;
Ok(())
})
.unwrap();
let detail = cluster_day_detail(&store, &["s1".into(), "s2".into()], "1970-01-01").unwrap();
assert_eq!(detail.tracks.len(), 1);
assert_eq!(detail.tracks[0].server_id, "s1");
assert_eq!(detail.tracks[0].track_id, "t1");
assert_eq!(detail.totals.track_play_count, 1);
}
#[test]
fn most_played_aggregates_cluster_members() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Song", "Band", "LP"),
track("s2", "t2", "Song", "Band", "LP"),
])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
store
.with_conn_mut("test", |conn| {
conn.execute(
"INSERT INTO play_session (server_id, track_id, started_at_ms, listened_sec, position_max_sec, completion, end_reason)
VALUES
('s1', 't1', 1700000000000, 60.0, 60.0, 'partial', 'ended'),
('s2', 't2', 1700000100000, 90.0, 90.0, 'full', 'ended')",
[],
)?;
Ok(())
})
.unwrap();
let rows = cluster_most_played(&store, &["s1".into(), "s2".into()], 10).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].track.server_id, "s1");
assert_eq!(rows[0].track_play_count, 2);
}
}
@@ -1,44 +0,0 @@
//! Priority rank SQL from an ordered server list (index 0 = highest).
use rusqlite::types::Value as SqlValue;
/// Build `CASE server_col WHEN ? THEN 0 … ELSE 9999 END` plus bind values.
pub fn priority_case_sql(server_col: &str, servers_ordered: &[String]) -> (String, Vec<SqlValue>) {
if servers_ordered.is_empty() {
return ("9999".to_string(), Vec::new());
}
let mut sql = format!("CASE {server_col}");
let mut params = Vec::with_capacity(servers_ordered.len());
for (rank, sid) in servers_ordered.iter().enumerate() {
sql.push_str(&format!(" WHEN ? THEN {rank}"));
params.push(SqlValue::Text(sid.clone()));
}
sql.push_str(" ELSE 9999 END");
(sql, params)
}
/// `server_id IN (?,?,…)` placeholders and bind values.
pub fn in_list_sql(servers: &[String]) -> (String, Vec<SqlValue>) {
if servers.is_empty() {
return ("0".to_string(), Vec::new());
}
let placeholders = vec!["?"; servers.len()].join(", ");
let params = servers
.iter()
.map(|s| SqlValue::Text(s.clone()))
.collect();
(placeholders, params)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn priority_case_orders_servers() {
let (sql, params) = priority_case_sql("t.server_id", &["a".into(), "b".into()]);
assert!(sql.contains("WHEN ? THEN 0"));
assert!(sql.contains("WHEN ? THEN 1"));
assert_eq!(params.len(), 2);
}
}
@@ -1,362 +0,0 @@
//! Batch rebuild of `track_cluster_key` — source of truth for cluster identity.
use rusqlite::{params, Transaction};
use crate::store::LibraryStore;
use super::db::{self, ATTACH_ALIAS, NORM_VERSION};
use super::keys::compute_track_cluster_keys;
const REBUILD_BATCH_SIZE: usize = 5_000;
struct TrackRow {
server_id: String,
track_id: String,
artist: Option<String>,
album_artist: Option<String>,
title: String,
album: String,
duration_sec: i64,
}
fn fetch_live_tracks(
tx: &Transaction<'_>,
server_id: Option<&str>,
) -> rusqlite::Result<Vec<TrackRow>> {
let (sql, bind): (&str, Option<&str>) = match server_id {
Some(_) => (
"SELECT server_id, id, artist, album_artist, title, album, duration_sec \
FROM track WHERE deleted = 0 AND server_id = ?1",
server_id,
),
None => (
"SELECT server_id, id, artist, album_artist, title, album, duration_sec \
FROM track WHERE deleted = 0",
None,
),
};
let mut stmt = tx.prepare(sql)?;
let rows = match bind {
Some(sid) => stmt.query_map(params![sid], map_track_row)?.collect(),
None => stmt.query_map([], map_track_row)?.collect(),
};
rows
}
fn map_track_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TrackRow> {
Ok(TrackRow {
server_id: row.get(0)?,
track_id: row.get(1)?,
artist: row.get(2)?,
album_artist: row.get(3)?,
title: row.get(4)?,
album: row.get(5)?,
duration_sec: row.get(6)?,
})
}
fn upsert_batch(tx: &Transaction<'_>, batch: &[(TrackRow, super::keys::TrackClusterKeys)]) -> rusqlite::Result<()> {
let mut stmt = tx.prepare(&format!(
"INSERT INTO {ATTACH_ALIAS}.track_cluster_key \
(server_id, track_id, cluster_key, album_key, artist_key, duration_sec) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6) \
ON CONFLICT(server_id, track_id) DO UPDATE SET \
cluster_key = excluded.cluster_key, \
album_key = excluded.album_key, \
artist_key = excluded.artist_key, \
duration_sec = excluded.duration_sec"
))?;
for (row, keys) in batch {
stmt.execute(params![
row.server_id,
row.track_id,
keys.cluster_key,
keys.album_key,
keys.artist_key,
row.duration_sec,
])?;
}
Ok(())
}
fn rebuild_in_tx(
tx: &Transaction<'_>,
server_id: Option<&str>,
clear_scope: bool,
) -> rusqlite::Result<u32> {
if clear_scope {
match server_id {
Some(sid) => {
tx.execute(
&format!("DELETE FROM {ATTACH_ALIAS}.track_cluster_key WHERE server_id = ?1"),
params![sid],
)?;
}
None => {
tx.execute(
&format!("DELETE FROM {ATTACH_ALIAS}.track_cluster_key"),
[],
)?;
}
}
}
let tracks = fetch_live_tracks(tx, server_id)?;
let mut written = 0u32;
let mut batch: Vec<(TrackRow, super::keys::TrackClusterKeys)> = Vec::new();
for row in tracks {
let Some(keys) = compute_track_cluster_keys(
row.artist.as_deref(),
row.album_artist.as_deref(),
&row.title,
&row.album,
) else {
continue;
};
batch.push((row, keys));
if batch.len() >= REBUILD_BATCH_SIZE {
upsert_batch(tx, &batch)?;
written = written.saturating_add(batch.len() as u32);
batch.clear();
}
}
if !batch.is_empty() {
upsert_batch(tx, &batch)?;
written = written.saturating_add(batch.len() as u32);
}
tx.execute(
&format!(
"INSERT INTO {ATTACH_ALIAS}.cluster_meta (key, value) VALUES ('norm_version', ?1) \
ON CONFLICT(key) DO UPDATE SET value = excluded.value"
),
params![NORM_VERSION],
)?;
Ok(written)
}
/// Rebuild cluster keys for every live track in the library index.
pub fn rebuild_all_cluster_keys(store: &LibraryStore) -> Result<u32, String> {
store.with_conn_mut("cluster_rebuild_all", |conn| {
let tx = conn.transaction()?;
let count = rebuild_in_tx(&tx, None, true)?;
tx.commit()?;
Ok(count)
})
}
/// Rebuild cluster keys for one server's live tracks (deletes stale rows first).
pub fn rebuild_cluster_keys_for_server(
store: &LibraryStore,
server_id: &str,
) -> Result<u32, String> {
store.with_conn_mut("cluster_rebuild_server", |conn| {
let tx = conn.transaction()?;
let count = rebuild_in_tx(&tx, Some(server_id), true)?;
tx.commit()?;
Ok(count)
})
}
/// Rebuild when `cluster_meta.norm_version` lags the compiled rules.
pub fn rebuild_if_norm_version_stale(store: &LibraryStore) -> Result<Option<u32>, String> {
let stale = store.with_conn("cluster_norm_check", db::needs_norm_rebuild)?;
if !stale {
return Ok(None);
}
rebuild_all_cluster_keys(store).map(Some)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::LibraryStore;
use rusqlite::params;
fn seed_track(
store: &LibraryStore,
server: &str,
id: &str,
artist: &str,
title: &str,
album: &str,
duration: i64,
) {
store
.with_conn_mut("misc", |conn| {
conn.execute(
"INSERT INTO track (server_id, id, title, artist, album, duration_sec, synced_at, raw_json) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, '{}')",
params![server, id, title, artist, album, duration],
)
})
.unwrap();
}
#[test]
fn rebuild_is_idempotent() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", "Artist", "Song", "Album", 200);
seed_track(&store, "s2", "t2", "Artist", "Song", "Album", 205);
let first = rebuild_all_cluster_keys(&store).unwrap();
assert_eq!(first, 2);
let count_after: i64 = store
.with_conn("misc", |c| {
c.query_row(
&format!("SELECT COUNT(*) FROM {ATTACH_ALIAS}.track_cluster_key"),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(count_after, 2);
let second = rebuild_all_cluster_keys(&store).unwrap();
assert_eq!(second, 2);
let count_again: i64 = store
.with_conn("misc", |c| {
c.query_row(
&format!("SELECT COUNT(*) FROM {ATTACH_ALIAS}.track_cluster_key"),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(count_again, 2);
}
#[test]
fn tracks_with_empty_fields_get_no_row() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", "", "Song", "Album", 100);
seed_track(&store, "s1", "t2", "Artist", "Song", "Album", 100);
let written = rebuild_all_cluster_keys(&store).unwrap();
assert_eq!(written, 1);
let count: i64 = store
.with_conn("misc", |c| {
c.query_row(
&format!("SELECT COUNT(*) FROM {ATTACH_ALIAS}.track_cluster_key"),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn same_cluster_key_across_servers_after_rebuild() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", "Band", "Hit", "LP", 180);
seed_track(&store, "s2", "t9", "Band", "Hit", "LP", 182);
rebuild_all_cluster_keys(&store).unwrap();
let keys: Vec<String> = store
.with_conn("misc", |c| {
let mut stmt = c.prepare(&format!(
"SELECT cluster_key FROM {ATTACH_ALIAS}.track_cluster_key ORDER BY server_id"
))?;
let rows: rusqlite::Result<Vec<String>> =
stmt.query_map([], |r| r.get(0))?.collect();
rows
})
.unwrap();
assert_eq!(keys.len(), 2);
assert_eq!(keys[0], keys[1]);
}
#[test]
fn per_server_rebuild_replaces_stale_rows() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", "A", "One", "X", 1);
seed_track(&store, "s2", "t1", "B", "Two", "Y", 2);
rebuild_all_cluster_keys(&store).unwrap();
store
.with_conn_mut("misc", |conn| {
conn.execute(
"UPDATE track SET title = 'Updated' WHERE server_id = 's1' AND id = 't1'",
[],
)
})
.unwrap();
rebuild_cluster_keys_for_server(&store, "s1").unwrap();
let title_norm_row: Option<String> = store
.with_conn("misc", |c| {
use rusqlite::OptionalExtension;
c.query_row(
&format!(
"SELECT k.cluster_key FROM {ATTACH_ALIAS}.track_cluster_key k \
JOIN track t ON t.server_id = k.server_id AND t.id = k.track_id \
WHERE k.server_id = 's1' AND k.track_id = 't1'"
),
[],
|r| r.get(0),
)
.optional()
})
.unwrap();
assert!(title_norm_row.is_some());
let s2_count: i64 = store
.with_conn("misc", |c| {
c.query_row(
&format!(
"SELECT COUNT(*) FROM {ATTACH_ALIAS}.track_cluster_key WHERE server_id = 's2'"
),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(s2_count, 1);
}
#[test]
fn norm_version_bump_triggers_full_rebuild() {
let store = LibraryStore::open_in_memory();
seed_track(&store, "s1", "t1", "Artist", "Old", "Album", 1);
rebuild_all_cluster_keys(&store).unwrap();
store
.with_conn_mut("misc", |conn| {
conn.execute(
&format!(
"UPDATE {ATTACH_ALIAS}.cluster_meta SET value = '0' WHERE key = 'norm_version'"
),
[],
)
})
.unwrap();
assert!(
store
.with_conn("misc", db::needs_norm_rebuild)
.unwrap()
);
let rebuilt = rebuild_if_norm_version_stale(&store).unwrap();
assert_eq!(rebuilt, Some(1));
let version: String = store
.with_conn("misc", |c| {
c.query_row(
&format!(
"SELECT value FROM {ATTACH_ALIAS}.cluster_meta WHERE key = 'norm_version'"
),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(version, NORM_VERSION);
}
}
@@ -1,198 +0,0 @@
//! Resolve cluster candidates for playback / writes (spec §56).
use rusqlite::types::Value as SqlValue;
use rusqlite::OptionalExtension;
use crate::dto::LibraryClusterCandidateDto;
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::merge::duration_partitions;
use super::priority::priority_case_sql;
/// All `(server_id, track_id)` rows sharing a `cluster_key`, ordered by priority.
pub fn resolve_candidates_by_cluster_key(
store: &LibraryStore,
servers_ordered: &[String],
cluster_key: &str,
) -> Result<Vec<LibraryClusterCandidateDto>, String> {
if servers_ordered.is_empty() || cluster_key.is_empty() {
return Ok(Vec::new());
}
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let in_placeholders = vec!["?"; servers_ordered.len()].join(", ");
let mut in_params: Vec<SqlValue> = servers_ordered
.iter()
.map(|s| SqlValue::Text(s.clone()))
.collect();
let sql = format!(
"SELECT t.server_id, t.id, COALESCE(k.duration_sec, t.duration_sec), ({priority_sql})
FROM {ATTACH_ALIAS}.track_cluster_key k
JOIN track t ON t.server_id = k.server_id AND t.id = k.track_id
WHERE k.cluster_key = ? AND t.deleted = 0 AND t.server_id IN ({in_placeholders})
ORDER BY 4, t.server_id, t.id"
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.push(SqlValue::Text(cluster_key.to_string()));
params.append(&mut in_params);
let rows: Vec<(String, String, i64, u32)> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let collected = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get::<_, i64>(3)? as u32))
})?;
collected.collect::<rusqlite::Result<Vec<_>>>()
})?;
let with_rank = rows;
let partitions = duration_partitions(cluster_key, &with_rank);
let winner = partitions.first();
Ok(with_rank
.into_iter()
.map(|(server_id, track_id, duration_sec, priority_rank)| {
let is_winner = winner
.map(|(_, ws, wt)| ws == &server_id && wt == &track_id)
.unwrap_or(false);
LibraryClusterCandidateDto {
server_id,
track_id,
duration_sec,
priority_rank,
is_winner,
}
})
.collect())
}
/// Resolve `cluster_key` from a seed track, then return candidates.
pub fn resolve_candidates_for_track(
store: &LibraryStore,
servers_ordered: &[String],
server_id: &str,
track_id: &str,
) -> Result<Vec<LibraryClusterCandidateDto>, String> {
let cluster_key: Option<String> = store.with_read_conn(|conn| {
conn.query_row(
&format!(
"SELECT cluster_key FROM {ATTACH_ALIAS}.track_cluster_key \
WHERE server_id = ?1 AND track_id = ?2"
),
rusqlite::params![server_id, track_id],
|r| r.get(0),
)
.optional()
})?;
let Some(cluster_key) = cluster_key else {
return Ok(vec![LibraryClusterCandidateDto {
server_id: server_id.to_string(),
track_id: track_id.to_string(),
duration_sec: track_duration(store, server_id, track_id)?,
priority_rank: servers_ordered
.iter()
.position(|s| s == server_id)
.unwrap_or(9999) as u32,
is_winner: true,
}]);
};
resolve_candidates_by_cluster_key(store, servers_ordered, &cluster_key)
}
fn track_duration(store: &LibraryStore, server_id: &str, track_id: &str) -> Result<i64, String> {
store
.with_read_conn(|conn| {
conn.query_row(
"SELECT duration_sec FROM track WHERE server_id = ?1 AND id = ?2 AND deleted = 0",
rusqlite::params![server_id, track_id],
|r| r.get(0),
)
})
.map_err(|e| e.to_string())
}
/// Lookup cluster key for a track (for search/seed mapping).
pub fn cluster_key_for_track(
store: &LibraryStore,
server_id: &str,
track_id: &str,
) -> Result<Option<String>, String> {
store
.with_read_conn(|conn| {
conn.query_row(
&format!(
"SELECT cluster_key FROM {ATTACH_ALIAS}.track_cluster_key \
WHERE server_id = ?1 AND track_id = ?2"
),
rusqlite::params![server_id, track_id],
|r| r.get(0),
)
.optional()
})
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repos::{TrackRepository, TrackRow};
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
fn tr(server: &str, id: &str) -> TrackRow {
TrackRow {
server_id: server.into(),
id: id.into(),
title: "Song".into(),
title_sort: None,
artist: Some("Band".into()),
artist_id: None,
album: "LP".into(),
album_id: None,
album_artist: Some("Band".into()),
duration_sec: 180,
track_number: Some(1),
disc_number: Some(1),
year: None,
genre: None,
suffix: None,
bit_rate: None,
size_bytes: None,
cover_art_id: None,
starred_at: None,
user_rating: None,
play_count: None,
played_at: None,
server_path: None,
library_id: None,
isrc: None,
mbid_recording: None,
bpm: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
content_hash: None,
server_updated_at: None,
server_created_at: None,
deleted: false,
synced_at: 1,
raw_json: "{}".into(),
}
}
#[test]
fn resolve_orders_by_priority() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[tr("s1", "t1"), tr("s2", "t2")])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
let key = cluster_key_for_track(&store, "s1", "t1").unwrap().unwrap();
let cands = resolve_candidates_by_cluster_key(&store, &["s1".into(), "s2".into()], &key).unwrap();
assert_eq!(cands.len(), 2);
assert!(cands[0].is_winner);
assert_eq!(cands[0].server_id, "s1");
}
}
@@ -1,243 +0,0 @@
//! Cluster-mode cross-server search — dedup by `cluster_key` + priority (spec §5).
use std::collections::HashSet;
use rusqlite::types::Value as SqlValue;
use crate::dto::{LibraryCrossServerSearchResponse, LibraryTrackDto};
use crate::repos;
use crate::search::{aliased_track_columns, fts_query, like_contains, PAGE_LIMIT_MAX};
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::merge::solo_partition_key;
use super::priority::{in_list_sql, priority_case_sql};
const FUZZY_PER_SERVER_CAP: usize = 20;
/// FTS union over ordered servers; dedup by `cluster_key` (not canonical id).
pub fn run_cluster_search(
store: &LibraryStore,
query: &str,
limit: u32,
offset: u32,
servers_ordered: &[String],
) -> Result<LibraryCrossServerSearchResponse, String> {
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset as usize;
if servers_ordered.is_empty() {
return Ok(LibraryCrossServerSearchResponse::default());
}
let Some(fts) = fts_query(query) else {
return Ok(LibraryCrossServerSearchResponse::default());
};
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let cols = aliased_track_columns("t");
let canonical_idx = repos::track_columns().split(',').count();
let sql = format!(
"SELECT {cols}, k.cluster_key, ({priority_sql}) AS priority_rank \
FROM track_fts f \
JOIN track t ON t.rowid = f.rowid \
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k \
ON k.server_id = t.server_id AND k.track_id = t.id \
WHERE track_fts MATCH ? AND t.deleted = 0 AND t.server_id IN ({in_placeholders}) \
ORDER BY bm25(track_fts) LIMIT ?"
);
let mut params: Vec<SqlValue> = Vec::new();
params.push(SqlValue::Text(fts));
params.append(&mut priority_params);
params.append(&mut in_params);
params.push(SqlValue::Integer((limit as i64).saturating_mul(4)));
let rows: Vec<(LibraryTrackDto, Option<String>, u32)> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let collected = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
let track = repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))?;
let cluster_key: Option<String> = r.get(canonical_idx)?;
let rank: i64 = r.get(canonical_idx + 1)?;
Ok((track, cluster_key, rank as u32))
})?;
collected.collect::<rusqlite::Result<Vec<_>>>()
})?;
let mut best_by_key: std::collections::HashMap<String, (LibraryTrackDto, u32)> =
std::collections::HashMap::new();
for (track, cluster_key, priority_rank) in rows {
let dedup_key = cluster_key
.clone()
.unwrap_or_else(|| solo_partition_key(&track.server_id, &track.id));
match best_by_key.get(&dedup_key) {
Some((_, best_rank)) if *best_rank <= priority_rank => {}
_ => {
best_by_key.insert(dedup_key, (track, priority_rank));
}
}
}
let hits: Vec<LibraryTrackDto> = best_by_key
.into_values()
.map(|(t, _)| t)
.skip(offset)
.take(limit as usize)
.collect();
let hit_keys: HashSet<(String, String)> = hits
.iter()
.map(|t| (t.server_id.clone(), t.id.clone()))
.collect();
let fuzzy = fuzzy_cluster_matches(
store,
servers_ordered,
query.trim(),
&hit_keys,
limit as usize,
)?;
Ok(LibraryCrossServerSearchResponse {
hits,
fuzzy,
servers_searched: servers_ordered.to_vec(),
})
}
/// Random merged track sample across cluster scope.
pub fn run_cluster_random_tracks(
store: &LibraryStore,
servers_ordered: &[String],
limit: u32,
) -> Result<Vec<LibraryTrackDto>, String> {
if servers_ordered.is_empty() {
return Ok(Vec::new());
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let cols = aliased_track_columns("t");
let sql = format!(
"WITH candidates AS (
SELECT
t.rowid AS tid,
t.server_id,
t.id AS track_id,
k.cluster_key,
COALESCE(k.duration_sec, t.duration_sec) AS dur,
({priority_sql}) AS priority_rank
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0 AND t.server_id IN ({in_placeholders})
),
refs AS (
SELECT cluster_key, MIN(priority_rank) AS best_rank
FROM candidates
WHERE cluster_key IS NOT NULL
GROUP BY cluster_key
),
ref_dur AS (
SELECT c.cluster_key, c.dur AS ref_dur
FROM candidates c
JOIN refs r ON c.cluster_key = r.cluster_key AND c.priority_rank = r.best_rank
),
partitioned AS (
SELECT c.tid,
CASE
WHEN c.cluster_key IS NULL THEN 'solo:' || c.server_id || ':' || c.track_id
WHEN ABS(c.dur - rd.ref_dur) <= {tol} THEN c.cluster_key
ELSE 'solo:' || c.server_id || ':' || c.track_id
END AS merge_key,
c.priority_rank
FROM candidates c
LEFT JOIN ref_dur rd ON c.cluster_key = rd.cluster_key
),
winners AS (
SELECT tid,
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
FROM partitioned
)
SELECT {cols}
FROM winners w
JOIN track t ON t.rowid = w.tid
WHERE w.rn = 1
ORDER BY RANDOM()
LIMIT ?",
tol = super::merge::DURATION_TOLERANCE_SEC,
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.push(SqlValue::Integer(limit as i64));
store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
.map_err(|e| e.to_string())
}
fn fuzzy_cluster_matches(
store: &LibraryStore,
targets: &[String],
query: &str,
hit_keys: &HashSet<(String, String)>,
overall_cap: usize,
) -> Result<Vec<LibraryTrackDto>, String> {
let like = like_contains(query);
let cols = aliased_track_columns("t");
let key_idx = repos::track_columns().split(',').count();
let sql = format!(
"SELECT {cols}, k.cluster_key \
FROM track t \
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k \
ON k.server_id = t.server_id AND k.track_id = t.id \
WHERE t.server_id = ? AND t.deleted = 0 AND t.title LIKE ? ESCAPE '\\' \
ORDER BY t.title COLLATE NOCASE ASC LIMIT ?"
);
let mut out: Vec<LibraryTrackDto> = Vec::new();
let mut seen_keys: HashSet<String> = HashSet::new();
for server in targets {
if out.len() >= overall_cap {
break;
}
let bound = [
SqlValue::Text(server.clone()),
SqlValue::Text(like.clone()),
SqlValue::Integer(FUZZY_PER_SERVER_CAP as i64),
];
let rows: Vec<(LibraryTrackDto, Option<String>)> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let collected = stmt.query_map(rusqlite::params_from_iter(bound.iter()), |r| {
let track = repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))?;
let cluster_key: Option<String> = r.get(key_idx)?;
Ok((track, cluster_key))
})?;
collected.collect::<rusqlite::Result<Vec<_>>>()
})?;
for (track, cluster_key) in rows {
if out.len() >= overall_cap {
break;
}
if hit_keys.contains(&(track.server_id.clone(), track.id.clone())) {
continue;
}
let dedup_key = cluster_key
.clone()
.unwrap_or_else(|| solo_partition_key(&track.server_id, &track.id));
if !seen_keys.insert(dedup_key) {
continue;
}
out.push(track);
}
}
Ok(out)
}
+5 -66
View File
@@ -7,11 +7,9 @@ use std::time::Duration;
use rusqlite::{params, Connection, OpenFlags};
use tauri::Manager;
use crate::server_cluster::{attach_cluster_database, attach_cluster_database_uri, cluster_db_path};
/// Current head of the embedded migrations. Bump each time a new
/// `migrations/NNN_*.sql` is added.
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 2;
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 1;
/// Lowest applied schema version the current code can advance from purely
/// additively. If a DB carries a version below this, the breaking-bump hook
@@ -24,15 +22,10 @@ pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 2;
pub const LIBRARY_DB_MIN_COMPATIBLE_VERSION: i64 = 1;
pub(crate) const INITIAL_SQL: &str = include_str!("../migrations/001_initial.sql");
const MIGRATION_002_ALBUM_BROWSE_INDEX: &str =
include_str!("../migrations/002_album_browse_index.sql");
/// Embedded migrations. Ordered ascending by `version`; the runner sorts
/// defensively before applying so the source order can stay readable.
const MIGRATIONS: &[(i64, &str)] = &[
(1, INITIAL_SQL),
(2, MIGRATION_002_ALBUM_BROWSE_INDEX),
];
const MIGRATIONS: &[(i64, &str)] = &[(1, INITIAL_SQL)];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MigrationOutcome {
@@ -47,17 +40,9 @@ pub(crate) enum MigrationOutcome {
/// In-memory tests share one DB across the read/write pair in a single store.
static IN_MEMORY_DB_COUNTER: AtomicU64 = AtomicU64::new(0);
fn in_memory_uri(prefix: &str) -> String {
fn in_memory_uri() -> String {
let n = IN_MEMORY_DB_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("file:psysonic_{prefix}_mem_{n}?mode=memory&cache=shared")
}
fn in_memory_library_uri() -> String {
in_memory_uri("library")
}
fn in_memory_cluster_uri() -> String {
in_memory_uri("cluster")
format!("file:psysonic_library_mem_{n}?mode=memory&cache=shared")
}
pub struct LibraryStore {
@@ -82,12 +67,10 @@ impl LibraryStore {
let write_conn = Connection::open(db_path).map_err(|e| e.to_string())?;
configure_write_connection(&write_conn).map_err(|e| e.to_string())?;
run_migrations(&write_conn).map_err(|e| e.to_string())?;
attach_cluster_file(&write_conn, db_path).map_err(|e| e.to_string())?;
checkpoint_wal_conn(&write_conn, "open").map_err(|e| e.to_string())?;
let read_conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|e| e.to_string())?;
configure_read_connection(&read_conn).map_err(|e| e.to_string())?;
attach_cluster_file(&read_conn, db_path).map_err(|e| e.to_string())?;
Ok(Self {
write_conn: Mutex::new(write_conn),
read_conn: Mutex::new(read_conn),
@@ -97,15 +80,12 @@ impl LibraryStore {
/// Build an in-memory DB with the production schema applied.
pub fn open_in_memory() -> Self {
let uri = in_memory_library_uri();
let cluster_uri = in_memory_cluster_uri();
let uri = in_memory_uri();
let write_conn = Connection::open(&uri).expect("in-memory write connection");
configure_write_connection(&write_conn).expect("write pragmas");
run_migrations(&write_conn).expect("schema migration");
attach_cluster_database_uri(&write_conn, &cluster_uri).expect("cluster attach write");
let read_conn = Connection::open(&uri).expect("in-memory read connection");
configure_read_connection(&read_conn).expect("read pragmas");
attach_cluster_database_uri(&read_conn, &cluster_uri).expect("cluster attach read");
Self {
write_conn: Mutex::new(write_conn),
read_conn: Mutex::new(read_conn),
@@ -237,11 +217,9 @@ impl LibraryStore {
let reopened_write = Connection::open(active_path).map_err(|e| e.to_string())?;
configure_write_connection(&reopened_write).map_err(|e| e.to_string())?;
attach_cluster_file(&reopened_write, active_path).map_err(|e| e.to_string())?;
let reopened_read = Connection::open_with_flags(active_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|e| e.to_string())?;
configure_read_connection(&reopened_read).map_err(|e| e.to_string())?;
attach_cluster_file(&reopened_read, active_path).map_err(|e| e.to_string())?;
*write_conn = reopened_write;
*read_conn = reopened_read;
Ok(Some(backup))
@@ -275,11 +253,9 @@ impl LibraryStore {
let reopened_write = Connection::open(active_path).map_err(|e| e.to_string())?;
configure_write_connection(&reopened_write).map_err(|e| e.to_string())?;
attach_cluster_file(&reopened_write, active_path).map_err(|e| e.to_string())?;
let reopened_read = Connection::open_with_flags(active_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|e| e.to_string())?;
configure_read_connection(&reopened_read).map_err(|e| e.to_string())?;
attach_cluster_file(&reopened_read, active_path).map_err(|e| e.to_string())?;
*write_conn = reopened_write;
*read_conn = reopened_read;
Ok(())
@@ -309,10 +285,6 @@ fn log_write_op(op: &str, lock_wait_ms: u128, exec_ms: u128) {
}
}
fn attach_cluster_file(conn: &Connection, library_db_path: &Path) -> rusqlite::Result<()> {
attach_cluster_database(conn, &cluster_db_path(library_db_path))
}
fn library_db_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let base = app.path().app_data_dir().map_err(|e| e.to_string())?;
let db_dir = base.join("databases").join("library");
@@ -506,39 +478,6 @@ fn handle_breaking_schema_bump(
mod tests {
use super::*;
#[test]
fn cluster_db_attached_on_open_in_memory() {
use crate::server_cluster::ATTACH_ALIAS;
let store = LibraryStore::open_in_memory();
let count: i64 = store
.with_conn("misc", |c| {
c.query_row(
&format!(
"SELECT COUNT(*) FROM {ATTACH_ALIAS}.sqlite_master \
WHERE type='table' AND name='track_cluster_key'"
),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(count, 1);
let read_count: i64 = store
.with_read_conn(|c| {
c.query_row(
&format!(
"SELECT COUNT(*) FROM {ATTACH_ALIAS}.cluster_meta WHERE key = 'norm_version'"
),
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(read_count, 1);
}
#[test]
fn read_conn_sees_committed_writes_from_write_conn() {
let store = LibraryStore::open_in_memory();
@@ -3,10 +3,12 @@ name = "psysonic-syncfs"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
psysonic-core = { path = "../psysonic-core" }
psysonic-library = { path = "../psysonic-library" }
psysonic-analysis = { path = "../psysonic-analysis" }
psysonic-audio = { path = "../psysonic-audio" }
+100 -2
View File
@@ -1,4 +1,4 @@
use std::path::Path;
use std::path::{Path, PathBuf};
/// Recursively sums the size of all files under `root`.
/// Missing roots, unreadable directories, and unreadable files are silently skipped.
@@ -25,6 +25,47 @@ pub fn dir_size_recursive(root: &Path) -> u64 {
total
}
/// All regular files under `root` (recursive). Missing or unreadable roots yield an empty list.
pub fn collect_regular_files_under(root: &Path) -> Vec<std::path::PathBuf> {
if !root.is_dir() {
return Vec::new();
}
let mut files = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let rd = match std::fs::read_dir(&dir) {
Ok(r) => r,
Err(_) => continue,
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.is_file() {
files.push(path);
}
}
}
files
}
fn normalize_path_for_prefix(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
/// Returns the `{…}/cache`, `{…}/library`, or `{…}/favorites` ancestor of a media file path.
pub fn local_tier_boundary_from_path(path: &Path) -> Option<PathBuf> {
let mut current = path.parent()?;
loop {
match current.file_name().and_then(|s| s.to_str()) {
Some("cache") | Some("library") | Some("favorites") => {
return Some(current.to_path_buf());
}
_ => current = current.parent()?,
}
}
}
/// Walks upward from `start_dir`, removing each empty directory using `remove_dir`
/// (never `remove_dir_all`). Stops as soon as a non-empty directory is hit, the
/// boundary is reached, or removal fails.
@@ -32,9 +73,11 @@ pub fn dir_size_recursive(root: &Path) -> u64 {
/// `boundary` is never removed and is treated as a hard stop. If `start_dir` is
/// not under `boundary`, the function is a no-op.
pub fn prune_empty_dirs_up_to(start_dir: &Path, boundary: &Path) {
let boundary_norm = normalize_path_for_prefix(boundary);
let mut current = Some(start_dir.to_path_buf());
while let Some(dir) = current {
if dir == boundary || !dir.starts_with(boundary) {
let dir_norm = normalize_path_for_prefix(&dir);
if dir_norm == boundary_norm || !dir_norm.starts_with(&boundary_norm) {
break;
}
match std::fs::read_dir(&dir) {
@@ -52,6 +95,29 @@ pub fn prune_empty_dirs_up_to(start_dir: &Path, boundary: &Path) {
}
}
/// Post-order sweep: removes empty child directories under `root` (never `root` itself).
pub fn prune_empty_subdirs_under(root: &Path) {
if !root.is_dir() {
return;
}
let children: Vec<PathBuf> = std::fs::read_dir(root)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
for child in children {
prune_empty_subdirs_under(&child);
let is_empty = std::fs::read_dir(&child)
.map(|mut rd| rd.next().is_none())
.unwrap_or(false);
if is_empty {
let _ = std::fs::remove_dir(&child);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -87,6 +153,17 @@ mod tests {
assert!(path.exists(), "boundary dir must never be removed");
}
#[test]
fn collect_regular_files_under_lists_nested_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.mp3"), b"x").unwrap();
let sub = dir.path().join("Artist/Album");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("b.flac"), b"yy").unwrap();
let files = collect_regular_files_under(dir.path());
assert_eq!(files.len(), 2);
}
#[test]
fn prune_empty_dirs_up_to_stops_at_non_empty_parent() {
let root = tempfile::tempdir().unwrap();
@@ -98,4 +175,25 @@ mod tests {
assert!(!child.exists(), "empty leaf should be pruned");
assert!(parent.exists(), "non-empty parent must stay");
}
#[test]
fn local_tier_boundary_from_path_finds_cache_root() {
let root = tempfile::tempdir().unwrap();
let track = root
.path()
.join("vol/media/cache/my.server/Artist/Album/track.flac");
std::fs::create_dir_all(track.parent().unwrap()).unwrap();
let boundary = local_tier_boundary_from_path(&track).unwrap();
assert_eq!(boundary, root.path().join("vol/media/cache"));
}
#[test]
fn prune_empty_subdirs_under_removes_nested_empty_tree() {
let root = tempfile::tempdir().unwrap();
let cache = root.path().join("cache").join("srv").join("Artist").join("Album");
std::fs::create_dir_all(&cache).unwrap();
prune_empty_subdirs_under(&root.path().join("cache"));
assert!(!cache.exists());
assert!(root.path().join("cache").exists(), "tier root preserved");
}
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -2,3 +2,4 @@ mod fs_utils;
pub mod offline;
pub mod downloads;
pub mod hot;
pub mod local;
+50 -17
View File
@@ -5,6 +5,8 @@ pub mod cli;
mod cover_cache;
mod library_analysis_backfill;
mod lib_commands;
mod theme_import;
pub mod theme_animation;
pub use psysonic_integration::discord;
@@ -114,6 +116,38 @@ pub fn run() {
let _ = window.set_title("Psysonic (Dev)");
}
// ── Dev: `--theme-watch <theme.css>` live theme reload ─────────
// Poll a local theme.css and push it into the running app on save,
// so theme authors get a live loop without re-importing a zip. The
// frontend (dev only) installs it under the id in its
// `[data-theme='<id>']` selector and applies it. Dev-builds only.
#[cfg(debug_assertions)]
{
let args: Vec<String> = std::env::args().collect();
if let Some(i) = args.iter().position(|a| a == "--theme-watch") {
match args.get(i + 1).cloned() {
Some(path) => {
eprintln!("[theme-watch] watching {path}");
let handle = app.handle().clone();
std::thread::spawn(move || {
let p = std::path::PathBuf::from(&path);
let mut last_css = String::new();
loop {
if let Ok(css) = std::fs::read_to_string(&p) {
if css != last_css {
last_css = css.clone();
let _ = handle.emit("theme-watch:css", css);
}
}
std::thread::sleep(std::time::Duration::from_millis(300));
}
});
}
None => eprintln!("[theme-watch] usage: --theme-watch <path/to/theme.css>"),
}
}
}
// ── Analysis cache (SQLite) ───────────────────────────────────
{
let cache = analysis_cache::AnalysisCache::init(app.handle())
@@ -593,6 +627,7 @@ pub fn run() {
})
.invoke_handler(tauri::generate_handler![
greet,
theme_import::import_theme_zip,
backup_export_library_db,
backup_import_library_db,
backup_export_full,
@@ -619,6 +654,7 @@ pub fn run() {
performance_cpu_snapshot,
set_subsonic_wire_user_agent,
no_compositing_mode,
theme_animation_risk,
linux_xdg_session_type,
is_tiling_wm_cmd,
open_mini_player,
@@ -708,30 +744,14 @@ pub fn run() {
psysonic_library::commands::library_search,
psysonic_library::commands::library_live_search,
psysonic_library::commands::library_advanced_search,
psysonic_library::commands::library_cluster_advanced_search,
psysonic_library::commands::library_list_lossless_albums,
psysonic_library::commands::library_list_albums,
psysonic_library::commands::library_list_albums_by_genre,
psysonic_library::commands::library_get_artist_lossless_browse,
psysonic_library::commands::library_search_cross_server,
psysonic_library::commands::library_cluster_list_tracks,
psysonic_library::commands::library_cluster_list_albums,
psysonic_library::commands::library_cluster_list_artists,
psysonic_library::commands::library_cluster_list_favorites,
psysonic_library::commands::library_cluster_list_favorite_albums,
psysonic_library::commands::library_cluster_list_favorite_artists,
psysonic_library::commands::library_cluster_player_stats_year_summary,
psysonic_library::commands::library_cluster_player_stats_heatmap,
psysonic_library::commands::library_cluster_player_stats_day_detail,
psysonic_library::commands::library_cluster_player_stats_recent_days,
psysonic_library::commands::library_cluster_player_stats_most_played,
psysonic_library::commands::library_cluster_resolve_candidates,
psysonic_library::commands::library_cluster_album_detail,
psysonic_library::commands::library_cluster_artist_detail,
psysonic_library::commands::library_search_cluster,
psysonic_library::commands::library_get_track,
psysonic_library::commands::library_get_tracks_batch,
psysonic_library::commands::library_get_tracks_by_album,
psysonic_library::commands::library_upsert_songs_from_api,
psysonic_library::commands::library_get_artifact,
psysonic_library::commands::library_get_facts,
psysonic_library::commands::library_get_offline_path,
@@ -792,6 +812,19 @@ pub fn run() {
psysonic_syncfs::cache::offline::clear_offline_cancel,
psysonic_syncfs::cache::offline::delete_offline_track,
psysonic_syncfs::cache::offline::get_offline_cache_size,
psysonic_syncfs::cache::local::download_track_local,
psysonic_syncfs::cache::local::probe_library_track_local,
psysonic_syncfs::cache::local::discover_library_tier_on_disk,
psysonic_syncfs::cache::local::prune_orphan_library_tier_files,
psysonic_syncfs::cache::local::prune_orphan_ephemeral_cache_files,
psysonic_syncfs::cache::local::evict_ephemeral_cache_orphans_to_fit,
psysonic_syncfs::cache::local::probe_media_files,
psysonic_syncfs::cache::local::get_media_tier_size,
psysonic_syncfs::cache::local::purge_media_tier,
psysonic_syncfs::cache::local::delete_media_file,
psysonic_syncfs::cache::local::prune_empty_media_tier_dirs,
psysonic_syncfs::cache::local::promote_stream_cache_to_local,
psysonic_syncfs::cache::local::migrate_legacy_offline_disk,
psysonic_syncfs::cache::hot::download_track_hot_cache,
psysonic_syncfs::cache::hot::promote_stream_cache_to_hot_cache,
psysonic_syncfs::cache::hot::get_hot_cache_size,
@@ -23,6 +23,7 @@ pub(crate) use perf::performance_cpu_snapshot;
pub(crate) use platform::{
linux_wayland_gpu_font_tuning_active, linux_wayland_text_render_settings_available,
set_linux_wayland_text_render_profile, set_linux_webkit_smooth_scrolling, set_window_decorations,
theme_animation_risk,
};
#[cfg(target_os = "linux")]
pub(crate) use platform::{
@@ -137,6 +137,29 @@ pub(crate) fn linux_webkit_apply_wayland_gpu_font_tuning(win: &tauri::WebviewWin
}
/// Toggle native window decorations at runtime (Linux custom title bar opt-out).
/// Tauri command: true when theme animations may be costly on this setup —
/// Linux with the Nvidia WebKit quirk active (recorded once at startup) or
/// compositing forced off. The frontend warns on animated themes when true.
/// Always false off Linux.
#[tauri::command]
pub(crate) fn theme_animation_risk() -> bool {
#[cfg(target_os = "linux")]
{
// Compositing forced off → GPU-accelerated effects/animation are costly.
if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE")
.map(|v| v == "1")
.unwrap_or(false)
{
return true;
}
crate::theme_animation::nvidia_quirk_active()
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
#[tauri::command]
pub(crate) fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) {
if let Some(win) = app_handle.get_webview_window("main") {
+6 -1
View File
@@ -17,11 +17,16 @@ fn apply_linux_webkit_nvidia_quirk() {
// may still be `XDG_SESSION_TYPE=wayland`. The quirk maps that to `__NV_DISABLE_EXPLICIT_SYNC`,
// which mismatches a real X11 EGL stack and can leave the webview gray — mirror the native-X11
// branch (`WEBKIT_DISABLE_DMABUF_RENDERER` only) whenever GDK is pinned to x11 first in the list.
// Detect once and record it for the UI's animated-theme CPU-load warning,
// so the theme_animation_risk command never has to re-probe the GPU.
let kind = needs_workaround();
psysonic_lib::theme_animation::set_nvidia_quirk_active(!matches!(kind, WorkaroundKind::None));
let forced_x11_gdk = std::env::var("GDK_BACKEND").ok().is_some_and(|s| {
matches!(s.split(',').next().map(str::trim), Some("x11"))
});
if forced_x11_gdk {
match needs_workaround() {
match kind {
WorkaroundKind::None => {}
WorkaroundKind::DisableWebkitDmabufRenderer | WorkaroundKind::DisableNvExplicitSync => {
set_webkit_disable_dmabuf_renderer();
+22
View File
@@ -0,0 +1,22 @@
//! Startup-recorded display hint for the theme system.
//!
//! The Nvidia/WebKit GPU quirk is detected once at process start (in `main()`,
//! before the webview/GTK init). We record whether it was needed so the UI can
//! warn that animated themes may raise CPU load on this setup — without
//! re-probing the GPU later. Read via the `theme_animation_risk` command
//! (`lib_commands::app_api::platform`).
use std::sync::OnceLock;
static NVIDIA_QUIRK_ACTIVE: OnceLock<bool> = OnceLock::new();
/// Record the startup Nvidia-WebKit-quirk detection. Called once from `main()`.
pub fn set_nvidia_quirk_active(active: bool) {
let _ = NVIDIA_QUIRK_ACTIVE.set(active);
}
/// Whether the Nvidia WebKit quirk was needed at startup. False when unrecorded
/// (non-Linux, or GPU acceleration opted in via `PSYSONIC_WEBKIT_GPU_ACCEL`).
pub(crate) fn nvidia_quirk_active() -> bool {
NVIDIA_QUIRK_ACTIVE.get().copied().unwrap_or(false)
}
+99
View File
@@ -0,0 +1,99 @@
//! Local theme-package import.
//!
//! Reads a user-picked `.zip` and returns its `manifest.json` + `theme.css`
//! to the frontend, which runs the full theme-store contract validation
//! (`src/utils/themes/validateThemePackage.ts`) before installing.
//!
//! Only those two entries are pulled out — the thumbnail is not needed (the UI
//! derives a swatch from the CSS). Parsing the untrusted archive happens here
//! in Rust, outside the webview, and every read is size-capped so a malformed
//! or hostile archive (lying header, zip-bomb, path traversal) cannot exhaust
//! memory or escape the archive.
use std::io::Read;
use serde::Serialize;
/// On-disk archive cap. A real token-only theme zip is a few KB.
const MAX_ARCHIVE_BYTES: u64 = 4 * 1024 * 1024;
/// Per-entry uncompressed caps — mirror the frontend/CI limits
/// (`validateThemeCss` caps CSS at 64 KB; the manifest is tiny).
const MAX_MANIFEST_BYTES: usize = 64 * 1024;
const MAX_CSS_BYTES: usize = 256 * 1024;
#[derive(Serialize)]
pub struct ImportedThemeFiles {
pub manifest: String,
pub css: String,
}
#[tauri::command]
pub fn import_theme_zip(path: String) -> Result<ImportedThemeFiles, String> {
let file = std::fs::File::open(&path).map_err(|e| format!("cannot open file: {e}"))?;
let len = file
.metadata()
.map_err(|e| format!("cannot read file info: {e}"))?
.len();
if len > MAX_ARCHIVE_BYTES {
return Err(format!(
"archive is too large (> {} KB)",
MAX_ARCHIVE_BYTES / 1024
));
}
let mut archive =
zip::ZipArchive::new(file).map_err(|_| "not a valid .zip archive".to_string())?;
let manifest = read_capped_entry(&mut archive, "manifest.json", MAX_MANIFEST_BYTES)?
.ok_or_else(|| "manifest.json was not found in the archive".to_string())?;
let css = read_capped_entry(&mut archive, "theme.css", MAX_CSS_BYTES)?
.ok_or_else(|| "theme.css was not found in the archive".to_string())?;
Ok(ImportedThemeFiles { manifest, css })
}
/// Find the first non-directory entry whose file name equals `wanted` (at the
/// archive root or under a single wrapping folder), reject path traversal, and
/// read it as UTF-8 text under `cap` bytes. Both the declared size and the
/// actual read are bounded, so a lying header cannot allocate past the cap.
fn read_capped_entry<R: Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
wanted: &str,
cap: usize,
) -> Result<Option<String>, String> {
for i in 0..archive.len() {
let mut entry = archive
.by_index(i)
.map_err(|e| format!("corrupt archive entry: {e}"))?;
if entry.is_dir() {
continue;
}
// `enclosed_name()` is `None` for absolute paths or `..` traversal.
let base = match entry.enclosed_name() {
Some(p) => match p.file_name().and_then(|s| s.to_str()) {
Some(s) => s.to_string(),
None => continue,
},
None => return Err("archive contains an unsafe path".to_string()),
};
if base != wanted {
continue;
}
if entry.size() > cap as u64 {
return Err(format!("{wanted} is too large (> {} KB)", cap / 1024));
}
let mut buf = Vec::new();
entry
.by_ref()
.take(cap as u64 + 1)
.read_to_end(&mut buf)
.map_err(|e| format!("cannot read {wanted}: {e}"))?;
if buf.len() > cap {
return Err(format!("{wanted} is too large (> {} KB)", cap / 1024));
}
return String::from_utf8(buf)
.map(Some)
.map_err(|_| format!("{wanted} is not valid UTF-8"));
}
Ok(None)
}
+56
View File
@@ -1,6 +1,10 @@
import { useEffect } from 'react';
import { useAuthStore } from './store/authStore';
import { usePlayerStore } from './store/playerStore';
import { useLyricsStore } from './store/lyricsStore';
import { useThemeStore } from './store/themeStore';
import { useInstalledThemesStore } from './store/installedThemesStore';
import { syncInjectedThemes } from './utils/themes/themeInjection';
import { useThemeScheduler } from './hooks/useThemeScheduler';
import { useFontStore } from './store/fontStore';
import { getWindowKind } from './app/windowKind';
@@ -13,14 +17,66 @@ export default function App() {
useThemeStore(s => s.theme);
const effectiveTheme = useThemeScheduler();
const font = useFontStore(s => s.font);
const installedThemes = useInstalledThemesStore(s => s.themes);
// Document-attribute hooks are shared between both window kinds — each
// webview has its own `document`, and theme / font / track-preview tokens
// are read by CSS in both trees.
// Installed community themes have no build-time CSS — inject their
// `[data-theme='<id>']` blocks into <head> from the persisted (localStorage)
// store. Runs before the data-theme effect below so the matching style exists
// when the attribute is applied. The store hydrates synchronously, so an
// active community theme is painted without a network round-trip.
useEffect(() => {
syncInjectedThemes(installedThemes);
}, [installedThemes]);
// Dev only: `--theme-watch <theme.css>` (debug builds) pushes a local theme's
// CSS in on every save. Install it under the id in its `[data-theme='<id>']`
// selector and apply it — the syncInjectedThemes effect above re-injects, so
// authoring is live without re-importing a zip. Never wired in production.
useEffect(() => {
if (!import.meta.env.DEV) return;
let unlisten: (() => void) | undefined;
void import('@tauri-apps/api/event').then(({ listen }) => {
const sub = listen<string>('theme-watch:css', ({ payload }) => {
const id = payload.match(/\[data-theme=['"]([^'"]+)['"]\]/)?.[1];
if (!id) return;
useInstalledThemesStore.getState().install({
id, name: id, author: 'dev', version: '0.0.0', description: '', mode: 'dark', css: payload, installedAt: Date.now(),
});
useThemeStore.getState().setTheme(id);
});
// Guard the mocked-in-tests case where listen() isn't a promise.
if (sub && typeof sub.then === 'function') sub.then(u => { unlisten = u; });
}).catch(() => {});
return () => unlisten?.();
}, []);
useEffect(() => {
document.documentElement.setAttribute('data-theme', effectiveTheme);
}, [effectiveTheme]);
// Expose app state on the theme root so themes can react to it with a
// same-element compound selector, e.g. `[data-theme='x'][data-playing='true']`.
// The set of allowed state attributes is the contract's `stateSelectors`.
// (Sidebar-collapsed is set in AppShell, where that state lives.)
const isPlaying = usePlayerStore(s => s.isPlaying);
useEffect(() => {
document.documentElement.setAttribute('data-playing', isPlaying ? 'true' : 'false');
}, [isPlaying]);
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
useEffect(() => {
document.documentElement.setAttribute('data-fullscreen', isFullscreenOpen ? 'true' : 'false');
}, [isFullscreenOpen]);
const lyricsOpen = useLyricsStore(s => s.activeTab === 'lyrics');
useEffect(() => {
document.documentElement.setAttribute('data-lyrics-open', lyricsOpen ? 'true' : 'false');
}, [lyricsOpen]);
useEffect(() => {
document.documentElement.setAttribute('data-font', font);
}, [font]);
+24 -427
View File
@@ -202,8 +202,6 @@ export interface LibrarySortClause {
export interface LibraryAdvancedSearchRequest {
serverId: string;
libraryScope?: string | null;
/** Multiple music-folder ids (OR). Preferred over `libraryScope` when length > 1. */
libraryScopeIds?: string[] | null;
query?: string | null; // shorthand → fts clause on text fields
entityTypes: LibraryEntityType[];
filters?: LibraryFilterClause[];
@@ -381,52 +379,9 @@ export function libraryLiveSearch(request: LibraryLiveSearchRequest): Promise<Li
}));
}
export interface LibraryAlbumBrowseRequest {
serverId: string;
libraryScope?: string | null;
libraryScopeIds?: string[] | null;
restrictAlbumIds?: string[] | null;
sort?: LibrarySortClause[];
limit?: number;
offset?: number;
}
export interface LibraryAlbumBrowseResponse {
albums: LibraryAlbumDto[];
hasMore: boolean;
source: 'local';
}
/** Paginated plain All Albums from the local track index. */
export function libraryListAlbums(
request: LibraryAlbumBrowseRequest,
): Promise<LibraryAlbumBrowseResponse> {
const indexKey = serverIndexKeyForId(request.serverId);
return invoke<LibraryAlbumBrowseResponse>('library_list_albums', {
request: {
serverId: indexKey,
libraryScope: request.libraryScope ?? undefined,
libraryScopeIds: request.libraryScopeIds ?? undefined,
restrictAlbumIds: request.restrictAlbumIds ?? undefined,
sort: request.sort,
limit: request.limit,
offset: request.offset,
},
}).then(response => ({
...response,
albums: response.albums.map(album => ({
...album,
serverId: mapServerIdFromIndexKey(album.serverId, request.serverId),
})),
}));
}
export interface LibraryLosslessAlbumsRequest {
serverId: string;
libraryScope?: string | null;
libraryScopeIds?: string[] | null;
restrictAlbumIds?: string[] | null;
sort?: LibrarySortClause[];
limit?: number;
offset?: number;
}
@@ -446,9 +401,6 @@ export function libraryListLosslessAlbums(
request: {
serverId: indexKey,
libraryScope: request.libraryScope ?? undefined,
libraryScopeIds: request.libraryScopeIds ?? undefined,
restrictAlbumIds: request.restrictAlbumIds ?? undefined,
sort: request.sort,
limit: request.limit,
offset: request.offset,
},
@@ -512,327 +464,6 @@ export function librarySearchCrossServer(args: {
}));
}
export interface LibraryClusterCandidateDto {
serverId: string;
trackId: string;
durationSec: number;
priorityRank: number;
isWinner: boolean;
}
export interface LibraryClusterResolveResponse {
candidates: LibraryClusterCandidateDto[];
clusterKey?: string | null;
}
function mapServersOrderedToIndexKeys(serverIds: string[]): string[] {
return serverIds.map(serverIndexKeyForId);
}
function mapClusterLibraryScopesToIndexKeys(
scopes: Record<string, string[]> | undefined,
): Record<string, string[]> | undefined {
if (!scopes) return undefined;
const out: Record<string, string[]> = {};
for (const [sid, scopeIds] of Object.entries(scopes)) {
if (scopeIds.length > 0) out[serverIndexKeyForId(sid)] = scopeIds;
}
return Object.keys(out).length > 0 ? out : undefined;
}
/** Merged track list for cluster scope (ordered members = priority). */
export function libraryClusterListTracks(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
libraryScopes?: Record<string, string[]>;
}): Promise<LibraryTracksEnvelope> {
return invoke<LibraryTracksEnvelope>('library_cluster_list_tracks', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
libraryScopes: mapClusterLibraryScopesToIndexKeys(args.libraryScopes) ?? {},
},
}).then(env => ({
...env,
tracks: mapTracksServerId(env.tracks),
}));
}
export interface LibraryClusterAlbumsResponse {
albums: LibraryAlbumDto[];
hasMore: boolean;
}
export interface LibraryClusterArtistsResponse {
artists: LibraryArtistDto[];
hasMore: boolean;
}
export function libraryClusterListAlbums(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
libraryScopes?: Record<string, string[]>;
}): Promise<LibraryClusterAlbumsResponse> {
return invoke<LibraryClusterAlbumsResponse>('library_cluster_list_albums', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
libraryScopes: mapClusterLibraryScopesToIndexKeys(args.libraryScopes) ?? {},
},
}).then(resp => ({
...resp,
albums: resp.albums.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })),
}));
}
export function libraryClusterListArtists(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
libraryScopes?: Record<string, string[]>;
}): Promise<LibraryClusterArtistsResponse> {
return invoke<LibraryClusterArtistsResponse>('library_cluster_list_artists', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
libraryScopes: mapClusterLibraryScopesToIndexKeys(args.libraryScopes) ?? {},
},
}).then(resp => ({
...resp,
artists: resp.artists.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })),
}));
}
export function libraryClusterListFavorites(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
}): Promise<LibraryTracksEnvelope> {
return invoke<LibraryTracksEnvelope>('library_cluster_list_favorites', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
},
}).then(env => ({
...env,
tracks: mapTracksServerId(env.tracks),
}));
}
export function libraryClusterPlayerStatsYearSummary(args: {
serversOrdered: string[];
year: number;
}): Promise<PlaySessionYearSummary> {
return invoke<PlaySessionYearSummary>('library_cluster_player_stats_year_summary', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
year: args.year,
},
});
}
export function libraryClusterPlayerStatsHeatmap(args: {
serversOrdered: string[];
year: number;
}): Promise<PlaySessionHeatmapDay[]> {
return invoke<PlaySessionHeatmapDay[]>('library_cluster_player_stats_heatmap', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
year: args.year,
},
});
}
export function libraryClusterResolveCandidates(args: {
serversOrdered: string[];
clusterKey?: string;
serverId?: string;
trackId?: string;
}): Promise<LibraryClusterResolveResponse> {
return invoke<LibraryClusterResolveResponse>('library_cluster_resolve_candidates', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
clusterKey: args.clusterKey,
serverId: args.serverId ? serverIndexKeyForId(args.serverId) : undefined,
trackId: args.trackId,
},
}).then(response => ({
...response,
candidates: response.candidates.map(c => ({
...c,
serverId: mapServerIdFromIndexKey(c.serverId),
})),
}));
}
export interface LibraryClusterAlbumDetailResponse {
album: LibraryAlbumDto;
tracks: LibraryTrackDto[];
ownerServerId: string;
relatedAlbums: LibraryAlbumDto[];
}
export interface LibraryClusterArtistDetailResponse {
artist: LibraryArtistDto;
albums: LibraryAlbumDto[];
topTracks: LibraryTrackDto[];
ownerServerId: string;
artistKey?: string | null;
}
export function libraryClusterAlbumDetail(args: {
serversOrdered: string[];
serverId: string;
entityId: string;
}): Promise<LibraryClusterAlbumDetailResponse> {
return invoke<LibraryClusterAlbumDetailResponse>('library_cluster_album_detail', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
serverId: serverIndexKeyForId(args.serverId),
entityId: args.entityId,
},
}).then(resp => ({
...resp,
album: { ...resp.album, serverId: mapServerIdFromIndexKey(resp.album.serverId) },
tracks: mapTracksServerId(resp.tracks),
ownerServerId: mapServerIdFromIndexKey(resp.ownerServerId),
relatedAlbums: resp.relatedAlbums.map(a => ({
...a,
serverId: mapServerIdFromIndexKey(a.serverId),
})),
}));
}
export function libraryClusterArtistDetail(args: {
serversOrdered: string[];
serverId: string;
entityId: string;
}): Promise<LibraryClusterArtistDetailResponse> {
return invoke<LibraryClusterArtistDetailResponse>('library_cluster_artist_detail', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
serverId: serverIndexKeyForId(args.serverId),
entityId: args.entityId,
},
}).then(resp => ({
...resp,
artist: { ...resp.artist, serverId: mapServerIdFromIndexKey(resp.artist.serverId) },
albums: resp.albums.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })),
topTracks: mapTracksServerId(resp.topTracks),
ownerServerId: mapServerIdFromIndexKey(resp.ownerServerId),
}));
}
/** Cluster-mode search — dedup by cluster_key + priority. */
export function librarySearchCluster(args: {
query: string;
limit?: number;
offset?: number;
serversOrdered: string[];
}): Promise<LibraryCrossServerSearchResponse> {
return invoke<LibraryCrossServerSearchResponse>('library_search_cluster', {
query: args.query,
limit: args.limit,
offset: args.offset,
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
}).then(response => ({
...response,
hits: mapTracksServerId(response.hits),
fuzzy: mapTracksServerId(response.fuzzy),
serversSearched: response.serversSearched.map(id => mapServerIdFromIndexKey(id)),
}));
}
export interface LibraryClusterAdvancedSearchRequest {
serversOrdered: string[];
query?: string | null;
entityTypes: LibraryEntityType[];
filters?: LibraryFilterClause[];
starredOnly?: boolean | null;
restrictAlbumIds?: string[] | null;
/** Per-member getAlbumList2 allowlists (`serverId` → album ids). */
restrictAlbumScopes?: Record<string, string[]>;
queryAlbumTitleOnly?: boolean | null;
sort?: LibrarySortClause[];
limit: number;
offset?: number;
skipTotals?: boolean;
libraryScopes?: Record<string, string[]>;
}
export function libraryClusterAdvancedSearch(
request: LibraryClusterAdvancedSearchRequest,
): Promise<LibraryAdvancedSearchResponse> {
return invoke<LibraryAdvancedSearchResponse>('library_cluster_advanced_search', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(request.serversOrdered),
query: request.query ?? undefined,
entityTypes: request.entityTypes,
filters: request.filters ?? [],
starredOnly: request.starredOnly ?? undefined,
restrictAlbumIds: request.restrictAlbumIds ?? undefined,
restrictAlbumScopes: mapClusterLibraryScopesToIndexKeys(request.restrictAlbumScopes) ?? {},
queryAlbumTitleOnly: request.queryAlbumTitleOnly ?? undefined,
sort: request.sort ?? [],
limit: request.limit,
offset: request.offset ?? 0,
skipTotals: request.skipTotals ?? false,
libraryScopes: mapClusterLibraryScopesToIndexKeys(request.libraryScopes) ?? {},
},
}).then(response => ({
...response,
artists: response.artists.map(artist => ({
...artist,
serverId: mapServerIdFromIndexKey(artist.serverId),
})),
albums: response.albums.map(album => ({
...album,
serverId: mapServerIdFromIndexKey(album.serverId),
})),
tracks: mapTracksServerId(response.tracks),
}));
}
export function libraryClusterListFavoriteAlbums(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
}): Promise<LibraryClusterAlbumsResponse> {
return invoke<LibraryClusterAlbumsResponse>('library_cluster_list_favorite_albums', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
},
}).then(resp => ({
...resp,
albums: resp.albums.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })),
}));
}
export function libraryClusterListFavoriteArtists(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
}): Promise<LibraryClusterArtistsResponse> {
return invoke<LibraryClusterArtistsResponse>('library_cluster_list_favorite_artists', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
},
}).then(resp => ({
...resp,
artists: resp.artists.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })),
}));
}
export function libraryGetTrack(
serverId: string,
trackId: string,
@@ -842,6 +473,18 @@ export function libraryGetTrack(
.then(track => (track ? { ...track, serverId } : track));
}
/** Seed library index rows from live Subsonic song payloads (pin/download cold miss). */
export function libraryUpsertSongsFromApi(
serverId: string,
songs: unknown[],
): Promise<number> {
const indexKey = serverIndexKeyForId(serverId);
return invoke<number>('library_upsert_songs_from_api', { serverId: indexKey, songs });
}
/** `library_get_tracks_batch` cap (spec §7.1). */
export const LIBRARY_TRACKS_BATCH_LIMIT = 100;
export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise<LibraryTrackDto[]> {
const indexKeyMap = new Map<string, string>();
const remapped = refs.map(ref => {
@@ -856,6 +499,18 @@ export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise<LibraryTrack
})));
}
/** Chunked batch fetch — safe when `refs.length` exceeds {@link LIBRARY_TRACKS_BATCH_LIMIT}. */
export async function libraryGetTracksBatchChunked(refs: TrackRefDto[]): Promise<LibraryTrackDto[]> {
if (refs.length === 0) return [];
const out: LibraryTrackDto[] = [];
for (let i = 0; i < refs.length; i += LIBRARY_TRACKS_BATCH_LIMIT) {
const chunk = refs.slice(i, i + LIBRARY_TRACKS_BATCH_LIMIT);
const batch = await libraryGetTracksBatch(chunk).catch(() => []);
out.push(...batch);
}
return out;
}
export function libraryGetTracksByAlbum(
serverId: string,
albumId: string,
@@ -1093,13 +748,11 @@ export function libraryGetCatalogYearBounds(args: { serverId: string }): Promise
export function libraryGetGenreAlbumCounts(args: {
serverId: string;
libraryScope?: string;
libraryScopeIds?: string[];
}): Promise<GenreAlbumCountRow[]> {
const indexKey = serverIndexKeyForId(args.serverId);
return invoke<GenreAlbumCountRow[]>('library_get_genre_album_counts', {
serverId: indexKey,
libraryScope: args.libraryScope,
libraryScopeIds: args.libraryScopeIds,
});
}
@@ -1107,7 +760,6 @@ export type LibraryGenreAlbumsRequest = {
serverId: string;
genre: string;
libraryScope?: string | null;
libraryScopeIds?: string[] | null;
sort?: LibrarySortClause[];
limit?: number;
offset?: number;
@@ -1131,7 +783,6 @@ export function libraryListAlbumsByGenre(
serverId: indexKey,
genre: request.genre,
libraryScope: request.libraryScope ?? undefined,
libraryScopeIds: request.libraryScopeIds ?? undefined,
sort: request.sort ?? [],
limit: request.limit ?? 50,
offset: request.offset ?? 0,
@@ -1187,60 +838,6 @@ export function libraryGetPlayerStatsRecentDays(limit = 30): Promise<PlaySession
return invoke<PlaySessionRecentDay[]>('library_get_player_stats_recent_days', { limit });
}
export interface PlaySessionMostPlayed {
track: LibraryTrackDto;
trackPlayCount: number;
totalListenedSec: number;
}
export function libraryClusterPlayerStatsDayDetail(args: {
serversOrdered: string[];
dateIso: string;
}): Promise<PlaySessionDayDetail> {
return invoke<PlaySessionDayDetail>('library_cluster_player_stats_day_detail', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
dateIso: args.dateIso,
},
}).then(detail => ({
...detail,
tracks: detail.tracks.map(track => ({
...track,
serverId: mapServerIdFromIndexKey(track.serverId),
})),
}));
}
export function libraryClusterPlayerStatsRecentDays(args: {
serversOrdered: string[];
limit?: number;
}): Promise<PlaySessionRecentDay[]> {
return invoke<PlaySessionRecentDay[]>('library_cluster_player_stats_recent_days', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
},
});
}
export function libraryClusterPlayerStatsMostPlayed(args: {
serversOrdered: string[];
limit?: number;
}): Promise<PlaySessionMostPlayed[]> {
return invoke<PlaySessionMostPlayed[]>('library_cluster_player_stats_most_played', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
},
}).then(rows => rows.map(row => ({
...row,
track: {
...row.track,
serverId: mapServerIdFromIndexKey(row.track.serverId),
},
})));
}
// ── Event subscriptions ───────────────────────────────────────────────
export interface LibrarySyncProgressPayload {
+3 -3
View File
@@ -1,7 +1,6 @@
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from './subsonicTypes';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { libraryScopeForServer } from './subsonicClient';
import { ndLogin } from './navidromeAdmin';
/** Server-keyed Bearer token cache. Cheap to keep — Navidrome tokens are long-lived. */
let cachedToken: { serverUrl: string; token: string } | null = null;
@@ -25,9 +24,10 @@ function asString(v: unknown, fallback = ''): string {
* Mirrors the Subsonic `musicFolderId` we pipe through `libraryFilterParams()` Navidrome
* uses the same id space, so the same value is valid for the native API's `library_id` filter. */
function currentLibraryId(): string | null {
const { activeServerId } = useAuthStore.getState();
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
if (!activeServerId) return null;
return libraryScopeForServer(activeServerId) ?? null;
const f = musicLibraryFilterByServer[activeServerId];
return !f || f === 'all' ? null : f;
}
function asNumber(v: unknown): number | undefined {
+5
View File
@@ -20,6 +20,11 @@ vi.mock('axios', () => ({
default: { get: vi.fn() },
}));
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
shouldAttemptSubsonicForActiveServer: () => true,
shouldAttemptSubsonicForServer: () => true,
}));
import axios from 'axios';
import { pingWithCredentials, ping } from './subsonic';
import { getAlbumInfo2 } from './subsonicAlbumInfo';
+2 -2
View File
@@ -72,7 +72,7 @@ describe('libraryFilterParams', () => {
it('returns { musicFolderId } when the active server has a specific filter', () => {
const serverId = setUpServer();
useAuthStore.setState({
musicLibraryFilterByServer: { [serverId]: ['mf-7'] },
musicLibraryFilterByServer: { [serverId]: 'mf-7' },
});
expect(libraryFilterParams()).toEqual({ musicFolderId: 'mf-7' });
});
@@ -91,7 +91,7 @@ describe('libraryScopeForServer', () => {
it('returns the folder id when scoped', () => {
const serverId = setUpServer();
useAuthStore.setState({
musicLibraryFilterByServer: { [serverId]: ['mf-7'] },
musicLibraryFilterByServer: { [serverId]: 'mf-7' },
});
expect(libraryScopeForServer(serverId)).toBe('mf-7');
});
+2 -109
View File
@@ -8,11 +8,6 @@ import type {
SubsonicArtistInfo,
SubsonicSong,
} from './subsonicTypes';
import { isAllLibrariesFilter, normalizeMusicLibraryFilter } from '../utils/musicLibraryFilter';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import { resolveClusterBrowseMembers } from '../utils/serverCluster/clusterBrowse';
import { libraryClusterResolveCandidates } from './library';
import { mergeClusterTracks, resolveClusterSeedIds } from '../utils/serverCluster/clusterDiscoveryMerge';
export async function getArtists(): Promise<SubsonicArtist[]> {
const data = await api<{ artists: { index: any } }>('getArtists.view', {
@@ -65,9 +60,6 @@ export async function getArtistInfoForServer(
}
export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
if (isClusterMode()) {
return getTopSongsCluster(artist);
}
const { activeServerId } = useAuthStore.getState();
if (!activeServerId) return [];
return getTopSongsForServer(activeServerId, artist);
@@ -76,7 +68,7 @@ export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
export async function getTopSongsForServer(serverId: string, artist: string): Promise<SubsonicSong[]> {
try {
const { musicLibraryFilterByServer } = useAuthStore.getState();
const scoped = !isAllLibrariesFilter(normalizeMusicLibraryFilter(musicLibraryFilterByServer[serverId]));
const scoped = musicLibraryFilterByServer[serverId] && musicLibraryFilterByServer[serverId] !== 'all';
const topCount = scoped ? 20 : 5;
const data = await apiForServer<{ topSongs: { song: SubsonicSong[] } }>(
serverId,
@@ -91,59 +83,7 @@ export async function getTopSongsForServer(serverId: string, artist: string): Pr
}
}
async function getTopSongsCluster(artist: string): Promise<SubsonicSong[]> {
const members = await resolveClusterBrowseMembers();
if (!members?.length) return [];
const settled = await Promise.allSettled(
members.map(serverId =>
apiForServer<{ topSongs: { song: SubsonicSong[] } }>(
serverId,
'getTopSongs.view',
{ artist, count: 20, ...libraryFilterParamsForServer(serverId) },
).then(data => ({ serverId, songs: data.topSongs?.song ?? [] })),
),
);
const merged = mergeClusterTracks(
settled.flatMap((row, idx) =>
row.status === 'fulfilled'
? row.value.songs.map(song => ({
item: { ...song, clusterBrowseServerId: row.value.serverId },
serverId: row.value.serverId,
priorityRank: idx,
}))
: [],
),
);
return merged.slice(0, 5);
}
export async function getSimilarSongs2(id: string, count = 50): Promise<SubsonicSong[]> {
if (isClusterMode()) {
const members = await resolveClusterBrowseMembers();
if (!members?.length) return [];
const requestCount = similarSongsRequestCount(count);
const settled = await Promise.allSettled(
members.map(serverId =>
apiForServer<{ similarSongs2: { song: SubsonicSong[] } }>(
serverId,
'getSimilarSongs2.view',
{ id, count: requestCount, ...libraryFilterParamsForServer(serverId) },
).then(data => ({ serverId, songs: data.similarSongs2?.song ?? [] })),
),
);
const merged = mergeClusterTracks(
settled.flatMap((row, idx) =>
row.status === 'fulfilled'
? row.value.songs.map(song => ({
item: { ...song, clusterBrowseServerId: row.value.serverId },
serverId: row.value.serverId,
priorityRank: idx,
}))
: [],
),
);
return merged.filter(s => s.id !== id).slice(0, count);
}
try {
const requestCount = similarSongsRequestCount(count);
const data = await api<{ similarSongs2: { song: SubsonicSong[] } }>('getSimilarSongs2.view', { id, count: requestCount, ...libraryFilterParams() });
@@ -156,54 +96,7 @@ export async function getSimilarSongs2(id: string, count = 50): Promise<Subsonic
}
/** Similar tracks for a song id (Subsonic `getSimilarSongs`) — Navidrome + AudioMuse Instant Mix. */
export async function getSimilarSongs(
id: string,
count = 50,
browseServerId?: string,
): Promise<SubsonicSong[]> {
if (isClusterMode()) {
const members = await resolveClusterBrowseMembers();
if (!members?.length) return [];
const activeServerId = browseServerId ?? useAuthStore.getState().activeServerId ?? members[0] ?? '';
const requestCount = similarSongsRequestCount(count);
const seedCandidates = await libraryClusterResolveCandidates({
serversOrdered: members,
serverId: activeServerId,
trackId: id,
}).catch(() => null);
const seeds = resolveClusterSeedIds(
Object.fromEntries((seedCandidates?.candidates ?? []).map(c => [c.serverId, c.trackId])),
members,
);
const resolvedSeeds = seeds.length > 0
? seeds
: members.map(serverId => ({ serverId, seedId: id }));
const settled = await Promise.allSettled(
resolvedSeeds.map(({ serverId, seedId }) =>
apiForServer<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>(
serverId,
'getSimilarSongs.view',
{ id: seedId, count: requestCount, ...libraryFilterParamsForServer(serverId) },
).then(data => {
const raw = data.similarSongs?.song;
const songs = !raw ? [] : Array.isArray(raw) ? raw : [raw];
return { serverId, songs };
}),
),
);
const merged = mergeClusterTracks(
settled.flatMap((row, idx) =>
row.status === 'fulfilled'
? row.value.songs.map(song => ({
item: { ...song, clusterBrowseServerId: row.value.serverId },
serverId: row.value.serverId,
priorityRank: idx,
}))
: [],
),
);
return merged.filter(s => s.id !== id).slice(0, count);
}
export async function getSimilarSongs(id: string, count = 50): Promise<SubsonicSong[]> {
try {
const requestCount = similarSongsRequestCount(count);
const data = await api<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>('getSimilarSongs.view', { id, count: requestCount, ...libraryFilterParams() });
+8 -8
View File
@@ -4,7 +4,6 @@ import { version } from '../../package.json';
import { useAuthStore } from '../store/authStore';
import type { ServerProfile } from '../store/authStoreTypes';
import { connectBaseUrlForServer } from '../utils/server/serverEndpoint';
import { libraryScopeForServer as scopeForServer } from '../utils/musicLibraryFilter';
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../utils/server/serverLookup';
export const SUBSONIC_CLIENT = `psysonic/${version}`;
@@ -107,16 +106,17 @@ export function libraryFilterParams(): Record<string, string | number> {
return activeServerId ? libraryFilterParamsForServer(activeServerId) : {};
}
export {
libraryScopeForServer,
libraryScopeIdsForServer,
libraryScopeInvokeArgs,
musicLibraryFilterForServer,
} from '../utils/musicLibraryFilter';
/** Navidrome/Subsonic music folder id for the local library index, or undefined for all libraries. */
export function libraryScopeForServer(serverId: string): string | undefined {
const resolved = resolveServerIdForIndexKey(serverId);
const f = useAuthStore.getState().musicLibraryFilterByServer[resolved];
if (f === undefined || f === 'all') return undefined;
return f;
}
/** Library folder filter for an explicit saved server (e.g. Now Playing while browsing another). */
export function libraryFilterParamsForServer(serverId: string): Record<string, string | number> {
const scope = scopeForServer(serverId);
const scope = libraryScopeForServer(serverId);
if (!scope) return {};
return { musicFolderId: scope };
}
+45 -44
View File
@@ -1,9 +1,8 @@
import {
isAllLibrariesFilter,
musicLibraryFilterForServer,
musicLibraryFilterStorageKey,
} from '../utils/musicLibraryFilter';
import { useAuthStore } from '../store/authStore';
import {
shouldAttemptSubsonicForActiveServer,
shouldAttemptSubsonicForServer,
} from '../utils/network/subsonicNetworkGuard';
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
import type {
RandomSongsFilters,
@@ -62,6 +61,7 @@ export async function getMusicFolders(): Promise<SubsonicMusicFolder[]> {
}
export async function getRandomAlbums(size = 6): Promise<SubsonicAlbum[]> {
if (!shouldAttemptSubsonicForActiveServer()) return [];
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
type: 'random',
size,
@@ -76,6 +76,7 @@ export async function getAlbumList(
offset = 0,
extra: Record<string, unknown> = {}
): Promise<SubsonicAlbum[]> {
if (!shouldAttemptSubsonicForActiveServer()) return [];
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
type,
size,
@@ -92,55 +93,46 @@ export async function getAlbumList(
* so similar tracks can leak from other libraries. When the user scoped to one folder, we keep a set of
* album ids in that scope (paginated getAlbumList2) and drop songs whose albumId is not in the set.
*/
const scopedLibraryAlbumIdCaches = new Map<
string,
{ folderId: string; filterVersion: number; ids: Set<string> }
>();
let scopedLibraryAlbumIdCache: {
serverId: string;
folderId: string;
filterVersion: number;
ids: Set<string>;
} | null = null;
/**
* Union of album ids across selected music folders **network fallback only**
* (Subsonic `getAlbumList2`). Local index browse filters via SQL `library_id`.
*/
export async function albumIdsInLibraryScope(serverId: string): Promise<Set<string> | null> {
const { musicLibraryFilterVersion } = useAuthStore.getState();
async function albumIdsInLibraryScope(serverId: string): Promise<Set<string> | null> {
const { musicLibraryFilterByServer, musicLibraryFilterVersion } = useAuthStore.getState();
if (!serverId) return null;
const filter = musicLibraryFilterForServer(serverId);
if (filter === 'all') {
scopedLibraryAlbumIdCaches.delete(serverId);
const folder = musicLibraryFilterByServer[serverId];
if (folder === undefined || folder === 'all') {
scopedLibraryAlbumIdCache = null;
return null;
}
const cacheKey = musicLibraryFilterStorageKey(serverId);
const hit = scopedLibraryAlbumIdCaches.get(serverId);
const hit = scopedLibraryAlbumIdCache;
if (
hit
&& hit.folderId === cacheKey
&& hit.filterVersion === musicLibraryFilterVersion
hit &&
hit.serverId === serverId &&
hit.folderId === folder &&
hit.filterVersion === musicLibraryFilterVersion
) {
return hit.ids;
}
const ids = new Set<string>();
const pageSize = 500;
for (const folderId of filter) {
let offset = 0;
for (;;) {
const albums = await getAlbumListForServer(
serverId,
'alphabeticalByName',
pageSize,
offset,
{ musicFolderId: folderId },
);
for (const a of albums) ids.add(a.id);
if (albums.length < pageSize) break;
offset += pageSize;
if (offset > 500_000) break;
}
let offset = 0;
for (;;) {
const albums = await getAlbumListForServer(serverId, 'alphabeticalByName', pageSize, offset);
for (const a of albums) ids.add(a.id);
if (albums.length < pageSize) break;
offset += pageSize;
if (offset > 500_000) break;
}
scopedLibraryAlbumIdCaches.set(serverId, {
folderId: cacheKey,
scopedLibraryAlbumIdCache = {
serverId,
folderId: folder,
filterVersion: musicLibraryFilterVersion,
ids,
});
};
return ids;
}
@@ -189,9 +181,9 @@ export async function filterAlbumsToActiveLibrary(albums: SubsonicAlbum[]): Prom
/** When scoped to one library, ask the server for more similar tracks — many will be filtered out client-side. */
export function similarSongsRequestCount(desired: number): number {
const { activeServerId } = useAuthStore.getState();
const f = activeServerId ? musicLibraryFilterForServer(activeServerId) : 'all';
if (isAllLibrariesFilter(f)) return desired;
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
const f = activeServerId ? musicLibraryFilterByServer[activeServerId] : undefined;
if (f === undefined || f === 'all') return desired;
return Math.min(300, Math.max(desired, desired * 4));
}
@@ -226,6 +218,7 @@ export async function getAlbumListForServer(
offset = 0,
extra: Record<string, unknown> = {},
): Promise<SubsonicAlbum[]> {
if (!shouldAttemptSubsonicForServer(serverId)) return [];
const data = await apiForServer<{ albumList2: { album: SubsonicAlbum[] } }>(serverId, 'getAlbumList2.view', {
type,
size,
@@ -238,6 +231,7 @@ export async function getAlbumListForServer(
}
export async function getSong(id: string): Promise<SubsonicSong | null> {
if (!shouldAttemptSubsonicForActiveServer()) return null;
try {
const data = await api<{ song: SubsonicSong }>('getSong.view', { id });
return data.song ?? null;
@@ -247,6 +241,7 @@ export async function getSong(id: string): Promise<SubsonicSong | null> {
}
export async function getSongForServer(serverId: string, id: string): Promise<SubsonicSong | null> {
if (!shouldAttemptSubsonicForServer(serverId, id)) return null;
try {
const data = await apiForServer<{ song: SubsonicSong }>(serverId, 'getSong.view', { id });
return data.song ?? null;
@@ -256,6 +251,9 @@ export async function getSongForServer(serverId: string, id: string): Promise<Su
}
export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
if (!shouldAttemptSubsonicForActiveServer()) {
throw new Error('Subsonic unavailable');
}
const data = await api<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>('getAlbum.view', { id });
const { song, ...album } = data.album;
return { album, songs: song ?? [] };
@@ -265,6 +263,9 @@ export async function getAlbumForServer(
serverId: string,
id: string,
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
if (!shouldAttemptSubsonicForServer(serverId)) {
throw new Error('Subsonic unavailable');
}
const data = await apiForServer<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>(serverId, 'getAlbum.view', { id });
const { song, ...album } = data.album;
return { album, songs: song ?? [] };
+21 -1
View File
@@ -1,6 +1,7 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { api } from './subsonicClient';
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
import { api, apiForServer } from './subsonicClient';
import type { SubsonicPlaylist, SubsonicSong } from './subsonicTypes';
export async function getPlaylists(includeOrbit = false): Promise<SubsonicPlaylist[]> {
@@ -19,6 +20,22 @@ export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlayl
return { playlist, songs: entry ?? [] };
}
export async function getPlaylistForServer(
serverId: string,
id: string,
): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] }> {
if (!shouldAttemptSubsonicForServer(serverId)) {
throw new Error('Subsonic unavailable');
}
const data = await apiForServer<{ playlist: SubsonicPlaylist & { entry: SubsonicSong[] } }>(
serverId,
'getPlaylist.view',
{ id },
);
const { entry, ...playlist } = data.playlist;
return { playlist, songs: entry ?? [] };
}
export async function createPlaylist(name: string, songIds?: string[]): Promise<SubsonicPlaylist> {
const params: Record<string, unknown> = { name };
if (songIds && songIds.length > 0) {
@@ -40,6 +57,9 @@ export async function updatePlaylist(id: string, songIds: string[], prevCount =
songIndexToRemove: Array.from({ length: prevCount }, (_, i) => i),
});
}
void import('../utils/offline/pinnedOfflineSync')
.then(m => m.schedulePinnedPlaylistSync(id))
.catch(() => {});
}
export async function updatePlaylistMeta(
+3
View File
@@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./subsonicArtists', () => ({ getArtist: vi.fn() }));
vi.mock('./subsonicLibrary', () => ({ getAlbum: vi.fn() }));
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
shouldAttemptSubsonicForActiveServer: vi.fn(() => true),
}));
import { getArtist } from './subsonicArtists';
import { invalidateEntityUserRatingCaches, prefetchArtistUserRatings } from './subsonicRatings';
+3
View File
@@ -1,5 +1,6 @@
import { getArtist } from './subsonicArtists';
import { getAlbum } from './subsonicLibrary';
import { shouldAttemptSubsonicForActiveServer } from '../utils/network/subsonicNetworkGuard';
const MIX_RATING_PREFETCH_CONCURRENCY = 8;
const RATING_CACHE_TTL = 7 * 60 * 1000; // 7 minutes
@@ -55,6 +56,7 @@ export async function prefetchArtistUserRatings(
else uncached.push(id);
}
if (!uncached.length) return out;
if (!shouldAttemptSubsonicForActiveServer()) return out;
let next = 0;
async function worker() {
for (;;) {
@@ -93,6 +95,7 @@ export async function prefetchAlbumUserRatings(
else uncached.push(id);
}
if (!uncached.length) return out;
if (!shouldAttemptSubsonicForActiveServer()) return out;
let next = 0;
async function worker() {
for (;;) {
+3
View File
@@ -11,6 +11,9 @@ vi.mock('./subsonicClient', () => ({
api: vi.fn(),
apiForServer: apiForServerMock,
}));
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
shouldAttemptSubsonicForServer: () => true,
}));
describe('subsonicScrobble', () => {
beforeEach(() => {
+3 -14
View File
@@ -1,9 +1,7 @@
import { api, apiForServer } from './subsonicClient';
import type { SubsonicNowPlaying } from './subsonicTypes';
import { patchLibraryTrackOnUse } from '../utils/library/patchOnUse';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import { clusterFanOutScrobbleSubmission } from '../utils/serverCluster/clusterWriteFanout';
import { useAuthStore } from '../store/authStore';
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
async function scrobbleOnServer(
serverId: string,
@@ -11,23 +9,14 @@ async function scrobbleOnServer(
submission: boolean,
time?: number,
): Promise<void> {
if (!shouldAttemptSubsonicForServer(serverId, id)) return;
const params: Record<string, unknown> = { id, submission };
if (time !== undefined) params.time = time;
await apiForServer(serverId, 'scrobble.view', params);
}
export async function scrobbleSong(
id: string,
time: number,
serverId: string,
resolvedServerId?: string,
): Promise<void> {
export async function scrobbleSong(id: string, time: number, serverId: string): Promise<void> {
if (!serverId) return;
if (isClusterMode()) {
const browseId = useAuthStore.getState().activeServerId ?? serverId;
await clusterFanOutScrobbleSubmission(browseId, id, time, resolvedServerId ?? serverId);
return;
}
try {
await scrobbleOnServer(serverId, id, true, time);
// Patch-on-use (§6.5 / F3): reflect the play in the local index so the
+55 -28
View File
@@ -1,4 +1,4 @@
import { api, libraryFilterParams } from './subsonicClient';
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
import { invalidateEntityUserRatingCaches } from './subsonicRatings';
import { useAuthStore } from '../store/authStore';
import { patchLibraryTrackOnUse, type StarPatchMeta } from '../utils/library/patchOnUse';
@@ -7,11 +7,6 @@ import {
invalidateStarredAlbumBrowse,
refreshStarredAlbumIndexFromServer,
} from '../utils/library/starredAlbumIndexSync';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import {
clusterFanOutRating,
clusterFanOutStar,
} from '../utils/serverCluster/clusterWriteFanout';
import type {
EntityRatingSupportLevel,
StarredResults,
@@ -20,6 +15,17 @@ import type {
SubsonicSong,
} from './subsonicTypes';
function parseStarred2Response(data: {
starred2?: {
artist?: SubsonicArtist[];
album?: SubsonicAlbum[];
song?: SubsonicSong[];
};
}): StarredResults {
const r = data.starred2 ?? {};
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
}
export async function getStarred(): Promise<StarredResults> {
const data = await api<{
starred2: {
@@ -28,25 +34,50 @@ export async function getStarred(): Promise<StarredResults> {
song?: SubsonicSong[];
}
}>('getStarred2.view', { ...libraryFilterParams() });
const r = data.starred2 ?? {};
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
return parseStarred2Response(data);
}
/** Starred entities for an explicit saved server (not necessarily the active one). */
export async function getStarredForServer(serverId: string): Promise<StarredResults> {
const data = await apiForServer<{
starred2: {
artist?: SubsonicArtist[];
album?: SubsonicAlbum[];
song?: SubsonicSong[];
};
}>(serverId, 'getStarred2.view', { ...libraryFilterParamsForServer(serverId) });
return parseStarred2Response(data);
}
function resolveStarServerId(meta?: StarPatchMeta): string | null {
return meta?.serverId ?? useAuthStore.getState().activeServerId;
}
async function starApi(
serverId: string | null | undefined,
endpoint: string,
params: Record<string, string>,
): Promise<void> {
const sid = serverId ?? useAuthStore.getState().activeServerId;
if (!sid) throw new Error('No server for star API');
if (sid === useAuthStore.getState().activeServerId) {
await api(endpoint, params);
} else {
await apiForServer(sid, endpoint, params);
}
}
export async function star(
id: string,
type: 'song' | 'album' | 'artist' = 'album',
_meta?: StarPatchMeta,
meta?: StarPatchMeta,
): Promise<void> {
const serverId = useAuthStore.getState().activeServerId;
if (type === 'song' && isClusterMode() && serverId) {
await clusterFanOutStar(serverId, id, true);
return;
}
const params: Record<string, string> = {};
if (type === 'song') params.id = id;
if (type === 'album') params.albumId = id;
if (type === 'artist') params.artistId = id;
await api('star.view', params);
const serverId = resolveStarServerId(meta);
await starApi(serverId, 'star.view', params);
if (type === 'song') {
patchLibraryTrackOnUse(serverId, id, { starredAt: Date.now() });
} else if (type === 'album' && serverId) {
@@ -54,23 +85,22 @@ export async function star(
const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId);
void refreshStarredAlbumIndexFromServer(serverId, indexEnabled).catch(() => {});
}
void import('../utils/offline/favoritesOfflineSync')
.then(m => m.onFavoritesOfflineStarChange(id, type, true, serverId ?? undefined))
.catch(() => {});
}
export async function unstar(
id: string,
type: 'song' | 'album' | 'artist' = 'album',
_meta?: StarPatchMeta,
meta?: StarPatchMeta,
): Promise<void> {
const serverId = useAuthStore.getState().activeServerId;
if (type === 'song' && isClusterMode() && serverId) {
await clusterFanOutStar(serverId, id, false);
return;
}
const params: Record<string, string> = {};
if (type === 'song') params.id = id;
if (type === 'album') params.albumId = id;
if (type === 'artist') params.artistId = id;
await api('unstar.view', params);
const serverId = resolveStarServerId(meta);
await starApi(serverId, 'unstar.view', params);
if (type === 'song') {
patchLibraryTrackOnUse(serverId, id, { starredAt: null });
} else if (type === 'album' && serverId) {
@@ -78,15 +108,12 @@ export async function unstar(
const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId);
void refreshStarredAlbumIndexFromServer(serverId, indexEnabled).catch(() => {});
}
void import('../utils/offline/favoritesOfflineSync')
.then(m => m.onFavoritesOfflineStarChange(id, type, false, serverId ?? undefined))
.catch(() => {});
}
export async function setRating(id: string, rating: number): Promise<void> {
const serverId = useAuthStore.getState().activeServerId;
if (isClusterMode() && serverId) {
await clusterFanOutRating(serverId, id, rating);
invalidateEntityUserRatingCaches(id);
return;
}
await api('setRating.view', { id, rating });
// No-op in Rust when `id` is an album/artist (no track row matches).
patchLibraryTrackOnUse(useAuthStore.getState().activeServerId, id, { userRating: rating });
+6 -6
View File
@@ -26,8 +26,8 @@ export interface SubsonicAlbum {
displayArtist?: string;
/** OpenSubsonic: per-disc subtitles (e.g. "Sessions" on CD 3 of a deluxe edition). */
discTitles?: SubsonicDiscTitle[];
/** Psysonic cluster: originating server for merged browse rows (client-only). */
clusterSeedServerId?: string;
/** Set when favorites are merged across servers (offline favorites tier). */
serverId?: string;
}
export interface SubsonicDiscTitle {
@@ -92,6 +92,8 @@ export interface SubsonicSong {
trackPeak?: number;
albumPeak?: number;
};
/** Set when favorites are merged across servers (offline favorites tier). */
serverId?: string;
/** OpenSubsonic: structured composer credit (string for back-compat). */
displayComposer?: string;
/** OpenSubsonic: structured contributors list — Navidrome ≥ 0.55. */
@@ -100,8 +102,6 @@ export interface SubsonicSong {
subRole?: string;
artist: { id?: string; name: string };
}>;
/** Psysonic cluster: browse-origin server for fan-out resolution (client-only). */
clusterBrowseServerId?: string;
}
export interface InternetRadioStation {
@@ -148,8 +148,8 @@ export interface SubsonicArtist {
starred?: string;
/** Present on some servers (e.g. OpenSubsonic) for artist-level rating. */
userRating?: number;
/** Psysonic cluster: originating server for merged browse rows (client-only). */
clusterSeedServerId?: string;
/** Set when favorites are merged across servers (offline favorites tier). */
serverId?: string;
}
export interface SubsonicGenre {
+24 -9
View File
@@ -11,16 +11,19 @@ import PlayerBar from '../components/PlayerBar';
import BottomNav from '../components/BottomNav';
import { useIsMobile } from '../hooks/useIsMobile';
import LiveSearch from '../components/LiveSearch';
import DevNetworkModeToggle from '../components/DevNetworkModeToggle';
import NowPlayingDropdown from '../components/NowPlayingDropdown';
import QueuePanel from '../components/QueuePanel';
import AppRoutes from './AppRoutes';
import FullscreenPlayer from '../components/FullscreenPlayer';
import FullscreenPlayer from '../components/fullscreenPlayer/FullscreenPlayerStatic';
import ContextMenu from '../components/ContextMenu';
import SongInfoModal from '../components/SongInfoModal';
import DownloadFolderModal from '../components/DownloadFolderModal';
import GlobalConfirmModal from '../components/GlobalConfirmModal';
import ThemeMigrationNotice from '../components/ThemeMigrationNotice';
import OrbitAccountPicker from '../components/OrbitAccountPicker';
import OrbitHelpModal from '../components/OrbitHelpModal';
import OrbitReconnectModal from '../components/OrbitReconnectModal';
import TooltipPortal from '../components/TooltipPortal';
import OverlayScrollArea from '../components/OverlayScrollArea';
import {
@@ -38,7 +41,8 @@ import { useOrbitHost } from '../hooks/useOrbitHost';
import { useOrbitGuest } from '../hooks/useOrbitGuest';
import { useOrbitBodyAttrs } from '../hooks/useOrbitBodyAttrs';
import { usePlatformShellSetup } from '../hooks/usePlatformShellSetup';
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
import { useWindowFullscreenState } from '../hooks/useWindowFullscreenState';
import { useNowPlayingTrayTitle } from '../hooks/useNowPlayingTrayTitle';
import { useTrayMenuI18n } from '../hooks/useTrayMenuI18n';
@@ -48,15 +52,14 @@ import { useGlobalDndAndSelectionBlockers } from '../hooks/useGlobalDndAndSelect
import { useAppActivityTracking } from '../hooks/useAppActivityTracking';
import { useMainScrollingIndicator } from '../hooks/useMainScrollingIndicator';
import { useCoverNavigationPriority } from '../hooks/useCoverNavigationPriority';
import { useClusterPlaybackMonitor } from '../hooks/useClusterPlaybackMonitor';
import { useLiveSearchRouteScope } from '../hooks/useLiveSearchRouteScope';
import { useNowPlayingPrewarm } from '../hooks/useNowPlayingPrewarm';
import { useOfflineAutoNav } from '../hooks/useOfflineAutoNav';
import { useOfflineLibraryFilterSuspend } from '../hooks/useOfflineLibraryFilterSuspend';
import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam';
import { IS_LINUX } from '../utils/platform';
import { useConnectionStatus } from '../hooks/useConnectionStatus';
import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore';
import { usePlayerStore } from '../store/playerStore';
import '../store/previewPlayerVolumeSync';
import '../store/queueResolverBridge';
@@ -103,12 +106,13 @@ export function AppShell() {
const location = useLocation();
const prevPathnameRef = useRef(location.pathname);
useCoverNavigationPriority();
useClusterPlaybackMonitor();
useLiveSearchRouteScope();
useNowPlayingPrewarm();
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
const offlineCtx = useOfflineBrowseContext();
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
const hasOfflineContent = offlineCtx.hasBrowsingContent;
const hasOfflineBrowse = offlineCtx.hasBrowseCapability;
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
const perfFlags = usePerfProbeFlags();
@@ -136,7 +140,8 @@ export function AppShell() {
document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTo({ top: 0 });
}, [location.pathname, location.state]);
useOfflineAutoNav(connStatus, hasOfflineContent, location.pathname, navigate);
useOfflineAutoNav(connStatus, offlineNav, location, navigate);
useOfflineLibraryFilterSuspend();
useEffect(() => {
initializeFromServerQueue();
@@ -175,6 +180,13 @@ export function AppShell() {
return () => window.removeEventListener('psy:toggle-sidebar', onToggleSidebar);
}, [isSidebarCollapsed, setSidebarCollapsed]);
// Expose sidebar state on the theme root so themes can react with a
// `[data-theme='x'][data-sidebar-collapsed='true']` compound (contract
// `stateSelectors`). Other state attributes are set in App.tsx.
useEffect(() => {
document.documentElement.setAttribute('data-sidebar-collapsed', isSidebarCollapsed ? 'true' : 'false');
}, [isSidebarCollapsed]);
// Workaround for WebKitGTK 2.50.x text-input repaint hang on
// Linux Mint / Ubuntu 24.04 (issues #342, #782). When opted in,
// nudge WebKit awake on every input/textarea focus via a sync
@@ -243,6 +255,7 @@ export function AppShell() {
<div className="main-content-zoom">
<header className="content-header">
<LiveSearch />
{import.meta.env.DEV && <DevNetworkModeToggle />}
<div className="spacer" />
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
<LastfmIndicator />
@@ -261,7 +274,7 @@ export function AppShell() {
</header>
<OrbitSessionBar />
{connStatus === 'disconnected' && (
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineContent} serverName={serverName} />
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineBrowse} serverName={serverName} />
)}
<div className="content-body app-shell-route-host">
<OverlayScrollArea
@@ -307,8 +320,10 @@ export function AppShell() {
<SongInfoModal />
<DownloadFolderModal />
<GlobalConfirmModal />
<ThemeMigrationNotice />
<OrbitAccountPicker />
<OrbitHelpModal />
<OrbitReconnectModal />
{!perfFlags.disableTooltipPortal && <TooltipPortal />}
<AppUpdater />
</div>
+28
View File
@@ -13,6 +13,12 @@ import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { useGlobalShortcutsStore } from '../store/globalShortcutsStore';
import { initHotCachePrefetch } from '../hotCachePrefetch';
import { initLocalPlaybackInvalidation } from '../localPlaybackInvalidation';
import { initFavoritesOfflineSync } from '../utils/offline/favoritesOfflineSync';
import { initPinnedOfflineSync } from '../utils/offline/pinnedOfflineSync';
import { initResumeIncompleteOfflinePins, scheduleResumeIncompleteOfflinePins } from '../utils/offline/resumeIncompleteOfflinePins';
import { runLegacyOfflineFileMigration } from '../utils/migrations/legacyOfflineFileMigration';
import { reconcileLibraryTierForServer } from '../utils/offline/libraryTierReconcile';
import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge';
import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigration';
import { bootstrapAllIndexedServers } from '../utils/library/librarySession';
@@ -98,6 +104,28 @@ export default function MainApp() {
return initHotCachePrefetch();
}, [migrationReady]);
useEffect(() => {
if (!migrationReady) return undefined;
void (async () => {
await runLegacyOfflineFileMigration();
const servers = useAuthStore.getState().servers;
for (const server of servers) {
await reconcileLibraryTierForServer(server.id);
}
scheduleResumeIncompleteOfflinePins();
})();
const stopInvalidation = initLocalPlaybackInvalidation();
const stopFavoritesSync = initFavoritesOfflineSync();
const stopPinnedOfflineSync = initPinnedOfflineSync();
const stopOfflineResume = initResumeIncompleteOfflinePins();
return () => {
stopInvalidation();
stopFavoritesSync();
stopPinnedOfflineSync();
stopOfflineResume();
};
}, [migrationReady, serverIdsKey]);
useEffect(() => {
if (!migrationReady) return;
useGlobalShortcutsStore.getState().registerAll();
+59
View File
@@ -24,10 +24,14 @@ vi.mock('./windowKind', () => ({
import { invoke } from '@tauri-apps/api/core';
import { getWindowKind } from './windowKind';
import {
applyThemeAtStartup,
installCrossWindowThemeSync,
pushLoggingModeToBackend,
pushUserAgentToBackend,
runPreReactBootstrap,
} from './bootstrap';
import { useThemeStore } from '../store/themeStore';
import { useInstalledThemesStore } from '../store/installedThemesStore';
const ORIGINAL_USER_AGENT = window.navigator.userAgent;
@@ -48,6 +52,8 @@ beforeEach(() => {
afterEach(() => {
setUserAgent(ORIGINAL_USER_AGENT);
document.documentElement.removeAttribute('data-theme');
document.head.querySelectorAll('style[data-installed-theme]').forEach((el) => el.remove());
});
describe('pushUserAgentToBackend', () => {
@@ -143,3 +149,56 @@ describe('runPreReactBootstrap', () => {
// the bootstrap doesn't second-guess that decision.
});
});
describe('applyThemeAtStartup', () => {
const setPersistedTheme = (state: Record<string, unknown>) =>
localStorage.setItem('psysonic_theme', JSON.stringify({ state, version: 1 }));
it('does nothing when there is no persisted theme', () => {
applyThemeAtStartup();
expect(document.documentElement.getAttribute('data-theme')).toBeNull();
});
it('sets data-theme to the active theme (scheduler off)', () => {
setPersistedTheme({ theme: 'kanagawa-wave', enableThemeScheduler: false });
applyThemeAtStartup();
expect(document.documentElement.getAttribute('data-theme')).toBe('kanagawa-wave');
});
it('injects installed community themes up front', () => {
setPersistedTheme({ theme: 'dracula', enableThemeScheduler: false });
localStorage.setItem('psysonic_installed_themes', JSON.stringify({
state: { themes: [{ id: 'dracula', name: 'Dracula', author: 'a', version: '1.0.0', description: '', mode: 'dark', css: "[data-theme='dracula']{--accent:#bd93f9;}", installedAt: 0 }] },
version: 1,
}));
applyThemeAtStartup();
expect(document.head.querySelector('style[data-installed-theme="dracula"]')).not.toBeNull();
expect(document.documentElement.getAttribute('data-theme')).toBe('dracula');
});
it('does not throw on malformed storage', () => {
localStorage.setItem('psysonic_theme', '{not json');
expect(() => applyThemeAtStartup()).not.toThrow();
expect(document.documentElement.getAttribute('data-theme')).toBeNull();
});
});
describe('installCrossWindowThemeSync', () => {
it('rehydrates the matching store on a cross-window storage event', () => {
const themeRehydrate = vi.spyOn(useThemeStore.persist, 'rehydrate').mockResolvedValue(undefined);
const installedRehydrate = vi.spyOn(useInstalledThemesStore.persist, 'rehydrate').mockResolvedValue(undefined);
installCrossWindowThemeSync();
window.dispatchEvent(new StorageEvent('storage', { key: 'psysonic_theme' }));
expect(themeRehydrate).toHaveBeenCalled();
window.dispatchEvent(new StorageEvent('storage', { key: 'psysonic_installed_themes' }));
expect(installedRehydrate).toHaveBeenCalled();
themeRehydrate.mockClear();
installedRehydrate.mockClear();
window.dispatchEvent(new StorageEvent('storage', { key: 'unrelated-key' }));
expect(themeRehydrate).not.toHaveBeenCalled();
expect(installedRehydrate).not.toHaveBeenCalled();
});
});
+67
View File
@@ -1,6 +1,10 @@
import { installQueueUndoHotkey } from '../store/queueUndoHotkey';
import { invoke } from '@tauri-apps/api/core';
import { getWindowKind } from './windowKind';
import { migrateThemeSelection } from '../utils/themes/themeMigration';
import { getScheduledTheme, useThemeStore } from '../store/themeStore';
import { syncInjectedThemes } from '../utils/themes/themeInjection';
import { useInstalledThemesStore, type InstalledTheme } from '../store/installedThemesStore';
/** Sync backend HTTP User-Agent from the main webview once at startup. */
export function pushUserAgentToBackend(): void {
@@ -35,6 +39,63 @@ export function pushLoggingModeToBackend(): void {
}
}
function readInstalledThemes(): InstalledTheme[] {
try {
const raw = localStorage.getItem('psysonic_installed_themes');
if (!raw) return [];
const parsed = JSON.parse(raw) as { state?: { themes?: InstalledTheme[] } };
return Array.isArray(parsed.state?.themes) ? (parsed.state!.themes as InstalledTheme[]) : [];
} catch {
return [];
}
}
/**
* Apply the active theme synchronously, before React mounts, so the first paint
* is already correct. Zustand rehydrate + the `data-theme` effect run after the
* first paint, so without this a non-Mocha active theme (every light theme and
* every installed community theme) flashes the `:root` Mocha default for a
* frame. We set `data-theme` to the effective (scheduler-resolved) theme and
* inject installed community themes' CSS up front. Runs after the migration so
* the persisted ids are already resolved.
*/
export function applyThemeAtStartup(): void {
try {
const raw = localStorage.getItem('psysonic_theme');
if (!raw) return; // fresh profile — the :root default is correct
const parsed = JSON.parse(raw) as { state?: Record<string, unknown> };
const s = parsed.state;
if (!s) return;
syncInjectedThemes(readInstalledThemes());
const effective = getScheduledTheme({
enableThemeScheduler: !!s.enableThemeScheduler,
theme: String(s.theme ?? 'mocha'),
themeDay: String(s.themeDay ?? 'latte'),
themeNight: String(s.themeNight ?? 'mocha'),
timeDayStart: String(s.timeDayStart ?? '07:00'),
timeNightStart: String(s.timeNightStart ?? '19:00'),
});
if (effective) document.documentElement.setAttribute('data-theme', effective);
} catch {
// Non-fatal — App's effects apply the theme after mount.
}
}
/**
* Keep theme state in sync across webviews (main mini player). Zustand
* persist does not sync across windows on its own; the `storage` event fires in
* *other* windows when localStorage changes, so rehydrate the relevant store
* there. Installing/applying/uninstalling in one window then live-updates the
* other (App's effects re-run and re-apply `data-theme` / injected styles).
*/
export function installCrossWindowThemeSync(): void {
if (typeof window === 'undefined') return;
window.addEventListener('storage', (e) => {
if (e.key === 'psysonic_theme') void useThemeStore.persist?.rehydrate?.();
else if (e.key === 'psysonic_installed_themes') void useInstalledThemesStore.persist?.rehydrate?.();
});
}
/** Mark the document in Vite dev so CSS can show dev-only chrome. */
export function markDevBuildDocument(): void {
if (import.meta.env.DEV) {
@@ -46,6 +107,12 @@ export function markDevBuildDocument(): void {
export function runPreReactBootstrap(): void {
// Pre-warm the window-kind cache so subsequent reads are sync + safe.
getWindowKind();
// Reset any persisted theme that is no longer bundled and not installed, so
// the store hydrates onto a paintable theme (no unstyled-:root flash).
migrateThemeSelection();
// Paint the correct theme on the very first frame (no Mocha flash).
applyThemeAtStartup();
installCrossWindowThemeSync();
markDevBuildDocument();
pushUserAgentToBackend();
pushLoggingModeToBackend();
+28 -20
View File
@@ -1,14 +1,13 @@
import { getAlbum } from '../api/subsonicLibrary';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import React, { memo, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore';
import { useLocalPlaybackStore } from '../store/localPlaybackStore';
import { isOfflinePinComplete } from '../utils/offline/offlineLibraryHelpers';
import { CoverArtImage } from '../cover/CoverArtImage';
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
import { coverStorageKeyFromRef } from '../cover/storageKeys';
@@ -17,12 +16,14 @@ import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
import { resolveCoverDisplayTier } from '../cover/tiers';
import { acquireUrl } from '../utils/imageCache/urlPool';
import { OpenArtistRefInline } from './OpenArtistRefInline';
import { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
import { fetchAlbumTracks, playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
import { useLongPressAction } from '../hooks/useLongPressAction';
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
import { useDragDrop } from '../contexts/DragDropContext';
import { isAlbumRecentlyAdded } from '../utils/albumRecency';
import { deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
import { coverServerScopeForServerId } from '../cover/serverScope';
import { appendServerQuery } from '../utils/navigation/detailServerScope';
interface AlbumCardProps {
album: SubsonicAlbum;
@@ -63,24 +64,28 @@ function AlbumCard({
}: AlbumCardProps) {
const { t } = useTranslation();
const { isHolding, pressBind } = useLongPressAction({
onShortPress: () => playAlbum(album.id),
onLongPress: () => playAlbumShuffled(album.id),
onShortPress: () => playAlbum(album.id, album.serverId ? { serverId: album.serverId } : undefined),
onLongPress: () => playAlbumShuffled(album.id, album.serverId ? { serverId: album.serverId } : undefined),
});
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const enqueue = usePlayerStore(s => s.enqueue);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const isOffline = useOfflineStore(s => {
const meta = s.albums[`${serverId}:${album.id}`];
if (!meta || meta.trackIds.length === 0) return false;
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
});
const activeServerId = useAuthStore(s => s.activeServerId ?? '');
const offlineServerId = album.serverId ?? activeServerId;
const localEntries = useLocalPlaybackStore(s => s.entries);
const isOffline = isOfflinePinComplete(album.id, offlineServerId);
const albumLinkQuery = useMemo(
() => appendServerQuery(linkQuery, album.serverId),
[linkQuery, album.serverId],
);
void localEntries;
const psyDrag = useDragDrop();
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, {
libraryResolve,
clusterSeedServerId: album.clusterSeedServerId,
});
const coverServerScope = useMemo(
() => coverServerScopeForServerId(album.serverId),
[album.serverId],
);
const coverRef = useAlbumCoverRef(album.id, album.coverArt, coverServerScope, { libraryResolve });
const dragCoverKey = useMemo(() => {
if (!coverRef) return '';
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' });
@@ -91,7 +96,7 @@ function AlbumCard({
const handleClick = (opts?: { shiftKey?: boolean }) => {
if (selectionMode) { onToggleSelect?.(album.id, opts); return; }
navigateToAlbum(album.id, { search: linkQuery, seedServerId: album.clusterSeedServerId });
navigateToAlbum(album.id, { search: albumLinkQuery });
};
return (
@@ -185,10 +190,13 @@ function AlbumCard({
onClick={async e => {
e.stopPropagation();
try {
const data = await getAlbum(album.id);
enqueue(data.songs.map(songToTrack));
const tracks = await fetchAlbumTracks(
album.id,
offlineServerId || undefined,
);
if (tracks.length > 0) enqueue(tracks);
} catch {
// Network failure — silent (toast would be too noisy for a hover action)
// Unavailable offline or network failure — silent on hover action
}
}}
aria-label={t('contextMenu.enqueueAlbum')}
+130 -99
View File
@@ -5,7 +5,6 @@ import { useNavigate } from 'react-router-dom';
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Share2, Highlighter, Loader2, Shuffle } from 'lucide-react';
import { CoverArtImage } from '../cover/CoverArtImage';
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
import { COVER_SCOPE_ACTIVE, type CoverServerScope } from '../cover/types';
import { useCoverLightboxSrc } from '../cover/lightbox';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../hooks/useIsMobile';
@@ -20,6 +19,7 @@ import { formatMb } from '../utils/format/formatBytes';
import { sanitizeHtml } from '../utils/sanitizeHtml';
import { OpenArtistRefInline } from './OpenArtistRefInline';
import { tooltipAttrs } from './tooltipAttrs';
import { offlineActionPolicy, type OfflineActionPolicy } from '../utils/offline/offlineActionPolicy';
/** True when the album artist label means "no single artist" `getArtistInfo`
* has nothing meaningful to return for these, so the Artist Bio entry is hidden.
@@ -72,12 +72,10 @@ interface AlbumHeaderProps {
headerArtistRefs: SubsonicOpenArtistRef[];
songs: SubsonicSong[];
coverArtId?: string;
/** Cluster / multi-server album detail — fetch cover from the seed member, not representative. */
coverServerScope?: CoverServerScope;
resolvedCoverUrl: string | null;
isStarred: boolean;
downloadProgress: number | null;
offlineStatus: 'none' | 'downloading' | 'cached';
offlineStatus: 'none' | 'queued' | 'downloading' | 'cached';
offlineProgress: { done: number; total: number } | null;
bio: string | null;
bioOpen: boolean;
@@ -94,6 +92,8 @@ interface AlbumHeaderProps {
onEntityRatingChange: (rating: number) => void;
/** `unknown` = probe pending or not run; from `entityRatingSupportByServer`. */
entityRatingSupport: EntityRatingSupportLevel | 'unknown';
/** Offline browse action gates (favorites, download, cache, bio, ratings). */
actionPolicy?: OfflineActionPolicy;
}
export default function AlbumHeader({
@@ -101,7 +101,6 @@ export default function AlbumHeader({
headerArtistRefs,
songs,
coverArtId,
coverServerScope = COVER_SCOPE_ACTIVE,
resolvedCoverUrl,
isStarred,
downloadProgress,
@@ -121,14 +120,16 @@ export default function AlbumHeader({
entityRatingValue,
onEntityRatingChange,
entityRatingSupport,
actionPolicy,
}: AlbumHeaderProps) {
const policy = actionPolicy ?? offlineActionPolicy('albumDetail', false);
const { t } = useTranslation();
const navigate = useNavigate();
const goBack = useAlbumDetailBack();
const isMobile = useIsMobile();
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
const coverRef = useAlbumCoverRef(info.id, coverArtId, coverServerScope, { libraryResolve: true });
const coverRef = useAlbumCoverRef(info.id, coverArtId, undefined, { libraryResolve: true });
const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverRef, {
alt: `${info.name} Cover`,
});
@@ -226,7 +227,7 @@ export default function AlbumHeader({
<StarRating
value={entityRatingValue}
onChange={onEntityRatingChange}
disabled={entityRatingSupport === 'track_only'}
disabled={!policy.canRate || entityRatingSupport === 'track_only'}
labelKey="entityRating.albumAriaLabel"
/>
</div>
@@ -254,14 +255,16 @@ export default function AlbumHeader({
{/* Row 2 — Secondary actions */}
<div className="album-actions-row album-actions-row--secondary">
<button
className={`album-icon-btn album-icon-btn--sm${isStarred ? ' is-starred' : ''}`}
onClick={onToggleStar}
aria-label={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
</button>
{policy.canFavorite && (
<button
className={`album-icon-btn album-icon-btn--sm${isStarred ? ' is-starred' : ''}`}
onClick={onToggleStar}
aria-label={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
</button>
)}
<button
className="album-icon-btn album-icon-btn--sm"
@@ -273,7 +276,7 @@ export default function AlbumHeader({
<Share2 size={16} />
</button>
{showBioButton && (
{showBioButton && policy.canShowBio && (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onBio}
@@ -284,44 +287,57 @@ export default function AlbumHeader({
</button>
)}
{downloadProgress !== null ? (
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
<Download size={14} />
<span className="album-icon-btn-pct">{downloadProgress}%</span>
</div>
) : (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onDownload}
aria-label={t('albumDetail.download')}
data-tooltip={t('albumDetail.download')}
>
<Download size={16} />
</button>
{policy.canDownload && (
downloadProgress !== null ? (
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
<Download size={14} />
<span className="album-icon-btn-pct">{downloadProgress}%</span>
</div>
) : (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onDownload}
aria-label={t('albumDetail.download')}
data-tooltip={t('albumDetail.download')}
>
<Download size={16} />
</button>
)
)}
{offlineStatus === 'downloading' ? (
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
<Loader2 size={14} className="spin" />
</div>
) : offlineStatus === 'cached' ? (
<button
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
onClick={onRemoveOffline}
aria-label={t('albumDetail.offlineCached')}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
</button>
) : (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onCacheOffline}
aria-label={t('albumDetail.cacheOffline')}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
</button>
{policy.canPinOffline && (
offlineStatus === 'downloading' ? (
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
<Loader2 size={14} className="spin" />
</div>
) : offlineStatus === 'queued' ? (
<button
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
onClick={onCacheOffline}
aria-label={t('albumDetail.offlineQueued')}
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
>
<HardDriveDownload size={16} />
</button>
) : offlineStatus === 'cached' ? (
<button
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
onClick={onRemoveOffline}
aria-label={t('albumDetail.offlineCached')}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
</button>
) : (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onCacheOffline}
aria-label={t('albumDetail.cacheOffline')}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
</button>
)
)}
</div>
</div>
@@ -352,13 +368,15 @@ export default function AlbumHeader({
>
<ListPlus size={16} />
</button>
<button
className={`btn btn-surface${isStarred ? ' is-starred' : ''}`}
onClick={onToggleStar}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
</button>
{policy.canFavorite && (
<button
className={`btn btn-surface${isStarred ? ' is-starred' : ''}`}
onClick={onToggleStar}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
</button>
)}
<button
type="button"
className="btn btn-surface"
@@ -370,7 +388,7 @@ export default function AlbumHeader({
</button>
</div>
{showBioButton && (
{showBioButton && policy.canShowBio && (
<button
className="btn btn-surface"
id="album-bio-btn"
@@ -381,47 +399,60 @@ export default function AlbumHeader({
</button>
)}
{downloadProgress !== null ? (
<div className="download-progress-wrap">
<Download size={14} />
<div className="download-progress-bar">
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
{policy.canDownload && (
downloadProgress !== null ? (
<div className="download-progress-wrap">
<Download size={14} />
<div className="download-progress-bar">
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
</div>
<span className="download-progress-pct">{downloadProgress}%</span>
</div>
<span className="download-progress-pct">{downloadProgress}%</span>
</div>
) : (
<button
className="btn btn-surface"
id="album-download-btn"
onClick={onDownload}
{...tooltipAttrs(t('albumDetail.downloadTooltip'))}
>
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}
</button>
) : (
<button
className="btn btn-surface"
id="album-download-btn"
onClick={onDownload}
{...tooltipAttrs(t('albumDetail.downloadTooltip'))}
>
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}
</button>
)
)}
{offlineStatus === 'downloading' && offlineProgress ? (
<div className="offline-cache-btn offline-cache-btn--progress">
<Loader2 size={14} className="spin" />
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
</div>
) : offlineStatus === 'cached' ? (
<button
className="btn btn-surface offline-cache-btn offline-cache-btn--cached"
onClick={onRemoveOffline}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.offlineCached')}
</button>
) : (
<button
className="btn btn-surface offline-cache-btn"
onClick={onCacheOffline}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.cacheOffline')}
</button>
{policy.canPinOffline && (
offlineStatus === 'downloading' && offlineProgress ? (
<div className="offline-cache-btn offline-cache-btn--progress">
<Loader2 size={14} className="spin" />
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
</div>
) : offlineStatus === 'queued' ? (
<button
className="btn btn-surface offline-cache-btn offline-cache-btn--queued"
onClick={onCacheOffline}
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
>
<HardDriveDownload size={16} />
{t('albumDetail.offlineQueued')}
</button>
) : offlineStatus === 'cached' ? (
<button
className="btn btn-surface offline-cache-btn offline-cache-btn--cached"
onClick={onRemoveOffline}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.offlineCached')}
</button>
) : (
<button
className="btn btn-surface offline-cache-btn"
onClick={onCacheOffline}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.cacheOffline')}
</button>
)
)}
</div>
)}
+1 -1
View File
@@ -251,7 +251,7 @@ export default function AlbumRow({
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{uniqueAlbums.map((a, idx) => (
<AlbumCard
key={a.id}
key={a.serverId ? `${a.serverId}:${a.id}` : a.id}
album={a}
showRating={showRating}
linkQuery={albumLinkQuery}
+12 -3
View File
@@ -1,6 +1,6 @@
import type { SubsonicSong } from '../api/subsonicTypes';
import type { Track } from '../store/playerStoreTypes';
import React, { useState, useEffect } from 'react';
import React, { useMemo, useState, useEffect } from 'react';
import { useTracklistColumns } from '../utils/useTracklistColumns';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
@@ -15,6 +15,7 @@ import { TrackRow } from './albumTrackList/TrackRow';
import { AlbumTrackListMobile } from './albumTrackList/AlbumTrackListMobile';
import { TracklistColumnPicker } from './albumTrackList/TracklistColumnPicker';
import { TracklistHeaderRow } from './albumTrackList/TracklistHeaderRow';
import { offlineActionPolicy, type OfflineActionPolicy } from '../utils/offline/offlineActionPolicy';
export type { SortKey } from '../utils/componentHelpers/albumTrackListHelpers';
@@ -38,6 +39,7 @@ interface AlbumTrackListProps {
sortKey?: SortKey;
sortDir?: 'asc' | 'desc';
onSort?: (key: SortKey) => void;
actionPolicy?: OfflineActionPolicy;
}
// ── AlbumTrackList ────────────────────────────────────────────────────────────
@@ -60,7 +62,9 @@ export default function AlbumTrackList({
sortKey,
sortDir,
onSort,
actionPolicy,
}: AlbumTrackListProps) {
const policy = actionPolicy ?? offlineActionPolicy('trackRow', false);
const { t } = useTranslation();
const isMobile = useIsMobile();
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
@@ -98,6 +102,10 @@ export default function AlbumTrackList({
);
const currentTrackId = currentTrack?.id ?? null;
const displayCols = useMemo(
() => (policy.canFavorite ? visibleCols : visibleCols.filter(c => c.key !== 'favorite')),
[policy.canFavorite, visibleCols],
);
if (isMobile) {
return (
@@ -139,7 +147,7 @@ export default function AlbumTrackList({
>
<TracklistHeaderRow
visibleCols={visibleCols}
visibleCols={displayCols}
gridStyle={gridStyle}
sortKey={sortKey}
sortDir={sortDir}
@@ -170,7 +178,7 @@ export default function AlbumTrackList({
key={song.id}
song={song}
globalIdx={globalIdx}
visibleCols={visibleCols}
visibleCols={displayCols}
gridStyle={gridStyle}
currentTrackId={currentTrackId}
isPlaying={isPlaying}
@@ -186,6 +194,7 @@ export default function AlbumTrackList({
onToggleSelect={onToggleSelect}
onDragStart={onDragStart}
setContextMenuSongId={setContextMenuSongId}
actionPolicy={policy}
/>
);
})}
+10 -9
View File
@@ -1,11 +1,13 @@
import type { SubsonicArtist } from '../api/subsonicTypes';
import React from 'react';
import React, { useMemo } from 'react';
import { Users } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { CoverArtImage } from '../cover/CoverArtImage';
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
import { coverServerScopeForServerId } from '../cover/serverScope';
import { appendServerQuery } from '../utils/navigation/detailServerScope';
interface Props {
artist: SubsonicArtist;
@@ -18,18 +20,17 @@ interface Props {
export default function ArtistCardLocal({ artist, linkQuery, libraryResolve = false }: Props) {
const { t } = useTranslation();
const navigateToArtist = useNavigateToArtist();
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, undefined, {
libraryResolve,
clusterSeedServerId: artist.clusterSeedServerId,
});
const coverServerScope = useMemo(
() => coverServerScopeForServerId(artist.serverId),
[artist.serverId],
);
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, coverServerScope, { libraryResolve });
const artistLinkQuery = appendServerQuery(linkQuery, artist.serverId);
return (
<div
className="artist-card"
onClick={() => navigateToArtist(artist.id, {
search: linkQuery,
seedServerId: artist.clusterSeedServerId,
})}
onClick={() => navigateToArtist(artist.id, artistLinkQuery ? { search: artistLinkQuery } : undefined)}
>
<div className="artist-card-avatar">
{coverRef ? (
+1 -1
View File
@@ -117,7 +117,7 @@ export default function ArtistRow({
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{artists.map(a => (
<ArtistCardLocal
key={a.id}
key={a.serverId ? `${a.serverId}:${a.id}` : a.id}
artist={a}
linkQuery={artistLinkQuery}
libraryResolve={libraryResolve}
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { ArrowUp } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
interface BackToTopButtonProps {
/** Id of the scroll viewport to watch/scroll. Defaults to the main route scroller. */
viewportId?: string;
/** Show the button once the viewport is scrolled past this many pixels. */
threshold?: number;
}
/**
* A floating "back to top" affordance for long pages. Watches a scroll viewport
* (the overlay-scroll element wrapping the routes by default) and, once it is
* scrolled past `threshold`, shows a button that smooth-scrolls it back to the
* top reusing the same `getElementById(...).scrollTo` pattern AppShell uses
* for its route-change scroll reset.
*
* The button is portalled into `.app-shell-route-host` and positioned
* `absolute` against it: the scroll viewport itself sets `contain: paint`, which
* would otherwise make a `position: fixed` child resolve against the *scrolling*
* box (so it would drift with the content). The route host is a non-contained,
* `position: relative` ancestor that spans exactly the content area (between the
* sidebar and queue, above the player bar), so the button stays pinned to the
* visible viewport corner regardless of scroll.
*/
export default function BackToTopButton({
viewportId = APP_MAIN_SCROLL_VIEWPORT_ID,
threshold = 400,
}: BackToTopButtonProps) {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [host, setHost] = useState<HTMLElement | null>(null);
useEffect(() => {
setHost(document.querySelector<HTMLElement>('.app-shell-route-host'));
const el = document.getElementById(viewportId);
if (!el) return;
const onScroll = () => setVisible(el.scrollTop > threshold);
onScroll(); // sync immediately (e.g. switching back to an already-scrolled tab)
el.addEventListener('scroll', onScroll, { passive: true });
return () => el.removeEventListener('scroll', onScroll);
}, [viewportId, threshold]);
if (!visible || !host) return null;
return createPortal(
<button
type="button"
className="back-to-top-btn"
onClick={() =>
document.getElementById(viewportId)?.scrollTo({ top: 0, behavior: 'smooth' })
}
aria-label={t('common.backToTop')}
data-tooltip={t('common.backToTop')}
data-tooltip-pos="left"
>
<ArrowUp size={18} aria-hidden="true" />
</button>,
host,
);
}
+8 -9
View File
@@ -1,11 +1,11 @@
import { getArtist, getArtistInfo } from '../api/subsonicArtists';
import { filterAlbumsToActiveLibrary, getAlbum } from '../api/subsonicLibrary';
import { filterAlbumsToActiveLibrary } from '../api/subsonicLibrary';
import { resolveAlbum, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import { shuffleArray } from '../utils/playback/shuffleArray';
import React, { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
import { useTranslation } from 'react-i18next';
import { Play, ListPlus, Music } from 'lucide-react';
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
@@ -585,23 +585,22 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e
onLongPress: () => playAlbumShuffled(album.id),
});
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum();
const enqueue = usePlayerStore(s => s.enqueue);
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, {
libraryResolve: false,
clusterSeedServerId: album.clusterSeedServerId,
});
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve: false });
const coverHandle = useCoverArt(coverRef, BECAUSE_CARD_COVER_CSS_PX, {
surface: 'dense',
ensurePriority: 'high',
});
const imgSrc = coverImgSrc(coverHandle.src);
const bgResolved = coverHandle.src;
const handleOpen = () => navigateToAlbum(album.id, { seedServerId: album.clusterSeedServerId });
const handleOpen = () => navigate(`/album/${album.id}`);
const handleEnqueue = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
const data = await getAlbum(album.id);
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
const data = await resolveAlbum(serverId, album.id);
if (!data) return;
enqueue(data.songs.map(songToTrack));
} catch {
/* silent — toast would be too noisy for a hover action */
+1 -1
View File
@@ -247,7 +247,7 @@ export default function CachedImage({
};
const fallbackStyle: React.CSSProperties = isFallback
? { objectFit: 'contain', background: 'var(--bg-card, var(--ctp-surface0, #313244))', padding: '15%' }
? { objectFit: 'contain', background: 'var(--bg-card, var(--bg-card, #313244))', padding: '15%' }
: {};
return (
-32
View File
@@ -1,32 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { ServerCluster } from '../utils/serverCluster/types';
import {
formatExcludedMemberLabels,
getClusterMergeDiagnostics,
type ClusterMergeDiagnostics,
} from '../utils/serverCluster/clusterMergeStatus';
export default function ClusterMergeBanner({ cluster }: { cluster: ServerCluster }) {
const { t } = useTranslation();
const [diag, setDiag] = useState<ClusterMergeDiagnostics | null>(null);
useEffect(() => {
let cancelled = false;
void getClusterMergeDiagnostics(cluster).then(res => {
if (!cancelled) setDiag(res);
}).catch(() => {
if (!cancelled) setDiag(null);
});
return () => { cancelled = true; };
}, [cluster]);
if (!diag || diag.mergeCount >= diag.totalCount) return null;
return (
<div className="connection-indicator-cluster-banner">
{t('cluster.mergeBanner', {
excluded: formatExcludedMemberLabels(diag.members),
})}
</div>
);
}
+38 -116
View File
@@ -3,14 +3,12 @@ import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Check, ChevronDown, Layers } from 'lucide-react';
import { Check, ChevronDown } from 'lucide-react';
import { ConnectionStatus } from '../hooks/useConnectionStatus';
import { useClusterConnectionLed } from '../hooks/useClusterConnectionLed';
import { useAuthStore } from '../store/authStore';
import { switchActiveCluster, switchActiveServer } from '../utils/server/switchActiveServer';
import { switchActiveServer } from '../utils/server/switchActiveServer';
import { showToast } from '../utils/ui/toast';
import { serverListDisplayLabel } from '../utils/server/serverDisplayName';
import type { ServerCluster } from '../utils/serverCluster/types';
interface Props {
status: ConnectionStatus;
@@ -22,22 +20,14 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
const { t } = useTranslation();
const navigate = useNavigate();
const servers = useAuthStore(s => s.servers);
const clusters = useAuthStore(s => s.clusters);
const activeServerId = useAuthStore(s => s.activeServerId);
const activeClusterId = useAuthStore(s => s.activeClusterId);
const [menuOpen, setMenuOpen] = useState(false);
const [switchingId, setSwitchingId] = useState<string | null>(null);
const [menuFixed, setMenuFixed] = useState({ top: 0, right: 0 });
const hostRef = useRef<HTMLDivElement>(null);
const menuPanelRef = useRef<HTMLDivElement>(null);
const multi = servers.length > 1 || clusters.length > 0;
const activeCluster = activeClusterId
? clusters.find(cluster => cluster.id === activeClusterId) ?? null
: null;
const clusterLed = useClusterConnectionLed(activeCluster);
const effectiveStatus =
activeCluster && clusterLed.ledStatus !== null ? clusterLed.ledStatus : status;
const multi = servers.length > 1;
const updateMenuPosition = useCallback(() => {
const el = hostRef.current;
@@ -91,7 +81,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
};
const onPickServer = async (srv: ServerProfile) => {
if (!activeClusterId && srv.id === activeServerId) {
if (srv.id === activeServerId) {
setMenuOpen(false);
return;
}
@@ -106,59 +96,14 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
navigate('/');
};
const onPickCluster = async (cluster: ServerCluster) => {
if (cluster.id === activeClusterId) {
setMenuOpen(false);
return;
}
setSwitchingId(cluster.id);
const ok = await switchActiveCluster(cluster.id);
setSwitchingId(null);
setMenuOpen(false);
if (!ok) {
showToast(t('connection.switchFailed'), 5000, 'error');
return;
}
navigate('/');
};
const label = isLan ? 'LAN' : t('connection.extern');
const metaTooltip = multi ? t('connection.switchScopeHint') : undefined;
const statusTooltip =
effectiveStatus === 'connected'
const tooltip = multi
? t('connection.switchServerHint')
: status === 'connected'
? t('connection.connectedTo', { server: serverName })
: effectiveStatus === 'disconnected'
: status === 'disconnected'
? t('connection.disconnectedFrom', { server: serverName })
: effectiveStatus === 'degraded'
? t('connection.connectedTo', { server: serverName })
: t('connection.checking');
const clusterActive = Boolean(activeCluster);
const ledTooltip = clusterActive
? (clusterLed.ledTooltip ?? t('connection.checking'))
: statusTooltip;
const renderMenuItem = (id: string, labelText: string, active: boolean, onClick: () => void, icon?: React.ReactNode) => (
<button
key={id}
type="button"
role="menuitem"
className={`nav-library-dropdown-item${active ? ' nav-library-dropdown-item--selected' : ''}`}
disabled={switchingId === id}
onClick={onClick}
>
<span className="nav-library-dropdown-item-label" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{icon}
{labelText}
</span>
{switchingId === id ? (
<div className="spinner" style={{ width: 14, height: 14, flexShrink: 0 }} aria-hidden />
) : active ? (
<Check size={16} className="nav-library-dropdown-check" aria-hidden />
) : (
<span className="nav-library-dropdown-check-spacer" aria-hidden />
)}
</button>
);
: t('connection.checking');
return (
<div className="connection-indicator-host" ref={hostRef}>
@@ -166,21 +111,14 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
className="connection-indicator"
style={{ cursor: 'pointer' }}
onClick={onTriggerClick}
data-tooltip={tooltip}
data-tooltip-pos="bottom"
role={multi ? 'button' : undefined}
aria-haspopup={multi ? 'menu' : undefined}
aria-expanded={multi ? menuOpen : undefined}
>
<div
className={`connection-led connection-led--${effectiveStatus}`}
data-tooltip={ledTooltip}
data-tooltip-pos="bottom"
{...(clusterActive ? { 'data-tooltip-wrap': true } : {})}
/>
<div
className="connection-meta"
data-tooltip={metaTooltip}
data-tooltip-pos="bottom"
>
<div className={`connection-led connection-led--${status}`} />
<div className="connection-meta">
<span className="connection-type">{label}</span>
<span className="connection-server" style={{ display: 'flex', alignItems: 'center', gap: 4, maxWidth: 120 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{serverName}</span>
@@ -198,7 +136,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
ref={menuPanelRef}
className="nav-library-dropdown-panel connection-indicator-dropdown-panel"
role="menu"
aria-label={t('connection.switchScopeTitle')}
aria-label={t('connection.switchServerTitle')}
style={{
position: 'fixed',
top: menuFixed.top,
@@ -208,38 +146,6 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
zIndex: 10050,
}}
>
{clusters.length > 0 && (
<>
<div
style={{
fontSize: 10,
fontWeight: 600,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: 'var(--text-muted)',
padding: '6px 10px 4px',
}}
>
{t('connection.switchClusterTitle')}
</div>
{clusters.map(cluster =>
renderMenuItem(
cluster.id,
cluster.name,
activeClusterId === cluster.id,
() => void onPickCluster(cluster),
<Layers size={14} style={{ flexShrink: 0, opacity: 0.85 }} aria-hidden />,
),
)}
<div
style={{
borderTop: '1px solid color-mix(in srgb, var(--text-muted) 15%, transparent)',
marginTop: 2,
paddingTop: 2,
}}
/>
</>
)}
<div
style={{
fontSize: 10,
@@ -252,14 +158,30 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
>
{t('connection.switchServerTitle')}
</div>
{servers.map(srv =>
renderMenuItem(
srv.id,
serverListDisplayLabel(srv, servers),
!activeClusterId && srv.id === activeServerId,
() => void onPickServer(srv),
),
)}
{servers.map(srv => {
const active = srv.id === activeServerId;
const busy = switchingId === srv.id;
const labelText = serverListDisplayLabel(srv, servers);
return (
<button
key={srv.id}
type="button"
role="menuitem"
className={`nav-library-dropdown-item${active ? ' nav-library-dropdown-item--selected' : ''}`}
disabled={busy}
onClick={() => onPickServer(srv)}
>
<span className="nav-library-dropdown-item-label">{labelText}</span>
{switchingId === srv.id ? (
<div className="spinner" style={{ width: 14, height: 14, flexShrink: 0 }} aria-hidden />
) : active ? (
<Check size={16} className="nav-library-dropdown-check" aria-hidden />
) : (
<span className="nav-library-dropdown-check-spacer" aria-hidden />
)}
</button>
);
})}
<div
style={{
borderTop: '1px solid color-mix(in srgb, var(--text-muted) 15%, transparent)',
+17
View File
@@ -43,6 +43,23 @@ vi.mock('@/utils/orbitBulkGuard', () => ({
orbitBulkGuard: vi.fn(async () => true),
}));
vi.mock('@/hooks/useOfflineBrowseContext', () => ({
useOfflineBrowseContext: () => ({
active: false,
serverId: 'srv-1',
capabilities: {
localLibrary: false,
favorites: false,
playlists: false,
manualPins: false,
playerStats: false,
},
hasBrowseCapability: false,
hasBrowsingContent: false,
connStatus: 'connected' as const,
}),
}));
import ContextMenu from './ContextMenu';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { usePlayerStore } from '@/store/playerStore';
+28
View File
@@ -17,8 +17,29 @@ import { useContextMenuKeyboardNav } from '../hooks/useContextMenuKeyboardNav';
import { useContextMenuRating } from '../hooks/useContextMenuRating';
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
import { useNavigate } from 'react-router-dom';
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
import {
offlineActionPolicy,
type OfflineSurface,
} from '../utils/offline/offlineActionPolicy';
import ContextMenuItems from './contextMenu/ContextMenuItems';
function contextMenuSurfaceForType(type: string | null): OfflineSurface {
switch (type) {
case 'album':
case 'multi-album':
return 'contextMenuAlbum';
case 'artist':
case 'multi-artist':
return 'contextMenuArtist';
case 'playlist':
case 'multi-playlist':
return 'contextMenuPlaylist';
default:
return 'contextMenuSong';
}
}
export { AddToPlaylistSubmenu };
@@ -180,6 +201,12 @@ export default function ContextMenu() {
const downloadAlbum = downloadAlbumAction;
const { active: offlineBrowseActive } = useOfflineBrowseContext();
const offlinePolicy = offlineActionPolicy(
contextMenuSurfaceForType(type),
offlineBrowseActive,
);
if (!contextMenu.isOpen || !contextMenu.item) return null;
return (
@@ -233,6 +260,7 @@ export default function ContextMenu() {
isStarred={isStarred}
pinToPlaybackServer={pinToPlaybackServer}
navigateLibrary={navigateLibrary}
offlinePolicy={offlinePolicy}
/>
</div>
</>
+23
View File
@@ -0,0 +1,23 @@
import { Cloud, CloudOff } from 'lucide-react';
import { useDevOfflineBrowseStore } from '../store/devOfflineBrowseStore';
/** DEV-only: simulate full offline (disconnect UI, block Subsonic, local playback only). */
export default function DevNetworkModeToggle() {
const forceOffline = useDevOfflineBrowseStore(s => s.forceOffline);
const toggle = useDevOfflineBrowseStore(s => s.toggleForceOffline);
if (!import.meta.env.DEV) return null;
return (
<button
type="button"
className={`dev-network-mode-toggle${forceOffline ? ' dev-network-mode-toggle--offline' : ''}`}
onClick={toggle}
title={forceOffline ? 'DEV: forced offline (click for online)' : 'DEV: online (click to simulate offline)'}
aria-pressed={forceOffline}
>
{forceOffline ? <CloudOff size={16} aria-hidden /> : <Cloud size={16} aria-hidden />}
<span>{forceOffline ? 'Offline' : 'Online'}</span>
</button>
);
}
-188
View File
@@ -1,188 +0,0 @@
/**
* `FullscreenPlayer` characterization (Phase F5c).
*
* Includes the §4.5 regression test from the v2 plan the cover image
* must call `useCachedUrl(coverUrl, coverKey, false)`. The `false`
* third argument selects the fallback path that avoids a double
* crossfade (fetchUrl blobUrl). A refactor that "tidies up" the
* useCachedUrl call sites would silently regress the FS player.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/api/subsonic', () => ({
savePlayQueue: vi.fn(async () => undefined),
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
coverArtCacheKey: vi.fn((id: string, size = 256) => `mock:cover:${id}:${size}`),
getSong: vi.fn(async () => null),
getRandomSongs: vi.fn(async () => []),
getSimilarSongs2: vi.fn(async () => []),
getTopSongs: vi.fn(async () => []),
getAlbumInfo2: vi.fn(async () => null),
reportNowPlaying: vi.fn(async () => undefined),
scrobbleSong: vi.fn(async () => undefined),
star: vi.fn(async () => undefined),
unstar: vi.fn(async () => undefined),
getLyricsBySongId: vi.fn(async () => null),
}));
vi.mock('@/api/lastfm', () => ({
lastfmScrobble: vi.fn(async () => undefined),
lastfmUpdateNowPlaying: vi.fn(async () => undefined),
lastfmLoveTrack: vi.fn(async () => undefined),
lastfmUnloveTrack: vi.fn(async () => undefined),
lastfmGetTrackLoved: vi.fn(async () => false),
lastfmGetAllLovedTracks: vi.fn(async () => []),
}));
// `useCachedUrl` is the surface §4.5 needs to characterize. Mock the module
// so we can assert the third positional arg `false` is preserved.
vi.mock('./CachedImage', async () => {
const actual = await vi.importActual<typeof import('./CachedImage')>('./CachedImage');
return {
...actual,
useCachedUrl: vi.fn((url, _key, opt) => `mock://${url}?opt=${String(opt ?? 'default')}`),
};
});
import FullscreenPlayer from './FullscreenPlayer';
import { useCachedUrl } from './CachedImage';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { usePlayerStore } from '@/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import { resetAllStores } from '@/test/helpers/storeReset';
import { makeTrack, seedQueue } from '@/test/helpers/factories';
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
import { fireEvent } from '@testing-library/react';
beforeEach(() => {
resetAllStores();
const id = useAuthStore.getState().addServer({
name: 'T', url: 'https://x.test', username: 'u', password: 'p',
});
useAuthStore.getState().setActiveServer(id);
vi.mocked(useCachedUrl).mockClear();
registerDefaultCoverInvokeHandlers();
onInvoke('audio_play', () => undefined);
onInvoke('audio_pause', () => undefined);
onInvoke('audio_stop', () => undefined);
onInvoke('audio_seek', () => undefined);
onInvoke('audio_get_state', () => ({ playing: false }));
onInvoke('audio_update_replay_gain', () => undefined);
onInvoke('discord_update_presence', () => undefined);
});
afterEach(() => {
vi.mocked(useCachedUrl).mockClear();
});
describe('FullscreenPlayer — render', () => {
it('renders the labelled Fullscreen Player dialog', () => {
usePlayerStore.setState({ currentTrack: makeTrack({ coverArt: 'art-1' }) });
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={() => {}} />,
);
expect(getByLabelText('Fullscreen Player')).toBeInTheDocument();
});
it('exposes the Close Fullscreen button', () => {
usePlayerStore.setState({ currentTrack: makeTrack() });
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={() => {}} />,
);
expect(getByLabelText('Close Fullscreen')).toBeInTheDocument();
});
});
describe('FullscreenPlayer — regression §4.5 of v2 plan', () => {
// The component calls `useCachedUrl` twice:
// - line 338: for the small art box (default behaviour, opt=true)
// - line 674: for the cover (opt=false, no fetchUrl fallback)
// The `false` arg is load-bearing — it avoids a double crossfade by
// routing through the cache-only path. Pin it.
it('passes opt=false on the cover-art useCachedUrl call (no fetchUrl fallback)', () => {
usePlayerStore.setState({
currentTrack: makeTrack({ coverArt: 'art-1', albumId: 'album-1' }),
});
renderWithProviders(<FullscreenPlayer onClose={() => {}} />);
const calls = vi.mocked(useCachedUrl).mock.calls;
const coverCall = calls.find(c => c[2] === false);
expect(coverCall).toBeDefined();
expect(typeof coverCall?.[1]).toBe('string');
expect(String(coverCall?.[1])).toContain('album-1');
});
it('also issues a useCachedUrl call with the default behaviour for the small art box', () => {
usePlayerStore.setState({
currentTrack: makeTrack({ coverArt: 'art-1', albumId: 'album-1' }),
});
renderWithProviders(<FullscreenPlayer onClose={() => {}} />);
const calls = vi.mocked(useCachedUrl).mock.calls;
const defaultOptCalls = calls.filter(c => c[2] !== false);
expect(defaultOptCalls.length).toBeGreaterThanOrEqual(1);
expect(defaultOptCalls.some(c => String(c[1]).includes('album-1'))).toBe(true);
});
});
describe('FullscreenPlayer — control wiring', () => {
it('clicking Close Fullscreen calls the onClose prop', () => {
usePlayerStore.setState({ currentTrack: makeTrack() });
const onClose = vi.fn();
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={onClose} />,
);
fireEvent.click(getByLabelText('Close Fullscreen'));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('clicking Stop calls stop()', () => {
usePlayerStore.setState({ currentTrack: makeTrack() });
const stopSpy = vi.spyOn(usePlayerStore.getState(), 'stop');
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={() => {}} />,
);
fireEvent.click(getByLabelText('Stop'));
expect(stopSpy).toHaveBeenCalledTimes(1);
});
it('clicking Previous Track calls previous()', () => {
seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
index: 1,
currentTrack: makeTrack({ id: 'b' }),
});
usePlayerStore.setState({ currentTime: 5 });
const prevSpy = vi.spyOn(usePlayerStore.getState(), 'previous');
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={() => {}} />,
);
fireEvent.click(getByLabelText('Previous Track'));
expect(prevSpy).toHaveBeenCalledTimes(1);
});
it('clicking Next Track calls next()', () => {
seedQueue([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], {
index: 0,
currentTrack: makeTrack({ id: 'a' }),
});
const nextSpy = vi.spyOn(usePlayerStore.getState(), 'next');
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={() => {}} />,
);
fireEvent.click(getByLabelText('Next Track'));
expect(nextSpy).toHaveBeenCalledTimes(1);
});
it('clicking Repeat cycles via toggleRepeat', () => {
usePlayerStore.setState({ currentTrack: makeTrack() });
const { getByLabelText } = renderWithProviders(
<FullscreenPlayer onClose={() => {}} />,
);
expect(usePlayerStore.getState().repeatMode).toBe('off');
fireEvent.click(getByLabelText('Repeat'));
expect(usePlayerStore.getState().repeatMode).toBe('all');
});
});
-228
View File
@@ -1,228 +0,0 @@
import { queueSongStar } from '../store/pendingStarSync';
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
import { usePlaybackTrackCoverRef } from '../cover/useLibraryCoverRef';
import { playbackCoverArtForAlbum } from '../utils/playback/playbackServer';
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
import {
SkipBack, SkipForward,
ChevronDown, Repeat, Repeat1, Square, Heart, MicVocal,
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useCachedUrl } from './CachedImage';
import { getCachedBlob } from '../utils/imageCache';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import { FsLyricsApple } from './fullscreenPlayer/FsLyricsApple';
import { FsLyricsRail } from './fullscreenPlayer/FsLyricsRail';
import { FsArt } from './fullscreenPlayer/FsArt';
import { FsPortrait } from './fullscreenPlayer/FsPortrait';
import { FsSeekbar } from './fullscreenPlayer/FsSeekbar';
import { FsLyricsMenu } from './fullscreenPlayer/FsLyricsMenu';
import { FsPlayBtn } from './fullscreenPlayer/FsPlayBtn';
import { useFsDynamicAccent } from '../hooks/useFsDynamicAccent';
import { useFsArtistPortrait } from '../hooks/useFsArtistPortrait';
import { useFsIdleFade } from '../hooks/useFsIdleFade';
import { useQueueTrackAt } from '../hooks/useQueueTracks';
interface FullscreenPlayerProps {
onClose: () => void;
}
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const { t } = useTranslation();
const currentTrack = usePlayerStore(s => s.currentTrack);
const repeatMode = usePlayerStore(s => s.repeatMode);
const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous);
const stop = usePlayerStore(s => s.stop);
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
// Derive isStarred inside the selector so we only re-render when the boolean
// actually flips — not when any unrelated track's star status changes.
const isStarred = usePlayerStore(s => {
const track = s.currentTrack;
if (!track) return false;
return track.id in s.starredOverrides ? s.starredOverrides[track.id] : !!track.starred;
});
const toggleStar = useCallback(() => {
if (!currentTrack) return;
queueSongStar(currentTrack.id, !isStarred);
}, [currentTrack, isStarred]);
const duration = currentTrack?.duration ?? 0;
const playbackCoverRef = usePlaybackTrackCoverRef(currentTrack ?? undefined);
const artCover = usePlaybackCoverArt(playbackCoverRef, 300);
const artUrl = artCover.src;
const artKey = artCover.cacheKey;
const portraitCover = usePlaybackCoverArt(playbackCoverRef, 500);
const coverUrl = portraitCover.src;
const coverKey = portraitCover.cacheKey;
// `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl).
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
// Dynamic accent color extracted from the current album cover, applied as
// --dynamic-fs-accent on the root element. Cache hits return instantly so
// same-album tracks reuse the color without re-fetching.
const dynamicAccent = useFsDynamicAccent(artUrl, artKey);
// Artist image → portrait on right. Falls back to cover art.
const artistBgUrl = useFsArtistPortrait(currentTrack?.artistId);
const portraitUrl = artistBgUrl || resolvedCoverUrl;
const showFullscreenLyrics = useAuthStore(s => s.showFullscreenLyrics);
const fsLyricsStyle = useAuthStore(s => s.fsLyricsStyle);
const showFsArtistPortrait = useAuthStore(s => s.showFsArtistPortrait);
const fsPortraitDim = useAuthStore(s => s.fsPortraitDim);
const isAppleMode = showFullscreenLyrics && fsLyricsStyle === 'apple';
// Pre-fetch next track's 300px cover into the IndexedDB cache. Resolver-first
// (thin-state): the next ref resolves from the cache (the prefetch window
// around the current index keeps it warm).
const queueIndex = usePlayerStore(s => s.queueIndex);
const nextTrack = useQueueTrackAt(queueIndex + 1);
const queueServerId = usePlayerStore(s => s.queueServerId);
const activeServerId = useAuthStore(s => s.activeServerId);
useEffect(() => {
if (!nextTrack?.albumId || !nextTrack.coverArt) return;
const { src: url, cacheKey: key } = playbackCoverArtForAlbum(
nextTrack.albumId,
nextTrack.coverArt,
300,
);
getCachedBlob(url, key).catch(() => {});
}, [nextTrack?.albumId, nextTrack?.coverArt, queueServerId, activeServerId]);
// Lyrics settings popover state
const [lyricsMenuOpen, setLyricsMenuOpen] = useState(false);
const closeLyricsMenu = useCallback(() => setLyricsMenuOpen(false), []);
const lyricsMenuTriggerRef = useRef<HTMLButtonElement>(null);
const fsControlsRef = useRef<HTMLDivElement>(null);
// Idle-fade system — hides controls after 3 s of inactivity; Esc closes.
const { isIdle, handleMouseMove } = useFsIdleFade(onClose);
const metaParts = useMemo(() => [
currentTrack?.album,
currentTrack?.year?.toString(),
currentTrack?.suffix?.toUpperCase(),
currentTrack?.bitRate ? `${currentTrack.bitRate} kbps` : '',
].filter(Boolean), [currentTrack]);
return (
<div
className="fs-player"
role="dialog"
aria-modal="true"
aria-label={t('player.fullscreen')}
data-idle={isIdle}
data-lyrics={isAppleMode || undefined}
onMouseMove={handleMouseMove}
style={{
...(dynamicAccent ? { '--dynamic-fs-accent': dynamicAccent } : {}),
'--fs-portrait-dim': String(fsPortraitDim / 100),
} as React.CSSProperties}
>
{/* Layer 0 — animated dark mesh gradient (real divs = will-change possible) */}
<div className="fs-mesh-bg" aria-hidden="true">
<div className="fs-mesh-blob fs-mesh-blob-a" />
<div className="fs-mesh-blob fs-mesh-blob-b" />
</div>
{/* Layer 1 — artist portrait, right half; hidden in lyrics mode */}
{showFsArtistPortrait && <FsPortrait url={portraitUrl} />}
{/* Layer 2 — horizontal scrim: dark left → transparent right */}
<div className="fs-scrim" aria-hidden="true" />
{/* Close */}
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
<ChevronDown size={28} />
</button>
{/* Lyrics: Apple Music-style (scrolling) or classic 5-line rail */}
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <FsLyricsApple currentTrack={currentTrack} />}
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <div className="fsa-fade-top" aria-hidden="true" />}
{showFullscreenLyrics && fsLyricsStyle === 'apple' && <div className="fsa-fade-bottom" aria-hidden="true" />}
{showFullscreenLyrics && fsLyricsStyle === 'rail' && <FsLyricsRail currentTrack={currentTrack} />}
{/* Layer 3 — info cluster, bottom-left */}
<div className="fs-cluster">
{/* Album art */}
<div className="fs-art-wrap">
<FsArt fetchUrl={artUrl} cacheKey={artKey} />
</div>
{/* Track title — massive statement */}
<p className="fs-track-title">{currentTrack?.title ?? '—'}</p>
{/* Artist — secondary, below track */}
<p className="fs-artist-name">{currentTrack?.artist ?? '—'}</p>
{/* Metadata row */}
{metaParts.length > 0 && (
<div className="fs-meta">
{metaParts.map((part, i) => (
<React.Fragment key={i}>
{i > 0 && <span className="fs-meta-dot">·</span>}
<span>{part}</span>
</React.Fragment>
))}
</div>
)}
{/* Controls */}
<div className="fs-controls" ref={fsControlsRef}>
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop" data-tooltip={t('player.stop')}>
<Square size={13} fill="currentColor" />
</button>
<button className="fs-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
<SkipBack size={19} />
</button>
<FsPlayBtn controlsAnchorRef={fsControlsRef} />
<button className="fs-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')}>
<SkipForward size={19} />
</button>
<button
className={`fs-btn fs-btn-sm${repeatMode !== 'off' ? ' active' : ''}`}
onClick={toggleRepeat}
aria-label={t('player.repeat')}
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
>
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
</button>
{currentTrack && (
<button
className={`fs-btn fs-btn-sm fs-btn-heart${isStarred ? ' active' : ''}`}
onClick={toggleStar}
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
>
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
</button>
)}
<div style={{ position: 'relative', zIndex: 9 }}>
<FsLyricsMenu open={lyricsMenuOpen} onClose={closeLyricsMenu} accentColor={dynamicAccent} triggerRef={lyricsMenuTriggerRef} />
<button
ref={lyricsMenuTriggerRef}
className={`fs-btn fs-btn-sm${lyricsMenuOpen ? ' active' : ''}`}
onClick={() => setLyricsMenuOpen(v => !v)}
aria-label={t('player.fsLyricsToggle')}
data-tooltip={lyricsMenuOpen ? undefined : t('player.fsLyricsToggle')}
style={{ color: showFullscreenLyrics ? (dynamicAccent ?? 'var(--accent)') : 'rgba(255,255,255,0.35)' }}
>
<MicVocal size={14} />
</button>
</div>
</div>
</div>
{/* Layer 4 — full-width seekbar, bottom edge */}
<FsSeekbar duration={duration} />
</div>
);
}
+44 -19
View File
@@ -1,4 +1,5 @@
import { getRandomAlbums, getAlbum } from '../api/subsonicLibrary';
import { getRandomAlbums } from '../api/subsonicLibrary';
import { resolveAlbum, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
@@ -33,14 +34,25 @@ function HeroBg({ url }: { url: string }) {
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
url ? [{ url, id: 0, visible: true }] : []
);
const counter = useRef(1);
const counter = useRef(url ? 1 : 0);
const latestUrlRef = useRef(url);
latestUrlRef.current = url;
useEffect(() => {
if (!url) return;
if (!url) {
setLayers([]);
return;
}
const id = counter.current++;
setLayers(prev => [...prev, { url, id, visible: false }]);
const t1 = setTimeout(() => setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id }))), 20);
const t2 = setTimeout(() => setLayers(prev => prev.filter(l => l.id === id)), 900);
const t1 = setTimeout(() => {
if (latestUrlRef.current !== url) return;
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
}, 20);
const t2 = setTimeout(() => {
if (latestUrlRef.current !== url) return;
setLayers(prev => prev.filter(l => l.id === id));
}, 900);
return () => { clearTimeout(t1); clearTimeout(t2); };
}, [url]);
@@ -259,7 +271,13 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const [albumFormats, setAlbumFormats] = useState<Record<string, string>>({});
useEffect(() => {
if (!album || albumFormats[album.id] !== undefined) return;
getAlbum(album.id).then(data => {
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
resolveAlbum(serverId, album.id).then(data => {
if (!data) {
setAlbumFormats(prev => ({ ...prev, [album.id]: '' }));
return;
}
const fmts = [...new Set(data.songs.map(s => s.suffix).filter((f): f is string => !!f))];
setAlbumFormats(prev => ({ ...prev, [album.id]: fmts.map(f => f.toUpperCase()).join(' / ') }));
}).catch(() => {
@@ -267,25 +285,26 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
});
}, [album?.id]);
const heroCoverRef = useAlbumCoverRef(album?.id, album?.coverArt, undefined, {
clusterSeedServerId: album?.clusterSeedServerId,
});
const heroCoverRef = useAlbumCoverRef(album?.id, album?.coverArt);
const bgHandle = useCoverArt(heroCoverRef, HERO_BG_CSS_PX, {
surface: 'dense',
ensurePriority: 'high',
});
// Keep the last known good URL so HeroBg never receives '' during a cache-miss
// transition (which would cause the background to flash empty before fading in).
const stableBgUrl = useRef('');
// Per-album fallback so a cache miss on the current slide does not flash empty,
// but never reuse another album's art (that caused bg/foreground desync on fast nav).
const stableBgByAlbum = useRef<Record<string, string>>({});
const albumId = album?.id;
useEffect(() => {
if (bgHandle.src && albumId) {
stableBgByAlbum.current[albumId] = bgHandle.src;
}
}, [bgHandle.src, albumId]);
const heroBgUrl = bgHandle.src || (albumId ? stableBgByAlbum.current[albumId] ?? '' : '');
const { isHolding, pressBind } = useLongPressAction({
onShortPress: () => { if (albumId) playAlbum(albumId); },
onLongPress: () => { if (albumId) playAlbumShuffled(albumId); },
});
useEffect(() => {
if (bgHandle.src) stableBgUrl.current = bgHandle.src;
}, [bgHandle.src, albumId]);
if (!album) return <div className="hero-placeholder" />;
@@ -295,10 +314,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
className="hero"
role="banner"
aria-label={t('hero.eyebrow')}
onClick={() => navigateToAlbum(album.id, { seedServerId: album.clusterSeedServerId })}
onClick={() => navigateToAlbum(album.id)}
style={{ cursor: 'pointer' }}
>
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={stableBgUrl.current} />}
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={heroBgUrl} />}
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <div className="hero-overlay" aria-hidden="true" />}
{/* key causes re-mount → animate-fade-in triggers on each album change */}
@@ -341,7 +360,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
onClick={async e => {
e.stopPropagation();
try {
const albumData = await getAlbum(album.id);
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
const albumData = await resolveAlbum(serverId, album.id);
if (!albumData) return;
usePlayerStore.getState().enqueue(albumData.songs.map(songToTrack));
} catch (_) {}
}}
@@ -371,7 +393,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
onClick={async (e) => {
e.stopPropagation();
try {
const albumData = await getAlbum(album.id);
const serverId = resolveMediaServerId(album.serverId);
if (!serverId) return;
const albumData = await resolveAlbum(serverId, album.id);
if (!albumData) return;
const tracks = albumData.songs.map(songToTrack);
usePlayerStore.getState().enqueue(tracks);
} catch (_) {}
+17 -35
View File
@@ -23,7 +23,6 @@ import {
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
import { Search, Disc3, Users, Music, TextSearch, Database, Globe } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
@@ -35,7 +34,7 @@ import { AlbumCoverArtImage } from '../cover/AlbumCoverArtImage';
import { ArtistCoverArtImage } from '../cover/ArtistCoverArtImage';
import { CoverArtImage } from '../cover/CoverArtImage';
import { COVER_DENSE_SEARCH_CSS_PX } from '../cover/layoutSizes';
import { albumCoverRef, coverScopeForServerProfileId } from '../cover/ref';
import { albumCoverRef } from '../cover/ref';
import { showToast } from '../utils/ui/toast';
import { useShareSearch } from '../hooks/useShareSearch';
import ShareSearchResults from './search/ShareSearchResults';
@@ -56,20 +55,11 @@ import { resolveIndexKey } from '../utils/server/serverIndexKey';
type LiveSearchSource = 'local' | 'network';
function LiveSearchAlbumThumb({
albumId,
coverArt,
clusterSeedServerId,
}: {
albumId: string;
coverArt: string;
clusterSeedServerId?: string;
}) {
function LiveSearchAlbumThumb({ albumId, coverArt }: { albumId: string; coverArt: string }) {
return (
<AlbumCoverArtImage
albumId={albumId}
coverArt={coverArt}
clusterSeedServerId={clusterSeedServerId}
libraryResolve={false}
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
surface="dense"
@@ -80,19 +70,17 @@ function LiveSearchAlbumThumb({
);
}
function LiveSearchSongThumb({
song,
}: {
song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber' | 'clusterBrowseServerId'>;
}) {
function LiveSearchSongThumb({ song }: { song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'> }) {
// Search results carry the per-track `mf-…` coverArt id, which the cover
// pipeline fails to resolve and the thumbnail goes blank. The album-scoped
// `al-<albumId>_0` id is what actually loads (verified in the RC1 blank-thumb
// investigation), and a song's search thumbnail is its album cover anyway —
// so fetch the album cover from the albumId. Falls back to a music glyph when
// there is no album to key on.
const albumId = song.albumId?.trim();
const scope = React.useMemo(
() => coverScopeForServerProfileId(song.clusterBrowseServerId),
[song.clusterBrowseServerId],
);
const coverRef = React.useMemo(
() => (albumId ? albumCoverRef(albumId, `al-${albumId}_0`, scope) : undefined),
[albumId, scope],
() => (albumId ? albumCoverRef(albumId, `al-${albumId}_0`) : undefined),
[albumId],
);
if (!coverRef) return <div className="search-result-icon"><Music size={14} /></div>;
return (
@@ -107,11 +95,7 @@ function LiveSearchSongThumb({
);
}
function LiveSearchArtistThumb({
artist,
}: {
artist: Pick<SubsonicArtist, 'id' | 'coverArt' | 'clusterSeedServerId'>;
}) {
function LiveSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
const [failed, setFailed] = useState(false);
useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]);
if (failed) return <div className="search-result-icon"><Users size={14} /></div>;
@@ -119,7 +103,6 @@ function LiveSearchArtistThumb({
<ArtistCoverArtImage
artistId={artist.id}
coverArt={artist.coverArt}
clusterSeedServerId={artist.clusterSeedServerId}
libraryResolve={false}
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
surface="dense"
@@ -155,7 +138,6 @@ export default function LiveSearch() {
const liveSearchGenRef = useRef(0);
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum();
const navigateToArtist = useNavigateToArtist();
const enqueue = usePlayerStore(state => state.enqueue);
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const ctxIsOpen = usePlayerStore(state => state.contextMenu.isOpen);
@@ -545,8 +527,8 @@ export default function LiveSearch() {
},
},
] : results ? [
...(results.artists.map(a => ({ id: a.id, action: () => { navigateToArtist(a.id, { seedServerId: a.clusterSeedServerId }); setOpen(false); setQuery(''); } }))),
...(results.albums.map(a => ({ id: a.id, action: () => { navigateToAlbum(a.id, { seedServerId: a.clusterSeedServerId }); setOpen(false); setQuery(''); } }))),
...(results.artists.map(a => ({ id: a.id, action: () => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); } }))),
...(results.albums.map(a => ({ id: a.id, action: () => { navigateToAlbum(a.id); setOpen(false); setQuery(''); } }))),
...(results.songs.map(s => ({ id: s.id, action: () => {
const track = songToTrack(s);
enqueue([track]);
@@ -756,7 +738,7 @@ export default function LiveSearch() {
const isCtxActive = ctxIsOpen && ctxType === 'artist' && ctxItemId === a.id;
return (
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => { navigateToArtist(a.id, { seedServerId: a.clusterSeedServerId }); setOpen(false); setQuery(''); }}
onClick={() => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); }}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, a, 'artist');
@@ -780,14 +762,14 @@ export default function LiveSearch() {
const isCtxActive = ctxIsOpen && ctxType === 'album' && ctxItemId === a.id;
return (
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => { navigateToAlbum(a.id, { seedServerId: a.clusterSeedServerId }); setOpen(false); setQuery(''); }}
onClick={() => { navigateToAlbum(a.id); setOpen(false); setQuery(''); }}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, a, 'album');
}}
role="option" aria-selected={activeIndex === i}>
{a.coverArt ? (
<LiveSearchAlbumThumb albumId={a.id} coverArt={a.coverArt} clusterSeedServerId={a.clusterSeedServerId} />
<LiveSearchAlbumThumb albumId={a.id} coverArt={a.coverArt} />
) : (
<div className="search-result-icon"><Disc3 size={14} /></div>
)}
+1 -1
View File
@@ -10,7 +10,7 @@ interface Props {
export default function LosslessFilterButton({ active, onChange }: Props) {
const { t } = useTranslation();
const tooltip = active ? t('albums.losslessTooltipOn') : t('albums.losslessTooltipOff');
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {};
const activeStyle = active ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {};
return (
<button
+16 -4
View File
@@ -4,10 +4,11 @@ import { useTranslation } from 'react-i18next';
import { Settings, HardDriveDownload } from 'lucide-react';
import { useSidebarStore } from '../store/sidebarStore';
import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore';
import { ALL_NAV_ITEMS } from '../config/navItems';
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
import { isOfflineSidebarNavAllowed } from '../utils/offline/offlineNavPolicy';
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']);
@@ -15,8 +16,10 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
const { t } = useTranslation();
const sidebarItems = useSidebarStore(s => s.items);
const randomNavMode = useAuthStore(s => s.randomNavMode);
const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
const offlineCtx = useOfflineBrowseContext();
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
const isServerOffline = offlineCtx.active;
const hasOfflineContent = offlineCtx.capabilities.manualPins;
const luckyMixBase = useLuckyMixAvailable();
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
@@ -29,6 +32,15 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false;
if (isServerOffline && !isOfflineSidebarNavAllowed(
cfg.id,
offlineNav.favoritesOfflineBrowse,
offlineNav.localLibraryBrowse,
offlineNav.playerStatsBrowse,
offlineNav.playlistsOfflineBrowse,
)) {
return false;
}
return true;
})
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
+1 -1
View File
@@ -238,7 +238,7 @@ export default function MobilePlayerView() {
const toggleStar = useCallback(() => {
if (!currentTrack) return;
queueSongStar(currentTrack.id, !isStarred);
queueSongStar(currentTrack.id, !isStarred, currentTrack.serverId);
}, [currentTrack, isStarred]);
// Scrubber touch/mouse drag
+2 -2
View File
@@ -108,7 +108,7 @@ export default function NowPlayingDropdown() {
{visible.length > 0 && (
<span style={{
background: 'var(--accent)',
color: 'var(--ctp-crust)',
color: 'var(--text-on-accent)',
fontSize: '10px',
fontWeight: 'bold',
padding: '2px 6px',
@@ -161,7 +161,7 @@ export default function NowPlayingDropdown() {
onClick={() => { if (stream.albumId) { setIsOpen(false); navigate(`/album/${stream.albumId}`); } }}
style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', background: 'var(--bg-hover)', padding: '0.5rem', borderRadius: '8px', cursor: stream.albumId ? 'pointer' : 'default' }}
>
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--bg-surface)' }}>
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--card-placeholder-bg)' }}>
{stream.albumId && stream.coverArt ? (
<TrackCoverArtImage
song={{
-7
View File
@@ -8,7 +8,6 @@ import { open as shellOpen } from '@tauri-apps/plugin-shell';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { usePlaybackServerId } from '../hooks/usePlaybackServerId';
import { useClusterMemberDisplayLabel } from '../hooks/useClusterMemberDisplayLabel';
import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown';
import CachedImage from './CachedImage';
import OverlayScrollArea from './OverlayScrollArea';
@@ -89,7 +88,6 @@ export default function NowPlayingInfo() {
const setEnableBandsintown = useAuthStore(s => s.setEnableBandsintown);
const subsonicServerId = usePlaybackServerId();
const subsonicReady = Boolean(subsonicServerId);
const clusterServerLabel = useClusterMemberDisplayLabel(subsonicServerId);
const primaryArtist = currentTrack ? primaryTrackArtistRef(currentTrack) : null;
const artistName = primaryArtist?.name ?? currentTrack?.artist ?? '';
@@ -232,11 +230,6 @@ export default function NowPlayingInfo() {
<div className="np-info-artist-body">
<div className="np-info-section-title">{t('nowPlayingInfo.artist', 'Artist')}</div>
<div className="np-info-artist-name">{artistName || t('common.unknownArtist', 'Unknown artist')}</div>
{clusterServerLabel && (
<div className="np-info-cluster-server">
{t('nowPlayingInfo.clusterServer')}: {clusterServerLabel}
</div>
)}
{bioClean && (
<>
<p
+135
View File
@@ -0,0 +1,135 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { HardDriveDownload, Heart } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { formatBytes } from '../utils/format/formatBytes';
const OPEN_DELAY_MS = 450;
interface Props {
label: string;
totalBytes: number | null;
libraryBytes: number | null;
favoritesBytes: number | null;
}
export function OfflineLibraryDiskStat({
label,
totalBytes,
libraryBytes,
favoritesBytes,
}: Props) {
const { t } = useTranslation();
const anchorRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [style, setStyle] = useState<React.CSSProperties>({ opacity: 0 });
const openTimerRef = useRef<number | null>(null);
const breakdownReady = libraryBytes !== null && favoritesBytes !== null;
const clearOpenTimer = () => {
if (openTimerRef.current !== null) {
clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
};
const scheduleOpen = () => {
if (!breakdownReady) return;
clearOpenTimer();
openTimerRef.current = window.setTimeout(() => setOpen(true), OPEN_DELAY_MS);
};
const close = () => {
clearOpenTimer();
setOpen(false);
};
const breakdownAriaLabel = breakdownReady
? [
t('connection.offlineLibraryDiskTierLibrary', { size: formatBytes(libraryBytes) }),
t('connection.offlineLibraryDiskTierFavorites', { size: formatBytes(favoritesBytes) }),
].join('. ')
: undefined;
useLayoutEffect(() => {
if (!open || !anchorRef.current || !popoverRef.current) {
setStyle({ opacity: 0 });
return;
}
const anchor = anchorRef.current.getBoundingClientRect();
const box = popoverRef.current.getBoundingClientRect();
const GAP = 8;
const MARGIN = 8;
let top = anchor.bottom + GAP;
let left = anchor.left + anchor.width / 2 - box.width / 2;
left = Math.max(MARGIN, Math.min(left, window.innerWidth - box.width - MARGIN));
top = Math.max(MARGIN, Math.min(top, window.innerHeight - box.height - MARGIN));
setStyle({ opacity: 1, top, left });
}, [open, libraryBytes, favoritesBytes]);
useEffect(() => () => clearOpenTimer(), []);
return (
<>
<div
ref={anchorRef}
className={`offline-library-header-stat${breakdownReady ? ' offline-library-header-stat--interactive' : ''}`}
aria-live="polite"
aria-label={breakdownAriaLabel}
onMouseEnter={scheduleOpen}
onMouseLeave={close}
onFocus={scheduleOpen}
onBlur={close}
tabIndex={breakdownReady ? 0 : undefined}
>
<span className="offline-library-disk-label">{label}</span>
<span className="offline-library-disk-value">
{totalBytes !== null ? formatBytes(totalBytes) : '…'}
</span>
</div>
{open && breakdownReady && createPortal(
<div
ref={popoverRef}
className="offline-library-disk-breakdown"
style={{ position: 'fixed', pointerEvents: 'auto', ...style }}
role="tooltip"
onMouseEnter={() => {
clearOpenTimer();
setOpen(true);
}}
onMouseLeave={close}
>
<div className="offline-library-disk-breakdown-row">
<span
className="offline-library-disk-breakdown-icon offline-library-disk-breakdown-icon--library"
aria-hidden
>
<HardDriveDownload size={15} strokeWidth={2} />
</span>
<span className="offline-library-disk-breakdown-size">
{formatBytes(libraryBytes)}
</span>
</div>
<div className="offline-library-disk-breakdown-divider" aria-hidden />
<div className="offline-library-disk-breakdown-row">
<span
className="offline-library-disk-breakdown-icon offline-library-disk-breakdown-icon--favorites"
aria-hidden
>
<Heart size={15} strokeWidth={2} />
</span>
<span className="offline-library-disk-breakdown-size">
{formatBytes(favoritesBytes)}
</span>
</div>
</div>,
document.body,
)}
</>
);
}

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