mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
7e9cb607635cd329f5e4e95d8927ca9a6e0ebaea
23 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7e9cb60763 |
refactor(api): co-locate server-protocol clients into lib/api
Move the Subsonic + Navidrome protocol clients and the library IPC facade (subsonic*, navidrome*, library.ts + tests) from src/api/ into src/lib/api/, the feature-free infra layer (plan M4, decision §10.3: shared client core → lib/). Pure move: deep-path import specifiers @/api/* -> @/lib/api/* across ~150 consumers; no behaviour change. Domain REST (lyrics/events) and cover/analysis infra stay in src/api/ pending their own M4 placement. tsc 0, lint 0/0, full suite 319 files / 2353 tests green. |
||
|
|
2a425862ae | refactor(album): co-locate album feature into features/album | ||
|
|
53b244de1f | refactor(ui): co-locate CachedImage as a shared primitive in src/ui | ||
|
|
184501744b |
fix(cover): restore full-resolution album and artist covers (#1205)
* fix(cover): build cover tiers from the full-resolution source Derive the larger tiers from the decoded download instead of re-reading the just-written smaller tier (resize never upscales, so 512/800 were stored at the small resolution). Resolve the full-res 2000 tier exactly so it is actually downloaded and cached. Bump the cache layout stamp to drop already-poisoned tiers. * fix(cover): open the cover lightbox at full resolution Require the exact requested tier for the full-res helper so a smaller warmed tier no longer pins the lightbox to a downscaled image. Race the 2000 fetch against a 500ms opening window, show the 800 tier meanwhile, and persist 2000 for the next open. Adds an opening animation (reduced-motion aware) and tests. * docs(changelog): note full-resolution cover fix (#1205) * fix(cover): keep full-res peek exact in peek_batch and the grid seeder Review follow-up: ensure-path peek alone left a hole — cover_cache_peek_batch still laddered a 2000 request down to a smaller tier, and the in-memory grid seeder wrote that smaller path under the 2000 key, so Hero/fullscreen/lightbox surfaces (which peek 2000 before ensure) still showed a downscaled cover. Share one exact-2000 rule (peek_plain_cover_tier) across ensure and peek_batch, and never seed the full-res key from a smaller tier file. |
||
|
|
86ae462ad6 |
feat(hero): album-artist backdrop, configurable per-surface sources, and prefetch (#1193)
* refactor(cover): extract shared pickArtistBackdrop priority helper
* feat(hero): show the album artist's fanart as the mainstage hero backdrop
* feat(settings): configurable per-surface artist backdrop sources
Each artist-backdrop surface (mainstage hero, artist-detail header, fullscreen player) gets its own enable toggle + an ordered, individually-toggleable source list, configured under External Artwork Scraper on the Integrations tab (shown when the scraper is on). Reorder via the shared useDragSource/psy-drop drag infra plus keyboard-accessible up/down buttons; each source has its own on/off.
The shared chooser pickArtistBackdrop is generalised to resolveBackdrop + backdropFromConfig (ordered candidate list with the same pending/miss/centred-framing semantics), so all three surfaces resolve identically. themeStore persist bumped to v2; defaults reproduce today's order, so there is no visible change without user action.
Gating decoupled: the three surfaces are gated solely by their own per-surface flag, not by enableCoverArtBackground (which stays scoped to album/playlist-header cover blur). Reorder maths extracted to a pure, unit-tested module. i18n en + de (other locales TODO before PR).
Tests: resolver (10) + reorder (7).
* feat(cover): make the ensure queue surface-aware for artist backdrops
coverEnsureQueued now threads optional CoverEnsureOpts through to the Rust ensure and weaves the external surface into the in-flight key, so the fanart and banner surfaces of one artist no longer collide on one download chain. External surfaces also bypass the disk-src memory short-circuit (their {tier}-{surface}.webp never seeds those caches, and the canonical cover must not read as a hit). New thin ensureArtistBackdropQueued wrapper. Backward-compatible: plain covers append nothing to the key; the 5 queue tests stay green.
* fix(cover): reset the artist external-image hook synchronously on artist change
The hook reset src in an effect (one render late), so for the render between an artist change and that effect a consumer read the *previous* artist's resolved image. The mainstage hero then froze (and cached into per-album memory) a neighbouring slide's banner. Reset synchronously via the React adjust-state-on-prop-change pattern. Also removes brief stale-image flashes on the artist-detail header and fullscreen player.
* feat(hero): prefetch artist backdrops and show-ready-now / upgrade-on-re-entry
warmHomeMainstageCovers now prefetches each hero slide's artist backdrop (banner/fanart) at static slide-index priorities (idx1=high, idx0=low, rest=middle; no reprioritise on navigation), then predecodes every slide already on disk. useHeroBackdrop shows the best source ready at entry (Navidrome on a cold first visit, the prefetched/cached external one on re-entry) and freezes that source choice for the visit so nothing swaps mid-dwell; the url is derived live from the frozen choice, with a per-album disk memory for re-entry. HeroBg now crossfades only after the image bytes load (onLoad/complete gate + onError + fallback). Inert when the scraper is off. Tests: per-album memory (5).
* docs(changelog): configurable artist backdrops + mainstage hero (PR #1193)
CHANGELOG Added entry, settingsCredits line, and the What's New Artist-artwork highlight extended to cover the mainstage hero backdrop and per-place source config.
* docs(changelog): fold mainstage hero + per-place backdrops into the fanart entry (PR #1193)
Merge the configurable-backdrops changes into the existing 'Artist artwork from fanart.tv' block (now PR #1137 and #1193) instead of a separate entry.
* fix(hero): revert HeroBg load-gate that blanked the app (Maximum update depth)
The byte-load gate I added drove the crossfade reveal from an inline img ref + onLoad that re-fires on every render and scheduled a setTimeout each call; frequent re-renders (playback, marquee) stacked nested updates until React threw 'Maximum update depth exceeded' — and with no ErrorBoundary the whole window blanked. Reverted HeroBg to the proven timer-based crossfade. The hero only ever receives ready/predecoded urls, so the gate was cosmetic.
* fix(hero): gate the HeroBg crossfade on image load to stop the slide flicker
The bare 20 ms reveal faded a layer in before its bytes were ready (notably the Navidrome raw url), so a slide change flickered. Now an Image() preloader in the [url] effect reveals the layer on load (or a cached complete check), with a fallback. Everything is scheduled once per url — no per-render <img> ref/onLoad — so unlike the reverted gate it can't stack nested updates.
* feat(i18n): translate the backdrop-source settings into the remaining 10 locales
Adds the per-surface backdrop config strings (es, fr, nl, zh, nb, ru, ro, ja, hu, pl) and extends externalArtworkDesc to mention the mainstage hero where the key exists (ja has none → falls back to en).
|
||
|
|
0b7b5df30c |
test(ui): smoke-test the #1165 hook-guard split and recent-search rename (#1168)
* test(cover): smoke-test CoverArtImage hook-guard split (#1165) * test(search): smoke-test MobileSearchOverlay recent-search (#1165) |
||
|
|
7c724a642f |
chore(eslint): add ESLint toolchain and clean src to strict 0/0 (#1165)
* chore(eslint): add eslint toolchain and configs * fix(eslint): resolve gradual-config errors (rules-of-hooks, no-empty, prefer-const, …) * chore(eslint): clear unused vars in config, contexts, app and test * chore(eslint): clear unused vars in api and music-network * chore(eslint): clear unused vars in utils * chore(eslint): clear unused vars in store * chore(eslint): clear unused vars in cover and hooks * chore(eslint): clear unused vars in components * chore(eslint): clear unused vars in pages * chore(eslint): remove explicit any in src * chore(eslint): align react-hooks exhaustive-deps * chore(eslint): zero gradual config on src * chore(eslint): strict hook rules in store and utils * chore(eslint): strict hook rules in hooks and cover * chore(eslint): strict hook rules in components * chore(eslint): strict hook rules in pages and contexts * chore(eslint): document scripts ignore in eslint config * chore(eslint): add lint script and zero strict findings * chore(eslint): address review round 1 (gradual 0/0, per-site disable reasons) * chore(eslint): tighten two set-state disable comments (review round 2 LOW) * chore(nix): sync npmDepsHash with package-lock.json * docs(changelog): add Under the Hood entry for ESLint setup (PR #1165) --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> |
||
|
|
b950d4704b |
feat(cover): artist artwork from fanart.tv (off by default) (#1137)
* feat(cover): add artist_artwork_lookup table + accessors
Image-scraper P0 (design-review §12): additive library-SQLite migration 013
plus get/upsert/clear-per-server accessors for the external artist-artwork
lookup (fanart.tv). Render never reads it; the on-demand cover ensure path
and the mbid_ambiguous 24h negative cache use it. server_id = serverIndexKey.
* feat(cover): add fanart.tv + getArtistInfo2 provider layer
Image-scraper P0 (§7/§19/§23): new cover_cache/external.rs — Rust-side
getArtistInfo2 tag-MBID resolution plus fanart.tv v3/music URL + first
artistbackground fetch (BYOK client_key sent in addition to the project
api_key per fanart.tv ToS, §22). Extract a shared build_subsonic_url helper
in fetch.rs (cover URL behaviour unchanged). Add a dedicated low-concurrency
fanart_http_sem so external HTTP never starves Navidrome (§26). URL builders
unit-tested; wired into ensure_inner next.
* feat(cover): wire fanart.tv external branch into ensure_inner
Image-scraper P0 (§16): on-demand artist `fanart` ensures try fanart.tv
before the Navidrome fallback. MBID resolved Rust-side via getArtistInfo2
(§23, tag MBID); on a miss it falls through WITHOUT a .fetch-failed marker so
Navidrome stays the display fallback (§28). External tiers are written as
{tier}-fanart.webp in the same entity dir (same cacheKind, §16) — 2000 + 512
(matryoshka §17); peek prefers them for the fanart surface. Dedicated
low-concurrency fanart lane (§26); .miss-fanart ~30min negative marker.
Additive IPC args (externalArtworkEnabled, surfaceKind), off by default and
gated by PSYSONIC_FANART_KEY — inert until a render surface opts in (P1).
Quality gate (§11), name->MusicBrainz (§19), and lookup-table writes are P1.
* feat(cover): fanart-first peek for the fanart surface
Image-scraper P0: for an artist `fanart` ensure, the early peek serves only
the external {tier}-fanart.webp tiers; if none exist yet it returns None so
ensure runs the external branch (fetch fanart) instead of short-circuiting on
a cached Navidrome tier. Realises "fanart prioritised" (§18) for the opt-in
surface; Navidrome stays the fallback inside the branch's miss path.
* chore(cover): dev-only artist-fanart spike helper
DEV-only window.psyFanartSpike(name) — resolves an artist by name and fires
the real cover_cache_ensure with externalArtworkEnabled+surfaceKind=fanart to
verify the P0 pipeline against a live server (with PSYSONIC_FANART_KEY set).
Not wired in production.
* feat(cover): §11 quality gate for the fanart surface
Before an external fanart fetch, check whether a Navidrome tier already on
disk is an HQ ~16:9 image (width >= 1280, aspect 1.6-2.0) and skip the fetch
if so — square artist portraits never satisfy it, so the common case still
fetches. Reads tier dimensions only (no full decode). Rust consts per the
design review. Unit-tested predicate.
* feat(cover): persist fanart resolution in artist_artwork_lookup (§12)
Wire the lookup table as both the MBID resolution cache and the negative
cache: a cached MBID skips the getArtistInfo2 round-trip; no_mbid/mbid_ambiguous
back off 24h and miss 30min from updated_at before re-querying. Writes
hit/miss/no_mbid with mbid/mbid_source/provider; transient network errors are
not cached. All store reads/writes run off the async executor via
spawn_blocking and no-op before login. Store reached via app.try_state::<LibraryRuntime>().
* feat(cover): compile-time fanart key fallback + album/name IPC args
A runtime PSYSONIC_FANART_KEY still wins (dev), else the key baked in at build
time via option_env! (release). Add additive artistName/albumTitle ensure args
as context for the §19 name->MusicBrainz fallback (inert until the render
passes them). Library backfill passes None.
* feat(settings): add External Artwork Scraper toggle under Integrations
Master toggle (themeStore, off by default per §20) in a new Integrations
subsection, alongside the other opt-in third-party categories. Contacts
fanart.tv only when enabled. i18n across all 9 locales.
* feat(cover): wire fanart background into the fullscreen player (§28)
New useArtistFanart hook resolves a fanart.tv 16:9 background via a dedicated
cover_cache_ensure (surfaceKind=fanart) — it bypasses the shared peek/disk-src
cache (the {tier}-fanart.webp surface is keyed differently) and reuses
coverDiskUrl for the asset URL. Fullscreen background priority is now
fanart -> Navidrome artist image (cover pipeline) -> album cover; the live
useFsArtistPortrait probe is deleted (§28). Additive ensure opts
(surfaceKind/artistName/albumTitle); externalArtworkEnabled is derived in
ensureArgsFromRef from the master toggle and restricted to the artist fanart
surface, so plain cover ensures are unaffected.
* feat(cover): generalize external surface to fanart + banner (§13)
surfaceKind='banner' fetches the fanart.tv musicbanner array -> {tier}-banner.webp
in the same entity dir; fanart stays the 16:9 artistbackground. The ensure
branch, peek, lookup rows (per-surface surface_kind), miss marker
(.miss-<surface>) and tier suffix are all surface-parameterised. The §11
quality gate stays fanart-only (the banner strip has its own aspect). Unit
test for the surface->fanart JSON key map.
* feat(artist): fanart banner on the artist-detail header (§13, Option B)
The artist-detail header gets an album-detail-style background layer: fanart.tv
banner (musicbanner) -> the 16:9 fanart background cropped to the strip ->
empty. Both via a shared useArtistExternalImage hook (useArtistBanner /
useArtistFanart); each fetches on demand and shares the Rust cache, so the
header and the fullscreen player warm each other's images. The header is its
own stacking context (isolation) so a z-index:-1 banner clips behind the avatar
+ meta with no content wrapper; the album-style framing (padding/radius/clip)
is applied only when a banner is shown, so the off-by-default case stays
pixel-identical.
* refactor(artist): reuse album-detail header structure for the fanart banner
The artist-detail header now uses the same album-detail-* container classes as
AlbumHeader (header/bg/overlay/content/hero) with the fanart banner as the
background; the Back button moves inside the header. A surgical
`artist-detail-bleed` cancels the artist page's .content-body padding so the
banner is full-bleed to the container edges, matching the album header exactly
instead of the earlier inset card. Reverts the experimental artist-specific bg
CSS.
* feat(cover): drop artist_artwork_lookup rows on clear-cover-cache (§12/B.4)
cover_cache_clear_server already removed the server's whole cover dir (so the
{tier}-fanart.webp / -banner.webp tiers + .miss-* markers go with it); also
clear the artist_artwork_lookup rows for that server (off-thread) so no stale
resolution state lingers. Automatic toggle-off purge deferred — turning the
toggle off already hides external artwork (render is gated), and explicit
cache-clear now cleans external state too.
* feat(cover): name->MusicBrainz album-confirmed MBID resolution (§19)
When getArtistInfo2 has no tag MBID and the ensure carries the artist name +
an album in context (fullscreen), one MusicBrainz release-search query resolves
the artist MBID: the primary artist across score>=90 releases wins, conflicting
ids -> mbid_ambiguous (24h backoff), none -> no_mbid. Sends the required
User-Agent; a single-permit musicbrainz_sem + >=1s spacing holds us under MB's
rate limit. mbid_source=musicbrainz persisted. Banner surface (no album
context) correctly skips this. Pure classify/escape helpers unit-tested.
* fix(cover): enable banner surface in ensureArgsFromRef
externalEnsureFields only set externalArtworkEnabled for surfaceKind 'fanart',
so the 'banner' surface never fired — the artist-detail header always fell back
to the fanart image instead of the fanart.tv musicbanner. Both external artist
surfaces (fanart/banner) now enable the external branch.
* feat(settings): optional BYOK personal fanart key field
Add an optional personal fanart.tv API key field to the External Artwork
Scraper block (shown when the toggle is on): a masked input, a saved/in-use
status line, and the simple note that it is sent in addition to the app key.
Persisted in themeStore and plumbed through cover_cache_ensure
(externalArtworkByok); Rust prefers the settings key, falling back to the
PSYSONIC_FANART_CLIENT_KEY dev env. i18n x9.
* fix(cover): resolve artist-page fanart image collision on navigation
The artist-detail header keyed its fanart/banner hooks on the route `id`,
which flips immediately on navigation while `artist`/`albums` refetch a beat
later. The mismatched ensure wrote the previous artist's image under the new
artist's id (e.g. Sepultura's image under Lordi's id).
- key on the loaded `artist.id`, not the route `id`, so id/name/album always
describe the same artist
- pick the §19 album context from an album that actually belongs to this
artist (`albums.find(a => a.artistId === artist.id)`), so a stale album can't
run a mismatched name→MusicBrainz query or cache a wrong `no_mbid`
- reset `src` on every input change in `useArtistExternalImage` so a previous
artist's image never lingers while the new one resolves
* fix(cover): strip trailing album qualifier before MusicBrainz lookup
Library titles like "Show No Mercy (2004 Remastered)" or "Album [Deluxe
Edition]" failed the §19 MusicBrainz release query, blocking name-confirmed
MBID resolution. `normalize_album_for_mb` strips a single trailing
parenthetical/bracketed qualifier; leading qualifiers (e.g. "(What's the
Story) Morning Glory?") are left intact. Unit-tested.
* fix(cover): don't cache no_mbid when album context is unavailable
The banner ensure could fire before the artist's albums loaded, with no album
in context. The old code cached `no_mbid` there and the 24h backoff then
blocked the later ensure that arrived *with* album context. Could-not-attempt
is not tried-and-failed: the no-album branch now returns without persisting.
* fix(cover): don't emit tier-ready for external fanart/banner surfaces
`try_external_fanart` emitted `cover:tier-ready` with the `{tier}-{surface}.webp`
path. That event is keyed by the canonical cover key (cacheKind/cacheEntityId/
tier, no surface), so the frontend `useCoverArtBridge` listener seeded the
Navidrome artist cover's disk-src cache with the external image — leaking
fanart/banner into the plain artist cover (avatar, fullscreen "navidrome-artist"
fallback) even with the scraper toggled off.
Remove the emit: the fanart/banner hooks read the path from the
`cover_cache_ensure` return value, so no event is needed. (No disk-level
overwrite — the suffixed files are never matched by `tier_exists`; this was
frontend disk-src-cache cross-contamination.)
* fix(cover): wait for the final external background before showing it, with fade-in
The fullscreen player and artist-detail header flashed several backgrounds in
sequence while the fanart resolved (upscaled album cover → Navidrome artist
image → fanart), and the artist header could show the fanart first and then
swap to the banner.
- the album cover is no longer a background source — it only feeds the
foreground thumbnail
- the external-artwork hooks return `{ src, pending }` so callers can tell
"still resolving" (hold back) from "resolved, no image" (fall back now)
- fullscreen background: scraper on → fanart, empty while it resolves, Navidrome
artist image only on a confirmed miss; scraper off → Navidrome artist image
- artist header: the banner is preferred — nothing shows while it resolves
(no fanart flash), fanart is the fallback only once the banner misses
- both backgrounds preload the chosen image and fade it in (`onLoad` plus a
`ref` `complete` check so an already-cached image, whose load event can fire
before React attaches the handler, still appears). The header fade is a
scoped inline opacity so the shared `album-detail-bg` class is untouched.
* ci(release): pass PSYSONIC_FANART_KEY into the macOS + Linux builds
* refactor(cover): extract external-artwork ensure into its own module
Pure code move: the on-demand fanart/banner fetch, the quality gate, the
surface-aware peek and the lookup-table cache move from cover_cache/mod.rs
into cover_cache/external_ensure.rs. Behaviour unchanged; mod.rs 1877 -> 1488.
* chore(cover): remove dev-only fanart spike helper
The real render wiring now exercises the external ensure branch, so the
dev-only window.psyFanartSpike helper is redundant.
* feat(cover): purge external artwork on opt-out (B3)
New cover_cache_purge_external command: when the External Artwork toggle is
turned off, drop every fetched {tier}-{provider}.webp, .miss-{provider}
marker and artist_artwork_lookup row across all configured servers, leaving
the canonical Navidrome covers intact. Opting out now removes the
third-party-sourced data instead of just hiding it (design-review §9/§12/B.4).
* docs: changelog, credits and what's new for artist fanart (PR #1137)
|
||
|
|
961dba996c |
fix(discord): resolve cover profile from the store, not getPlaybackServerId
Follow-up to the dual-address cover fix: a playback/active cover scope was
routed through getPlaybackServerId(), which returns a `string` that can be
empty or an index-key (not a profile id) for locally-cached tracks. The
`?? activeServerId` fallback never fired (empty string isn't nullish), so no
profile matched and the URL came back null — which the presence layer caches
per cover id, producing intermittent "cover shows, then doesn't".
A playback/active scope always means the active server (a cross-server track
gets an explicit `server` scope), so resolve the active profile directly.
(cherry picked from commit
|
||
|
|
4fd558fa28 |
fix(discord): use the public server address for Rich Presence cover art
The Discord cover URL was built via the connect endpoint, which prefers the
LAN address — but Discord fetches the image from its own servers, so a LAN
address is unreachable and the cover falls back to the app icon. This is a
dual-address regression: before a second (public) address could be added,
the only configured URL was the public one.
Build the Discord large-image URL via serverShareBaseUrl (public preferred,
like share links / Orbit invites) instead of the connect URL. Adds a test.
(cherry picked from commit
|
||
|
|
3de7b57cc5 |
Fullscreen player polish + Discord Rich Presence fixes (#1068)
* fix(fullscreen-player): drop track-number prefix from title, stop clipping descenders
The big title showed a zero-padded queue position ("11. ") before the
song name; remove it (the position still lives in the top bar). Also bump
line-height 1.05 -> 1.25 so overflow:hidden no longer crops descenders
(g, j, p, q, y) at 46px.
* feat(settings): clarify built-in Discord RP vs official Navidrome plugin
Add a bold notice atop the Discord Rich Presence block explaining it is
the built-in integration, and that users wanting the official Navidrome
Discord RP plugin should leave it off and enable "Show in Now Playing"
instead. Add a matching bold note to the Now Playing setting. New i18n
keys discordRichPresenceNotice / nowPlayingPluginNote across all 5 locales.
* fix(discord-rp): use HTTPS cover URL instead of local file:// path
Discord Rich Presence images are fetched by Discord's servers, so
large_image must be a key or an https:// URL they can reach. Since the
cover pipeline moved to an on-disk webp cache, coverArtUrlForDiscord
returned a file://.../800.webp path on cache hit, which Discord cannot
load and silently falls back to the app icon. Always hand it the
getCoverArt URL; MPRIS keeps the local file:// path unchanged.
* i18n(settings): fill discordRichPresenceNotice + nowPlayingPluginNote for es, nb, ro, ru
The two new Discord-RP notice keys had only landed in 5 of the 9 locales;
complete the set so no locale falls back to English.
* docs(changelog): fullscreen title + Discord RP fixes (PR #1068)
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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) |
||
|
|
42aec6720c |
fix(cover): stop per-song over-fetch + log failed cover downloads (#944)
* fix(cover): stop per-song cover over-fetch (album/mf-* explosion) album_has_distinct_disc_covers returned true as soon as two tracks on the same disc had different cover ids. On Navidrome every song has its own mf-<id> coverArt, so almost every album was flagged "distinct disc covers" and backfill warmed one cover per track (~520k for ~170k tracks), filling album/ with mf-* dirs instead of ~one cover per album. Treat a release as having distinct disc covers only when each disc has a single consistent cover that differs across discs (genuine box set); per-song ids now collapse to one cover per album. Mirror the same fix in the TS albumHasDistinctDiscCovers used by on-demand warming. Adds regression tests on both sides. * docs(changelog): record per-song cover over-fetch fix (PR #944) Give the album/mf-* over-fetch fix its own [1.47.0] Fixed entry and a settingsCredits line under PR #944. * feat(cover): log failed cover downloads with album/artist name A non-200 (or network-failed) getCoverArt download was swallowed silently. Now the failure is logged with the resolved album/artist name and the server error, so a server refusing covers under backfill load (5xx/429/timeouts) is visible. - cover_resolve: describe_cover_entity() resolves a human label from the local index (album "Name" — Artist / artist "Name"), best-effort with id fallback. - cover_cache: log_cover_fetch_failure() in ensure_inner logs on the download error path; threads optional library_server_id through CoverCacheEnsureArgs so the name lookup happens only on failure. Backfill logs at normal level, on-demand misses at debug level. |
||
|
|
08b6aeeb17 |
fix(perf): reduce idle Rust CPU and stabilize Performance Probe overlay (#939)
* fix(perf): skip Performance Probe CPU snapshot poll on Windows Windows has no Rust CPU/RSS sampler, but the probe still invoked performance_cpu_snapshot every 2s when the modal or overlay pins were active. Skip the IPC on unsupported platforms and only poll JS-side metrics; do not start overlay polling for CPU/memory pins alone. * fix(analysis): park backfill coordinator until Advanced is configured #881 started run_coordinator_forever at app init with a 2s sleep even when disabled, waking tokio on every platform for no work. Park on Notify instead; wake on configure (enable/disable) and library sync-idle. Long sleeps use select with wake so sync-idle can interrupt COMPLETED_RECHECK waits. Investigation branch — not for merge until periodic CPU root cause is confirmed. * fix(perf): stop probe overlay flicker on live poll updates Publish CPU samples only after a valid jiffies baseline, skip no-op snapshot emits, and sync sparkline history atomically in the store. Overlay uses wall-clock sparkline time and auto-scales low CPU values. * fix(perf): cut idle Rust CPU from probe scan, cover prefetch, and storage poll Move performance_cpu_snapshot /proc work to spawn_blocking so tokio workers are not charged with probe sampling. Stop lazy cover strategy from running route prefetch disk stats every 1.5s, and slow hot-cache size refresh on Settings → Storage to 15s. * fix(perf): stabilize probe sparkline clock between live poll ticks Track sampleAt separately from updatedAt so CPU rate history and overlay sparklines only advance on real % changes, not FPS re-renders or RSS-only poll ticks. Hold CPU sparkline Y scale with a peak ref to avoid scale jumps. * fix(cover): restore lazy route prefetch without idle disk stats poll Re-enable lazy cover registry warm-up so cached WebP paths reach diskSrcCache before cells mount. Skip cover_cache_stats on every 1.5s tick — drain batches via ensure only, poll full disk usage every 30s when the registry is idle. * docs: CHANGELOG and credits for PR #939 idle CPU perf fix * fix(cover): peek before route prefetch ensure to match main responsiveness Route prefetch moved batch drain ahead of cover_cache_stats for idle CPU, which removed the accidental throttle and flooded ensure invoke slots. Use warmCoverDiskSrcBatch first (cached hits skip ensure), ensure misses only, and yield while high-priority viewport work is queued. |
||
|
|
9925771a86 |
feat(browse,cover,perf): lazy catalogs, cover pipeline, and Performance Probe (#890)
* fix(cover): per-server cache stats and cover pipeline perf probe Stop count_cached_cover_ids from borrowing sibling bucket counts so Settings progress no longer attributes one server's disk cache to another. Add cover pipeline queue stats (ui ensure queue, ui vs lib HTTP/WebP semaphores) to Performance Probe overlay, with clearer ui/lib labels. * fix(browse): stabilize in-page infinite scroll and cap cover memory caches Extract useInpageScrollSentinel for album grids and song lists so sentinel reconnects do not spam loadMore during scroll. Harden useAlbumBrowseData with sync loading refs, tighter root margin, and hasMore termination when dedupe adds nothing. Pause middle-priority cover work during SQL pagination and bound diskSrc/resolve/ensure tail maps on long cold-cache sessions. * refactor(browse): unify in-page infinite scroll hooks and sentinel UI Extract shared transport (viewport ref, async pagination guards, client slice) and InpageScrollSentinel so Albums, New Releases, Artists, and song lists use one pagination pattern instead of duplicated IntersectionObserver wiring. * fix(browse): prioritize album SQL pagination over cover ensures Pause the entire webview ensure pump during grid page fetches, resume after SQL settles, add cover-queue backpressure before load-more, and re-probe the sentinel when pagination finishes so cold-cache scroll does not stall. * fix(browse): unblock covers, SQL spawn_blocking, and pagination retry Pair grid-pagination hold begin/end on stale fetches, resume the ensure pump after SQL, retry load-more when the cover backlog drains while the sentinel stays visible, and run album browse SQL on spawn_blocking so Tokio stays responsive during library_advanced_search. * feat(browse): All Albums client-slice scroll on local index (Artists-style) Load the filtered catalog once from SQLite when the library index is ready, then grow the visible grid with useClientSliceInfiniteScroll instead of offset SQL pagination per scroll. Network-only servers keep page mode. * fix(browse): lazy local catalog chunks instead of full 50k SQL fetch All Albums slice mode now loads 200 albums first, shows the grid immediately, then appends catalog chunks in the background as the user scrolls. Avoids the blocking library_advanced_search that hung the app on large libraries. * fix(browse): keep album covers loading during active grid scroll Pass high ensure priority and the in-page scroll root to AlbumCard on All Albums, stop pausing cover traffic for background catalog chunks, and never trim high-priority ensure jobs from the queue during scroll bursts. * fix(cover): viewport priority tiers and unstick ensure invoke pump All Albums uses IO-driven high/middle instead of blanket high; release only on unmount so scroll-ahead jobs are not dropped on reprioritize. Ensure queue shares one Rust flight per cover id, attaches duplicate waiters without consuming invoke slots, and times out wedged calls. Warm the first viewport slice on large grids; acquire CPU permits before spawn_blocking in cover_cache to avoid blocking-thread deadlocks. * fix(cover): wire in-page scroll root on New Releases and Lossless grids AlbumCard IO uses the same viewport id as VirtualCardGrid so cover ensure priority tracks visible in-page rows like All Albums. * fix(browse): lazy local artist catalog in 200-row chunks Replace runLocalBrowseAllArtists bulk fetch with paginated local-index chunks so large libraries do not hang on open; preserve text search, starred, letter filter, and client-slice scroll behavior. * feat(perf): add RSS and thread CPU groups to Performance Probe Extend performance_cpu_snapshot with process RSS (psysonic + WebKit children) and in-process thread CPU breakdown. Classify tokio-rt-worker and tokio-* workers separately from glib, audio/pipewire, reqwest, and other misc threads (Linux /proc only). * feat(perf): redesign Performance Probe with tabs, pins, and overlay layout Split the probe into Monitor (live metric cards, per-metric overlay pins, corner and opacity controls) and Toggles (diagnostic tree). Share live polling via perfLiveStore; label analysis/cover pipeline blocks in the HUD. * feat(perf): overlay sparklines, macOS CPU/memory, and sync fixes Add 1-minute pinned-metric sparklines with right-aligned growth and a shared poll clock. Enable macOS performance snapshots via sysinfo. Fix overlay infinite loop from unstable history snapshots, bar/sparkline tick jitter, and probe bar rescale flicker. * docs: CHANGELOG and credits for PR #890 * perf(probe): scoped CPU poll, adjustable interval, lazy thread groups Read only psysonic + WebKit children instead of the full process table; macOS uses sysctl host CPU and refreshes cached child PIDs. Add 0.5–10s poll slider (default 2s). Collect /proc thread groups only when the Monitor section is open or a thread metric is pinned. * feat(perf): three-way overlay mode switch (off / FPS / pinned) Add Monitor control for overlay visibility: hidden, FPS-only, or pinned metrics from Monitor. Live CPU poll runs only in pinned mode with live pins. |
||
|
|
8443b3d4be |
fix(artist): align top-track covers with album grid cover path (#886)
* fix(artist): align top-track covers with album grid cover path Top tracks now resolve album.id + album.coverArt like AlbumCard, use the same useAlbumCoverRef/CoverArtImage dense pipeline, and batch-warm covers on page load instead of a custom sparse resolver. * docs: CHANGELOG and credits for PR #886 artist top-track covers * fix(artist): match All Albums cover warm tier and prefetch on detail page Warm top-track and discography covers at dense grid tier (140px) instead of 32px thumb tier so disk peek hits cached WebP. Register high-priority dense prefetch like All Albums and ensure top-track cells at high priority so dense defer-until-visible does not stall visible thumbs. * fix(artist): satisfy tsc for top-track cover warm helpers Use optional coverArt access in pushAlbumWarmRow and align album pick types with topSongAlbumForCover (id + name + coverArt). |
||
|
|
9fa086c428 |
feat(server): dual server address (LAN + public) per profile (#880)
* feat(server): ServerProfile.alternateUrl + shareUsesLocalUrl
Additive optional fields on ServerProfile to back the dual-address work.
'alternateUrl' is the optional second endpoint (e.g. LAN counterpart of a
public 'url' or vice versa); 'shareUsesLocalUrl' chooses which of the two
goes into Orbit / entity / magic-string shares when both are set.
Pure schema change — no runtime consumers yet. Single-address profiles
keep the same persisted shape; the optional fields are simply absent.
* feat(server): serverEndpoint module with LAN classification + IPv6
Introduces a single home for connect-/share-layer URL utilities that
the dual-address work will build on:
* normalizeServerBaseUrl(raw) — aligned with serverProfileBaseUrl
* isLanUrl(url) — IPv4 (unchanged) + IPv6: ::1 loopback, fe80::/10
link-local, fc00::/7 ULA, and IPv4-mapped (both dot-decimal and the
URL-API-normalized hex form, since new URL() rewrites ::ffff:1.2.3.4
to ::ffff:HHHH:HHHH)
* allNormalizedAddresses(profile) — deduped [url, alternateUrl?]
* serverAddressEndpoints(profile) — LAN-first ServerEndpoint[]
isLanUrl moves out of useConnectionStatus.ts (the hook now imports +
re-exports it for backward compatibility); OrbitStartModal points at
the new path directly. 38 unit tests cover the IPv4/IPv6 matrix,
dedupe, ordering, and edge cases.
* feat(server): pickReachableBaseUrl with in-memory connect cache
Adds the runtime connect layer on top of serverEndpoint:
* pickReachableBaseUrl(profile) — sequential LAN-first ping via the
existing pingWithCredentials; first OK wins.
* ensureConnectUrlResolved(profile) — boot / switch / online-event
entry point (same mechanism, intent-named).
* In-memory cache keyed by profileId; sticky behaviour tries the
cached endpoint first, falls back to the natural order on miss.
* invalidateReachableEndpointCache(profileId?) — single-profile and
whole-cache flushes for profile-edit / credentials-change / online.
* getCachedConnectBaseUrl(profileId) — sync getter for getBaseUrl
fallback path (next commit).
The cache is **session-only**, never persisted. Single-address profiles
keep the same shape (one endpoint, one ping). 9 new unit tests cover
single + dual address, LAN-first preference, fallthrough, unreachable +
stale-cache clear, sticky hit, and the two invalidate flavours.
* feat(server): getBaseUrl reads connect cache, falls back to primary url
Dual-address profiles need 'getBaseUrl' to return the runtime-probed
connect URL (LAN at home, public elsewhere), not the literal primary
'url'. To keep the sync getter shape that ~60 call sites depend on,
the store now reads 'getCachedConnectBaseUrl(server.id)' from the
serverEndpoint cache.
If no probe has run yet (very early boot, before switchActiveServer
populates the cache), it falls back to the normalized primary URL —
identical to today's behaviour. Single-address profiles see no
difference; their cache entry equals serverProfileBaseUrl(url).
* feat(server): switch + connection-status probe via ensureConnectUrlResolved
Both surfaces that previously called pingWithCredentials(server.url, ...)
directly now route through the dual-address connect layer:
* switchActiveServer awaits ensureConnectUrlResolved(server), reads the
identity (type/serverVersion/openSubsonic) off the returned ping, and
hands probe.baseUrl (not server.url) to scheduleInstantMixProbeForServer
so the AudioMuse probe also hits the reachable endpoint.
* useConnectionStatus.check() does the same on every 120 s tick — sticky
cache fast-paths the steady state, and a network change naturally
flips the active endpoint without a manual retry.
* useConnectionStatus.retry() and the 'online' event flush the cached
entry for the active profile first, so the next probe starts from the
natural LAN-first order instead of revalidating a stale URL.
Single-address profiles behave identically: one endpoint in the list,
one ping per check. No behaviour change for them.
* feat(server): PickReachableResult.ping + connectBaseUrlForServer helper
Two additive extensions to the serverEndpoint surface that the upcoming
CONNECT migrations all need:
* 'ping: PingWithCredentialsResult' on the OK branch of
PickReachableResult so callers (switchActiveServer,
useConnectionStatus) can read type/serverVersion/openSubsonic from
the same probe instead of issuing a second pingWithCredentials.
* connectBaseUrlForServer(server) — sync getter for the connect URL
of *any* saved profile (active or not). Reads the cache, falls back
to normalized primary url on miss. Becomes the canonical helper
for non-active-server HTTP traffic (apiForServer, stream URLs,
cover fetches, library bind session).
Tests: pingOk() fixture now returns identity fields; the single
toEqual-asserting test in pickReachableBaseUrl became a guarded read
to keep equality concise while still asserting on the new ping field.
* feat(connect): apiForServer + stream URLs route via connectBaseUrlForServer
Non-active-server HTTP traffic (the apiForServer entry point, used by
QueuePanel cross-server cover fetches and share-paste resolution) and
the stream-URL builders previously read 'server.url' directly,
bypassing the dual-address connect cache. Both now go through
connectBaseUrlForServer, which serves the cached LAN/public endpoint
when one exists and falls back to the normalized primary url
otherwise.
buildStreamUrl(id) now uses the baseUrl it already pulled from
getBaseUrl() (which is connect-cache aware as of 2a6d8283) instead of
re-normalizing server.url — same value for single-address profiles,
correctly dual-address for the rest. Single-address callers see no
behaviour change.
* feat(connect): cover fetch + cover-cache ensure use connect URL
Both cover-fetch surfaces previously read 'scope.url' / 'server.url'
straight into HTTP requests, which would freeze the cover pipeline on
the primary URL even when a LAN endpoint was reachable:
* buildCoverArtFetchUrl(ref, tier) — for the 'server' scope (queue
rows cached against a non-active profile) and the 'playback' scope
(cross-server playback) now resolves the connect URL via
connectBaseUrlForServer before handing off to
buildCoverArtUrlForServer.
* ensureArgsFromRef in coverCache.ts — restBaseUrl for the Tauri
'cover_cache_ensure' invoke now uses the cached connect URL for
both 'server' and 'active'/'playback' scopes.
Scope.url itself remains the index-stable primary URL — that's what
storageKeys (INDEX) consume. Only the HTTP base shifts to the connect
endpoint, exactly the split the spec calls out.
* feat(connect): library bind + server-test probe via ensureConnectUrlResolved
Two more 'rebind / re-test an existing saved server' paths still pinged
the literal primary URL — both go through the dual-address connect
layer now:
* bindIndexedServer (librarySession.ts): used to ping server.url then
pass serverProfileBaseUrl(server) as the bind 'baseUrl' to Rust.
Now: one ensureConnectUrlResolved call covers both — probe.baseUrl
feeds librarySyncBindSession directly, so Rust's per-server library
sync uses whichever endpoint actually answered. Drops the
pingWithCredentials and serverProfileBaseUrl imports.
* testConnection (ServersTab.tsx): same shape — probe via the connect
layer, read identity from probe.ping, hand probe.baseUrl to
scheduleInstantMixProbeForServer so the AudioMuse probe also hits
the connect endpoint. Single-address profiles still ping once.
Add/edit save handlers (handleAddServer / handleEditServer) keep the
direct pingWithCredentials against the user-entered data.url — that
flow is the dual-address verify hook, which PR 2 extends.
* feat(server): serverShareBaseUrl — public-by-default share URL picker
Adds the share-layer companion to connectBaseUrlForServer. Different
intent: connect picks the reachable endpoint for HTTP, share picks the
URL that goes into Orbit invites / entity share payloads / queue share
links / magic strings — where a guest opening the link is not on the
host's LAN.
Logic per spec §5.1:
* Single-address profile → that one address (normalized).
* Both set, default flag → public.
* Both set, shareUsesLocalUrl flag → local.
* Edge cases (both LAN, both public, missing one of the two): fall
back to the first endpoint in the list so the function is total.
Empty profile returns the normalized url (possibly empty) — defensive,
never throws. 7 unit tests pin all six branches plus the empty case.
Call-site migration (Orbit, copyEntityShareLink, QueuePanel, magic
string srv field, findServerIdForShareUrl, Orbit LAN warning) lands
in the next commits.
* feat(share): Orbit + entity + queue shares use serverShareBaseUrl
Four share-encoding surfaces previously read 'getBaseUrl()' (connect)
or the raw 'server.url' (primary) when embedding the host into outgoing
share links — both wrong for a dual-address profile:
* copyEntityShareLink (track / album / artist / composer) — was
getBaseUrl(); now reads serverShareBaseUrl(active). Guests opening
the link are off-LAN, so public is the right default.
* OrbitStartModal — was raw server?.url for both 'buildOrbitShareLink'
and the LAN warning. Now goes through serverShareBaseUrl; the LAN
warning correspondingly reflects the address the guest will see,
not the host's primary.
* OrbitSharePopover — same fix on the host-only share popover.
* QueuePanel.handleCopyQueueShare — was getBaseUrl(); same migration.
Single-address profiles return exactly the same string from
serverShareBaseUrl as serverProfileBaseUrl(server.url) did before, so
no behaviour change for the common case. shareUsesLocalUrl flips the
default to LAN for the rare 'share into a LAN-only group' use; that
checkbox lands with the form UI in the next sub-phase.
findServerIdForShareUrl + paste-side share matchers are migrated in
the next commit (read-side of the same contract).
* feat(share): paste-side resolves dual-address profiles + connect URL
Read-side counterpart to the encode-side migration:
* findServerIdForShareUrl(servers, shareSrv) now matches a profile
when shareSrv normalizes to either profile.url OR profile.alternateUrl.
Without this, a paste of a link generated against the host's LAN
address (shareUsesLocalUrl=true) would fail to find the local saved
profile even though it's the same server.
* resolveSharedSong / resolveShareSearchAlbum / resolveShareSearchArtist
in enqueueShareSearchPayload now hand connectBaseUrlForServer(lookup.server)
to the *WithCredentials HTTP calls instead of the raw lookup.server.url.
The looked-up profile may be dual-address; this routes the song / album /
artist fetch through whichever endpoint is currently reachable.
Indirect consumers (shareServerOriginLabel, shareQueueServerContext) read
the same findServerIdForShareUrl, so no code change needed there — they
now match both addresses transparently.
* feat(verify): serverFingerprint + same-server verification
Pure-logic core of dual-address verify. Three exports:
* fetchServerFingerprint(baseUrl, user, pass) — one ping (envelope
'version' extracted alongside type/serverVersion/openSubsonic) plus
four soft-fail optional calls in parallel (getMusicFolders, getUser,
getLicense, getIndexes). Optional failures collapse to null fields,
not whole-fingerprint failure. Subsonic-generic — never branches on
type === 'navidrome'. indexesDigest is a hash of letter-count plus
sorted first 20 artist ids, so two probes against the same library
see the same digest without comparing full payloads.
* compareFingerprints(a, b) — strict on the ping triple
(type case-insensitive, serverVersion exact, openSubsonic boolean);
envelope apiVersion informational only. Body signals counted only
when both sides have a non-null value; empty musicFolders [] on both
sides still counts as a matching signal. Result: 'match' (>=1 common
signal all agreeing) / 'mismatch' (any common differs) /
'insufficient' (0 common). No 'save anyway' for insufficient in v1.
* verifySameServerEndpoints(profile, user, pass) — single-address
short-circuits to ok:true (nothing to verify). Otherwise parallel
fingerprint probes, then pairwise compare. Ping-fail on any
endpoint reports the offending host for the UI.
20 unit tests cover the compare matrix (every body signal in every
direction), Navidrome- vs minimal-Subsonic-shape probes, ping-fail,
and all four verify outcomes (ok / unreachable / mismatch /
insufficient). HTTP mocked via vi.stubGlobal('fetch') + plain-object
Response shape (avoiding any Response-polyfill dependency).
* feat(boundary): resolve_host_addresses Tauri command + TS wrapper
Adds one additive invoke for dual-address form hints. Spec §9.1
+ contracts.md §6.
Rust side (src-tauri/src/lib_commands/app_api/network.rs):
* #[tauri::command] resolve_host_addresses(hostname: String) ->
Result<Vec<String>, String>
* tokio::net::lookup_host with a port suffix (':0'); discards the
port from each SocketAddr, dedupes addresses via HashSet.
* strip_port helper handles 'host:port', 'ipv4:port',
'[ipv6]:port', '[ipv6]' (no port), and bare 'ipv6' (left as-is
for lookup_host to wrap). 7 unit tests cover each shape.
* Lookup failure → Ok(vec![]) so a DNS hiccup doesn't surface as
an error toast or block the save flow — form-hint only.
Frontend wrapper (src/api/network.ts):
* resolveHostAddresses(hostname) — trims, invokes, swallows errors
to an empty array so consumers get a clean total function.
§04 boundary note: additive command, no breaking change. Added to
the invoke_handler! generate_handler list in lib.rs at the natural
alphabetical-ish slot near migration_run.
* feat(form): AddServerForm — second optional address + share flag + DNS hint
UI side of dual-address. AddServerForm now carries the four new
moving parts spec §6 calls out:
* Second address field ('Second address (optional)') under the
primary URL. Placeholder flips to suggest the opposite kind
of address based on the primary's LAN classification.
* Contextual hint under the second field when it's empty —
'add a public address for outside-home use' (primary is LAN)
or 'add a local address for faster home access' (primary is
public). Hint disappears once the field has content.
* Two-LAN client-side check on submit: when both addresses
classify as LAN, save is blocked with a toast. Reverse case
(both public) intentionally allowed per spec §6.3.
* shareUsesLocalUrl checkbox — visibility rule per spec §5.3:
hidden until the second address has content; shows with the
persisted value on edit; cleared when the user empties the
second address before save.
DNS hint: on primary-URL blur we call the Tauri
resolve_host_addresses command and classify the response by
isLanUrl. Literal IPs skip DNS (already classified locally). DNS
miss → no hint surfaces (never blocks save). Used only for the
hint text — connect still goes through pingWithCredentials.
i18n: 9 new keys in en + ru (serverAlternateUrl*, shareUsesLocalUrl*,
serverBothLanError). Other locales fall back to English.
onSave signature widened to 'void | Promise<void>' — ServersTab /
Login both accept that already. The save flow itself still calls
the legacy pingWithCredentials path; verify wiring lands in 2e.
* feat(verify): wire verifySameServerEndpoints into add + dual-edit flows
Save flow now blocks persisting a dual-address profile that fails the
same-server check. Spec §6.4 + §7.4.
handleAddServer:
* When data.alternateUrl is non-empty, runs verifySameServerEndpoints
BEFORE the existing ping. mismatch / insufficient / unreachable each
surface a localized toast and abort the save (no addServer call).
* Single-address adds keep the legacy single-ping path — no extra
network round-trip when there's nothing to verify.
handleEditServer:
* Unconditional save remains the default — but if the edit either
introduces / changes alternateUrl OR changes url / username / password
while alternateUrl is set, verify runs first and may block.
* After persist, invalidates the reachable-endpoint cache for this
profile id so any sticky cached connect URL from before the edit is
re-probed on the next access (credentials may have changed, alternate
may have appeared).
i18n: 4 new toast keys in en + ru (dualAddressVerifying placeholder for
future progress UI, plus mismatch / insufficient / unreachable). Other
locales fall back to English.
announceVerifyResult helper keeps the toast routing in one place so
handleAddServer and handleEditServer share the same surface.
* i18n(settings): dual-address keys across all 9 locales
Adds the 13 new dual-address keys to the remaining 7 locales:
de · fr · es · nl · nb · ro · zh.
* serverAlternateUrl + placeholderPublic / placeholderLocal
* serverAlternateUrlHintAddPublic / HintAddLocal (contextual hints
under the second-address field)
* serverBothLanError (client-side two-LAN validation)
* dualAddressVerifying / Mismatch / Insufficient / Unreachable
(toast strings; Unreachable carries the {{host}} interpolation)
* shareUsesLocalUrl + Desc (checkbox label + short description)
Placeholders (example.com URL / 192.168.1.100:4533) kept identical
across all locales — same shape the existing serverUrlPlaceholder
already uses.
Single coordinated sweep so every supported language ships dual-
address fully translated rather than falling back to English.
* feat(cover): cover_cache_rename_server_bucket Tauri command
Adds the disk-side companion to the upcoming URL-change remigration
flow (dual-server-address spec §8.3). When a user edits the primary
url so the derived index key changes, the SQLite migration already
re-tags rows via the existing migration_run command — this command
moves the cover-cache bucket on disk so cached WebP tiles stay
reachable under the new key.
Behaviour:
* old_key == new_key → no-op.
* Old bucket missing → no-op success (nothing to migrate).
* New bucket missing → simple fs::rename (fastest path).
* Both exist → recursive merge with 'prefer existing' on file
collision (the newer bucket wins; data is never lost).
* Emits 'cover:bucket-renamed' with {oldKey, newKey} on success
so the frontend disk-src cache can invalidate stale URLs.
Sanitization: rejects empty, backslash, and '..' path segments at
the FS boundary. Forward slashes are legitimate (a Navidrome
mounted at a subpath like 'music.example.com/navidrome' produces
an index key with one); they're handled by Path::join.
4 unit tests cover key sanitization (accepts real keys, rejects
traversal/backslash) and the merge semantics (unique-file move +
prefer-existing on collision). Registered in lib.rs invoke handler.
* feat(remap): rewriteFrontendStoreKeysForRemap — explicit oldKey→newKey path
Adds the URL-change remigration entry point next to the existing
UUID→indexKey migration. Same plumbing (offline store, hot cache,
analysis-strategy maps) but driven by explicit { oldKey, newKey }[]
mappings instead of being derived from the current servers list.
Also folds in the player-side cleanup the existing path didn't have:
* queueServerId — if currently bound to a remapped index key,
repoint to the new one so playback continues through the rename
instead of looking unbound.
* analysisStrategyStore — runs the same map-key swap inline (the
'migrateServerOverrides' helper handles UUID→indexKey, not
indexKey→indexKey).
Same prefer-existing-on-collision semantics as the disk-side
cover_cache_rename_server_bucket — if a destination key already
carries data, we keep it. 8 unit tests cover no-ops, the four
store rewrites, the queue repoint, the 'leave other servers
untouched' guarantee, and the collision case.
* feat(remap): serverUrlRemigration orchestrator — 4-stage pipeline
Single-file orchestrator that runs the full URL-change remigration when
a profile edit shifts the primary url's derived index key. Spec §8 +
contracts.md §4.
Two exports:
* indexKeyRemapForUrlChange(prev, next): IndexKeyRemap | null
— short-circuits scheme-only edits ('http://x' ↔ 'https://x'),
trailing-slash differences, and alternateUrl-only changes by
normalizing both sides through serverIndexKeyFromUrl and
returning null when they match. Empty urls also return null.
* runIndexKeyRemigration(remap): Promise<IndexKeyRemigrationResult>
— runs inspect → run → frontend-rewrite → cover-rename in order,
aborts on the first failure and reports the offending stage.
Failure ordering rationale:
* inspect / run failures stop the destructive step — DB is
untouched, the caller can retry safely.
* frontend rewrite swallows errors (best-effort; zustand persist
catches up on rehydrate next session).
* cover-rename failure is reported but does NOT roll back the DB
rows (that would be even more destructive); covers under the
old key recover via the existing cover backfill on next access.
10 tests cover the detect logic (5 cases including the path-suffix
case) plus all four pipeline stages with mocked Tauri invoke —
including verifying the legacyId/indexKey mapping shape lands on
both inspect and run calls.
* feat(remap): wire URL-change remigration into ServersTab edit flow
handleEditServer now detects a primary-url index-key change BEFORE
any other save logic (verify, persist, etc.) and orchestrates the
full remigration when one is needed.
Flow:
* indexKeyRemapForUrlChange(prev, next) — null for scheme-only
edits / alternateUrl-only edits / no-op edits, so the common case
falls straight through without prompting the user.
* On a real remap → confirm modal via useConfirmModalStore.request
(danger style, plain-language oldKey → newKey copy, explicit
'cannot be undone'). User cancel → abort save entirely.
* On confirm → runIndexKeyRemigration pipeline. Failure surfaces a
stage-specific toast (inspect / run / cover-rename — wording per
spec §8.4 + the recoverability matrix in serverUrlRemigration.ts).
* On success → fall through to the existing dual-address verify,
then auth.updateServer + invalidate cache + ping (unchanged).
i18n: 7 new keys (urlRemigrationTitle / Message with oldKey + newKey
interpolation / Confirm / Progress / FailureInspect / FailureRun /
FailureCoverRename) — full sweep across all 9 locales (en, de, ru,
fr, es, nl, nb, ro, zh).
Test fixup: the offline-store collision test in rewriteFrontendStoreKeys
needed an 'as unknown as' cast — the existing OfflineTrackMeta type
doesn't carry the test-only marker property, and a one-step cast
TS rejected as not overlapping enough.
* feat(magic): magic-string v2 encode/decode with dual-address fields
Adds the v2 wire format for server invites. Spec §10 + contracts.md §5.2.
Encode is opportunistic: payloads with alternateUrl set OR
shareUsesLocalUrl=true emit v2; everything else stays v1 byte-identical.
This means single-address profiles keep producing exactly the same
invite they did before — older receivers can't tell the difference.
v2 shape: { v: 2, url, alt?, shareLocal?, u, w, n? }
* url — the host's share URL (public by default; LAN if shareLocal),
NOT necessarily the host's primary URL. Receiver treats it as the
primary of the new profile (their own index key).
* alt — the host's alternate address (the other half of the pair).
* shareLocal — mirrors the host's shareUsesLocalUrl preference so
onward shares from the receiver behave the same way.
Decode accepts both v1 and v2. v2-only fields are left undefined when
decoding a v1 payload (no '|| undefined' shim — kept absent so zustand
persist diffs stay clean). Defensive: an empty alt on a v2 invite
decodes as if the field were absent, never as ''.
Test updates: 'rejects a payload with the wrong version' now targets
v: 3 (v: 2 became valid). 7 new tests cover the v1/v2 wire-format
choice, both v2 round-trip shapes, the v1-decode-into-v2-fields-
undefined backward-compat case, and the empty-alt edge.
* feat(magic): wire magic-string v2 through invite-encode + paste consumers
Five surfaces now produce / accept v2 invites with the dual-address
fields end-to-end:
Encode side (admin → user invite):
* MagicStringModal — looks up the saved profile that matches the
serverUrl prop (primary OR alternateUrl normalize-match) and
encodes through serverShareBaseUrl + alternateUrl + the share
flag. Falls back to plain v1 for single-address profiles.
* UserForm — same lookup + encode plumbing on the in-form
'Save & get magic string' flow.
Decode side (paste invite into form):
* AddServerForm useEffect (initialInvite from outer state) +
handleMagicStringChange (live paste) both pull alternateUrl
and shareUsesLocalUrl off the decoded payload into form state,
so the second-address field + checkbox surface immediately on a
v2 paste.
* Login form state gains hidden alternateUrl + shareUsesLocalUrl
fields (not user-editable — Login stays single-address by
design); v2 paste / initial-invite both populate them so they
persist with the new profile via addServer. handleQuickConnect
likewise forwards the existing dual-address shape for
already-saved profiles.
* attemptConnect signature widened with the two optional fields;
addServer / updateServer call sites conditionally include them
only when alternateUrl is non-empty so single-address profiles
keep their lean persisted shape.
Both encode helpers share the same magicPayloadAddressFields lookup
(MagicStringModal + UserForm) — the duplication is acceptable for
two co-located small files but a single shared helper would be the
natural place to consolidate if a third encoder appears.
* fix(form): magic-string submit forwards decoded alternateUrl + share flag
AddServerForm.submit's magic-string branch was forwarding only
name/url/username/password from the decoded payload, silently dropping
alternateUrl + shareUsesLocalUrl. A v2 invite pasted into the
magic-string field and saved would land in the store as a single-
address profile even though handleMagicStringChange had already
populated the dual-address fields into form state for display.
Now the magic branch picks the dual-address fields off the decoded
payload (same conditional spread as the non-magic branch a few lines
down) so v2 invites round-trip end-to-end through the form.
* fix(verify): extractUserId drops username fallback to avoid false mismatches
Spec §7.2 footnote: 'fallback (normalized username) only if no id on
either side'. The old extract unconditionally fell back to the
authenticated username whenever user.id was empty — but the
comparator can't tell username-fallback apart from a server-supplied
id. So a server pair where endpoint A returns user.id='42' and
endpoint B only returns user.username='frank' would land in compare
with userId values '42' vs 'frank' and report mismatch on what is
actually the same user on the same server.
Now extractUserId returns null when user.id is absent. Both sides
returning null means compareFingerprints skips userId as a common
signal (correct behaviour per the §7.3 'both sides have a value'
rule). One new test pins it; the call site drops the redundant
fallbackUsername argument.
* fix(remap): rewriteFrontendStoreKeysForRemap migrates coverStrategyStore
Spec §8.2 lists 'analysis/cover strategy maps' as targets of the
URL-change remigration. The For-Remap path covered analysis but
missed cover — a user-set cover strategy override (e.g. 'aggressive'
for one server) would silently drop when the user edited that
profile's primary URL, because the key tagged under the old index
key never made it to the new one.
Same shape as the analysis remap inline: prefer-existing on
collision (newer key wins), delete the legacy entry afterwards.
One new test pins the move; setup adds a fresh useCoverStrategyStore
reset between cases.
* fix(cover): is_safe_index_key rejects absolute paths + drive letters
Defense-in-depth gap in cover_cache_rename_server_bucket. The old
sanitizer only rejected backslashes and '..' segments — '/etc/passwd'
on Unix and 'C:\Windows' on Windows both passed. Path::join with an
absolute argument REPLACES the base path, so root.join('/etc/passwd')
on Unix would walk out of cover-cache entirely.
Real index keys never reach this state — they come out of
serverIndexKeyFromUrl which strips schemes and trailing slashes, so
no leading separator and no drive-letter prefix is ever produced.
But the comment promises 'defense in depth' and that defense is
worth completing.
Now rejects:
* empty keys (would join to the cover-cache root itself)
* leading '/' or '\'
* Windows drive-letter prefix 'X:' (case-insensitive ASCII letter)
* backslashes anywhere (separators are forward-slash only)
* '..' segments after split('/')
One new Rust test covers all four cases.
* fix(connection): isLan badge reflects active endpoint, not primary url
useConnectionStatus.isLan was reading isLanUrl(server.url) — the
*primary* address — so a dual-address profile that had fallen over to
its public alternate kept advertising 'LAN' in the badge until the
user looked at the server URL itself. Spec call-site-checklist line
25: 'active endpoint kind for badge (LAN vs extern)'.
Now: ensureConnectUrlResolved returns probe.endpoint.kind on success;
the hook tracks the latest kind in local state and surfaces that.
Pre-first-probe fall-through keeps the old primary-URL classification
so the badge has something to render at mount time.
Three tests pin the three states (LAN-active after probe, public-
active after fallback, primary-fallback before first probe completes);
plus one for the online-event handler that invalidates the cache and
re-probes.
* fix(server): dedupe concurrent pickReachableBaseUrl calls for same profile
Multiple call sites probe on roughly the same beat — useConnectionStatus
120-s tick + online handler + initial mount, switchActiveServer,
bindIndexedServer, and ServersTab.testConnection. Two near-simultaneous
probes for the same profile both observed an empty cache, both pinged
every endpoint, and raced to write the sticky URL with last-write-wins.
On a dual-address profile the slower probe could stomp the correct
LAN sticky a millisecond after it was set.
Now: an in-flight Map<profileId, Promise<PickReachableResult>>; a
second call for the same id during an active probe returns the same
promise instead of starting a fresh one. The finally-clear keeps the
dedup window narrow (one settled probe → next call probes fresh).
invalidateReachableEndpointCache deliberately doesn't touch the
in-flight map — the racing probe's own finally will clean up.
Three tests pin it: two concurrent calls share one ping; a fresh
call after the previous settled does ping again; plus the existing
shareBaseUrl two-LAN-with-flag-on test + the two-public-default test
for completeness.
* refactor(magic): extract magicPayloadAddressFields to single shared helper
The lookup that turns a serverUrl into the v2-encode-ready
{ url, alternateUrl?, shareUsesLocalUrl? } shape was duplicated
byte-identically between MagicStringModal.tsx and UserForm.tsx —
both Navidrome admin user-mgmt encode sites. Workdocs §03 / §1a
'one source of truth for behavior'.
Now lives in src/utils/server/serverMagicString.ts as a named
export. Both call sites import + pass the current servers list
(via useAuthStore.getState().servers) — keeps the helper pure so
it's straight to unit-test if a third encoder ever appears.
* fix(cover): wire cover:bucket-renamed listener for URL-change remigration
cover_cache_rename_server_bucket emits cover:bucket-renamed on
successful rename / merge, but no frontend listener was wiring it in.
After a URL-change remigration the in-memory disk-src cache still
held entries pointing at `{root}/{oldKey}/…/.webp` paths the disk
no longer carries — the next read would serve a stale URL until
the entry naturally aged out.
New: forgetDiskSrcForServer(serverIndexKey) in diskSrcCache drops
every cover entry under one server key in one pass (the existing
forgetDiskSrcPrefix needs a non-empty coverArtId and can't blanket-
clear a server). useCoverArtBridge subscribes to cover:bucket-
renamed and calls it with oldKey.
Tests: forgetDiskSrcForServer happy path, empty-key defensive
no-op, no-match no-op; plus a regression-pin on the existing
forgetDiskSrcPrefix so the two helpers don't drift in semantics.
* test(dual-server): fill the high-impact coverage gaps from the branch review
Adds the tests the review identified as missing on the hot-path UI
flows + the partial-fail / alt-only edges:
* AddServerForm.test.tsx (new) — five component tests:
single-address save / dual-address save / two-LAN block-with-
toast / v2 magic-string paste forwarding alt + share flag /
edit-flow stripping alt+flag when the field is emptied.
* Login.test.tsx (new) — v2 invite paste persists alternateUrl +
shareUsesLocalUrl onto the saved profile; v1 invite leaves
those fields undefined.
* shareLink.test.ts — alternateUrl-only match for
findServerIdForShareUrl (the dual-address paste case where the
host shared the LAN URL via shareUsesLocalUrl=true); 'first hit
wins' across two profiles where both match.
* serverFingerprint.test.ts — partial-fail mix (folders+user ok,
license+indexes rejected) so a Promise.allSettled regression
wouldn't go silent.
* cover_cache/mod.rs — rename_bucket_inner extracted as a
testable FS-only helper (the Tauri command wrapper now just
locks state + calls it + emits the event), plus six tests
covering empty/unsafe keys, no-op-when-missing, no-op-when-
equal, simple-rename, merge-with-prefer-existing.
Also: every test fixture that I authored on this branch using
'frank' / 'frank@example.com' as a stand-in username/email
swapped to 'tester' / 'tester@example.com' — keeping personal
names out of test data is the codebase convention (see
factories.ts) and is now a memory rule.
* fix(test): typed mock access for pingWithCredentials in useConnectionStatus
vi.mocked(...) returns the mock typed properly — bare .mock fails
tsc because the imported function signature isn't a vi.Mock at
import time.
* docs(changelog): dual server address (PR #880)
* fix(test): align diskSrcCache tests with main cover storage key API
After rebase onto main, forgetDiskSrcPrefix takes a CoverArtRef-shaped
argument and storage keys include cacheKind; merge coverDiskUrl tests
from main with dual-address forgetDiskSrcForServer coverage.
|
||
|
|
df3533bb5a |
fix(cover): Windows thumbnails, tier fallback, PNG decode, coverArt id (#878)
* fix(cover): tier fallback for sparse surfaces and Windows asset URLs Sparse UI (player bar, queue) now reads disk covers via the same tier ladder as dense grids, so a warm 800.webp satisfies a 128px request. Reject non-asset convertFileSrc results on Windows, widen Tauri asset scope, and seed ladder keys on cover:tier-ready. applyDiskPath uses seedGridDiskSrcCache only to avoid notify/subscriber infinite loops. * fix(artist): top-track thumb uses album coverArt already warm in grid Song coverArt ids often differ from album cover ids (e.g. Octastorium in the grid vs empty track thumb). Prefer the album row's coverArt on artist pages and ensure high priority for 32px dense cells. * fix(cover): albumId for playback/queue; no broken img until disk URL ready Prefer albumId over track-id coverArt (Navidrome). Wire queue to CoverArtImage with playback scope. CoverArtImage renders a placeholder div until asset src exists to avoid the browser broken-image icon. * fix(test): add song id to resolveArtistPageSongCoverArtId fixture Pick<SubsonicSong, …> requires id; fixes tsc in CI/build. * fix(cover): resolve albumId for Now Playing and artist top tracks Prefer albumId when album.coverArt echoes track id; use sparse surface on artist suggestion thumbs; apply resolveSubsonicSongCoverArtId across playback surfaces (Now Playing, fullscreen, mobile, mini). * fix(cover): decode PNG from Subsonic before WebP tier encode Enable `png` in the image crate — some servers return PNG cover art; failed decode left `.fetch-failed` and empty thumbs for those albums. * refactor(cover): consolidate cover id resolution and align tests Move resolveSubsonicSongCoverArtId helpers to src/cover/resolveCoverArtId.ts with resolvePlaybackTrackCoverArtId for player surfaces; co-locate tests; fix FullscreenPlayer expectations for albumId-first resolution. * docs: CHANGELOG and credits for PR #878 * fix(cover): keep per-track coverArt when distinct from song id Address PR #878 review (b): albumId only when coverArt is missing or echoes track id; pin case with unit test; comment isRawFsPath symmetry. * chore(cover): address PR #878 review nits (scope, tests, rename) Narrow asset scope to cover-cache dirs only; add diskSrcCache Windows-path tests; rename ArtistTopTrackCover; CHANGELOG symptom-first wording. * fix(cover): restore asset scope to app data dirs (Windows regression) $APPDATA/cover-cache/** did not match Tauri scope resolution — covers were blocked after load. Use $APPDATA/** and $APPLOCALDATA/** (no $DATA). * fix(cover): Windows asset URLs — restore DATA scope, path normalize Regression after review nits: dropped $DATA/** and strict isAssetProtocolUrl blocked valid http://asset.localhost URLs on Windows. Normalize C:/ paths before convertFileSrc; CoverArtImage/Hero hide broken img on load error. * fix(cover): disk peek fallbacks when cache folder id differs Small surfaces resolve albumId while cover-cache often stores WebP under track id or album.coverArt from the grid. Peek batch now tries legacy ids; playback scope resolves server index key by URL key, not UUID-only lookup. * fix(cover): Navidrome al-* vs mf-* disk id mismatch UI used mf-* coverArtId while library backfill only cached al-* folders. Prefer album id for display/peek when coverArt is mf-*; backfill now queues both distinct album_id and cover_art_id values. * fix(cover): mf→al disk peek when mf folder missing in cache Navidrome Subsonic often returns mf-* coverArtId while backfill only creates al-* folders. Peek mf first, then al-* from hints; load albumId from library when Subsonic omits it; ensure fallback uses al-* id. * feat(cover): CoverArtRef, segment disk layout, library-index backfill Normalize cover caching around stable entity ids from the local library and Navidrome fetch ids. Disk paths live in psysonic_core::cover_cache_layout (album/<entityId>/); UI uses CoverArtRef with cacheEntityId + fetchCoverArtId. - Remove SQLite/mf peek helpers (diskPeekIds, peekCoverOnDisk, mergeDiskIdHints) - Backfill reads album/artist rows from library SQLite (bare Navidrome ids ok) - Use stored cover_art_id for HTTP; per-disc dirs only when discs differ - Migrate call sites to albumCoverRef / albumCoverRefForPlayback * feat(cover): central CoverEntry resolver (artist, album, track) Add resolveEntry.ts and Rust CoverEntry helpers as the single source of truth for cache_entity_id vs fetch_cover_art_id. ref.ts delegates to them; resolveCoverArtId becomes a thin compatibility shim. * feat(cover): resolve cover entries from local library index Add library_resolve_cover_entry IPC and cover_resolve.rs so album, artist, and track covers use SQLite cover_art_id + disc detection. TypeScript helpers in resolveEntryLibrary.ts prefer the index over live API fields when rows exist. * feat(cover): library-first hooks for grids and playback UI Add useAlbumCoverRef, useArtistCoverRef, useTrackCoverRef, and usePlaybackTrackCoverRef — sync fallback then SQLite index upgrade. Wire album/artist cards, album header, song card, and all player surfaces to resolve covers from the local library when indexed. * feat(cover): complete library-first migration across all UI surfaces Add Album/Artist/TrackCoverArtImage, useLibraryCoverPrefetch, and batch resolve helpers. Migrate grids, search, home, playback sidecars, warm peek, playlists, and share flows to hooks that upgrade from SQLite. Backfill normalizes album rows through cover_resolve; document paths in COVER_PATHS.md. Radio remains a deliberate non-library exception. * fix(cover): stop render loop from unstable serverScope in library hooks Default param `{ kind: 'active' }` created a new object every render, so every grid cell re-ran library_resolve IPC and setState in a loop. Use COVER_SCOPE_ACTIVE singleton, coverScopeKey deps, and guarded sync updates. * chore(cover): remove COVER_PATHS.md from app tree (lives in workdocs) Audit doc is team spec — see workdocs 2026-05-cover-art-pipeline/cover-paths-audit.md. * fix(cover): unstick library backfill after route changes (PR #870 regression) useCoverNavigationPriority cleanup called beginNavigation instead of end, leaking navigationHoldDepth so ui_priority_hold never released and backfill never downloaded. Also skip disk check after cover_resolve normalization. * fix(cover): segment progress, cap backfill CPU, include artists in catalog Progress and disk size now scan album/ and artist/ segments (canonical 800.webp). Prune legacy flat server/al-* dirs on startup and backfill pass. Backfill: max 2 concurrent ensures; JPEG decode and WebP encode run on the blocking pool behind a shared 2-permit semaphore so Tokio workers stay cool. Artists were missing because the catalog only read the empty artist table; add distinct artist_id from track and album rows. Paginate with a composite (kind, id) cursor so album and artist rows are not skipped. * fix(cover): drop legacy prune; backfill per-disc and artist catalog Remove prune_legacy_* and cover_cache_catalog_entry — layout is only cover_dir (album|artist segments); stale flat dirs clear on LAYOUT_STAMP change. Backfill: artists from track/album artist_id; expand albums to per-CD mf-* slots when discs differ; fix resolve_album_cover_entry when album row is missing. * fix(cover): reduce library IPC storms and fix multi-disc player art Skip per-row library_resolve on live search and artist album grids; warm grids from API coverArt after mount instead of blocking layout. Dedupe and cap concurrent library_resolve calls. Restore per-disc cache keys in the player and queue when track mf-* art differs from the album bucket. * fix(cover): skip library resolve on advanced and full search rows Use API coverArt for album/artist rails and lazy viewport artwork so result pages do not fire hundreds of library_resolve IPC calls at once. * fix(cover): default libraryResolve off for browse grids and rails Skip per-card library_resolve on album/artist/song browse UI by default; keep it on album/artist headers, playback queue rows, and orbit approval. * fix(cover): split UI/backfill CPU pools and restore mainstage hero carousel Library backfill no longer shares the 2-permit JPEG/WebP semaphore with visible cover ensures. Hero initializes albums from props, re-binds scroll visibility after mount, updates backdrop on slide change, and uses library resolve for correct cover art on the banner. * fix(analysis): resume full-library scan after candidates phase Reset the SQL cursor when entering full-library mode so tracks with partial analysis are not skipped. Tighten TS backfill completion and CPU queue watermarking; align cover-cache key tests with album-scoped storage keys. * fix(library): remove useless map_err in cover_resolve (clippy) CI treats clippy::useless-conversion as error on rusqlite optional() chains. * fix(cover): satisfy clippy on cover_cache_ensure IPC args Pass CoverCacheEnsureArgs as a single Tauri parameter instead of nine positional fields; align frontend invoke payload with { args }. |
||
|
|
fab6ff19bf |
fix(home): Discover Songs covers for local-index tracks (#874)
* fix(home): pre-warm and prefetch Discover Songs row covers The Discover Songs raблин. il came out of the cover pipeline merge with two gaps that left its cards stuck on the placeholder disc icon on cold caches. - `warmHomeMainstageCovers` walked `heroAlbums` / `recent` / `random` through `ensureAlbumCoverMisses` + `predecodeWarmAlbums` but skipped `discoverSongs`, so songs that were peeked but missed on disk had to wait for lazy per-card ensure - `Home.tsx`'s `coverPrefetchRegister` lumped `songRefs` into a `cappedRest` slice already saturated by 48 album refs + 16 artist refs at a 24-entry cap, so the song row's background prefetch was discarded entirely Fix: ensure + decode-warm the Discover Songs cells alongside the album rails, and register the song refs in their own bucket with a sane cap and `middle` priority. Both follow the same shape as the working album rails — no behavior change for surfaces that were already painting. * fix(library): resolve track cover art from albumId for local index songs Discover Songs uses runLocalRandomSongs; trackToSong only mapped coverArtId, so rows with empty cover_art_id but a valid album_id showed the disc placeholder. Mirror Rust COALESCE(cover_art_id, album_id) and Live Search's coverArt ?? albumId in trackToSong, SongCard, and Home prefetch. * docs(release): note Discover Songs cover fix in CHANGELOG and credits (PR #874) * docs(release): credit PR #874 to Psychotoxical and cucadmuh jointly * chore(credits): drop PR #874 from settingsCredits — minor fix --------- Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> |
||
|
|
45b9229ceb |
refactor(queue): thin-state refs as canonical, full Track via resolver (#872)
* refactor(queue): wire queue UI to the track resolver (thin-state phase 3) cucadmuh's phase-3 steps: - Selectors (useQueueTracks) read resolver-first: getCachedTrack → queue: Track[] fallback (until phase 4), F4 star/rating overrides merged on read. - QueueList rows source their track from the resolver (queue fallback); rows show title/artist/duration only, so no override merge there. - pendingStarSync star/rating success → invalidateQueueResolver so the cache reflects the synced value. - queueResolverBridge re-seeds on queueIndex change too — the prefetch window travels with the playing track. Additive: queue: Track[] stays canonical and behaviour is unchanged (rows resolve to the same data). Phase 4 drops queue: Track[] and the fallbacks. * docs(changelog): queue panel reads through track cache (#860) * fix(queue): stop a render loop that froze the UI on long queues A long virtualized queue + a track change could lock the WebView for ~2 min: - useVirtualizer was handed a fresh `initialRect` object literal every render, so it kept re-initializing in a loop. Hoisted it to a stable module constant. - getCachedTrack did an LRU bump (Map delete+set) during render — a render-time side effect. Made it a pure read; recency is set at write time in cacheSet. * perf(mobile): virtualize the mobile player queue drawer The mobile now-playing queue drawer rendered the full queue with .map; a multi-thousand-track queue meant thousands of DOM nodes. Virtualize it with @tanstack/react-virtual (uniform rows, stable initialRect) so the DOM stays at O(visible rows), matching the desktop QueuePanel. Active track is centred on open via scrollToIndex. * perf(mini): virtualize the mini-player queue list The mini-player queue rendered the full MiniSyncPayload queue with .map. Virtualize it against the OverlayScrollArea viewport (stable initialRect) so the mini window's DOM stays at O(visible rows). Drag-reorder is preserved: rows keep data-mq-idx alongside the virtualizer's measureElement. * refactor(queue): add resolveQueueTrack/getQueueTracksView helper (thin-state phase 4) Render-safe ref→Track view for the phase-4 consumer migration off queue: Track[]. Resolver cache → caller fallback (legacy queue[idx] during dual-write) → placeholder; ref queue-only flags carried, F4 overrides merged. Pure synchronous read, no cache mutation (the freeze landmine), so it is safe in render. * refactor(queue): keep queueItems as the canonical in-memory mirror (thin-state phase 4) Step 1b: dual-write the thin queueItems ref list at every queue write site (the 11 mutations, next/radio top-up, playTrack, undo/redo restore, instant-mix, radio, server-queue init, lucky-mix rollback, and hydrate) so it tracks queue: Track[] in memory, not only at persist time. Identity-preserving maps (star/rating overrides) keep the same refs and are intentionally left untouched. Resolves the restore double-role flagged for 1b: queueItemsIndex is now the restore-pending sentinel that gates hydrateQueueFromIndex, while queueItems stays canonical -- rebuilt from the whole queue after a full hydrate instead of cleared. Normal mutations never set the sentinel, so it only fires on a fresh cold-start restore, not on later server switches. No behaviour change; queue: Track[] stays the source consumers read until phase 3. tsc + full vitest suite (1119 tests) green. * refactor(queue): mobile queue drawer reads through the track resolver (thin-state phase 4) Step 2: the mobile now-playing queue drawer resolves each row's track from the resolver cache (→ queue: Track[] fallback until phase 4), matching the desktop QueueList wired in the phase-3 commit. Subscribes to the resolver version so rows re-render as the cache fills. Structure (count, order, keys, the playTrack arg) still comes from queue: Track[] until it is dropped in the final step. The mobile drawer was the last queue display surface still reading track metadata straight off the fat queue. tsc + full vitest suite green. * refactor(queue): ref-native queue mutations + dual-write bridge (thin-state phase 4) Step 3a: the 11 queueMutationActions now splice/filter/reorder QueueItemRef[] (matching by trackId + the ref's queue-only flags) instead of Track[]. `bridgeQueueFromItems` rebuilds the dual-written queue: Track[] from the new refs by id — purely structural (no resolver/override merge), so behaviour is byte-identical and playerStore.queue.test.ts stays unchanged green. The working ref list comes from `itemsOf(state)` (derived from queue: Track[] for now); the final step swaps that one line to state.queueItems once the fat queue is gone. enqueue / enqueueAt / enqueueRadio seed the resolver cache with incoming tracks (seed-before-splice) so they resolve without a network round-trip after the fat queue is dropped. Adds a DEV-only id-parity guardrail (queue vs queueItems); dev-runtime only, silent in vitest and prod. tsc + full vitest suite (1119) green; contract test unchanged. * refactor(queue): ref-native radio/infinite top-ups (thin-state phase 4) Step 3b: nextAction's proactive infinite-queue and radio top-ups build the new queue as QueueItemRef[] and bridge back to queue: Track[] (same as the queue mutations), and seed the resolver cache with the freshly fetched tracks so they resolve without a network round-trip after the fat queue is dropped. The radio top-up keeps its HISTORY_KEEP front-trim, now expressed on refs. The exhausted-queue refill paths hand their new queue to playTrack, which keeps its fat-queue handling until the final step (its no-arg case needs the resolver- derived queue that lands with the queue: Track[] removal). tsc + full vitest (1119) green; contract test unchanged. * refactor(queue): undo snapshots store thin refs, not Track[] (thin-state phase 4) Step 4: QueueUndoSnapshot.queue: Track[] becomes queueItems: QueueItemRef[], killing the undo "hidden multiplier" — 32 snapshots of a 50k queue now cost refs, not 32×50k full tracks. applyQueueHistorySnapshot rebuilds the display queue from the refs via resolveQueueTrack: resolver cache → the live queue by id (covers tracks the edit didn't remove) → placeholder. currentTrack stays a full track in the snapshot and is restored to the engine unchanged. The snapshot refs derive from queue: Track[] for now (so the undo/redo contract cases, which seed only `queue`, stay green); the final step swaps that to [...s.queueItems]. tsc + full vitest suite (1119) green. * perf(mini): cap the mini-player queue snapshot to ±100 around the current track (thin-state phase 4) Step 5: the mini bridge no longer serializes the full queue over IPC on every push — a 50k Artist-Radio queue would otherwise re-encode in full on every track advance. snapshot() sends a window of 100 tracks before/after the playing song; queueIndex is made slice-relative. The mini component stays unchanged (slice- relative); jump/reorder/remove control events are translated back to absolute queue indices via the window offset captured on the last push. tsc + full vitest suite (1119) green. Mini bridge has no unit tests — needs a quick mini-player smoke (queue shows ±100, jump/reorder/remove land correctly). * refactor(queue): make queueItems a required PlayerState field (thin-state phase 4) Foundation for the final consumer migration off queue: Track[]: queueItems has been written at every queue write site since phase 1b, so promoting it from optional to required is a no-op at runtime (tsc confirms zero new errors) and lets the upcoming reader migrations read state.queueItems without `?? []` noise. * refactor(queue): migrate structural queue readers off queue: Track[] (thin-state phase 4) First reader batch toward dropping queue: Track[]: the queue-length selectors (usePlaybackServerId, usePlaybackCoverArt, useQueuePanelDrag, useMiniQueueDrag) now read state.queueItems.length, and FullscreenPlayer's next-track cover prefetch resolves through useQueueTrackAt instead of indexing the fat queue. All behaviour- identical during dual-write (queueItems is in lockstep with queue). tsc + full vitest suite (1119) green. Note: getPlaybackServerId() (playbackServer.ts) deliberately stays on queue for now — it is called from many partially-mocked test stores, so it migrates with the final field removal where the seedQueue helper covers those tests. * refactor(queue): QueuePanel save/share/playlist read queueItems (thin-state phase 4) The id/length reads (save to playlist, share link, create playlist, empty-queue guards, next-tracks divider) now read state.queueItems instead of the fat queue. Behaviour-identical during dual-write; queue: Track[] stays for the rendered QueueList + auto-scroll until the field is dropped. tsc + full suite (1119) green. * refactor(queue): drop queue: Track[] — thin queueItems is the only queue (thin-state phase 4) The store no longer holds the fat queue. `queueItems: QueueItemRef[]` is the sole canonical queue; full `Track`s resolve on demand via the resolver (index batch → getSong fallback, bounded LRU cache); only `currentTrack` stays a full Track. At 50k tracks the store holds ~hundreds of resolved tracks + the refs, not 50k Track objects. - **Persist:** partialize is refs-only (no windowed slice / PERSIST_QUEUE_HALF). A `merge` migrates every historical blob shape → `queueItems` (existing `queueItems` → legacy `queueRefs` → pre-ref windowed `queue: Track[]`) and drops the obsolete `queue` key, so saved queues survive the upgrade. - **Restore (decision B):** `hydrateQueueFromIndex` eager-resolves the whole ref list into the cache on cold start (index → getSong, so an index-off queue still plays), clears the restore sentinel. - **Resolver bridge:** keeps `[idx-50, idx+200]` warm via `resolveVisibleRange`. - **Mutations / actions / playback:** operate on refs; the playing track is `currentTrack`, the next/neighbour tracks resolve from the cache. Navigation (next/previous/row-jump) keeps `queueItems` and only moves the index — no full resolve or queue rebuild per track change. - **Persist tests** cover the three old-blob migrations; `seedQueue` test helper replaces the `setState({ queue })` seeds. tsc + full vitest suite (1115) green. Behaviour-preserving by the test contract; the gapless track change + cold-start restore + mini cap still want a live smoke before merge. * fix(queue): star/rating keeps the queue row resolved instead of blanking to "…" (thin-state) Rating/starring a queue song flashed the row's title to the "…" placeholder until the next track change. Root cause: on sync success pendingStarSync called invalidateQueueResolver, which DROPPED the cached track — and with queue: Track[] gone there's no fat fallback, so the row resolved to a placeholder until the resolver bridge re-fetched the window. Fix: add patchCachedTrack(trackId, patch) and use it on star/rating success to update the cached entry in place (title kept, synced starred/userRating applied) instead of dropping it. No placeholder flash, no re-fetch. tsc + full vitest suite (1115) green. * fix(player): quota-safe persist so a full localStorage can't kill playback A very large queue (~50k refs) overflows the localStorage quota; the persist write then threw QuotaExceededError from inside set(), which aborted playTrack before audio_play — no audio output at all. Back the player persist with a quota-safe storage wrapper so a failed write degrades to a no-op instead of throwing. Restoring the full ref list at that ceiling (vs a windowed cap) is left as a follow-up. * polish(player): throttle the quota-skip persist warning to once per key The quota-safe persist logs a skip on every failed write; on a huge queue that floods the dev console once per mutation. Warn once per key per quota-exceeded streak, re-armed when a write to that key next succeeds. * fix(queue): port new cover-pipeline readers to thin-state Main's cover pipeline (#870) reads s.queue.length and seeds the player store with queue: [track] in its tests. Under thin-state, queue: Track[] no longer exists — the canonical queue is queueItems: QueueItemRef[]. These four files were brought across in the merge but still spoke the old shape; this commit aligns them with the thin-state contract. - src/cover/usePlaybackCoverArt: queueLength = queueItems.length - src/cover/usePlaybackCoverArt.test: seed via toQueueItemRefs - src/api/coverCache.test: same - src/hooks/useNowPlayingPrewarm.test: same (two test cases) * fix(queue): canonicalize thin-state server identity for mixed-server queues `QueueItemRef.serverId` and `PlayerState.queueServerId` are now written as the URL-derived index key on every writer path, matching the library index direction. Mixed-server queues with duplicate `trackId` across servers stay unambiguous because the resolver cache, persistence, and playback bindings all share one key shape. - new `canonicalQueueServerKey()` helper (idempotent UUID-or-key normalizer) - `toQueueItemRefs`, `bindQueueServerForPlayback`, `seedQueueResolver`, and `hydrateQueueFromIndex` emit canonical keys - `getCachedTrack` falls back to the canonical lookup so refs persisted in the legacy UUID shape still resolve through the migration window - persist `merge` rewrites `queueServerId` and every ref `serverId` on rehydrate, so the live store never holds mixed shapes - `removeServer` compares against the resolved id so a profile delete still clears the matching queue binding - the two `playbackServer.test.ts` asserts that hard-coded the UUID shape are updated to the canonical key (existing reader-tolerance is unchanged) * fix(queue-undo): bind snapshot prepend to snapshot-canonical server identity When `applyQueueHistorySnapshot` has to prepend the still-playing track (the snapshot's queue does not contain it), the new ref must follow the snapshot's playback server, not the live `queueServerId`. A server switch racing the undo would otherwise stamp the prepended ref with the new server, mis-resolving the playing track on the very next render. - `QueueUndoSnapshot` now carries `queueServerId` (captured by `queueUndoSnapshotFromState`); older in-memory entries fall back through the snapshot's own refs and finally the live store value - the prepend in `applyQueueHistorySnapshot` plus the post-restore `seedQueueResolver` both source the server identity from this snapshot context, run through `canonicalQueueServerKey` so cache bucket and ref shape stay in lockstep * test(queue): regression cluster for mixed-server queues with duplicate trackId Covers the four invariants the thin-state review called out: - resolver correctness: same `trackId` on two servers maps to two distinct cache entries via canonical keys, and legacy UUID-shaped refs still read the same entries through the compat lookup path - restore/hydrate: persist `merge` forward-migrates UUID-form blobs in three shapes (canonical `queueItems`, legacy `queueRefs`, mixed-server `queueItems`) to canonical keys - undo snapshot application: prepended ref follows the snapshot's playback server even when the live queue has been rebound to a different one, with fallback to snapshot refs and live state for legacy entries - queue sync id emission: `flushPlayQueuePosition` -> `savePlayQueue` passes plain track ids and the playback server out of band, no per-ref `serverId` ever leaks into the request body Also asserts the write helpers (`toQueueItemRefs`, `bindQueueServerForPlayback`) emit canonical keys directly. * perf(queue-header): coalesce resolver burst updates and aggregate in one pass `QueueHeader` recomputed total and remaining queue durations on every resolver cache version bump via two separate full-queue reduces. A mass resolve burst (queue restore, prefetch window slide) bumps the version dozens of times in one frame, and very long queues turned that into visible main-thread stutter. - one pass: a single for-loop produces both total and future-tracks duration; a 50k-track queue costs one walk per recompute, not two - `useDeferredValue(version)` coalesces the burst into a single low-priority commit so the cache version is only sampled once per React frame instead of once per cache write * fix(queue): use stable artist seed for radio top-up The proactive radio top-up in `runNext` seeded `getSimilarSongs2` and `getTopSongs` from `resolveQueueTrack(nextRef)` metadata. When the next ref is still cold in the resolver cache, the placeholder track has empty artist fields, and the top-up would fire `getSimilarSongs2('')` -- silently returning nothing and leaving the queue dry just before the radio rail would have refilled. - prefer the just-played `currentTrack` (always fully resolved in the player store) and the stored radio seed artist id - fall back to the next-track metadata only when those are missing - skip the top-up entirely when no stable seed is available, instead of emitting a non-deterministic empty request * docs(changelog): queue mixed-server routing and quota-safe persist (#872) |
||
|
|
418b25914a |
feat(cover): unify cover pipeline and stabilize mainstage/now-playing (#870)
* chore(cover): scaffold cover module and rust cover_cache stub Wave 0: src/cover/ skeleton per contracts.md §12, stub IPC commands in cover_cache/mod.rs (no-op returns until phase B). * feat(cover): add unified cover module and tier resolver (phase A) Wave 1A: tiers, storage keys, resolveJs with cold/sibling races, useCoverArt, CoverArtImage, layoutSizes, playback scope helpers, coverSiblings tier ladder, deprecated shims on subsonicStreamUrl. * feat(cover): rust disk cache and tier-ready events (phase B) Wave 1B: cover_cache module with WebP tier encode, HTTP canonical 800 fetch, cover_cache_* commands, cover:tier-ready / cover:evicted events, disk layout tests. * feat(cover): prefetch hook, tier-ready handoff, library backfill IPC (phase B/C) Wave 2: useCoverArtPrefetch, cover:tier-ready/evicted bridge, one-time IDB cover key clear, prefetch registry drain, MainApp wiring. * feat(cover): migrate dense grids to CoverArtImage and prefetch (phase D) Wave 3A: dense surfaces use layout-native displayCssPx, surface=dense, coverPrefetchRegister on Home/Albums/search; AlbumCard cell width from grid. * feat(cover): migrate sparse surfaces and integrations (phase E sparse) Wave 3B: sparse CoverArtImage/useCoverArt, lightbox tier 2000, ArtistHeroCover, MPRIS/Discord/export integrations, playback chrome and detail heroes. * feat(cover): revalidation scheduler and disk pressure gate (phase E+) Wave 4: coverCacheMaxMb settings (en/ru), StorageTab disk usage, cover_cache_configure, useCoverRevalidateScheduler, playbackServer uses cover fetchUrl; pressure watermarks. * docs: CHANGELOG and credits for cover art pipeline PR #869 * fix(cover): stop webview getCoverArt storm on dense grids (429) Dense surfaces no longer put rotating getCoverArt URLs in img src; load disk via Rust ensure + convertFileSrc. Tier-ready notifies listeners instead of invalidating IDB. Throttle background prefetch and cap Home registry. * fix(cover): omit empty img src until cover URL is ready React 19 warns on src=""; CoverArtImage uses undefined until disk/IDB resolves; queue current track shows placeholder when src is still empty. * fix(cover): disk cache by host index key, parallel ensure, asset protocol Bind cover storage to serverIndexKey (library host), rename cover IPC/events, fix REST base URL and Tauri flat args, enable protocol-asset for disk paths, add prioritized ensure queue, and wipe legacy profile-UUID cache once. Limit Vite dep scan to index.html so research/target HTML is ignored. * fix(cover): WebP tiers, disk peek, home cache, asset URLs for mainstage Encode lossy WebP (~82), write only missing tiers, library cover backfill, and cover_cache_peek_batch for fast paint from disk. diskSrcCache + CSP asset protocol; no IDB fallback when server is up. Session Home feed cache with warm peek on return; BecauseYouLike deduped cover hook and high prefetch. * feat(cover): per-server cache strategy and native library backfill Move cover disk cache settings to Offline & cache with Lazy/Aggressive per server, per-server clear, and no size cap. Run full-catalog backfill on the Rust runtime (sync-idle wake, bounded HTTP, bulk 800px writes without flooding the webview). Drop global prefetch limits from auth store and waveform clear from the offline storage block. * fix(build): CSP connect-src for Subsonic API; quieter prod nix build Prod webview blocked axios ping after cover CSP (missing connect-src). Drop cargo tauri -v in flake build, raise Vite chunk limit, ignore tsbuildinfo. * fix(cover): complete WebP ladder in library bulk backfill Aggressive backfill now writes all derived tiers (128–800), skips IDs only when the full ladder exists (not 800 alone), avoids fetch-failed markers on bulk HTTP errors, and stops the pass when the active server changes. * fix(cover,home): navigation-priority backfill and Because You Like UX Pause library cover backfill while navigating; split peek/ensure traffic so grids and rails win over bulk work. Disk src lookup, grid warm hooks, and non-blocking mainstage prime for faster visible covers. Because You Like: session snapshot, staggered horizontal skeleton row, text hidden until cover is ready, and layout aligned with loaded cards. * feat(random-albums,library): local-first album fetch + cover art pipeline Random Albums теперь запрашивает локальный SQLite-индекс (ORDER BY RANDOM() LIMIT N) вместо сетевого запроса к серверу. При готовом индексе спиннер исчезает практически мгновенно; сеть используется только как фолбэк. - advanced_search.rs: добавляет `("random", _) => RANDOM()` в allowlist сортировок - browseTextSearch.ts: runLocalRandomAlbums — SQLite-рандом для Albums - RandomAlbums.tsx: doFetchRandomAlbums local-first для обоих путей (без жанра и с жанром через runLocalAlbumsByGenres + JS-shuffle); speculative reserve прогревает следующий батч в фоне после каждого Refresh Также: обновление пайплайна обложек (coverTraffic, peekQueue, ensureQueue, diskSrcLookup, warmDiskPeek, prefetchRegistry, useCoverArt, useWarmGridCovers, useCoverNavigationPriority, resolveIntersectionScrollRoot и сопутствующие компоненты/хуки). * fix(random-albums): prevent double-load on Zustand rehydration useEffect([selectedGenres, load]) fired twice on every visit: first with default store values, then again ~50 ms later when Zustand rehydrated mixMinRatingFilterEnabled/minAlbum/minArtist from localStorage. Previously this was invisible because the first network fetch took ~1.5 s, so loadingRef.current was still true on the second fire. With the new local-first SQLite path the first load completes in ~50 ms, leaving the guard cleared before rehydration triggers a second random batch. Fix: ref-pattern — keep loadRef.current fresh on every render, effect depends only on selectedGenres. Manual Refresh and genre-filter changes still call the latest closure correctly. * fix(random-albums): stop warmCoverDiskSrcBatch in fillReserve from causing visual flash fillReserve вызывал warmCoverDiskSrcBatch для обложек резервного батча, что вызывало bumpDiskSrcCache() для каждой новой обложки (~30+ вызовов). Это будило всех подписчиков useCoverArt на текущей странице, провоцируя видимую перерисовку примерно через ~1.5 с после загрузки (когда filterAlbumsByMixRatings делает сетевые запросы к рейтингам артистов). - fillReserve: убран warmCoverDiskSrcBatch — обложки прогреваются лениво при consume резерва через primeAlbumCoversForDisplay - reserve-путь в load(): добавлен primeAlbumCoversForDisplay перед setAlbums (аналогично non-reserve пути; при уже прогретом кэше — мгновенно) * feat(because-you-like): reserve-first pattern — instant display on return visits Каждый визит на Mainstage после первого теперь отдаёт готовую заготовку мгновенно, вместо spinner → сетевые запросы → контент. Архитектура: - resolvePicks / fetchBecauseYouLike вынесены на уровень модуля (выход из замыкания useEffect); читают текущий localStorage, возвращают { anchor, recs, nextAnchorHistory, nextPicksHistory } - fillBecauseReserve — fire-and-forget фоновая функция: запускается сразу после отображения результата, кладёт следующий батч в _becauseReserve. Covers намеренно не прогреваются (bumpDiskSrcCache на текущей странице не нужен); они прогреваются через primeAlbumCoversForDisplay при consume. - useLayoutEffect: если reserve готов — не сбрасывает стейт в skeleton (контент появляется без мигания) - useEffect: reserve-first path — consume → primeCovers → setState → fill; full-fetch path сохранён как fallback при первом визите или промахе Поведение: - Визит 1: full fetch (как раньше) → показ → fillReserve R1 - Визит 2+: consume R1 → мгновенный показ → fillReserve R2 - При сетевом сбое: restore из session cache (как раньше) * fix(because-you-like): initialise state from reserve — no skeleton flash on remount При ремаунте компонент стартовал с refreshing=true/anchor=null/recs=[] и показывал skeleton на один тик до того как useEffect отработает. Теперь useState() использует lazy initializers, которые читают _becauseReserve прямо в первом рендере: если reserve валиден — state сразу refreshing=false, anchor=X, recs=[...] и skeleton не показывается вообще. Covers уже в diskSrcCache (из предыдущего показа) и появляются без дополнительных запросов. useLayoutEffect упрощён: вызывает hasValidReserve() и сбрасывает в skeleton только если reserve отсутствует (для случая navigation без ремаунта). * fix(because-you-like): apply reserve in useLayoutEffect to handle async pool arrival Lazy initializers не могли применить reserve при первом рендере, потому что mostPlayed/recentlyPlayed/starred приходят из Home.tsx асинхронно — pool=[] на первом рендере, poolKey не совпадает с reserve. useLayoutEffect теперь активно ставит стейт из reserve (а не просто не сбрасывает): когда pool обновляется до реальных данных, useLayoutEffect срабатывает синхронно до paint, проверяет reserve и сразу применяет anchor/recs/refreshing=false. При отсутствии reserve — сбрасывает в skeleton как прежде. * fix(because-you-like): reserve > cache > skeleton — eliminate skeleton flash on mount Корневая причина: Home.tsx загружает mostPlayed асинхронно через useEffect, поэтому на первом рендере pool=[], poolKey=''. Reserve хранится с реальным poolKey → mismatch → lazy initializers запускали skeleton. Теперь двухуровневый fallback без зависимости от poolKey: 1. reserve (serverId + poolKey совпадают) → мгновенный новый батч 2. becauseYouLikeCache (только serverId) → stale-while-revalidate, контент доступен сразу с mount, обновляется тихо в фоне 3. skeleton → только при полном отсутствии данных (первый визит) Применяется одинаково в lazy useState initializers, useLayoutEffect и full-fetch path useEffect (не сбрасывать в skeleton пока есть cached контент). * fix(because-you-like): key reserve by serverId only; guard useEffect on empty pool Проблема: reserve хранился с poolKey, но на первом рендере pool=[] → poolKey='' → mismatch → показывался кэш (предыдущий набор) ~500ms пока Home.tsx не загружал mostPlayed. Исправления: - BecauseReserve: убран poolKey — reserve валиден для любого pool-состояния на том же сервере. Pool (топ-артисты) меняется медленно; один раз показать reserve с чуть устаревшим anchor лучше чем показывать предыдущий набор 500ms - hasValidReserve: проверяет только serverId - fillBecauseReserve: убран poolKey из сигнатуры и хранилища - useEffect: guard pool.length === 0 → возврат без fetch/consume; effect перезапустится когда pool заполнится (реальные deps изменятся) → reserve применяется из useLayoutEffect ещё до pool, без стале-флэша Итоговый порядок: reserve (instant, serverId) > cache (stale-while-revalidate) > skeleton (только первый визит) * fix(home): remove mix-rating deps from feed useEffect — prevent Zustand rehydration double-fetch Корень: useAuthStore(mixMinRatingFilterEnabled/Album/Artist) были в deps useEffect. Zustand persist реhydrates асинхронно — сначала activeServerId, потом mix-rating значения. Это вызывало двойной запуск эффекта: - Первый запуск: homeFeedCache hit → показывает набор предыдущего просмотра - Второй запуск (после rehydration): cache miss или повторный fetch с реальными mix-настройками → ~500ms → новый набор Итог: Hero, AlbumRow, BecauseYouLikeRail показывали предыдущий набор первые ~500ms при каждом возврате на Mainstage. Fix: убраны mixMinRatingFilterEnabled/Album/Artist из deps. getMixMinRatingsConfigFromAuth() читается внутри эффекта через getState() — всегда актуальные значения без пересоздания замыкания. Mix-настройки по-прежнему применяются при fetch, но не вызывают двойной запуск при rehydration. * feat(home): local-first discover songs via SQLite ORDER BY RANDOM() Добавлена runLocalRandomSongs (аналог runLocalRandomAlbums для треков) в browseTextSearch.ts — использует libraryAdvancedSearch с sort random, field уже поддерживается Rust-кодом через wildcarded ("random", _) ветку. В Home.tsx: discoverSongs теперь сначала пробует локальный индекс, и только при недоступности (индекс не готов, ошибка) падает обратно на getRandomSongs.view. Ускоряет первую загрузку Mainstage — треки берутся из SSD вместо сети. * fix(home): pre-populate state from cache at mount — eliminate empty-state flash on return visits Причина: Home.tsx размонтируется при навигации. При возврате первый рендер всегда с пустыми массивами (heroAlbums=[], mostPlayed=[] и т.д.), потом useEffect читает homeFeedCache и заполняет state. Даже один кадр с пустым состоянием вызывает перерисовку Hero и BecauseYouLikeRail (pool=[]). Решение: getInitialHomeFeed() читает homeFeedCache синхронно через useAuthStore.getState() (не hook) в lazy useState initializers. К моменту повторного визита store уже rehydrated — все state получают кэшированные данные до первого рендера. Дополнительно: wasPrePopulated предотвращает повторный applyFeedSnapshot в useEffect когда state уже заполнен — иначе новые ссылки на массивы вызывали бы ненужные ре-рендеры дочерних компонентов с теми же данными. * fix(mainstage): keep refresh without return flicker Keep Home and Because You Like visually stable during a single visit while still refreshing data for the next re-enter. Improve mainstage cover warmup by ensuring and pre-decoding above-the-fold artwork so hero and top rails appear instantly after navigation. * fix(mainstage): stabilize because rail and hero background framing Measure Because You Like layout before first paint to avoid width snap flicker, and render hero background as centered cover-fit images so the frame no longer jumps from top to middle on mount. * fix(now-playing): prewarm track data and prevent stale carry-over Warm Now Playing fetch caches and playback cover art on track change so entering the page no longer waits on first-load requests. Gate key-based sections (top songs, tour, Last.fm) by the active track/artist keys to avoid briefly rendering values from the previous track. * fix(cover,test): refresh playback scope and default tauri cover mocks Recompute playback cover scope when queue/server context changes so now-playing art resolves against the correct server after handoffs. Add default cover-cache invoke handlers to the shared Tauri test harness to prevent unhandled rejections in suites that mount cover-aware UI. * fix(cover,now-playing,test): align prewarm scopes and tighten tauri mocks Make cover-cache invoke defaults opt-in for tests, align radio prewarm scope with active rendering scope, and add targeted hook tests for prewarm + playback-scope reactivity. Also harden Rust cover URL building to avoid panic on malformed base URLs. * test(cover): hoist mocked useCoverArt and clean EOF whitespace Fix the new playback-scope hook test to use a hoisted vi.mock-safe stub and keep branch-wide diff checks clean by removing an accidental trailing blank line. * fix(cover): align playback ensure auth and harden backfill retry flow Use playback-server credentials for playback-scoped cover ensures, persist fetch-failed markers for bulk library backfill failures, and avoid advancing backfill cursor when UI-priority hold interrupts a batch. * fix(ci): resolve clippy lint and update frontend node runtime Move fetch helper before the test module to satisfy clippy's items-after-test-module rule, and modernize frontend CI to setup-node v6 with lts/* instead of pinned Node 20. * chore(settings): simplify cover and analytics strategy copy Move strategy summaries below tables, simplify Lazy/Aggressive wording, keep analytics warning always visible, and localize Russian texts to plain language without technical jargon. |