Commit Graph

30 Commits

Author SHA1 Message Date
cucadmuh 4ac373a65b feat(search): scoped live search on browse pages (#938)
* feat(artists): scoped live search badge replaces page filter

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* refactor(search): rename AdvancedSearch page to SearchBrowsePage

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

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

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

* docs: add CHANGELOG and credits for PR #936
2026-06-01 03:11:27 +03:00
cucadmuh df3533bb5a fix(cover): Windows thumbnails, tier fallback, PNG decode, coverArt id (#878)
* fix(cover): tier fallback for sparse surfaces and Windows asset URLs

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

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

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

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

Prefer albumId over track-id coverArt (Navidrome). Wire queue to CoverArtImage
with playback scope. CoverArtImage renders a placeholder div until asset src
exists to avoid the browser broken-image icon.

* fix(test): add song id to resolveArtistPageSongCoverArtId fixture

Pick<SubsonicSong, …> requires id; fixes tsc in CI/build.

* fix(cover): resolve albumId for Now Playing and artist top tracks

Prefer albumId when album.coverArt echoes track id; use sparse surface
on artist suggestion thumbs; apply resolveSubsonicSongCoverArtId across
playback surfaces (Now Playing, fullscreen, mobile, mini).

* fix(cover): decode PNG from Subsonic before WebP tier encode

Enable `png` in the image crate — some servers return PNG cover art;
failed decode left `.fetch-failed` and empty thumbs for those albums.

* refactor(cover): consolidate cover id resolution and align tests

Move resolveSubsonicSongCoverArtId helpers to src/cover/resolveCoverArtId.ts
with resolvePlaybackTrackCoverArtId for player surfaces; co-locate tests;
fix FullscreenPlayer expectations for albumId-first resolution.

* docs: CHANGELOG and credits for PR #878

* fix(cover): keep per-track coverArt when distinct from song id

Address PR #878 review (b): albumId only when coverArt is missing or
echoes track id; pin case with unit test; comment isRawFsPath symmetry.

* chore(cover): address PR #878 review nits (scope, tests, rename)

Narrow asset scope to cover-cache dirs only; add diskSrcCache Windows-path
tests; rename ArtistTopTrackCover; CHANGELOG symptom-first wording.

* fix(cover): restore asset scope to app data dirs (Windows regression)

$APPDATA/cover-cache/** did not match Tauri scope resolution — covers
were blocked after load. Use $APPDATA/** and $APPLOCALDATA/** (no $DATA).

* fix(cover): Windows asset URLs — restore DATA scope, path normalize

Regression after review nits: dropped $DATA/** and strict isAssetProtocolUrl
blocked valid http://asset.localhost URLs on Windows. Normalize C:/ paths
before convertFileSrc; CoverArtImage/Hero hide broken img on load error.

* fix(cover): disk peek fallbacks when cache folder id differs

Small surfaces resolve albumId while cover-cache often stores WebP under
track id or album.coverArt from the grid. Peek batch now tries legacy ids;
playback scope resolves server index key by URL key, not UUID-only lookup.

* fix(cover): Navidrome al-* vs mf-* disk id mismatch

UI used mf-* coverArtId while library backfill only cached al-* folders.
Prefer album id for display/peek when coverArt is mf-*; backfill now
queues both distinct album_id and cover_art_id values.

* fix(cover): mf→al disk peek when mf folder missing in cache

Navidrome Subsonic often returns mf-* coverArtId while backfill only
creates al-* folders. Peek mf first, then al-* from hints; load albumId
from library when Subsonic omits it; ensure fallback uses al-* id.

* feat(cover): CoverArtRef, segment disk layout, library-index backfill

Normalize cover caching around stable entity ids from the local library
and Navidrome fetch ids. Disk paths live in psysonic_core::cover_cache_layout
(album/<entityId>/); UI uses CoverArtRef with cacheEntityId + fetchCoverArtId.

- Remove SQLite/mf peek helpers (diskPeekIds, peekCoverOnDisk, mergeDiskIdHints)
- Backfill reads album/artist rows from library SQLite (bare Navidrome ids ok)
- Use stored cover_art_id for HTTP; per-disc dirs only when discs differ
- Migrate call sites to albumCoverRef / albumCoverRefForPlayback

* feat(cover): central CoverEntry resolver (artist, album, track)

Add resolveEntry.ts and Rust CoverEntry helpers as the single source of
truth for cache_entity_id vs fetch_cover_art_id. ref.ts delegates to them;
resolveCoverArtId becomes a thin compatibility shim.

* feat(cover): resolve cover entries from local library index

Add library_resolve_cover_entry IPC and cover_resolve.rs so album,
artist, and track covers use SQLite cover_art_id + disc detection.
TypeScript helpers in resolveEntryLibrary.ts prefer the index over
live API fields when rows exist.

* feat(cover): library-first hooks for grids and playback UI

Add useAlbumCoverRef, useArtistCoverRef, useTrackCoverRef, and
usePlaybackTrackCoverRef — sync fallback then SQLite index upgrade.
Wire album/artist cards, album header, song card, and all player
surfaces to resolve covers from the local library when indexed.

* feat(cover): complete library-first migration across all UI surfaces

Add Album/Artist/TrackCoverArtImage, useLibraryCoverPrefetch, and batch
resolve helpers. Migrate grids, search, home, playback sidecars, warm
peek, playlists, and share flows to hooks that upgrade from SQLite.
Backfill normalizes album rows through cover_resolve; document paths in
COVER_PATHS.md. Radio remains a deliberate non-library exception.

* fix(cover): stop render loop from unstable serverScope in library hooks

Default param `{ kind: 'active' }` created a new object every render, so
every grid cell re-ran library_resolve IPC and setState in a loop. Use
COVER_SCOPE_ACTIVE singleton, coverScopeKey deps, and guarded sync updates.

* chore(cover): remove COVER_PATHS.md from app tree (lives in workdocs)

Audit doc is team spec — see workdocs 2026-05-cover-art-pipeline/cover-paths-audit.md.

* fix(cover): unstick library backfill after route changes (PR #870 regression)

useCoverNavigationPriority cleanup called beginNavigation instead of end,
leaking navigationHoldDepth so ui_priority_hold never released and backfill
never downloaded. Also skip disk check after cover_resolve normalization.

* fix(cover): segment progress, cap backfill CPU, include artists in catalog

Progress and disk size now scan album/ and artist/ segments (canonical 800.webp).
Prune legacy flat server/al-* dirs on startup and backfill pass.

Backfill: max 2 concurrent ensures; JPEG decode and WebP encode run on the
blocking pool behind a shared 2-permit semaphore so Tokio workers stay cool.

Artists were missing because the catalog only read the empty artist table;
add distinct artist_id from track and album rows. Paginate with a composite
(kind, id) cursor so album and artist rows are not skipped.

* fix(cover): drop legacy prune; backfill per-disc and artist catalog

Remove prune_legacy_* and cover_cache_catalog_entry — layout is only
cover_dir (album|artist segments); stale flat dirs clear on LAYOUT_STAMP change.

Backfill: artists from track/album artist_id; expand albums to per-CD mf-* slots
when discs differ; fix resolve_album_cover_entry when album row is missing.

* fix(cover): reduce library IPC storms and fix multi-disc player art

Skip per-row library_resolve on live search and artist album grids; warm
grids from API coverArt after mount instead of blocking layout. Dedupe and
cap concurrent library_resolve calls. Restore per-disc cache keys in the
player and queue when track mf-* art differs from the album bucket.

* fix(cover): skip library resolve on advanced and full search rows

Use API coverArt for album/artist rails and lazy viewport artwork so
result pages do not fire hundreds of library_resolve IPC calls at once.

* fix(cover): default libraryResolve off for browse grids and rails

Skip per-card library_resolve on album/artist/song browse UI by default;
keep it on album/artist headers, playback queue rows, and orbit approval.

* fix(cover): split UI/backfill CPU pools and restore mainstage hero carousel

Library backfill no longer shares the 2-permit JPEG/WebP semaphore with
visible cover ensures. Hero initializes albums from props, re-binds scroll
visibility after mount, updates backdrop on slide change, and uses library
resolve for correct cover art on the banner.

* fix(analysis): resume full-library scan after candidates phase

Reset the SQL cursor when entering full-library mode so tracks with
partial analysis are not skipped. Tighten TS backfill completion and
CPU queue watermarking; align cover-cache key tests with album-scoped
storage keys.

* fix(library): remove useless map_err in cover_resolve (clippy)

CI treats clippy::useless-conversion as error on rusqlite optional() chains.

* fix(cover): satisfy clippy on cover_cache_ensure IPC args

Pass CoverCacheEnsureArgs as a single Tauri parameter instead of nine
positional fields; align frontend invoke payload with { args }.
2026-05-28 03:15:08 +03:00
cucadmuh 418b25914a feat(cover): unify cover pipeline and stabilize mainstage/now-playing (#870)
* chore(cover): scaffold cover module and rust cover_cache stub

Wave 0: src/cover/ skeleton per contracts.md §12, stub IPC commands
in cover_cache/mod.rs (no-op returns until phase B).

* feat(cover): add unified cover module and tier resolver (phase A)

Wave 1A: tiers, storage keys, resolveJs with cold/sibling races,
useCoverArt, CoverArtImage, layoutSizes, playback scope helpers,
coverSiblings tier ladder, deprecated shims on subsonicStreamUrl.

* feat(cover): rust disk cache and tier-ready events (phase B)

Wave 1B: cover_cache module with WebP tier encode, HTTP canonical 800 fetch,
cover_cache_* commands, cover:tier-ready / cover:evicted events, disk layout tests.

* feat(cover): prefetch hook, tier-ready handoff, library backfill IPC (phase B/C)

Wave 2: useCoverArtPrefetch, cover:tier-ready/evicted bridge, one-time IDB
cover key clear, prefetch registry drain, MainApp wiring.

* feat(cover): migrate dense grids to CoverArtImage and prefetch (phase D)

Wave 3A: dense surfaces use layout-native displayCssPx, surface=dense,
coverPrefetchRegister on Home/Albums/search; AlbumCard cell width from grid.

* feat(cover): migrate sparse surfaces and integrations (phase E sparse)

Wave 3B: sparse CoverArtImage/useCoverArt, lightbox tier 2000, ArtistHeroCover,
MPRIS/Discord/export integrations, playback chrome and detail heroes.

* feat(cover): revalidation scheduler and disk pressure gate (phase E+)

Wave 4: coverCacheMaxMb settings (en/ru), StorageTab disk usage, cover_cache_configure,
useCoverRevalidateScheduler, playbackServer uses cover fetchUrl; pressure watermarks.

* docs: CHANGELOG and credits for cover art pipeline PR #869

* fix(cover): stop webview getCoverArt storm on dense grids (429)

Dense surfaces no longer put rotating getCoverArt URLs in img src; load
disk via Rust ensure + convertFileSrc. Tier-ready notifies listeners instead
of invalidating IDB. Throttle background prefetch and cap Home registry.

* fix(cover): omit empty img src until cover URL is ready

React 19 warns on src=""; CoverArtImage uses undefined until disk/IDB
resolves; queue current track shows placeholder when src is still empty.

* fix(cover): disk cache by host index key, parallel ensure, asset protocol

Bind cover storage to serverIndexKey (library host), rename cover IPC/events,
fix REST base URL and Tauri flat args, enable protocol-asset for disk paths,
add prioritized ensure queue, and wipe legacy profile-UUID cache once.
Limit Vite dep scan to index.html so research/target HTML is ignored.

* fix(cover): WebP tiers, disk peek, home cache, asset URLs for mainstage

Encode lossy WebP (~82), write only missing tiers, library cover backfill,
and cover_cache_peek_batch for fast paint from disk. diskSrcCache + CSP
asset protocol; no IDB fallback when server is up. Session Home feed cache
with warm peek on return; BecauseYouLike deduped cover hook and high prefetch.

* feat(cover): per-server cache strategy and native library backfill

Move cover disk cache settings to Offline & cache with Lazy/Aggressive
per server, per-server clear, and no size cap. Run full-catalog backfill
on the Rust runtime (sync-idle wake, bounded HTTP, bulk 800px writes
without flooding the webview). Drop global prefetch limits from auth store
and waveform clear from the offline storage block.

* fix(build): CSP connect-src for Subsonic API; quieter prod nix build

Prod webview blocked axios ping after cover CSP (missing connect-src).
Drop cargo tauri -v in flake build, raise Vite chunk limit, ignore tsbuildinfo.

* fix(cover): complete WebP ladder in library bulk backfill

Aggressive backfill now writes all derived tiers (128–800), skips IDs
only when the full ladder exists (not 800 alone), avoids fetch-failed
markers on bulk HTTP errors, and stops the pass when the active server
changes.

* fix(cover,home): navigation-priority backfill and Because You Like UX

Pause library cover backfill while navigating; split peek/ensure traffic
so grids and rails win over bulk work. Disk src lookup, grid warm hooks,
and non-blocking mainstage prime for faster visible covers.

Because You Like: session snapshot, staggered horizontal skeleton row,
text hidden until cover is ready, and layout aligned with loaded cards.

* feat(random-albums,library): local-first album fetch + cover art pipeline

Random Albums теперь запрашивает локальный SQLite-индекс (ORDER BY RANDOM()
LIMIT N) вместо сетевого запроса к серверу. При готовом индексе спиннер
исчезает практически мгновенно; сеть используется только как фолбэк.

- advanced_search.rs: добавляет `("random", _) => RANDOM()` в allowlist сортировок
- browseTextSearch.ts: runLocalRandomAlbums — SQLite-рандом для Albums
- RandomAlbums.tsx: doFetchRandomAlbums local-first для обоих путей (без жанра
  и с жанром через runLocalAlbumsByGenres + JS-shuffle); speculative reserve
  прогревает следующий батч в фоне после каждого Refresh

Также: обновление пайплайна обложек (coverTraffic, peekQueue, ensureQueue,
diskSrcLookup, warmDiskPeek, prefetchRegistry, useCoverArt, useWarmGridCovers,
useCoverNavigationPriority, resolveIntersectionScrollRoot и сопутствующие
компоненты/хуки).

* fix(random-albums): prevent double-load on Zustand rehydration

useEffect([selectedGenres, load]) fired twice on every visit: first with
default store values, then again ~50 ms later when Zustand rehydrated
mixMinRatingFilterEnabled/minAlbum/minArtist from localStorage.

Previously this was invisible because the first network fetch took ~1.5 s,
so loadingRef.current was still true on the second fire. With the new
local-first SQLite path the first load completes in ~50 ms, leaving the
guard cleared before rehydration triggers a second random batch.

Fix: ref-pattern — keep loadRef.current fresh on every render, effect
depends only on selectedGenres. Manual Refresh and genre-filter changes
still call the latest closure correctly.

* fix(random-albums): stop warmCoverDiskSrcBatch in fillReserve from causing visual flash

fillReserve вызывал warmCoverDiskSrcBatch для обложек резервного батча, что
вызывало bumpDiskSrcCache() для каждой новой обложки (~30+ вызовов). Это будило
всех подписчиков useCoverArt на текущей странице, провоцируя видимую перерисовку
примерно через ~1.5 с после загрузки (когда filterAlbumsByMixRatings делает
сетевые запросы к рейтингам артистов).

- fillReserve: убран warmCoverDiskSrcBatch — обложки прогреваются лениво при
  consume резерва через primeAlbumCoversForDisplay
- reserve-путь в load(): добавлен primeAlbumCoversForDisplay перед setAlbums
  (аналогично non-reserve пути; при уже прогретом кэше — мгновенно)

* feat(because-you-like): reserve-first pattern — instant display on return visits

Каждый визит на Mainstage после первого теперь отдаёт готовую заготовку
мгновенно, вместо spinner → сетевые запросы → контент.

Архитектура:
- resolvePicks / fetchBecauseYouLike вынесены на уровень модуля (выход из
  замыкания useEffect); читают текущий localStorage, возвращают
  { anchor, recs, nextAnchorHistory, nextPicksHistory }
- fillBecauseReserve — fire-and-forget фоновая функция: запускается сразу
  после отображения результата, кладёт следующий батч в _becauseReserve.
  Covers намеренно не прогреваются (bumpDiskSrcCache на текущей странице не
  нужен); они прогреваются через primeAlbumCoversForDisplay при consume.
- useLayoutEffect: если reserve готов — не сбрасывает стейт в skeleton
  (контент появляется без мигания)
- useEffect: reserve-first path — consume → primeCovers → setState → fill;
  full-fetch path сохранён как fallback при первом визите или промахе

Поведение:
- Визит 1: full fetch (как раньше) → показ → fillReserve R1
- Визит 2+: consume R1 → мгновенный показ → fillReserve R2
- При сетевом сбое: restore из session cache (как раньше)

* fix(because-you-like): initialise state from reserve — no skeleton flash on remount

При ремаунте компонент стартовал с refreshing=true/anchor=null/recs=[] и
показывал skeleton на один тик до того как useEffect отработает.

Теперь useState() использует lazy initializers, которые читают _becauseReserve
прямо в первом рендере: если reserve валиден — state сразу refreshing=false,
anchor=X, recs=[...] и skeleton не показывается вообще. Covers уже в diskSrcCache
(из предыдущего показа) и появляются без дополнительных запросов.

useLayoutEffect упрощён: вызывает hasValidReserve() и сбрасывает в skeleton
только если reserve отсутствует (для случая navigation без ремаунта).

* fix(because-you-like): apply reserve in useLayoutEffect to handle async pool arrival

Lazy initializers не могли применить reserve при первом рендере, потому что
mostPlayed/recentlyPlayed/starred приходят из Home.tsx асинхронно — pool=[]
на первом рендере, poolKey не совпадает с reserve.

useLayoutEffect теперь активно ставит стейт из reserve (а не просто не сбрасывает):
когда pool обновляется до реальных данных, useLayoutEffect срабатывает синхронно
до paint, проверяет reserve и сразу применяет anchor/recs/refreshing=false.
При отсутствии reserve — сбрасывает в skeleton как прежде.

* fix(because-you-like): reserve > cache > skeleton — eliminate skeleton flash on mount

Корневая причина: Home.tsx загружает mostPlayed асинхронно через useEffect,
поэтому на первом рендере pool=[], poolKey=''. Reserve хранится с реальным
poolKey → mismatch → lazy initializers запускали skeleton.

Теперь двухуровневый fallback без зависимости от poolKey:
1. reserve (serverId + poolKey совпадают) → мгновенный новый батч
2. becauseYouLikeCache (только serverId) → stale-while-revalidate, контент
   доступен сразу с mount, обновляется тихо в фоне
3. skeleton → только при полном отсутствии данных (первый визит)

Применяется одинаково в lazy useState initializers, useLayoutEffect и
full-fetch path useEffect (не сбрасывать в skeleton пока есть cached контент).

* fix(because-you-like): key reserve by serverId only; guard useEffect on empty pool

Проблема: reserve хранился с poolKey, но на первом рендере pool=[] → poolKey=''
→ mismatch → показывался кэш (предыдущий набор) ~500ms пока Home.tsx не загружал
mostPlayed.

Исправления:
- BecauseReserve: убран poolKey — reserve валиден для любого pool-состояния
  на том же сервере. Pool (топ-артисты) меняется медленно; один раз показать
  reserve с чуть устаревшим anchor лучше чем показывать предыдущий набор 500ms
- hasValidReserve: проверяет только serverId
- fillBecauseReserve: убран poolKey из сигнатуры и хранилища
- useEffect: guard pool.length === 0 → возврат без fetch/consume;
  effect перезапустится когда pool заполнится (реальные deps изменятся)
  → reserve применяется из useLayoutEffect ещё до pool, без стале-флэша

Итоговый порядок: reserve (instant, serverId) > cache (stale-while-revalidate)
> skeleton (только первый визит)

* fix(home): remove mix-rating deps from feed useEffect — prevent Zustand rehydration double-fetch

Корень: useAuthStore(mixMinRatingFilterEnabled/Album/Artist) были в deps
useEffect. Zustand persist реhydrates асинхронно — сначала activeServerId,
потом mix-rating значения. Это вызывало двойной запуск эффекта:
- Первый запуск: homeFeedCache hit → показывает набор предыдущего просмотра
- Второй запуск (после rehydration): cache miss или повторный fetch с
  реальными mix-настройками → ~500ms → новый набор

Итог: Hero, AlbumRow, BecauseYouLikeRail показывали предыдущий набор
первые ~500ms при каждом возврате на Mainstage.

Fix: убраны mixMinRatingFilterEnabled/Album/Artist из deps. getMixMinRatingsConfigFromAuth()
читается внутри эффекта через getState() — всегда актуальные значения без
пересоздания замыкания. Mix-настройки по-прежнему применяются при fetch,
но не вызывают двойной запуск при rehydration.

* feat(home): local-first discover songs via SQLite ORDER BY RANDOM()

Добавлена runLocalRandomSongs (аналог runLocalRandomAlbums для треков)
в browseTextSearch.ts — использует libraryAdvancedSearch с sort random,
field уже поддерживается Rust-кодом через wildcarded ("random", _) ветку.

В Home.tsx: discoverSongs теперь сначала пробует локальный индекс,
и только при недоступности (индекс не готов, ошибка) падает обратно
на getRandomSongs.view. Ускоряет первую загрузку Mainstage — треки
берутся из SSD вместо сети.

* fix(home): pre-populate state from cache at mount — eliminate empty-state flash on return visits

Причина: Home.tsx размонтируется при навигации. При возврате первый рендер
всегда с пустыми массивами (heroAlbums=[], mostPlayed=[] и т.д.), потом
useEffect читает homeFeedCache и заполняет state. Даже один кадр с пустым
состоянием вызывает перерисовку Hero и BecauseYouLikeRail (pool=[]).

Решение: getInitialHomeFeed() читает homeFeedCache синхронно через
useAuthStore.getState() (не hook) в lazy useState initializers. К моменту
повторного визита store уже rehydrated — все state получают кэшированные
данные до первого рендера.

Дополнительно: wasPrePopulated предотвращает повторный applyFeedSnapshot
в useEffect когда state уже заполнен — иначе новые ссылки на массивы
вызывали бы ненужные ре-рендеры дочерних компонентов с теми же данными.

* fix(mainstage): keep refresh without return flicker

Keep Home and Because You Like visually stable during a single visit while still refreshing data for the next re-enter. Improve mainstage cover warmup by ensuring and pre-decoding above-the-fold artwork so hero and top rails appear instantly after navigation.

* fix(mainstage): stabilize because rail and hero background framing

Measure Because You Like layout before first paint to avoid width snap flicker, and render hero background as centered cover-fit images so the frame no longer jumps from top to middle on mount.

* fix(now-playing): prewarm track data and prevent stale carry-over

Warm Now Playing fetch caches and playback cover art on track change so entering the page no longer waits on first-load requests. Gate key-based sections (top songs, tour, Last.fm) by the active track/artist keys to avoid briefly rendering values from the previous track.

* fix(cover,test): refresh playback scope and default tauri cover mocks

Recompute playback cover scope when queue/server context changes so now-playing art resolves against the correct server after handoffs. Add default cover-cache invoke handlers to the shared Tauri test harness to prevent unhandled rejections in suites that mount cover-aware UI.

* fix(cover,now-playing,test): align prewarm scopes and tighten tauri mocks

Make cover-cache invoke defaults opt-in for tests, align radio prewarm scope with active rendering scope, and add targeted hook tests for prewarm + playback-scope reactivity. Also harden Rust cover URL building to avoid panic on malformed base URLs.

* test(cover): hoist mocked useCoverArt and clean EOF whitespace

Fix the new playback-scope hook test to use a hoisted vi.mock-safe stub and keep branch-wide diff checks clean by removing an accidental trailing blank line.

* fix(cover): align playback ensure auth and harden backfill retry flow

Use playback-server credentials for playback-scoped cover ensures, persist fetch-failed markers for bulk library backfill failures, and avoid advancing backfill cursor when UI-priority hold interrupts a batch.

* fix(ci): resolve clippy lint and update frontend node runtime

Move fetch helper before the test module to satisfy clippy's items-after-test-module rule, and modernize frontend CI to setup-node v6 with lts/* instead of pinned Node 20.

* chore(settings): simplify cover and analytics strategy copy

Move strategy summaries below tables, simplify Lazy/Aggressive wording, keep analytics warning always visible, and localize Russian texts to plain language without technical jargon.
2026-05-26 19:35:08 +03:00
cucadmuh 91e7195e0f fix(library): scoped live search FTS, race, and multi-server advanced search (#868)
* fix(library): scoped live search FTS, race, and multi-server advanced search

Scope track_fts live search to active server_id so multi-server libraries
no longer show empty or wrong-server hits. Match Navidrome-style any-word
prefix matching and GROUP BY artist/album dedupe on track_fts only.

Frontend: parallel local vs search3 race (empty waits, 8s network timeout),
merge supplemental hits after both settle, debug via Settings → Logging →
Debug (frontend_debug_log). Advanced Search FTS subqueries use the same
server scope fix.

* docs: CHANGELOG and credits for PR #868 live search fix
2026-05-25 04:20:02 +03:00
cucadmuh 11974e1438 feat(analysis): ship index-key rebuild, strategy controls, and playback/queue pipeline updates (#864)
* feat(analysis): align index settings and per-server strategies

Rebuild the local index UX to live under Servers with per-server analytics
strategies, and scope analysis queue hints/pruning by playback server so
priorities stay isolated across profiles.

* feat(analysis): add progress tracking and server analysis deletion functionality

Introduce new interfaces for tracking library analysis progress and reporting on server analysis deletions. Implement functions to retrieve analysis progress for a server and to delete all analysis data for a specified server, enhancing the analytics strategy section with real-time progress updates and management capabilities. Update relevant components and localization files to support these features.

* feat(server): implement server index key migration and enhance server ID resolution

Add functionality to migrate server index keys from legacy IDs to new URL-based keys, improving server ID resolution across the application. Introduce new types and commands for handling server key migrations in both analysis and library contexts. Update relevant functions to utilize the new server ID resolution logic, ensuring consistency and accuracy in server-related operations.

* refactor(library): simplify server ID handling in sync progress and idle subscriptions

Refactor the library sync progress and idle subscription functions to directly use the payload's server ID without additional mapping. Update related components to resolve server IDs using a new utility function, ensuring consistent server ID resolution across the application. This change enhances code clarity and maintains functionality.

* refactor(analytics): rename advanced strategy to aggressive and update descriptions

Refactor the AnalyticsStrategySection component to rename the 'advanced' strategy to 'aggressive' for clarity. Update related localization strings to reflect this change, enhancing the user experience by providing clearer descriptions of the analytics strategies. Additionally, remove unused strategy description functions to streamline the code.

* fix(audio): update server ID handling in audio progress functions

Refactor the audio progress handling to utilize the new `getPlaybackIndexKey` function for server ID resolution. This change ensures that the correct analysis server ID is used when processing audio progress, enhancing the accuracy of playback operations. Additionally, a minor update was made to the analysis cache to include a checkpoint after seeding from bytes. Update the library path in live search to reflect the new database structure.

* refactor(analysis): update server ID handling and drop legacy keys

Refactor server ID handling across analysis components to utilize scheme-less keys (host + optional path) instead of legacy scheme-based keys. Introduce SQL migrations to drop legacy analysis rows and library entries keyed by scheme URLs. Update relevant functions and tests to ensure consistent server ID resolution and remove references to the legacy '' scope, enhancing clarity and maintainability.

* refactor(migration): switch to strategy C dual-db flow

Replace destructive server-key migration paths with a blocking inspect/run pipeline that imports into v2 sqlite files, verifies data, then switches active databases with backup safety. Add frontend migration orchestration and post-switch key rewrites while preserving existing user settings behavior.

* fix(migration): harden runtime db switch and startup gate

Switch database promotion through live runtime store/cache connection swaps so migration cannot leave writers on old sqlite inodes, and tighten startup gating to block initialization until migration completes. Also fix empty-bucket warning detection and set the done flag only after a post-run inspect confirms no pending legacy rows.

* feat(migration): enhance migration reporting with skipped server rows tracking

Add new fields to migration interfaces and reports to track skipped rows for removed servers. Update relevant components to display warnings and log messages when such rows are encountered during migration processes, improving visibility and user awareness of migration status.

* fix(migration): avoid startup blocking modal on no-op runs

Keep migration gate completed by default after successful runs and perform done-flag inspections without forcing a blocking phase, so normal app startup no longer flashes migration preparation when no migration is needed.

* fix(migration): enforce startup precheck and purge unknown rows

Prevent stale done-flag bypass by starting migration state in idle and gating completion on orchestrator precheck, and delete unknown removed-server rows from v2 databases before switch so skipped rows are not carried into the new active DB.

* fix(migration): block UI during done-flag precheck

Set inspecting phase before the first migration inspection and treat idle as blocking in the migration gate, so startup precheck cannot render the app before migration status is confirmed.

* fix(migration): hide precheck modal when no migration is needed

Keep startup precheck in a non-blocking idle phase and show the migration modal only after inspect confirms real migration work, removing the recurring half-second migration flash for already-migrated users.

* fix(migration): cleanup legacy db files after path migration

Always remove legacy analysis and library sqlite files (including wal/shm sidecars) when the new database paths are active, so old-path artifacts from previous builds do not linger after migration.

* docs(changelog): add PR #864 release notes and contributor credit

Document the full index-key rebuild scope for 1.47.0 and add the
corresponding settings credit entry for PR #864.

* test(analysis): raise hot-path coverage for analysis cache

Add focused unit tests for analysis cache compute/store hot paths and edge branches so coverage regressions are caught before CI. Make AppHandle entrypoints runtime-generic and enable tauri test utilities in dev dependencies to cover no-cache and registered-cache execute paths.

* fix(migration): make rebind pass resilient to foreign key ordering

Run library and analysis server_id rebind operations inside a foreign-key-disabled transaction and validate with PRAGMA foreign_key_check after commit, so migrations from older databases do not fail on transient FK ordering during bulk updates.

* feat(backup): add dual-database backup flow and blocking UX

Extend backup/export and restore flows to handle library databases with unified archive detection and asynchronous backend execution. Improve backup UI with a global blocking modal and clearer localized copy so long operations do not look like app hangs.

* docs(changelog): add PR #864 backup notes and contributor credit

Update 1.47.0 release notes with backup/restore UX and archive-flow entries for PR #864, and add the matching settings credits contribution line for cucadmuh.

* docs(changelog): sort 1.47.0 entries from old to new

Reorder Added, Changed, and Fixed subsections in the 1.47.0 changelog so entries follow chronological PR order inside each block.

* fix(playback): align offline/hot cache lookup with indexKey scope

Use a canonical playback cache key based on indexKey with legacy UUID fallback so migrated offline and hot-cache entries are still resolved on normal play, resume, queue-undo, and prefetch paths. Refresh PR #864 changelog/credits text to reflect the full migration and backup scope.
2026-05-24 21:11:04 +03:00
cucadmuh 7afddf7b84 feat(library): browse local index race and catalog paths (#847)
* feat(library): race local index vs network on browse text search

Wire Artists, Composers, Tracks, and SearchResults to parallel local FTS
and network search3 with graceful fallback when remote fails while the
index is ready.

* feat(library): local browse for albums/artists and dev race logging

Serve All Albums and Artists catalog from the local index when ready,
with network fallback. Log browse text-search race outcomes to DevTools
(`[psysonic][library] browse-race …`) including winner, timings, and hits.

* docs(changelog): note PR #847 browse local index race and catalog paths

* refactor(library): unify DevTools search log format

Live Search, Advanced Search, and browse races emit one-line
`search [surface] …` entries via formatLibrarySearchLine (DEV only).
2026-05-22 01:44:25 +02:00
cucadmuh 5bf2441ccf feat(library): local library index and search (preview) (#846)
* feat(library): scaffold psysonic-library crate with v1 schema and store (#791)

Adds a new workspace crate that will host the unified track store and the
upcoming sync engine. PR-1a covers spec phases A1–A6:

- migrations/001_initial.sql: full v1 schema — sync_state, track, album,
  artist, track_fts (+ ai/ad/au triggers), track_extension, track_offline,
  track_id_history, track_fact, track_artifact, canonical_track,
  canonical_identity, track_canonical_link, canonical_enrichment_link, and
  all §5.2 partial indexes.
- store::LibraryStore: WAL + foreign_keys=ON SQLite connection rooted at
  app_data_dir/library.sqlite (distinct from the analysis cache, which
  uses app_config_dir). schema_migrations table + idempotent embedded
  migration runner; LIBRARY_DB_SCHEMA_VERSION = 1.
- repos::TrackRepository::upsert_batch: 35-column transactional upsert
  with ON CONFLICT(server_id, id) all-fields rewrite; FTS rows follow via
  the triggers.
- search::search_tracks: minimal bm25-ordered FTS5 helper scoped to a
  single server_id, filtering deleted rows.
- filter::FilterFieldRegistry: static v1 registry (text, genre, year,
  starred = V1; bpm = SchemaV1UiLater; user_rating/suffix/bit_rate =
  Planned). Entity routing is a silent skip per §5.13.3.

No Tauri commands, no frontend, no sync — those land in PR-2..PR-7.

PR-1b will follow with the migration-runner edge-case tests, the
initial_sync_cursor_json read/write API, and the breaking-migration hook
stub (P22).

* feat(library): A7 migration-runner safety net + initial-sync cursor API (PR-1b) (#792)

* feat(library): wire migration-runner safety net and initial-sync cursor API

PR-1b — Phase A7 infrastructure on top of PR-1a. Production behaviour
is unchanged at v1 launch; everything here is plumbing that PR-3 will
consume.

- store::run_migrations_with: testable entry point that takes an explicit
  migration slice, a min-compatible-version threshold, and a breaking-bump
  hook. The prod `run_migrations` fixes those to MIGRATIONS,
  LIBRARY_DB_MIN_COMPATIBLE_VERSION, and the no-op stub. The slice is now
  sorted defensively before applying.
- store::LIBRARY_DB_MIN_COMPATIBLE_VERSION: new public constant (currently
  equal to LIBRARY_DB_SCHEMA_VERSION). When a future release needs to
  invalidate v1 data, bumping this above the max applied version trips
  the hook on next open per spec §5.7 / P22.
- store::MigrationOutcome (Applied | BreakingBump): crate-internal signal
  callers can branch on. PR-1b consumers ignore it; PR-3 / Settings will
  surface the "library rebuilt after update" toast when it surfaces.
- store::handle_breaking_schema_bump: documented no-op stub. The drop +
  resync logic lands with the first real breaking bump.
- repos::SyncStateRepository: ensure(server_id, scope) idempotently
  inserts a default row; get_initial_sync_cursor / set_initial_sync_cursor
  read and write sync_state.initial_sync_cursor_json via
  serde_json::Value. The set uses ON CONFLICT … DO UPDATE scoped to the
  cursor column only, so phase / poll-stats / tier survive cursor writes
  intact.
- Tests cover: additive 002-style migration preserves prior data
  (spec §5.7 explicit integration test), runner sorts an unsorted source
  slice, breaking-bump hook fires when max applied < min_compatible,
  hook does not fire on a fresh DB, cursor round-trips a nested
  serde_json::Value, ON CONFLICT preserves sibling columns, library_scope
  separates rows per server.

End-to-end "kill mid-500k-sync → resume same cursor" stays out of scope
per the kickoff answer — it belongs to PR-3 / C2 where the
InitialSyncRunner lives.

* test(library): cover AC A3 — 500-row upsert_batch under perf budget

* feat(library): Subsonic REST client for the sync engine (Phase B, PR-2) (#793)

Phase B (B1-B9 per spec §10) — pure-Rust Subsonic client that the
library-sync engine (PR-3) will drive. No Tauri commands, no events;
the surface is added internally to psysonic-integration as a sibling
of the existing navidrome native-REST module.

- B1 — SubsonicClient + ping over /rest/{method}.view. Auth via the
  legacy salted-md5 token (spec v1.13+, advertised as 1.16.1). New
  SubsonicCredentials helper computes token = md5(password || salt)
  and ships a per-process unique salt nonce so back-to-back calls
  don't repeat.
- B2 — get_scan_status → ScanStatus { scanning, count, folder_count,
  last_scan }. Lightweight poll for the Huge-tier path (§6.2.2).
- B3 — get_album_list2(type, size, offset, musicFolderId?) +
  get_album(id). The two-call pattern the sync engine walks during
  initial ingest (§6.3).
- B4 — search3(query, songCount, songOffset, musicFolderId?). Empty
  query → all songs paged (Navidrome quirk, spec §2.4).
- B5 — get_indexes(musicFolderId?, ifModifiedSince?). Conditional
  fetch for file-tree fallback (S3 / §3.1).
- B6 — get_song(id). Error code 70 maps to the dedicated
  SubsonicError::NotFound variant so the tombstone reconciler can
  match on the variant instead of parsing strings.
- B8 — get_artists(musicFolderId?). ID3-path artist index; clients
  compare ArtistIndex.last_modified_ms against the local watermark
  to decide if a delta pass is needed (§2.2.1).
- B9 — fingerprint_sample helper picks every-Nth track id for the
  server-fingerprint verify pass. Sampling is deterministic so
  reruns probe the same tracks. The verify-and-compare glue itself
  is library-side (PR-3 territory, deps on the store).

Tests cover envelope parsing (status=ok/failed, code 70 → NotFound,
missing body key), credentials (md5 vectors, salt uniqueness across
1k rapid calls, salt differs per from_password call), each endpoint
end-to-end through wiremock with query-param matchers, OpenSubsonic
forward-compat (unknown fields ignored on Song), and the trailing-slash
base-URL normalisation.

Cargo.toml — adds query + form + multipart to psysonic-integration's
reqwest feature set. PR-2's client needs `query`; the other two were
already used by existing navidrome::covers / remote::lastfm code and
only worked via top-crate feature unification. Aligning the crate's
own deps means `cargo test -p psysonic-integration` now compiles
without depending on the workspace build.

Out of scope: capability detection (C1 / PR-3), Navidrome native
bulk path (uses existing psysonic-integration::navidrome::queries),
fixtures harness expansion (G1).

* feat(library): subsonic client follow-ups from PR-2 review (PR-2b) (#794)

Picks up the three non-blocking items from cucadmuh's PR-793 review
(handoffs/2026-05-19-pr-793-review.md) before PR-3 starts on top.

- Fresh `(token, salt)` per request. `SubsonicClient` now caches the
  plaintext username + password and derives a new `SubsonicCredentials`
  inside `send()` for every endpoint call — matches the frontend's
  `subsonicClient.ts` `getAuthParams()` lifecycle and follows Subsonic
  replay-resistance guidance. Test path keeps a `with_static_credentials`
  constructor so wiremock matchers stay deterministic. New
  `build_credentials` (`pub(crate)`) routes the two modes.
- `SUBSONIC_CLIENT_ID` now carries the crate version
  (`psysonic/<CARGO_PKG_VERSION>`) — aligns with the frontend's
  `psysonic/${version}` so Navidrome log lines correlate across the
  WebView and Rust sync paths.
- `Song.mbid_recording` gains the `musicBrainzId` serde alias (plus the
  schema-column spelling) so the OpenSubsonic field lands on the same
  hot column the §5.1 schema names. P13 strong-key matching can now key
  off it on ingest.
- `get_song_with_raw` / `get_album_with_raw` return both the typed
  projection and the raw `serde_json::Value` body sub-tree. PR-3 ingest
  will write that raw value verbatim into `track.raw_json`, so
  OpenSubsonic extensions (`contributors`, `replayGain`, future fields)
  survive without manual field mirroring. Internal `parse_envelope_body`
  extracts the validation + body-key lookup once; `parse_envelope` and
  the new `parse_envelope_with_raw` share it.

Tests cover: `from_password` produces unique salt/token across two
back-to-back calls (direct + over-the-wire via wiremock
`received_requests`), static mode returns the same triple,
`c` query param starts with `psysonic/` and equals `SUBSONIC_CLIENT_ID`,
`get_song_with_raw` preserves untyped fields (`replayGain`,
`contributors`) in the raw value, `get_album_with_raw` keeps per-track
extensions in `raw.song[i]`, error 70 still maps to `NotFound` on the
raw variant, and `Song` deserializes `musicBrainzId` and
`mbid_recording` interchangeably.

B9 fingerprint-verify glue and the wider raw-ingest call sites stay
with PR-3 / C2 as the review's §5 / §7 checklist directs.

* feat(library): capability probe + sync_state accessors (Phase C1+C7, PR-3a) (#795)

First sub-PR of Phase C (sync orchestrator). Lands the foundation that
PR-3b's InitialSyncRunner consumes — pure plumbing, no runners or
background tasks yet.

- C1 capability probe. `psysonic_library::sync::CapabilityProbe::run`
  drives the §6.1 probe chain: Subsonic ping (captures `ServerInfo`
  envelope metadata for server-type / OpenSubsonic detection), then
  best-effort probes for search3 / getScanStatus / getIndexes, plus an
  optional Navidrome native bulk probe (caller passes
  `NavidromeProbeCredentials`). `CapabilityFlags(u32)` matches the
  §6.1.1 bitfield: NavidromeNativeBulk / SubsonicSearch3Bulk /
  ScanStatusAvailable / OpenSubsonic / UnstableTrackIds / FileTreeBrowse.
- C7 sync_state accessors. `SyncStateRepository` gains get/set
  capability_flags, get/set sync_phase (idle / probing / initial_sync
  / ready / error), and column-scoped setters for server_last_scan_iso,
  indexes_last_modified_ms, artists_last_modified_ms, library_tier.
  Every setter uses `ON CONFLICT … DO UPDATE` scoped to its own column
  so concurrent watermark writes don't clobber each other.
- Supporting additions in `psysonic-integration`:
  - `subsonic::SubsonicClient::server_info()` extracts `ServerInfo`
    from the ping envelope (server_type, server_version, api_version,
    open_subsonic). Re-uses `send()` so auth lifecycle is the same.
  - `navidrome::probe::native_bulk_available(url, token)` does the
    `GET /api/song?_start=0&_end=1` Bearer-auth probe. Returns
    Ok(true) on 2xx, Ok(false) on 4xx (auth ok but endpoint missing),
    Err on 5xx. Probe-only — full nd_list_songs port is PR-3b.
- `psysonic-library/Cargo.toml` gains a `psysonic-integration`
  dependency (sync calls into Subsonic + Navidrome probes). DAG stays
  acyclic: integration does not depend on library.

Per cucadmuh's PR-3 kickoff answer (handoff `2026-05-19-pr3-kickoff.md`):
- Crate placement: option A — sync lives in `psysonic-library/src/sync/`,
  no new psysonic-sync crate.
- N1 gate: probe is `/api/song?_start=0&_end=1` only; `nd_list_artists_by_role`
  is NOT required (Q3 answer + N1 ingest port lands in PR-3b).
- UnstableTrackIds: set for Navidrome via `ServerInfo.server_type`,
  cleared for generic Subsonic.

Tests added: 23 across library/sync, library/repos/sync_state,
integration/subsonic, integration/navidrome/probe. Cover bitfield
contains/insert/remove + spec bit values, probe across mixed-capability
servers (full Navidrome, minimal Subsonic, broken endpoints), ping-failure
short-circuit, optional Navidrome creds gating N1, sync_state column-scoped
upserts (capability_flags / sync_phase / watermarks / library_tier),
cross-column independence (capability writes don't reset cursor),
ServerInfo extraction from ping envelope, Navidrome bulk probe across
2xx/4xx/5xx.

* feat(library): InitialSyncRunner + C12 backoff + C13 id remap (Phase C2/C12/C13, PR-3b) (#796)

Second sub-PR of Phase C — wires the actual ingest path on top of
PR-3a's capability + sync_state foundation. Runner is pure async Rust:
PR-3d will spawn it inside a tokio task and emit Tauri progress events
on top.

- C2 InitialSyncRunner. Drives spec §6.3 IS-1 → IS-6: probe-derived
  IngestStrategy (enum N1/S1/S2/S3, selector picks N1 → S1 → S2 chain
  per kickoff Q3), per-page upsert loop, cursor flush after every
  successful batch, IS-4 best-effort getArtists watermark, IS-5
  getScanStatus.lastScan capture, IS-6 phase=ready + cursor cleared.
  Resume is automatic: a non-empty initial_sync_cursor_json restarts
  at the persisted offset; a strategy mismatch between cursor and
  capability flags surfaces as SyncError::CursorIncompatible.
- C12 backoff. sync::backoff::Backoff implements the §6.8 schedule
  (2s → 4s → … cap 120s) with ±25% jitter via deterministic salt.
  retry_with_backoff wraps every endpoint call: transport / Navidrome
  failures retry up to MAX_ATTEMPTS_PER_BATCH (5), the cursor never
  advances on failure, success resets the counter. Cancellation
  AtomicBool is checked between attempts.
- C13 id remap. TrackRepository::upsert_batch_with_remap performs the
  §6.9 detect-and-rebind pass inside the same SQLite transaction as
  the upsert: a content_hash or server_path collision on a different
  existing id triggers UPDATE of child tables (track_offline,
  track_extension, track_fact, track_artifact, track_canonical_link),
  INSERT INTO track_id_history, DELETE old track row. Off when
  UnstableTrackIds is clear (generic Subsonic). New
  TrackIdHistoryRepository read-side helper for forward lookups
  (analysis cache reuse, Phase E).
- IngestStrategy enum + selector (sync::strategy) — N1 → S1 → S2;
  N1 requires Navidrome bearer credentials at runtime (skipped when
  None). S3 is enumerated for future file-tree fallback but returns
  StrategyUnsupported in v1 per kickoff Q3.
- InitialSyncCursor (sync::cursor) — JSON-serialisable
  { strategy, phase, library_scope, ingested_count, strategy_state }.
  StrategyState tagged enum: LinearOffset { offset } for N1/S1,
  AlbumCrawl { album_offset, current_album_id } for S2.
- mapping::subsonic_song_to_track_row + navidrome_song_to_track_row
  centralise the JSON → TrackRow projection. Subsonic path also reads
  replayGain.{trackGain,albumGain} from the raw value so PR-3b doesn't
  drop the columns that PR-2b reserves on TrackRow.
- Supporting bits in psysonic-integration:
  - subsonic types now derive Serialize so the runner can round-trip
    a typed Song back into raw JSON when feeding upsert.
  - navidrome::queries gains nd_list_songs_internal — pure async
    function (no #[tauri::command] decorator) that the N1 ingest
    loop calls directly. The existing Tauri command wraps it.

Tests added across sync::* and repos::track_id_history. Wiremock
covers S1 happy-path, mid-cursor resume from a persisted offset,
strategy mismatch → CursorIncompatible, 503 transient → retry-then-
succeed, AtomicBool cancellation → Cancelled, N1 paginated /api/song
ingest, S2 album crawl, and §6.9 remap firing under UnstableTrackIds
during an actual sync. Backoff schedule + jitter formula pinned.
TrackRepository remap path covered by content_hash collision,
server_path collision, hash+path-missing skip, identity-noop, and
remap-off compatibility with the existing upsert_batch contract.

Also fixes cucadmuh's PR-3a review minor 1: drops the dead
`mount_ok` scaffolding from sync::capability tests.

Out of scope per kickoff Q2:
- DeltaSyncRunner + tombstones → PR-3c
- Background task lifecycle, cancellation wiring, progress emit
  throttle, adaptive scheduler, request budget, bandwidth lane → PR-3d
- Tauri command surface for "sync now" / progress events → PR-5

* feat(library): search3 raw envelope fidelity for S1 ingest (PR-3b follow-up) (#797)

Picks up cucadmuh's PR-3b review minor 1: the S1 path in
InitialSyncRunner was reserialising the typed `Song` for
`track.raw_json`, dropping unknown OpenSubsonic extensions
(`replayGain`, `contributors`, …). N1 and S2 already carry the raw
sub-tree verbatim through `nd_list_songs_internal` and
`get_album_with_raw`; S1 now matches via the new
`SubsonicClient::search3_with_raw` mirror of the PR-2b pattern.

- subsonic::SubsonicClient::search3_with_raw — returns
  `(SearchResult, serde_json::Value)`; uses the existing
  `parse_envelope_with_raw` so error 70 / `Api { code, .. }` mapping
  stays consistent.
- sync::initial::run_s1 now calls `search3_with_raw` and feeds the
  per-song raw sub-tree (`raw_body.song[i]`) into
  `subsonic_song_to_track_row` instead of a typed reserialise.

Tests cover `search3_with_raw` round-trip on a payload with
`replayGain` + `contributors` (verifies the raw value preserves both)
and the empty-result case where the body is `searchResult3: {}`.
Plus an end-to-end S1 ingest test that asserts the persisted
`track.raw_json` column contains the OpenSubsonic extensions after a
full runner pass, and that `replay_gain_track_db` / `_album_db` still
land on the typed columns via the mapping helper.

Full review: psysonic-workdocs/internal/collaboration/handoffs/2026-05-19-pr-796-review.md

* feat(library): DeltaSyncRunner + TombstoneReconciler (Phase C3/C4, PR-3c) (#798)

Third sub-PR of Phase C — drives targeted delta passes on top of
PR-3a/b's foundation. Pure async; PR-3d will spawn it inside the
background scheduler.

- C3 DeltaSyncRunner. Walks spec §6.4 DS-0 … DS-9:
  - DS-0/1/2/3 cheap probe via `getArtists` (small/medium tier) or
    `getScanStatus` (huge tier when `ScanStatusAvailable`). Server
    watermark match → up_to_date short-circuit, scan-in-progress →
    deferred_scanning report; zero further requests in either case.
  - DS-4 targeted ingest. Strategy from capability_flags: N1-delta
    when NavidromeNativeBulk is set, otherwise S2-delta. S1 has no
    delta semantic so it's not used here.
    - N1-delta: GET /api/song _sort=updated_at _order=DESC, pages
      until rows fall under the local `MAX(server_updated_at)`
      watermark; out-of-band rows in the same page are dropped.
    - S2-delta: getAlbumList2 type=newest then type=recent, up to
      a small page cap; getAlbum is fetched only for album_ids the
      local store doesn't already have. Known albums are skipped
      so a play-bump under "recent" doesn't re-ingest the whole
      tracklist.
  - DS-6 id remap reuses TrackRepository::upsert_batch_with_remap.
  - DS-9 stamps next watermark (artists_last_modified_ms or
    server_last_scan_iso) + last_delta_sync_at.
  - DS-5 canonical matcher (Phase H) and DS-7 starred delta are out
    of scope for PR-3c.
- C4 TombstoneReconciler. Caller-driven streaming: each
  `reconcile_chunk(budget)` picks the next `budget` ids ordered by
  synced_at ASC, calls getSong, marks deleted=1 on code 70, and
  refreshes synced_at on every checked id so the queue rotates.
  Mode A (manual integrity) loops until checked == 0; Mode B
  (auto-threshold) tests `should_auto_reconcile(local, server, pct)`
  per delta tick and runs a small budgeted chunk. Memory bounded —
  no full local-id list ever held in RAM.

- SyncStateRepository: new getters for artists_last_modified_ms,
  server_last_scan_iso, library_tier; new
  set_last_delta_sync_at stamp helper. All column-scoped upserts
  preserve neighbouring fields.

Tests cover DS-2 short-circuit (watermark match), DS-3 defer
(scanning=true), N1-delta watermark cutoff (3 fresh + 2 stale rows →
only 3 upserted), S2-delta known-album skip (mock 404 on al_known
guards the assertion), DS-9 watermark + last_delta stamping,
should_auto_reconcile threshold cases (gap, tolerance, server=0,
local<=server), reconcile_chunk code-70 → deleted=1, budget +
ordering (oldest first, newest untouched), empty-store noop, and
cancellation.

PR-3d (background task, probe→flags wiring, progress emit, adaptive
scheduler, request budget, bandwidth throttle) lands next on the
same integration branch.

* feat(library): sync supervisor + progress channel + DS-8 wiring (Phase C5/C6, PR-3d1) (#799)

First half of PR-3d (cucadmuh-approved split per kickoff Q2).
Pure-Rust lifecycle + progress infrastructure on top of the runners
from PR-3a/b/c. Tauri events stay in the top crate (PR-5); this PR
only ships the channel the top crate will subscribe to.

- C5 SyncSupervisor. Spawns a sync workload inside a tokio task,
  owns the cancellation AtomicBool, and exposes a single-consumer
  mpsc receiver for ProgressEvent. join() returns the inner
  Result<(), SyncError>; panics surface as Storage so callers
  never need to know about tokio internals.
- C6 progress channel. New sync::progress module:
  - ProgressEvent enum — lean variants
    (PhaseChanged / IngestPage / Remapped / Tombstoned /
    Completed / Error). Server / scope context lives on the
    channel side (one supervisor = one scope).
  - Progress trait + NoopProgress default + ChannelProgress
    forwarding through tokio mpsc. Throttle is the simple
    last-emit-timestamp gate; terminal events (Completed /
    Error) bypass it.
- InitialSyncRunner + DeltaSyncRunner gain with_progress(...)
  builders. IS-1 / IS-6 emit PhaseChanged + Completed; delta
  emits PhaseChanged at strategy pick, Tombstoned at DS-8, and
  Completed at DS-9. Defaults to NoopProgress so existing call
  sites keep working.
- DS-8 wired. DeltaSyncRunner::with_tombstone_budget(n) drives
  TombstoneReconciler::reconcile_chunk(n) after DS-4 ingest;
  shares the runner's cancellation flag + sleep override. The
  DeltaSyncReport gains tombstones_checked / tombstones_deleted
  so callers can act on the counts.
- capability::probe_and_persist helper. Chains
  CapabilityProbe::run with sync_state writes: sets phase to
  "probing" before the probe, persists capability_flags, then
  drops back to "idle". PR-3d2 (the scheduler) will call this in
  front of every initial / delta run so the stored flags reflect
  the live server.

Tests cover: ChannelProgress throttle (zero-interval pass-through,
terminal bypass, non-terminal collapse, sender alive after
receiver drop), SyncSupervisor task completion + cancel +
panic-as-Storage + receiver-take-once, probe_and_persist
round-trip through SyncStateRepository (flags persisted, phase ends
at "idle"), DS-8 reconcile-after-ingest landing tombstones on
code 70 returns.

PR-3d2 follows with the adaptive scheduler (C8), request budget
(C9), poll EWMA (C10), and the bandwidth / queue priority lane
(C11).

* feat(library): adaptive scheduler + request budget + EWMA poll + bandwidth (Phase C8/C9/C10/C11, PR-3d2) (#800)

Second half of PR-3d per cucadmuh's kickoff-Q2 split. Wraps the
runners + supervisor from PR-3a/b/c/d1 into a tick-driven background
scheduler. Top crate (PR-5) plumbs the timer.

- C8 BackgroundScheduler. Tick-based — caller drives the interval,
  scheduler decides whether the tick should run. is_due(now_ms)
  checks sync_state.next_poll_at; tick(now_ms) either skips
  (not due / PrefetchActive pause), or runs a DeltaSyncRunner with
  the right budget + tombstone trigger, then stamps the next
  poll_at via the adaptive formula. No tokio task ownership —
  tests stay deterministic, PR-5 plugs spawn behaviour to taste.
- C9 RequestBudget. PassKind enum (PollTick / DeltaLight /
  DeltaMismatch / InitialSync) with caps per spec §6.2.5
  (1 / 50 / 200 / unlimited). RequestBudget::has_room(used) gates
  the runner; PR-3d2 ships the data type, runner enforcement of
  the cap is a future tightening (DeltaSyncRunner already has its
  own page cap so the soft cap mostly informs Settings).
- C10 PollStats EWMA. New sync::poll_stats with PollStats
  (artist_count, ewma_bytes, ewma_duration_ms, library_tier),
  observe()/set_artist_count()/reclassify() helpers, the §6.2.2
  tier table (<2k / 2k-15k / >15k or ewma_bytes >2MB), and
  next_interval_ms following the spec formula
  (base * load_factor * artist_factor, load_factor clamped
  [1, 10]).
- C11 PlaybackHint + ParallelismBudget. PlaybackHint enum
  (Idle / Playing / PrefetchActive) resolved to a
  ParallelismBudget { max_concurrent, min_request_gap_ms }.
  PrefetchActive pauses bulk (`max_concurrent = 0`) per
  §6.2.4; the scheduler honours it via tick short-circuit.
- Auto-tombstone wire. Before running the DeltaSyncRunner the
  scheduler tests `should_auto_reconcile(local, server, pct)`
  against the persisted counts; on threshold trip it sets
  `with_tombstone_budget(200)` (the §6.2.5 DeltaMismatch cap).
- SyncStateRepository gains poll_stats_json get/set,
  next_poll_at get/set, local_track_count get/set, and
  server_track_count get/set — all column-scoped upserts.

Tests: ~30 new across poll_stats / budget / bandwidth / scheduler.
EWMA seed + smoothing, tier-classification edges (artist + size
overrides), next-interval formula bounds (idle base, slow-network
load_factor clamp), RequestBudget caps per pass, ParallelismBudget
resolution, scheduler is_due (no schedule / future schedule),
tick short-circuit (not due, PrefetchActive pause), tick runs
delta and persists next_poll_at, auto-tombstone trigger above
5 % threshold, PollStats round-trip through SQLite.

Together with PR-3d1 this finishes Phase C — Tauri command surface
(D1-D4) lands with PR-5.

* feat(library): read-only Tauri command surface (Phase D1 part 1, PR-5a) (#801)

First sub-PR of Phase D per cucadmuh's kickoff Q1 split. Lands the
LibraryRuntime Tauri State plus the 8 read-only library commands
from spec §7.1. No SyncSupervisor spawn, no sync lifecycle commands,
no credentials store — those land in PR-5b.

- New psysonic_library::runtime::LibraryRuntime — Tauri State
  wrapping Arc<LibraryStore>. Top crate's lib.rs setup() now calls
  LibraryStore::init(app), wraps the result in the runtime, and
  app.manage's it. Mirrors the AnalysisCache wiring above it.
- New psysonic_library::dto module — camelCase wire DTOs per
  src-tauri/CLAUDE.md: SyncStateDto, LibraryTrackDto (flat
  projection over the track hot columns + raw_json sub-tree),
  LibraryTracksEnvelope, TrackArtifactDto, TrackFactDto,
  OfflinePathDto, TrackRefDto. local_tracks_max_updated_ms helper
  surfaces the implicit N1-delta watermark on the SyncStateDto.
- New psysonic_library::payload module — pure
  ProgressEvent → LibrarySyncProgressPayload mapper (the
  payload Tauri events carry once PR-5b plugs the supervisor's
  mpsc receiver into AppHandle::emit). Constants for the event
  names too. Unit-testable without Tauri runtime.
- New psysonic_library::commands module with 8 #[tauri::command]
  handlers:
  - library_get_status — joins the sync_state row + the
    track-watermark MAX query into one SyncStateDto.
  - library_search — FTS5 via the existing search_tracks helper,
    paginated; hydrates hits to full LibraryTrackDto.
  - library_get_track — single SELECT through new
    TrackRepository::find_one.
  - library_get_tracks_batch — capped at 100 refs/call per spec,
    preserves caller-supplied order, drops unknowns silently.
  - library_get_tracks_by_album — ordered by
    disc/track/id via new TrackRepository::find_by_album.
  - library_get_artifact — flexible WHERE over track_artifact
    (artifact_kind required, source/format optional), latest
    fetched_at wins.
  - library_get_facts — fact_kinds filter optional;
    returns all rows for the (server_id, track_id) pair when
    none specified, sorted by fact_kind + fetched_at DESC.
  - library_get_offline_path — returns local_path with a
    `missing: true` flag when the row is absent.
- TrackRepository gains find_one / find_batch / find_by_album
  with a shared row-to-TrackRow mapper. SQL constants pinned next
  to the existing UPSERT_SQL so a schema change touches one file.
- src-tauri/src/lib.rs: LibraryStore::init in setup(), the eight
  command handlers added to invoke_handler!.

Tests cover: DTO field-name camelCase (IPC contract guard),
LibraryTrackDto round-trip through TrackRow, raw_json fallback to
Value::Null on bad input, local_tracks_max_updated_ms ignores
deleted rows, TrackRepository::find_one / find_batch / find_by_album
ordering + unknown-ref drop, ProgressEvent mapper across all six
variants + serialization keys camelCase. Library tests at 166;
workspace stays green.

Out of scope per kickoff Q1:
- Mutating commands (library_sync_*, library_patch_*,
  library_put_*, library_purge_*, library_delete_*) → PR-5b
- SyncSupervisor spawn + background scheduler tick loop +
  progress emit → PR-5b
- library_sync_bind_session / clear_session credentials → PR-5b
- TS wrappers + Settings UI + server-remove modal → PR-5c
- library_advanced_search / library_search_cross_server SQL
  builders → PR-5d

* feat(library): sync lifecycle + mutate + purge Tauri surface (Phase D1 part 2, PR-5b) (#802)

Second sub-PR of Phase D per cucadmuh's kickoff Q1 split. Adds the
mutating side of §7.1 plus the SyncSession credentials store, the
PlaybackHint setter, the orchestrator that runs InitialSyncRunner /
DeltaSyncRunner under a Tauri AppHandle and emits library:sync-progress
and library:sync-idle events, and the top-crate scheduler tick task
that sweeps every bound session through BackgroundScheduler::tick.

- LibraryRuntime extended per kickoff Q2: sync_sessions HashMap,
  playback_hint cell, current_job (cancel handle + identity), and
  scheduler_cancel flag the tick task watches. Kickoff sketch said
  Mutex<Option<SyncSupervisor>> — supervisor's join() consumes self,
  so holding it in the mutex would block library_sync_cancel behind
  the orchestrator's join; CurrentJob carries the Arc<AtomicBool>
  cancel + metadata instead, orchestrator task owns supervisor /
  receiver / join.
- New commands (spec §7.1):
  - library_sync_bind_session — caches Subsonic creds in memory,
    tries navidrome_token once for bearer cache, runs
    probe_and_persist so capability_flags reflect the live server.
  - library_sync_clear_session — drops cached credentials.
  - library_set_playback_hint — JS pushes idle / playing /
    prefetch_active from existing audio listeners.
  - library_sync_start — dispatches InitialSyncRunner (mode='full')
    or DeltaSyncRunner (mode='delta', with auto-tombstone budget
    when local/server count gap exceeds threshold). Spawns runner
    + orchestrator task that drains the progress mpsc into
    library:sync-progress emits and emits library:sync-idle when
    the runner exits.
  - library_sync_cancel — trips the current job's cancel flag.
  - library_patch_track — sparse JSON patch (starredAt, userRating,
    playCount, playedAt) per §6.5.
  - library_put_artifact / library_put_fact — upserts with
    ON CONFLICT scoped to the PK so lyrics / BPM writes survive
    re-fetches.
  - library_purge_server — transactional DELETE across the v1
    schema tables for this server_id. include_offline (default
    false) controls track_offline + bytes_freed.
  - library_delete_server_data — alias that always purges offline
    too (logout flow).
- src-tauri/src/lib.rs setup() spawns a 30 s
  MissedTickBehavior::Skip task that snapshots bound sessions and
  drives BackgroundScheduler::tick(now_ms) for each. Honours
  runtime.scheduler_cancel + the current PlaybackHint. Background
  ticks stay silent (NoopProgress) — Tauri emit for the
  scheduler path lands when Settings (PR-5c) surfaces it.
- psysonic-integration::navidrome re-exports navidrome_token so the
  bind_session command can drive the bearer cache without making
  the client module pub.

Tests cover: LibraryRuntime session round-trip (set/get/clear
scopes per server), playback_hint default + setter, snapshot
returns clones so callers can mutate freely. Existing library tests
stay green (171 → 171; new code paths under the Tauri command
surface — devtools integration smoke is PR-5c's job).

Out of scope per kickoff Q1:
- src/library/ TS wrappers + Settings UI subsection + server-remove
  modal → PR-5c
- library_advanced_search / library_search_cross_server SQL
  builders → PR-5d
- Background-tick Tauri emit (NoopProgress today) → PR-5c
- analysis_cache cross-purge in library_purge_server → PR-6

* feat(library): typed invoke wrappers + verify_integrity command (Phase D2 + part of D1, PR-5c) (#803)

Frontend-facing slice of Phase D. Ships the typed src/api/library.ts
wrapper layer that any Settings / browse code will import from, plus
the manual-integrity backend command PR-5b's review §5 note 2 called
out as missing.

Scope cut from cucadmuh's PR-5 kickoff Q1 split: that proposal had
PR-5c = D2 + D3 + D4 (wrappers + Settings subsection + server-remove
modal). The Settings UI + server-remove + audio playback-hint
wiring + authStore extensions + i18n strings turn into a thick frontend
patch in their own right; landing them in one PR with the wrappers
would mix Tauri-surface review with Settings UX review. The split:

- PR-5c (this PR) — D2 wrappers + library_sync_verify_integrity.
- PR-5c-ui (follow-up) — D3 Library Settings subsection, D4
  server-remove modal contract, playback hint feed, authStore /
  i18n.

Per kickoff exit clause ("Do not split 5c unless review size
forces it"). Reviewable as a clean Tauri-surface vs UX boundary.

- Backend: `library_sync_verify_integrity { serverId, libraryScope? }`
  command — same dispatch shape as `library_sync_start { mode:'delta' }`
  but always forces the full `DELTA_MISMATCH_CAP` tombstone budget
  regardless of the local/server count gap. Spec §6.7 Mode A user-
  initiated full reconcile bypasses the threshold check that
  governs background ticks.
  `library_sync_start` itself is refactored to delegate to a private
  `library_sync_start_inner(force_full_tombstone)` so both entry
  points share the runner-spawn + orchestrator + emit code.

- Frontend `src/api/library.ts`: full typed wrapper layer over the
  19 `library_*` Tauri commands. DTO mirrors carry the camelCase
  wire shape (`SyncStateDto`, `LibraryTrackDto`, `TrackArtifactDto`,
  `TrackFactDto`, `OfflinePathDto`, `PurgeReportDto`, `SyncJobDto`,
  `TrackRefDto`, `ArtifactInputDto`, `FactInputDto`). Plus the
  `LibrarySyncProgressPayload` / `LibrarySyncIdlePayload` interfaces
  and `subscribeLibrarySyncProgress` / `subscribeLibrarySyncIdle`
  helpers that wrap `@tauri-apps/api/event` listen. PlaybackHint
  literal type lives here too (`'idle' | 'playing' | 'prefetch_active'`)
  so the audio listeners in PR-5c-ui can import a single source of
  truth.

- `src-tauri/src/lib.rs` adds the new verify_integrity handler to
  the `invoke_handler!` aggregate.

Tests: library tests stay at 171 — verify_integrity is exercised
through the existing `sync_start_inner` paths; the wrapper layer is
trivial passthrough that TypeScript types already check. Vitest
coverage for the typed wrappers belongs with PR-5c-ui where there
are real consumers (LibraryTab) to drive integration tests.

PR-5c-ui (next) lands:
- Library Settings subsection (§7.3 minus advanced toggles)
- ServerRemoveModal extension (keep vs delete local index per §5.6)
- authStore: libraryIndexEnabledByServer + auto-reconcile toggle
- src/store/audioListenerSetup audio:playing / ended /
  setDeferHotCachePrefetch → library_set_playback_hint
- i18n keys for the new strings

* feat(library): Settings library index UI + playback hint + purge-on-remove (Phase D3/D4, PR-5c-ui) (#804)

* feat(library): Settings library index UI + playback hint + purge-on-remove (Phase D3/D4, PR-5c-ui)

Frontend half of Phase D, on top of PR-5c's typed wrappers. Wires the
Settings → Library subsection (§7.3), the audio playback-hint feed
(§6.2.4), and the server-remove keep-vs-delete choice (§5.6).

- New libraryIndexStore (Zustand, persisted) — per-server enable flag
  + auto-reconcile toggle. Kept out of authStore so the index feature
  evolves independently and the persisted blob stays small.
- New LibraryIndexSection in Settings → Library:
  - Per-server "Enable local library index" toggle → binds /
    clears the Rust sync session with the active server's
    credentials. Off by default (P6).
  - Read-only status (Idle / Checking / Initial sync / Ready (n) /
    Error) polled from library_get_status every 3 s, overlaid with
    live library:sync-progress events.
  - Sync now / Verify integrity / Cancel buttons. Verify runs one
    §6.7 pass (budget 200) per click; the status line shows the
    checked/removed counts so large libraries can be continued with
    another click (auto-resume loop is a follow-up).
  - Auto-reconcile toggle.
  - Subscribes to library:sync-progress + library:sync-idle for the
    active server; errors surface as a toast.
- Audio playback hint: handleAudioPlaying → 'playing',
  handleAudioEnded → 'idle' via notifyLibraryPlaybackHint, which
  gates on the per-server index toggle + dedupes repeated hints so
  the IPC boundary isn't spammed on every progress tick.
- ServersTab delete flow: when a server with an enabled index is
  removed, a second confirm offers keep-vs-delete of the local
  library cache (OK = library_delete_server_data, Cancel = retain
  for offline). Always clears the sync session.
- i18n: en + de keys for the new strings; other locales fall back
  to en via i18next (later sweep).

Per PR-803 review §5: verify-integrity resume UX is one-pass-per-click
with a visible counter; sync_start idempotency (replaces in-flight)
is surfaced via the Cancel button appearing while busy.

Out of scope:
- VirtualSongList / playerStore local-mode consumers → PR-7 (F1/F3/F5)
- library_advanced_search / cross-server UI → PR-5d + PR-7 F2
- Auto-resume loop for very large verify-integrity runs → follow-up
- Search-all-servers + threshold input (advanced §7.3) → later

* fix(library): normalize server base URL before bind probe

The bind toggle threw "subsonic transport: builder error | relative
URL without a base" — `server.url` is stored bare (e.g.
`nas.example.com`) and reqwest needs a scheme. Two-sided fix:

- Frontend: LibraryIndexSection passes `authStore.getBaseUrl()`
  (adds http:// + strips trailing slash) instead of the raw
  `server.url`, matching the existing `subsonic.ts` convention.
- Backend: `library_sync_bind_session` normalizes the incoming
  `base_url` defensively so the stored session + every downstream
  caller (sync_start, scheduler tick, navidrome_token) gets a
  scheme-qualified URL regardless of what the WebView sends.

Tests: normalize_base_url covers bare host, trailing slash, existing
http/https scheme, and whitespace.

* fix(library): re-bind sync session on startup + server switch

"Library sync failed: no bound session" — the per-server index toggle
persists in localStorage but the Rust sync session (credentials +
bearer) lives in process memory and is gone after an app restart, so
the toggle showed "on" while no session existed. Per PR-5 kickoff Q5
("on server connect if index already on").

- New `ensureActiveServerSessionBound()` helper: re-binds the active
  server's session when its index toggle is enabled. Best-effort —
  silent on failure (Settings surfaces the real error on explicit
  toggle).
- MainApp re-binds on every `activeServerId` change (covers app
  startup + server switch — `setActiveServer` drives the effect).
- LibraryIndexSection re-binds on mount before the first status poll,
  so Sync now / Verify integrity work immediately even when the
  toggle was already on from a previous run.

* fix(library): trigger initial full sync on first enable (PR-804 review §5.1)

cucadmuh's PR-804 review flagged this as release-blocking: the toggle
only bound the session and «Sync now» / the background tick ran
delta-only, so a fresh enable left the index empty — delta can't
populate a never-synced library.

- On first enable, after bind, fetch status and dispatch
  `library_sync_start { mode: 'full' }` when `lastFullSyncAt` is null
  (matches spec §6.2 "initial sync always background").
- «Sync now» now picks mode adaptively: `full` until a full sync has
  completed, `delta` afterward — so the button works both for the
  initial population and incremental updates.

Other PR-804 review notes (auto-reconcile toggle → backend wiring,
prefetch_active hint, clear-old-session-on-switch) stay as documented
non-blocking follow-ups.

* feat(library): advanced search + cross-server SQL builders (Phase D-search, PR-5d) (#806)

* feat(library): advanced search + cross-server SQL builders + commands (Phase D-search, PR-5d)

- FilterFieldRegistry SQL resolution: SqlFragment, compare_fragment, validate_for_entity (§5.13.5)
- Advanced Search builder: per-entity track/album/artist queries; genre (case-insensitive), year, starred, bpm filters; bpm dual-storage resolution (§5.13.4); libraryScope; sort allowlist; full-match totals
- Cross-server FTS union (§5.5B / §5.9 A') with canonical-id dedup
- library_advanced_search + library_search_cross_server commands, registered in the shell

* feat(library): typed advanced search / cross-server invoke wrappers (PR-5d)

Mirror request/response DTOs and add libraryAdvancedSearch / librarySearchCrossServer
in src/api/library.ts. UI parity (AdvancedSearch.tsx) stays PR-7.

* fix(library): self-heal stale/unreadable initial-sync cursor instead of bricking (#807)

The initial-sync cursor records the ingest strategy it was created under.
When a re-probe later selects a different strategy (e.g. the Navidrome
native bearer is briefly unavailable, downgrading N1->S2), the cursor guard
returned a hard error — and since nothing clears the cursor, every later
full sync failed with no recovery path.

Reset the stale (or unreadable) cursor and start fresh under the selected
strategy instead of erroring. Re-ingest is idempotent (upsert); the
tombstone pass reconciles leftovers.

* fix(library): emit per-batch progress during initial sync (#808)

The initial-sync runner only emitted PhaseChanged (start) and Completed
(end), so the Settings status sat at "initial_sync" with no count for the
entire ingest — looking stuck on large libraries even while rows landed.

Emit IngestPage per batch from the N1/S1/S2 loops with the running ingested
total; the existing <=2 Hz throttle paces it. The frontend already renders
the count from these events.

* feat(library): Advanced Search reads the local index when ready (Phase F2, PR-7a) (#811)

When the active server's index is fully synced, Advanced Search serves
query / genre / year / result-type from library_advanced_search (instant +
offline) and pages songs locally. On not-ready or any failure it falls back
to the existing network path unchanged (spec 5.13.6). Results map from each
entity's stored Subsonic rawJson, with the flat hot columns as a fallback.

* feat(library): canonical matcher — link tracks by ISRC/MBID on ingest (Phase H1/H2, PR-4a) (#812)

Adds the strong-key cross-server matcher (spec §5.5A): on every track upsert,
link (server_id, track_id) to a canonical id derived from its ISRC (preferred)
or MBID recording. Deterministic id (`{kind}:{value}`) keeps it O(1) and
idempotent — no lookup-then-create race, no fuzzy loop on the bulk path.
Tracks without a strong key stay standalone (fuzzy/search-time matching is H3).

* feat(library): cross-server fuzzy fallback in search (Phase H3, PR-4b) (#813)

library_search_cross_server now returns a `fuzzy` list alongside the exact
FTS `hits` (spec §5.9): per-server `title LIKE %query%` for matches the exact
pass missed (diacritics, partial words), capped per server, excluding exact
hits and deduped by canonical id against them. Shared `like_contains` moved
to the `search` module.

* feat(library): FactRepository with TTL + provenance rules (Phase E4, PR-6a) (#814)

Typed CRUD over track_fact behind library_get_facts / library_put_fact
(spec §5.12): get lazily deletes the track's expired facts then returns the
survivors (no background GC, P34); a `user` bpm fact also writes the hot
track.bpm column so the override wins and survives a resync (R6-3.4). The
commands now delegate here instead of inlining the SQL.

* feat(library): ArtifactRepository with TTL + 512KB cap (Phase E4, PR-6b) (#815)

* fix(integration): decode OpenSubsonic isrc string-array on Song (#818)

OpenSubsonic types `isrc` as `string[]`; Navidrome 0.61.2 ships it as
`isrc: []` or `["USRC…"]`. The typed `Song.isrc: Option<String>` could
not decode either form, which broke the S1 (`search3`) and S2
(`getAlbum`) ingest paths on real Navidrome libraries — initial sync
could not complete past the first array-valued track.

Add a tolerant `de_string_or_seq` deserializer: plain string →
`Some`, non-empty array → first usable value (string element, or an
object element's `name` for the `[{ "name": … }]` shape), `[]`/null →
`None`. The full multi-value set still survives verbatim in
`track.raw_json` (ADR-7). Applied to `Song.isrc`.

Per maintainer policy R7-15 (workdocs question
2026-05-20-large-library-ingest-client-only, checklist item 1):
treat Navidrome as a black box, harden the client decode.

Tests cover `isrc: []` → None, populated array, and the legacy
single-string form.

* feat(library): large-library ingest strategy — S1 over N1 (R7-15) (#819)

Per maintainer policy R7-15 (large-library ingest, client-only): very
large Navidrome catalogs must not start initial sync on N1 — its native
`/api/song` returns HTTP 500 beyond a deep offset and can never finish.
S1 (`search3`) does not hit that wall.

- Add `IngestStrategy::select_initial_strategy(flags, server_track_count,
  n1_bulk_unreliable)`. Large libraries (count > LARGE_LIBRARY_THRESHOLD,
  default 40_000) or servers flagged `n1_bulk_unreliable` route to S1 — or
  S2 when search3 bulk is absent. Normal-size libraries keep the cheapest
  N1 → S1 → S2 chain unchanged.
- Persist the learned per-server `n1_bulk_unreliable` flag on `sync_state`
  (additive migration 002, DEFAULT 0). The mid-run N1→S1 fallback that
  sets it lands in a follow-up.
- Capture `getScanStatus.count` in the capability probe and persist it as
  the `server_track_count` watermark, so the threshold applies from the
  first sync rather than only after N1 hits the wall once. A count-less
  probe never clobbers a watermark from a prior run.
- The initial-sync runner now selects via the new policy.

Tests: selector table (all branches incl. threshold boundary and the
search3-absent fallback), repo flag roundtrip, probe count capture +
watermark-preservation, migration head-version bookkeeping.

* feat(library): freeze ingest strategy on resume (R7-15 Q3) (#820)

A persisted initial-sync cursor that has already made progress must resume
under its own strategy and ignore what a fresh capability probe would now
pick. Previously any strategy mismatch reset the cursor to a fresh one — so
a flapping Navidrome bearer (N1 flag toggling between probes) restarted
ingest from offset 0 on every launch, which is why large initial syncs
never completed across restarts.

`load_or_init_cursor` now:
- resumes the cursor's strategy when it has progress (`ingested_count > 0`
  or `phase != Ingest`), regardless of the re-selected strategy;
- adopts the freshly-selected strategy only when there is no resumable
  progress (offset 0), where re-selecting costs nothing;
- still resets a corrupt/unreadable cursor rather than hard-erroring.

One guarded exception: a cursor still on N1 after the server was learned
`n1_bulk_unreliable` is known-broken and re-selects onto the non-N1 path
instead of resuming a wall-bound N1 loop (the mid-run N1→S1 fallback that
preserves progress lands next).

Tests: resume-with-progress freezes strategy and keeps the count;
no-progress cursor adopts the re-selected strategy; known-broken N1 cursor
re-selects; unreadable cursor still resets.

* feat(library): one-way N1→S1 fallback on deep-offset 500 (R7-15 Q5) (#821)

When the N1 ingest loop hits a persistent HTTP 500 at or beyond the
deep-offset safety line (`N1_DEEP_OFFSET_SAFE`, 50_000) it now treats it
as Navidrome's server-side deep-offset wall rather than a transient error:
it learns `n1_bulk_unreliable` for the server and finishes the sync on S1.

- `run_n1` catches the wall after retry exhaustion (`n1_hit_deep_offset_wall`:
  HTTP 500 AND offset >= the safety line) and hands off to `fall_back_n1_to_s1`.
  A 500 below the line stays a propagated error — no silent downgrade.
- The fallback flags the server, then restarts S1 from offset 0. N1 (`id ASC`)
  and S1 (`search3` default order) don't share an offset space, so resuming
  from the N1 offset would skip songs; re-ingest is idempotent (PK upsert),
  duplicate work over the rows N1 already wrote is acceptable for v1. The
  cursor is rewritten in place, never zeroed.
- One-way only: S1 never flips back to N1 mid-run. Combined with the
  persisted flag and the resume freeze, a future sync selects S1 directly.
- `N1_DEEP_OFFSET_SAFE` is overridable on the runner so the fallback is
  testable without 50k rows of fixture data.

Tests: deep-offset 500 falls back to S1, ingests the full set without
duplicating N1's rows, and persists the flag; a shallow 500 propagates and
does not flag the server.

* feat(library): cache + retry Navidrome bearer, keep N1 flag on transient loss (R7-15 Q3) (#822)

A flaky `/auth/login` previously stripped N1 for a whole bind: the bearer
was fetched once, best-effort, and a single miss dropped to Subsonic-only.
Per R7-15 Q3 a transient `navidrome_token` failure must not drop the
`NavidromeNativeBulk` capability.

- `bind_session` fetches the bearer with `navidrome_token_with_retry`
  (3 attempts, short backoff); if it still fails, it keeps the bearer
  cached from a prior bind instead of overwriting it with `None`. The token
  / credentials are never logged.
- `probe_and_persist` preserves a previously-learned `NavidromeNativeBulk`
  flag when it probes without a token — the server still supports
  `/api/song`; only the bearer is missing this bind. The capability is a
  stable server property, so a token-less probe must not clear it.
- `library_sync_start_inner` masks `NavidromeNativeBulk` from *this run's*
  strategy selection when the session has no token, so the run proceeds
  Subsonic-only (S1/S2) instead of selecting N1 with no creds. The
  persisted capability stays intact for a later bind that recovers the
  token. The in-flight cursor is already protected by the resume freeze.

Tests: token retry yields the token on success and `None` after exhausting
attempts; the probe keeps a learned N1 flag across a token-less re-probe.

* feat(library): mid-run S1→S2 fallback on persistent S1 failure (R7-15 Q8) (#823)

The N1→S1 fallback (#821) had no analogue when S1 itself fails on a server.
Per R7-15 Q8, a persistent S1 failure (C12 retries already exhausted) must
fall back to the universal S2 album crawl — no new artist-walk strategy.

- `run_s1` catches a persistent fetch failure from the `search3` retry loop
  (`is_fetch_failure`: transport / HTTP / decode / Subsonic API / not-found)
  and hands off to `fall_back_s1_to_s2`. Cancellation and storage errors
  propagate untouched.
- The fallback restarts S2 from scratch. S1 (`search3` order) and S2
  (album-list order) don't share an offset space, so resuming from the S1
  offset would skip songs; re-ingest is idempotent (PK upsert). The cursor is
  rewritten in place, never zeroed — the resume freeze then keeps the run on
  S2 across restarts.

This completes the ingest fallback chain N1→S1→S2 from the §6.3 strategy
order; the start-time "no search3 → S2" selection was already covered (#819).

Tests: a persistent S1 500 falls back to S2 and the album crawl ingests the
track.

* fix(library): resume interrupted initial sync on startup (#824)

* fix(library): resume interrupted initial sync on startup

An initial sync killed mid-run (app restart) sat at `idle` until the user
clicked «Sync now» — the background scheduler is delta-only and the
auto-full-sync only fired on the index toggle, not on the startup re-bind.

`resumeInitialSyncIfIncomplete` runs after the active server's session is
re-bound (startup + server switch): if no full sync has completed yet
(`!lastFullSyncAt`) it dispatches `library_sync_start { mode: 'full' }`,
which resumes from the persisted cursor instead of restarting from zero.
Once a full sync has landed it is a no-op, so delta stays the scheduler's
job. Best-effort — errors stay silent (Settings surfaces them on explicit
action).

Tests: starts a full sync when none has completed, no-ops once a full sync
has landed, stays silent when the status lookup fails.

* fix(library): silence cancelled-sync toast, de-dupe startup resume

Two rough edges from the startup resume:

- A cancelled sync surfaced as «Library sync failed: sync cancelled». The
  orchestrator emitted the runner's `Cancelled` result as an error on the
  sync-idle event. Cancellation is expected — the user cancelled, or a newer
  `library_sync_start` superseded the job (server switch / startup resume) —
  and is documented as silent. `sync_outcome_to_result` now maps
  `SyncError::Cancelled` to a clean idle, only real errors toast.
- `resumeInitialSyncIfIncomplete` is now de-duped per server. React
  StrictMode fires the startup effect twice, so a second `library_sync_start`
  cancelled the first (`set_current_job` is cancel-and-replace) — harmless
  with the fix above, but the dedupe avoids the wasted job + probe entirely.

Tests: `sync_outcome_to_result` keeps `Cancelled` silent and forwards real
errors; concurrent resume calls start a single full sync.

* fix(library): run DB read commands off the main thread (async) (#825)

The 10 library read commands were synchronous (`pub fn`). Per the Tauri v2
docs, commands without `async` run on the main thread — so a read that
blocks freezes the UI. During an initial sync the runner holds the single
`Mutex<Connection>` for a whole batch write (500 rows × per-row remap on
Navidrome + upsert + FTS, one transaction), and the Settings library
section polls `library_get_status` on an interval. Each batch write blocked
that polled read on the main thread → the window greyed out until the batch
finished, with the freeze growing as the DB grew.

Make the DB-touching read commands `async` so they run off the main thread:
`library_get_status`, `library_search`, `library_get_track`,
`library_get_tracks_batch`, `library_get_tracks_by_album`,
`library_get_artifact`, `library_get_facts`, `library_get_offline_path`,
`library_advanced_search`, `library_search_cross_server`. Reads still
serialize behind the writer (the single connection is intentional — the
schema mirrors `analysis_cache`, spec §5.1), but the wait no longer blocks
the UI. State-only commands stay sync. Invoke names / payloads are
unchanged, so the frontend is unaffected.

Spec §15 R7-15 follow-up — surfaced in live QA on a 170k library.

* feat(library): scope analysis cache by server_id (E1, schema only) (#826)

Add a versioned migration to audio-analysis.sqlite so waveform/loudness
rows are keyed per server. This is the schema-only step (PR-6c-1): every
existing row migrates to server_id='' and behaviour is unchanged. The
server_id write/read wiring, legacy fallback and lazy re-tag follow in 6c-2.

- migrations 001 (baseline = the pre-versioning schema) + 002 (rebuild the
  three tables with server_id; PK (server_id, track_id, md5_16kb), loudness
  + target_lufs)
- versioned runner mirroring the library store; each migration commits its
  schema change and version marker in one transaction, so a failure or crash
  rolls the whole migration back and retries cleanly
- VACUUM INTO snapshot before the table rewrite as a safety net beyond the
  transaction (disk-full at COMMIT, FS corruption)
- TrackKey gains server_id; all callers pass "" for now

* feat(library): analysis cache server_id wiring (E1, 6c-2) (#827)

* feat(library): scope analysis cache writes/reads/deletes by server_id (E1 wiring)

Build on the 6c-1 schema migration: thread the playback server scope
(playbackServerId ?? activeServerId) through the analysis cache so a server
switch can no longer surface another server's waveform/loudness for the same
bare track_id.

- Write: seed_from_bytes_* and the CPU-seed / HTTP-backfill queues carry a
  server_id; every audio write path (in-memory, ranged, legacy stream, local
  file, spill, preload), the syncfs offline/hot caches, and the backfill
  command write under the playback server (empty = legacy '').
- Read: get_latest_*_for_track and the exact-key lookup try the server scope
  first, then fall back to the legacy '' rows; a legacy hit is re-tagged onto
  the server scope (INSERT OR IGNORE, never clobbers a precise row). No bulk
  backfill — existing caches re-tag lazily on play, so they are not
  re-analysed wholesale.
- The backend gain-resolution path (loudness normalization, replay-gain
  updates, device resume) is scoped via a pinned current_playback_server_id on
  the audio engine, so normalization keeps working for server-scoped rows.
- Delete: delete_*_for_track_id scope to (server + legacy ''); reseed on one
  server no longer wipes another server's analysis. delete_all_waveforms stays
  global (Settings -> Storage).

Tauri boundary: analysis_get_waveform(_for_track), analysis_get_loudness_for_track,
analysis_delete_waveform/loudness_for_track and analysis_enqueue_seed_from_url
gain an optional serverId; audio_play and audio_preload gain an optional
serverId. All additive (absent = legacy '').

* feat(library): pass playback serverId to analysis IPC (E1 wiring)

Send getPlaybackServerId() (queueServerId ?? activeServerId) with every
analysis-cache call so reads/writes/deletes scope to the right server:

- audio_play / audio_preload (playTrack, resume, queue-undo restore, gapless
  byte-preload)
- analysis_get_waveform_for_track / analysis_get_loudness_for_track (waveform +
  loudness refresh)
- analysis_delete_waveform/loudness_for_track + analysis_enqueue_seed_from_url
  (reseed + loudness backfill)

Absent serverId stays backward-compatible (legacy '' scope).

* feat(library): content_hash from playback (E2, 6d) (#828)

* feat(library): record playback content_hash into the track store (E2)

Bridge the playback-derived md5_16kb into library `track.content_hash` (R7-16 Q4)
so id-remap can rebind a track when the server reassigns ids (§6.9).

- New `ContentHashSink` port in psysonic-core (closure handle, mirrors
  PlaybackQueryHandle): keeps psysonic-analysis decoupled from psysonic-library.
- `seed_from_bytes_into_cache` returns the computed md5; `seed_from_bytes_execute`
  fires the sink after a successful seed (Upserted or cache-hit) when a real
  server is known. The shell crate registers the sink to patch the library.
- `patch_content_hash` + `library_patch_track`'s new optional `contentHash`
  field write it; both no-op when the library has no row for (server_id, id),
  i.e. the index is off for that server.
- Sync upsert no longer clobbers it: `content_hash = COALESCE(NULLIF(
  excluded.content_hash,''), track.content_hash)` — a sync (which passes NULL)
  preserves the playback hash, a non-empty incoming hash still wins.

No schema migration — the `content_hash` column already exists. Tauri boundary:
`library_patch_track` gains optional `contentHash` (additive).

* feat(library): expose contentHash on libraryPatchTrack wrapper (E2)

Add optional `contentHash` to the `libraryPatchTrack` patch type so the TS
contract matches the extended Rust command. Normally written by the Rust
analysis bridge; exposed for completeness.

* feat(library): enrichment summary on library_get_track (E3, 6e) (#829)

* feat(library): enrichment summary on library_get_track (E3)

Add an optional `enrichment { waveformReady, loudnessReady, lyricsCached }` to
the single-track `library_get_track` read (R7-16 Q5). Read-only, per-server,
never blocks on the network; list/batch projections leave it unset.

- New `AnalysisReadinessQuery` port in psysonic-core (closure handle, mirrors
  ContentHashSink) keeps psysonic-library decoupled from psysonic-analysis. The
  shell crate registers it to probe the analysis cache by exact
  (server_id, track_id, content_hash) key with legacy '' fallback — read-only,
  no re-tag. waveform/loudness readiness is gated on a known content_hash (E2).
- `lyricsCached` from a new pure-read `ArtifactRepository::lyrics_cached`
  (valid, non-expired, non-not_found lyrics row).
- `library_purge_server`'s `includeAnalysis` documented as a deliberate v1
  no-op (R7-16 Q7): analysis is never deleted on purge / server remove.

Tauri boundary: `LibraryTrackDto` gains optional `enrichment` (additive).

* feat(library): mirror enrichment on LibraryTrackDto wrapper (E3)

Add `TrackEnrichmentDto` + optional `enrichment` to the TS `LibraryTrackDto` so
the contract matches the extended `library_get_track` response.

* feat(library): VirtualSongList browses the local index when ready (F1) (#830)

The all-songs browse now serves pages from the local library index when it is
ready for the active server, falling back to the unchanged network path
otherwise.

- `runLocalSongBrowse` (reuses the F2 local-read adapters): empty-query
  browse-all via `library_advanced_search`, whose default track order
  (`t.title COLLATE NOCASE ASC`) matches the network `ndListSongs('title','ASC')`
  path, so paging stays coherent across a local↔network boundary.
- Gated per page on `libraryIsReady` + `source === 'local'`; any miss / failure
  returns null → VirtualSongList uses the existing browse path unchanged.
- Search (non-empty query) stays on the network path for now; rich search is
  already covered by Advanced Search (F2).

* feat(library): patch-on-use for star/rating/scrobble (PR-7 F3) (#831)

* feat(library): library_patch_track clears nullable fields on explicit null (F3)

Extract the patch logic into a testable `apply_track_patch`. Nullable integer
fields (`starredAt` / `userRating` / `playCount` / `playedAt`) now distinguish an
absent key (leave untouched) from an explicit `null` (clear the column), so
`unstar` ({ starredAt: null }) actually un-stars the local row. `.map` keeps the
present/absent distinction; `as_i64()` yields the value or `None` → bound as SQL
NULL. F3 is the first caller that sends null, so no existing behaviour changes.

* feat(library): patch-on-use wiring for star / rating / scrobble (F3)

After a successful star/unstar, setRating, or play scrobble, mirror the change
into the local library index via `library_patch_track` so its reads (browse F1,
advanced search F2) reflect the action immediately — no stale list after a rate,
no full resync.

- `patchLibraryTrackOnUse` helper: fire-and-forget, gated on the index being
  enabled for the server; the Rust command additionally no-ops when no row
  matches (album/artist id, or index off).
- Wired at the central API chokepoints: `star`/`unstar` (song only) →
  `starredAt`, `setRating` → `userRating`, `scrobbleSong` → `playedAt`.
- `play_count` is left to the next sync (the patch sets absolute values; a
  correct increment needs the current base).

F4 (deprecating the player-store override maps) is intentionally separate —
removing them would break instant star feedback when the index is off.

* feat(library): full-queue restore from the index on startup (PR-7 F5) (#832)

Persist the whole queue as a lightweight ref list and rehydrate it from the
local index on startup, so the entire queue survives a restart instead of only
the windowed slice (R7-17 / §8.6).

- Persist adds `queueRefs` (full ordered ids) + `queueRefsIndex` alongside the
  existing windowed `queue`. Ids are tiny; the windowed objects stay as the
  no-index fallback.
- `hydrateQueueFromIndex` (startup, after session bind): when the library index
  is ready for the queue's server, hydrate the full queue via
  `library_get_tracks_batch` (batched ≤100), map `songToTrack ∘ trackToSong`,
  re-locate the current track so `queueIndex` stays aligned, then clear the refs.
- Index not ready / missing rows / current track not found → keep the windowed
  fallback (queue never empty when the index is off, the P6 default). Old
  persisted shape without refs loads unchanged.
- `trackToSong` exported from the F2 local-read adapters (one mapper).

Kept the windowed-objects persist (did not drop the cap per R7-17 note): the
index-off default needs the embedded fallback or the queue would restore empty.

* feat(library): pending-sync for song star/rating (PR-7 F4) (#833)

* feat(library): central pending-sync helper for song star/rating (PR-7 F4)

`queueSongStar` / `queueSongRating` (spec §6.5 / R7-18): set the player-store
override optimistically, retry the Subsonic API with exponential backoff (flush
on `online` / window focus), and on success clear the override + patch the
in-memory Track so the UI stays correct without it. The F3 index patch-on-use
runs inside the API layer, unchanged.

- No rollback on the first network error (the override survives until the retry
  succeeds or the app restarts; overrides are session-only, not persisted).
- Latest-toggle-wins coalescing + an identity guard so a fast re-toggle while a
  request is in flight can't retire the newer task.
- v1: songs only.

* feat(library): route song star/rating through the pending-sync helper (PR-7 F4)

Replace the scattered optimistic-set + API-call + rollback logic with the single
`queueSongStar` / `queueSongRating` helper across cucadmuh's named v1 surfaces:
PlayerBar, FullscreenPlayer, MobilePlayerView, both context menus (song + queue
row), both shortcut paths, the song-rating hook + player-bar stars, skip→1★, and
AlbumDetail (song star + rating). The 30+ override read sites are unchanged —
they already read `override ?? track`, and the override now clears on success.

Standalone page toggles (Favorites, RandomMix, NowPlaying star) and the separate
mini-player webview keep their existing path — no regression (a non-migrated
override simply lingers as before) — and move to a follow-up.

* feat(library): route remaining song star/rating sites through pending-sync (F4 follow-up) (#834)

Migrate the three standalone song write sites left out of #833 onto the
central queueSongStar / queueSongRating helper:

- Favorites: handleRate + removeSong (un-star)
- RandomMix: toggleSongStar (drops local try/catch rollback per no-rollback policy)
- useNowPlayingStarLove: toggleStar (keeps local view state, helper owns override + retried sync)

MiniContextMenu stays on its direct path (separate webview, no shared store).
No behaviour change for album/artist rating paths.

* feat(library): route playlist song star/rating through pending-sync (F4 follow-up) (#835)

The playlist-detail star/rating hook was the last shared-store song write
site still calling the Subsonic API directly. Route handleRate +
handleToggleStar through queueSongRating / queueSongStar, matching the
Favorites and RandomMix follow-ups; keep the local ratings/starredSongs
view state, drop the inline override.

MiniContextMenu remains on its direct path (separate webview).

* feat(library): BPM range filter UI in Advanced Search (PR-7 F6) (#836)

* feat(library-sync): parallel initial ingest (S2 + N1/S1 prefetch)

Wire C11 ParallelismBudget (max 4 when idle) into InitialSyncRunner:
parallel getAlbum for S2, up to 4 in-flight pages for N1/S1, and persist
S2 cursor once per album-list page instead of per album.

* fix(library-sync): defer scheduler during initial sync and improve ingest diagnostics

Background delta/tombstone ticks every 30s were competing with IS-3 bulk ingest
for the write mutex (20–60s lock waits on large libraries). Skip scheduler while
sync_phase is initial_sync/probing or bulk ingest is active.

Serialize ingest batch metrics as camelCase for DevTools, add bulk-ingest FTS/index
suspension, combined cursor persist, write-op tracing, live local search, and
library dev logging helpers.

* fix(library-search): scoped FTS, cancel stale live search, skip 1-char queries

Use column-scoped FTS for artists/albums/songs, min two graphemes for local
FTS, capped match counts in Advanced Search, and title browse index (m004).

Live Search aborts superseded network requests, passes requestEpoch to drop
stale Rust FTS, and avoids search3 fallback for too-short queries.

* fix(library-search): prefix FTS, fast subquery joins, hide BPM in Advanced Search

Live and Advanced Search now use FTS5 prefix tokens ("metal"*) and limit
bm25 ranking inside rowid subqueries so large libraries stay in the ms range.
Advanced Search BPM filter is removed from the UI until enrichment ships.

* feat(library-search): race local index vs search3, show first result

Live Search and Advanced Search text queries run library and network
backends in parallel; the faster source wins. Adds searchRace helper
and search_race dev logging.

* feat(library-index): multi-server UI, serial sync queue, scoped local search

Add master library index toggle with per-server rows, offline retry, and a
frontend sync queue so initial ingest runs one server at a time. Scope Live
Search and Advanced Search to the sidebar music library filter via library_id
and raw_json fallbacks; coerce numeric libraryId on ingest. Promote idle sync
state to ready when a full sync stamp exists and block cross-server initial
sync starts in Rust.

* chore(library-store): compliance — clippy, i18n, CHANGELOG

Fix clippy/tsc blockers (request structs, IngestPageCtx, type aliases),
add library index strings to all 9 locales, and document the preview
feature in CHANGELOG [1.47.0] with Psychotoxical + cucadmuh attribution.

* docs(credits): library index preview contributions

Credit Psychotoxical for the local library store foundation and cucadmuh
for multi-server UI, scoped search, and i18n. Drop removed scan-trigger
wording from PR #780 entry.

* docs(release): link library index preview to PR #846

* docs(changelog): sort [1.47.0] entries by ascending PR number

* docs(changelog): mark library index as Added in [1.47.0]

* docs(changelog): restructure [1.47.0] into Added/Changed/Fixed

Match 1.46.0 layout: new features in Added (incl. library index),
enhancements in Changed, bug fixes in Fixed — PR ascending within each block.

* fix(library): address PR #846 review — delta guard + FTS order

Skip background scheduler delta when LibraryRuntime already has a
foreground sync job for the same server. Preserve bm25 rowid ordering
in live search track/artist/album fetches.

* fix(library): address remaining PR #846 review items

S2 resume persists current_album_id per album; same-server resync awaits
the previous runner. N1 delta watermark uses strict less-than; Navidrome
HTTP 500 detection is structured. Adds genre/year indexes, backoff jitter
salt, LiveSearch failure toast, and user-facing search badge copy.

* fix(library): close resync notify race and tighten FTS trigger test

Use notify_one() so an early runner completion cannot lose the drain
signal before same-server full resync awaits. FTS test now compares
normalized trigger bodies from migration vs suspend/restore roundtrip.

---------

Co-authored-by: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com>
2026-05-22 00:33:09 +02:00
Maxim Isaev e7431b94b8 feat(search): queue pasted share links from Live Search and mobile search
Implement share-link detection in search (track, queue, album, artist,
composer): enqueue tracks/queues without interrupting playback; preview
album/artist/composer without switching the active server; queue preview
modal with scrollable track list. Based on community PR #551.

Co-authored-by: Daniel Wagner <daniel.iuser@icloud.com>
2026-05-15 13:38:35 +03:00
Frank Stellmacher 7a7a9f5e6b refactor(utils): group utils/ files into topic folders (Phase L, part 1) (#689)
111 of 122 top-level src/utils/ files move into 16 topic folders (audio,
cache, cover, share, server, playback, playlist, deviceSync, waveform,
mix, format, export, changelog, ui, perf, componentHelpers). True
singletons with no cluster stay at the utils/ root.

Pure file-move: a path-aware codemod rewrote 539 relative-import
specifiers across 275 files; no logic touched. The hot-path coverage
gate list (.github/frontend-hot-path-files.txt) is updated to the new
paths for the 11 gated utils files — a mechanical consequence of the
move, not a CI change. tsc is green.
2026-05-14 14:27:44 +02:00
Frank Stellmacher 9606a99efb refactor(api): F.50 — extract 7 small domain modules (#615)
Seven domain-eng splits peel ~200 LOC of read endpoints out of
`api/subsonic.ts`:

- `subsonicStreamUrl.ts` — `buildStreamUrl`, `coverArtCacheKey`,
  `buildCoverArtUrl`, `buildDownloadUrl` (token-signed URL builders
  for the four /rest endpoints we hand to the browser).
- `subsonicStarRating.ts` — `getStarred`, `star`, `unstar`,
  `setRating`, `probeEntityRatingSupport`. `setRating` still triggers
  the lazy `navidromeBrowse` cache invalidation; the same-folder
  lazy import path is preserved.
- `subsonicSearch.ts` — `search`, `searchSongsPaged`.
- `subsonicScrobble.ts` — `scrobbleSong`, `reportNowPlaying`,
  `getNowPlaying`.
- `subsonicAlbumInfo.ts` — `getAlbumInfo2`.
- `subsonicLyrics.ts` — `getLyricsBySongId`.
- `subsonicGenres.ts` — `getGenres`, `getAlbumsByGenre`.

63 external call sites migrated to direct imports. Four `vi.mock`
targets in the store-level tests pointed at `../api/subsonic` and
were updated to the new module paths.

Pure code-move. subsonic.ts: 762 → 561 LOC (−201).
2026-05-13 00:46:13 +02:00
Frank Stellmacher 72030f17fd refactor(api): F.48 — extract subsonic types + client primitives (#613)
First Phase F slice. Splits the 1333-LOC `api/subsonic.ts` along its
two most obvious axes:

- `subsonicTypes.ts` — all ~24 exported interfaces + type aliases
  (album/song/artist/playlist/directory/genre/now-playing/radio,
  random-songs filters, three statistics shapes, search + starred
  results, AlbumInfo, structured-lyrics types, etc.) plus the
  `RADIO_PAGE_SIZE` constant.
- `subsonicClient.ts` — token-auth + `getClient` + `api<T>()` +
  `libraryFilterParams` + `secureRandomSalt` / `getAuthParams` /
  `SUBSONIC_CLIENT`. The credential-bearing API helpers
  (`pingWithCredentials`, `apiWithCredentials`, `restBaseFromUrl`,
  `probeInstantMixWithCredentials`) stay in `subsonic.ts` for now —
  they could move into the client module in a follow-up.

66 external call sites migrated to direct imports from the new
modules (no re-export shims in `subsonic.ts`). Pure code-move;
contract test stays green.

subsonic.ts: 1333 → 1078 LOC (−255).
2026-05-13 00:05:58 +02:00
Frank Stellmacher 9fac6eb490 refactor(player): E.42 — migrate playerStore re-exports to direct imports (#607)
Migrates ~74 call sites away from the playerStore re-export shims that
were kept during M0–E.41 to avoid touching 30+ imports per PR. Now
that the bigger refactor work is done, each helper goes back to its
real home:

- `initAudioListeners`, `installQueueUndoHotkey`, `flushPlayQueuePosition`
  → from their own store modules
- `getPlaybackProgressSnapshot`, `subscribePlaybackProgress`,
  `PlaybackProgressSnapshot` → from `playbackProgress`
- `resolveReplayGainDb`, `shuffleArray`, `songToTrack`
  → from `utils/*`
- `_resetQueueUndoStacksForTest`, `consumePendingQueueListScrollTop`,
  `registerQueueListScrollTopReader` → from `queueUndo`
- `PlayerState`, `Track` types → from `playerStoreTypes`

Drops the corresponding 13 re-export stubs from `playerStore.ts` and
the now-unused imports. Also drops dead section banners + per-wrapper
comments above one-line action delegates. Trims one stale "(separate
PR)" note in `transportLightActions.ts` since that follow-up landed
in E.39.

`playerStore.ts`: 180 → 112 LOC (−68). Down from Phase E's starting
3732 LOC.

`bootstrap.test.ts` mock target updated from `../store/playerStore`
to `../store/queueUndoHotkey` to keep the spy reachable after the
import change.
2026-05-12 22:46:13 +02:00
cucadmuh c3d37546cf Feat/search improvements (#470)
* feat(covers): race sibling downscale vs fetch, search thumb priorities

Run getCoverArt and client downscale in parallel when another size of the
same cover is cached; first successful result wins and aborts the other path.
Await both branches so inflight bookkeeping does not detach early.

Extend the cover cache size roster so provisional siblings resolve for sizes
used in the UI (e.g. 400/600/800, 48/96).

CachedImage: fetchQueueBias for live/mobile search (artist thumbnails ahead of
albums in fetch-slot ordering); configurable observeRootMargin with a wider
default to prepare priority slightly before elements enter view.

Mobile search adds round artist-thumb styling; add shared cover blob downscale
helper.

* perf(image-cache): batch sibling IDB reads and guard cover size registry

Use one read transaction when probing IndexedDB for sibling cover keys.
Extract COVER_ART_REGISTERED_SIZES and add Vitest coverage so every literal
coverArtCacheKey(_, size) in src stays aligned with sibling invalidation.
Honor AbortSignal during JPEG encode in downscaleCoverBlob.
2026-05-06 01:26:30 +03:00
cucadmuh 9d30285ff1 Perf/UI cover cache mainstage (#468)
* Enhance CachedImage and ArtistDetail components with improved image caching and priority handling

- Refactor CachedImage to utilize a priority system for image loading based on viewport visibility, improving performance during scrolling.
- Update useCachedUrl to accept an optional getPriority function for better cache management.
- Optimize ArtistDetail and Artists components by using useMemo for cover art URLs, reducing redundant calculations and improving rendering efficiency.
- Adjust image loading logic in CachedImage to ensure smoother transitions and avoid unnecessary fetch requests.

* perf(ui): unblock IDB cover art, stabilize mainstage rails and virtual lists

Let IndexedDB reads bypass the network concurrency slot so cached thumbnails
paint without queueing behind remote fetches; debounce disk eviction during
heavy scrolling.

Fix mainstage horizontal rails: dedupe album/song ids for React keys, widen
artwork budget overscan, avoid resetting the budget on list append, and raise
Home initial artwork budgets. CachedImage treats already-decoded images as
loaded; rail cards load cover images eagerly.

Refresh dynamic color extraction and extend virtual scrolling / scroll roots on
Albums, Artists, Playlists, and related surfaces.


Remove local agent-only commit instructions from the repository tree.

* perf(virtual): viewport-based overscan for main scroll lists

Drive TanStack Virtual overscan from measured scroll height so each list
renders about one screen of extra rows above and below the viewport for
snappier scrolling on Albums, Artists (list mode), and Tracks virtual song list.

Introduce useResizeClientHeight helpers (ID + ref) for ResizeObserver-based
clientHeight tracking.

* docs(changelog): note PR #468 UI cover cache, rails, and virtual lists

Add a coarse summary under 1.46.0 Changed for cover-art pipeline,
mainstage rails, viewport-based overscan, and library/chrome polish.
2026-05-06 00:15:58 +03:00
cucadmuh 9ad0f8af6d feat(ui): UI refinements — sidebar indicators, adaptive header, and interaction polish (#397)
* feat(ui): unify queue toggle handle behavior

Show the queue toggle in the header when the queue is collapsed and use a seam-aligned drag handle when it is open. Hide the seam handle while the main content is actively scrolling to reduce accidental interactions.

* feat(ui): add adaptive header search collapse behavior

Collapse header search to a magnifier when top controls get crowded and expand it as an overlay only while active. Use measured header space with hysteresis to avoid flicker and keep neighboring controls stable.

* chore(ui): remove leftover search prototype artifacts

Drop an unused icon import from live search and remove an unused header container-type rule left from an earlier layout experiment.

* feat(ui): persist sidebar and queue visibility state

Save left sidebar collapse and right queue open/closed visibility in local storage helpers so both panel modes are restored after app restart.

* feat(ui): unify player overflow menu behavior

Use a single overflow menu for click and wheel interactions, with a volume-only mode that keeps the same layout and volume controls as the full menu.

* feat(ui): add wheel seek controls to waveform

Apply 10-second wheel seek steps with trailing 1-second debounce and keep the waveform preview stable so the playhead moves smoothly during rapid scroll input.

* fix(now-playing): stabilize narrow dashboard layout

Switch now-playing responsiveness to container-based breakpoints and prevent stacked widgets from overlapping when width is constrained.

* fix(search): reduce collapse jitter and avoid header overlap

Add a short collapse-state cooldown to prevent threshold flicker and hide conflicting header controls while collapsed search expands as an overlay.

* fix(i18n): localize player overflow controls across locales

Replace hardcoded player overflow labels with translation keys and add the missing keys for all shipped locale files.

* fix(search): keep advanced control clickable in collapsed mode

Prevent focus loss on the advanced search button in collapsed overlay mode so its click handler consistently runs.

* fix(i18n): restore queue translation in offline library

Use the existing queue.appendToQueue key for the offline enqueue button tooltip and label instead of a missing key and hardcoded English text.

* fix(ui): apply overlay scrollbar to right-panel text tabs

Switch now-playing content, lyrics, and info panes to OverlayScrollArea and harden tour-item layout so long concert metadata stays within panel bounds.

* fix(ui): add unread indicator for new releases and guard sidebar drag clicks

Track unread new-release IDs per server/library scope and clear the badge when opening the New Releases page. Also prevent click-through navigation after sidebar drag release and keep related i18n/responsive sidebar-adjacent refinements in this snapshot.

* fix(ui): stabilize live dropdown layering and unread reset flow

Render the topbar Live dropdown via a portal so it consistently overlays sidebar layers. Rework new-releases unread tracking to handle library scope baselines, ignore stale refresh races, and mark items as seen after a 5-second stay on the New Releases page.

* feat(ui): add localized New badges for recently added albums

Show a theme-consistent New badge on album cards and album detail for albums created within the last 48 hours. Localize the badge label across all supported locales and centralize recency logic in a shared utility to avoid duplication.

* fix(album): prevent tracklist jump when entering multiselect

Move bulk selection actions from the tracklist body into the album toolbar next to the track filter. Keep selection controls stable in the header area so enabling multiselect no longer shifts the tracklist content downward.

* fix(tray): add playback-state badge and finalize queue handle tooltip

Show play/pause/stop icons in the Linux tray now-playing entry and persist state safely in Tauri managed state.
Also switch the queue-resize handle tooltip to the dedicated localized key across all locales.

* fix(header): prioritize search collapse before Live/Orbit labels

Make topbar compression deterministic by collapsing search first and compacting Live/Orbit labels only in sustained low-space mode.
Add sticky hysteresis-based header compact state to prevent oscillation while resizing.

* fix(ui): stabilize header compaction and show tray state icons

Prevent topbar flicker in the narrow-width range by tightening compact-mode thresholds, gating on real overflow, and removing width transitions from live search.
Also include playback state icons in tray tooltip text across platforms while preserving tooltip length limits.

* fix(tray): keep tooltip iconization Windows-only

Revert Linux tray tooltip/title fallback attempts and keep state icons only in Windows tray tooltips, while Linux continues to show playback state in the now-playing menu entry.

* fix(ui): restore queue resize response after overlay scroll interactions

Hide the queue handle while scrolling on both the main route viewport and the now-playing viewport, and clear stale thumb-drag state before starting queue resize.
Also ignore inactive/faded overlay thumbs in resizer suppression so horizontal pointer transitions no longer leave the queue seam unresponsive.

* docs(changelog): summarize ui-refinements branch features

Document the branch-level feature additions in 1.45.0 as separate changelog sections and group remaining branch-local fixes under a single polish entry.

* docs(changelog): add PR #397 references for ui-refinements

Attach PR metadata to the new 1.45.0 ui-refinement sections and the polish entry so release notes map directly to the merged branch discussion.
2026-05-01 15:42:40 +03:00
Frank Stellmacher 3c0a42e298 fix(search): right-click context menu on artist + album rows (#302)
Mirrors what PR #298 did for songs in the live-search dropdown:
both artist and album rows now respond to right-click with the
matching context menu (type 'artist' / 'album'), and pick up the
.context-active highlight while their menu is open. Click-to-navigate
behaviour is unchanged.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:50:54 +02:00
Frank Stellmacher 93b724fc65 fix(search,ctx): enqueue on live-search click + reposition CM with right-click (#298)
Two coupled UX fixes around the header live search and the global context
menu, born from the same testing pass.

LiveSearch / MobileSearchOverlay
- Single click on a song no longer calls playTrack(track) without a
  queue argument. The store fell back to the existing queue, didn't
  find the new track, and ended up playing something the user couldn't
  navigate or see in the queue list — the player header showed metadata
  but the queue itself didn't contain it.
- New behaviour: enqueue([track]) + "Added to queue" toast. Whatever was
  playing keeps playing; the new track lands at the bottom and is
  visible/navigable. Mobile gets the same behaviour (tap-only).
- Desktop also gets a right-click context menu on song rows for users
  who want immediate playback or different routing (Play Now, Play Next,
  Add to Playlist, etc.). The dropdown stays open while the CM is up,
  and the row picks up the .context-active highlight so the user can
  see which song the menu refers to.

ContextMenu
- Removed the transparent fullscreen backdrop (z-index 998) that
  previously caught outside clicks. It also blocked right-clicks from
  reaching elements *underneath* it — so right-clicking a different
  song row to reposition the menu hit the backdrop instead, closed the
  menu, and never opened a new one for the row the user actually
  pointed at.
- Replaced with a document-level mousedown listener (gated on
  `contextMenu.isOpen`) that closes the menu when the click lands
  outside `menuRef`. Standard outside-click pattern, doesn't occlude
  the underlying UI, and right-clicking another row now naturally
  pivots the CM to that row.

i18n: new search.addedToQueueToast key in 8 locales.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 02:32:18 +02:00
Frank Stellmacher 9da1d643f7 perf(search): use CachedImage for result thumbs to stop re-render download storms (#245)
The sidebar LiveSearch and mobile search overlay rendered cover thumbs via
raw <img src={buildCoverArtUrl(...)}> instead of going through the
IndexedDB-backed CachedImage path used everywhere else in the app.

buildCoverArtUrl() mints a fresh URL on every call (random salt + a new
MD5 token per Subsonic spec), so the browser cache was permanently
defeated for these thumbs — every re-render of the dropdown produced
new URLs and the browser dispatched HTTP requests for every "new"
image.

While typing in the search field this multiplied: each keystroke
triggered a re-render with fresh URLs for every visible cover, and
after the 300 ms debounce a fresh search() result triggered another
wave. Five visible album thumbs × five keystrokes ≈ 50 redundant cover
fetches in 1.5 s, manifesting as periodic typing delays.

Switch all three call sites to CachedImage with the stable
coverArtCacheKey() — same pattern CLAUDE.md explicitly recommends:

    buildCoverArtUrl() generates a new ephemeral URL every call —
    browser cache is useless. Use coverArtCacheKey(id, size) +
    CachedImage for <img> tags.

Confirmed locally: typing in the search field is noticeably smoother.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:37:47 +02:00
Psychotoxical 1cc674e4ee fix(ui): icon updates, remove mic button, theme fixes
- LiveSearch: replace SlidersVertical with TextSearch for advanced search
- AlbumHeader: add Highlighter icon for artist bio button
- PlayerBar: remove unused lyrics/mic button
- components.css: album-detail-badge always opaque (accent bg, white text)
- theme: Middle Earth — remove sidebar stripes, fix queue/artist/bio contrast
- theme: Toy Tale — fix muted text, queue tabs, sidebar labels, divider
- theme: Tetrastack — brighten purple/blue palette, raise text-muted contrast
- theme: Horde & Alliance — remove repeating sidebar line pattern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:05:20 +02:00
Psychotoxical 15dc970f53 fix(audio): restore msg binding in DecodeError arm, use SlidersVertical for EQ icon
- Revert _msg → msg in DecodeError match arm; the variable is used in the
  debug_assertions eprintln! block so the underscore prefix caused a compile error
- Replace SlidersHorizontal with SlidersVertical (lucide-react) in PlayerBar,
  LiveSearch, and AdvancedSearch so the EQ/filter icon shows vertical bars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 00:28:12 +02:00
Maxim Isaev f08619fb3d feat(subsonic): per-server music folder filter and sidebar picker
Apply musicFolderId across Subsonic requests, bump a filter version so library views reload, and add a fixed-position sidebar dropdown (with capped height when there are many folders).
2026-04-07 13:59:59 +03:00
Psychotoxical 43c656dfc3 feat: v1.23.0 — Advanced Search, Genre Mix overhaul, Playlist append, Contributors table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:56:59 +02:00
trbn 95cdbc7fc7 fix: replay gain not applying to tracks
Replay gain was not working because track objects were created manually without
including replay gain metadata from the Subsonic API response.

Changes:
- Add songToTrack() helper function to properly map SubsonicSong to Track with
  replayGainTrackDb, replayGainAlbumDb, and replayGainPeak fields
- Add audio_update_replay_gain Tauri command for dynamic volume recalculation
  when replay gain settings change mid-playback
- Add updateReplayGainForCurrentTrack() to recalculate volume when toggling
  replay gain setting
- Fetch fresh track data on cold resume (app relaunch) to ensure replay gain
  values are current from server
- Update all files that create track objects to use songToTrack()

Fixes issue where toggling replay gain ON/OFF or changing between track/album
mode had no effect on currently playing or newly played tracks.
2026-03-30 19:11:33 +02:00
Psychotoxical 3effad0830 feat: v1.15.0 — Genre Strip, Lyrics Accent, Sidebar Button fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:47:32 +01:00
Psychotoxical 744775d3a1 feat: Rust audio engine, genre mix, queue shuffle, and UX improvements (v1.2.0)
### Audio Engine
- Replace Howler.js with native Rust/rodio backend (src-tauri/src/audio.rs)
- audio_play/pause/resume/stop/seek/set_volume Tauri commands
- Generation counter cancels stale in-flight downloads on skip
- Wall-clock position tracking; audio:ended after 2 ticks near end

### Playback Persistence
- Persist currentTrack, queue, queueIndex, currentTime to localStorage
- Cold-start resume: play + seek to saved position on app restart
- Position priority: server queue > localStorage

### Random Mix
- Genre blacklist: excludeAudiobooks toggle + custom chip-based blacklist
- Clickable genre chips in tracklist to blacklist on the fly
- Super Genre Mix: 9 genres, progressive rendering, Load 10 more
- Promise.allSettled + 45s timeout for large Metal/Rock libraries
- Two-column filter panel at top of page

### Queue & UI
- Shuffle button in queue header (Fisher-Yates, keeps current track at 0)
- LiveSearch arrow key / Enter / Escape keyboard navigation
- Multi-line tooltip support (data-tooltip-wrap attribute)
- Update link uses Tauri shell open() to launch system browser
- Sidebar min-width 180px → 200px (fixes Psysonic title clipping)
- Tooltip z-index fix: main-content z-index:1 over queue panel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 12:06:03 +01:00
Psychotoxical 1e599d9636 feat: Search results page, gapless removal, UI polish (v1.0.11)
- Add full search results page (/search?q=) with artist, album, song sections
- Fix search results column alignment (fixed-width Format column eliminates per-grid auto-sizing variance)
- Remove experimental gapless playback (caused song skipping and beginning cutoffs)
- Add known limitations to README and CHANGELOG (seeking, DnD reliability)
- Update GitHub Actions to Node.js 24 (checkout@v5, setup-node@v5)
- Add align-items: center to tracklist-header CSS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 12:33:58 +01:00
Psychotoxical c9b4bc091e feat: Ambient Stage, Queue DnD overhaul, and GStreamer fixes (v1.0.8) 2026-03-13 21:57:07 +01:00
Psychotoxical 6456b3e561 chore: prepare release v1.0.1 and ignore CLAUDE.md 2026-03-11 21:56:26 +01:00
Psychotoxical 65459e53f1 Initial release with i18n and theming 2026-03-09 19:02:57 +01:00