diff --git a/CHANGELOG.md b/CHANGELOG.md index ca04eea1..6db54a61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -167,6 +167,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Now Playing — metadata reads from the local library index first + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1049](https://github.com/Psychotoxical/psysonic/pull/1049)** + +* The "from this album", "discography", "most played" and song details on the Now Playing page now come from the local library index when it has them, only falling back to the server when the index can't serve a row. Cards and fields (genre, play count, contributors) stay populated during cached and offline playback, with fewer server requests. + + + ### Library DB — named slow-write ops for stall diagnosis **By [@cucadmuh](https://github.com/cucadmuh), PR [#1043](https://github.com/Psychotoxical/psysonic/pull/1043)** diff --git a/src/hooks/useNowPlayingFetchers.ts b/src/hooks/useNowPlayingFetchers.ts index 871c295f..ece531c5 100644 --- a/src/hooks/useNowPlayingFetchers.ts +++ b/src/hooks/useNowPlayingFetchers.ts @@ -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> = []; @@ -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(() => { diff --git a/src/utils/library/nowPlayingMetadataResolve.test.ts b/src/utils/library/nowPlayingMetadataResolve.test.ts new file mode 100644 index 00000000..b9a0df86 --- /dev/null +++ b/src/utils/library/nowPlayingMetadataResolve.test.ts @@ -0,0 +1,192 @@ +/** + * Index-first behaviour matrix for the Now Playing metadata resolvers (#1046). + * Index hit → no Subsonic call; index miss → network fallback (when reachable); + * index off → network fallback; unreachable → index still runs, no network call + * (PR #1049 gate split). The byte-style guard inside `getSongForServer` is + * exercised by useNowPlayingFetchers.test.ts. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { onInvoke } from '@/test/mocks/tauri'; +import { useLibraryIndexStore } from '@/store/libraryIndexStore'; +import type { LibraryAdvancedSearchResponse } from '@/api/library'; +import * as subsonicArtists from '@/api/subsonicArtists'; +import * as subsonicLibrary from '@/api/subsonicLibrary'; + +// Network reachability is decided by the guard; mock it so we can test both arms. +vi.mock('@/utils/network/subsonicNetworkGuard', () => ({ + shouldAttemptSubsonicForServer: vi.fn(() => true), +})); +import { shouldAttemptSubsonicForServer } from '@/utils/network/subsonicNetworkGuard'; +import { + resolveNpAlbum, + resolveNpDiscography, + resolveNpTopSongs, + resolveNpSongMeta, +} from './nowPlayingMetadataResolve'; + +const guard = vi.mocked(shouldAttemptSubsonicForServer); + +const ready = () => + onInvoke('library_get_status', () => ({ + serverId: 's1', libraryScope: '', syncPhase: 'ready', + capabilityFlags: 0, libraryTier: 'unknown', syncedAt: 0, + })); + +const search = (over: Partial): LibraryAdvancedSearchResponse => ({ + artists: [], albums: [], tracks: [], + totals: { artists: 0, albums: 0, tracks: 0 }, + appliedFilters: [], source: 'local', ...over, +}); + +beforeEach(() => { + useLibraryIndexStore.setState({ masterEnabled: true }); + vi.restoreAllMocks(); + guard.mockReturnValue(true); +}); + +describe('resolveNpAlbum', () => { + it('index hit → no getAlbumForServer call', async () => { + ready(); + onInvoke('library_get_tracks_by_album', () => [ + { serverId: 's1', id: 't1', title: 'Track', album: 'Alb', albumId: 'al1', artistId: 'ar1', durationSec: 100, syncedAt: 0, rawJson: {} }, + ]); + onInvoke('library_advanced_search', () => search({ albums: [{ serverId: 's1', id: 'al1', name: 'Alb', artistId: 'ar1', syncedAt: 0, rawJson: {} }] })); + const spy = vi.spyOn(subsonicLibrary, 'getAlbumForServer'); + const res = await resolveNpAlbum('s1', 'al1'); + expect(spy).not.toHaveBeenCalled(); + expect(res?.album.id).toBe('al1'); + }); + + it('index miss + reachable → getAlbumForServer fallback', async () => { + ready(); + onInvoke('library_get_tracks_by_album', () => []); + const spy = vi.spyOn(subsonicLibrary, 'getAlbumForServer') + .mockResolvedValue({ album: { id: 'al1', name: 'Net' } as never, songs: [] }); + const res = await resolveNpAlbum('s1', 'al1'); + expect(spy).toHaveBeenCalledWith('s1', 'al1'); + expect(res?.album.id).toBe('al1'); + }); + + it('index off → getAlbumForServer fallback', async () => { + useLibraryIndexStore.setState({ masterEnabled: false }); + const spy = vi.spyOn(subsonicLibrary, 'getAlbumForServer') + .mockResolvedValue({ album: { id: 'al1', name: 'Net' } as never, songs: [] }); + await resolveNpAlbum('s1', 'al1'); + expect(spy).toHaveBeenCalledWith('s1', 'al1'); + }); + + it('unreachable → index runs, no network fallback', async () => { + guard.mockReturnValue(false); + ready(); + onInvoke('library_get_tracks_by_album', () => []); // index miss + const spy = vi.spyOn(subsonicLibrary, 'getAlbumForServer'); + const res = await resolveNpAlbum('s1', 'al1'); + expect(spy).not.toHaveBeenCalled(); + expect(res).toBeNull(); + }); +}); + +describe('resolveNpDiscography', () => { + it('index hit → no getArtistForServer call', async () => { + ready(); + onInvoke('library_advanced_search', () => search({ + albums: [ + { serverId: 's1', id: 'al1', name: 'A1', artistId: 'ar1', syncedAt: 0, rawJson: {} }, + { serverId: 's1', id: 'al2', name: 'A2', artistId: 'other', syncedAt: 0, rawJson: {} }, + ], + })); + const spy = vi.spyOn(subsonicArtists, 'getArtistForServer'); + const albums = await resolveNpDiscography('s1', 'ar1'); + expect(spy).not.toHaveBeenCalled(); + expect(albums.map(a => a.id)).toEqual(['al1']); + }); + + it('index empty + reachable → getArtistForServer fallback', async () => { + ready(); + onInvoke('library_advanced_search', () => search({ albums: [] })); + const spy = vi.spyOn(subsonicArtists, 'getArtistForServer') + .mockResolvedValue({ albums: [{ id: 'al9' }] } as never); + const albums = await resolveNpDiscography('s1', 'ar1'); + expect(spy).toHaveBeenCalledWith('s1', 'ar1'); + expect(albums.map(a => a.id)).toEqual(['al9']); + }); + + it('unreachable + index empty → no network, empty list', async () => { + guard.mockReturnValue(false); + ready(); + onInvoke('library_advanced_search', () => search({ albums: [] })); + const spy = vi.spyOn(subsonicArtists, 'getArtistForServer'); + const albums = await resolveNpDiscography('s1', 'ar1'); + expect(spy).not.toHaveBeenCalled(); + expect(albums).toEqual([]); + }); +}); + +describe('resolveNpTopSongs', () => { + // Index path: artist's discography albums → their tracks → sort by play_count. + it('index hit → top tracks from the artist albums, by play count', async () => { + ready(); + onInvoke('library_advanced_search', () => search({ + albums: [{ serverId: 's1', id: 'al1', name: 'A1', artistId: 'ar1', syncedAt: 0, rawJson: {} }], + })); + onInvoke('library_get_tracks_by_album', () => [ + { serverId: 's1', id: 't-lo', title: 'Low', album: 'A1', artistId: 'ar1', durationSec: 1, playCount: 2, syncedAt: 0, rawJson: {} }, + { serverId: 's1', id: 't-hi', title: 'High', album: 'A1', artistId: 'ar1', durationSec: 1, playCount: 9, syncedAt: 0, rawJson: {} }, + ]); + const spy = vi.spyOn(subsonicArtists, 'getTopSongsForServer'); + const songs = await resolveNpTopSongs('s1', 'ar1', 'Artist One'); + expect(spy).not.toHaveBeenCalled(); + expect(songs.map(s => s.id)).toEqual(['t-hi', 't-lo']); // play_count desc + }); + + it('index has no albums + reachable → getTopSongsForServer fallback', async () => { + ready(); + onInvoke('library_advanced_search', () => search({ albums: [] })); + const spy = vi.spyOn(subsonicArtists, 'getTopSongsForServer') + .mockResolvedValue([{ id: 'net1' } as never]); + const songs = await resolveNpTopSongs('s1', 'ar1', 'Artist One'); + expect(spy).toHaveBeenCalledWith('s1', 'Artist One'); + expect(songs.map(s => s.id)).toEqual(['net1']); + }); + + it('unreachable + no index albums → no network, empty', async () => { + guard.mockReturnValue(false); + ready(); + onInvoke('library_advanced_search', () => search({ albums: [] })); + const spy = vi.spyOn(subsonicArtists, 'getTopSongsForServer'); + const songs = await resolveNpTopSongs('s1', 'ar1', 'Artist One'); + expect(spy).not.toHaveBeenCalled(); + expect(songs).toEqual([]); + }); +}); + +describe('resolveNpSongMeta', () => { + it('index hit → no getSongForServer call', async () => { + ready(); + onInvoke('library_get_track', () => ({ + serverId: 's1', id: 't1', title: 'Local', album: 'Alb', artistId: 'ar1', durationSec: 100, + genre: 'Doom', playCount: 5, syncedAt: 0, rawJson: {}, + })); + const spy = vi.spyOn(subsonicLibrary, 'getSongForServer'); + const song = await resolveNpSongMeta('s1', 't1'); + expect(spy).not.toHaveBeenCalled(); + expect(song?.genre).toBe('Doom'); + }); + + it('index miss → getSongForServer fallback', async () => { + ready(); + onInvoke('library_get_track', () => null); + const spy = vi.spyOn(subsonicLibrary, 'getSongForServer') + .mockResolvedValue({ id: 't1', title: 'Net' } as never); + const song = await resolveNpSongMeta('s1', 't1'); + expect(spy).toHaveBeenCalledWith('s1', 't1'); + expect(song?.title).toBe('Net'); + }); + + it('index off → getSongForServer fallback', async () => { + useLibraryIndexStore.setState({ masterEnabled: false }); + const spy = vi.spyOn(subsonicLibrary, 'getSongForServer').mockResolvedValue(null); + await resolveNpSongMeta('s1', 't1'); + expect(spy).toHaveBeenCalledWith('s1', 't1'); + }); +}); diff --git a/src/utils/library/nowPlayingMetadataResolve.ts b/src/utils/library/nowPlayingMetadataResolve.ts new file mode 100644 index 00000000..488c5a73 --- /dev/null +++ b/src/utils/library/nowPlayingMetadataResolve.ts @@ -0,0 +1,108 @@ +/** + * Index-first metadata resolvers for the Now Playing page (issue #1046). + * + * The local library index is first-class: when SQLite has the row, Now Playing + * reads it there; Subsonic/network is fallback only on index miss / index off / + * not ready. This mirrors the in-tree index-first family (`queueTrackResolver`, + * `offlineLibraryIndexLoad`, `useQueueTrackEnrichment`) rather than adding a + * fourth always-network path. + * + * Gate split (PR #1049 review): the index arm runs whenever there is a playback + * server id — including when the server is unreachable, the whole point of + * index-first for offline-pinned playback. The reachability guard + * (`shouldAttemptSubsonicForServer`, no trackId) lives only in each resolver's + * **network fallback arm**, so offline reads still succeed from SQLite. + * + * `artistInfo` (bio / similar) has no index source and stays network-only — it + * is intentionally absent here. + */ +import { libraryGetTrack, libraryGetTracksByAlbum } from '../../api/library'; +import { getArtistForServer, getTopSongsForServer } from '../../api/subsonicArtists'; +import { getAlbumForServer, getSongForServer } from '../../api/subsonicLibrary'; +import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes'; +import { shouldAttemptSubsonicForServer } from '../network/subsonicNetworkGuard'; +import { loadAlbumFromLibraryIndex, loadArtistFromLibraryIndex } from '../offline/offlineLibraryIndexLoad'; +import { trackToSong } from './advancedSearchLocal'; +import { libraryIsReady } from './libraryReady'; + +const TOP_SONGS_LIMIT = 5; + +/** Album card — index `loadAlbumFromLibraryIndex`, else `getAlbumForServer`. */ +export async function resolveNpAlbum( + serverId: string, + albumId: string, +): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null> { + if (await libraryIsReady(serverId)) { + try { + const hit = await loadAlbumFromLibraryIndex(serverId, albumId); + if (hit) return hit; + } catch { /* index error → network fallback */ } + } + if (!shouldAttemptSubsonicForServer(serverId)) return null; + return getAlbumForServer(serverId, albumId); +} + +/** Discography — index `loadArtistFromLibraryIndex().albums`, else `getArtistForServer().albums`. */ +export async function resolveNpDiscography( + serverId: string, + artistId: string, +): Promise { + if (await libraryIsReady(serverId)) { + try { + const hit = await loadArtistFromLibraryIndex(serverId, artistId); + // Empty albums == miss: the index may not carry this artist's albums yet; + // let the network arm try before settling on an empty discography. + if (hit && hit.albums.length > 0) return hit.albums; + } catch { /* index error → network fallback */ } + } + if (!shouldAttemptSubsonicForServer(serverId)) return []; + const artist = await getArtistForServer(serverId, artistId); + return artist.albums; +} + +/** + * Most played — derive from the artist's own discography albums (same bucket the + * discography card uses), sorted by play_count. This is deterministic: it can't + * pull the wrong artist's tracks the way an FTS-on-name query could. Network + * `getTopSongsForServer` is the fallback on index miss / off / unreachable. + */ +export async function resolveNpTopSongs( + serverId: string, + artistId: string | undefined, + artistName: string, +): Promise { + if (artistId && await libraryIsReady(serverId)) { + try { + const hit = await loadArtistFromLibraryIndex(serverId, artistId); + if (hit && hit.albums.length > 0) { + const perAlbum = await Promise.all( + hit.albums.map(a => libraryGetTracksByAlbum(serverId, a.id).catch(() => [])), + ); + const songs = perAlbum + .flat() + .map(trackToSong) + .sort((a, b) => (b.playCount ?? 0) - (a.playCount ?? 0)) + .slice(0, TOP_SONGS_LIMIT); + if (songs.length > 0) return songs; + } + } catch { /* index error → network fallback */ } + } + if (!shouldAttemptSubsonicForServer(serverId)) return []; + return getTopSongsForServer(serverId, artistName); +} + +/** Song-level meta — index `libraryGetTrack` → `trackToSong`, else `getSongForServer`. */ +export async function resolveNpSongMeta( + serverId: string, + songId: string, +): Promise { + if (await libraryIsReady(serverId)) { + try { + const dto = await libraryGetTrack(serverId, songId); + if (dto) return trackToSong(dto); + } catch { /* index error → network fallback */ } + } + // Network arm keeps its own byte-style guard (`shouldAttemptSubsonicForServer` + // with the trackId + psysonic-local:// skip) — unchanged from #1042. + return getSongForServer(serverId, songId); +}