mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
* 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:
@@ -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(() => {
|
||||
|
||||
@@ -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>): 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');
|
||||
});
|
||||
});
|
||||
@@ -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<SubsonicAlbum[]> {
|
||||
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<SubsonicSong[]> {
|
||||
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<SubsonicSong | null> {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user