mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
fix(offline): on-disk-only local browse for hot cache and pins (#1243)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 ?? '';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user