From cf982a6ac273b51e71cb99965e39c2e9dbfe3f3d Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Mon, 6 Jul 2026 05:51:57 +0300 Subject: [PATCH] fix(offline): on-disk-only local browse for hot cache and pins (#1243) --- CHANGELOG.md | 8 + src/app/AppShell.tsx | 4 +- src/config/settingsCredits.ts | 1 + .../album/hooks/useAlbumBrowseData.ts | 44 +- .../artist/hooks/useArtistsBrowseCatalog.ts | 44 +- src/features/artist/pages/Artists.tsx | 4 +- src/features/artist/utils/artistsHelpers.ts | 79 +- src/features/genre/pages/Genres.tsx | 47 +- .../offline/utils/offlineLocalBrowse.test.ts | 720 +++++++++++++++++- .../offline/utils/offlineLocalBrowse.ts | 362 +++++++-- .../playback/genreBrowsePlayback.test.ts | 43 ++ .../utils/playback/genreBrowsePlayback.ts | 18 + .../search/hooks/useSongBrowseList.ts | 11 +- .../search/pages/SearchBrowsePage.tsx | 12 +- .../utils/tracksBrowseDiscoveryChrome.test.ts | 28 + .../utils/tracksBrowseDiscoveryChrome.ts | 10 + .../sidebar/components/MobileMoreOverlay.tsx | 4 +- src/features/sidebar/components/Sidebar.tsx | 4 +- .../hooks/useReactiveOfflineBrowseContext.ts | 27 + src/lib/library/albumGroupArtist.test.ts | 52 ++ src/lib/library/albumGroupArtist.ts | 45 ++ src/lib/library/artistLetterBucket.ts | 60 ++ .../localPlayback/browsablePlaybackTiers.ts | 13 + src/store/localPlaybackBrowseRevision.ts | 64 ++ .../offlineLocalLibrarySyncRevision.test.ts | 56 ++ src/store/offlineLocalLibrarySyncRevision.ts | 76 ++ 26 files changed, 1614 insertions(+), 222 deletions(-) create mode 100644 src/features/search/utils/tracksBrowseDiscoveryChrome.test.ts create mode 100644 src/features/search/utils/tracksBrowseDiscoveryChrome.ts create mode 100644 src/features/sidebar/hooks/useReactiveOfflineBrowseContext.ts create mode 100644 src/lib/library/albumGroupArtist.test.ts create mode 100644 src/lib/library/albumGroupArtist.ts create mode 100644 src/lib/library/artistLetterBucket.ts create mode 100644 src/lib/localPlayback/browsablePlaybackTiers.ts create mode 100644 src/store/localPlaybackBrowseRevision.ts create mode 100644 src/store/offlineLocalLibrarySyncRevision.test.ts create mode 100644 src/store/offlineLocalLibrarySyncRevision.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a9b6f060..b29b8238 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -124,6 +124,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * The Genres page and album browse genre filter no longer miss genres on large libraries when **All libraries** is selected — both now use the indexed `track_genre` SQL aggregate instead of sampling the first page of albums (regression from multi-library scope routing in PR #1241). +### Offline browse — on-disk-only Artists, Albums, Tracks, and Genres + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#1243](https://github.com/Psychotoxical/psysonic/pull/1243)** + +* When browsing offline, Artists, Albums, Tracks, and Genres now list only content with on-disk bytes — library pins, favorites-auto saves, and hot-cache playback — instead of the full server or local index catalog. +* Sidebar and shell gates react when hot-cache rows appear; browse pages reload after hot-cache growth and library sync without leaving the page. +* Album vs track artist credit mode, starred artists, genre filters, and Tracks discovery rails respect the on-disk scope; album artist grouping follows indexed `album_artist` parity. + ## [1.49.0] - 2026-06-29 diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 47df5050..9898ccba 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -37,7 +37,7 @@ import { useOrbitHost } from '@/features/orbit'; import { useOrbitGuest } from '@/features/orbit'; import { useOrbitBodyAttrs } from '@/features/orbit'; import { usePlatformShellSetup } from '@/app/hooks/usePlatformShellSetup'; -import { useOfflineBrowseContext } from '@/features/offline'; +import { useReactiveOfflineBrowseContext } from '@/features/sidebar/hooks/useReactiveOfflineBrowseContext'; import { offlineBrowseNavFlags } from '@/features/offline'; import { useWindowFullscreenState } from '@/app/hooks/useWindowFullscreenState'; import { useNowPlayingTrayTitle } from '@/app/hooks/useNowPlayingTrayTitle'; @@ -114,7 +114,7 @@ export function AppShell() { useLiveSearchRouteScope(); useNowPlayingPrewarm(); const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar); - const offlineCtx = useOfflineBrowseContext(); + const offlineCtx = useReactiveOfflineBrowseContext(); const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities); const hasOfflineBrowse = offlineCtx.hasBrowseCapability; const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar); diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 078dd030..a1eab82e 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -187,6 +187,7 @@ const CONTRIBUTOR_ENTRIES = [ 'CLI — relative volume via signed `volume` argument (+/− percent delta); suppress WebKit NVIDIA stderr notes on CLI argv; faster Linux CLI forward before WebKit init (PR #1238)', 'Multi-library filter — priority-ordered multi-select scope across browse/search/detail, sargable library_id + FTS-first SQL, rebuildable library-cluster.db identity keys, locale-aware name normalization (PR #1241)', 'Genres — full catalog via indexed SQL when All libraries is selected; no longer samples first album page on large libraries (PR #1242)', + 'Offline browse — on-disk-only Artists/Albums/Tracks/Genres (pins, favorites-auto, hot-cache); reactive sidebar gates and sync-idle reload; local credit mode and genre scope (PR #1243)', ], }, { diff --git a/src/features/album/hooks/useAlbumBrowseData.ts b/src/features/album/hooks/useAlbumBrowseData.ts index a5153119..d94d0111 100644 --- a/src/features/album/hooks/useAlbumBrowseData.ts +++ b/src/features/album/hooks/useAlbumBrowseData.ts @@ -32,13 +32,18 @@ import { ALBUM_YEAR_FILTER_DEBOUNCE_MS, resolveAlbumYearBounds, } from '@/lib/library/albumYearFilter'; -import { loadOfflineAlbumBrowseInitial } from '@/features/offline'; -import { useOfflineBrowseReloadToken } from '@/features/offline'; +import { + fetchOfflineLocalAlbumGenreOptions, + loadOfflineAlbumBrowseInitial, + offlineLocalBrowseEnabled, + useOfflineBrowseContext, + useOfflineBrowseReloadToken, +} from '@/features/offline'; +import { useOfflineLocalBrowseReloadKey } from '@/store/localPlaybackBrowseRevision'; import { fetchAlbumBrowseCatalogChunk, mergeAlbumCatalogChunk, } from '@/features/album/utils/albumBrowseCatalogChunk'; -import { useOfflineBrowseContext } from '@/features/offline'; import { useClientSliceInfiniteScroll } from '@/lib/hooks/useClientSliceInfiniteScroll'; import { useDebouncedValue } from '@/lib/hooks/useDebouncedValue'; import { useInpageScrollSentinel } from '@/lib/hooks/useInpageScrollSentinel'; @@ -116,6 +121,10 @@ export function useAlbumBrowseData({ }: UseAlbumBrowseDataArgs) { const offlineBrowseActive = useOfflineBrowseContext().active; const offlineBrowseReloadTs = useOfflineBrowseReloadToken(); + const offlineLocalBrowseReloadKey = useOfflineLocalBrowseReloadKey( + serverId, + offlineBrowseActive, + ); const [albums, setAlbums] = useState([]); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); @@ -153,13 +162,17 @@ export function useAlbumBrowseData({ }), [sort, yearFilterActive, yearFilterBounds, losslessOnly, starredOnly, compFilter]); const catalogLoadKey = useMemo( - () => albumBrowseInitialLoadKey( - serverId, - musicLibraryFilterVersion, - browseQuery, - offlineBrowseActive, - ), - [serverId, musicLibraryFilterVersion, browseQuery, offlineBrowseActive], + () => { + const base = albumBrowseInitialLoadKey( + serverId, + musicLibraryFilterVersion, + browseQuery, + offlineBrowseActive, + ); + if (!offlineBrowseActive) return base; + return `${base}\0${offlineLocalBrowseReloadKey}`; + }, + [serverId, musicLibraryFilterVersion, browseQuery, offlineBrowseActive, offlineLocalBrowseReloadKey], ); const compFilterActive = compFilter !== 'all'; @@ -203,7 +216,10 @@ export function useAlbumBrowseData({ const libraryScopeActive = libraryScopeIsActive(serverId); const narrowGenreList = yearFilterActive || losslessOnly || starredOnly || compFilterActive; /** When true, GenreFilterBar uses `genreCatalogOptions` instead of server `getGenres()`. */ - const genreCatalogActive = narrowGenreList || (indexEnabled && libraryScopeActive); + const genreCatalogActive = + narrowGenreList + || (indexEnabled && libraryScopeActive) + || (offlineBrowseActive && !!serverId && offlineLocalBrowseEnabled(serverId)); const compScanExhausted = useMemo( () => compFilterClientOnly && !genreFiltered @@ -621,7 +637,9 @@ export function useAlbumBrowseData({ let cancelled = false; void albumBrowseTimed( 'genre_options', - () => fetchAlbumBrowseGenreOptions(serverId, indexEnabled, browseQueryWithoutGenre), + () => offlineBrowseActive && serverId && offlineLocalBrowseEnabled(serverId) + ? fetchOfflineLocalAlbumGenreOptions(serverId, browseQueryWithoutGenre, starredOverrides) + : fetchAlbumBrowseGenreOptions(serverId, indexEnabled, browseQueryWithoutGenre), ).then(options => { if (!cancelled) { setGenreCatalogOptions(options); @@ -638,6 +656,8 @@ export function useAlbumBrowseData({ indexEnabled, browseQueryWithoutGenre, musicLibraryFilterVersion, + offlineBrowseActive, + starredOverrides, ]); const loadMorePage = useCallback(() => { diff --git a/src/features/artist/hooks/useArtistsBrowseCatalog.ts b/src/features/artist/hooks/useArtistsBrowseCatalog.ts index 8abc9e5d..617d1cf9 100644 --- a/src/features/artist/hooks/useArtistsBrowseCatalog.ts +++ b/src/features/artist/hooks/useArtistsBrowseCatalog.ts @@ -9,8 +9,8 @@ import { fetchNetworkArtistCatalog, fetchStarredArtistsForBrowse, } from '@/features/artist/utils/artistBrowseCreditMode'; -import { useOfflineBrowseContext } from '@/features/offline'; -import { useOfflineBrowseReloadToken } from '@/features/offline'; +import { useOfflineBrowseContext, useOfflineBrowseReloadToken } from '@/features/offline'; +import { useOfflineLocalBrowseReloadKey } from '@/store/localPlaybackBrowseRevision'; import { fetchOfflineLocalArtistCatalogChunk, fetchOfflineLocalStarredArtists, @@ -46,6 +46,8 @@ export type UseArtistsBrowseCatalogArgs = { letterFilter: string; musicLibraryFilterVersion: number; libraryScopeKey: string; + /** Server `ignoredArticles` for offline letter buckets (Navidrome parity). */ + ignoredArticles?: string | null; }; export function useArtistsBrowseCatalog({ @@ -56,9 +58,14 @@ export function useArtistsBrowseCatalog({ letterFilter, musicLibraryFilterVersion, libraryScopeKey, + ignoredArticles, }: UseArtistsBrowseCatalogArgs) { const offlineBrowseActive = useOfflineBrowseContext().active; const offlineBrowseReloadTs = useOfflineBrowseReloadToken(); + const offlineLocalBrowseReloadKey = useOfflineLocalBrowseReloadKey( + serverId, + offlineBrowseActive, + ); const [catalogArtists, setCatalogArtists] = useState([]); const [loading, setLoading] = useState(true); const [catalogHasMore, setCatalogHasMore] = useState(false); @@ -71,7 +78,7 @@ export function useArtistsBrowseCatalog({ const catalogLoadKey = useMemo(() => { if (!serverId) return ''; - return artistBrowseInitialLoadKey( + const base = artistBrowseInitialLoadKey( serverId, musicLibraryFilterVersion, libraryScopeKey, @@ -80,7 +87,9 @@ export function useArtistsBrowseCatalog({ starredOnly, offlineBrowseActive, ); - }, [serverId, musicLibraryFilterVersion, libraryScopeKey, creditMode, letterFilter, starredOnly, offlineBrowseActive]); + if (!offlineBrowseActive) return base; + return `${base}\0${offlineLocalBrowseReloadKey}`; + }, [serverId, musicLibraryFilterVersion, libraryScopeKey, creditMode, letterFilter, starredOnly, offlineBrowseActive, offlineLocalBrowseReloadKey]); useLayoutEffect(() => { const cached = readArtistBrowseCatalogCache(catalogLoadKey); @@ -117,6 +126,7 @@ export function useArtistsBrowseCatalog({ ARTIST_CATALOG_CHUNK_SIZE, creditMode, letterFilter, + ignoredArticles, ), { append, offset: catalogOffsetRef.current }, ); @@ -184,7 +194,7 @@ export function useArtistsBrowseCatalog({ setCatalogLoadingMore(false); } } - }, [creditMode, letterFilter, offlineBrowseActive, serverId]); + }, [creditMode, ignoredArticles, letterFilter, offlineBrowseActive, serverId]); useEffect(() => { let cancelled = false; @@ -238,21 +248,12 @@ export function useArtistsBrowseCatalog({ emitArtistsBrowseDebug('load_branch', { mode: 'offline' }); if (!cancelled && generation === loadGenerationRef.current) { if (serverId && starredOnly && offlineLocalBrowseEnabled(serverId)) { - try { - setCatalogArtists( - await artistBrowseTimed( - 'offline_starred', - () => fetchStarredArtistsForBrowse(creditMode, serverId, true), - ), - ); - } catch { - setCatalogArtists( - (await artistBrowseTimed( - 'offline_starred_fallback', - () => fetchOfflineLocalStarredArtists(serverId), - )) ?? [], - ); - } + setCatalogArtists( + (await artistBrowseTimed( + 'offline_starred', + () => fetchOfflineLocalStarredArtists(serverId, creditMode), + )) ?? [], + ); } else if (serverId && !starredOnly && offlineLocalBrowseEnabled(serverId)) { const first = await artistBrowseTimed( 'offline_catalog_initial', @@ -262,6 +263,7 @@ export function useArtistsBrowseCatalog({ ARTIST_CATALOG_CHUNK_SIZE, creditMode, letterFilter, + ignoredArticles, ), ); setCatalogArtists(first?.artists ?? []); @@ -447,7 +449,7 @@ export function useArtistsBrowseCatalog({ return () => { cancelled = true; }; - }, [catalogLoadKey, creditMode, letterFilter, musicLibraryFilterVersion, indexEnabled, offlineBrowseActive, offlineBrowseReloadTs, serverId, starredOnly]); + }, [catalogLoadKey, creditMode, ignoredArticles, letterFilter, musicLibraryFilterVersion, indexEnabled, offlineBrowseActive, offlineBrowseReloadTs, serverId, starredOnly]); return { catalogArtists, diff --git a/src/features/artist/pages/Artists.tsx b/src/features/artist/pages/Artists.tsx index 9b45bd5e..5ba2872b 100644 --- a/src/features/artist/pages/Artists.tsx +++ b/src/features/artist/pages/Artists.tsx @@ -110,6 +110,7 @@ export default function Artists() { const navigate = useNavigate(); const openContextMenu = usePlayerStore(state => state.openContextMenu); const setShowArtistImages = useAuthStore(s => s.setShowArtistImages); + const ignoredArticles = useLibraryIgnoredArticles(serverId, indexEnabled); const { catalogArtists, @@ -127,6 +128,7 @@ export default function Artists() { letterFilter, musicLibraryFilterVersion, libraryScopeKey, + ignoredArticles, }); const { textSearchArtists, textSearchLoading, effectiveFilter } = useBrowseArtistTextSearch( @@ -177,8 +179,6 @@ export default function Artists() { const selectedArtists = artists.filter(a => selectedIds.has(a.id)); - const ignoredArticles = useLibraryIgnoredArticles(serverId, indexEnabled); - const { filtered, visible, hasMore, groups, letters, artistListFlatRows, } = useArtistsFiltering({ artists, filter: effectiveFilter, letterFilter, starredOnly, visibleCount, viewMode, ignoredArticles }); diff --git a/src/features/artist/utils/artistsHelpers.ts b/src/features/artist/utils/artistsHelpers.ts index aeb02a72..c0f9c949 100644 --- a/src/features/artist/utils/artistsHelpers.ts +++ b/src/features/artist/utils/artistsHelpers.ts @@ -1,73 +1,28 @@ import type { SubsonicArtist } from '@/lib/api/subsonicTypes'; +import { + DEFAULT_IGNORED_ARTICLES, + OTHER_BUCKET, + artistBucketKey, + artistLetterBucket, + sortKeyFromDisplayName, + stripLeadingArticles, +} from '@/lib/library/artistLetterBucket'; + +export { + DEFAULT_IGNORED_ARTICLES, + OTHER_BUCKET, + artistBucketKey, + artistLetterBucket, + sortKeyFromDisplayName, + stripLeadingArticles, +}; export const ALL_SENTINEL = 'ALL'; -/** Catch-all bucket for names that start with neither an A–Z letter nor a digit - * (accented Latin like Æ/Ø/Å, and non-Latin scripts: CJK, Cyrillic, …). */ -export const OTHER_BUCKET = 'OTHER'; export const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''), OTHER_BUCKET]; -/** Navidrome default (`IgnoredArticles` when the server omits the field). */ -export const DEFAULT_IGNORED_ARTICLES = 'The El La Los Las Le Les Os As O A'; - /** Stable ordering index for a bucket key — '#' first, A–Z, then 'Other' last. */ const BUCKET_ORDER = new Map(ALPHABET.map((l, i) => [l, i])); -/** Strip leading articles for sort/bucket keys (Navidrome `RemoveArticle` parity). */ -export function stripLeadingArticles( - name: string, - ignoredArticles = DEFAULT_IGNORED_ARTICLES, -): string { - const trimmed = name.trim(); - for (const article of ignoredArticles.split(' ').filter(Boolean)) { - const prefix = `${article} `; - if ( - trimmed.length >= prefix.length - && trimmed.slice(0, prefix.length).toLowerCase() === prefix.toLowerCase() - ) { - return trimmed.slice(prefix.length).trimStart(); - } - } - return trimmed; -} - -/** Sort key from display name — article strip + lowercase (Navidrome parity). */ -export function sortKeyFromDisplayName( - displayName: string, - ignoredArticles?: string | null, -): string { - const articles = ignoredArticles?.trim() || DEFAULT_IGNORED_ARTICLES; - return stripLeadingArticles(displayName, articles).toLowerCase(); -} - -/** - * Bucket an artist name into the alphabet index (after article stripping): - * - `#` → starts with a digit (0–9) - * - `A`–`Z` → starts with an ASCII letter on the sort key - * - `OTHER` → anything else (accents, CJK, Cyrillic, symbols, empty) - * - * Buckets always derive from the display `name` + `ignoredArticles`, never the - * persisted `nameSort` (which can lag a renamed artist until the next reconcile). - */ -export function artistBucketKey( - name: string, - ignoredArticles?: string | null, -): string { - const sortKey = sortKeyFromDisplayName(name, ignoredArticles); - const first = sortKey?.[0]; - if (!first) return OTHER_BUCKET; - if (/^[0-9]$/.test(first)) return '#'; - const up = first.toUpperCase(); - return /^[A-Z]$/.test(up) ? up : OTHER_BUCKET; -} - -/** Letter bucket for a browse row — uses the server's `ignoredArticles` when known. */ -export function artistLetterBucket( - artist: SubsonicArtist, - ignoredArticles?: string | null, -): string { - return artistBucketKey(artist.name, ignoredArticles); -} - /** Sort comparator for bucket keys following ALPHABET order (unknown keys last). */ export function compareBuckets(a: string, b: string): number { return (BUCKET_ORDER.get(a) ?? 999) - (BUCKET_ORDER.get(b) ?? 999); diff --git a/src/features/genre/pages/Genres.tsx b/src/features/genre/pages/Genres.tsx index b55e1ed9..e87290d2 100644 --- a/src/features/genre/pages/Genres.tsx +++ b/src/features/genre/pages/Genres.tsx @@ -4,14 +4,16 @@ import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Tags } from 'lucide-react'; import { APP_MAIN_SCROLL_VIEWPORT_ID } from '@/constants/appScroll'; -import { subscribeLibrarySyncIdle } from '@/lib/api/library'; import { useAuthStore } from '@/store/authStore'; import { useLibraryIndexStore } from '@/store/libraryIndexStore'; import { fetchGenreCatalog, filterGenresWithContent } from '@/features/playback/utils/playback/genreBrowsePlayback'; import { libraryScopeCacheKeyForServer } from '@/lib/api/subsonicClient'; import { peekGenreCatalogCache } from '@/lib/library/genreCatalogCountsCache'; -import { resolveIndexKey } from '@/lib/server/serverIndexKey'; import { genreColor } from '@/lib/library/genreColor'; +import { useOfflineBrowseContext, offlineLocalBrowseEnabled } from '@/features/offline'; +import { useOfflineLocalBrowseReloadKey } from '@/store/localPlaybackBrowseRevision'; +import { useOfflineLocalLibrarySyncRevision } from '@/store/offlineLocalLibrarySyncRevision'; +import { useLocalPlaybackStore } from '@/store/localPlaybackStore'; const SCROLL_KEY = 'genres-scroll'; const FONT_MIN_REM = 0.78; @@ -24,14 +26,27 @@ export default function Genres() { const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const libraryScopeKey = libraryScopeCacheKeyForServer(serverId); - const cachedGenres = serverId ? peekGenreCatalogCache(serverId, libraryScopeKey, true) : null; + const offlineBrowseActive = useOfflineBrowseContext().active; + const localPlaybackEntries = useLocalPlaybackStore(s => s.entries); + const librarySyncRevision = useOfflineLocalLibrarySyncRevision(serverId || null); + const offlineLocalBrowseReloadKey = useOfflineLocalBrowseReloadKey( + serverId, + offlineBrowseActive, + ); + const skipGenreCatalogCache = offlineBrowseActive + && offlineLocalBrowseEnabled(serverId, localPlaybackEntries); + const cachedGenres = serverId && !skipGenreCatalogCache + ? peekGenreCatalogCache(serverId, libraryScopeKey, true) + : null; const [rawGenres, setRawGenres] = useState(cachedGenres ?? []); const [loading, setLoading] = useState(!cachedGenres); useEffect(() => { let cancelled = false; const scopeKey = libraryScopeCacheKeyForServer(serverId); - const cached = serverId ? peekGenreCatalogCache(serverId, scopeKey, true) : null; + const cached = serverId && !skipGenreCatalogCache + ? peekGenreCatalogCache(serverId, scopeKey, true) + : null; if (cached) { // React Compiler set-state-in-effect rule: state set from an async result resolved in this effect. // eslint-disable-next-line react-hooks/set-state-in-effect @@ -50,35 +65,13 @@ export default function Genres() { return () => { cancelled = true; }; - }, [serverId, indexEnabled, musicLibraryFilterVersion]); + }, [serverId, indexEnabled, musicLibraryFilterVersion, offlineBrowseActive, skipGenreCatalogCache, librarySyncRevision, offlineLocalBrowseReloadKey]); const genres = useMemo( () => filterGenresWithContent([...rawGenres]).sort((a, b) => b.albumCount - a.albumCount), [rawGenres], ); - // After library resync the in-memory catalog cache is cleared, but this page - // can still hold pre-sync genres until we refetch (issue #1162). - useEffect(() => { - if (!serverId || !indexEnabled) return; - let cancelled = false; - const indexKey = resolveIndexKey(serverId); - let unlisten: (() => void) | undefined; - void subscribeLibrarySyncIdle(payload => { - if (!payload.ok) return; - if (payload.serverId !== indexKey && payload.serverId !== serverId) return; - void fetchGenreCatalog(serverId, indexEnabled).then(data => { - if (!cancelled) setRawGenres(data); - }); - }).then(fn => { - unlisten = fn; - }); - return () => { - cancelled = true; - unlisten?.(); - }; - }, [serverId, indexEnabled]); - // Log-scale font sizing — flattens the long tail (a 1000-album genre and a // 50-album genre look distinct, but a 1-album genre still has a readable size). const maxLog = useMemo(() => { diff --git a/src/features/offline/utils/offlineLocalBrowse.test.ts b/src/features/offline/utils/offlineLocalBrowse.test.ts index b99dd360..75d9c7ac 100644 --- a/src/features/offline/utils/offlineLocalBrowse.test.ts +++ b/src/features/offline/utils/offlineLocalBrowse.test.ts @@ -4,19 +4,43 @@ import { useAuthStore } from '@/store/authStore'; import { useLibraryIndexStore } from '@/store/libraryIndexStore'; import { useLocalPlaybackStore } from '@/store/localPlaybackStore'; import { + buildAlbumFromTracks, countLocalBrowsableTracks, + fetchOfflineLocalAlbumCatalogChunk, + fetchOfflineLocalArtistCatalogChunk, + fetchOfflineLocalAlbumGenreOptions, + fetchOfflineLocalGenreCatalog, fetchOfflineLocalBrowsableSongPage, + fetchOfflineLocalStarredArtists, + invalidateBrowsableLocalTrackCache, + loadArtistFromLocalPlayback, offlineLocalBrowseEnabled, + resetBrowsableLocalTrackCacheForTests, + searchOfflineLocalAlbums, + searchOfflineLocalArtists, + searchOfflineLocalBrowsableSongs, } from '@/features/offline/utils/offlineLocalBrowse'; +import { resetOfflineLocalLibrarySyncRevisionForTests, bumpOfflineLocalLibrarySyncRevisionForTests } from '@/store/offlineLocalLibrarySyncRevision'; -const { libraryGetTracksBatchChunkedMock } = vi.hoisted(() => ({ +const { libraryGetTracksBatchChunkedMock, libraryAdvancedSearchMock } = vi.hoisted(() => ({ libraryGetTracksBatchChunkedMock: vi.fn(async (): Promise => []), + libraryAdvancedSearchMock: vi.fn(async () => ({ + source: 'local' as const, + albums: [], + artists: [ + { id: 'ghost', name: 'Ghost Artist', serverId: 'srv-a', syncedAt: 0, rawJson: {} }, + ], + tracks: [], + totals: { tracks: 0, albums: 0, artists: 1 }, + appliedFilters: [], + })), })); vi.mock('@/lib/api/library', () => ({ libraryGetTracksBatchChunked: libraryGetTracksBatchChunkedMock, libraryGetTracksByAlbum: vi.fn(async () => []), - libraryAdvancedSearch: vi.fn(async () => ({ albums: [], artists: [], tracks: [] })), + libraryAdvancedSearch: libraryAdvancedSearchMock, + subscribeLibrarySyncIdle: vi.fn(async () => () => {}), })); describe('offlineLocalBrowse', () => { @@ -27,8 +51,11 @@ describe('offlineLocalBrowse', () => { }); useLibraryIndexStore.setState({ masterEnabled: true }); useLocalPlaybackStore.setState({ entries: {} }); + resetBrowsableLocalTrackCacheForTests(); + resetOfflineLocalLibrarySyncRevisionForTests(); libraryGetTracksBatchChunkedMock.mockReset(); libraryGetTracksBatchChunkedMock.mockResolvedValue([]); + libraryAdvancedSearchMock.mockClear(); }); it('offlineLocalBrowseEnabled requires index and local bytes', () => { @@ -51,6 +78,25 @@ describe('offlineLocalBrowse', () => { expect(offlineLocalBrowseEnabled('srv-a')).toBe(true); }); + it('offlineLocalBrowseEnabled treats hot-cache ephemeral bytes like library pins', () => { + useLocalPlaybackStore.setState({ + entries: { + 'a.test:t9': { + serverIndexKey: 'a.test', + trackId: 't9', + localPath: '/media/cache/a.test/t9.flac', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'ephemeral', + cachedAt: 1, + suffix: 'flac', + }, + }, + }); + expect(countLocalBrowsableTracks('srv-a')).toBe(1); + expect(offlineLocalBrowseEnabled('srv-a')).toBe(true); + }); + it('fetchOfflineLocalBrowsableSongPage pages local bytes alphabetically', async () => { useLocalPlaybackStore.setState({ entries: { @@ -92,4 +138,674 @@ describe('offlineLocalBrowse', () => { expect(page?.hasMore).toBe(true); }); + it('fetchOfflineLocalArtistCatalogChunk lists only artists with local bytes', async () => { + useLocalPlaybackStore.setState({ + entries: { + 'a.test:t1': { + serverIndexKey: 'a.test', + trackId: 't1', + localPath: '/media/cache/a.test/t1.flac', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'ephemeral', + cachedAt: 1, + suffix: 'flac', + }, + }, + }); + libraryGetTracksBatchChunkedMock.mockResolvedValue([ + { + id: 't1', + title: 'Song', + artist: 'Local Only', + artistId: 'art-local', + album: 'Al', + albumId: 'al-1', + durationSec: 1, + serverId: 'srv-a', + syncedAt: 1, + rawJson: {}, + }, + ]); + + const page = await fetchOfflineLocalArtistCatalogChunk('srv-a', 0, 50); + expect(page?.artists).toEqual([ + { id: 'art-local', name: 'Local Only', albumCount: 1, serverId: 'srv-a' }, + ]); + expect(libraryAdvancedSearchMock).not.toHaveBeenCalled(); + }); + + it('searchOfflineLocalArtists ignores the full library index', async () => { + useLocalPlaybackStore.setState({ + entries: { + 'a.test:t1': { + serverIndexKey: 'a.test', + trackId: 't1', + localPath: '/media/cache/a.test/t1.flac', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'ephemeral', + cachedAt: 1, + suffix: 'flac', + }, + }, + }); + libraryGetTracksBatchChunkedMock.mockResolvedValue([ + { + id: 't1', + title: 'Song', + artist: 'Cached Band', + artistId: 'art-cached', + album: 'Al', + albumId: 'al-1', + durationSec: 1, + serverId: 'srv-a', + syncedAt: 1, + rawJson: {}, + }, + ]); + + await expect(searchOfflineLocalArtists('srv-a', 'cached')).resolves.toEqual([ + { id: 'art-cached', name: 'Cached Band', albumCount: 1, serverId: 'srv-a' }, + ]); + expect(libraryAdvancedSearchMock).not.toHaveBeenCalled(); + }); + + it('fetchOfflineLocalAlbumGenreOptions counts genres from local albums only', async () => { + useLocalPlaybackStore.setState({ + entries: { + 'a.test:t1': { + serverIndexKey: 'a.test', + trackId: 't1', + localPath: '/media/cache/a.test/t1.flac', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'ephemeral', + cachedAt: 1, + suffix: 'flac', + }, + 'a.test:t2': { + serverIndexKey: 'a.test', + trackId: 't2', + localPath: '/media/cache/a.test/t2.flac', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'ephemeral', + cachedAt: 1, + suffix: 'flac', + }, + }, + }); + libraryGetTracksBatchChunkedMock.mockResolvedValue([ + { + id: 't1', title: 'One', artist: 'A', artistId: 'art-a', album: 'Al1', albumId: 'al-1', + genre: 'Rock', durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {}, + }, + { + id: 't2', title: 'Two', artist: 'B', artistId: 'art-b', album: 'Al2', albumId: 'al-2', + genre: 'Jazz', durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {}, + }, + ]); + + await expect(fetchOfflineLocalAlbumGenreOptions('srv-a', { + sort: 'alphabeticalByName', + genres: [], + losslessOnly: false, + starredOnly: false, + compFilter: 'all', + })).resolves.toEqual([ + { genre: 'Jazz', count: 1 }, + { genre: 'Rock', count: 1 }, + ]); + expect(libraryAdvancedSearchMock).not.toHaveBeenCalled(); + }); + + it('fetchOfflineLocalArtistCatalogChunk honours album vs track credit mode', async () => { + useLocalPlaybackStore.setState({ + entries: { + 'a.test:t1': { + serverIndexKey: 'a.test', + trackId: 't1', + localPath: '/media/cache/a.test/t1.flac', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'ephemeral', + cachedAt: 1, + suffix: 'flac', + }, + 'a.test:t2': { + serverIndexKey: 'a.test', + trackId: 't2', + localPath: '/media/cache/a.test/t2.flac', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'ephemeral', + cachedAt: 1, + suffix: 'flac', + }, + }, + }); + libraryGetTracksBatchChunkedMock.mockResolvedValue([ + { + id: 't1', title: 'Feat', artist: 'Guest', artistId: 'art-guest', + albumArtist: 'Headliner', album: 'Al1', albumId: 'al-1', + durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {}, + }, + { + id: 't2', title: 'Title', artist: 'Headliner', artistId: 'art-head', + albumArtist: 'Headliner', album: 'Al1', albumId: 'al-1', + durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {}, + }, + ]); + + const trackMode = await fetchOfflineLocalArtistCatalogChunk('srv-a', 0, 50, 'track'); + expect(trackMode?.artists.map(a => a.id).sort()).toEqual(['art-guest', 'art-head']); + + const albumMode = await fetchOfflineLocalArtistCatalogChunk('srv-a', 0, 50, 'album'); + expect(albumMode?.artists).toEqual([ + { id: 'art-head', name: 'Headliner', albumCount: 1, serverId: 'srv-a' }, + ]); + }); + + it('fetchBrowsableLocalTrackDtos reuses the in-memory batch for pagination chunks', async () => { + useLocalPlaybackStore.setState({ + entries: { + 'a.test:t1': { + serverIndexKey: 'a.test', + trackId: 't1', + localPath: '/media/cache/a.test/t1.flac', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'ephemeral', + cachedAt: 1, + suffix: 'flac', + }, + }, + }); + libraryGetTracksBatchChunkedMock.mockResolvedValue([ + { + id: 't1', title: 'Song', artist: 'A', artistId: 'art-a', album: 'Al', albumId: 'al-1', + durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {}, + }, + ]); + + await fetchOfflineLocalArtistCatalogChunk('srv-a', 0, 1); + await fetchOfflineLocalArtistCatalogChunk('srv-a', 1, 1); + expect(libraryGetTracksBatchChunkedMock).toHaveBeenCalledTimes(1); + }); + + it('loadArtistFromLocalPlayback uses local track rows only', async () => { + useLocalPlaybackStore.setState({ + entries: { + 'a.test:t1': { + serverIndexKey: 'a.test', + trackId: 't1', + localPath: '/media/cache/a.test/t1.flac', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'ephemeral', + cachedAt: 1, + suffix: 'flac', + }, + }, + }); + libraryGetTracksBatchChunkedMock.mockResolvedValue([ + { + id: 't1', title: 'Song', artist: 'Local Only', artistId: 'art-local', + album: 'Al', albumId: 'al-1', durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {}, + }, + ]); + + const detail = await loadArtistFromLocalPlayback('srv-a', 'art-local', 'track'); + expect(detail?.artist.name).toBe('Local Only'); + expect(detail?.albums).toHaveLength(1); + expect(libraryAdvancedSearchMock).not.toHaveBeenCalled(); + }); + + it('fetchOfflineLocalGenreCatalog maps local album genres to SubsonicGenre', async () => { + useLocalPlaybackStore.setState({ + entries: { + 'a.test:t1': { + serverIndexKey: 'a.test', + trackId: 't1', + localPath: '/media/cache/a.test/t1.flac', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'ephemeral', + cachedAt: 1, + suffix: 'flac', + }, + }, + }); + libraryGetTracksBatchChunkedMock.mockResolvedValue([ + { + id: 't1', title: 'One', artist: 'A', artistId: 'art-a', album: 'Al', albumId: 'al-1', + genre: 'Rock', durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {}, + }, + ]); + + await expect(fetchOfflineLocalGenreCatalog('srv-a')).resolves.toEqual([ + { value: 'Rock', albumCount: 1, songCount: 0 }, + ]); + }); + + it('buildAlbumFromTracks derives album artist credit from grouped tracks', () => { + const album = buildAlbumFromTracks('al-1', [ + { + id: 't1', title: 'Feat', artist: 'Guest', artistId: 'art-guest', + albumArtist: 'Headliner', album: 'Mix', albumId: 'al-1', + durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {}, + }, + { + id: 't2', title: 'Title', artist: 'Headliner', artistId: 'art-head', + albumArtist: 'Headliner', album: 'Mix', albumId: 'al-1', + durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {}, + }, + ], 'srv-a'); + expect(album.artist).toBe('Headliner'); + expect(album.artistId).toBe('art-head'); + }); + + it('fetchOfflineLocalArtistCatalogChunk filters letter buckets with ignoredArticles', async () => { + useLocalPlaybackStore.setState({ + entries: { + 'a.test:t1': { + serverIndexKey: 'a.test', + trackId: 't1', + localPath: '/media/cache/a.test/t1.flac', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'ephemeral', + cachedAt: 1, + suffix: 'flac', + }, + }, + }); + libraryGetTracksBatchChunkedMock.mockResolvedValue([ + { + id: 't1', title: 'Song', artist: 'The Kinks', artistId: 'art-kinks', + album: 'Al', albumId: 'al-1', durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {}, + }, + ]); + + const bucketK = await fetchOfflineLocalArtistCatalogChunk( + 'srv-a', 0, 50, 'track', 'K', 'The', + ); + expect(bucketK?.artists.map(a => a.name)).toEqual(['The Kinks']); + + const bucketT = await fetchOfflineLocalArtistCatalogChunk( + 'srv-a', 0, 50, 'track', 'T', 'The', + ); + expect(bucketT?.artists).toEqual([]); + }); + + it('fetchOfflineLocalStarredArtists respects album credit mode', async () => { + useLocalPlaybackStore.setState({ + entries: { + 'a.test:t1': { + serverIndexKey: 'a.test', + trackId: 't1', + localPath: '/media/cache/a.test/t1.flac', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'ephemeral', + cachedAt: 1, + suffix: 'flac', + }, + 'a.test:t2': { + serverIndexKey: 'a.test', + trackId: 't2', + localPath: '/media/cache/a.test/t2.flac', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'ephemeral', + cachedAt: 1, + suffix: 'flac', + }, + }, + }); + libraryGetTracksBatchChunkedMock.mockResolvedValue([ + { + id: 't1', title: 'Feat', artist: 'Guest', artistId: 'art-guest', + albumArtist: 'Headliner', album: 'Al', albumId: 'al-1', starredAt: 1, + durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {}, + }, + { + id: 't2', title: 'Title', artist: 'Headliner', artistId: 'art-head', + albumArtist: 'Headliner', album: 'Al', albumId: 'al-1', starredAt: 1, + durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {}, + }, + ]); + + await expect(fetchOfflineLocalStarredArtists('srv-a', 'album')).resolves.toEqual([ + { + id: 'art-head', + name: 'Headliner', + albumCount: 1, + serverId: 'srv-a', + starred: expect.any(String), + }, + ]); + }); + + it('invalidateBrowsableLocalTrackCache refetches track metadata after invalidation', async () => { + useLocalPlaybackStore.setState({ + entries: { + 'a.test:t1': { + serverIndexKey: 'a.test', + trackId: 't1', + localPath: '/media/cache/a.test/t1.flac', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'ephemeral', + cachedAt: 1, + suffix: 'flac', + }, + }, + }); + libraryGetTracksBatchChunkedMock + .mockResolvedValueOnce([ + { + id: 't1', title: 'Old', artist: 'A', artistId: 'art-a', album: 'Al', albumId: 'al-1', + genre: 'Rock', durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {}, + }, + ]) + .mockResolvedValueOnce([ + { + id: 't1', title: 'New', artist: 'A', artistId: 'art-a', album: 'Al', albumId: 'al-1', + genre: 'Jazz', durationSec: 1, serverId: 'srv-a', syncedAt: 2, rawJson: {}, + }, + ]); + + await fetchOfflineLocalArtistCatalogChunk('srv-a', 0, 10); + invalidateBrowsableLocalTrackCache('srv-a'); + await expect(searchOfflineLocalBrowsableSongs('srv-a', 'new', 0, 10)).resolves.toEqual([ + expect.objectContaining({ title: 'New' }), + ]); + expect(libraryGetTracksBatchChunkedMock).toHaveBeenCalledTimes(2); + }); + + it('invalidateBrowsableLocalTrackCache matches library index key from sync-idle', async () => { + useLocalPlaybackStore.setState({ + entries: { + 'a.test:t1': { + serverIndexKey: 'a.test', + trackId: 't1', + localPath: '/media/cache/a.test/t1.flac', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'ephemeral', + cachedAt: 1, + suffix: 'flac', + }, + }, + }); + libraryGetTracksBatchChunkedMock + .mockResolvedValueOnce([ + { + id: 't1', title: 'Old', artist: 'A', artistId: 'art-a', album: 'Al', albumId: 'al-1', + genre: 'Rock', durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {}, + }, + ]) + .mockResolvedValueOnce([ + { + id: 't1', title: 'New', artist: 'A', artistId: 'art-a', album: 'Al', albumId: 'al-1', + genre: 'Jazz', durationSec: 1, serverId: 'srv-a', syncedAt: 2, rawJson: {}, + }, + ]); + + await fetchOfflineLocalArtistCatalogChunk('srv-a', 0, 10); + invalidateBrowsableLocalTrackCache('a.test'); + await expect(searchOfflineLocalBrowsableSongs('srv-a', 'new', 0, 10)).resolves.toEqual([ + expect.objectContaining({ title: 'New' }), + ]); + expect(libraryGetTracksBatchChunkedMock).toHaveBeenCalledTimes(2); + }); + + it('fetchOfflineLocalAlbumCatalogChunk pages filtered local albums', async () => { + useLocalPlaybackStore.setState({ + entries: { + 'a.test:t1': { + serverIndexKey: 'a.test', + trackId: 't1', + localPath: '/media/cache/a.test/t1.flac', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'ephemeral', + cachedAt: 1, + suffix: 'flac', + }, + }, + }); + libraryGetTracksBatchChunkedMock.mockResolvedValue([ + { + id: 't1', title: 'Song', artist: 'A', artistId: 'art-a', + album: 'Local Album', albumId: 'al-1', durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {}, + }, + ]); + + const page = await fetchOfflineLocalAlbumCatalogChunk('srv-a', { + sort: 'alphabeticalByName', + genres: [], + losslessOnly: false, + starredOnly: false, + compFilter: 'all', + }, 0, 10); + expect(page?.albums[0]?.name).toBe('Local Album'); + expect(page?.albums[0]?.artistId).toBe('art-a'); + }); + + it('searchOfflineLocalBrowsableSongs matches title artist and album', async () => { + useLocalPlaybackStore.setState({ + entries: { + 'a.test:t1': { + serverIndexKey: 'a.test', + trackId: 't1', + localPath: '/media/cache/a.test/t1.flac', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'ephemeral', + cachedAt: 1, + suffix: 'flac', + }, + }, + }); + libraryGetTracksBatchChunkedMock.mockResolvedValue([ + { + id: 't1', title: 'Unique Title', artist: 'Band', artistId: 'art-a', + album: 'Album', albumId: 'al-1', durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {}, + }, + ]); + + await expect(searchOfflineLocalBrowsableSongs('srv-a', 'unique', 0, 10)).resolves.toEqual([ + expect.objectContaining({ id: 't1', title: 'Unique Title' }), + ]); + }); + + it('searchOfflineLocalAlbums finds albums by title or artist', async () => { + useLocalPlaybackStore.setState({ + entries: { + 'a.test:t1': { + serverIndexKey: 'a.test', + trackId: 't1', + localPath: '/media/cache/a.test/t1.flac', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'ephemeral', + cachedAt: 1, + suffix: 'flac', + }, + }, + }); + libraryGetTracksBatchChunkedMock.mockResolvedValue([ + { + id: 't1', title: 'Song', artist: 'Cached Band', artistId: 'art-a', + album: 'Pinned LP', albumId: 'al-1', durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {}, + }, + ]); + + await expect(searchOfflineLocalAlbums('srv-a', 'pinned')).resolves.toEqual([ + expect.objectContaining({ name: 'Pinned LP' }), + ]); + }); + + it('loadArtistFromLocalPlayback album credit loads albums by album artist id', async () => { + useLocalPlaybackStore.setState({ + entries: { + 'a.test:t1': { + serverIndexKey: 'a.test', + trackId: 't1', + localPath: '/media/cache/a.test/t1.flac', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'ephemeral', + cachedAt: 1, + suffix: 'flac', + }, + 'a.test:t2': { + serverIndexKey: 'a.test', + trackId: 't2', + localPath: '/media/cache/a.test/t2.flac', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'ephemeral', + cachedAt: 1, + suffix: 'flac', + }, + }, + }); + libraryGetTracksBatchChunkedMock.mockResolvedValue([ + { + id: 't1', title: 'Feat', artist: 'Guest', artistId: 'art-guest', + albumArtist: 'Headliner', album: 'Al', albumId: 'al-1', + durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {}, + }, + { + id: 't2', title: 'Title', artist: 'Headliner', artistId: 'art-head', + albumArtist: 'Headliner', album: 'Al', albumId: 'al-1', + durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {}, + }, + ]); + + const detail = await loadArtistFromLocalPlayback('srv-a', 'art-head', 'album'); + expect(detail?.artist.name).toBe('Headliner'); + expect(detail?.albums).toHaveLength(1); + }); + + it('loadArtistFromLocalPlayback falls back to album credit when track mode misses album artist id', async () => { + useLocalPlaybackStore.setState({ + entries: { + 'a.test:t1': { + serverIndexKey: 'a.test', + trackId: 't1', + localPath: '/media/cache/a.test/t1.flac', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'ephemeral', + cachedAt: 1, + suffix: 'flac', + }, + 'a.test:t2': { + serverIndexKey: 'a.test', + trackId: 't2', + localPath: '/media/cache/a.test/t2.flac', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'ephemeral', + cachedAt: 1, + suffix: 'flac', + }, + }, + }); + libraryGetTracksBatchChunkedMock.mockResolvedValue([ + { + id: 't1', title: 'Feat', artist: 'Guest', artistId: 'art-guest', + albumArtist: 'Headliner', album: 'Al', albumId: 'al-1', + durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {}, + }, + { + id: 't2', title: 'Title', artist: 'Headliner', artistId: 'art-head', + albumArtist: 'Headliner', album: 'Al', albumId: 'al-1', + durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {}, + }, + ]); + + const detail = await loadArtistFromLocalPlayback('srv-a', 'art-head', 'track'); + expect(detail?.artist.name).toBe('Headliner'); + expect(detail?.albums).toHaveLength(1); + }); + + it('offlineLocalBrowseRevision bumps when hot-cache rows are added', async () => { + const { offlineLocalBrowseRevision } = await import('@/store/localPlaybackBrowseRevision'); + useLocalPlaybackStore.setState({ entries: {} }); + expect(offlineLocalBrowseRevision('srv-a', {})).toBe(''); + + const entries = { + 'a.test:t1': { + serverIndexKey: 'a.test', + trackId: 't1', + localPath: '/media/cache/a.test/t1.flac', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'ephemeral' as const, + cachedAt: 1, + suffix: 'flac', + }, + }; + const first = offlineLocalBrowseRevision('srv-a', entries); + expect(first).toBe('t1:ephemeral:1'); + + const second = offlineLocalBrowseRevision('srv-a', { + ...entries, + 'a.test:t2': { + serverIndexKey: 'a.test', + trackId: 't2', + localPath: '/media/cache/a.test/t2.flac', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'ephemeral' as const, + cachedAt: 2, + suffix: 'flac', + }, + }); + expect(second).not.toBe(first); + }); + + it('fetchBrowsableLocalTrackDtos refetches after library sync revision bump', async () => { + useLocalPlaybackStore.setState({ + entries: { + 'a.test:t1': { + serverIndexKey: 'a.test', + trackId: 't1', + localPath: '/media/cache/a.test/t1.flac', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'ephemeral', + cachedAt: 1, + suffix: 'flac', + }, + }, + }); + libraryGetTracksBatchChunkedMock + .mockResolvedValueOnce([ + { + id: 't1', title: 'Old', artist: 'A', artistId: 'art-a', album: 'Al', albumId: 'al-1', + genre: 'Rock', durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {}, + }, + ]) + .mockResolvedValueOnce([ + { + id: 't1', title: 'New', artist: 'A', artistId: 'art-a', album: 'Al', albumId: 'al-1', + genre: 'Jazz', durationSec: 1, serverId: 'srv-a', syncedAt: 2, rawJson: {}, + }, + ]); + + await fetchOfflineLocalArtistCatalogChunk('srv-a', 0, 10); + bumpOfflineLocalLibrarySyncRevisionForTests('srv-a'); + await expect(searchOfflineLocalBrowsableSongs('srv-a', 'new', 0, 10)).resolves.toEqual([ + expect.objectContaining({ title: 'New' }), + ]); + expect(libraryGetTracksBatchChunkedMock).toHaveBeenCalledTimes(2); + }); + }); diff --git a/src/features/offline/utils/offlineLocalBrowse.ts b/src/features/offline/utils/offlineLocalBrowse.ts index d0695b9e..8c983778 100644 --- a/src/features/offline/utils/offlineLocalBrowse.ts +++ b/src/features/offline/utils/offlineLocalBrowse.ts @@ -1,56 +1,125 @@ import type { ArtistCreditMode, LibraryTrackDto } from '@/lib/api/library'; import { libraryAdvancedSearch, libraryGetTracksBatchChunked, libraryGetTracksByAlbum } from '@/lib/api/library'; -import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes'; +import type { SubsonicAlbum, SubsonicArtist, SubsonicGenre, SubsonicSong } from '@/lib/api/subsonicTypes'; +import { useAuthStore } from '@/store/authStore'; import { useLibraryIndexStore } from '@/store/libraryIndexStore'; import type { LocalPlaybackEntry } from '@/store/localPlaybackStore'; import { useLocalPlaybackStore } from '@/store/localPlaybackStore'; import { albumToAlbum, - artistToArtist, resolveTrackCoverArtId, trackToSong, } from '@/lib/library/advancedSearchLocal'; import { albumIsCompilationFromTrackDtos } from '@/lib/library/albumCompilation'; import { + countGenresFromAlbums, filterAlbumsByCompilation, filterAlbumsByGenres, filterAlbumsByStarred, filterAlbumsByYearBounds, } from '@/lib/library/albumBrowseFilters'; -import type { AlbumBrowseQuery } from '@/lib/library/albumBrowseTypes'; +import type { AlbumBrowseQuery, GenreFilterOption } from '@/lib/library/albumBrowseTypes'; import { sortSubsonicAlbums } from '@/lib/library/albumBrowseSort'; +import { + pickAlbumGroupArtistFromTrackDtos, + resolveAlbumCreditArtistId, +} from '@/lib/library/albumGroupArtist'; +import { artistLetterBucket } from '@/lib/library/artistLetterBucket'; import { isLosslessSuffix } from '@/lib/library/losslessFormats'; +import { hasBrowsableLocalPlaybackBytes } from '@/lib/localPlayback/browsablePlaybackTiers'; +import { offlineLocalLibrarySyncRevision } from '@/store/offlineLocalLibrarySyncRevision'; +import { resolveIndexKey } from '@/lib/server/serverIndexKey'; import { entryBelongsToServer } from '@/store/localPlaybackResolve'; function sortBrowsableSongs(songs: SubsonicSong[]): SubsonicSong[] { return [...songs].sort((a, b) => a.title.localeCompare(b.title)); } -function listBrowsableEntries(serverId: string): LocalPlaybackEntry[] { - return Object.values(useLocalPlaybackStore.getState().entries).filter( - e => (e.tier === 'library' || e.tier === 'favorite-auto') - && !!e.localPath - && entryBelongsToServer(e, serverId), + +function listBrowsableEntries( + serverId: string, + entries: Record = useLocalPlaybackStore.getState().entries, +): LocalPlaybackEntry[] { + return Object.values(entries).filter( + e => hasBrowsableLocalPlaybackBytes(e) && entryBelongsToServer(e, serverId), ); } -export function countLocalBrowsableTracks(serverId: string): number { - return listBrowsableEntries(serverId).length; +export function countLocalBrowsableTracks( + serverId: string, + entries?: Record, +): number { + return listBrowsableEntries(serverId, entries).length; } -/** Local library index + at least one on-disk library/favorites track for this server. */ -export function offlineLocalBrowseEnabled(serverId: string | null | undefined): boolean { +/** Local library index + at least one on-disk library, favorites-auto, or hot-cache track. */ +export function offlineLocalBrowseEnabled( + serverId: string | null | undefined, + entries?: Record, +): boolean { if (!serverId) return false; if (!useLibraryIndexStore.getState().isIndexEnabled(serverId)) return false; - return countLocalBrowsableTracks(serverId) > 0; + return countLocalBrowsableTracks(serverId, entries) > 0; +} + +function browsableEntriesRevision(serverId: string): string { + const filterVer = useAuthStore.getState().musicLibraryFilterVersion; + const syncRev = offlineLocalLibrarySyncRevision(serverId); + const entries = listBrowsableEntries(serverId) + .map(e => `${e.trackId}:${e.cachedAt}`) + .sort() + .join('\0'); + return `${filterVer}\0${syncRev}\0${entries}`; +} + +type BrowsableTrackCache = { + serverId: string; + revision: string; + tracks: LibraryTrackDto[]; +}; + +let browsableTrackCache: BrowsableTrackCache | null = null; + +/** Drop cached on-disk track DTOs after library resync or pin set changes. */ +export function invalidateBrowsableLocalTrackCache(serverId?: string): void { + if (!browsableTrackCache) return; + if (!serverId) { + browsableTrackCache = null; + return; + } + const cachedId = browsableTrackCache.serverId; + if ( + cachedId === serverId + || resolveIndexKey(cachedId) === serverId + || resolveIndexKey(serverId) === cachedId + ) { + browsableTrackCache = null; + } +} + +/** Test-only reset. */ +export function resetBrowsableLocalTrackCacheForTests(): void { + browsableTrackCache = null; } /** Track DTOs for every library/favorite-auto entry with on-disk bytes for this server. */ export async function fetchBrowsableLocalTrackDtos(serverId: string): Promise { + const revision = browsableEntriesRevision(serverId); + if ( + browsableTrackCache?.serverId === serverId + && browsableTrackCache.revision === revision + ) { + return browsableTrackCache.tracks; + } const entries = listBrowsableEntries(serverId); - if (entries.length === 0) return []; + if (entries.length === 0) { + browsableTrackCache = { serverId, revision, tracks: [] }; + return []; + } const refs = entries.map(e => ({ serverId, trackId: e.trackId })); - return libraryGetTracksBatchChunked(refs); + const tracks = await libraryGetTracksBatchChunked(refs); + browsableTrackCache = { serverId, revision, tracks }; + return tracks; } export function buildAlbumFromTracks( @@ -62,11 +131,13 @@ export function buildAlbumFromTracks( const first = tracks[0]; const starred = tracks.some(t => t.starredAt != null); const isCompilation = albumIsCompilationFromTrackDtos(tracks); + const creditName = pickAlbumGroupArtistFromTrackDtos(tracks); + const artistId = resolveAlbumCreditArtistId(tracks, creditName); return { id: albumId, name: first.album ?? albumId, - artist: first.albumArtist ?? first.artist ?? '', - artistId: first.artistId ?? '', + artist: creditName, + artistId, coverArt: resolveTrackCoverArtId(first) ?? albumId, year: first.year ?? undefined, genre: first.genre ?? undefined, @@ -119,6 +190,126 @@ function aggregateArtistsFromTracks( .sort((a, b) => a.name.localeCompare(b.name)); } +/** Album credit groups by album artist; track credit groups by track performer id. */ +function aggregateArtistsFromTracksForCreditMode( + tracks: LibraryTrackDto[], + serverId: string, + creditMode: ArtistCreditMode, +): SubsonicArtist[] { + if (creditMode === 'track') { + return aggregateArtistsFromTracks(tracks, serverId); + } + const byAlbum = new Map(); + for (const track of tracks) { + const albumId = track.albumId; + if (!albumId) continue; + const list = byAlbum.get(albumId) ?? []; + list.push(track); + byAlbum.set(albumId, list); + } + const byArtistId = new Map }>(); + for (const [albumId, albumTracks] of byAlbum) { + const creditName = pickAlbumGroupArtistFromTrackDtos(albumTracks); + const artistId = resolveAlbumCreditArtistId(albumTracks, creditName); + if (!artistId) continue; + const entry = byArtistId.get(artistId) ?? { name: creditName, albumIds: new Set() }; + entry.albumIds.add(albumId); + byArtistId.set(artistId, entry); + } + return [...byArtistId.entries()] + .map(([id, { name, albumIds }]) => ({ + id, + name, + albumCount: albumIds.size, + serverId, + })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +function starredIsoFromTrackTimestamps(timestamps: number[]): string { + const max = timestamps.length > 0 ? Math.max(...timestamps) : Date.now(); + return new Date(max).toISOString(); +} + +function attachStarredFromTracks( + artists: SubsonicArtist[], + tracks: LibraryTrackDto[], + creditMode: ArtistCreditMode, +): SubsonicArtist[] { + const starredAtByArtistId = new Map(); + if (creditMode === 'track') { + for (const track of tracks) { + if (!track.artistId || track.starredAt == null) continue; + const list = starredAtByArtistId.get(track.artistId) ?? []; + list.push(track.starredAt); + starredAtByArtistId.set(track.artistId, list); + } + } else { + const byAlbum = new Map(); + for (const track of tracks) { + const albumId = track.albumId; + if (!albumId) continue; + const list = byAlbum.get(albumId) ?? []; + list.push(track); + byAlbum.set(albumId, list); + } + for (const albumTracks of byAlbum.values()) { + const artistId = resolveAlbumCreditArtistId( + albumTracks, + pickAlbumGroupArtistFromTrackDtos(albumTracks), + ); + if (!artistId) continue; + const starredTs = albumTracks + .map(t => t.starredAt) + .filter((v): v is number => v != null); + if (starredTs.length === 0) continue; + const list = starredAtByArtistId.get(artistId) ?? []; + list.push(...starredTs); + starredAtByArtistId.set(artistId, list); + } + } + return artists.map(artist => ({ + ...artist, + starred: starredIsoFromTrackTimestamps(starredAtByArtistId.get(artist.id) ?? []), + })); +} + +function localTracksForArtist( + tracks: LibraryTrackDto[], + artistId: string, + serverId: string, + creditMode: ArtistCreditMode, +): LibraryTrackDto[] { + if (creditMode === 'track') { + return tracks.filter(t => t.artistId === artistId); + } + const albumIds = new Set( + aggregateAlbumsFromTracks(tracks, serverId) + .filter(a => a.artistId === artistId) + .map(a => a.id), + ); + return tracks.filter(t => t.albumId && albumIds.has(t.albumId)); +} + +function resolveLocalArtistTracks( + allTracks: LibraryTrackDto[], + artistId: string, + serverId: string, + creditMode?: ArtistCreditMode, +): { tracks: LibraryTrackDto[]; creditMode: ArtistCreditMode } { + const preferred = creditMode ?? useAuthStore.getState().artistBrowseCreditMode; + let tracks = localTracksForArtist(allTracks, artistId, serverId, preferred); + if (tracks.length > 0) { + return { tracks, creditMode: preferred }; + } + const alternate: ArtistCreditMode = preferred === 'album' ? 'track' : 'album'; + tracks = localTracksForArtist(allTracks, artistId, serverId, alternate); + if (tracks.length > 0) { + return { tracks, creditMode: alternate }; + } + return { tracks: [], creditMode: preferred }; +} + function applyAlbumBrowseQuery( albums: SubsonicAlbum[], query: AlbumBrowseQuery, @@ -175,37 +366,26 @@ export async function searchOfflineLocalBrowsableSongs( return sortBrowsableSongs(matched).slice(offset, offset + chunkSize); } -export async function fetchOfflineLocalStarredArtists(serverId: string): Promise { +export async function fetchOfflineLocalStarredArtists( + serverId: string, + creditMode: ArtistCreditMode = 'album', +): Promise { if (!offlineLocalBrowseEnabled(serverId)) return null; const tracks = (await fetchBrowsableLocalTrackDtos(serverId)).filter(t => t.starredAt != null); - return aggregateArtistsFromTracks(tracks, serverId); + return attachStarredFromTracks( + aggregateArtistsFromTracksForCreditMode(tracks, serverId, creditMode), + tracks, + creditMode, + ); } -async function fetchOfflineLocalArtistCatalogFromIndex( - serverId: string, - offset: number, - chunkSize: number, - creditMode: ArtistCreditMode, +function filterArtistsByLetterBucket( + artists: SubsonicArtist[], letterBucket?: string | null, -): Promise<{ artists: SubsonicArtist[]; hasMore: boolean } | null> { - const bucket = letterBucket && letterBucket !== 'ALL' ? letterBucket : undefined; - try { - const resp = await libraryAdvancedSearch({ - serverId, - entityTypes: ['artist'], - artistCreditMode: creditMode, - ...(bucket ? { artistLetterBucket: bucket } : {}), - sort: [{ field: 'name', dir: 'asc' }], - limit: chunkSize, - offset, - skipTotals: true, - }); - if (resp.source !== 'local') return null; - const artists = resp.artists.map(artistToArtist).map(a => ({ ...a, serverId })); - return { artists, hasMore: artists.length === chunkSize }; - } catch { - return null; - } + ignoredArticles?: string | null, +): SubsonicArtist[] { + if (!letterBucket || letterBucket === 'ALL') return artists; + return artists.filter(a => artistLetterBucket(a, ignoredArticles) === letterBucket); } export async function fetchOfflineLocalArtistCatalogChunk( @@ -214,18 +394,15 @@ export async function fetchOfflineLocalArtistCatalogChunk( chunkSize: number, creditMode: ArtistCreditMode = 'album', letterBucket?: string | null, + ignoredArticles?: string | null, ): Promise<{ artists: SubsonicArtist[]; hasMore: boolean } | null> { if (!offlineLocalBrowseEnabled(serverId)) return null; - const fromIndex = await fetchOfflineLocalArtistCatalogFromIndex( - serverId, - offset, - chunkSize, - creditMode, - letterBucket, - ); - if (fromIndex) return fromIndex; const tracks = await fetchBrowsableLocalTrackDtos(serverId); - const artists = aggregateArtistsFromTracks(tracks, serverId); + const artists = filterArtistsByLetterBucket( + aggregateArtistsFromTracksForCreditMode(tracks, serverId, creditMode), + letterBucket, + ignoredArticles, + ); const slice = artists.slice(offset, offset + chunkSize); return { artists: slice, @@ -241,27 +418,8 @@ export async function searchOfflineLocalArtists( if (!offlineLocalBrowseEnabled(serverId)) return null; const q = query.trim().toLowerCase(); if (!q) return []; - try { - const resp = await libraryAdvancedSearch({ - serverId, - entityTypes: ['artist'], - artistCreditMode: creditMode, - query: q, - limit: 500, - offset: 0, - skipTotals: true, - }); - if (resp.source === 'local') { - return resp.artists - .map(artistToArtist) - .map(a => ({ ...a, serverId })) - .filter(a => a.name.toLowerCase().includes(q)); - } - } catch { - /* fall through */ - } const tracks = await fetchBrowsableLocalTrackDtos(serverId); - return aggregateArtistsFromTracks(tracks, serverId) + return aggregateArtistsFromTracksForCreditMode(tracks, serverId, creditMode) .filter(a => a.name.toLowerCase().includes(q)); } @@ -286,6 +444,39 @@ export async function fetchOfflineLocalAlbumCatalogChunk( }; } +/** Genre filter dropdown options from on-disk albums only (offline All Albums). */ +export async function fetchOfflineLocalAlbumGenreOptions( + serverId: string, + query: AlbumBrowseQuery, + starredOverrides: Record = {}, +): Promise { + if (!offlineLocalBrowseEnabled(serverId)) return []; + let tracks = await fetchBrowsableLocalTrackDtos(serverId); + if (query.losslessOnly) { + tracks = tracks.filter(t => isLosslessSuffix(t.suffix ?? undefined)); + } + let albums = aggregateAlbumsFromTracks(tracks, serverId); + albums = applyAlbumBrowseQuery(albums, { ...query, genres: [] }, starredOverrides); + return countGenresFromAlbums(filterAlbumsByCompilation(albums, query.compFilter)); +} + +/** Genres cloud from on-disk albums only (offline browse). */ +export async function fetchOfflineLocalGenreCatalog(serverId: string): Promise { + if (!offlineLocalBrowseEnabled(serverId)) return []; + const options = await fetchOfflineLocalAlbumGenreOptions(serverId, { + sort: 'alphabeticalByName', + genres: [], + losslessOnly: false, + starredOnly: false, + compFilter: 'all', + }); + return options.map(o => ({ + value: o.genre, + albumCount: o.count, + songCount: 0, + })); +} + export async function searchOfflineLocalAlbums( serverId: string, query: string, @@ -336,29 +527,32 @@ export async function loadAlbumFromLocalPlayback( export async function loadArtistFromLocalPlayback( serverId: string, artistId: string, + creditMode?: ArtistCreditMode, ): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] } | null> { if (!offlineLocalBrowseEnabled(serverId)) return null; const localIds = new Set(listBrowsableEntries(serverId).map(e => e.trackId)); - const tracks = (await fetchBrowsableLocalTrackDtos(serverId)).filter( - t => t.artistId === artistId && localIds.has(t.id), + const allTracks = (await fetchBrowsableLocalTrackDtos(serverId)).filter(t => localIds.has(t.id)); + const { tracks, creditMode: effectiveCreditMode } = resolveLocalArtistTracks( + allTracks, + artistId, + serverId, + creditMode, ); if (tracks.length === 0) return null; const albums = aggregateAlbumsFromTracks(tracks, serverId) .sort((a, b) => a.name.localeCompare(b.name)); - const artistDto = tracks[0]; - const artistSearch = await libraryAdvancedSearch({ - serverId, - entityTypes: ['artist'], - limit: 10_000, - }).catch(() => null); - const match = artistSearch?.artists.find(a => a.id === artistId); + const catalogMatch = aggregateArtistsFromTracksForCreditMode(allTracks, serverId, effectiveCreditMode) + .find(a => a.id === artistId) + ?? aggregateArtistsFromTracksForCreditMode(allTracks, serverId, effectiveCreditMode === 'album' ? 'track' : 'album') + .find(a => a.id === artistId); + const fallback = tracks[0]; - const artist = match - ? { ...artistToArtist(match), serverId, albumCount: albums.length } + const artist: SubsonicArtist = catalogMatch + ? { ...catalogMatch, albumCount: albums.length } : { id: artistId, - name: artistDto.artist ?? artistDto.albumArtist ?? artistId, + name: fallback.artist ?? fallback.albumArtist ?? artistId, albumCount: albums.length, serverId, }; diff --git a/src/features/playback/utils/playback/genreBrowsePlayback.test.ts b/src/features/playback/utils/playback/genreBrowsePlayback.test.ts index e73b85b5..8d61f328 100644 --- a/src/features/playback/utils/playback/genreBrowsePlayback.test.ts +++ b/src/features/playback/utils/playback/genreBrowsePlayback.test.ts @@ -29,6 +29,18 @@ vi.mock('@/lib/library/libraryReady', () => ({ libraryIsReady: vi.fn(), })); +const isOfflineBrowseActiveMock = vi.fn(() => false); +const offlineLocalBrowseEnabledMock = vi.fn((_serverId?: string) => false); +const fetchOfflineLocalGenreCatalogMock = vi.fn(async (_serverId?: string) => [ + { value: 'CachedLocal', albumCount: 2, songCount: 0 }, +]); + +vi.mock('@/features/offline', () => ({ + isOfflineBrowseActive: () => isOfflineBrowseActiveMock(), + offlineLocalBrowseEnabled: (serverId: string) => offlineLocalBrowseEnabledMock(serverId), + fetchOfflineLocalGenreCatalog: (serverId: string) => fetchOfflineLocalGenreCatalogMock(serverId), +})); + // Spread the real leaf module so other consumers pulled in transitively (the // album barrel reaches this via the artist↔album edge → useGenreAlbumBrowse needs // GENRE_ALBUM_FIRST_PAGE); only fetchGenreAlbumTotal is stubbed here. @@ -46,6 +58,9 @@ import { libraryIsReady } from '@/lib/library/libraryReady'; describe('genreBrowsePlayback', () => { beforeEach(() => { resetGenreCatalogCountsCacheForTests(); + isOfflineBrowseActiveMock.mockReturnValue(false); + offlineLocalBrowseEnabledMock.mockReturnValue(false); + fetchOfflineLocalGenreCatalogMock.mockClear(); vi.mocked(libraryIsReady).mockReset(); vi.mocked(libraryAdvancedSearch).mockReset(); vi.mocked(libraryGetGenreAlbumCounts).mockReset(); @@ -214,4 +229,32 @@ describe('genreBrowsePlayback', () => { expect(libraryGetGenreAlbumCounts).toHaveBeenCalledTimes(1); }); + + it('bypasses online genre cache when offline local browse is active', async () => { + vi.mocked(libraryIsReady).mockResolvedValue(true); + vi.mocked(libraryGetGenreAlbumCounts).mockResolvedValue([ + { value: 'Rock', albumCount: 999, songCount: 900 }, + ]); + await fetchGenreCatalog('srv-1', true); + + isOfflineBrowseActiveMock.mockReturnValue(true); + offlineLocalBrowseEnabledMock.mockReturnValue(true); + + await expect(fetchGenreCatalog('srv-1', true)).resolves.toEqual([ + { value: 'CachedLocal', albumCount: 2, songCount: 0 }, + ]); + expect(fetchOfflineLocalGenreCatalogMock).toHaveBeenCalledWith('srv-1'); + expect(libraryGetGenreAlbumCounts).toHaveBeenCalledTimes(1); + }); + + it('reads album totals from offline local genre catalog', async () => { + isOfflineBrowseActiveMock.mockReturnValue(true); + offlineLocalBrowseEnabledMock.mockReturnValue(true); + fetchOfflineLocalGenreCatalogMock.mockResolvedValue([ + { value: 'Rock', albumCount: 3, songCount: 0 }, + ]); + + await expect(fetchGenreAlbumCount('srv-1', 'Rock', true)).resolves.toBe(3); + expect(fetchGenreAlbumTotal).not.toHaveBeenCalled(); + }); }); diff --git a/src/features/playback/utils/playback/genreBrowsePlayback.ts b/src/features/playback/utils/playback/genreBrowsePlayback.ts index 723c347b..ce0e650b 100644 --- a/src/features/playback/utils/playback/genreBrowsePlayback.ts +++ b/src/features/playback/utils/playback/genreBrowsePlayback.ts @@ -25,6 +25,11 @@ import { } from '@/lib/library/genreCatalogCountsCache'; import { fetchGenreAlbumTotal } from '@/lib/library/genreAlbumBrowse'; import { libraryIsReady } from '@/lib/library/libraryReady'; +import { + fetchOfflineLocalGenreCatalog, + isOfflineBrowseActive, + offlineLocalBrowseEnabled, +} from '@/features/offline'; /** Drop genres with no indexed albums/tracks (stale server list or orphan rows). */ export function filterGenresWithContent(genres: SubsonicGenre[]): SubsonicGenre[] { @@ -121,6 +126,13 @@ export async function fetchGenreAlbumCount( ): Promise { if (!genre.trim()) return null; if (indexEnabled && serverId) { + if (isOfflineBrowseActive() && offlineLocalBrowseEnabled(serverId)) { + const genres = await fetchOfflineLocalGenreCatalog(serverId); + const match = genres.find( + g => g.value.localeCompare(genre, undefined, { sensitivity: 'accent' }) === 0, + ); + return match?.albumCount ?? null; + } const scopeKey = libraryScopeCacheKeyForServer(serverId); const cached = lookupGenreAlbumCount(serverId, genre, scopeKey); if (cached != null) return cached; @@ -152,6 +164,12 @@ export async function fetchGenreCatalog( const scopeKey = libraryScopeCacheKeyForServer(serverId); const cacheKey = genreCatalogCacheKey(serverId, scopeKey); + const offlineLocal = isOfflineBrowseActive() && offlineLocalBrowseEnabled(serverId); + + if (offlineLocal) { + return filterGenresWithContent(await fetchOfflineLocalGenreCatalog(serverId)); + } + const fresh = peekGenreCatalogCache(serverId, scopeKey, false); if (fresh) return fresh; diff --git a/src/features/search/hooks/useSongBrowseList.ts b/src/features/search/hooks/useSongBrowseList.ts index 04e281eb..1545aa0f 100644 --- a/src/features/search/hooks/useSongBrowseList.ts +++ b/src/features/search/hooks/useSongBrowseList.ts @@ -14,13 +14,14 @@ import { } from '@/lib/library/browseTextSearch'; import { useAuthStore } from '@/store/authStore'; import { useLibraryIndexStore } from '@/store/libraryIndexStore'; -import { useOfflineBrowseContext } from '@/features/offline'; -import { useOfflineBrowseReloadToken } from '@/features/offline'; import { fetchOfflineLocalBrowsableSongPage, offlineLocalBrowseEnabled, searchOfflineLocalBrowsableSongs, + useOfflineBrowseContext, + useOfflineBrowseReloadToken, } from '@/features/offline'; +import { useOfflineLocalBrowseReloadKey } from '@/store/localPlaybackBrowseRevision'; const PAGE_SIZE = 50; @@ -61,6 +62,10 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); const offlineBrowseActive = useOfflineBrowseContext().active; const offlineBrowseReloadTs = useOfflineBrowseReloadToken(); + const offlineLocalBrowseReloadKey = useOfflineLocalBrowseReloadKey( + serverId, + offlineBrowseActive, + ); const [debouncedQuery, setDebouncedQuery] = useState( () => initialRestore?.query.trim() ?? searchQuery.trim(), @@ -193,7 +198,7 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS return () => { cancelled = true; }; - }, [debouncedQuery, searchQuery, fetchSongPage, enabled, musicLibraryFilterVersion, offlineBrowseReloadTs]); + }, [debouncedQuery, searchQuery, fetchSongPage, enabled, musicLibraryFilterVersion, offlineBrowseReloadTs, offlineLocalBrowseReloadKey]); const loadMore = useCallback(async () => { if (!enabled || loading || !hasMore) return; diff --git a/src/features/search/pages/SearchBrowsePage.tsx b/src/features/search/pages/SearchBrowsePage.tsx index 7540e4cd..24c8199e 100644 --- a/src/features/search/pages/SearchBrowsePage.tsx +++ b/src/features/search/pages/SearchBrowsePage.tsx @@ -39,10 +39,12 @@ import { useSongBrowseList, type SongBrowseListRestore } from '@/features/search import { useAdvancedSearchRunner } from '@/features/search/hooks/useAdvancedSearchRunner'; import TracksPageChrome from '@/features/search/components/TracksPageChrome'; import SongBrowseSection from '@/features/search/components/SongBrowseSection'; +import { tracksBrowseDiscoveryChromeHidden } from '@/features/search/utils/tracksBrowseDiscoveryChrome'; import { useLiveSearchScopeStore, useScopedBrowseSearchQuery, } from '@/store/liveSearchScopeStore'; +import { useOfflineBrowseContext } from '@/features/offline'; const MOOD_UI_ENABLED = OXIMEDIA_MOOD_SEARCH_ENABLED; @@ -105,6 +107,7 @@ export default function SearchBrowsePage() { const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const serverId = useAuthStore(s => s.activeServerId); const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); + const offlineBrowseActive = useOfflineBrowseContext().active; const [activeSearch, setActiveSearch] = useState(() => restoreStash?.activeSearch ?? null); const [songsServerOffset, setSongsServerOffset] = useState(() => restoreStash?.songsServerOffset ?? 0); const [songsHasMore, setSongsHasMore] = useState(() => restoreStash?.songsHasMore ?? false); @@ -182,9 +185,12 @@ export default function SearchBrowsePage() { // eslint-disable-next-line react-hooks/refs () => leaveSnapshotRef.current != null, ); - const tracksDiscoveryHidden = - tracksSearchActive - || (isLeaveRestorePending && !!(restoreStash?.query.trim() || songBrowseInitialRestore?.query.trim())); + const tracksDiscoveryHidden = tracksBrowseDiscoveryChromeHidden({ + offlineBrowseActive, + tracksSearchActive, + leaveRestorePendingWithQuery: isLeaveRestorePending + && !!(restoreStash?.query.trim() || songBrowseInitialRestore?.query.trim()), + }); const handleTracksChromeLayoutReady = useCallback(() => { setTracksChromeLayoutReady(true); diff --git a/src/features/search/utils/tracksBrowseDiscoveryChrome.test.ts b/src/features/search/utils/tracksBrowseDiscoveryChrome.test.ts new file mode 100644 index 00000000..d1d324b4 --- /dev/null +++ b/src/features/search/utils/tracksBrowseDiscoveryChrome.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { tracksBrowseDiscoveryChromeHidden } from '@/features/search/utils/tracksBrowseDiscoveryChrome'; + +describe('tracksBrowseDiscoveryChromeHidden', () => { + it('hides discovery chrome during offline local browse', () => { + expect(tracksBrowseDiscoveryChromeHidden({ + offlineBrowseActive: true, + tracksSearchActive: false, + leaveRestorePendingWithQuery: false, + })).toBe(true); + }); + + it('shows discovery chrome when online and not searching', () => { + expect(tracksBrowseDiscoveryChromeHidden({ + offlineBrowseActive: false, + tracksSearchActive: false, + leaveRestorePendingWithQuery: false, + })).toBe(false); + }); + + it('hides discovery chrome during active track text search', () => { + expect(tracksBrowseDiscoveryChromeHidden({ + offlineBrowseActive: false, + tracksSearchActive: true, + leaveRestorePendingWithQuery: false, + })).toBe(true); + }); +}); diff --git a/src/features/search/utils/tracksBrowseDiscoveryChrome.ts b/src/features/search/utils/tracksBrowseDiscoveryChrome.ts new file mode 100644 index 00000000..3da03312 --- /dev/null +++ b/src/features/search/utils/tracksBrowseDiscoveryChrome.ts @@ -0,0 +1,10 @@ +/** Hide Tracks browse hero / Highly Rated / Random Pick discovery rails. */ +export function tracksBrowseDiscoveryChromeHidden(args: { + offlineBrowseActive: boolean; + tracksSearchActive: boolean; + leaveRestorePendingWithQuery: boolean; +}): boolean { + return args.offlineBrowseActive + || args.tracksSearchActive + || args.leaveRestorePendingWithQuery; +} diff --git a/src/features/sidebar/components/MobileMoreOverlay.tsx b/src/features/sidebar/components/MobileMoreOverlay.tsx index e26b78f5..ee524af6 100644 --- a/src/features/sidebar/components/MobileMoreOverlay.tsx +++ b/src/features/sidebar/components/MobileMoreOverlay.tsx @@ -7,7 +7,7 @@ import { useAuthStore } from '@/store/authStore'; import { ALL_NAV_ITEMS } from '@/config/navItems'; import { useLuckyMixAvailable } from '@/features/randomMix'; import { isOfflineSidebarNavAllowed } from '@/features/offline'; -import { useOfflineBrowseContext } from '@/features/offline'; +import { useReactiveOfflineBrowseContext } from '@/features/sidebar/hooks/useReactiveOfflineBrowseContext'; import { offlineBrowseNavFlags } from '@/features/offline'; const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']); @@ -16,7 +16,7 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void }) const { t } = useTranslation(); const sidebarItems = useSidebarStore(s => s.items); const randomNavMode = useAuthStore(s => s.randomNavMode); - const offlineCtx = useOfflineBrowseContext(); + const offlineCtx = useReactiveOfflineBrowseContext(); const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities); const isServerOffline = offlineCtx.active; const hasOfflineContent = offlineCtx.capabilities.manualPins; diff --git a/src/features/sidebar/components/Sidebar.tsx b/src/features/sidebar/components/Sidebar.tsx index ec27e701..c51c7b9d 100644 --- a/src/features/sidebar/components/Sidebar.tsx +++ b/src/features/sidebar/components/Sidebar.tsx @@ -24,7 +24,7 @@ import { useSidebarNavDnd } from '@/features/sidebar/hooks/useSidebarNavDnd'; import { useSidebarLibraryDropdown } from '@/features/sidebar/hooks/useSidebarLibraryDropdown'; import { useSidebarScrollVisible } from '@/features/sidebar/hooks/useSidebarScrollVisible'; import { isOfflineSidebarNavAllowed } from '@/features/offline'; -import { useOfflineBrowseContext } from '@/features/offline'; +import { useReactiveOfflineBrowseContext } from '@/features/sidebar/hooks/useReactiveOfflineBrowseContext'; import { offlineBrowseNavFlags } from '@/features/offline'; import { useSidebarPerfProbe } from '@/features/sidebar/hooks/useSidebarPerfProbe'; import SidebarPerfProbeModal from '@/features/sidebar/components/SidebarPerfProbeModal'; @@ -63,7 +63,7 @@ export default function Sidebar({ const syncJobFail = useDeviceSyncJobStore(s => s.failed); const syncJobTotal = useDeviceSyncJobStore(s => s.total); const isSyncing = syncJobStatus === 'running'; - const offlineCtx = useOfflineBrowseContext(); + const offlineCtx = useReactiveOfflineBrowseContext(); const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities); const serverId = useAuthStore(s => s.activeServerId ?? ''); const isLoggedIn = useAuthStore(s => s.isLoggedIn); diff --git a/src/features/sidebar/hooks/useReactiveOfflineBrowseContext.ts b/src/features/sidebar/hooks/useReactiveOfflineBrowseContext.ts new file mode 100644 index 00000000..702e632e --- /dev/null +++ b/src/features/sidebar/hooks/useReactiveOfflineBrowseContext.ts @@ -0,0 +1,27 @@ +import { useMemo } from 'react'; +import { + hasOfflineBrowseCapability, + offlineLocalBrowseEnabled, + useOfflineBrowseContext, + type OfflineBrowseContext, +} from '@/features/offline'; +import { useLocalPlaybackStore } from '@/store/localPlaybackStore'; + +/** Hot-cache rows update sidebar and shell offline gates without polling getState(). */ +export function useReactiveOfflineBrowseContext(): OfflineBrowseContext { + const ctx = useOfflineBrowseContext(); + const entries = useLocalPlaybackStore(s => s.entries); + return useMemo(() => { + const localLibrary = offlineLocalBrowseEnabled(ctx.serverId, entries); + const capabilities = { ...ctx.capabilities, localLibrary }; + return { + ...ctx, + capabilities, + hasBrowseCapability: hasOfflineBrowseCapability( + capabilities.localLibrary, + capabilities.favorites, + capabilities.manualPins, + ), + }; + }, [ctx, entries]); +} diff --git a/src/lib/library/albumGroupArtist.test.ts b/src/lib/library/albumGroupArtist.test.ts new file mode 100644 index 00000000..6f3f0794 --- /dev/null +++ b/src/lib/library/albumGroupArtist.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import type { LibraryTrackDto } from '@/lib/api/library'; +import { + pickAlbumGroupArtist, + pickAlbumGroupArtistFromTrackDtos, + resolveAlbumCreditArtistId, +} from '@/lib/library/albumGroupArtist'; + +function track( + overrides: Partial & Pick, +): LibraryTrackDto { + return { + serverId: 'srv-a', + title: 'Song', + album: 'Album', + durationSec: 1, + syncedAt: 1, + rawJson: {}, + ...overrides, + }; +} + +describe('albumGroupArtist', () => { + it('pickAlbumGroupArtist prefers albumArtist over track performer', () => { + expect(pickAlbumGroupArtist('Guest', 'Headliner')).toBe('Headliner'); + expect(pickAlbumGroupArtist('Guest', ' ')).toBe('Guest'); + }); + + it('pickAlbumGroupArtistFromTrackDtos uses MIN(artist) when albumArtist absent', () => { + const tracks = [ + track({ id: 't2', artist: 'Zebra' }), + track({ id: 't1', artist: 'Alpha' }), + ]; + expect(pickAlbumGroupArtistFromTrackDtos(tracks)).toBe('Alpha'); + }); + + it('pickAlbumGroupArtistFromTrackDtos uses MAX(albumArtist) when present on any track', () => { + const tracks = [ + track({ id: 't1', albumArtist: 'Alpha', artist: 'A' }), + track({ id: 't2', albumArtist: 'Zulu', artist: 'B' }), + ]; + expect(pickAlbumGroupArtistFromTrackDtos(tracks)).toBe('Zulu'); + }); + + it('resolveAlbumCreditArtistId prefers performer row matching credit name', () => { + const tracks = [ + track({ id: 't1', artist: 'Guest', artistId: 'art-guest', albumArtist: 'Headliner' }), + track({ id: 't2', artist: 'Headliner', artistId: 'art-head', albumArtist: 'Headliner' }), + ]; + expect(resolveAlbumCreditArtistId(tracks, 'Headliner')).toBe('art-head'); + }); +}); diff --git a/src/lib/library/albumGroupArtist.ts b/src/lib/library/albumGroupArtist.ts new file mode 100644 index 00000000..e71ec4ea --- /dev/null +++ b/src/lib/library/albumGroupArtist.ts @@ -0,0 +1,45 @@ +import type { LibraryTrackDto } from '@/lib/api/library'; + +/** Navidrome / OpenSubsonic album row display artist — non-empty albumArtist wins. */ +export function pickAlbumGroupArtist( + trackArtist: string | null | undefined, + albumArtist: string | null | undefined, +): string { + const aa = albumArtist?.trim(); + if (aa) return aa; + return trackArtist?.trim() ?? ''; +} + +/** Album credit name from grouped local tracks (`MAX(album_artist)` else `MIN(artist)` parity). */ +export function pickAlbumGroupArtistFromTrackDtos(tracks: LibraryTrackDto[]): string { + const albumArtists = tracks + .map(t => t.albumArtist?.trim()) + .filter((name): name is string => !!name); + if (albumArtists.length > 0) { + const sorted = [...albumArtists].sort((a, b) => a.localeCompare(b)); + return sorted[sorted.length - 1]!; + } + const performers = tracks + .map(t => t.artist?.trim()) + .filter((name): name is string => !!name) + .sort((a, b) => a.localeCompare(b)); + return performers[0] ?? ''; +} + +/** + * Best-effort album-artist id for offline aggregates — prefer a track row whose + * performer name matches the album credit (index artist-table parity). + */ +export function resolveAlbumCreditArtistId( + tracks: LibraryTrackDto[], + creditName: string, +): string { + const key = creditName.trim().toLowerCase(); + if (key) { + const match = tracks.find( + t => t.artistId && t.artist?.trim().toLowerCase() === key, + ); + if (match?.artistId) return match.artistId; + } + return tracks.find(t => t.artistId)?.artistId ?? ''; +} diff --git a/src/lib/library/artistLetterBucket.ts b/src/lib/library/artistLetterBucket.ts new file mode 100644 index 00000000..84f2a244 --- /dev/null +++ b/src/lib/library/artistLetterBucket.ts @@ -0,0 +1,60 @@ +import type { SubsonicArtist } from '@/lib/api/subsonicTypes'; + +/** Catch-all bucket for names that start with neither an A–Z letter nor a digit. */ +export const OTHER_BUCKET = 'OTHER'; + +/** Navidrome default (`IgnoredArticles` when the server omits the field). */ +export const DEFAULT_IGNORED_ARTICLES = 'The El La Los Las Le Les Os As O A'; + +/** Strip leading articles for sort/bucket keys (Navidrome `RemoveArticle` parity). */ +export function stripLeadingArticles( + name: string, + ignoredArticles = DEFAULT_IGNORED_ARTICLES, +): string { + const trimmed = name.trim(); + for (const article of ignoredArticles.split(' ').filter(Boolean)) { + const prefix = `${article} `; + if ( + trimmed.length >= prefix.length + && trimmed.slice(0, prefix.length).toLowerCase() === prefix.toLowerCase() + ) { + return trimmed.slice(prefix.length).trimStart(); + } + } + return trimmed; +} + +/** Sort key from display name — article strip + lowercase (Navidrome parity). */ +export function sortKeyFromDisplayName( + displayName: string, + ignoredArticles?: string | null, +): string { + const articles = ignoredArticles?.trim() || DEFAULT_IGNORED_ARTICLES; + return stripLeadingArticles(displayName, articles).toLowerCase(); +} + +/** + * Bucket an artist name into the alphabet index (after article stripping): + * - `#` → starts with a digit (0–9) + * - `A`–`Z` → starts with an ASCII letter on the sort key + * - `OTHER` → anything else (accents, CJK, Cyrillic, symbols, empty) + */ +export function artistBucketKey( + name: string, + ignoredArticles?: string | null, +): string { + const sortKey = sortKeyFromDisplayName(name, ignoredArticles); + const first = sortKey?.[0]; + if (!first) return OTHER_BUCKET; + if (/^[0-9]$/.test(first)) return '#'; + const up = first.toUpperCase(); + return /^[A-Z]$/.test(up) ? up : OTHER_BUCKET; +} + +/** Letter bucket for a browse row — uses the server's `ignoredArticles` when known. */ +export function artistLetterBucket( + artist: SubsonicArtist, + ignoredArticles?: string | null, +): string { + return artistBucketKey(artist.name, ignoredArticles); +} diff --git a/src/lib/localPlayback/browsablePlaybackTiers.ts b/src/lib/localPlayback/browsablePlaybackTiers.ts new file mode 100644 index 00000000..ad96e1bd --- /dev/null +++ b/src/lib/localPlayback/browsablePlaybackTiers.ts @@ -0,0 +1,13 @@ +export type BrowsableLocalPlaybackTier = 'library' | 'favorite-auto' | 'ephemeral'; + +/** Tiers with on-disk bytes eligible for offline local browse. */ +export function isBrowsableLocalPlaybackTier(tier: string): tier is BrowsableLocalPlaybackTier { + return tier === 'library' || tier === 'favorite-auto' || tier === 'ephemeral'; +} + +export function hasBrowsableLocalPlaybackBytes(entry: { + tier: string; + localPath?: string | null; +}): boolean { + return isBrowsableLocalPlaybackTier(entry.tier) && !!entry.localPath; +} diff --git a/src/store/localPlaybackBrowseRevision.ts b/src/store/localPlaybackBrowseRevision.ts new file mode 100644 index 00000000..dda02032 --- /dev/null +++ b/src/store/localPlaybackBrowseRevision.ts @@ -0,0 +1,64 @@ +import { useMemo } from 'react'; +import type { LocalPlaybackEntry } from '@/store/localPlaybackStore'; +import { useLocalPlaybackStore } from '@/store/localPlaybackStore'; +import { hasBrowsableLocalPlaybackBytes } from '@/lib/localPlayback/browsablePlaybackTiers'; +import { entryBelongsToServer } from '@/store/localPlaybackResolve'; +import { useOfflineLocalLibrarySyncRevision } from '@/store/offlineLocalLibrarySyncRevision'; + +function listBrowsableLocalEntries( + serverId: string, + entries: Record, +): LocalPlaybackEntry[] { + return Object.values(entries).filter( + e => hasBrowsableLocalPlaybackBytes(e) && entryBelongsToServer(e, serverId), + ); +} + +/** Stable revision for on-disk browse bytes — bumps when pins or hot-cache rows change. */ +export function offlineLocalBrowseRevision( + serverId: string, + entries: Record, +): string { + return listBrowsableLocalEntries(serverId, entries) + .map(e => `${e.trackId}:${e.tier}:${e.cachedAt}`) + .sort() + .join('\0'); +} + +export function countBrowsableLocalEntries( + serverId: string, + entries: Record, +): number { + return listBrowsableLocalEntries(serverId, entries).length; +} + +/** Reactive local-bytes revision for offline browse reload keys. */ +export function useOfflineLocalBrowseRevision( + serverId: string | null | undefined, +): string { + const entries = useLocalPlaybackStore(s => s.entries); + return useMemo( + () => (serverId ? offlineLocalBrowseRevision(serverId, entries) : ''), + [serverId, entries], + ); +} + +/** Entries + library sync revisions for offline browse catalog reload keys. */ +export function useOfflineLocalBrowseReloadKey( + serverId: string | null | undefined, + offlineBrowseActive: boolean, +): string { + const entriesRev = useOfflineLocalBrowseRevision(offlineBrowseActive ? serverId : null); + const syncRev = useOfflineLocalLibrarySyncRevision(offlineBrowseActive ? serverId : null); + return useMemo( + () => (offlineBrowseActive ? `${entriesRev}\0${syncRev}` : ''), + [offlineBrowseActive, entriesRev, syncRev], + ); +} + +export function countLocalBrowsableTracksFromEntries( + serverId: string, + entries: Record, +): number { + return countBrowsableLocalEntries(serverId, entries); +} diff --git a/src/store/offlineLocalLibrarySyncRevision.test.ts b/src/store/offlineLocalLibrarySyncRevision.test.ts new file mode 100644 index 00000000..687a2c73 --- /dev/null +++ b/src/store/offlineLocalLibrarySyncRevision.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { LibrarySyncIdlePayload } from '@/lib/api/library/dto'; +import { useAuthStore } from '@/store/authStore'; + +const syncIdleHandlerRef = vi.hoisted(() => ({ + current: null as ((payload: LibrarySyncIdlePayload) => void) | null, +})); + +vi.mock('@/lib/api/library/events', () => ({ + subscribeLibrarySyncIdle: vi.fn(async (handler: (payload: LibrarySyncIdlePayload) => void) => { + syncIdleHandlerRef.current = handler; + return () => { + syncIdleHandlerRef.current = null; + }; + }), +})); + +import { + offlineLocalLibrarySyncRevision, + resetOfflineLocalLibrarySyncRevisionForTests, +} from '@/store/offlineLocalLibrarySyncRevision'; + +describe('offlineLocalLibrarySyncRevision', () => { + beforeEach(() => { + useAuthStore.setState({ + activeServerId: 'srv-a', + servers: [{ id: 'srv-a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' }], + }); + resetOfflineLocalLibrarySyncRevisionForTests(); + syncIdleHandlerRef.current = null; + }); + + it('bumps revision after successful sync-idle for index key and profile id', () => { + expect(offlineLocalLibrarySyncRevision('srv-a')).toBe(0); + syncIdleHandlerRef.current?.({ + serverId: 'a.test', + libraryScope: 'default', + kind: 'delta_sync', + ok: true, + error: null, + }); + expect(offlineLocalLibrarySyncRevision('srv-a')).toBe(1); + expect(offlineLocalLibrarySyncRevision('a.test')).toBe(1); + }); + + it('ignores failed sync-idle payloads', () => { + syncIdleHandlerRef.current?.({ + serverId: 'a.test', + libraryScope: 'default', + kind: 'delta_sync', + ok: false, + error: 'fail', + }); + expect(offlineLocalLibrarySyncRevision('srv-a')).toBe(0); + }); +}); diff --git a/src/store/offlineLocalLibrarySyncRevision.ts b/src/store/offlineLocalLibrarySyncRevision.ts new file mode 100644 index 00000000..244e3996 --- /dev/null +++ b/src/store/offlineLocalLibrarySyncRevision.ts @@ -0,0 +1,76 @@ +import { useSyncExternalStore } from 'react'; +import { subscribeLibrarySyncIdle } from '@/lib/api/library/events'; +import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup'; +import { resolveIndexKey } from '@/lib/server/serverIndexKey'; + +const syncRevisionByScope = new Map(); +const listeners = new Set<() => void>(); +let syncHookRegistered = false; + +function notifySyncRevisionListeners(): void { + for (const listener of listeners) { + listener(); + } +} + +function scopeKeysForServer(serverId: string): string[] { + const keys = new Set([serverId]); + keys.add(resolveIndexKey(serverId)); + const profileId = resolveServerIdForIndexKey(serverId); + if (profileId) keys.add(profileId); + return [...keys]; +} + +function bumpOfflineLocalLibrarySyncRevision(serverIdFromEvent: string): void { + for (const key of scopeKeysForServer(serverIdFromEvent)) { + syncRevisionByScope.set(key, (syncRevisionByScope.get(key) ?? 0) + 1); + } + notifySyncRevisionListeners(); +} + +function ensureOfflineLocalLibrarySyncHook(): void { + if (syncHookRegistered) return; + syncHookRegistered = true; + if (typeof subscribeLibrarySyncIdle !== 'function') return; + void subscribeLibrarySyncIdle(payload => { + if (payload.ok) { + bumpOfflineLocalLibrarySyncRevision(payload.serverId); + } + }); +} + +/** Monotonic revision bumped after successful library sync-idle for a server scope. */ +export function offlineLocalLibrarySyncRevision(serverId: string): number { + ensureOfflineLocalLibrarySyncHook(); + let max = 0; + for (const key of scopeKeysForServer(serverId)) { + max = Math.max(max, syncRevisionByScope.get(key) ?? 0); + } + return max; +} + +/** Reactive library sync revision for offline browse reload keys. */ +export function useOfflineLocalLibrarySyncRevision( + serverId: string | null | undefined, +): number { + ensureOfflineLocalLibrarySyncHook(); + return useSyncExternalStore( + onStoreChange => { + listeners.add(onStoreChange); + return () => listeners.delete(onStoreChange); + }, + () => (serverId ? offlineLocalLibrarySyncRevision(serverId) : 0), + () => 0, + ); +} + +/** Test-only reset. */ +export function resetOfflineLocalLibrarySyncRevisionForTests(): void { + syncRevisionByScope.clear(); + syncHookRegistered = false; +} + +/** Test-only bump without going through sync-idle events. */ +export function bumpOfflineLocalLibrarySyncRevisionForTests(serverId: string): void { + bumpOfflineLocalLibrarySyncRevision(serverId); +}