fix(now-playing): index-first metadata fetchers (#1046) (#1049)

* feat(now-playing): index-first metadata resolvers (#1046)

Add nowPlayingMetadataResolve.ts: four index-first resolvers (album,
discography, top songs, song meta) that read the local library index when
it has the row and fall back to Subsonic only on index miss / off / not
ready. Reuses loadAlbumFromLibraryIndex, loadArtistFromLibraryIndex,
libraryGetTrack, libraryAdvancedSearch, trackToSong, and libraryIsReady —
the same index-first family as queueTrackResolver, not a new network path.

getSongForServer keeps its byte-style trackId guard; the index arm runs
first so an index hit avoids the guarded call. artistInfo has no index
source and stays network-only (not handled here).

* feat(now-playing): wire index-first resolvers into fetchers + prewarm (#1046)

Replace the four direct Subsonic calls in useNowPlayingFetchers and
prewarmNowPlayingFetchers (album, discography, top songs, song meta) with
the resolveNp* resolvers — index-first, network fallback. artistInfo
stays on getArtistInfoForServer (no index source). Caches, id-gating
tuples, and the subsonicFetchAllowed guard are unchanged; the top-songs
effect gains artistId in its deps (index arm filters by it).

Net: on a populated, ready index those four cards render from SQLite with
no Subsonic call; index miss/off/not-ready falls back to the network path
exactly as before.

* docs: add CHANGELOG entry for PR #1049

* fix(now-playing): widen resolveNpTopSongs artistId to optional (tsc)

The top-songs fetch isn't guard-narrowed on artistId, so callers pass
string | undefined. The resolver already handles a missing artistId (falls
straight to the network arm); widening the param type fixes the CI tsc
failure that the wiring commit introduced.

* fix(now-playing): run index reads offline + deterministic top songs (#1049 review)

Address cucadmuh's two in-PR review points:

1. Index-first reads now run whenever there's a playback server id, including
   when the server is unreachable — the offline win of index-first. Split the
   hook/prewarm gate: resolvers run on server-id presence; the reachability
   guard moved into each resolver's network fallback arm. artistInfo (no index)
   stays network-only.
2. resolveNpTopSongs derives top songs from the artist's own discography albums
   (sorted by play_count) instead of an FTS-on-name query, so name collisions
   can't surface the wrong tracks. getTopSongsForServer remains the fallback.

Resolver tests extended with the unreachable (index-only) cases.
This commit is contained in:
Psychotoxical
2026-06-09 19:22:43 +02:00
committed by GitHub
parent 276903d0af
commit c33d1e64c5
4 changed files with 341 additions and 32 deletions
+33 -32
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { getArtistForServer, getArtistInfoForServer, getTopSongsForServer } from '../api/subsonicArtists';
import { getAlbumForServer, getSongForServer } from '../api/subsonicLibrary';
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 {
lastfmGetArtistStats, lastfmGetTrackInfo, lastfmIsConfigured,
@@ -86,10 +86,9 @@ export async function prewarmNowPlayingFetchers(
} = deps;
if (!fetchEnabled || !subsonicServerId) return;
// The only network gate in this function. No trackId: metadata must be fetched
// even when the track's audio is local (hot-cache / offline). Offline is still
// covered by the online/reachability checks inside the guard.
if (!shouldAttemptSubsonicForServer(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>> = [];
@@ -97,7 +96,7 @@ export async function prewarmNowPlayingFetchers(
const cacheKey = subsonicCacheKey(subsonicServerId, songId);
if (songMetaCache.get(cacheKey) === undefined) {
jobs.push(
getSongForServer(subsonicServerId, songId)
resolveNpSongMeta(subsonicServerId, songId)
.then(v => songMetaCache.set(cacheKey, v ?? null))
.catch(() => songMetaCache.set(cacheKey, null)),
);
@@ -106,7 +105,8 @@ export async function prewarmNowPlayingFetchers(
if (artistId) {
const artistKey = subsonicCacheKey(subsonicServerId, artistId);
if (artistInfoCache.get(artistKey) === undefined) {
// 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,
@@ -117,8 +117,8 @@ export async function prewarmNowPlayingFetchers(
}
if (discographyCache.get(artistKey) === undefined) {
jobs.push(
getArtistForServer(subsonicServerId, artistId)
.then(v => discographyCache.set(artistKey, v.albums))
resolveNpDiscography(subsonicServerId, artistId)
.then(albums => discographyCache.set(artistKey, albums))
.catch(() => discographyCache.set(artistKey, [])),
);
}
@@ -128,7 +128,7 @@ export async function prewarmNowPlayingFetchers(
const cacheKey = subsonicCacheKey(subsonicServerId, albumId);
if (albumCache.get(cacheKey) === undefined) {
jobs.push(
getAlbumForServer(subsonicServerId, albumId)
resolveNpAlbum(subsonicServerId, albumId)
.then(v => albumCache.set(cacheKey, v))
.catch(() => albumCache.set(cacheKey, null)),
);
@@ -139,7 +139,7 @@ export async function prewarmNowPlayingFetchers(
const cacheKey = subsonicCacheKey(subsonicServerId, artistName);
if (topSongsCache.get(cacheKey) === undefined) {
jobs.push(
getTopSongsForServer(subsonicServerId, artistName)
resolveNpTopSongs(subsonicServerId, artistId, artistName)
.then(v => topSongsCache.set(cacheKey, v))
.catch(() => topSongsCache.set(cacheKey, [])),
);
@@ -208,28 +208,29 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
seedKeySlot(lfmArtistKey, k => lfmArtistCache.get(k)));
const { status: connStatus } = useConnectionStatus();
// No trackId: see prewarm note — metadata is fetched whenever the server is
// reachable, regardless of whether the track's audio plays from local cache.
const subsonicFetchAllowed = fetchEnabled
&& !!subsonicServerId
&& shouldAttemptSubsonicForServer(subsonicServerId);
// 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(() => {
if (!subsonicFetchAllowed || !songId) { setSongMetaEntry(null); return; }
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;
getSongForServer(subsonicServerId, songId)
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; };
}, [subsonicFetchAllowed, subsonicServerId, songId, connStatus]);
}, [indexFetchAllowed, subsonicServerId, songId, connStatus]);
useEffect(() => {
if (!subsonicFetchAllowed || !artistId) { setArtistInfoEntry(null); return; }
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; }
@@ -239,32 +240,32 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
.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; };
}, [subsonicFetchAllowed, subsonicServerId, artistId, audiomuseNavidromeEnabled, connStatus]);
}, [networkOnlyAllowed, subsonicServerId, artistId, audiomuseNavidromeEnabled, connStatus]);
useEffect(() => {
if (!subsonicFetchAllowed || !albumId) { setAlbumDataEntry(null); return; }
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;
getAlbumForServer(subsonicServerId, albumId)
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; };
}, [subsonicFetchAllowed, subsonicServerId, albumId, connStatus]);
}, [indexFetchAllowed, subsonicServerId, albumId, connStatus]);
useEffect(() => {
if (!subsonicFetchAllowed || !topSongsKey) { setTopSongsEntry(null); return; }
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;
getTopSongsForServer(subsonicServerId, artistName)
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; };
}, [subsonicFetchAllowed, topSongsKey, subsonicServerId, artistName, connStatus]);
}, [indexFetchAllowed, topSongsKey, subsonicServerId, artistId, artistName, connStatus]);
useEffect(() => {
if (!tourKey) { setTourEventsEntry(null); setTourLoading(false); return; }
@@ -281,17 +282,17 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
// Discography via getArtist
useEffect(() => {
if (!subsonicFetchAllowed || !artistId) { setDiscographyEntry(null); return; }
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;
getArtistForServer(subsonicServerId, artistId)
.then(v => { if (!cancelled) { discographyCache.set(cacheKey, v.albums); setDiscographyEntry({ id: artistId, value: v.albums }); } })
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; };
}, [subsonicFetchAllowed, subsonicServerId, artistId, connStatus]);
}, [indexFetchAllowed, subsonicServerId, artistId, connStatus]);
// Last.fm track info (per-track)
useEffect(() => {