fix(offline): on-disk-only local browse for hot cache and pins (#1243)

This commit is contained in:
cucadmuh
2026-07-06 05:51:57 +03:00
committed by GitHub
parent 37190775cb
commit cf982a6ac2
26 changed files with 1614 additions and 222 deletions
+8
View File
@@ -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
+2 -2
View File
@@ -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);
+1
View File
@@ -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)',
],
},
{
+32 -12
View File
@@ -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<SubsonicAlbum[]>([]);
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(() => {
@@ -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<SubsonicArtist[]>([]);
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,
+2 -2
View File
@@ -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 });
+17 -62
View File
@@ -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 AZ 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, AZ, 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 (09)
* - `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);
+20 -27
View File
@@ -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<SubsonicGenre[]>(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(() => {
@@ -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<LibraryTrackDto[]> => []),
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);
});
});
+278 -84
View File
@@ -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<string, LocalPlaybackEntry> = 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<string, LocalPlaybackEntry>,
): 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<string, LocalPlaybackEntry>,
): 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<LibraryTrackDto[]> {
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<string, LibraryTrackDto[]>();
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<string, { name: string; albumIds: Set<string> }>();
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<string>() };
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<string, number[]>();
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<string, LibraryTrackDto[]>();
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<SubsonicArtist[] | null> {
export async function fetchOfflineLocalStarredArtists(
serverId: string,
creditMode: ArtistCreditMode = 'album',
): Promise<SubsonicArtist[] | null> {
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<string, boolean> = {},
): Promise<GenreFilterOption[]> {
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<SubsonicGenre[]> {
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,
};
@@ -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();
});
});
@@ -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<number | null> {
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;
@@ -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;
@@ -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<SearchOpts | null>(() => 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);
@@ -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);
});
});
@@ -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;
}
@@ -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;
+2 -2
View File
@@ -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);
@@ -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]);
}
+52
View File
@@ -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<LibraryTrackDto> & Pick<LibraryTrackDto, 'id'>,
): 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');
});
});
+45
View File
@@ -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 ?? '';
}
+60
View File
@@ -0,0 +1,60 @@
import type { SubsonicArtist } from '@/lib/api/subsonicTypes';
/** Catch-all bucket for names that start with neither an AZ 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 (09)
* - `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);
}
@@ -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;
}
+64
View File
@@ -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<string, LocalPlaybackEntry>,
): 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, LocalPlaybackEntry>,
): string {
return listBrowsableLocalEntries(serverId, entries)
.map(e => `${e.trackId}:${e.tier}:${e.cachedAt}`)
.sort()
.join('\0');
}
export function countBrowsableLocalEntries(
serverId: string,
entries: Record<string, LocalPlaybackEntry>,
): 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<string, LocalPlaybackEntry>,
): number {
return countBrowsableLocalEntries(serverId, entries);
}
@@ -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);
});
});
@@ -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<string, number>();
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<string>([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);
}