mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +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:
@@ -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)**
|
||||
|
||||
@@ -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