mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
test(offline): mock useOfflineBrowseReloadToken submodule in useSongBrowseList
The offline move collapsed a single-module mock into a `@/features/offline` barrel mock, which shadowed the real `useOfflineBrowseContext` the test relied on. Mock the reload-token submodule directly so the barrel re-exports the stub while the sibling context hook stays live.
This commit is contained in:
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* Regression tests for the id-gating tuple pattern in `useArtistDetailData`.
|
||||
*
|
||||
* `info` (the SubsonicArtistInfo returned by getArtistInfo) is held as a
|
||||
* `{ id, value }` tuple internally and gated on id-match at the return
|
||||
* statement. Without this gate, navigating between /artist/A → /artist/B
|
||||
* would render one frame with A's `largeImageUrl` paired with B's id —
|
||||
* exactly the cache-mismatch shape that PR #732 fixed for the queue info
|
||||
* panel and that the shared ArtistCard (now used on this page) would
|
||||
* otherwise persist into IndexedDB.
|
||||
*/
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { SubsonicArtistInfo } from '../api/subsonicTypes';
|
||||
|
||||
vi.mock('../api/subsonicArtists');
|
||||
vi.mock('../api/subsonicSearch');
|
||||
|
||||
import { getArtist, getArtistInfo, getTopSongs } from '../api/subsonicArtists';
|
||||
import { search } from '../api/subsonicSearch';
|
||||
import { useArtistDetailData } from './useArtistDetailData';
|
||||
|
||||
const mockArtistInfo = vi.mocked(getArtistInfo) as unknown as {
|
||||
mockImplementation: (impl: (id: string) => Promise<SubsonicArtistInfo | null>) => void;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getTopSongs).mockResolvedValue([]);
|
||||
vi.mocked(search).mockResolvedValue({ songs: [], albums: [], artists: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function routerWrapper({ children }: { children: React.ReactNode }) {
|
||||
return React.createElement(MemoryRouter, null, children);
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void;
|
||||
let reject!: (e?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('useArtistDetailData — id-gated info', () => {
|
||||
it('returns null info when id changes before the new fetch resolves', async () => {
|
||||
vi.mocked(getArtist).mockImplementation(async (id) => (
|
||||
{ artist: { id, name: id }, albums: [] }
|
||||
));
|
||||
const a = deferred<SubsonicArtistInfo | null>();
|
||||
const b = deferred<SubsonicArtistInfo | null>();
|
||||
mockArtistInfo.mockImplementation(async (id) => {
|
||||
if (id === 'A') return a.promise;
|
||||
if (id === 'B') return b.promise;
|
||||
return null;
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ id }: { id: string }) => useArtistDetailData(id),
|
||||
{ initialProps: { id: 'A' }, wrapper: routerWrapper },
|
||||
);
|
||||
|
||||
await act(async () => { a.resolve({ largeImageUrl: 'A.jpg' } as SubsonicArtistInfo); });
|
||||
await waitFor(() => expect(result.current.info).toEqual({ largeImageUrl: 'A.jpg' }));
|
||||
|
||||
// Switch to artist B. info must flip to null until B's fetch resolves —
|
||||
// it must never carry A's largeImageUrl paired with B's id, since the
|
||||
// shared ArtistCard would build a `coverArtCacheKey(B, 80)` from `id`
|
||||
// and pair it with A's URL inside CachedImage.
|
||||
rerender({ id: 'B' });
|
||||
expect(result.current.info).toBeNull();
|
||||
|
||||
await act(async () => { b.resolve({ largeImageUrl: 'B.jpg' } as SubsonicArtistInfo); });
|
||||
await waitFor(() => expect(result.current.info).toEqual({ largeImageUrl: 'B.jpg' }));
|
||||
});
|
||||
|
||||
it('keeps the album-artist credit on featured compilation albums', async () => {
|
||||
// "Also featured on" synthesises albums from search3 child songs. A
|
||||
// compilation has no flat `albumArtist` on the child — the credit lives in
|
||||
// OpenSubsonic's structured `albumArtists` (and/or `displayAlbumArtist`).
|
||||
// Dropping it made the card render "—" instead of "Various Artists".
|
||||
vi.mocked(getArtist).mockResolvedValue({ artist: { id: 'A', name: 'A' }, albums: [] });
|
||||
vi.mocked(search).mockResolvedValue({
|
||||
artists: [],
|
||||
albums: [],
|
||||
songs: [
|
||||
{
|
||||
id: 's1', title: 'Track', artistId: 'A', artist: 'A',
|
||||
album: 'A Compilation', albumId: 'comp1', coverArt: 'c1', duration: 100,
|
||||
albumArtists: [{ id: 'va', name: 'Various Artists' }],
|
||||
},
|
||||
{
|
||||
id: 's2', title: 'Other', artistId: 'A', artist: 'A',
|
||||
album: 'Display Only', albumId: 'comp2', coverArt: 'c2', duration: 90,
|
||||
displayAlbumArtist: 'Various Artists',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtistDetailData('A'), { wrapper: routerWrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.featuredAlbums).toHaveLength(2));
|
||||
const structured = result.current.featuredAlbums.find(a => a.id === 'comp1');
|
||||
const displayOnly = result.current.featuredAlbums.find(a => a.id === 'comp2');
|
||||
expect(structured?.artists).toEqual([{ id: 'va', name: 'Various Artists' }]);
|
||||
expect(displayOnly?.artist).toBe('Various Artists');
|
||||
});
|
||||
|
||||
it('ignores a late-arriving resolve for a stale id', async () => {
|
||||
vi.mocked(getArtist).mockImplementation(async (id) => (
|
||||
{ artist: { id, name: id }, albums: [] }
|
||||
));
|
||||
const a = deferred<SubsonicArtistInfo | null>();
|
||||
mockArtistInfo.mockImplementation(async (id) => {
|
||||
if (id === 'A') return a.promise;
|
||||
if (id === 'B') return { largeImageUrl: 'B.jpg' } as SubsonicArtistInfo;
|
||||
return null;
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ id }: { id: string }) => useArtistDetailData(id),
|
||||
{ initialProps: { id: 'A' }, wrapper: routerWrapper },
|
||||
);
|
||||
|
||||
rerender({ id: 'B' });
|
||||
await waitFor(() => expect(result.current.info).toEqual({ largeImageUrl: 'B.jpg' }));
|
||||
|
||||
// A's late resolve must not overwrite B's info.
|
||||
await act(async () => { a.resolve({ largeImageUrl: 'A.jpg' } as SubsonicArtistInfo); });
|
||||
expect(result.current.info).toEqual({ largeImageUrl: 'B.jpg' });
|
||||
});
|
||||
});
|
||||
@@ -1,248 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { search } from '../api/subsonicSearch';
|
||||
import { getArtist, getArtistForServer, getArtistInfo, getTopSongs } from '../api/subsonicArtists';
|
||||
import type {
|
||||
SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo, SubsonicSong,
|
||||
} from '../api/subsonicTypes';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useConnectionStatus } from './useConnectionStatus';
|
||||
import { loadArtistFromLibraryIndex } from '@/features/offline';
|
||||
import { useOfflineBrowseContext } from '@/features/offline';
|
||||
import { loadArtistFromLocalPlayback, offlineLocalBrowseEnabled } from '@/features/offline';
|
||||
import { readDetailServerId } from '../utils/navigation/detailServerScope';
|
||||
import { runLocalArtistLosslessBrowse } from '../utils/library/browseTextSearch';
|
||||
import { isLosslessSuffix } from '../utils/library/losslessFormats';
|
||||
|
||||
export interface UseArtistDetailDataOptions {
|
||||
/** When true, albums and top tracks are limited to lossless containers (local index preferred). */
|
||||
losslessOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface ArtistDetailDataResult {
|
||||
artist: SubsonicArtist | null;
|
||||
setArtist: React.Dispatch<React.SetStateAction<SubsonicArtist | null>>;
|
||||
albums: SubsonicAlbum[];
|
||||
topSongs: SubsonicSong[];
|
||||
info: SubsonicArtistInfo | null;
|
||||
featuredAlbums: SubsonicAlbum[];
|
||||
loading: boolean;
|
||||
artistInfoLoading: boolean;
|
||||
featuredLoading: boolean;
|
||||
isStarred: boolean;
|
||||
setIsStarred: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
losslessOnly: boolean;
|
||||
}
|
||||
|
||||
function filterNetworkArtistToLossless(
|
||||
albums: SubsonicAlbum[],
|
||||
songs: SubsonicSong[],
|
||||
): { albums: SubsonicAlbum[]; songs: SubsonicSong[] } {
|
||||
const losslessSongs = songs.filter(s => isLosslessSuffix(s.suffix));
|
||||
const albumIds = new Set(losslessSongs.map(s => s.albumId).filter(Boolean));
|
||||
return {
|
||||
albums: albums.filter(a => albumIds.has(a.id)),
|
||||
songs: losslessSongs,
|
||||
};
|
||||
}
|
||||
|
||||
export function useArtistDetailData(
|
||||
id: string | undefined,
|
||||
options: UseArtistDetailDataOptions = {},
|
||||
): ArtistDetailDataResult {
|
||||
const losslessOnly = options.losslessOnly ?? false;
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const [searchParams] = useSearchParams();
|
||||
const serverId = readDetailServerId(searchParams, activeServerId);
|
||||
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
|
||||
const { status: connStatus } = useConnectionStatus();
|
||||
const audiomuseNavidromeEnabled = useAuthStore(
|
||||
s => !!(serverId && s.audiomuseNavidromeByServer[serverId]),
|
||||
);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active && !!serverId;
|
||||
const preferLocalBytesOnly = offlineBrowseActive && offlineLocalBrowseEnabled(serverId);
|
||||
const preferLocalArtist = preferLocalBytesOnly
|
||||
|| (connStatus === 'disconnected' && favoritesOfflineEnabled && !!serverId);
|
||||
|
||||
const [artist, setArtist] = useState<SubsonicArtist | null>(null);
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [featuredAlbums, setFeaturedAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [topSongs, setTopSongs] = useState<SubsonicSong[]>([]);
|
||||
const [infoEntry, setInfoEntry] = useState<{ id: string; value: SubsonicArtistInfo | null } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
const [artistInfoLoading, setArtistInfoLoading] = useState(false);
|
||||
const [featuredLoading, setFeaturedLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let cancelled = false;
|
||||
// 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
|
||||
setLoading(true);
|
||||
setInfoEntry(null);
|
||||
setTopSongs([]);
|
||||
setFeaturedAlbums([]);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
if (offlineBrowseActive && !preferLocalBytesOnly) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (preferLocalArtist && serverId && id) {
|
||||
const local = preferLocalBytesOnly
|
||||
? await loadArtistFromLocalPlayback(serverId, id)
|
||||
: await loadArtistFromLibraryIndex(serverId, id);
|
||||
if (cancelled) return;
|
||||
if (local) {
|
||||
setArtist(local.artist);
|
||||
setIsStarred(!!local.artist.starred);
|
||||
setAlbums(local.albums);
|
||||
setTopSongs([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (preferLocalBytesOnly) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (losslessOnly && serverId) {
|
||||
const local = await runLocalArtistLosslessBrowse(serverId, id);
|
||||
if (cancelled) return;
|
||||
if (local) {
|
||||
const artistData = serverId
|
||||
? await getArtistForServer(serverId, id).catch(() => null)
|
||||
: await getArtist(id).catch(() => null);
|
||||
if (cancelled) return;
|
||||
if (artistData) {
|
||||
setArtist(artistData.artist);
|
||||
setIsStarred(!!artistData.artist.starred);
|
||||
}
|
||||
setAlbums(local.albums);
|
||||
setTopSongs([...local.songs].sort((a, b) => (b.playCount ?? 0) - (a.playCount ?? 0)));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const artistData = serverId
|
||||
? await getArtistForServer(serverId, id)
|
||||
: await getArtist(id);
|
||||
if (cancelled) return;
|
||||
setArtist(artistData.artist);
|
||||
let nextAlbums = artistData.albums;
|
||||
setIsStarred(!!artistData.artist.starred);
|
||||
setLoading(false);
|
||||
|
||||
const songsData = await getTopSongs(artistData.artist.name).catch(() => [] as SubsonicSong[]);
|
||||
if (cancelled) return;
|
||||
let nextSongs = songsData ?? [];
|
||||
if (losslessOnly) {
|
||||
({ albums: nextAlbums, songs: nextSongs } = filterNetworkArtistToLossless(nextAlbums, nextSongs));
|
||||
}
|
||||
setAlbums(nextAlbums);
|
||||
setTopSongs(nextSongs);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
if (preferLocalArtist && serverId && id) {
|
||||
try {
|
||||
const local = preferLocalBytesOnly
|
||||
? await loadArtistFromLocalPlayback(serverId, id)
|
||||
: await loadArtistFromLibraryIndex(serverId, id);
|
||||
if (cancelled) return;
|
||||
if (local) {
|
||||
setArtist(local.artist);
|
||||
setIsStarred(!!local.artist.starred);
|
||||
setAlbums(local.albums);
|
||||
setTopSongs([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
console.error(err);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [id, losslessOnly, serverId, offlineBrowseActive, preferLocalArtist, preferLocalBytesOnly, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || preferLocalArtist) return;
|
||||
let cancelled = false;
|
||||
// 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
|
||||
setArtistInfoLoading(true);
|
||||
getArtistInfo(id, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined })
|
||||
.then(artistInfo => {
|
||||
if (!cancelled) setInfoEntry({ id, value: artistInfo ?? null });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setInfoEntry({ id, value: null });
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setArtistInfoLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [id, audiomuseNavidromeEnabled, preferLocalArtist]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || !artist || preferLocalArtist) return;
|
||||
const ownAlbumIds = new Set(albums.map(a => a.id));
|
||||
// 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
|
||||
setFeaturedLoading(true);
|
||||
search(artist.name, { songCount: 500, artistCount: 0, albumCount: 0 })
|
||||
.catch(() => ({ songs: [], albums: [], artists: [] }))
|
||||
.then(searchResults => {
|
||||
let featuredSongs = (searchResults.songs ?? []).filter(
|
||||
song => song.artistId === id && !ownAlbumIds.has(song.albumId),
|
||||
);
|
||||
if (losslessOnly) {
|
||||
featuredSongs = featuredSongs.filter(s => isLosslessSuffix(s.suffix));
|
||||
}
|
||||
const albumMap = new Map<string, SubsonicAlbum>();
|
||||
featuredSongs.forEach(song => {
|
||||
if (!albumMap.has(song.albumId)) {
|
||||
albumMap.set(song.albumId, {
|
||||
id: song.albumId,
|
||||
name: song.album,
|
||||
// search3 children carry the album-artist credit in OpenSubsonic's
|
||||
// structured `albumArtists` / `displayAlbumArtist` (e.g. "Various
|
||||
// Artists" on compilations), not the flat `albumArtist` field — keep
|
||||
// all of them so the card resolves a name instead of "—".
|
||||
artist: song.albumArtist ?? song.displayAlbumArtist ?? '',
|
||||
artistId: '',
|
||||
artists: song.albumArtists,
|
||||
coverArt: song.coverArt,
|
||||
songCount: 1,
|
||||
duration: song.duration,
|
||||
year: song.year,
|
||||
});
|
||||
} else {
|
||||
const a = albumMap.get(song.albumId)!;
|
||||
a.songCount++;
|
||||
a.duration += song.duration;
|
||||
}
|
||||
});
|
||||
setFeaturedAlbums([...albumMap.values()]);
|
||||
setFeaturedLoading(false);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [artist?.id, musicLibraryFilterVersion, losslessOnly, albums, preferLocalArtist]);
|
||||
|
||||
const info = infoEntry && infoEntry.id === id ? infoEntry.value : null;
|
||||
|
||||
return {
|
||||
artist, setArtist, albums, topSongs, info, featuredAlbums,
|
||||
loading, artistInfoLoading, featuredLoading,
|
||||
isStarred, setIsStarred,
|
||||
losslessOnly,
|
||||
};
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { getArtistInfoForServer } from '../api/subsonicArtists';
|
||||
import type { SubsonicArtistInfo, SubsonicOpenArtistRef } from '../api/subsonicTypes';
|
||||
import { makeCache } from '../utils/cache/nowPlayingCache';
|
||||
|
||||
const artistInfoCache = makeCache<SubsonicArtistInfo | null>();
|
||||
|
||||
function cacheKey(serverId: string, artistId: string): string {
|
||||
return `${serverId}:${artistId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches `getArtistInfo` for each ref with an id. Returns `undefined` for ids
|
||||
* still loading, `null` when fetch finished with no info.
|
||||
*/
|
||||
export function useArtistInfoBatch(
|
||||
serverId: string | undefined,
|
||||
refs: SubsonicOpenArtistRef[],
|
||||
similarArtistCount?: number,
|
||||
): Record<string, SubsonicArtistInfo | null | undefined> {
|
||||
const ids = useMemo(
|
||||
() => [...new Set(refs.map(r => r.id?.trim()).filter((id): id is string => Boolean(id)))],
|
||||
[refs],
|
||||
);
|
||||
const idsKey = ids.join('\x1e');
|
||||
|
||||
const [byId, setById] = useState<Record<string, SubsonicArtistInfo | null | undefined>>(() => {
|
||||
if (!serverId || ids.length === 0) return {};
|
||||
const seed: Record<string, SubsonicArtistInfo | null | undefined> = {};
|
||||
for (const id of ids) {
|
||||
const cached = artistInfoCache.get(cacheKey(serverId, id));
|
||||
if (cached !== undefined) seed[id] = cached;
|
||||
}
|
||||
return seed;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId || ids.length === 0) {
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setById({});
|
||||
return;
|
||||
}
|
||||
|
||||
const next: Record<string, SubsonicArtistInfo | null | undefined> = {};
|
||||
const pending: string[] = [];
|
||||
for (const id of ids) {
|
||||
const cached = artistInfoCache.get(cacheKey(serverId, id));
|
||||
if (cached !== undefined) {
|
||||
next[id] = cached;
|
||||
} else {
|
||||
next[id] = undefined;
|
||||
pending.push(id);
|
||||
}
|
||||
}
|
||||
setById(next);
|
||||
|
||||
if (pending.length === 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
void Promise.all(
|
||||
pending.map(async id => {
|
||||
try {
|
||||
const info = await getArtistInfoForServer(serverId, id, {
|
||||
similarArtistCount: similarArtistCount,
|
||||
});
|
||||
artistInfoCache.set(cacheKey(serverId, id), info ?? null);
|
||||
return [id, info ?? null] as const;
|
||||
} catch {
|
||||
artistInfoCache.set(cacheKey(serverId, id), null);
|
||||
return [id, null] as const;
|
||||
}
|
||||
}),
|
||||
).then(results => {
|
||||
if (cancelled) return;
|
||||
setById(prev => {
|
||||
const merged = { ...prev };
|
||||
for (const [id, info] of results) merged[id] = info;
|
||||
return merged;
|
||||
});
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
// Keyed on idsKey (the stable string form of `ids`); depending on the ids
|
||||
// array directly would re-fetch on every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [serverId, idsKey, similarArtistCount]);
|
||||
|
||||
return byId;
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useLocalPlaybackStore } from '../store/localPlaybackStore';
|
||||
import { useOfflineJobStore } from '@/features/offline';
|
||||
import { useArtistOfflineState } from './useArtistOfflineState';
|
||||
|
||||
describe('useArtistOfflineState', () => {
|
||||
beforeEach(() => {
|
||||
useOfflineJobStore.setState({ jobs: [], pinQueue: [], bulkProgress: {} });
|
||||
useLocalPlaybackStore.setState({ entries: {} });
|
||||
});
|
||||
|
||||
it('reports cached when every album is pinned', () => {
|
||||
useLocalPlaybackStore.setState({
|
||||
entries: {
|
||||
'srv:al-1': {
|
||||
serverIndexKey: 'srv',
|
||||
trackId: 't1',
|
||||
localPath: '/x',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
pinSource: { kind: 'artist', sourceId: 'al-1' },
|
||||
},
|
||||
'srv:al-2': {
|
||||
serverIndexKey: 'srv',
|
||||
trackId: 't2',
|
||||
localPath: '/y',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
pinSource: { kind: 'artist', sourceId: 'al-2' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useArtistOfflineState('artist-1', 'srv', ['al-1', 'al-2']),
|
||||
);
|
||||
expect(result.current.status).toBe('cached');
|
||||
});
|
||||
|
||||
it('reports queued when bulk progress is active but albums only wait in pin queue', () => {
|
||||
useOfflineJobStore.setState({
|
||||
bulkProgress: { 'artist-1': { done: 0, total: 2 } },
|
||||
pinQueue: [
|
||||
{ albumId: 'al-1', albumName: 'One', pinKind: 'artist', status: 'queued', queuedAt: 1 },
|
||||
{ albumId: 'al-2', albumName: 'Two', pinKind: 'artist', status: 'queued', queuedAt: 2 },
|
||||
],
|
||||
jobs: [],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useArtistOfflineState('artist-1', 'srv', ['al-1', 'al-2']),
|
||||
);
|
||||
expect(result.current.status).toBe('queued');
|
||||
expect(result.current.progress).toEqual({ done: 0, total: 2 });
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useLocalPlaybackStore } from '../store/localPlaybackStore';
|
||||
import { useOfflineJobStore } from '@/features/offline';
|
||||
import { isOfflinePinComplete } from '@/features/offline';
|
||||
|
||||
export type ArtistOfflineStatus = 'none' | 'queued' | 'downloading' | 'cached';
|
||||
|
||||
interface UseArtistOfflineStateResult {
|
||||
status: ArtistOfflineStatus;
|
||||
progress: { done: number; total: number } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offline discography status for an artist page. Uses persisted library pins
|
||||
* (not ephemeral bulkProgress) so "Discography cached" survives navigation.
|
||||
*/
|
||||
export function useArtistOfflineState(
|
||||
artistId: string,
|
||||
serverId: string,
|
||||
albumIds: string[],
|
||||
): UseArtistOfflineStateResult {
|
||||
useLocalPlaybackStore(s => s.entries);
|
||||
|
||||
const allPinned = albumIds.length > 0
|
||||
&& albumIds.every(id => isOfflinePinComplete(id, serverId));
|
||||
|
||||
const bulkDone = useOfflineJobStore(s => (artistId ? s.bulkProgress[artistId]?.done : undefined));
|
||||
const bulkTotal = useOfflineJobStore(s => (artistId ? s.bulkProgress[artistId]?.total : undefined));
|
||||
const hasQueuedAlbums = useOfflineJobStore(s =>
|
||||
albumIds.length > 0
|
||||
&& albumIds.some(id => s.pinQueue.some(p => p.albumId === id && p.status === 'queued')),
|
||||
);
|
||||
const hasDownloadingAlbums = useOfflineJobStore(s =>
|
||||
albumIds.length > 0
|
||||
&& albumIds.some(id =>
|
||||
s.pinQueue.some(p => p.albumId === id && p.status === 'downloading')
|
||||
|| s.jobs.some(j => j.albumId === id && (j.status === 'queued' || j.status === 'downloading')),
|
||||
),
|
||||
);
|
||||
|
||||
const bulkActive = bulkTotal !== undefined && bulkDone !== undefined && bulkDone < bulkTotal;
|
||||
const waitingInQueue = bulkActive && hasQueuedAlbums && !hasDownloadingAlbums;
|
||||
|
||||
const status: ArtistOfflineStatus = allPinned
|
||||
? 'cached'
|
||||
: hasDownloadingAlbums || (bulkActive && !waitingInQueue)
|
||||
? 'downloading'
|
||||
: waitingInQueue
|
||||
? 'queued'
|
||||
: 'none';
|
||||
|
||||
const progress = bulkActive && bulkDone !== undefined && bulkTotal !== undefined
|
||||
? { done: bulkDone, total: bulkTotal }
|
||||
: null;
|
||||
|
||||
return { status, progress };
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getMusicNetworkRuntime } from '../music-network';
|
||||
import { search } from '../api/subsonicSearch';
|
||||
import type { SubsonicArtist, SubsonicArtistInfo } from '../api/subsonicTypes';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
export interface ArtistSimilarArtistsResult {
|
||||
similarArtists: SubsonicArtist[];
|
||||
similarLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the "Similar Artists" list for the current artist:
|
||||
* - Default: Last.fm getSimilar → server search for each name → keep first exact match.
|
||||
* - With audiomuseNavidromeEnabled on: prefer info.similarArtist; fall back to Last.fm
|
||||
* when the server returns nothing and Last.fm is configured.
|
||||
*/
|
||||
export function useArtistSimilarArtists(
|
||||
artist: SubsonicArtist | null,
|
||||
info: SubsonicArtistInfo | null,
|
||||
artistInfoLoading: boolean,
|
||||
): ArtistSimilarArtistsResult {
|
||||
const audiomuseNavidromeEnabled = useAuthStore(
|
||||
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
|
||||
);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const enrichmentConfigured = useAuthStore(s => s.enrichmentPrimaryId !== null);
|
||||
|
||||
const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]);
|
||||
const [similarLoading, setSimilarLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!artist || audiomuseNavidromeEnabled || !enrichmentConfigured) return;
|
||||
// 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
|
||||
setSimilarArtists([]);
|
||||
setSimilarLoading(true);
|
||||
getMusicNetworkRuntime().getSimilarArtists(artist.name).then(async names => {
|
||||
if (names.length === 0) { setSimilarLoading(false); return; }
|
||||
const results = await Promise.all(
|
||||
names.slice(0, 30).map(name =>
|
||||
search(name, { artistCount: 3, albumCount: 0, songCount: 0 }).catch(() => ({ artists: [], albums: [], songs: [] }))
|
||||
)
|
||||
);
|
||||
const seen = new Set<string>([artist.id]);
|
||||
const found: SubsonicArtist[] = [];
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const targetName = names[i].toLowerCase();
|
||||
const match = results[i].artists.find(a => a.name.toLowerCase() === targetName);
|
||||
if (match && !seen.has(match.id)) {
|
||||
seen.add(match.id);
|
||||
found.push(match);
|
||||
}
|
||||
}
|
||||
setSimilarArtists(found);
|
||||
setSimilarLoading(false);
|
||||
}).catch(() => setSimilarLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [artist?.id, musicLibraryFilterVersion, audiomuseNavidromeEnabled, enrichmentConfigured]);
|
||||
|
||||
/** When AudioMuse is on but the server returns no similar artists, fall back to Last.fm (if configured). */
|
||||
useEffect(() => {
|
||||
if (!artist || !audiomuseNavidromeEnabled || !enrichmentConfigured) return;
|
||||
if (artistInfoLoading) return;
|
||||
if ((info?.similarArtist?.length ?? 0) > 0) return;
|
||||
|
||||
// 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
|
||||
setSimilarArtists([]);
|
||||
setSimilarLoading(true);
|
||||
getMusicNetworkRuntime().getSimilarArtists(artist.name).then(async names => {
|
||||
if (names.length === 0) { setSimilarLoading(false); return; }
|
||||
const results = await Promise.all(
|
||||
names.slice(0, 30).map(name =>
|
||||
search(name, { artistCount: 3, albumCount: 0, songCount: 0 }).catch(() => ({ artists: [], albums: [], songs: [] }))
|
||||
)
|
||||
);
|
||||
const seen = new Set<string>([artist.id]);
|
||||
const found: SubsonicArtist[] = [];
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const targetName = names[i].toLowerCase();
|
||||
const match = results[i].artists.find(a => a.name.toLowerCase() === targetName);
|
||||
if (match && !seen.has(match.id)) {
|
||||
seen.add(match.id);
|
||||
found.push(match);
|
||||
}
|
||||
}
|
||||
setSimilarArtists(found);
|
||||
setSimilarLoading(false);
|
||||
}).catch(() => setSimilarLoading(false));
|
||||
// Keyed on artist?.id / artist?.name; depending on the `artist` object would
|
||||
// re-run on every render when its identity changes but its id/name do not.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
artist?.id,
|
||||
artist?.name,
|
||||
musicLibraryFilterVersion,
|
||||
audiomuseNavidromeEnabled,
|
||||
artistInfoLoading,
|
||||
info?.similarArtist?.length,
|
||||
enrichmentConfigured,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audiomuseNavidromeEnabled) return;
|
||||
if ((info?.similarArtist?.length ?? 0) > 0) {
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSimilarArtists([]);
|
||||
setSimilarLoading(false);
|
||||
}
|
||||
}, [artist?.id, audiomuseNavidromeEnabled, info?.similarArtist?.length]);
|
||||
|
||||
return { similarArtists, similarLoading };
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import { getArtists } from '../api/subsonicArtists';
|
||||
import type { SubsonicArtist } from '../api/subsonicTypes';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { dedupeById } from '../utils/dedupeById';
|
||||
import {
|
||||
fetchLocalArtistCatalogChunk,
|
||||
fetchNetworkStarredArtists,
|
||||
} from '../utils/library/browseTextSearch';
|
||||
import { useOfflineBrowseContext } from '@/features/offline';
|
||||
import { useOfflineBrowseReloadToken } from '@/features/offline';
|
||||
import {
|
||||
fetchOfflineLocalArtistCatalogChunk,
|
||||
fetchOfflineLocalStarredArtists,
|
||||
offlineLocalBrowseEnabled,
|
||||
} from '@/features/offline';
|
||||
|
||||
/** Local-index artist catalog buffer grows by this many rows per background SQL chunk. */
|
||||
export const ARTIST_CATALOG_CHUNK_SIZE = 200;
|
||||
|
||||
export type ArtistsBrowseMode = 'slice' | 'network';
|
||||
|
||||
export type UseArtistsBrowseCatalogArgs = {
|
||||
serverId: string | null | undefined;
|
||||
indexEnabled: boolean;
|
||||
starredOnly: boolean;
|
||||
musicLibraryFilterVersion: number;
|
||||
};
|
||||
|
||||
export function useArtistsBrowseCatalog({
|
||||
serverId,
|
||||
indexEnabled,
|
||||
starredOnly,
|
||||
musicLibraryFilterVersion,
|
||||
}: UseArtistsBrowseCatalogArgs) {
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||
const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
|
||||
const [catalogArtists, setCatalogArtists] = useState<SubsonicArtist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [catalogHasMore, setCatalogHasMore] = useState(false);
|
||||
const [catalogLoadingMore, setCatalogLoadingMore] = useState(false);
|
||||
const [browseMode, setBrowseMode] = useState<ArtistsBrowseMode>('network');
|
||||
|
||||
const loadGenerationRef = useRef(0);
|
||||
const catalogOffsetRef = useRef(0);
|
||||
const catalogLoadingRef = useRef(false);
|
||||
|
||||
const loadCatalogChunk = useCallback(async (append: boolean) => {
|
||||
if (!serverId || catalogLoadingRef.current) return;
|
||||
const generation = loadGenerationRef.current;
|
||||
catalogLoadingRef.current = true;
|
||||
setCatalogLoadingMore(true);
|
||||
try {
|
||||
if (offlineBrowseActive) {
|
||||
if (!offlineLocalBrowseEnabled(serverId)) return;
|
||||
const chunk = await fetchOfflineLocalArtistCatalogChunk(
|
||||
serverId,
|
||||
catalogOffsetRef.current,
|
||||
ARTIST_CATALOG_CHUNK_SIZE,
|
||||
);
|
||||
if (generation !== loadGenerationRef.current || chunk == null) return;
|
||||
if (append) {
|
||||
setCatalogArtists(prev => {
|
||||
const merged = dedupeById([...prev, ...chunk.artists]);
|
||||
catalogOffsetRef.current = merged.length;
|
||||
return merged;
|
||||
});
|
||||
} else {
|
||||
setCatalogArtists(chunk.artists);
|
||||
catalogOffsetRef.current = chunk.artists.length;
|
||||
}
|
||||
setCatalogHasMore(chunk.hasMore);
|
||||
return;
|
||||
}
|
||||
const chunk = await fetchLocalArtistCatalogChunk(
|
||||
serverId,
|
||||
catalogOffsetRef.current,
|
||||
ARTIST_CATALOG_CHUNK_SIZE,
|
||||
);
|
||||
if (generation !== loadGenerationRef.current || chunk == null) return;
|
||||
if (append) {
|
||||
setCatalogArtists(prev => {
|
||||
const merged = dedupeById([...prev, ...chunk.artists]);
|
||||
catalogOffsetRef.current = merged.length;
|
||||
return merged;
|
||||
});
|
||||
} else {
|
||||
setCatalogArtists(chunk.artists);
|
||||
catalogOffsetRef.current = chunk.artists.length;
|
||||
}
|
||||
setCatalogHasMore(chunk.hasMore);
|
||||
setBrowseMode('slice');
|
||||
} finally {
|
||||
catalogLoadingRef.current = false;
|
||||
if (generation === loadGenerationRef.current) {
|
||||
setCatalogLoadingMore(false);
|
||||
}
|
||||
}
|
||||
}, [offlineBrowseActive, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const generation = ++loadGenerationRef.current;
|
||||
catalogOffsetRef.current = 0;
|
||||
catalogLoadingRef.current = false;
|
||||
// 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
|
||||
setCatalogArtists([]);
|
||||
setCatalogHasMore(false);
|
||||
setCatalogLoadingMore(false);
|
||||
setBrowseMode('network');
|
||||
setLoading(true);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
if (offlineBrowseActive) {
|
||||
if (!cancelled && generation === loadGenerationRef.current) {
|
||||
if (serverId && starredOnly && offlineLocalBrowseEnabled(serverId)) {
|
||||
setCatalogArtists((await fetchOfflineLocalStarredArtists(serverId)) ?? []);
|
||||
} else if (serverId && !starredOnly && offlineLocalBrowseEnabled(serverId)) {
|
||||
const first = await fetchOfflineLocalArtistCatalogChunk(
|
||||
serverId,
|
||||
0,
|
||||
ARTIST_CATALOG_CHUNK_SIZE,
|
||||
);
|
||||
setCatalogArtists(first?.artists ?? []);
|
||||
catalogOffsetRef.current = first?.artists.length ?? 0;
|
||||
setCatalogHasMore(first?.hasMore ?? false);
|
||||
} else {
|
||||
setCatalogArtists([]);
|
||||
setCatalogHasMore(false);
|
||||
}
|
||||
setBrowseMode('slice');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (starredOnly) {
|
||||
if (!cancelled && generation === loadGenerationRef.current) {
|
||||
setCatalogArtists(await fetchNetworkStarredArtists());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (indexEnabled && serverId) {
|
||||
const first = await fetchLocalArtistCatalogChunk(
|
||||
serverId,
|
||||
0,
|
||||
ARTIST_CATALOG_CHUNK_SIZE,
|
||||
);
|
||||
if (cancelled || generation !== loadGenerationRef.current) return;
|
||||
if (first != null) {
|
||||
setBrowseMode('slice');
|
||||
setCatalogArtists(first.artists);
|
||||
catalogOffsetRef.current = first.artists.length;
|
||||
setCatalogHasMore(first.hasMore);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!cancelled && generation === loadGenerationRef.current) {
|
||||
setCatalogArtists(await getArtists());
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
if (!cancelled && generation === loadGenerationRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [musicLibraryFilterVersion, indexEnabled, offlineBrowseActive, offlineBrowseReloadTs, serverId, starredOnly]);
|
||||
|
||||
return {
|
||||
catalogArtists,
|
||||
loading,
|
||||
catalogHasMore,
|
||||
catalogLoadingMore,
|
||||
browseMode,
|
||||
loadCatalogChunk,
|
||||
catalogLoadingRef,
|
||||
};
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import {
|
||||
DEFAULT_ARTIST_BROWSE_RETURN_STATE,
|
||||
type ArtistBrowseReturnState,
|
||||
type ArtistBrowseViewMode,
|
||||
isArtistsBrowsePath,
|
||||
useArtistBrowseSessionStore,
|
||||
} from '../store/artistBrowseSessionStore';
|
||||
import { isArtistDetailPath } from '../store/albumBrowseSessionStore';
|
||||
import { shouldRestoreArtistBrowseSession } from '../utils/navigation/albumDetailNavigation';
|
||||
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
|
||||
|
||||
export type ArtistBrowseScrollSnapshot = {
|
||||
scrollTop: number;
|
||||
visibleCount: number;
|
||||
};
|
||||
|
||||
function returnStateForNavigation(
|
||||
serverId: string,
|
||||
navigationType: NavigationType,
|
||||
locationState: unknown,
|
||||
): ArtistBrowseReturnState {
|
||||
if (!shouldRestoreArtistBrowseSession(navigationType, locationState) || !serverId) {
|
||||
return DEFAULT_ARTIST_BROWSE_RETURN_STATE;
|
||||
}
|
||||
return (
|
||||
useArtistBrowseSessionStore.getState().peekReturnStash(serverId)
|
||||
?? DEFAULT_ARTIST_BROWSE_RETURN_STATE
|
||||
);
|
||||
}
|
||||
|
||||
export function useArtistsBrowseFilters(
|
||||
serverId: string,
|
||||
scrollSnapshotRef?: RefObject<ArtistBrowseScrollSnapshot>,
|
||||
) {
|
||||
const navigationType = useNavigationType();
|
||||
const location = useLocation();
|
||||
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
|
||||
|
||||
const [letterFilter, setLetterFilter] = useState(
|
||||
() => returnStateForNavigation(serverId, navigationType, location.state).letterFilter,
|
||||
);
|
||||
const [starredOnly, setStarredOnly] = useState(
|
||||
() => returnStateForNavigation(serverId, navigationType, location.state).starredOnly,
|
||||
);
|
||||
const [viewMode, setViewMode] = useState<ArtistBrowseViewMode>(
|
||||
() => returnStateForNavigation(serverId, navigationType, location.state).viewMode,
|
||||
);
|
||||
|
||||
const browseStateRef = useRef<ArtistBrowseReturnState>(DEFAULT_ARTIST_BROWSE_RETURN_STATE);
|
||||
const restoredFromStashRef = useRef(false);
|
||||
const showArtistImages = useAuthStore(s => s.showArtistImages);
|
||||
|
||||
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
browseStateRef.current = {
|
||||
filter: useLiveSearchScopeStore.getState().query,
|
||||
letterFilter,
|
||||
starredOnly,
|
||||
viewMode,
|
||||
showArtistImages,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
restoredFromStashRef.current = false;
|
||||
}, [serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId) return;
|
||||
|
||||
if (shouldRestoreArtistBrowseSession(navigationType, location.state)) {
|
||||
restoredFromStashRef.current = true;
|
||||
const restored = useArtistBrowseSessionStore.getState().peekReturnStash(serverId);
|
||||
if (restored) {
|
||||
useLiveSearchScopeStore.getState().setQuery(restored.filter);
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLetterFilter(restored.letterFilter);
|
||||
setStarredOnly(restored.starredOnly);
|
||||
setViewMode(restored.viewMode);
|
||||
setShowArtistImages(restored.showArtistImages);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (restoredFromStashRef.current) return;
|
||||
|
||||
useArtistBrowseSessionStore.getState().clearReturnStash(serverId);
|
||||
useLiveSearchScopeStore.getState().setQuery('');
|
||||
setLetterFilter(DEFAULT_ARTIST_BROWSE_RETURN_STATE.letterFilter);
|
||||
setStarredOnly(false);
|
||||
setViewMode('grid');
|
||||
}, [serverId, navigationType, location.state, setShowArtistImages]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!serverId) return;
|
||||
const path = window.location.pathname;
|
||||
if (isArtistDetailPath(path)) {
|
||||
// Read at cleanup time on purpose: we want the scroll snapshot as it is
|
||||
// at navigation-away. Copying it at effect setup would stash a stale value.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const snapshot = scrollSnapshotRef?.current;
|
||||
useArtistBrowseSessionStore.getState().stashReturnState(serverId, {
|
||||
...browseStateRef.current,
|
||||
scrollTop: snapshot?.scrollTop,
|
||||
visibleCount: snapshot?.visibleCount,
|
||||
});
|
||||
} else if (!isArtistsBrowsePath(path)) {
|
||||
useArtistBrowseSessionStore.getState().clearReturnStash(serverId);
|
||||
}
|
||||
};
|
||||
}, [serverId, scrollSnapshotRef]);
|
||||
|
||||
return {
|
||||
letterFilter,
|
||||
setLetterFilter,
|
||||
starredOnly,
|
||||
setStarredOnly,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
};
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useLayoutEffect, useRef, type RefObject } from 'react';
|
||||
import type { Virtualizer } from '@tanstack/react-virtual';
|
||||
type BrowseScrollSnapshot = {
|
||||
scrollTop: number;
|
||||
visibleCount: number;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
scrollSnapshotRef: RefObject<BrowseScrollSnapshot>;
|
||||
getScrollRoot: () => HTMLElement | null;
|
||||
isScrollRestorePending: boolean;
|
||||
resetKey: string;
|
||||
viewMode: 'grid' | 'list';
|
||||
listVirtualize: boolean;
|
||||
listVirtualizer: Virtualizer<HTMLElement, Element>;
|
||||
};
|
||||
|
||||
/** Scroll to top when browse filters shrink the list (e.g. scoped text search). */
|
||||
export function useArtistsBrowseScrollReset({
|
||||
scrollSnapshotRef,
|
||||
getScrollRoot,
|
||||
isScrollRestorePending,
|
||||
resetKey,
|
||||
viewMode,
|
||||
listVirtualize,
|
||||
listVirtualizer,
|
||||
}: Args): void {
|
||||
const prevResetKeyRef = useRef(resetKey);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isScrollRestorePending) return;
|
||||
if (prevResetKeyRef.current === resetKey) return;
|
||||
prevResetKeyRef.current = resetKey;
|
||||
|
||||
const el = getScrollRoot();
|
||||
if (!el) return;
|
||||
|
||||
if (el.scrollTop !== 0) {
|
||||
el.scrollTop = 0;
|
||||
el.dispatchEvent(new Event('scroll', { bubbles: false }));
|
||||
}
|
||||
scrollSnapshotRef.current.scrollTop = 0;
|
||||
|
||||
if (listVirtualize && viewMode === 'list') listVirtualizer.scrollToOffset(0);
|
||||
}, [
|
||||
resetKey,
|
||||
isScrollRestorePending,
|
||||
getScrollRoot,
|
||||
scrollSnapshotRef,
|
||||
viewMode,
|
||||
listVirtualize,
|
||||
listVirtualizer,
|
||||
]);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
|
||||
import {
|
||||
peekArtistBrowseScrollRestore,
|
||||
useArtistBrowseSessionStore,
|
||||
} from '../store/artistBrowseSessionStore';
|
||||
import { shouldRestoreArtistBrowseSession } from '../utils/navigation/albumDetailNavigation';
|
||||
|
||||
type PendingScroll = {
|
||||
scrollTop: number;
|
||||
visibleCount: number;
|
||||
};
|
||||
|
||||
export type UseArtistsBrowseScrollRestoreArgs = {
|
||||
serverId: string;
|
||||
scrollBodyEl: HTMLElement | null;
|
||||
visibleCount: number;
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
loadMore: () => void;
|
||||
};
|
||||
|
||||
export type UseArtistsBrowseScrollRestoreResult = {
|
||||
isScrollRestorePending: boolean;
|
||||
};
|
||||
|
||||
function readPendingScrollRestore(
|
||||
serverId: string,
|
||||
navigationType: NavigationType,
|
||||
locationState: unknown,
|
||||
): PendingScroll | null {
|
||||
if (!shouldRestoreArtistBrowseSession(navigationType, locationState) || !serverId) return null;
|
||||
return peekArtistBrowseScrollRestore(serverId);
|
||||
}
|
||||
|
||||
/** Restore Artists in-page scroll after returning from artist detail. */
|
||||
export function useArtistsBrowseScrollRestore({
|
||||
serverId,
|
||||
scrollBodyEl,
|
||||
visibleCount,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
loadMore,
|
||||
}: UseArtistsBrowseScrollRestoreArgs): UseArtistsBrowseScrollRestoreResult {
|
||||
const navigationType = useNavigationType();
|
||||
const location = useLocation();
|
||||
const initRef = useRef(false);
|
||||
const pendingRef = useRef<PendingScroll | null>(null);
|
||||
const doneRef = useRef(false);
|
||||
|
||||
// React Compiler refs rule: ref used as a once-only init guard (checked before first assignment); not render data.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
if (!initRef.current) {
|
||||
initRef.current = true;
|
||||
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
pendingRef.current = readPendingScrollRestore(serverId, navigationType, location.state);
|
||||
}
|
||||
|
||||
const [isScrollRestorePending, setIsScrollRestorePending] = useState(
|
||||
() => readPendingScrollRestore(serverId, navigationType, location.state) !== null,
|
||||
);
|
||||
|
||||
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
useLayoutEffect(() => {
|
||||
const pending = pendingRef.current;
|
||||
if (doneRef.current || !pending) return;
|
||||
if (!scrollBodyEl || loading) return;
|
||||
|
||||
const needsMore = visibleCount < pending.visibleCount && hasMore;
|
||||
if (needsMore) {
|
||||
if (!loadingMore) loadMore();
|
||||
return;
|
||||
}
|
||||
if (loadingMore) return;
|
||||
|
||||
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
scrollBodyEl.scrollTop = pending.scrollTop;
|
||||
scrollBodyEl.dispatchEvent(new Event('scroll', { bubbles: false }));
|
||||
pendingRef.current = null;
|
||||
doneRef.current = true;
|
||||
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsScrollRestorePending(false);
|
||||
useArtistBrowseSessionStore.getState().clearReturnStash(serverId);
|
||||
}, [
|
||||
scrollBodyEl,
|
||||
visibleCount,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
loadMore,
|
||||
serverId,
|
||||
]);
|
||||
|
||||
return { isScrollRestorePending };
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { SubsonicArtist } from '../api/subsonicTypes';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { ALL_SENTINEL, artistLetterBucket, compareBuckets, type ArtistListFlatRow } from '../utils/componentHelpers/artistsHelpers';
|
||||
|
||||
interface UseArtistsFilteringArgs {
|
||||
artists: SubsonicArtist[];
|
||||
filter: string;
|
||||
letterFilter: string;
|
||||
starredOnly: boolean;
|
||||
visibleCount: number;
|
||||
viewMode: 'grid' | 'list';
|
||||
/** Server `ignoredArticles` when known (local index); omit for Navidrome default. */
|
||||
ignoredArticles?: string | null;
|
||||
}
|
||||
|
||||
interface UseArtistsFilteringResult {
|
||||
filtered: SubsonicArtist[];
|
||||
visible: SubsonicArtist[];
|
||||
hasMore: boolean;
|
||||
groups: Record<string, SubsonicArtist[]>;
|
||||
letters: string[];
|
||||
artistListFlatRows: ArtistListFlatRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoised filter + group pipeline for the artists page. Reading
|
||||
* `starredOverrides` here keeps the star-toggle reactive without
|
||||
* dragging the full player store through Artists.tsx props.
|
||||
*
|
||||
* Walking 5000+ artists per render was measurable — every cheap state
|
||||
* update (selection mode, view mode, page size) used to re-filter the
|
||||
* whole list. With this hook the three artist arrays
|
||||
* (filtered → visible → flat-rows) only recompute when their explicit
|
||||
* deps change.
|
||||
*
|
||||
* Group-by-letter and flat-row construction short-circuit when the user
|
||||
* is on the grid view, since neither output is needed there.
|
||||
*/
|
||||
export function useArtistsFiltering({
|
||||
artists,
|
||||
filter,
|
||||
letterFilter,
|
||||
starredOnly,
|
||||
visibleCount,
|
||||
viewMode,
|
||||
ignoredArticles,
|
||||
}: UseArtistsFilteringArgs): UseArtistsFilteringResult {
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let out = artists;
|
||||
if (letterFilter !== ALL_SENTINEL) {
|
||||
out = out.filter(a => artistLetterBucket(a, ignoredArticles) === letterFilter);
|
||||
}
|
||||
if (filter) {
|
||||
const needle = filter.toLowerCase();
|
||||
out = out.filter(a => a.name.toLowerCase().includes(needle));
|
||||
}
|
||||
if (starredOnly) {
|
||||
out = out.filter(a => a.id in starredOverrides ? starredOverrides[a.id] : !!a.starred);
|
||||
}
|
||||
return out;
|
||||
}, [artists, letterFilter, filter, starredOnly, starredOverrides, ignoredArticles]);
|
||||
|
||||
const visible = useMemo(() => filtered.slice(0, visibleCount), [filtered, visibleCount]);
|
||||
const hasMore = visibleCount < filtered.length;
|
||||
|
||||
const { groups, letters } = useMemo(() => {
|
||||
if (viewMode !== 'list') return { groups: {} as Record<string, SubsonicArtist[]>, letters: [] as string[] };
|
||||
const g: Record<string, SubsonicArtist[]> = {};
|
||||
for (const a of visible) {
|
||||
const key = artistLetterBucket(a, ignoredArticles);
|
||||
if (!g[key]) g[key] = [];
|
||||
g[key].push(a);
|
||||
}
|
||||
return { groups: g, letters: Object.keys(g).sort(compareBuckets) };
|
||||
}, [visible, viewMode, ignoredArticles]);
|
||||
|
||||
const artistListFlatRows = useMemo((): ArtistListFlatRow[] => {
|
||||
if (viewMode !== 'list') return [];
|
||||
const out: ArtistListFlatRow[] = [];
|
||||
for (const letter of letters) {
|
||||
out.push({ kind: 'letter', letter });
|
||||
const group = groups[letter];
|
||||
for (let i = 0; i < group.length; i++) {
|
||||
out.push({ kind: 'artist', artist: group[i], isLastInLetter: i === group.length - 1 });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [viewMode, letters, groups]);
|
||||
|
||||
return { filtered, visible, hasMore, groups, letters, artistListFlatRows };
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* @deprecated Import {@link useClientSliceInfiniteScroll} instead.
|
||||
* Kept as a thin alias for existing call sites.
|
||||
*/
|
||||
export {
|
||||
useClientSliceInfiniteScroll as useArtistsInfiniteScroll,
|
||||
type UseClientSliceInfiniteScrollArgs as UseArtistsInfiniteScrollArgs,
|
||||
type UseClientSliceInfiniteScrollResult as UseArtistsInfiniteScrollResult,
|
||||
} from './useClientSliceInfiniteScroll';
|
||||
@@ -1,82 +0,0 @@
|
||||
import type { SubsonicArtist } from '../api/subsonicTypes';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
BROWSE_TEXT_DEBOUNCE_NETWORK_MS,
|
||||
BROWSE_TEXT_DEBOUNCE_RACE_MS,
|
||||
browseRaceCountsArtists,
|
||||
raceBrowseWithLocalFallback,
|
||||
runLocalBrowseArtists,
|
||||
runNetworkBrowseArtists,
|
||||
type LibrarySearchSurface,
|
||||
} from '../utils/library/browseTextSearch';
|
||||
import { useOfflineBrowseContext } from '@/features/offline';
|
||||
import { offlineLocalBrowseEnabled, searchOfflineLocalArtists } from '@/features/offline';
|
||||
|
||||
/**
|
||||
* Debounced artist/composer name search with local-vs-network race when the
|
||||
* library index is enabled. Returns `textSearchArtists` when a raced query is
|
||||
* active; callers should pass `effectiveFilter` (empty while raced) into their
|
||||
* local filter hook so the query is not applied twice.
|
||||
*/
|
||||
export function useBrowseArtistTextSearch(
|
||||
filter: string,
|
||||
indexEnabled: boolean,
|
||||
serverId: string | null | undefined,
|
||||
surface: LibrarySearchSurface = 'artists_browse',
|
||||
) {
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||
const [debouncedFilter, setDebouncedFilter] = useState('');
|
||||
const [textSearchArtists, setTextSearchArtists] = useState<SubsonicArtist[] | null>(null);
|
||||
const [textSearchLoading, setTextSearchLoading] = useState(false);
|
||||
const searchGenRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const ms = indexEnabled ? BROWSE_TEXT_DEBOUNCE_RACE_MS : BROWSE_TEXT_DEBOUNCE_NETWORK_MS;
|
||||
const timer = window.setTimeout(() => setDebouncedFilter(filter.trim()), ms);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [filter, indexEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const q = debouncedFilter;
|
||||
if (!q || !indexEnabled || !serverId) {
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setTextSearchArtists(null);
|
||||
setTextSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const gen = ++searchGenRef.current;
|
||||
const isStale = () => gen !== searchGenRef.current;
|
||||
setTextSearchLoading(true);
|
||||
|
||||
void (async () => {
|
||||
if (offlineBrowseActive) {
|
||||
const artists = offlineLocalBrowseEnabled(serverId)
|
||||
? await searchOfflineLocalArtists(serverId, q)
|
||||
: [];
|
||||
if (isStale()) return;
|
||||
setTextSearchArtists(artists);
|
||||
setTextSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
const outcome = await raceBrowseWithLocalFallback(
|
||||
isStale,
|
||||
() => runLocalBrowseArtists(serverId, q),
|
||||
() => runNetworkBrowseArtists(q),
|
||||
{
|
||||
surface,
|
||||
query: q,
|
||||
indexEnabled,
|
||||
counts: browseRaceCountsArtists,
|
||||
},
|
||||
);
|
||||
if (isStale()) return;
|
||||
setTextSearchArtists(outcome?.result ?? null);
|
||||
setTextSearchLoading(false);
|
||||
})();
|
||||
}, [debouncedFilter, indexEnabled, offlineBrowseActive, serverId, surface]);
|
||||
|
||||
const effectiveFilter = textSearchArtists != null ? '' : filter;
|
||||
return { textSearchArtists, textSearchLoading, effectiveFilter };
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { navigateToArtistDetail } from '../utils/navigation/albumDetailNavigation';
|
||||
|
||||
/** Navigate to artist detail, remembering the current page for the back button. */
|
||||
export function useNavigateToArtist() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
return useCallback(
|
||||
(artistId: string, opts?: { search?: string }) => {
|
||||
navigateToArtistDetail(navigate, location, artistId, opts);
|
||||
},
|
||||
[navigate, location],
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,10 @@ vi.mock('../utils/library/advancedSearchLocal', () => ({
|
||||
runLocalSongBrowse: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/offline', () => ({
|
||||
// Only the reload-token hook was stubbed pre-move (its own module); mock that
|
||||
// submodule directly so the barrel re-exports the stub while the real
|
||||
// `useOfflineBrowseContext` (a different submodule) stays live.
|
||||
vi.mock('@/features/offline/hooks/useOfflineBrowseReloadToken', () => ({
|
||||
useOfflineBrowseReloadToken: () => undefined,
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user