refactor(nowPlaying): co-locate now playing feature into features/nowPlaying

This commit is contained in:
Psychotoxical
2026-06-29 23:38:09 +02:00
parent e0e10e0034
commit 931c47e19e
29 changed files with 151 additions and 139 deletions
-265
View File
@@ -1,265 +0,0 @@
/**
* Regression tests for the id-gating tuple pattern in `useNowPlayingFetchers`.
*
* Each id-keyed slot (`artistInfo`, `songMeta`, `albumData`, `discography`) is
* held as a `{ id, value }` tuple internally and gated on id-match at the
* return statement. This guarantees that consumers building a `cacheKey` from
* the current id can never receive a value paired with a previously-current
* id — the bug that PR #732 fixed inside `NowPlayingInfo.tsx` and that this
* hook would otherwise leak into every other consumer (e.g. ArtistCard on the
* NowPlaying page).
*/
import { renderHook, act, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SubsonicArtistInfo, SubsonicSong, SubsonicAlbum, SubsonicArtist } from '../api/subsonicTypes';
vi.mock('../api/subsonicArtists');
vi.mock('../api/subsonicLibrary');
vi.mock('../api/bandsintown');
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
shouldAttemptSubsonicForServer: vi.fn(() => true),
}));
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
import { getArtistForServer, getArtistInfoForServer, getTopSongsForServer } from '../api/subsonicArtists';
import { getAlbumForServer, getSongForServer } from '../api/subsonicLibrary';
import { fetchBandsintownEvents } from '../api/bandsintown';
import { useNowPlayingFetchers, type NowPlayingFetchersDeps } from './useNowPlayingFetchers';
// Resolved return shapes of the mocked API calls — used to cast deliberately
// partial test fixtures without `any`.
type ArtistForServer = Awaited<ReturnType<typeof getArtistForServer>>;
type AlbumForServer = Awaited<ReturnType<typeof getAlbumForServer>>;
// The real getArtistInfo signature returns `Promise<SubsonicArtistInfo>`, but
// the hook treats `null` as the "no info available" case and stores it as
// such in its tuple. The tests mirror that — cast to a nullable-returning
// shape so we can mock the empty case without `as any` at every site.
const mockArtistInfo = vi.mocked(getArtistInfoForServer) as unknown as {
mockImplementation: (impl: (serverId: string, id: string) => Promise<SubsonicArtistInfo | null>) => void;
mockResolvedValue: (v: SubsonicArtistInfo | null) => void;
};
const baseDeps: NowPlayingFetchersDeps = {
songId: undefined,
artistId: undefined,
albumId: undefined,
artistName: '',
enableBandsintown: false,
audiomuseNavidromeEnabled: false,
enrichmentKey: '',
currentTrack: null,
subsonicServerId: 'srv1',
fetchEnabled: true,
};
beforeEach(() => {
vi.mocked(getTopSongsForServer).mockResolvedValue([]);
vi.mocked(getArtistForServer).mockResolvedValue({ albums: [] } as unknown as ArtistForServer);
vi.mocked(fetchBandsintownEvents).mockResolvedValue([]);
});
afterEach(() => {
vi.clearAllMocks();
});
/** Deferred promise helper — lets a test step the fetch resolution manually. */
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('useNowPlayingFetchers — id-gated artistInfo', () => {
it('returns null artistInfo while the previously-resolved info belongs to a different artistId', async () => {
const a = deferred<SubsonicArtistInfo | null>();
const b = deferred<SubsonicArtistInfo | null>();
mockArtistInfo.mockImplementation(async (_sid, id) => {
if (id === 'art-A') return a.promise;
if (id === 'art-B') return b.promise;
return null;
});
const { result, rerender } = renderHook(
({ artistId }: { artistId: string }) =>
useNowPlayingFetchers({ ...baseDeps, artistId, artistName: artistId }),
{ initialProps: { artistId: 'art-A' } },
);
// Before any resolve, no info yet.
expect(result.current.artistInfo).toBeNull();
// Resolve A — artistInfo becomes A's info.
await act(async () => { a.resolve({ largeImageUrl: 'A.jpg' } as SubsonicArtistInfo); });
await waitFor(() => {
expect(result.current.artistInfo).toEqual({ largeImageUrl: 'A.jpg' });
});
// Switch to artistId B. Without id-gating, artistInfo would still be A's
// info paired with the new B id — the bug that PR #732 fixed in the queue
// info panel. With gating, artistInfo flips to null immediately.
rerender({ artistId: 'art-B' });
expect(result.current.artistInfo).toBeNull();
// Resolve B — artistInfo now becomes B's info, never paired with A.
await act(async () => { b.resolve({ largeImageUrl: 'B.jpg' } as SubsonicArtistInfo); });
await waitFor(() => {
expect(result.current.artistInfo).toEqual({ largeImageUrl: 'B.jpg' });
});
});
it('does not leak a late-arriving resolve for a stale artistId', async () => {
// Race: artist A's fetch resolves AFTER the consumer switched to B.
const a = deferred<SubsonicArtistInfo | null>();
mockArtistInfo.mockImplementation(async (_sid, id) => {
if (id === 'art-A') return a.promise;
if (id === 'art-B') return { largeImageUrl: 'B.jpg' } as SubsonicArtistInfo;
return null;
});
const { result, rerender } = renderHook(
({ artistId }: { artistId: string }) =>
useNowPlayingFetchers({ ...baseDeps, artistId, artistName: artistId }),
{ initialProps: { artistId: 'art-A' } },
);
// Switch to B before A resolves.
rerender({ artistId: 'art-B' });
await waitFor(() => {
expect(result.current.artistInfo).toEqual({ largeImageUrl: 'B.jpg' });
});
// Late A resolve must not overwrite the displayed value for B.
await act(async () => { a.resolve({ largeImageUrl: 'A.jpg' } as SubsonicArtistInfo); });
expect(result.current.artistInfo).toEqual({ largeImageUrl: 'B.jpg' });
});
});
describe('useNowPlayingFetchers — id-gated songMeta / albumData / discography', () => {
it('gates songMeta on songId match', async () => {
const s1 = deferred<SubsonicSong | null>();
const s2 = deferred<SubsonicSong | null>();
vi.mocked(getSongForServer).mockImplementation(async (_sid, id) => {
if (id === 's1') return s1.promise;
if (id === 's2') return s2.promise;
return null;
});
const { result, rerender } = renderHook(
({ songId }: { songId: string }) =>
useNowPlayingFetchers({ ...baseDeps, songId }),
{ initialProps: { songId: 's1' } },
);
await act(async () => { s1.resolve({ id: 's1', title: 'Track 1' } as SubsonicSong); });
await waitFor(() => expect(result.current.songMeta?.title).toBe('Track 1'));
rerender({ songId: 's2' });
expect(result.current.songMeta).toBeNull();
await act(async () => { s2.resolve({ id: 's2', title: 'Track 2' } as SubsonicSong); });
await waitFor(() => expect(result.current.songMeta?.title).toBe('Track 2'));
});
it('gates albumData on albumId match', async () => {
const al1 = deferred<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null>();
const al2 = deferred<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null>();
vi.mocked(getAlbumForServer).mockImplementation(async (_sid, id) => {
if (id === 'alb1') return al1.promise as unknown as AlbumForServer;
if (id === 'alb2') return al2.promise as unknown as AlbumForServer;
return null as unknown as AlbumForServer;
});
const { result, rerender } = renderHook(
({ albumId }: { albumId: string }) =>
useNowPlayingFetchers({ ...baseDeps, albumId }),
{ initialProps: { albumId: 'alb1' } },
);
await act(async () => { al1.resolve({ album: { id: 'alb1', name: 'A1' } as SubsonicAlbum, songs: [] }); });
await waitFor(() => expect(result.current.albumData?.album.name).toBe('A1'));
rerender({ albumId: 'alb2' });
expect(result.current.albumData).toBeNull();
await act(async () => { al2.resolve({ album: { id: 'alb2', name: 'A2' } as SubsonicAlbum, songs: [] }); });
await waitFor(() => expect(result.current.albumData?.album.name).toBe('A2'));
});
it('gates discography on artistId match (empty fallback while gated)', async () => {
const d1 = deferred<{ artist: Partial<SubsonicArtist>; albums: SubsonicAlbum[] }>();
const d2 = deferred<{ artist: Partial<SubsonicArtist>; albums: SubsonicAlbum[] }>();
vi.mocked(getArtistForServer).mockImplementation(async (_sid, id) => {
if (id === 'art-D1') return d1.promise as unknown as ArtistForServer;
if (id === 'art-D2') return d2.promise as unknown as ArtistForServer;
return { albums: [] } as unknown as ArtistForServer;
});
mockArtistInfo.mockResolvedValue(null);
const { result, rerender } = renderHook(
({ artistId }: { artistId: string }) =>
useNowPlayingFetchers({ ...baseDeps, artistId, artistName: artistId }),
{ initialProps: { artistId: 'art-D1' } },
);
await act(async () => { d1.resolve({ artist: {}, albums: [{ id: 'al-D1' } as SubsonicAlbum] }); });
await waitFor(() => expect(result.current.discography.map(a => a.id)).toEqual(['al-D1']));
rerender({ artistId: 'art-D2' });
expect(result.current.discography).toEqual([]);
await act(async () => { d2.resolve({ artist: {}, albums: [{ id: 'al-D2' } as SubsonicAlbum] }); });
await waitFor(() => expect(result.current.discography.map(a => a.id)).toEqual(['al-D2']));
});
});
describe('useNowPlayingFetchers — local-playback metadata', () => {
// Regression: the metadata gate must never pass the playing track id, or the
// guard's `psysonic-local://` skip would blank every Subsonic card whenever
// the track plays from hot-cache / offline bytes. Guard is called with the
// server id only.
// Keep the shared guard mock at its permissive default after the behaviour
// case below swaps in a trackId-sensitive implementation.
afterEach(() => {
vi.mocked(shouldAttemptSubsonicForServer).mockImplementation(() => true);
});
it('queries the network guard without a trackId', async () => {
const guard = vi.mocked(shouldAttemptSubsonicForServer);
renderHook(() => useNowPlayingFetchers({ ...baseDeps, songId: 'song-1', albumId: 'al-1', artistId: 'art-1', artistName: 'Artist' }));
await waitFor(() => expect(guard).toHaveBeenCalled());
for (const call of guard.mock.calls) {
expect(call).toHaveLength(1);
expect(call[1]).toBeUndefined();
}
});
it('still loads album / discography / top songs when the playback bytes are local', async () => {
// Mirror the real guard: a byte-style call (with a trackId resolving to
// psysonic-local://) is blocked, but the metadata gate (server id only) is
// allowed. If the hook ever passed the trackId again, every fetch below
// would be gated off and the cards would blank — exactly the #1042 bug.
// Ids are unique to this test so the shared module caches don't short-circuit it.
vi.mocked(shouldAttemptSubsonicForServer).mockImplementation(
(_serverId, trackId) => trackId === undefined,
);
vi.mocked(getSongForServer).mockResolvedValue({ id: 'np-song', title: 'Local Track' } as SubsonicSong);
vi.mocked(getAlbumForServer).mockResolvedValue(
{ album: { id: 'np-al', name: 'Album' } as SubsonicAlbum, songs: [] },
);
vi.mocked(getArtistForServer).mockResolvedValue({ albums: [{ id: 'np-al' } as SubsonicAlbum] } as unknown as ArtistForServer);
vi.mocked(getTopSongsForServer).mockResolvedValue([{ id: 'np-top' } as unknown as SubsonicSong]);
const { result } = renderHook(() =>
useNowPlayingFetchers({ ...baseDeps, songId: 'np-song', albumId: 'np-al', artistId: 'np-art', artistName: 'NP Artist' }),
);
await waitFor(() => expect(getAlbumForServer).toHaveBeenCalledWith('srv1', 'np-al'));
await waitFor(() => expect(getArtistForServer).toHaveBeenCalledWith('srv1', 'np-art'));
await waitFor(() => expect(getTopSongsForServer).toHaveBeenCalledWith('srv1', 'NP Artist'));
await waitFor(() => expect(result.current.albumData?.album.id).toBe('np-al'));
await waitFor(() => expect(result.current.discography.map(a => a.id)).toEqual(['np-al']));
await waitFor(() => expect(result.current.topSongs.map(s => s.id)).toEqual(['np-top']));
});
});
-353
View File
@@ -1,353 +0,0 @@
import { useEffect, useState } from 'react';
import { getArtistInfoForServer } from '../api/subsonicArtists';
import type { SubsonicAlbum, SubsonicArtistInfo, SubsonicSong } from '../api/subsonicTypes';
import { resolveNpAlbum, resolveNpDiscography, resolveNpSongMeta, resolveNpTopSongs } from '../utils/library/nowPlayingMetadataResolve';
import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown';
import type { ArtistStats, TrackStats } from '../music-network';
import { getMusicNetworkRuntimeOrNull } from '../music-network';
import { makeCache } from '../utils/cache/nowPlayingCache';
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
import { useConnectionStatus } from './useConnectionStatus';
// Module-level TTL caches (shared across mounts)
const songMetaCache = makeCache<SubsonicSong | null>();
const artistInfoCache = makeCache<SubsonicArtistInfo | null>();
const albumCache = makeCache<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null>();
const topSongsCache = makeCache<SubsonicSong[]>();
const tourCache = makeCache<BandsintownEvent[]>();
const discographyCache = makeCache<SubsonicAlbum[]>();
const networkTrackCache = makeCache<TrackStats | null>();
const networkArtistCache = makeCache<ArtistStats | null>();
export interface NowPlayingFetchersDeps {
songId: string | undefined;
artistId: string | undefined;
albumId: string | undefined;
artistName: string;
enableBandsintown: boolean;
audiomuseNavidromeEnabled: boolean;
enrichmentKey: string;
currentTrack: { artist: string; title: string } | null;
/** Subsonic server for API calls — must match the playing queue server. */
subsonicServerId: string;
/**
* Caller intent / prerequisites only (e.g. "we have a playback server id").
* The network reachability decision — online, server reachable, and no
* trackId so local-cache playback still loads metadata — is made here via
* `shouldAttemptSubsonicForServer`; callers must not pre-apply that guard.
*/
fetchEnabled?: boolean;
}
export interface NowPlayingFetchersResult {
songMeta: SubsonicSong | null;
artistInfo: SubsonicArtistInfo | null;
albumData: { album: SubsonicAlbum; songs: SubsonicSong[] } | null;
topSongs: SubsonicSong[];
tourEvents: BandsintownEvent[];
tourLoading: boolean;
discography: SubsonicAlbum[];
networkTrack: TrackStats | null;
networkArtist: ArtistStats | null;
}
function subsonicCacheKey(serverId: string, id: string): string {
return serverId ? `${serverId}:${id}` : id;
}
// id-keyed slots are held as `{ id, value }` tuples and gated on id-match in
// the return statement. Without the gate, a track switch renders one frame
// with the previous track's value paired with the new id — consumers that
// build a cacheKey from the new id (e.g. CachedImage) would persist a
// mismatched blob in IndexedDB and never recover. See PR #732 for the same
// fix inside `NowPlayingInfo.tsx`.
type IdSlot<T> = { id: string; value: T } | null;
type KeySlot<T> = { key: string; value: T } | null;
function seedSlot<T>(id: string, lookup: (id: string) => T | undefined): IdSlot<T> {
if (!id) return null;
const cached = lookup(id);
return cached === undefined ? null : { id, value: cached };
}
function seedKeySlot<T>(key: string, lookup: (key: string) => T | undefined): KeySlot<T> {
if (!key) return null;
const cached = lookup(key);
return cached === undefined ? null : { key, value: cached };
}
export async function prewarmNowPlayingFetchers(
deps: NowPlayingFetchersDeps,
): Promise<void> {
const {
songId, artistId, albumId, artistName, enableBandsintown, audiomuseNavidromeEnabled,
enrichmentKey, currentTrack, subsonicServerId, fetchEnabled = true,
} = deps;
if (!fetchEnabled || !subsonicServerId) return;
// Index-first resolvers run whenever there's a server id (offline included) —
// each guards its own network fallback. artistInfo below is the one
// network-only job, so it keeps the reachability gate.
const jobs: Array<Promise<unknown>> = [];
if (songId) {
const cacheKey = subsonicCacheKey(subsonicServerId, songId);
if (songMetaCache.get(cacheKey) === undefined) {
jobs.push(
resolveNpSongMeta(subsonicServerId, songId)
.then(v => songMetaCache.set(cacheKey, v ?? null))
.catch(() => songMetaCache.set(cacheKey, null)),
);
}
}
if (artistId) {
const artistKey = subsonicCacheKey(subsonicServerId, artistId);
// artistInfo (bio/similar) is network-only — keep the reachability gate.
if (shouldAttemptSubsonicForServer(subsonicServerId) && artistInfoCache.get(artistKey) === undefined) {
jobs.push(
getArtistInfoForServer(subsonicServerId, artistId, {
similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined,
})
.then(v => artistInfoCache.set(artistKey, v ?? null))
.catch(() => artistInfoCache.set(artistKey, null)),
);
}
if (discographyCache.get(artistKey) === undefined) {
jobs.push(
resolveNpDiscography(subsonicServerId, artistId)
.then(albums => discographyCache.set(artistKey, albums))
.catch(() => discographyCache.set(artistKey, [])),
);
}
}
if (albumId) {
const cacheKey = subsonicCacheKey(subsonicServerId, albumId);
if (albumCache.get(cacheKey) === undefined) {
jobs.push(
resolveNpAlbum(subsonicServerId, albumId)
.then(v => albumCache.set(cacheKey, v))
.catch(() => albumCache.set(cacheKey, null)),
);
}
}
if (artistName) {
const cacheKey = subsonicCacheKey(subsonicServerId, artistName);
if (topSongsCache.get(cacheKey) === undefined) {
jobs.push(
resolveNpTopSongs(subsonicServerId, artistId, artistName)
.then(v => topSongsCache.set(cacheKey, v))
.catch(() => topSongsCache.set(cacheKey, [])),
);
}
if (enableBandsintown && tourCache.get(artistName) === undefined) {
jobs.push(
fetchBandsintownEvents(artistName)
.then(v => tourCache.set(artistName, v))
.catch(() => tourCache.set(artistName, [])),
);
}
}
const prewarmRuntime = getMusicNetworkRuntimeOrNull();
if (prewarmRuntime?.getEnrichmentPrimaryId() && currentTrack) {
const trackKey = `${currentTrack.artist} ${currentTrack.title} ${enrichmentKey}`;
if (networkTrackCache.get(trackKey) === undefined) {
jobs.push(
prewarmRuntime.getTrackStats({ title: currentTrack.title, artist: currentTrack.artist })
.then(v => networkTrackCache.set(trackKey, v))
.catch(() => networkTrackCache.set(trackKey, null)),
);
}
const artistKey = `${currentTrack.artist} ${enrichmentKey}`;
if (networkArtistCache.get(artistKey) === undefined) {
jobs.push(
prewarmRuntime.getArtistStats(currentTrack.artist)
.then(v => networkArtistCache.set(artistKey, v))
.catch(() => networkArtistCache.set(artistKey, null)),
);
}
}
await Promise.allSettled(jobs);
}
export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingFetchersResult {
const {
songId, artistId, albumId, artistName, enableBandsintown, audiomuseNavidromeEnabled,
enrichmentKey, currentTrack, subsonicServerId, fetchEnabled = true,
} = deps;
// id-keyed entity state — seeded from TTL cache so same-artist song switches
// are instant. Held as `{ id, value }` tuples and gated below.
const [songMetaEntry, setSongMetaEntry] = useState<IdSlot<SubsonicSong | null>>(() =>
seedSlot(songId && subsonicServerId ? songId : '', k => songMetaCache.get(subsonicCacheKey(subsonicServerId, k))));
const [artistInfoEntry, setArtistInfoEntry] = useState<IdSlot<SubsonicArtistInfo | null>>(() =>
seedSlot(artistId && subsonicServerId ? artistId : '', k => artistInfoCache.get(subsonicCacheKey(subsonicServerId, k))));
const [albumDataEntry, setAlbumDataEntry] = useState<IdSlot<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null>>(() =>
seedSlot(albumId && subsonicServerId ? albumId : '', k => albumCache.get(subsonicCacheKey(subsonicServerId, k))));
const [discographyEntry, setDiscographyEntry] = useState<IdSlot<SubsonicAlbum[]>>(() =>
seedSlot(artistId && subsonicServerId ? artistId : '', k => discographyCache.get(subsonicCacheKey(subsonicServerId, k))));
// Name-keyed / global state — no cacheKey/persistence hazard, kept as plain state.
const topSongsKey = artistName && subsonicServerId ? subsonicCacheKey(subsonicServerId, artistName) : '';
const tourKey = enableBandsintown && artistName ? artistName : '';
const [topSongsEntry, setTopSongsEntry] = useState<KeySlot<SubsonicSong[]>>(() =>
seedKeySlot(topSongsKey, k => topSongsCache.get(k)));
const [tourEventsEntry, setTourEventsEntry] = useState<KeySlot<BandsintownEvent[]>>(() =>
seedKeySlot(tourKey, k => tourCache.get(k)));
const [tourLoading, setTourLoading] = useState(false);
const networkTrackKey = currentTrack ? `${currentTrack.artist} ${currentTrack.title} ${enrichmentKey}` : '';
const networkArtistKey = artistName ? `${artistName} ${enrichmentKey}` : '';
const [networkTrackEntry, setNetworkTrackEntry] = useState<KeySlot<TrackStats | null>>(() =>
seedKeySlot(networkTrackKey, k => networkTrackCache.get(k)));
const [networkArtistEntry, setNetworkArtistEntry] = useState<KeySlot<ArtistStats | null>>(() =>
seedKeySlot(networkArtistKey, k => networkArtistCache.get(k)));
const { status: connStatus } = useConnectionStatus();
// Gate split (PR #1049): index-first resolvers run whenever there's a server id
// — they read SQLite even when the server is unreachable (the offline win) and
// guard their own network fallback. Only artistInfo (bio/similar, no index) is
// network-only, so it keeps the reachability gate.
const indexFetchAllowed = fetchEnabled && !!subsonicServerId;
const networkOnlyAllowed = indexFetchAllowed && shouldAttemptSubsonicForServer(subsonicServerId);
// Fetch batch per entity change (not per song switch — same-artist songs share artist/top/tour fetches)
useEffect(() => {
// 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
if (!indexFetchAllowed || !songId) { setSongMetaEntry(null); return; }
const cacheKey = subsonicCacheKey(subsonicServerId, songId);
const cached = songMetaCache.get(cacheKey);
if (cached !== undefined) { setSongMetaEntry({ id: songId, value: cached }); return; }
setSongMetaEntry(null);
let cancelled = false;
resolveNpSongMeta(subsonicServerId, songId)
.then(v => { if (!cancelled) { songMetaCache.set(cacheKey, v ?? null); setSongMetaEntry({ id: songId, value: v ?? null }); } })
.catch(() => { if (!cancelled) { songMetaCache.set(cacheKey, null); setSongMetaEntry({ id: songId, value: null }); } });
return () => { cancelled = true; };
}, [indexFetchAllowed, subsonicServerId, songId, connStatus]);
useEffect(() => {
// 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
if (!networkOnlyAllowed || !artistId) { setArtistInfoEntry(null); return; }
const cacheKey = subsonicCacheKey(subsonicServerId, artistId);
const cached = artistInfoCache.get(cacheKey);
if (cached !== undefined) { setArtistInfoEntry({ id: artistId, value: cached }); return; }
setArtistInfoEntry(null);
let cancelled = false;
getArtistInfoForServer(subsonicServerId, artistId, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined })
.then(v => { if (!cancelled) { artistInfoCache.set(cacheKey, v ?? null); setArtistInfoEntry({ id: artistId, value: v ?? null }); } })
.catch(() => { if (!cancelled) { artistInfoCache.set(cacheKey, null); setArtistInfoEntry({ id: artistId, value: null }); } });
return () => { cancelled = true; };
}, [networkOnlyAllowed, subsonicServerId, artistId, audiomuseNavidromeEnabled, connStatus]);
useEffect(() => {
// 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
if (!indexFetchAllowed || !albumId) { setAlbumDataEntry(null); return; }
const cacheKey = subsonicCacheKey(subsonicServerId, albumId);
const cached = albumCache.get(cacheKey);
if (cached !== undefined) { setAlbumDataEntry({ id: albumId, value: cached }); return; }
setAlbumDataEntry(null);
let cancelled = false;
resolveNpAlbum(subsonicServerId, albumId)
.then(v => { if (!cancelled) { albumCache.set(cacheKey, v); setAlbumDataEntry({ id: albumId, value: v }); } })
.catch(() => { if (!cancelled) { albumCache.set(cacheKey, null); setAlbumDataEntry({ id: albumId, value: null }); } });
return () => { cancelled = true; };
}, [indexFetchAllowed, subsonicServerId, albumId, connStatus]);
useEffect(() => {
// 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
if (!indexFetchAllowed || !topSongsKey) { setTopSongsEntry(null); return; }
const cached = topSongsCache.get(topSongsKey);
if (cached !== undefined) { setTopSongsEntry({ key: topSongsKey, value: cached }); return; }
setTopSongsEntry(null);
let cancelled = false;
resolveNpTopSongs(subsonicServerId, artistId, artistName)
.then(v => { if (!cancelled) { topSongsCache.set(topSongsKey, v); setTopSongsEntry({ key: topSongsKey, value: v }); } })
.catch(() => { if (!cancelled) { topSongsCache.set(topSongsKey, []); setTopSongsEntry({ key: topSongsKey, value: [] }); } });
return () => { cancelled = true; };
}, [indexFetchAllowed, topSongsKey, subsonicServerId, artistId, artistName, connStatus]);
useEffect(() => {
// 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
if (!tourKey) { setTourEventsEntry(null); setTourLoading(false); return; }
const cached = tourCache.get(tourKey);
if (cached !== undefined) { setTourEventsEntry({ key: tourKey, value: cached }); setTourLoading(false); return; }
let cancelled = false;
setTourLoading(true);
setTourEventsEntry(null);
fetchBandsintownEvents(artistName)
.then(v => { if (!cancelled) { tourCache.set(tourKey, v); setTourEventsEntry({ key: tourKey, value: v }); } })
.finally(() => { if (!cancelled) setTourLoading(false); });
return () => { cancelled = true; };
}, [tourKey, artistName]);
// Discography via getArtist
useEffect(() => {
// 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
if (!indexFetchAllowed || !artistId) { setDiscographyEntry(null); return; }
const cacheKey = subsonicCacheKey(subsonicServerId, artistId);
const cached = discographyCache.get(cacheKey);
if (cached !== undefined) { setDiscographyEntry({ id: artistId, value: cached }); return; }
setDiscographyEntry(null);
let cancelled = false;
resolveNpDiscography(subsonicServerId, artistId)
.then(albums => { if (!cancelled) { discographyCache.set(cacheKey, albums); setDiscographyEntry({ id: artistId, value: albums }); } })
.catch(() => { if (!cancelled) { discographyCache.set(cacheKey, []); setDiscographyEntry({ id: artistId, value: [] }); } });
return () => { cancelled = true; };
}, [indexFetchAllowed, subsonicServerId, artistId, connStatus]);
// Enrichment track stats (per-track, from the enrichment primary)
useEffect(() => {
const runtime = getMusicNetworkRuntimeOrNull();
// 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
if (!runtime?.getEnrichmentPrimaryId() || !currentTrack || !networkTrackKey) { setNetworkTrackEntry(null); return; }
const cached = networkTrackCache.get(networkTrackKey);
if (cached !== undefined) { setNetworkTrackEntry({ key: networkTrackKey, value: cached }); return; }
setNetworkTrackEntry(null);
let cancelled = false;
runtime.getTrackStats({ title: currentTrack.title, artist: currentTrack.artist })
.then(v => { if (!cancelled) { networkTrackCache.set(networkTrackKey, v); setNetworkTrackEntry({ key: networkTrackKey, value: v }); } })
.catch(() => { if (!cancelled) { networkTrackCache.set(networkTrackKey, null); setNetworkTrackEntry({ key: networkTrackKey, value: null }); } });
return () => { cancelled = true; };
}, [networkTrackKey, currentTrack, enrichmentKey]);
// Enrichment artist stats (per-artist — shared across same-artist tracks)
useEffect(() => {
const runtime = getMusicNetworkRuntimeOrNull();
// 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
if (!runtime?.getEnrichmentPrimaryId() || !artistName || !networkArtistKey) { setNetworkArtistEntry(null); return; }
const cached = networkArtistCache.get(networkArtistKey);
if (cached !== undefined) { setNetworkArtistEntry({ key: networkArtistKey, value: cached }); return; }
setNetworkArtistEntry(null);
let cancelled = false;
runtime.getArtistStats(artistName)
.then(v => { if (!cancelled) { networkArtistCache.set(networkArtistKey, v); setNetworkArtistEntry({ key: networkArtistKey, value: v }); } })
.catch(() => { if (!cancelled) { networkArtistCache.set(networkArtistKey, null); setNetworkArtistEntry({ key: networkArtistKey, value: null }); } });
return () => { cancelled = true; };
}, [networkArtistKey, artistName, enrichmentKey]);
// Gate id-keyed slots on id-match so consumers never see a value paired
// with the wrong id, even on the single render between an id change and
// the next effect run.
const songMeta = songMetaEntry && songMetaEntry.id === songId ? songMetaEntry.value : null;
const artistInfo = artistInfoEntry && artistInfoEntry.id === artistId ? artistInfoEntry.value : null;
const albumData = albumDataEntry && albumDataEntry.id === albumId ? albumDataEntry.value : null;
const discography = discographyEntry && discographyEntry.id === artistId ? discographyEntry.value : [];
const topSongs = topSongsEntry && topSongsEntry.key === topSongsKey ? topSongsEntry.value : [];
const tourEvents = tourEventsEntry && tourEventsEntry.key === tourKey ? tourEventsEntry.value : [];
const networkTrack = networkTrackEntry && networkTrackEntry.key === networkTrackKey ? networkTrackEntry.value : null;
const networkArtist = networkArtistEntry && networkArtistEntry.key === networkArtistKey ? networkArtistEntry.value : null;
return { songMeta, artistInfo, albumData, topSongs, tourEvents, tourLoading, discography, networkTrack, networkArtist };
}
-109
View File
@@ -1,109 +0,0 @@
import { renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { coverCacheEnsure, coverCachePeekBatch } from '../api/coverCache';
import { coverIndexKeyFromRef } from '../cover/storageKeys';
import { useNowPlayingPrewarm } from './useNowPlayingPrewarm';
import { prewarmNowPlayingFetchers } from './useNowPlayingFetchers';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { makeTrack } from '../test/helpers/factories';
import { resetAllStores } from '../test/helpers/storeReset';
import { toQueueItemRefs } from '../utils/library/queueItemRef';
vi.mock('../api/coverCache', async importOriginal => {
const actual = await importOriginal<typeof import('../api/coverCache')>();
return {
...actual,
coverCachePeekBatch: vi.fn(async () => ({})),
coverCacheEnsure: vi.fn(async () => ({ hit: false, path: '', tier: 800 })),
};
});
vi.mock('./useNowPlayingFetchers', () => ({
prewarmNowPlayingFetchers: vi.fn(async () => undefined),
}));
function seedServers(): { active: string; playback: string } {
const active = useAuthStore.getState().addServer({
name: 'Active',
url: 'https://active.test',
username: 'active-user',
password: 'active-pass',
});
const playback = useAuthStore.getState().addServer({
name: 'Playback',
url: 'https://playback.test',
username: 'play-user',
password: 'play-pass',
});
useAuthStore.getState().setActiveServer(active);
return { active, playback };
}
describe('useNowPlayingPrewarm', () => {
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
});
it('prewarms track data and artwork with playback scope', async () => {
const { playback } = seedServers();
const track = makeTrack({
id: 'song-1',
artistId: 'artist-1',
albumId: 'album-1',
artist: 'Artist One',
coverArt: 'cover-1',
});
usePlayerStore.setState({
queueItems: toQueueItemRefs(playback, [track]),
queueIndex: 0,
queueServerId: playback,
currentTrack: track,
});
renderHook(() => useNowPlayingPrewarm());
await waitFor(() => {
expect(prewarmNowPlayingFetchers).toHaveBeenCalledTimes(1);
expect(coverCachePeekBatch).toHaveBeenCalledTimes(1);
});
const peekRef = vi.mocked(coverCachePeekBatch).mock.calls[0]?.[0]?.[0];
const playbackProfile = useAuthStore.getState().servers.find(s => s.id === playback);
expect(playbackProfile).toBeDefined();
expect(peekRef && coverIndexKeyFromRef(peekRef)).toBe('playback.test');
const ensureRef = vi.mocked(coverCacheEnsure).mock.calls[0]?.[0];
expect(ensureRef?.serverScope.kind).toBe('server');
});
it('prewarms radio artwork with active scope (not playback queue scope)', async () => {
const { active, playback } = seedServers();
const track = makeTrack({ id: 'song-2', coverArt: 'cover-2' });
usePlayerStore.setState({
queueItems: toQueueItemRefs(playback, [track]),
queueIndex: 0,
queueServerId: playback,
currentTrack: null,
currentRadio: {
id: 'radio-1',
name: 'Radio 1',
streamUrl: 'https://radio.test/stream',
coverArt: 'https://radio.test/art.jpg',
},
});
renderHook(() => useNowPlayingPrewarm());
await waitFor(() => {
expect(coverCachePeekBatch).toHaveBeenCalledTimes(1);
});
const peekRef = vi.mocked(coverCachePeekBatch).mock.calls[0]?.[0]?.[0];
const activeProfile = useAuthStore.getState().servers.find(s => s.id === active);
expect(activeProfile).toBeDefined();
expect(peekRef && coverIndexKeyFromRef(peekRef)).toBe('active.test');
const ensureRef = vi.mocked(coverCacheEnsure).mock.calls[0]?.[0];
expect(ensureRef?.serverScope).toEqual({ kind: 'active' });
});
});
-106
View File
@@ -1,106 +0,0 @@
import { useEffect } from 'react';
import { coverCacheEnsure, coverCachePeekBatch } from '../api/coverCache';
import { albumCoverRef } from '../cover/ref';
import { resolvePlaybackCoverScope } from '../cover/ref';
import { resolveTrackCoverRefFromLibrary } from '../cover/resolveEntryLibrary';
import { getDiskSrc, rememberDiskSrc } from '../cover/diskSrcCache';
import { coverStorageKeyFromRef } from '../cover/storageKeys';
import { resolveCoverDisplayTier } from '../cover/tiers';
import { coverArtIdFromRadio } from '../cover/ids';
import type { CoverArtRef } from '../cover/types';
import { prewarmNowPlayingFetchers } from './useNowPlayingFetchers';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { usePlaybackServerId } from './usePlaybackServerId';
import { primaryTrackArtistRef } from '../utils/playback/trackArtistRefs';
const NOW_PLAYING_COVER_CSS_PX = 800;
async function prewarmCoverRef(ref: CoverArtRef): Promise<void> {
if (!ref.fetchCoverArtId) return;
const tier = resolveCoverDisplayTier(NOW_PLAYING_COVER_CSS_PX, { surface: 'sparse' });
const storageKey = coverStorageKeyFromRef(ref, tier);
if (getDiskSrc(storageKey)) return;
const hits = await coverCachePeekBatch([ref], tier);
const hitPath = hits[storageKey];
if (hitPath) {
rememberDiskSrc(storageKey, hitPath);
return;
}
const ensured = await coverCacheEnsure(ref, tier, 'high');
if (ensured.hit && ensured.path) {
rememberDiskSrc(storageKey, ensured.path);
}
}
/**
* Warm the Now Playing data + key artwork as soon as the playing track changes,
* so opening `/now-playing` shows track-correct content instantly.
*/
export function useNowPlayingPrewarm(): void {
const currentTrack = usePlayerStore(s => s.currentTrack);
const currentRadio = usePlayerStore(s => s.currentRadio);
const playbackServerId = usePlaybackServerId();
const enableBandsintown = useAuthStore(s => s.enableBandsintown);
const activeServerId = useAuthStore(s => s.activeServerId);
const audiomuseNavidromeEnabled = useAuthStore(
s => (playbackServerId ? Boolean(s.audiomuseNavidromeByServer[playbackServerId]) : false),
);
const enrichmentKey = useAuthStore(s => s.enrichmentPrimaryId ?? '');
useEffect(() => {
if (!currentTrack || !playbackServerId) return;
const primary = primaryTrackArtistRef(currentTrack);
void prewarmNowPlayingFetchers({
songId: currentTrack.id,
artistId: primary.id,
albumId: currentTrack.albumId,
artistName: primary.name ?? currentTrack.artist,
enableBandsintown,
audiomuseNavidromeEnabled,
enrichmentKey,
currentTrack,
subsonicServerId: playbackServerId,
// No `fetchEnabled` / no trackId: prewarmNowPlayingFetchers owns the single
// reachability gate, and metadata must warm even when the track's audio
// plays from local cache.
});
if (currentTrack.albumId && currentTrack.id) {
void resolveTrackCoverRefFromLibrary(
{
id: currentTrack.id,
albumId: currentTrack.albumId,
coverArt: currentTrack.coverArt,
discNumber: (currentTrack as { discNumber?: number }).discNumber,
},
resolvePlaybackCoverScope(),
).then(ref => {
if (ref) void prewarmCoverRef(ref);
});
}
// Keyed on currentTrack?.id; depending on the `currentTrack` object would
// re-prewarm on every render when its identity changes but its id does not.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
currentTrack?.id,
currentTrack?.artistId,
currentTrack?.artists,
currentTrack?.albumId,
currentTrack?.coverArt,
currentTrack?.artist,
playbackServerId,
enableBandsintown,
audiomuseNavidromeEnabled,
enrichmentKey,
]);
useEffect(() => {
if (!currentRadio?.coverArt || !activeServerId) return;
const radioCoverArtId = coverArtIdFromRadio(currentRadio.id);
void prewarmCoverRef(albumCoverRef(radioCoverArtId, radioCoverArtId, { kind: 'active' }));
}, [currentRadio?.id, currentRadio?.coverArt, activeServerId]);
}
-50
View File
@@ -1,50 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { queueSongStar } from '../store/pendingStarSync';
import type { SubsonicSong } from '../api/subsonicTypes';
import type { Track } from '../store/playerStoreTypes';
import type { TrackStats } from '../music-network';
import { getMusicNetworkRuntime } from '../music-network';
export interface NowPlayingStarLoveDeps {
currentTrack: Pick<Track, 'id' | 'title' | 'artist' | 'serverId'> | null;
songMeta: SubsonicSong | null;
networkTrack: TrackStats | null;
networkLoveEnabled: boolean;
}
export interface NowPlayingStarLoveResult {
starred: boolean;
networkLoved: boolean;
toggleStar: () => Promise<void>;
toggleNetworkLove: () => Promise<void>;
}
export function useNowPlayingStarLove(deps: NowPlayingStarLoveDeps): NowPlayingStarLoveResult {
const { currentTrack, songMeta, networkTrack, networkLoveEnabled } = deps;
// Star
const [starred, setStarred] = useState(false);
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { setStarred(!!songMeta?.starred); }, [songMeta]);
const toggleStar = useCallback(async () => {
if (!currentTrack) return;
const next = !starred;
setStarred(next); // local view; helper owns the override + retried server sync (no rollback)
queueSongStar(currentTrack.id, next, currentTrack.serverId);
}, [currentTrack, starred]);
// Love (enrichment primary; seeded from track.getInfo, toggle via love/unlove)
const [networkLoved, setNetworkLoved] = useState(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
useEffect(() => { setNetworkLoved(!!networkTrack?.userLoved); }, [networkTrack]);
const toggleNetworkLove = useCallback(async () => {
if (!currentTrack || !networkLoveEnabled) return;
const track = { title: currentTrack.title, artist: currentTrack.artist };
if (networkLoved) { await getMusicNetworkRuntime().setTrackLoved(track, false); setNetworkLoved(false); }
else { await getMusicNetworkRuntime().setTrackLoved(track, true); setNetworkLoved(true); }
}, [currentTrack, networkLoved, networkLoveEnabled]);
return { starred, networkLoved, toggleStar, toggleNetworkLove };
}