From 418b25914aee48b8398ad9c5f9c6217b664baa6a Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Tue, 26 May 2026 19:35:08 +0300 Subject: [PATCH] feat(cover): unify cover pipeline and stabilize mainstage/now-playing (#870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .github/workflows/frontend-tests.yml | 12 +- .gitignore | 1 + CHANGELOG.md | 10 + nix/psysonic.nix | 2 +- src-tauri/Cargo.lock | 63 ++ src-tauri/Cargo.toml | 4 +- .../psysonic-library/src/advanced_search.rs | 3 + .../psysonic-library/src/cover_backfill.rs | 352 +++++++ src-tauri/crates/psysonic-library/src/lib.rs | 1 + src-tauri/src/cover_cache/backfill_worker.rs | 405 ++++++++ src-tauri/src/cover_cache/disk.rs | 42 + src-tauri/src/cover_cache/encode.rs | 75 ++ src-tauri/src/cover_cache/fetch.rs | 98 ++ src-tauri/src/cover_cache/mod.rs | 885 ++++++++++++++++++ src-tauri/src/lib.rs | 25 + src-tauri/tauri.conf.json | 6 +- src/api/coverCache.test.ts | 74 ++ src/api/coverCache.ts | 221 +++++ src/api/subsonic.contract.test.ts | 13 +- src/api/subsonicStreamUrl.ts | 27 +- src/app/AppShell.tsx | 4 + src/app/MainApp.tsx | 12 + src/app/TauriEventBridge.tsx | 2 + src/components/AlbumCard.tsx | 48 +- src/components/AlbumHeader.tsx | 46 +- src/components/ArtistCardLocal.tsx | 22 +- src/components/BecauseYouLikeRail.tsx | 519 ++++++++-- src/components/ConnectionIndicator.tsx | 2 +- src/components/FullscreenPlayer.test.tsx | 15 +- src/components/Hero.tsx | 48 +- src/components/HostApprovalQueue.tsx | 12 +- src/components/LiveSearch.tsx | 44 +- src/components/MobileSearchOverlay.tsx | 47 +- src/components/NowPlayingDropdown.tsx | 10 +- src/components/OrbitGuestQueue.tsx | 27 +- src/components/PlayerBar.test.tsx | 3 +- src/components/PlayerBar.tsx | 27 +- src/components/QueuePanel.test.tsx | 3 +- src/components/QueuePanel.tsx | 7 +- src/components/SongCard.tsx | 41 +- src/components/VirtualCardGrid.tsx | 28 +- .../artistDetail/ArtistDetailHero.tsx | 41 +- .../ArtistSuggestionTrackCover.tsx | 15 +- src/components/artists/ArtistAvatars.tsx | 49 +- src/components/favorites/RadioFavorites.tsx | 12 +- .../favorites/TopFavoriteArtists.tsx | 15 +- src/components/internetRadio/RadioCard.tsx | 12 +- .../internetRadio/RadioEditModal.tsx | 11 +- src/components/nowPlaying/DiscographyCard.tsx | 18 +- src/components/playerBar/PlayerTrackInfo.tsx | 35 +- src/components/playlist/PlaylistEditModal.tsx | 27 +- src/components/playlist/PlaylistHero.tsx | 26 +- .../playlist/PlaylistSongSearchPanel.tsx | 18 +- .../playlists/PlaylistCoverImages.tsx | 28 +- .../queuePanel/QueueCurrentTrack.tsx | 2 +- .../search/ShareQueuePreviewModal.tsx | 42 +- src/components/search/ShareSearchResults.tsx | 81 +- .../settings/AnalyticsStrategySection.tsx | 51 +- .../settings/CoverCacheStrategySection.tsx | 318 +++++++ src/components/settings/LibraryTab.tsx | 1 - src/components/settings/StorageTab.tsx | 36 +- src/components/settings/settingsTabs.ts | 1 + src/config/settingsCredits.ts | 1 + src/cover/CoverArtImage.tsx | 112 +++ src/cover/artistHero.tsx | 91 ++ src/cover/coverTraffic.ts | 87 ++ src/cover/diskHandoff.ts | 37 + src/cover/diskSrcCache.ts | 98 ++ src/cover/diskSrcLookup.test.ts | 40 + src/cover/diskSrcLookup.ts | 63 ++ src/cover/ensureQueue.test.ts | 75 ++ src/cover/ensureQueue.ts | 215 +++++ src/cover/fetchUrl.ts | 38 + src/cover/ids.ts | 17 + src/cover/imgSrc.ts | 4 + src/cover/index.ts | 28 + src/cover/integrations/discord.ts | 23 + src/cover/integrations/export.ts | 13 + src/cover/integrations/mpris.ts | 22 + src/cover/invalidate.ts | 3 + src/cover/layoutSizes.ts | 28 + src/cover/lightbox.tsx | 49 + src/cover/manifest.ts | 4 + src/cover/peekQueue.ts | 114 +++ src/cover/prefetchRegistry.ts | 77 ++ src/cover/prefetchStrategy.ts | 19 + src/cover/reachability.ts | 13 + src/cover/ref.ts | 28 + src/cover/resolveDisk.ts | 57 ++ src/cover/resolveJs.test.ts | 65 ++ src/cover/resolveJs.ts | 141 +++ src/cover/revalidate.ts | 7 + src/cover/storageKeys.test.ts | 63 ++ src/cover/storageKeys.ts | 45 + src/cover/tiers.test.ts | 22 + src/cover/tiers.ts | 17 + src/cover/types.ts | 39 + src/cover/useCoverArt.ts | 151 +++ src/cover/useCoverArtPrefetch.ts | 60 ++ src/cover/useCoverRevalidateScheduler.ts | 27 + src/cover/usePlaybackCoverArt.test.ts | 91 ++ src/cover/usePlaybackCoverArt.ts | 30 + src/cover/warmDiskPeek.ts | 204 ++++ src/hooks/tauriBridge/useCoverArtBridge.ts | 59 ++ src/hooks/useCoverCacheEvictTick.ts | 17 + src/hooks/useCoverNavigationPriority.ts | 22 + src/hooks/useLibraryCoverBackfill.ts | 78 ++ src/hooks/useNowPlayingFetchers.ts | 167 +++- src/hooks/useNowPlayingPrewarm.test.ts | 104 ++ src/hooks/useNowPlayingPrewarm.ts | 96 ++ src/hooks/usePlaybackCoverArt.ts | 19 +- src/hooks/usePlaylistCovers.ts | 67 +- src/hooks/useWarmGridCovers.ts | 58 ++ src/locales/en/settings.ts | 22 +- src/locales/ru/settings.ts | 36 +- src/pages/AlbumDetail.tsx | 18 +- src/pages/Albums.tsx | 77 +- src/pages/ArtistDetail.tsx | 35 +- src/pages/ComposerDetail.tsx | 54 +- src/pages/GenreDetail.tsx | 2 + src/pages/Home.tsx | 193 +++- src/pages/LabelAlbums.tsx | 2 + src/pages/LosslessAlbums.tsx | 2 + src/pages/MostPlayed.tsx | 18 +- src/pages/NewReleases.tsx | 2 + src/pages/NowPlaying.tsx | 15 +- src/pages/OfflineLibrary.tsx | 22 +- src/pages/PlaylistDetail.tsx | 11 +- src/pages/RandomAlbums.tsx | 119 ++- src/pages/Tracks.tsx | 21 +- .../audioListenerSetup/discordPresence.ts | 17 +- src/store/audioListenerSetup/mprisSync.ts | 37 +- src/store/authStore.ts | 3 + src/store/authStoreTypes.ts | 4 +- src/store/becauseYouLikeCache.ts | 32 + src/store/coverStrategyStore.ts | 113 +++ src/store/homeFeedCache.ts | 37 + src/styles/components/hero.css | 10 +- .../components/orbit-session-top-strip.css | 87 ++ src/test/README.md | 7 +- src/test/mocks/tauri.ts | 8 + src/utils/cover/coverBlobDownscale.ts | 2 +- src/utils/export/exportAlbumCard.ts | 8 +- src/utils/export/exportNewAlbums.ts | 14 +- src/utils/imageCache/coverSiblings.ts | 7 +- src/utils/library/browseTextSearch.ts | 52 + src/utils/library/coverBackfillWake.ts | 17 + src/utils/library/coverStrategy.ts | 21 + .../library/libraryCoverBackfillPolicy.ts | 25 + src/utils/library/libraryReady.ts | 7 + .../migrations/coverIdbUpgradeMigration.ts | 51 + .../offline/offlineLibraryHelpers.test.ts | 17 +- src/utils/offline/offlineLibraryHelpers.ts | 22 +- src/utils/playback/playbackServer.test.ts | 2 +- src/utils/playback/playbackServer.ts | 31 +- src/utils/server/rewriteFrontendStoreKeys.ts | 2 + src/utils/server/switchActiveServer.ts | 7 + .../ui/resolveIntersectionScrollRoot.test.ts | 12 + src/utils/ui/resolveIntersectionScrollRoot.ts | 11 +- vite.config.ts | 23 +- 160 files changed, 7551 insertions(+), 914 deletions(-) create mode 100644 src-tauri/crates/psysonic-library/src/cover_backfill.rs create mode 100644 src-tauri/src/cover_cache/backfill_worker.rs create mode 100644 src-tauri/src/cover_cache/disk.rs create mode 100644 src-tauri/src/cover_cache/encode.rs create mode 100644 src-tauri/src/cover_cache/fetch.rs create mode 100644 src-tauri/src/cover_cache/mod.rs create mode 100644 src/api/coverCache.test.ts create mode 100644 src/api/coverCache.ts create mode 100644 src/components/settings/CoverCacheStrategySection.tsx create mode 100644 src/cover/CoverArtImage.tsx create mode 100644 src/cover/artistHero.tsx create mode 100644 src/cover/coverTraffic.ts create mode 100644 src/cover/diskHandoff.ts create mode 100644 src/cover/diskSrcCache.ts create mode 100644 src/cover/diskSrcLookup.test.ts create mode 100644 src/cover/diskSrcLookup.ts create mode 100644 src/cover/ensureQueue.test.ts create mode 100644 src/cover/ensureQueue.ts create mode 100644 src/cover/fetchUrl.ts create mode 100644 src/cover/ids.ts create mode 100644 src/cover/imgSrc.ts create mode 100644 src/cover/index.ts create mode 100644 src/cover/integrations/discord.ts create mode 100644 src/cover/integrations/export.ts create mode 100644 src/cover/integrations/mpris.ts create mode 100644 src/cover/invalidate.ts create mode 100644 src/cover/layoutSizes.ts create mode 100644 src/cover/lightbox.tsx create mode 100644 src/cover/manifest.ts create mode 100644 src/cover/peekQueue.ts create mode 100644 src/cover/prefetchRegistry.ts create mode 100644 src/cover/prefetchStrategy.ts create mode 100644 src/cover/reachability.ts create mode 100644 src/cover/ref.ts create mode 100644 src/cover/resolveDisk.ts create mode 100644 src/cover/resolveJs.test.ts create mode 100644 src/cover/resolveJs.ts create mode 100644 src/cover/revalidate.ts create mode 100644 src/cover/storageKeys.test.ts create mode 100644 src/cover/storageKeys.ts create mode 100644 src/cover/tiers.test.ts create mode 100644 src/cover/tiers.ts create mode 100644 src/cover/types.ts create mode 100644 src/cover/useCoverArt.ts create mode 100644 src/cover/useCoverArtPrefetch.ts create mode 100644 src/cover/useCoverRevalidateScheduler.ts create mode 100644 src/cover/usePlaybackCoverArt.test.ts create mode 100644 src/cover/usePlaybackCoverArt.ts create mode 100644 src/cover/warmDiskPeek.ts create mode 100644 src/hooks/tauriBridge/useCoverArtBridge.ts create mode 100644 src/hooks/useCoverCacheEvictTick.ts create mode 100644 src/hooks/useCoverNavigationPriority.ts create mode 100644 src/hooks/useLibraryCoverBackfill.ts create mode 100644 src/hooks/useNowPlayingPrewarm.test.ts create mode 100644 src/hooks/useNowPlayingPrewarm.ts create mode 100644 src/hooks/useWarmGridCovers.ts create mode 100644 src/store/becauseYouLikeCache.ts create mode 100644 src/store/coverStrategyStore.ts create mode 100644 src/store/homeFeedCache.ts create mode 100644 src/utils/library/coverBackfillWake.ts create mode 100644 src/utils/library/coverStrategy.ts create mode 100644 src/utils/library/libraryCoverBackfillPolicy.ts create mode 100644 src/utils/migrations/coverIdbUpgradeMigration.ts diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index a77c52cb..52ae2acf 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -38,9 +38,9 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: - node-version: '20' + node-version: 'lts/*' cache: 'npm' - run: npm ci - name: vitest @@ -51,9 +51,9 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: - node-version: '20' + node-version: 'lts/*' cache: 'npm' - run: npm ci - name: tsc @@ -69,9 +69,9 @@ jobs: continue-on-error: true steps: - uses: actions/checkout@v5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: - node-version: '20' + node-version: 'lts/*' cache: 'npm' - name: install jq run: sudo apt-get update && sudo apt-get install -y jq diff --git a/.gitignore b/.gitignore index ae78786b..25277893 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,4 @@ result-* dev.sh shell.nix prod.sh +tsconfig.tsbuildinfo diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b960826..3367f5ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Cover art — tier ladder, disk cache, and grid prefetch + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#869](https://github.com/Psychotoxical/psysonic/pull/869)** + +* Album and artist grids load sharper repeat visits from a dedicated WebP disk cache (separate from the general image cache budget in **Settings → Storage**). +* Dense lists prefetch smaller tiers first, then steady-state grid quality capped at 512px for scroll performance; detail and player chrome still resolve up to 800px on demand. +* One-time upgrade clears legacy multi-size IndexedDB cover keys; offline-first when the server is unreachable. + + + ## Changed ### Linux — session GDK, WebKitGTK mitigations, and Wayland text diff --git a/nix/psysonic.nix b/nix/psysonic.nix index 86efabfc..0b6f3065 100644 --- a/nix/psysonic.nix +++ b/nix/psysonic.nix @@ -138,7 +138,7 @@ stdenv.mkDerivation (finalAttrs: { buildPhase = '' runHook preBuild export HOME=$(mktemp -d) - (cd src-tauri && cargo tauri build --no-bundle -v) + (cd src-tauri && cargo tauri build --no-bundle) runHook postBuild ''; diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c800f5f8..eb648eb5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2216,6 +2216,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "httparse" version = "1.10.1" @@ -2456,9 +2462,22 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "image-webp", "moxcms", "num-traits", "png 0.18.1", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", ] [[package]] @@ -2805,6 +2824,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libwebp-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54cd30df7c7165ce74a456e4ca9732c603e8dc5e60784558c1c6dc047f876733" +dependencies = [ + "cc", + "glob", +] + [[package]] name = "linux-raw-sys" version = "0.3.8" @@ -4014,6 +4043,7 @@ dependencies = [ "ebur128", "futures-util", "id3", + "image", "libc", "lofty", "md5", @@ -4049,6 +4079,7 @@ dependencies = [ "url", "webkit2gtk", "webkit2gtk-nvidia-quirk", + "webp", "windows 0.62.2", "zbus 5.15.0", "zip 0.6.6", @@ -4173,6 +4204,12 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.39.3" @@ -5672,6 +5709,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "http-range", "image", "jni 0.21.1", "libc", @@ -6907,6 +6945,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webp" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c071456adef4aca59bf6a583c46b90ff5eb0b4f758fc347cea81290288f37ce1" +dependencies = [ + "image", + "libwebp-sys", +] + [[package]] name = "webpki-root-certs" version = "1.0.7" @@ -8083,6 +8131,21 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "3.15.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2ae4d7c5..bfc0eb09 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -42,7 +42,7 @@ psysonic-audio = { path = "crates/psysonic-audio" } psysonic-library = { path = "crates/psysonic-library" } psysonic-syncfs = { path = "crates/psysonic-syncfs" } psysonic-integration = { path = "crates/psysonic-integration" } -tauri = { version = "2", features = ["tray-icon", "image-png"] } +tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] } tauri-plugin-single-instance = "2" tauri-plugin-shell = "2" tauri-plugin-global-shortcut = "2" @@ -74,6 +74,8 @@ rusqlite = { version = "0.39", features = ["bundled"] } ebur128 = "0.1" dasp_sample = "0.11.0" zip = "0.6.6" +image = { version = "0.25", default-features = false, features = ["jpeg", "webp"] } +webp = "0.3" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/src-tauri/crates/psysonic-library/src/advanced_search.rs b/src-tauri/crates/psysonic-library/src/advanced_search.rs index 73218b61..af97fc47 100644 --- a/src-tauri/crates/psysonic-library/src/advanced_search.rs +++ b/src-tauri/crates/psysonic-library/src/advanced_search.rs @@ -1094,6 +1094,9 @@ fn sort_column(field: &str, entity: EntityKind) -> Option<&'static str> { ("year", EntityKind::Album) => Some("a.year"), ("artist", EntityKind::Album) => Some("a.artist COLLATE NOCASE"), ("name", EntityKind::Artist) => Some("ar.name COLLATE NOCASE"), + // SQLite built-in: ORDER BY RANDOM() LIMIT N — fast pseudo-random sample, + // no index scan needed beyond the row-id range. Direction is ignored. + ("random", _) => Some("RANDOM()"), _ => None, } } diff --git a/src-tauri/crates/psysonic-library/src/cover_backfill.rs b/src-tauri/crates/psysonic-library/src/cover_backfill.rs new file mode 100644 index 00000000..1caa3513 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/cover_backfill.rs @@ -0,0 +1,352 @@ +//! Library cursor scan for background cover disk warm-up. +//! +//! Cover IDs for backfill come from **track** + **album** rows using +//! `COALESCE(cover_art_id, album_id)` (album table id as fallback). +//! Artist IDs are excluded — `getCoverArt` with `artist_id` often 404s and stalled the queue. + +use std::path::Path; + +use crate::store::LibraryStore; + +const DEFAULT_BATCH: u32 = 32; +const MAX_BATCH: u32 = 48; +const SCAN_PAGE: i64 = 256; +const MAX_SCAN_PAGES: usize = 16; + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LibraryCoverBackfillBatchDto { + pub cover_ids: Vec, + pub next_cursor: Option, + pub exhausted: bool, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LibraryCoverProgressDto { + pub total_distinct: i64, + pub pending: i64, + pub done: i64, +} + +const COVER_ID_SUBQUERY: &str = " + SELECT DISTINCT COALESCE(NULLIF(TRIM(album_id), ''), NULLIF(TRIM(cover_art_id), '')) AS id + FROM track + WHERE server_id = ?1 AND deleted = 0 + AND ( + NULLIF(TRIM(album_id), '') IS NOT NULL + OR NULLIF(TRIM(cover_art_id), '') IS NOT NULL + ) + UNION + SELECT DISTINCT COALESCE(NULLIF(TRIM(id), ''), NULLIF(TRIM(cover_art_id), '')) AS id + FROM album + WHERE server_id = ?1 + AND ( + NULLIF(TRIM(id), '') IS NOT NULL + OR NULLIF(TRIM(cover_art_id), '') IS NOT NULL + )"; + +pub const COVER_FETCH_FAIL_MARKER: &str = ".fetch-failed"; + +/// Recent HTTP failure — skip in backfill cursor so slots go to fetchable album art. +pub fn cover_fetch_recently_failed(cover_dir: &Path) -> bool { + let marker = cover_dir.join(COVER_FETCH_FAIL_MARKER); + let Ok(meta) = std::fs::metadata(&marker) else { + return false; + }; + let Ok(modified) = meta.modified() else { + return true; + }; + modified + .elapsed() + .map(|e| e < std::time::Duration::from_secs(30 * 60)) + .unwrap_or(true) +} + +/// Remove `.fetch-failed` markers so the next library pass retries HTTP. +pub fn clear_cover_fetch_failures(cover_root: &Path, server_index_key: &str) -> u32 { + let server_dir = cover_root.join(server_index_key); + let Ok(entries) = std::fs::read_dir(&server_dir) else { + return 0; + }; + let mut cleared = 0u32; + for id_dir in entries.flatten() { + let marker = id_dir.path().join(COVER_FETCH_FAIL_MARKER); + if marker.is_file() && std::fs::remove_file(&marker).is_ok() { + cleared += 1; + } + } + cleared +} + +fn fetch_cover_id_page( + store: &LibraryStore, + library_server_id: &str, + after: &str, + limit: i64, +) -> Result, String> { + store.with_read_conn(|conn| { + let sql = format!( + "SELECT id FROM ({COVER_ID_SUBQUERY}) + WHERE id > ?2 + ORDER BY id ASC + LIMIT ?3" + ); + let mut stmt = conn.prepare(&sql)?; + let ids = stmt + .query_map(rusqlite::params![library_server_id, after, limit], |row| { + row.get::<_, String>(0) + })? + .collect::, _>>()?; + Ok(ids) + }) +} + +pub fn count_distinct_cover_ids(store: &LibraryStore, library_server_id: &str) -> Result { + store.with_read_conn(|conn| { + let sql = format!("SELECT COUNT(*) FROM ({COVER_ID_SUBQUERY})"); + conn.query_row(&sql, rusqlite::params![library_server_id], |row| row.get(0)) + }) +} + +/// Library warm-up target tier — HTTP fetch size and progress heuristic. +pub const LIBRARY_COVER_CANONICAL_TIER: u32 = 800; + +/// WebP ladder written by aggressive backfill (must match `cover_cache::DERIVE_TIERS`). +pub const LIBRARY_COVER_DERIVE_TIERS: [u32; 4] = [128, 256, 512, 800]; + +fn tier_file_ready(dir: &Path, tier: u32) -> bool { + let path = dir.join(format!("{tier}.webp")); + path.is_file() && path.metadata().map(|m| m.len() > 0).unwrap_or(false) +} + +fn cover_ladder_complete_on_disk(dir: &Path) -> bool { + LIBRARY_COVER_DERIVE_TIERS + .iter() + .all(|&tier| tier_file_ready(dir, tier)) +} + +pub fn cover_canonical_cached_on_disk( + cover_root: &Path, + server_index_key: &str, + cover_art_id: &str, +) -> bool { + let dir = cover_root.join(server_index_key).join(cover_art_id); + tier_file_ready(&dir, LIBRARY_COVER_CANONICAL_TIER) +} + +pub fn cover_ladder_cached_on_disk( + cover_root: &Path, + server_index_key: &str, + cover_art_id: &str, +) -> bool { + let dir = cover_root.join(server_index_key).join(cover_art_id); + cover_ladder_complete_on_disk(&dir) +} + +pub fn collect_cover_backfill_batch( + store: &LibraryStore, + library_server_id: &str, + cover_root: &Path, + server_index_key: &str, + cursor: Option<&str>, + limit: Option, +) -> Result { + let want = limit.unwrap_or(DEFAULT_BATCH).min(MAX_BATCH) as usize; + let mut after = cursor.map(str::to_string).unwrap_or_default(); + let mut pending = Vec::with_capacity(want); + let mut sql_exhausted = false; + + for _ in 0..MAX_SCAN_PAGES { + if pending.len() >= want { + break; + } + let page = fetch_cover_id_page(store, library_server_id, &after, SCAN_PAGE)?; + if page.is_empty() { + sql_exhausted = true; + break; + } + for id in &page { + after.clone_from(id); + let dir = cover_root.join(server_index_key).join(id); + if cover_canonical_cached_on_disk(cover_root, server_index_key, id) + || cover_fetch_recently_failed(&dir) + { + continue; + } + pending.push(id.clone()); + if pending.len() >= want { + break; + } + } + if (page.len() as i64) < SCAN_PAGE { + sql_exhausted = true; + break; + } + } + + Ok(LibraryCoverBackfillBatchDto { + cover_ids: pending, + next_cursor: if sql_exhausted { None } else { Some(after) }, + exhausted: sql_exhausted, + }) +} + +/// Distinct library cover IDs still missing canonical `800.webp` (not raw dir count on disk). +pub fn count_pending_canonical_covers( + store: &LibraryStore, + library_server_id: &str, + cover_root: &Path, + server_index_key: &str, +) -> Result { + let mut after = String::new(); + let mut pending = 0i64; + loop { + let page = fetch_cover_id_page(store, library_server_id, &after, SCAN_PAGE)?; + if page.is_empty() { + break; + } + for id in &page { + after.clone_from(id); + if !cover_canonical_cached_on_disk(cover_root, server_index_key, id) { + pending += 1; + } + } + if (page.len() as i64) < SCAN_PAGE { + break; + } + } + Ok(pending) +} + +/// UI progress — fast approximate counts (no full-library disk walk). +pub fn collect_cover_progress( + store: &LibraryStore, + library_server_id: &str, + _cover_root: &Path, + _server_index_key: &str, + cached_dirs_with_canonical: i64, +) -> Result { + let total = count_distinct_cover_ids(store, library_server_id)?; + let done = cached_dirs_with_canonical.min(total); + Ok(LibraryCoverProgressDto { + total_distinct: total, + pending: (total - done).max(0), + done, + }) +} + +/// Accurate pending count — expensive; run off the UI thread only. +#[allow(dead_code)] +pub fn collect_cover_progress_accurate( + store: &LibraryStore, + library_server_id: &str, + cover_root: &Path, + server_index_key: &str, +) -> Result { + let total = count_distinct_cover_ids(store, library_server_id)?; + let pending = count_pending_canonical_covers( + store, + library_server_id, + cover_root, + server_index_key, + )?; + let done = (total - pending).max(0); + Ok(LibraryCoverProgressDto { + total_distinct: total, + pending, + done, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::LibraryStore; + + fn seed_track(store: &LibraryStore, server_id: &str, track_id: &str, album_id: &str, cover: Option<&str>) { + store + .with_conn_mut("test_seed", |conn| { + conn.execute( + "INSERT INTO track ( + server_id, id, title, album, album_id, duration_sec, deleted, synced_at, raw_json, + cover_art_id + ) VALUES (?1, ?2, 't', 'al', ?3, 200, 0, 1, '{}', ?4)", + rusqlite::params![server_id, track_id, album_id, cover], + )?; + Ok(()) + }) + .unwrap(); + } + + #[test] + fn backfill_uses_track_album_id_when_cover_art_null() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "srv", "tr1", "al-99", None); + let batch = collect_cover_backfill_batch( + &store, + "srv", + Path::new("/tmp/empty-cover-root"), + "srv-host", + None, + Some(10), + ) + .unwrap(); + assert_eq!(batch.cover_ids, vec!["al-99".to_string()]); + } + + #[test] + fn backfill_skips_when_canonical_800_exists() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "srv", "tr1", "al-partial", None); + let root = std::env::temp_dir().join("psysonic-cover-backfill-test"); + let host = "srv-host"; + let id_dir = root.join(host).join("al-partial"); + std::fs::create_dir_all(&id_dir).unwrap(); + std::fs::write(id_dir.join("128.webp"), b"x").unwrap(); + + let batch = collect_cover_backfill_batch( + &store, + "srv", + &root, + host, + None, + Some(10), + ) + .unwrap(); + assert_eq!(batch.cover_ids, vec!["al-partial".to_string()]); + + std::fs::write(id_dir.join("800.webp"), b"canonical").unwrap(); + let batch2 = collect_cover_backfill_batch( + &store, + "srv", + &root, + host, + None, + Some(10), + ) + .unwrap(); + assert!(batch2.cover_ids.is_empty()); + + let _ = std::fs::remove_dir_all(root.join(host)); + } + + #[test] + fn count_distinct_includes_artist_ids() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "srv", "tr1", "al-1", Some("cv-1")); + store + .with_conn_mut("test_artist", |conn| { + conn.execute( + "INSERT INTO track ( + server_id, id, title, album, album_id, artist_id, duration_sec, deleted, synced_at, raw_json + ) VALUES ('srv', 'tr2', 't', 'al', 'al-2', 'ar-1', 200, 0, 1, '{}')", + [], + )?; + Ok(()) + }) + .unwrap(); + let n = count_distinct_cover_ids(&store, "srv").unwrap(); + assert_eq!(n, 2); // cv-1, al-1 — artist ids excluded from backfill catalog + } +} diff --git a/src-tauri/crates/psysonic-library/src/lib.rs b/src-tauri/crates/psysonic-library/src/lib.rs index e0f4fdd4..e2546554 100644 --- a/src-tauri/crates/psysonic-library/src/lib.rs +++ b/src-tauri/crates/psysonic-library/src/lib.rs @@ -11,6 +11,7 @@ pub(crate) mod bulk_ingest; pub mod advanced_search; mod advanced_search_mood; pub mod analysis_backfill; +pub mod cover_backfill; pub mod canonical; pub mod commands; pub mod cross_server; diff --git a/src-tauri/src/cover_cache/backfill_worker.rs b/src-tauri/src/cover_cache/backfill_worker.rs new file mode 100644 index 00000000..354c2693 --- /dev/null +++ b/src-tauri/src/cover_cache/backfill_worker.rs @@ -0,0 +1,405 @@ +//! Library cover backfill — one background pass per wake (native, not webview timers). + +use super::{state, CoverCacheEnsureArgs, CoverCacheState}; +use psysonic_library::cover_backfill::{ + clear_cover_fetch_failures, collect_cover_backfill_batch, collect_cover_progress, + LibraryCoverBackfillBatchDto, LIBRARY_COVER_CANONICAL_TIER, +}; +use psysonic_library::payload::LibrarySyncProgressPayload; +use psysonic_library::repos::sync_state::SyncStateRepository; +use psysonic_library::LibraryRuntime; +use serde::{Deserialize, Serialize}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; +use tauri::{AppHandle, Emitter, Listener, Manager}; +use tokio::sync::{Mutex, Semaphore}; + +use super::{count_cached_cover_ids, dir_usage_for_server}; + +/// Concurrent library downloads (encode runs on blocking pool; no webview tier events). +const LIBRARY_BACKFILL_PARALLEL: usize = 4; +const BATCH_SIZE: u32 = 24; +const PENDING_RESTART_THRESHOLD: i64 = 32; +const SYNC_WAIT_MS: u64 = 5000; +const PROGRESS_EVERY_BATCHES: u32 = 8; + +#[derive(Clone)] +pub struct CoverBackfillSession { + pub server_index_key: String, + pub library_server_id: String, + pub rest_base_url: String, + pub username: String, + pub password: String, +} + +pub struct CoverBackfillWorker { + pub enabled: AtomicBool, + /// When true, the active pass yields so visible-route cover IPC is not starved. + pub ui_priority_hold: AtomicBool, + session: Mutex>, + cursor: Mutex, + pass_running: AtomicBool, + backfill_http: Arc, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CoverBackfillPulseDto { + pub scheduled: u32, + pub exhausted: bool, + pub pending: i64, + pub done: i64, + pub total: i64, + pub status: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CoverBackfillRunDto { + pub started: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SyncIdlePayload { + server_id: String, + ok: bool, +} + +impl CoverBackfillWorker { + pub fn new() -> Self { + Self { + enabled: AtomicBool::new(false), + ui_priority_hold: AtomicBool::new(false), + session: Mutex::new(None), + cursor: Mutex::new(String::new()), + pass_running: AtomicBool::new(false), + backfill_http: Arc::new(Semaphore::new(LIBRARY_BACKFILL_PARALLEL)), + } + } + + pub fn set_ui_priority_hold(&self, hold: bool) { + self.ui_priority_hold.store(hold, Ordering::Relaxed); + } + + pub async fn set_session(&self, enabled: bool, session: Option) { + self.enabled.store(enabled, Ordering::Relaxed); + *self.session.lock().await = session; + if !enabled { + *self.cursor.lock().await = String::new(); + } + } + + pub async fn reset_cursor(&self) { + *self.cursor.lock().await = String::new(); + } +} + +fn sync_allows_cover_backfill(store: &psysonic_library::store::LibraryStore, server_id: &str) -> bool { + let repo = SyncStateRepository::new(store); + match repo.get_sync_phase(server_id, "") { + Ok(Some(phase)) => phase != "initial_sync" && phase != "probing", + _ => true, + } +} + +fn session_matches_server(session: &CoverBackfillSession, server_id: &str) -> bool { + server_id == session.server_index_key || server_id == session.library_server_id +} + +/// Backfill runs only while this session is still the configured focus (active server). +async fn session_still_focused(worker: &CoverBackfillWorker, expected: &CoverBackfillSession) -> bool { + if !worker.enabled.load(Ordering::Relaxed) { + return false; + } + worker + .session + .lock() + .await + .as_ref() + .is_some_and(|s| s.server_index_key == expected.server_index_key) +} + +async fn progress_snapshot( + store: &psysonic_library::store::LibraryStore, + root: &std::path::Path, + library_server_id: &str, + server_index_key: &str, +) -> Result<(i64, i64, i64), String> { + let cached = count_cached_cover_ids(root, server_index_key); + let p = collect_cover_progress(store, library_server_id, root, server_index_key, cached)?; + Ok((p.done, p.total_distinct, p.pending)) +} + +async fn emit_library_progress( + app: &AppHandle, + session: &CoverBackfillSession, + done: i64, + total: i64, + pending: i64, + root: &std::path::Path, +) { + let (bytes, entry_count) = dir_usage_for_server(root, &session.server_index_key); + let _ = app.emit( + "cover:library-progress", + serde_json::json!({ + "serverIndexKey": session.server_index_key, + "done": done, + "total": total, + "pending": pending, + "bytes": bytes, + "entryCount": entry_count, + }), + ); +} + +async fn ensure_one( + worker: &CoverBackfillWorker, + st: Arc>, + http_sem: Arc, + app: AppHandle, + session: CoverBackfillSession, + cover_art_id: String, +) { + if worker.ui_priority_hold.load(Ordering::Relaxed) { + return; + } + let args = CoverCacheEnsureArgs { + server_index_key: session.server_index_key, + cover_art_id, + tier: LIBRARY_COVER_CANONICAL_TIER, + rest_base_url: session.rest_base_url, + username: session.username, + password: session.password, + library_bulk: true, + }; + let _ = CoverCacheState::ensure_inner(&st, &app, &args, Some(http_sem)).await; +} + +async fn run_full_pass(app: AppHandle, worker: Arc) { + if !worker.enabled.load(Ordering::Relaxed) { + return; + } + let session = worker.session.lock().await.clone(); + let Some(session) = session else { + return; + }; + + let runtime = match app.try_state::() { + Some(r) => r, + None => return, + }; + + while !sync_allows_cover_backfill(&runtime.store, &session.library_server_id) { + if !worker.enabled.load(Ordering::Relaxed) { + return; + } + tokio::time::sleep(Duration::from_millis(SYNC_WAIT_MS)).await; + } + + let st = match state(&app) { + Ok(s) => s, + Err(_) => return, + }; + let root = { + let guard = st.lock().await; + guard.root.clone() + }; + let st_arc = st.clone(); + + worker.reset_cursor().await; + let http_sem = worker.backfill_http.clone(); + let mut batch_count = 0u32; + + loop { + if !session_still_focused(&worker, &session).await { + break; + } + + if worker.ui_priority_hold.load(Ordering::Relaxed) { + tokio::time::sleep(Duration::from_millis(200)).await; + continue; + } + + let cursor = worker.cursor.lock().await.clone(); + let cursor_opt = if cursor.is_empty() { + None + } else { + Some(cursor) + }; + let store = runtime.store.clone(); + let lib_id = session.library_server_id.clone(); + let index_key = session.server_index_key.clone(); + let root_for_batch = root.clone(); + + let batch: Option = + match tauri::async_runtime::spawn_blocking(move || { + collect_cover_backfill_batch( + &store, + &lib_id, + &root_for_batch, + &index_key, + cursor_opt.as_deref(), + Some(BATCH_SIZE), + ) + }) + .await + { + Ok(Ok(b)) => Some(b), + _ => None, + }; + + let Some(batch) = batch else { + break; + }; + + batch_count += 1; + if !session_still_focused(&worker, &session).await { + break; + } + let ids = batch.cover_ids.clone(); + let mut paused_for_ui_priority = false; + let mut set = tokio::task::JoinSet::new(); + for id in ids { + if worker.ui_priority_hold.load(Ordering::Relaxed) { + paused_for_ui_priority = true; + break; + } + let st = st_arc.clone(); + let http_sem = http_sem.clone(); + let app = app.clone(); + let session = session.clone(); + let worker_arc = worker.clone(); + set.spawn(async move { + ensure_one(worker_arc.as_ref(), st, http_sem, app, session, id).await; + }); + } + while set.join_next().await.is_some() {} + if paused_for_ui_priority || worker.ui_priority_hold.load(Ordering::Relaxed) { + continue; + } + + if batch_count.is_multiple_of(PROGRESS_EVERY_BATCHES) { + if let Ok((done, total, pending)) = progress_snapshot( + &runtime.store, + &root, + &session.library_server_id, + &session.server_index_key, + ) + .await + { + emit_library_progress(&app, &session, done, total, pending, &root).await; + } + } + + if batch.exhausted { + worker.cursor.lock().await.clear(); + if let Ok((done, total, pending)) = progress_snapshot( + &runtime.store, + &root, + &session.library_server_id, + &session.server_index_key, + ) + .await + { + if pending > PENDING_RESTART_THRESHOLD { + let root3 = root.clone(); + let index_key3 = session.server_index_key.clone(); + let _ = tauri::async_runtime::spawn_blocking(move || { + clear_cover_fetch_failures(&root3, &index_key3) + }) + .await; + } + emit_library_progress(&app, &session, done, total, pending, &root).await; + } + break; + } + + if let Some(next) = batch.next_cursor { + *worker.cursor.lock().await = next; + } + } +} + +/// Start one full-catalog pass on the Tokio runtime (survives inactive webview). +pub async fn try_schedule_full_pass(app: &AppHandle) -> bool { + let worker = match app.try_state::>() { + Some(w) => w.inner().clone(), + None => return false, + }; + if !worker.enabled.load(Ordering::Relaxed) { + return false; + } + if worker + .pass_running + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + return false; + } + + let app = app.clone(); + tauri::async_runtime::spawn(async move { + run_full_pass(app, worker.clone()).await; + worker.pass_running.store(false, Ordering::SeqCst); + }); + true +} + +fn on_sync_idle(app: &AppHandle, payload: SyncIdlePayload) { + if !payload.ok { + return; + } + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let worker = match app.try_state::>() { + Some(w) => w.inner().clone(), + None => return, + }; + if !worker.enabled.load(Ordering::Relaxed) { + return; + } + let session = worker.session.lock().await.clone(); + let Some(session) = session else { + return; + }; + if !session_matches_server(&session, &payload.server_id) { + return; + } + let _ = try_schedule_full_pass(&app).await; + }); +} + +/// Listen for library sync completion in native code (not throttled with the webview). +pub fn setup_library_sync_idle_listener(app: &AppHandle) { + let app_handle = app.clone(); + let _ = app.listen(LibrarySyncProgressPayload::IDLE_EVENT_NAME, move |event| { + let Ok(payload) = serde_json::from_str::(event.payload()) else { + return; + }; + on_sync_idle(&app_handle, payload); + }); +} + +/// Legacy single-step API (optional diagnostics). +pub async fn pulse_backfill(app: &AppHandle, _worker: &Arc) -> CoverBackfillPulseDto { + if try_schedule_full_pass(app).await { + return CoverBackfillPulseDto { + scheduled: 0, + exhausted: false, + pending: 0, + done: 0, + total: 0, + status: "active".into(), + }; + } + CoverBackfillPulseDto { + scheduled: 0, + exhausted: true, + pending: 0, + done: 0, + total: 0, + status: "disabled".into(), + } +} diff --git a/src-tauri/src/cover_cache/disk.rs b/src-tauri/src/cover_cache/disk.rs new file mode 100644 index 00000000..d414e168 --- /dev/null +++ b/src-tauri/src/cover_cache/disk.rs @@ -0,0 +1,42 @@ +use std::path::{Path, PathBuf}; + +pub const DERIVE_TIERS: [u32; 4] = [128, 256, 512, 800]; + +/// `server_index_key` — host (+ optional path), same bucket as library `server_id`. +pub fn cover_dir(root: &Path, server_index_key: &str, cover_art_id: &str) -> PathBuf { + root.join(server_index_key).join(cover_art_id) +} + +pub fn tier_path(dir: &Path, tier: u32) -> PathBuf { + dir.join(format!("{tier}.webp")) +} + +#[allow(dead_code)] +pub fn meta_path(dir: &Path) -> PathBuf { + dir.join("meta.json") +} + +pub fn tier_exists(dir: &Path, tier: u32) -> Option { + let p = tier_path(dir, tier); + if p.is_file() { Some(p) } else { None } +} + +/// Write missing WebP tiers up to `max_tier` (used by library bulk backfill). +pub fn write_derived_webp_tiers( + dir: &Path, + img: &image::DynamicImage, + max_tier: u32, +) -> Result<(), String> { + use super::encode::write_webp_tier; + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + for &tier in DERIVE_TIERS.iter() { + if tier > max_tier { + continue; + } + if tier_exists(dir, tier).is_some() { + continue; + } + write_webp_tier(img, tier, &tier_path(dir, tier))?; + } + Ok(()) +} diff --git a/src-tauri/src/cover_cache/encode.rs b/src-tauri/src/cover_cache/encode.rs new file mode 100644 index 00000000..d22e3557 --- /dev/null +++ b/src-tauri/src/cover_cache/encode.rs @@ -0,0 +1,75 @@ +use image::imageops::FilterType; +use image::DynamicImage; +use std::path::Path; + +pub fn resize_tier(img: &DynamicImage, tier: u32) -> DynamicImage { + let (w, h) = (img.width(), img.height()); + let max_dim = w.max(h); + if max_dim <= tier { + return img.clone(); + } + let scale = tier as f32 / max_dim as f32; + let nw = (w as f32 * scale).round().max(1.0) as u32; + let nh = (h as f32 * scale).round().max(1.0) as u32; + img.resize(nw, nh, FilterType::Triangle) +} + +/// Lossy WebP quality (0–100). Larger tiers use lower Q — UI rarely shows 800px raw; +/// dense grids cap at 512px (see `COVER_ART_DENSE_MAX_TIER`). +pub fn webp_quality_for_tier(tier: u32) -> f32 { + match tier { + 2000 => 82.0, + 800 => 70.0, + 512 => 73.0, + 256 => 76.0, + 128 => 78.0, + _ => 74.0, + } +} + +pub fn encode_webp(img: &DynamicImage, tier: u32) -> Result, String> { + let rgba = img.to_rgba8(); + let enc = webp::Encoder::from_rgba(rgba.as_raw(), rgba.width(), rgba.height()); + Ok(enc.encode(webp_quality_for_tier(tier)).to_vec()) +} + +pub fn write_webp_tier(img: &DynamicImage, tier: u32, path: &Path) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let resized = resize_tier(img, tier); + let bytes = encode_webp(&resized, tier)?; + std::fs::write(path, bytes).map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use image::RgbaImage; + + #[test] + fn resize_tier_scales_down_large_canvas() { + let img = DynamicImage::ImageRgba8(RgbaImage::new(800, 600)); + let out = resize_tier(&img, 128); + assert!(out.width() <= 128); + assert!(out.height() <= 128); + } + + #[test] + fn webp_quality_decreases_with_tier_size() { + assert!(webp_quality_for_tier(800) < webp_quality_for_tier(512)); + assert!(webp_quality_for_tier(512) < webp_quality_for_tier(128)); + assert!(webp_quality_for_tier(800) < webp_quality_for_tier(2000)); + } + + #[test] + fn webp_encode_800_smaller_than_old_lossless_upper_bound() { + let img = DynamicImage::ImageRgba8(RgbaImage::new(800, 800)); + let bytes = encode_webp(&img, 800).expect("webp"); + assert!( + bytes.len() < 250_000, + "expected lossy 800 webp well under legacy JPEG cap, got {} bytes", + bytes.len() + ); + } +} diff --git a/src-tauri/src/cover_cache/fetch.rs b/src-tauri/src/cover_cache/fetch.rs new file mode 100644 index 00000000..bf6d142f --- /dev/null +++ b/src-tauri/src/cover_cache/fetch.rs @@ -0,0 +1,98 @@ +use reqwest::Client; +use std::time::{SystemTime, UNIX_EPOCH}; +use url::Url; + +const SUBSONIC_CLIENT: &str = "Psysonic"; + +fn random_salt() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("{nanos:x}") +} + +pub fn build_cover_art_url( + rest_base: &str, + username: &str, + password: &str, + cover_art_id: &str, + size: u32, +) -> String { + let base = rest_base.trim_end_matches('/'); + let api_base = if base.ends_with("/rest") { + base.to_string() + } else { + format!("{base}/rest") + }; + let salt = random_salt(); + let token = format!("{:x}", md5::compute(format!("{password}{salt}"))); + let endpoint = format!("{api_base}/getCoverArt.view"); + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + serializer.append_pair("id", cover_art_id); + serializer.append_pair("size", &size.to_string()); + serializer.append_pair("u", username); + serializer.append_pair("t", &token); + serializer.append_pair("s", &salt); + serializer.append_pair("v", "1.16.1"); + serializer.append_pair("c", SUBSONIC_CLIENT); + let query = serializer.finish(); + match Url::parse(&endpoint) { + Ok(mut url) => { + url.set_query(Some(&query)); + url.to_string() + } + Err(_) => format!("{endpoint}?{query}"), + } +} + +pub async fn fetch_cover_bytes(client: &Client, url: &str) -> Result, String> { + let resp = client + .get(url) + .send() + .await + .map_err(|e| e.to_string())?; + if !resp.status().is_success() { + return Err(format!("cover HTTP {}", resp.status())); + } + resp.bytes().await.map(|b| b.to_vec()).map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::build_cover_art_url; + + #[test] + fn cover_url_from_host_root() { + let url = build_cover_art_url( + "http://navidrome.local:4533", + "u", + "p", + "al-1", + 800, + ); + assert!(url.starts_with("http://navidrome.local:4533/rest/getCoverArt.view?")); + assert!(url.contains("id=al-1")); + assert!(url.contains("size=800")); + } + + #[test] + fn cover_url_when_rest_suffix_already_present() { + let url = build_cover_art_url( + "http://navidrome.local:4533/rest", + "u", + "p", + "al-1", + 128, + ); + assert!(url.starts_with("http://navidrome.local:4533/rest/getCoverArt.view?")); + assert!(!url.contains("/rest/rest/")); + } + + #[test] + fn cover_url_does_not_panic_on_malformed_base() { + let url = build_cover_art_url("://bad-url", "u", "p", "al-1", 128); + assert!(url.contains("/rest/getCoverArt.view?")); + assert!(url.contains("id=al-1")); + } +} diff --git a/src-tauri/src/cover_cache/mod.rs b/src-tauri/src/cover_cache/mod.rs new file mode 100644 index 00000000..def71a86 --- /dev/null +++ b/src-tauri/src/cover_cache/mod.rs @@ -0,0 +1,885 @@ +//! Cover art disk cache — WebP tiers, prefetch, revalidation (phase B). + +mod backfill_worker; +mod disk; +mod encode; +mod fetch; + +use disk::{cover_dir, tier_exists, tier_path, DERIVE_TIERS}; +use encode::write_webp_tier; +use fetch::{build_cover_art_url, fetch_cover_bytes}; +use image::{DynamicImage, ImageReader}; +use psysonic_library::cover_backfill::{ + clear_cover_fetch_failures, collect_cover_backfill_batch, collect_cover_progress, + count_distinct_cover_ids, cover_fetch_recently_failed, LibraryCoverBackfillBatchDto, + LibraryCoverProgressDto, COVER_FETCH_FAIL_MARKER, +}; +use psysonic_library::LibraryRuntime; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::{Mutex, Semaphore}; +use tauri::{AppHandle, Emitter, Manager}; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CoverCacheEnsureResult { + pub hit: bool, + pub path: String, + pub tier: u32, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CoverCacheStatsDto { + pub bytes: u64, + pub count: u64, + pub pressure: String, + pub auto_download_enabled: bool, + pub entry_count: u64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CoverCacheEnsureArgs { + pub server_index_key: String, + pub cover_art_id: String, + pub tier: u32, + pub rest_base_url: String, + pub username: String, + pub password: String, + /// Library backfill: all derived tiers, no `cover:tier-ready` floods to the webview. + #[serde(default)] + pub library_bulk: bool, +} + +/// Cap concurrent cover HTTP fetches (library backfill + UI share this pool). +const COVER_HTTP_CONCURRENCY: usize = 16; + +pub struct CoverCacheState { + pub root: PathBuf, + pub client: Client, + pub max_bytes: u64, + pub high_watermark_pct: u64, + pub resume_watermark_pct: u64, + pub http_sem: Arc, +} + +impl CoverCacheState { + pub fn new(root: PathBuf) -> Result { + std::fs::create_dir_all(&root).map_err(|e| e.to_string())?; + let client = Client::builder() + .timeout(Duration::from_secs(25)) + .connect_timeout(Duration::from_secs(10)) + .build() + .map_err(|e| e.to_string())?; + Ok(Self { + root, + client, + max_bytes: 10 * 1024 * 1024 * 1024, + high_watermark_pct: 90, + resume_watermark_pct: 85, + http_sem: Arc::new(Semaphore::new(COVER_HTTP_CONCURRENCY)), + }) + } + + fn pressure_from_bytes(&self, _bytes: u64) -> (String, bool) { + ("ok".into(), true) + } + + fn pressure(&self) -> (String, bool) { + let (bytes, _) = dir_usage_at_root(&self.root); + self.pressure_from_bytes(bytes) + } + + pub(crate) async fn ensure_inner( + state: &Arc>, + app: &AppHandle, + args: &CoverCacheEnsureArgs, + http_sem_override: Option>, + ) -> Result { + let this = state.lock().await; + let dir = cover_dir(&this.root, &args.server_index_key, &args.cover_art_id); + if let Some(path) = peek_tier_path(&dir, args.tier) { + return Ok(CoverCacheEnsureResult { + hit: true, + path: path.to_string_lossy().into_owned(), + tier: args.tier, + }); + } + + let (_, auto_dl) = this.pressure(); + if !auto_dl && args.tier != 2000 { + return Ok(CoverCacheEnsureResult { + hit: false, + path: String::new(), + tier: args.tier, + }); + } + + let client = this.client.clone(); + let root = this.root.clone(); + let http_sem = http_sem_override.unwrap_or_else(|| this.http_sem.clone()); + drop(this); + + if cover_fetch_recently_failed(&dir) { + return Ok(CoverCacheEnsureResult { + hit: false, + path: String::new(), + tier: args.tier, + }); + } + + let img = match load_cover_source(&dir, &client, &http_sem, args).await { + Ok(img) => img, + Err(_) => { + let _ = std::fs::create_dir_all(&dir); + let _ = std::fs::write(dir.join(COVER_FETCH_FAIL_MARKER), b"1"); + return Ok(CoverCacheEnsureResult { + hit: false, + path: String::new(), + tier: args.tier, + }); + } + }; + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + + let requested = args.tier; + let quiet = args.library_bulk; + let tiers_now: Vec = if args.library_bulk { + DERIVE_TIERS + .iter() + .copied() + .filter(|t| *t <= requested) + .collect() + } else if requested == 2000 { + vec![2000] + } else { + DERIVE_TIERS + .iter() + .copied() + .filter(|t| *t <= requested) + .collect() + }; + + let mut wrote_requested = false; + if quiet { + let dir_bg = dir.clone(); + let img_bg = img.clone(); + let max_tier = requested; + let wrote = tauri::async_runtime::spawn_blocking(move || -> Result { + disk::write_derived_webp_tiers(&dir_bg, &img_bg, max_tier)?; + Ok(tier_exists(&dir_bg, max_tier).is_some()) + }) + .await + .map_err(|e| e.to_string())??; + wrote_requested = wrote; + } else { + for tier in tiers_now { + if tier_exists(&dir, tier).is_some() { + if tier == requested { + wrote_requested = true; + } + continue; + } + let path = tier_path(&dir, tier); + write_webp_tier(&img, tier, &path)?; + emit_tier_ready(app, args, tier, &path); + if tier == requested { + wrote_requested = true; + } + } + } + + if !wrote_requested && tier_exists(&dir, requested).is_some() { + wrote_requested = true; + } + + let out_path = tier_path(&dir, requested); + if wrote_requested || out_path.is_file() { + if !quiet { + spawn_derive_remaining_tiers( + app.clone(), + state.clone(), + root, + args.clone(), + img, + requested, + ); + } + return Ok(CoverCacheEnsureResult { + hit: true, + path: out_path.to_string_lossy().into_owned(), + tier: requested, + }); + } + + Ok(CoverCacheEnsureResult { + hit: false, + path: String::new(), + tier: requested, + }) + } +} + +fn emit_tier_ready(app: &AppHandle, args: &CoverCacheEnsureArgs, tier: u32, path: &Path) { + let Ok(meta) = std::fs::metadata(path) else { + return; + }; + if !meta.is_file() || meta.len() == 0 { + return; + } + let _ = app.emit( + "cover:tier-ready", + serde_json::json!({ + "serverIndexKey": args.server_index_key, + "coverArtId": args.cover_art_id, + "tier": tier, + "path": path.to_string_lossy(), + }), + ); +} + +fn decode_image_bytes(bytes: &[u8]) -> Result { + ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .map_err(|e| e.to_string())? + .decode() + .map_err(|e| e.to_string()) +} + +fn load_image_from_disk(dir: &Path) -> Option { + for tier in [800u32, 512, 256, 128] { + if let Some(path) = tier_exists(dir, tier) { + if let Ok(img) = image::open(&path) { + return Some(img); + } + } + } + None +} + +async fn load_cover_source( + dir: &Path, + client: &Client, + http_sem: &Semaphore, + args: &CoverCacheEnsureArgs, +) -> Result { + if let Some(img) = load_image_from_disk(dir) { + return Ok(img); + } + let _permit = http_sem + .acquire() + .await + .map_err(|e| e.to_string())?; + let fetch_size = if args.tier >= 2000 { + 2000 + } else { + 800 + }; + let url = build_cover_art_url( + &args.rest_base_url, + &args.username, + &args.password, + &args.cover_art_id, + fetch_size, + ); + let bytes = fetch_cover_bytes(client, &url).await?; + decode_image_bytes(&bytes) +} + +fn spawn_derive_remaining_tiers( + app: AppHandle, + state: Arc>, + _root: PathBuf, + args: CoverCacheEnsureArgs, + img: DynamicImage, + requested: u32, +) { + let tiers_bg: Vec = if requested == 2000 { + vec![] + } else { + DERIVE_TIERS + .iter() + .copied() + .filter(|t| *t > requested && *t <= 800) + .collect() + }; + if tiers_bg.is_empty() { + return; + } + tauri::async_runtime::spawn(async move { + let dir = { + let guard = state.lock().await; + cover_dir(&guard.root, &args.server_index_key, &args.cover_art_id) + }; + let _ = tauri::async_runtime::spawn_blocking(move || { + for tier in tiers_bg { + if tier_exists(&dir, tier).is_some() { + continue; + } + let path = tier_path(&dir, tier); + if write_webp_tier(&img, tier, &path).is_ok() { + emit_tier_ready(&app, &args, tier, &path); + } + } + }) + .await; + }); +} + +fn dir_has_any_cached_tier(dir: &Path) -> bool { + if tier_exists(dir, 800).is_some() { + return true; + } + for tier in DERIVE_TIERS { + if tier != 800 && tier_exists(dir, tier).is_some() { + return true; + } + } + tier_exists(dir, 2000).is_some() +} + +fn count_cached_in_server_dir(server_dir: &Path) -> i64 { + let Ok(entries) = std::fs::read_dir(server_dir) else { + return 0; + }; + entries + .flatten() + .filter(|e| e.path().is_dir()) + .filter(|e| dir_has_any_cached_tier(&e.path())) + .count() as i64 +} + +/// Count cover ID dirs with any cached tier (UI progress — matches visible disk cache). +pub(crate) fn count_cached_cover_ids(root: &Path, server_index_key: &str) -> i64 { + let keyed = count_cached_in_server_dir(&root.join(server_index_key)); + if keyed > 0 { + return keyed; + } + // Legacy profile-uuid bucket or host alias — don't show 0 when files exist elsewhere. + let Ok(entries) = std::fs::read_dir(root) else { + return 0; + }; + entries + .flatten() + .filter(|e| { + e.path().is_dir() + && e.file_name().to_string_lossy() != ".storage-layout" + }) + .map(|e| count_cached_in_server_dir(&e.path())) + .max() + .unwrap_or(0) +} + +/// Disk usage for one server bucket only (cheaper than scanning all hosts). +pub(crate) fn dir_usage_for_server(root: &Path, server_index_key: &str) -> (u64, u64) { + let mut bytes = 0u64; + let mut count = 0u64; + let server_dir = root.join(server_index_key); + let Ok(ids) = std::fs::read_dir(&server_dir) else { + return (0, 0); + }; + for id_dir in ids.flatten() { + if !id_dir.path().is_dir() { + continue; + } + if dir_has_any_cached_tier(&id_dir.path()) { + count += 1; + } + let Ok(files) = std::fs::read_dir(id_dir.path()) else { + continue; + }; + for f in files.flatten() { + if let Ok(meta) = f.metadata() { + bytes += meta.len(); + } + } + } + (bytes, count) +} + +pub(crate) fn dir_usage_at_root(root: &Path) -> (u64, u64) { + let mut bytes = 0u64; + let mut count = 0u64; + let Ok(entries) = std::fs::read_dir(root) else { + return (0, 0); + }; + for server in entries.flatten() { + if server.file_name().to_string_lossy() == ".storage-layout" { + continue; + } + if !server.path().is_dir() { + continue; + } + let Ok(ids) = std::fs::read_dir(server.path()) else { + continue; + }; + for id_dir in ids.flatten() { + if !id_dir.path().is_dir() { + continue; + } + if dir_has_any_cached_tier(&id_dir.path()) { + count += 1; + } + let Ok(files) = std::fs::read_dir(id_dir.path()) else { + continue; + }; + for f in files.flatten() { + if let Ok(meta) = f.metadata() { + bytes += meta.len(); + } + } + } + } + (bytes, count) +} + +fn state(app: &AppHandle) -> Result>, String> { + app.try_state::>>() + .map(|s| s.inner().clone()) + .ok_or_else(|| "cover cache not initialized".into()) +} + +const COVER_CACHE_LAYOUT_STAMP: &str = "index-key-v1"; + +/// Drop legacy profile-uuid directories when switching to host index keys (no migration). +fn reset_cover_cache_for_index_key_layout(root: &Path) -> Result<(), String> { + let stamp = root.join(".storage-layout"); + if stamp.is_file() { + if let Ok(s) = std::fs::read_to_string(&stamp) { + if s.trim() == COVER_CACHE_LAYOUT_STAMP { + return Ok(()); + } + } + } + if root.exists() { + for entry in std::fs::read_dir(root).map_err(|e| e.to_string())?.flatten() { + let path = entry.path(); + if path.file_name().and_then(|n| n.to_str()) == Some(".storage-layout") { + continue; + } + if path.is_dir() { + let _ = std::fs::remove_dir_all(&path); + } else { + let _ = std::fs::remove_file(&path); + } + } + } + std::fs::create_dir_all(root).map_err(|e| e.to_string())?; + std::fs::write(&stamp, COVER_CACHE_LAYOUT_STAMP).map_err(|e| e.to_string())?; + Ok(()) +} + +pub use backfill_worker::{ + pulse_backfill, setup_library_sync_idle_listener, try_schedule_full_pass, CoverBackfillPulseDto, + CoverBackfillRunDto, CoverBackfillSession, CoverBackfillWorker, +}; + +pub fn init_cover_cache(app: &AppHandle) -> Result<(), String> { + let root = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("cover-cache"); + reset_cover_cache_for_index_key_layout(&root)?; + app.manage(Arc::new(Mutex::new(CoverCacheState::new(root)?))); + app.manage(Arc::new(CoverBackfillWorker::new())); + setup_library_sync_idle_listener(app); + Ok(()) +} + +#[tauri::command] +pub async fn library_cover_backfill_run_full_pass(app: AppHandle) -> Result { + Ok(CoverBackfillRunDto { + started: try_schedule_full_pass(&app).await, + }) +} + +#[tauri::command] +pub async fn library_cover_backfill_pulse(app: AppHandle) -> Result { + let worker = app + .try_state::>() + .ok_or_else(|| "cover backfill worker not initialized".to_string())?; + Ok(pulse_backfill(&app, &worker).await) +} + +#[tauri::command] +pub async fn library_cover_backfill_reset_cursor(app: AppHandle) -> Result<(), String> { + let worker = app + .try_state::>() + .ok_or_else(|| "cover backfill worker not initialized".to_string())?; + worker.reset_cursor().await; + Ok(()) +} + +/// Pause library backfill while the user navigates / visible covers load (Rust pass yields). +#[tauri::command] +pub async fn library_cover_backfill_set_ui_priority( + app: AppHandle, + hold: bool, +) -> Result<(), String> { + let worker = app + .try_state::>() + .ok_or_else(|| "cover backfill worker not initialized".to_string())?; + worker.set_ui_priority_hold(hold); + Ok(()) +} + +#[tauri::command] +pub async fn library_cover_backfill_configure( + app: AppHandle, + enabled: bool, + server_index_key: String, + library_server_id: String, + rest_base_url: String, + username: String, + password: String, +) -> Result<(), String> { + let worker = app + .try_state::>() + .ok_or_else(|| "cover backfill worker not initialized".to_string())?; + let session = if enabled && !library_server_id.is_empty() && !server_index_key.is_empty() { + Some(CoverBackfillSession { + server_index_key, + library_server_id, + rest_base_url, + username, + password, + }) + } else { + None + }; + worker + .set_session(enabled && session.is_some(), session) + .await; + if enabled { + let _ = try_schedule_full_pass(&app).await; + } + Ok(()) +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CoverCachePeekItem { + pub server_index_key: String, + pub cover_art_id: String, + pub tier: u32, +} + +/// Best-effort disk hit without network (exact tier, then largest tier on disk ≤ wanted). +#[tauri::command] +pub async fn cover_cache_peek_batch( + app: AppHandle, + items: Vec, +) -> Result, String> { + let st = state(&app)?; + let root = { + let guard = st.lock().await; + guard.root.clone() + }; + let mut out = HashMap::new(); + for item in items { + let dir = cover_dir(&root, &item.server_index_key, &item.cover_art_id); + let path = peek_tier_path(&dir, item.tier); + if let Some(p) = path { + let key = format!( + "{}:cover:{}:{}", + item.server_index_key, item.cover_art_id, item.tier + ); + out.insert(key, p.to_string_lossy().into_owned()); + } + } + Ok(out) +} + +fn peek_fallback_tiers(want: u32) -> &'static [u32] { + match want { + 512 => &[800, 256, 128], + 256 => &[800, 512, 128], + 128 => &[256, 512, 800], + 64 => &[128, 256, 512, 800], + w if w > 512 && w < 800 => &[800, 512, 256, 128], + w if w > 800 => &[512, 256, 128], + _ => &[800, 512, 256, 128], + } +} + +/// Disk-only: exact tier, then grid-friendly upscales (512 → 800 before 128). +fn peek_tier_path(dir: &Path, want: u32) -> Option { + if let Some(p) = tier_exists(dir, want) { + return Some(p); + } + for &tier in peek_fallback_tiers(want) { + if let Some(p) = tier_exists(dir, tier) { + return Some(p); + } + } + None +} + +#[tauri::command] +pub async fn cover_cache_ensure( + app: AppHandle, + server_index_key: String, + cover_art_id: String, + tier: u32, + rest_base_url: String, + username: String, + password: String, +) -> Result { + let args = CoverCacheEnsureArgs { + server_index_key, + cover_art_id, + tier, + rest_base_url, + username, + password, + library_bulk: false, + }; + let st = state(&app)?; + CoverCacheState::ensure_inner(&st, &app, &args, None).await +} + +#[tauri::command] +pub async fn cover_cache_ensure_batch( + app: AppHandle, + items: Vec, +) -> Result<(), String> { + if items.is_empty() { + return Ok(()); + } + let st = state(&app)?; + for item in items { + let st = st.clone(); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let _ = CoverCacheState::ensure_inner(&st, &app, &item, None).await; + }); + } + Ok(()) +} + +#[tauri::command] +pub async fn cover_cache_stats(app: AppHandle) -> Result { + let st = state(&app)?; + let root = { + let guard = st.lock().await; + guard.root.clone() + }; + let (bytes, entry_count) = tauri::async_runtime::spawn_blocking(move || dir_usage_at_root(&root)) + .await + .map_err(|e| e.to_string())?; + let st = state(&app)?; + let guard = st.lock().await; + let (pressure, auto_download_enabled) = guard.pressure_from_bytes(bytes); + Ok(CoverCacheStatsDto { + bytes, + count: entry_count, + pressure, + auto_download_enabled, + entry_count, + }) +} + +#[tauri::command] +pub async fn cover_cache_evict_tick(_app: AppHandle) -> Result { + Ok(0) +} + +#[tauri::command] +pub async fn cover_cache_stats_server( + app: AppHandle, + server_index_key: String, +) -> Result { + let st = state(&app)?; + let guard = st.lock().await; + let (bytes, entry_count) = dir_usage_for_server(&guard.root, &server_index_key); + let (pressure, auto_download_enabled) = guard.pressure_from_bytes(bytes); + Ok(CoverCacheStatsDto { + bytes, + count: entry_count, + pressure, + auto_download_enabled, + entry_count, + }) +} + +#[tauri::command] +pub async fn cover_cache_clear_server( + app: AppHandle, + server_index_key: String, +) -> Result<(), String> { + let st = state(&app)?; + let guard = st.lock().await; + let path = guard.root.join(&server_index_key); + if path.is_dir() { + std::fs::remove_dir_all(&path).map_err(|e| e.to_string())?; + } + drop(guard); + let _ = app.emit( + "cover:cache-cleared", + serde_json::json!({ "serverIndexKey": server_index_key }), + ); + Ok(()) +} + +#[tauri::command] +pub async fn cover_cache_configure( + app: AppHandle, + max_mb: u64, + high_watermark_pct: u64, + resume_watermark_pct: u64, +) -> Result<(), String> { + let st = state(&app)?; + let mut guard = st.lock().await; + guard.max_bytes = max_mb.saturating_mul(1024 * 1024); + guard.high_watermark_pct = high_watermark_pct.clamp(50, 99); + guard.resume_watermark_pct = resume_watermark_pct.clamp(40, 95); + Ok(()) +} + +#[tauri::command] +pub async fn cover_cache_clear(app: AppHandle) -> Result<(), String> { + let st = state(&app)?; + let guard = st.lock().await; + if guard.root.exists() { + for entry in std::fs::read_dir(&guard.root).map_err(|e| e.to_string())?.flatten() { + let name = entry.file_name(); + if name.to_string_lossy() == ".storage-layout" { + continue; + } + if entry.path().is_dir() { + let _ = std::fs::remove_dir_all(entry.path()); + } else { + let _ = std::fs::remove_file(entry.path()); + } + } + } + drop(guard); + let _ = app.emit("cover:cache-cleared", serde_json::json!({})); + Ok(()) +} + +#[tauri::command] +pub async fn library_cover_backfill_batch( + app: AppHandle, + server_index_key: String, + library_server_id: String, + cursor: Option, + limit: Option, +) -> Result { + let runtime = app + .try_state::() + .ok_or_else(|| "LibraryRuntime not initialized".to_string())?; + let st = state(&app)?; + let root = { + let guard = st.lock().await; + guard.root.clone() + }; + let store = runtime.store.clone(); + tauri::async_runtime::spawn_blocking(move || { + collect_cover_backfill_batch( + &store, + &library_server_id, + &root, + &server_index_key, + cursor.as_deref(), + limit, + ) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn library_cover_progress( + app: AppHandle, + server_index_key: String, + library_server_id: String, +) -> Result { + let runtime = app + .try_state::() + .ok_or_else(|| "LibraryRuntime not initialized".to_string())?; + let st = state(&app)?; + let root = { + let guard = st.lock().await; + guard.root.clone() + }; + let index_key = server_index_key.clone(); + let store = runtime.store.clone(); + tauri::async_runtime::spawn_blocking(move || { + let cached_dirs = count_cached_cover_ids(&root, &index_key); + collect_cover_progress( + &store, + &library_server_id, + &root, + &index_key, + cached_dirs, + ) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn library_cover_clear_fetch_failures( + app: AppHandle, + server_index_key: String, +) -> Result { + let st = state(&app)?; + let guard = st.lock().await; + Ok(clear_cover_fetch_failures(&guard.root, &server_index_key)) +} + +#[tauri::command] +pub async fn library_cover_catalog_size( + app: AppHandle, + library_server_id: String, +) -> Result { + let runtime = app + .try_state::() + .ok_or_else(|| "LibraryRuntime not initialized".to_string())?; + let store = runtime.store.clone(); + tauri::async_runtime::spawn_blocking(move || { + count_distinct_cover_ids(&store, &library_server_id) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub fn cover_revalidate_enqueue() -> Result<(), String> { + Ok(()) +} + +#[tauri::command] +pub fn cover_revalidate_tick(_cycle_days: Option) -> Result { + Ok(0) +} + +#[tauri::command] +pub fn cover_revalidate_batch() -> Result { + Ok(serde_json::json!({ + "cursor": null, + "processed": 0, + "changed": 0 + })) +} + +#[cfg(test)] +mod tests { + use super::disk::{cover_dir, tier_path}; + + #[test] + fn disk_layout_paths() { + let root = std::path::Path::new("/tmp/cover-test"); + let dir = cover_dir(root, "srv", "al-1"); + assert_eq!(dir, root.join("srv").join("al-1")); + assert_eq!(tier_path(&dir, 512), dir.join("512.webp")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4db64d94..79f0f779 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] pub mod cli; +mod cover_cache; mod lib_commands; pub use psysonic_integration::discord; @@ -119,6 +120,9 @@ pub fn run() { app.manage(cache); } + cover_cache::init_cover_cache(app.handle()) + .map_err(|e| format!("cover cache init failed: {e}"))?; + // ── Library track store (psysonic-library, PR-5a + PR-5b) ───── // PR-5a brought up the read-only Tauri surface + LibraryRuntime. // PR-5b adds the mutating commands, sync session map, current-job @@ -726,6 +730,27 @@ pub fn run() { psysonic_library::commands::library_migrate_server_index_keys, psysonic_library::commands::library_delete_server_data, psysonic_library::commands::library_analysis_backfill_batch, + cover_cache::cover_cache_peek_batch, + cover_cache::cover_cache_ensure, + cover_cache::cover_cache_ensure_batch, + cover_cache::cover_cache_stats, + cover_cache::cover_cache_evict_tick, + cover_cache::cover_cache_configure, + cover_cache::cover_cache_clear, + cover_cache::cover_cache_clear_server, + cover_cache::cover_cache_stats_server, + cover_cache::library_cover_backfill_batch, + cover_cache::library_cover_progress, + cover_cache::library_cover_catalog_size, + cover_cache::library_cover_clear_fetch_failures, + cover_cache::library_cover_backfill_configure, + cover_cache::library_cover_backfill_pulse, + cover_cache::library_cover_backfill_reset_cursor, + cover_cache::library_cover_backfill_set_ui_priority, + cover_cache::library_cover_backfill_run_full_pass, + cover_cache::cover_revalidate_enqueue, + cover_cache::cover_revalidate_tick, + cover_cache::cover_revalidate_batch, psysonic_syncfs::cache::offline::download_track_offline, psysonic_syncfs::cache::offline::cancel_offline_downloads, psysonic_syncfs::cache::offline::clear_offline_cancel, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 854ebe81..dcdaa3db 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -27,7 +27,11 @@ } ], "security": { - "csp": null + "csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval' ipc: http://ipc.localhost tauri:; connect-src 'self' ipc: http://ipc.localhost tauri: https: http: ws: wss:; img-src 'self' asset: http://asset.localhost https: http: data: blob:; media-src 'self' asset: http://asset.localhost https: http: data: blob:;", + "assetProtocol": { + "enable": true, + "scope": ["$APPDATA/**", "$APPDATA/cover-cache/**"] + } } }, "plugins": { diff --git a/src/api/coverCache.test.ts b/src/api/coverCache.test.ts new file mode 100644 index 00000000..7686a55d --- /dev/null +++ b/src/api/coverCache.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useAuthStore } from '../store/authStore'; +import { usePlayerStore } from '../store/playerStore'; +import { makeTrack } from '../test/helpers/factories'; +import { resetAllStores } from '../test/helpers/storeReset'; +import { invokeMock, onInvoke } from '../test/mocks/tauri'; +import { coverArtRef } from '../cover/ref'; +import { coverCacheEnsure, coverCacheRestHost, librarySqlServerId } from './coverCache'; + +describe('librarySqlServerId', () => { + beforeEach(() => { + resetAllStores(); + useAuthStore.setState({ + servers: [{ id: 'profile-uuid', name: 'Home', url: 'http://music.example:4533', username: 'u', password: 'p' }], + activeServerId: 'profile-uuid', + }); + }); + + it('maps auth profile UUID to host index key for SQLite', () => { + expect(librarySqlServerId('profile-uuid')).toBe('music.example:4533'); + }); + + it('passes through values that are already index keys', () => { + expect(librarySqlServerId('music.example:4533')).toBe('music.example:4533'); + }); +}); + +describe('coverCacheRestHost', () => { + it('strips /rest for Rust cover fetch', () => { + expect(coverCacheRestHost('http://music.example:4533')).toBe('http://music.example:4533'); + expect(coverCacheRestHost('http://music.example:4533/')).toBe('http://music.example:4533'); + }); +}); + +describe('coverCacheEnsure', () => { + beforeEach(() => { + resetAllStores(); + }); + + it('uses playback server credentials when scope kind is playback', async () => { + const activeServerId = useAuthStore.getState().addServer({ + name: 'Active', + url: 'http://active.example:4533', + username: 'active-user', + password: 'active-pass', + }); + const playbackServerId = useAuthStore.getState().addServer({ + name: 'Playback', + url: 'http://playback.example:5533', + username: 'playback-user', + password: 'playback-pass', + }); + useAuthStore.getState().setActiveServer(activeServerId); + + const track = makeTrack({ id: 'q1', coverArt: 'cover-1' }); + usePlayerStore.setState({ + queue: [track], + queueIndex: 0, + queueServerId: playbackServerId, + currentTrack: track, + }); + + onInvoke('cover_cache_ensure', () => ({ hit: false, path: '', tier: 256 })); + + await coverCacheEnsure(coverArtRef('cover-1', { kind: 'playback' }), 256); + + const call = invokeMock.mock.calls.find(c => c[0] === 'cover_cache_ensure'); + expect(call).toBeTruthy(); + const payload = call?.[1] as Record; + expect(payload.restBaseUrl).toBe('http://playback.example:5533'); + expect(payload.username).toBe('playback-user'); + expect(payload.password).toBe('playback-pass'); + }); +}); diff --git a/src/api/coverCache.ts b/src/api/coverCache.ts new file mode 100644 index 00000000..f0bed847 --- /dev/null +++ b/src/api/coverCache.ts @@ -0,0 +1,221 @@ +import { invoke } from '@tauri-apps/api/core'; +import { useAuthStore } from '../store/authStore'; +import { coverIndexKeyFromRef } from '../cover/storageKeys'; +import { serverIndexKeyForProfile } from '../utils/server/serverIndexKey'; +import { getPlaybackServerId } from '../utils/playback/playbackServer'; +import { restBaseFromUrl } from './subsonicClient'; +import type { CoverArtRef, CoverArtTier } from '../cover/types'; + +/** Library SQLite `track.server_id` uses host index keys, not auth profile UUIDs. */ +export function librarySqlServerId(profileOrIndexServerId: string): string { + const server = useAuthStore.getState().servers.find(s => s.id === profileOrIndexServerId); + if (server) return serverIndexKeyForProfile(server); + return profileOrIndexServerId; +} + +/** Host root for Rust `build_cover_art_url` (`{host}/rest/getCoverArt.view`). */ +export function coverCacheRestHost(serverUrl: string): string { + return restBaseFromUrl(serverUrl).replace(/\/rest$/i, ''); +} + +export type CoverCacheEnsureResult = { + hit: boolean; + path: string; + tier: CoverArtTier; +}; + +export type CoverCacheStats = { + bytes: number; + count: number; + pressure: 'ok' | 'pressure' | 'full'; + autoDownloadEnabled: boolean; + entryCount: number; +}; + +let coverAutoDownloadEnabled = true; + +export function setCoverCacheAutoDownloadEnabled(enabled: boolean): void { + coverAutoDownloadEnabled = enabled; +} + +function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier) { + const { getBaseUrl, getActiveServer } = useAuthStore.getState(); + const scope = ref.serverScope; + if (scope.kind === 'server') { + return { + serverIndexKey: coverIndexKeyFromRef(ref), + coverArtId: ref.coverArtId, + tier, + restBaseUrl: coverCacheRestHost(scope.url), + username: scope.username, + password: scope.password, + }; + } + const server = + scope.kind === 'playback' + ? (() => { + const playbackServerId = getPlaybackServerId(); + if (playbackServerId) { + const playbackServer = useAuthStore + .getState() + .servers.find(s => s.id === playbackServerId); + if (playbackServer) return playbackServer; + } + return getActiveServer(); + })() + : getActiveServer(); + const baseUrl = server?.url || getBaseUrl(); + return { + serverIndexKey: coverIndexKeyFromRef(ref), + coverArtId: ref.coverArtId, + tier, + restBaseUrl: baseUrl ? coverCacheRestHost(baseUrl) : '', + username: server?.username ?? '', + password: server?.password ?? '', + }; +} + +export type CoverCachePeekItem = { + serverIndexKey: string; + coverArtId: string; + tier: CoverArtTier; +}; + +/** Disk-only — no HTTP. Returns map storageKey → absolute .webp path. */ +export async function coverCachePeekBatch( + items: CoverCachePeekItem[], +): Promise> { + if (items.length === 0) return {}; + const raw = await invoke>('cover_cache_peek_batch', { items }); + const out: Record = {}; + for (const item of items) { + const key = `${item.serverIndexKey}:cover:${item.coverArtId}:${item.tier}`; + if (raw[key]) out[key] = raw[key]; + } + return out; +} + +export async function coverCacheEnsure( + ref: CoverArtRef, + tier: CoverArtTier, + _priority?: string, +): Promise { + return invoke('cover_cache_ensure', ensureArgsFromRef(ref, tier)); +} + +export async function coverCacheEnsureBatch( + refs: CoverArtRef[], + tier: CoverArtTier, + _priority?: string, +): Promise { + if (refs.length === 0) return; + const items = refs.map(ref => ensureArgsFromRef(ref, tier)); + await invoke('cover_cache_ensure_batch', { items }); +} + +export async function coverCacheStats(): Promise { + const stats = await invoke('cover_cache_stats', {}); + setCoverCacheAutoDownloadEnabled(stats.autoDownloadEnabled); + return stats; +} + +/** Clears all servers (legacy). Prefer `coverCacheClearServer`. */ +export async function coverCacheClear(): Promise { + return invoke('cover_cache_clear', {}); +} + +export async function coverCacheClearServer(serverIndexKey: string): Promise { + return invoke('cover_cache_clear_server', { serverIndexKey }); +} + +export async function coverCacheStatsServer( + serverIndexKey: string, +): Promise> { + const stats = await invoke('cover_cache_stats_server', { serverIndexKey }); + return { bytes: stats.bytes, entryCount: stats.entryCount }; +} + +export async function libraryCoverBackfillBatch( + serverIndexKey: string, + libraryServerId: string, + cursor?: string | null, + limit?: number, +): Promise<{ coverIds: string[]; nextCursor: string | null; exhausted: boolean }> { + const sqlServerId = librarySqlServerId(libraryServerId); + const diskKey = serverIndexKey || sqlServerId; + return invoke('library_cover_backfill_batch', { + serverIndexKey: diskKey, + libraryServerId: sqlServerId, + cursor, + limit, + }); +} + +export async function libraryCoverProgress( + serverIndexKey: string, + libraryServerId: string, +): Promise<{ totalDistinct: number; pending: number; done: number }> { + const sqlServerId = librarySqlServerId(libraryServerId); + const diskKey = serverIndexKey || sqlServerId; + return invoke('library_cover_progress', { + serverIndexKey: diskKey, + libraryServerId: sqlServerId, + }); +} + +export type LibraryCoverBackfillConfigureArgs = { + enabled: boolean; + serverIndexKey: string; + libraryServerId: string; + restBaseUrl: string; + username: string; + password: string; +}; + +export async function libraryCoverBackfillConfigure( + args: LibraryCoverBackfillConfigureArgs, +): Promise { + return invoke('library_cover_backfill_configure', args); +} + +export type CoverBackfillPulseResult = { + scheduled: number; + exhausted: boolean; + pending: number; + done: number; + total: number; + status: 'idle' | 'active' | 'blocked_sync' | 'blocked_pressure' | 'disabled' | string; +}; + +/** One backfill step (legacy); prefer `libraryCoverBackfillRunFullPass`. */ +export async function libraryCoverBackfillPulse(): Promise { + return invoke('library_cover_backfill_pulse'); +} + +/** Start one full-catalog pass on the native runtime (works when the window is inactive). */ +export async function libraryCoverBackfillRunFullPass(): Promise<{ started: boolean }> { + return invoke<{ started: boolean }>('library_cover_backfill_run_full_pass'); +} + +export async function libraryCoverBackfillResetCursor(): Promise { + return invoke('library_cover_backfill_reset_cursor'); +} + +/** Yield native library backfill while the user navigates (visible covers first). */ +export async function libraryCoverBackfillSetUiPriority(hold: boolean): Promise { + return invoke('library_cover_backfill_set_ui_priority', { hold }); +} + +export async function libraryCoverClearFetchFailures(serverIndexKey: string): Promise { + return invoke('library_cover_clear_fetch_failures', { serverIndexKey }); +} + +export async function libraryCoverCatalogSize(libraryServerId: string): Promise { + return invoke('library_cover_catalog_size', { + libraryServerId: librarySqlServerId(libraryServerId), + }); +} + +export function coverCacheMayBackgroundDownload(): boolean { + return coverAutoDownloadEnabled; +} diff --git a/src/api/subsonic.contract.test.ts b/src/api/subsonic.contract.test.ts index 065d170c..8901d184 100644 --- a/src/api/subsonic.contract.test.ts +++ b/src/api/subsonic.contract.test.ts @@ -128,10 +128,10 @@ describe('getClient', () => { }); describe('coverArtCacheKey', () => { - it('uses serverId + entity id + size as a stable cache key', () => { - const id = setUpServer(); - expect(coverArtCacheKey('cover-1')).toBe(`${id}:cover:cover-1:256`); - expect(coverArtCacheKey('cover-1', 200)).toBe(`${id}:cover:cover-1:200`); + it('uses host index key + entity id + tier as a stable cache key', () => { + setUpServer(); + expect(coverArtCacheKey('cover-1')).toBe('music.example.com:cover:cover-1:256'); + expect(coverArtCacheKey('cover-1', 200)).toBe('music.example.com:cover:cover-1:200'); }); it('falls back to "_" as the server-id segment when no server is active', () => { @@ -212,8 +212,9 @@ describe('buildCoverArtUrlForServer', () => { }); describe('coverArtCacheKeyForServer', () => { - it('scopes cache keys by server id', () => { - expect(coverArtCacheKeyForServer('srv-b', 'cover-1', 80)).toBe('srv-b:cover:cover-1:80'); + it('scopes cache keys by host index key when profile is known', () => { + const profileId = setUpServer({ url: 'https://b.example' }); + expect(coverArtCacheKeyForServer(profileId, 'cover-1', 80)).toBe('b.example:cover:cover-1:80'); }); }); diff --git a/src/api/subsonicStreamUrl.ts b/src/api/subsonicStreamUrl.ts index a4268e6c..2168b9c9 100644 --- a/src/api/subsonicStreamUrl.ts +++ b/src/api/subsonicStreamUrl.ts @@ -1,4 +1,6 @@ import md5 from 'md5'; +import { coverStorageKey } from '../cover/storageKeys'; +import type { CoverArtTier } from '../cover/types'; import { useAuthStore } from '../store/authStore'; import { findServerByIdOrIndexKey } from '../utils/server/serverLookup'; import { restBaseFromUrl, SUBSONIC_CLIENT, secureRandomSalt } from './subsonicClient'; @@ -53,16 +55,29 @@ export function buildStreamUrl(id: string): string { return streamUrlFromProfile(server.url, server.username, server.password, id); } -/** Stable cache key for cover art — does not include ephemeral auth params. */ +/** @deprecated Use `coverStorageKey` from `src/cover/storageKeys` — shim until migration. */ export function coverArtCacheKey(id: string, size = 256): string { - const server = useAuthStore.getState().getActiveServer(); - return coverArtCacheKeyForServer(server?.id ?? '_', id, size); + return coverStorageKey({ kind: 'active' }, id, size as CoverArtTier); } -export function coverArtCacheKeyForServer(serverId: string, id: string, size = 256): string { - return `${serverId}:cover:${id}:${size}`; +/** @deprecated Use `coverStorageKey` from `src/cover/storageKeys` — shim until migration. */ +export function coverArtCacheKeyForServer(serverIdOrKey: string, id: string, size = 256): string { + const server = findServerByIdOrIndexKey(serverIdOrKey); + if (!server) return `${serverIdOrKey}:cover:${id}:${size}`; + return coverStorageKey( + { + kind: 'server', + serverId: server.id, + url: server.url, + username: server.username, + password: server.password, + }, + id, + size as CoverArtTier, + ); } +/** @deprecated Use `buildCoverArtFetchUrl` from `src/cover/fetchUrl` — shim until migration. */ export function buildCoverArtUrl(id: string, size = 256): string { const { getBaseUrl, getActiveServer } = useAuthStore.getState(); const server = getActiveServer(); @@ -71,7 +86,7 @@ export function buildCoverArtUrl(id: string, size = 256): string { return `${baseUrl}/rest/getCoverArt.view?${p.toString()}`; } -/** Cover art for a specific saved server (e.g. share-search preview on a non-active server). */ +/** @deprecated Use `buildCoverArtFetchUrl` from `src/cover/fetchUrl` — shim until migration. */ export function buildCoverArtUrlForServer( serverUrl: string, username: string, diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 85738289..42d48c59 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -46,6 +46,8 @@ import { useQueueResizer } from '../hooks/useQueueResizer'; import { useGlobalDndAndSelectionBlockers } from '../hooks/useGlobalDndAndSelectionBlockers'; import { useAppActivityTracking } from '../hooks/useAppActivityTracking'; import { useMainScrollingIndicator } from '../hooks/useMainScrollingIndicator'; +import { useCoverNavigationPriority } from '../hooks/useCoverNavigationPriority'; +import { useNowPlayingPrewarm } from '../hooks/useNowPlayingPrewarm'; import { useOfflineAutoNav } from '../hooks/useOfflineAutoNav'; import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam'; import { IS_LINUX } from '../utils/platform'; @@ -96,6 +98,8 @@ export function AppShell() { const { status: connStatus, isRetrying: connRetrying, retry: connRetry, isLan, serverName } = useConnectionStatus(); const navigate = useNavigate(); const location = useLocation(); + useCoverNavigationPriority(); + useNowPlayingPrewarm(); const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar); const offlineAlbums = useOfflineStore(s => s.albums); const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums); diff --git a/src/app/MainApp.tsx b/src/app/MainApp.tsx index e44b1963..51014eff 100644 --- a/src/app/MainApp.tsx +++ b/src/app/MainApp.tsx @@ -18,6 +18,10 @@ import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigrat import { bootstrapAllIndexedServers } from '../utils/library/librarySession'; import { hydrateQueueFromIndex } from '../utils/library/queueRestore'; import { useLibraryAnalysisBackfill } from '../hooks/useLibraryAnalysisBackfill'; +import { useCoverArtPrefetch } from '../cover/useCoverArtPrefetch'; +import { useLibraryCoverBackfill } from '../hooks/useLibraryCoverBackfill'; +import { useCoverRevalidateScheduler } from '../cover/useCoverRevalidateScheduler'; +import { runCoverIdbUpgradeMigration } from '../utils/migrations/coverIdbUpgradeMigration'; import { useMigrationOrchestrator } from '../hooks/useMigrationOrchestrator'; import { IS_WINDOWS } from '../utils/platform'; import TauriEventBridge from './TauriEventBridge'; @@ -61,6 +65,14 @@ export default function MainApp() { }, [activeServerId, serverIdsKey, masterEnabled, migrationReady]); useLibraryAnalysisBackfill(migrationReady); + useCoverArtPrefetch(migrationReady); + useLibraryCoverBackfill(migrationReady); + useCoverRevalidateScheduler(migrationReady); + + useEffect(() => { + if (!migrationReady) return; + void runCoverIdbUpgradeMigration(); + }, [migrationReady]); // Push playback state to mini window + handle control events. useEffect(() => { diff --git a/src/app/TauriEventBridge.tsx b/src/app/TauriEventBridge.tsx index 65307c98..f376cd7c 100644 --- a/src/app/TauriEventBridge.tsx +++ b/src/app/TauriEventBridge.tsx @@ -8,6 +8,7 @@ import { useInAppKeybindings } from '../hooks/tauriBridge/useInAppKeybindings'; import { useMediaAndWindowBridge } from '../hooks/tauriBridge/useMediaAndWindowBridge'; import { usePlayerSnapshotPublisher } from '../hooks/tauriBridge/usePlayerSnapshotPublisher'; import { useLibraryDevSyncLog } from '../hooks/tauriBridge/useLibraryDevSyncLog'; +import { useCoverArtBridge } from '../hooks/tauriBridge/useCoverArtBridge'; /** * Single mount point for everything that bridges Rust ↔ React in the main @@ -35,6 +36,7 @@ export function TauriEventBridge() { useMediaAndWindowBridge(navigate); usePlayerSnapshotPublisher(); useLibraryDevSyncLog(); + useCoverArtBridge(); return null; } diff --git a/src/components/AlbumCard.tsx b/src/components/AlbumCard.tsx index 01ba050f..a3ad75af 100644 --- a/src/components/AlbumCard.tsx +++ b/src/components/AlbumCard.tsx @@ -1,4 +1,3 @@ -import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl'; import { getAlbum } from '../api/subsonicLibrary'; import type { SubsonicAlbum } from '../api/subsonicTypes'; import { songToTrack } from '../utils/playback/songToTrack'; @@ -9,7 +8,12 @@ import { useTranslation } from 'react-i18next'; import { usePlayerStore } from '../store/playerStore'; import { useOfflineStore } from '../store/offlineStore'; import { useAuthStore } from '../store/authStore'; -import CachedImage from './CachedImage'; +import { CoverArtImage } from '../cover/CoverArtImage'; +import type { CoverPrefetchPriority } from '../cover/types'; +import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes'; +import { coverStorageKey } from '../cover/storageKeys'; +import { resolveCoverDisplayTier } from '../cover/tiers'; +import { acquireUrl } from '../utils/imageCache/urlPool'; import { OpenArtistRefInline } from './OpenArtistRefInline'; import { playAlbum } from '../utils/playback/playAlbum'; import { useDragDrop } from '../contexts/DragDropContext'; @@ -24,7 +28,14 @@ interface AlbumCardProps { showRating?: boolean; selectedAlbums?: SubsonicAlbum[]; disableArtwork?: boolean; + /** Layout-native cover square width in CSS px (from parent grid). */ + displayCssPx?: number; + /** @deprecated Use displayCssPx — kept for call-site transition only */ artworkSize?: number; + /** In-page scroll viewport (`VirtualCardGrid` `scrollRootId`) for cover IO priority. */ + observeScrollRootId?: string; + /** `high` for bounded grids (Random Albums, …) — skip defer-until-visible. */ + ensurePriority?: CoverPrefetchPriority; } function AlbumCard({ @@ -35,7 +46,10 @@ function AlbumCard({ showRating = false, selectedAlbums = [], disableArtwork = false, - artworkSize = 300, + displayCssPx = COVER_DENSE_GRID_MIN_CELL_CSS_PX, + artworkSize: _artworkSize, + observeScrollRootId, + ensurePriority, }: AlbumCardProps) { const { t } = useTranslation(); const navigate = useNavigate(); @@ -47,16 +61,12 @@ function AlbumCard({ if (!meta || meta.trackIds.length === 0) return false; return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]); }); - // buildCoverArtUrl emits a salted URL; memoize to avoid churn on rerenders. - const coverUrl = useMemo( - () => (album.coverArt ? buildCoverArtUrl(album.coverArt, artworkSize) : ''), - [album.coverArt, artworkSize], - ); - const coverCacheKey = useMemo( - () => (album.coverArt ? coverArtCacheKey(album.coverArt, artworkSize) : ''), - [album.coverArt, artworkSize], - ); const psyDrag = useDragDrop(); + const dragCoverKey = useMemo(() => { + if (!album.coverArt) return ''; + const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' }); + return coverStorageKey({ kind: 'active' }, album.coverArt, tier); + }, [album.coverArt, displayCssPx]); const isNewAlbum = isAlbumRecentlyAdded(album.created); const artistRefs = useMemo(() => deriveAlbumArtistRefs(album), [album]); @@ -89,7 +99,8 @@ function AlbumCard({ if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); - psyDrag.startDrag({ data: JSON.stringify({ type: 'album', id: album.id, name: album.name }), label: album.name, coverUrl: coverUrl || undefined }, me.clientX, me.clientY); + const coverUrl = dragCoverKey ? acquireUrl(dragCoverKey) ?? undefined : undefined; + psyDrag.startDrag({ data: JSON.stringify({ type: 'album', id: album.id, name: album.name }), label: album.name, coverUrl }, me.clientX, me.clientY); } }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; @@ -98,13 +109,16 @@ function AlbumCard({ }} >
- {!disableArtwork && coverUrl ? ( - ) : (
diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx index 22a9fb28..56d6ecd2 100644 --- a/src/components/AlbumHeader.tsx +++ b/src/components/AlbumHeader.tsx @@ -1,11 +1,11 @@ -import { buildCoverArtUrl } from '../api/subsonicStreamUrl'; import type { EntityRatingSupportLevel, SubsonicOpenArtistRef, SubsonicSong } from '../api/subsonicTypes'; import React, { useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { useNavigate } from 'react-router-dom'; import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Share2, Highlighter, Loader2, Shuffle } from 'lucide-react'; -import CachedImage from './CachedImage'; -import CoverLightbox from './CoverLightbox'; +import { CoverArtImage } from '../cover/CoverArtImage'; +import { coverArtRef } from '../cover/ref'; +import { useCoverLightboxSrc } from '../cover/lightbox'; import { useTranslation } from 'react-i18next'; import { useIsMobile } from '../hooks/useIsMobile'; import { useThemeStore } from '../store/themeStore'; @@ -68,8 +68,7 @@ interface AlbumHeaderProps { /** OpenSubsonic album credits (derived from album + songs). */ headerArtistRefs: SubsonicOpenArtistRef[]; songs: SubsonicSong[]; - coverUrl: string; - coverKey: string; + coverArtId?: string; resolvedCoverUrl: string | null; isStarred: boolean; downloadProgress: number | null; @@ -96,8 +95,7 @@ export default function AlbumHeader({ info, headerArtistRefs, songs, - coverUrl, - coverKey, + coverArtId, resolvedCoverUrl, isStarred, downloadProgress, @@ -122,7 +120,14 @@ export default function AlbumHeader({ const navigate = useNavigate(); const isMobile = useIsMobile(); const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground); - const [lightboxOpen, setLightboxOpen] = useState(false); + + const coverRef = useMemo( + () => (coverArtId ? coverArtRef(coverArtId) : null), + [coverArtId], + ); + const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverRef, { + alt: `${info.name} Cover`, + }); const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0); const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0); @@ -130,11 +135,6 @@ export default function AlbumHeader({ const isNewAlbum = isAlbumRecentlyAdded(info.created); const showBioButton = !isVariousArtistsLabel(info.artist); - const lightboxCoverSrc = useMemo( - () => (info.coverArt ? buildCoverArtUrl(info.coverArt, 2000) : ''), - [info.coverArt], - ); - const handleShareAlbum = async () => { try { const ok = await copyEntityShareLink('album', info.id); @@ -148,13 +148,7 @@ export default function AlbumHeader({ return ( <> {bioOpen && bio && } - {lightboxOpen && info.coverArt && ( - setLightboxOpen(false)} - /> - )} + {lightbox}
{resolvedCoverUrl && enableCoverArtBackground && ( @@ -173,14 +167,20 @@ export default function AlbumHeader({ {t('albumDetail.back')}
- {coverUrl ? ( + {coverArtId ? ( ) : (
diff --git a/src/components/ArtistCardLocal.tsx b/src/components/ArtistCardLocal.tsx index 61d62e52..33a3bc4e 100644 --- a/src/components/ArtistCardLocal.tsx +++ b/src/components/ArtistCardLocal.tsx @@ -1,10 +1,11 @@ -import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl'; import type { SubsonicArtist } from '../api/subsonicTypes'; -import React, { useMemo } from 'react'; +import React from 'react'; import { useNavigate } from 'react-router-dom'; import { Users } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import CachedImage from './CachedImage'; +import { CoverArtImage } from '../cover/CoverArtImage'; +import { coverArtIdFromArtist } from '../cover/ids'; +import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes'; interface Props { artist: SubsonicArtist; @@ -13,19 +14,16 @@ interface Props { export default function ArtistCardLocal({ artist }: Props) { const { t } = useTranslation(); const navigate = useNavigate(); - const coverId = artist.coverArt || artist.id; - // buildCoverArtUrl generates a new crypto salt on every call — must be - // memoized to prevent a new URL on every parent re-render causing refetch loops. - const coverSrc = useMemo(() => buildCoverArtUrl(coverId, 300), [coverId]); - const coverCacheKey = useMemo(() => coverArtCacheKey(coverId, 300), [coverId]); + const coverId = coverArtIdFromArtist(artist); return (
navigate(`/artist/${artist.id}`)}>
- {coverId ? ( - { diff --git a/src/components/BecauseYouLikeRail.tsx b/src/components/BecauseYouLikeRail.tsx index c9ea50e2..7eca4f4d 100644 --- a/src/components/BecauseYouLikeRail.tsx +++ b/src/components/BecauseYouLikeRail.tsx @@ -1,14 +1,22 @@ -import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl'; import { getArtist, getArtistInfo } from '../api/subsonicArtists'; import { getAlbum } from '../api/subsonicLibrary'; import type { SubsonicAlbum } from '../api/subsonicTypes'; import { songToTrack } from '../utils/playback/songToTrack'; import { shuffleArray } from '../utils/playback/shuffleArray'; -import React, { memo, useEffect, useMemo, useRef, useState } from 'react'; +import React, { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Play, ListPlus, Music } from 'lucide-react'; -import CachedImage, { useCachedUrl } from './CachedImage'; +import { coverArtRef } from '../cover/ref'; +import { coverPrefetchRegister } from '../cover/prefetchRegistry'; +import { coverImgSrc } from '../cover/imgSrc'; +import { useCoverArt } from '../cover/useCoverArt'; +import { primeAlbumCoversForDisplay } from '../cover/warmDiskPeek'; +import { + readBecauseYouLikeCache, + writeBecauseYouLikeCache, + type BecauseYouLikeAnchor, +} from '../store/becauseYouLikeCache'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import { playAlbum } from '../utils/playback/playAlbum'; @@ -39,11 +47,229 @@ const SIMILAR_FETCH = 25; const SIMILAR_PICK = 6; const SHOW_COUNT = 3; const PICKS_HISTORY_SIZE = 30; -const COVER_SIZE = 300; +/** `.because-card-cover-wrap` layout square (160×160). */ +const BECAUSE_CARD_COVER_CSS_PX = 160; +const ROW_STAGGER_MS = 150; -interface Anchor { - id: string; - name: string; +// ── Module-level reserve: next batch pre-fetched in background after each display ── +type BecauseReserve = { + serverId: string; + // poolKey intentionally omitted — reserve is valid for any pool state on the + // same server. Pool (top-played artists) changes slowly; showing a slightly-off + // anchor once before the next fill corrects it is far better than showing a + // skeleton because the pool hadn't loaded yet. + anchor: BecauseYouLikeAnchor; + recs: SubsonicAlbum[]; + /** Rotation state to commit to localStorage when this reserve is consumed. */ + nextAnchorHistory: string[]; + nextPicksHistory: string[]; +}; +let _becauseReserve: BecauseReserve | null = null; +let _becauseReserveFilling = false; + +/** Helper: read a JSON string[] from localStorage, returning [] on any failure. */ +function readJsonArray(key: string | null): string[] { + if (!key) return []; + try { + const raw = localStorage.getItem(key); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []; + } catch { + return []; + } +} + +/** Resolve a set of album picks for one anchor candidate. */ +async function resolvePicks( + candidate: BecauseYouLikeAnchor, + recentPicks: Set, +): Promise { + const info = await getArtistInfo(candidate.id, { similarArtistCount: SIMILAR_FETCH }); + const similar = (info.similarArtist ?? []).filter(s => s.id); + if (similar.length === 0) return null; + + const sampled = shuffleArray(similar).slice(0, SIMILAR_PICK); + const results = await Promise.all(sampled.map(s => getArtist(s.id).catch(() => null))); + + const picks: SubsonicAlbum[] = []; + for (const r of results) { + if (!r || r.albums.length === 0) continue; + const fresh = r.albums.filter(a => !recentPicks.has(a.id)); + const choice = fresh.length > 0 ? fresh : r.albums; + const album = choice[Math.floor(Math.random() * choice.length)]; + picks.push(album); + if (picks.length >= SHOW_COUNT) break; + } + return picks.length > 0 ? picks : null; +} + +type FetchBecauseResult = { + anchor: BecauseYouLikeAnchor; + recs: SubsonicAlbum[]; + nextAnchorHistory: string[]; + nextPicksHistory: string[]; +}; + +/** + * Core fetch: rotate anchor, call Last.fm / Subsonic, return result + updated + * rotation snapshots. Does NOT touch React state or localStorage — callers do that. + * Reads the CURRENT localStorage values so it always reflects the latest rotation. + */ +async function fetchBecauseYouLike( + pool: BecauseYouLikeAnchor[], + anchorHistKey: string | null, + picksHistKey: string | null, +): Promise { + const anchorHistory = readJsonArray(anchorHistKey); + const picksHistory = readJsonArray(picksHistKey); + + const cooldown = Math.min(ANCHOR_COOLDOWN, Math.max(0, Math.floor(pool.length / 2))); + const recentAnchors = new Set(anchorHistory.slice(-cooldown)); + const eligibleRaw = pool.filter(a => !recentAnchors.has(a.id)); + const eligible = eligibleRaw.length > 0 ? eligibleRaw : pool.slice(); + const candidates = shuffleArray(eligible); + const recentPicks = new Set(picksHistory); + + const tries = Math.min(ANCHOR_MAX_TRIES, candidates.length); + const tryList = candidates.slice(0, tries); + + const buildResult = (candidate: BecauseYouLikeAnchor, picks: SubsonicAlbum[]): FetchBecauseResult => ({ + anchor: candidate, + recs: picks, + nextAnchorHistory: [...anchorHistory, candidate.id].slice(-ANCHOR_COOLDOWN), + nextPicksHistory: [...picksHistory, ...picks.map(p => p.id)].slice(-PICKS_HISTORY_SIZE), + }); + + /** First two shuffled anchors in parallel — cuts cold-start wait on slow Last.fm. */ + if (tryList.length >= 2) { + const raced = await Promise.all( + tryList.slice(0, 2).map(async candidate => { + try { + const picks = await resolvePicks(candidate, recentPicks); + return picks ? { candidate, picks } : null; + } catch { + return null; + } + }), + ); + const hit = raced.find((r): r is { candidate: BecauseYouLikeAnchor; picks: SubsonicAlbum[] } => r != null); + if (hit) return buildResult(hit.candidate, hit.picks); + } + + for (const candidate of tryList) { + try { + const picks = await resolvePicks(candidate, recentPicks); + if (!picks) continue; + return buildResult(candidate, picks); + } catch { + /* try next anchor */ + } + } + + return null; +} + +/** + * Fire-and-forget: fetch the next batch in the background so the next visit is + * instant. localStorage rotation is NOT updated here — the snapshots are stored + * in the reserve and applied only when the reserve is consumed. + * Covers are NOT pre-warmed here (avoids bumpDiskSrcCache side-effects on the + * currently-visible page); they are warmed via primeAlbumCoversForDisplay on consume. + */ +async function fillBecauseReserve( + pool: BecauseYouLikeAnchor[], + serverId: string, + anchorHistKey: string | null, + picksHistKey: string | null, +): Promise { + if (_becauseReserveFilling) return; + _becauseReserveFilling = true; + try { + const result = await fetchBecauseYouLike(pool, anchorHistKey, picksHistKey); + if (result) { + _becauseReserve = { serverId, ...result }; + // Also refresh the session snapshot so a quick leave→return can pick up + // newer cards even before the reserve is explicitly consumed. + writeBecauseYouLikeCache({ serverId, anchor: result.anchor, recs: result.recs }); + } + } catch { + /* Network failure — next visit falls back to a fresh fetch. */ + } finally { + _becauseReserveFilling = false; + } +} + +/** One classic because-card shell, then extra grid slots fill in. */ +function useBecauseRowSlotCount(active: boolean, max = SHOW_COUNT): number { + const [count, setCount] = useState(1); + + useEffect(() => { + if (!active) { + setCount(1); + return; + } + setCount(1); + const timers: ReturnType[] = []; + for (let slot = 2; slot <= max; slot += 1) { + timers.push(setTimeout(() => setCount(slot), ROW_STAGGER_MS * (slot - 1))); + } + return () => timers.forEach(clearTimeout); + }, [active, max]); + + return count; +} + +/** Lead placeholder — same shell as a loaded because-card (cover + text block). */ +function BecauseCardSkeletonLead() { + return ( +