perf(home): sample discover artists from local index

This commit is contained in:
cucadmuh
2026-07-17 00:42:47 +03:00
parent 09169faa57
commit 7f4db455c2
9 changed files with 276 additions and 13 deletions
@@ -747,6 +747,18 @@ pub async fn library_scope_list_mainstage_albums(
.await
}
// NOT specta-collected: returns LibraryArtistDto carrying raw_json: Value.
#[tauri::command]
pub async fn library_list_random_artists(
runtime: State<'_, LibraryRuntime>,
server_id: String,
limit: Option<u32>,
) -> Result<Vec<crate::dto::LibraryArtistDto>, String> {
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || crate::random_artists::list_random_artists(&store, &server_id, limit))
.await
}
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn library_scope_list_artists(
@@ -38,6 +38,7 @@ pub mod lossless_albums;
pub mod lossless_formats;
pub mod payload;
pub mod repos;
pub mod random_artists;
pub mod runtime;
pub mod scope_merge;
pub mod search;
@@ -0,0 +1,80 @@
//! Bounded random artist samples for local-only Home discovery.
use rusqlite::params;
use crate::dto::LibraryArtistDto;
use crate::store::LibraryStore;
pub const RANDOM_ARTISTS_LIMIT: u32 = 50;
pub fn list_random_artists(
store: &LibraryStore,
server_id: &str,
limit: Option<u32>,
) -> Result<Vec<LibraryArtistDto>, String> {
let limit = limit.unwrap_or(RANDOM_ARTISTS_LIMIT).clamp(1, RANDOM_ARTISTS_LIMIT);
store
.with_read_conn(|conn| {
let mut stmt = conn.prepare(
"SELECT server_id, id, name, name_sort, album_count, synced_at, raw_json \
FROM artist WHERE server_id = ?1 ORDER BY RANDOM() LIMIT ?2",
)?;
let rows = stmt.query_map(params![server_id, i64::from(limit)], |row| {
let raw_json = row
.get::<_, Option<String>>(6)?
.and_then(|raw| serde_json::from_str(&raw).ok())
.unwrap_or(serde_json::Value::Null);
Ok(LibraryArtistDto {
server_id: row.get(0)?,
id: row.get(1)?,
name: row.get(2)?,
name_sort: row.get(3)?,
album_count: row.get(4)?,
synced_at: row.get(5)?,
raw_json,
})
})?
.collect::<rusqlite::Result<Vec<_>>>();
rows
})
.map_err(|error| error.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn insert_artists(store: &LibraryStore, server_id: &str, count: u32) {
store.with_conn_mut("random_artists.test", |conn| {
for index in 0..count {
conn.execute(
"INSERT INTO artist (server_id, id, name, synced_at) VALUES (?1, ?2, ?3, 1)",
params![server_id, format!("artist-{index}"), format!("Artist {index}")],
)?;
}
Ok(())
}).unwrap();
}
#[test]
fn samples_only_the_requested_server() {
let store = LibraryStore::open_in_memory();
insert_artists(&store, "server-a", 5);
insert_artists(&store, "server-b", 5);
let artists = list_random_artists(&store, "server-a", Some(5)).unwrap();
assert_eq!(artists.len(), 5);
assert!(artists.iter().all(|artist| artist.server_id == "server-a"));
}
#[test]
fn caps_the_sample_to_fifty_rows() {
let store = LibraryStore::open_in_memory();
insert_artists(&store, "server-a", RANDOM_ARTISTS_LIMIT + 10);
let artists = list_random_artists(&store, "server-a", Some(500)).unwrap();
assert_eq!(artists.len(), RANDOM_ARTISTS_LIMIT as usize);
}
}
+2
View File
@@ -1047,6 +1047,7 @@ pub fn run() {
psysonic_library::commands::library_cluster_rebuild,
psysonic_library::commands::library_scope_list_albums,
psysonic_library::commands::library_scope_list_mainstage_albums,
psysonic_library::commands::library_list_random_artists,
psysonic_library::commands::library_scope_list_artists,
psysonic_library::commands::library_scope_search_tracks,
psysonic_library::commands::library_scope_album_detail,
@@ -1293,6 +1294,7 @@ mod specta_export {
"library_get_tracks_by_album",
"library_list_albums_by_genre",
"library_list_lossless_albums",
"library_list_random_artists",
"library_live_search",
"library_scope_album_detail",
"library_scope_artist_detail",
@@ -116,6 +116,7 @@ describe('homeFeedLoader failure isolation', () => {
getArtistsForServer: vi.fn(async () => []),
getRandomSongsForServer: vi.fn(async () => []),
runLocalRandomSongs: vi.fn(async () => null),
runLocalRandomArtists: vi.fn(async () => null),
filterAlbumsByMixRatingsAcrossServers: vi.fn(async albums => albums),
shuffle: items => items,
},
@@ -184,6 +185,7 @@ describe('homeFeedLoader failure isolation', () => {
const getArtistsForServer = vi.fn(async () => []);
const getRandomSongsForServer = vi.fn(async () => []);
const runLocalRandomSongs = vi.fn(async () => null);
const runLocalRandomArtists = vi.fn(async () => null);
const filterAlbumsByMixRatingsAcrossServers = vi.fn(async albums => albums);
const onSectionResult = vi.fn();
@@ -204,6 +206,7 @@ describe('homeFeedLoader failure isolation', () => {
getArtistsForServer,
getRandomSongsForServer,
runLocalRandomSongs,
runLocalRandomArtists,
filterAlbumsByMixRatingsAcrossServers,
shuffle: items => items,
},
@@ -212,6 +215,7 @@ describe('homeFeedLoader failure isolation', () => {
expect(getAlbumListForServer).not.toHaveBeenCalled();
expect(getArtistsForServer).not.toHaveBeenCalled();
expect(runLocalRandomSongs).not.toHaveBeenCalled();
expect(runLocalRandomArtists).not.toHaveBeenCalled();
expect(getRandomSongsForServer).not.toHaveBeenCalled();
expect(filterAlbumsByMixRatingsAcrossServers).not.toHaveBeenCalled();
expect(result).toMatchObject({
@@ -250,6 +254,7 @@ describe('homeFeedLoader failure isolation', () => {
getArtistsForServer: vi.fn(async () => []),
getRandomSongsForServer: vi.fn(async () => []),
runLocalRandomSongs: vi.fn(async () => null),
runLocalRandomArtists: vi.fn(async () => null),
filterAlbumsByMixRatingsAcrossServers: vi.fn(async albums => albums),
shuffle: items => items,
},
@@ -280,6 +285,43 @@ describe('homeFeedLoader failure isolation', () => {
}
});
it('uses local random artists before the network and records each server source', async () => {
const getArtistsForServer = vi.fn(async (serverId: string) => [
{ id: `network-${serverId}`, name: `Network ${serverId}` },
]);
const runLocalRandomArtists = vi.fn(async (serverId: string | null | undefined) => (
serverId === 'a' ? [{ id: 'local-a', name: 'Local A', serverId }] : null
));
const onSectionResult = vi.fn();
const result = await loadHomeFeed({
serverIds: ['a', 'b'], scopeKey: 'scope', scopeVersion: 1, randomSize: 0,
anchorServerId: 'a', scopes: [], showArtists: true, showSongs: false, mixConfig,
onSectionResult,
deps: {
getAlbumListForServer: vi.fn(async () => []) as never,
getArtistsForServer,
getRandomSongsForServer: vi.fn(async () => []),
runLocalRandomSongs: vi.fn(async () => null),
runLocalRandomArtists,
filterAlbumsByMixRatingsAcrossServers: vi.fn(async albums => albums),
shuffle: items => items,
},
});
expect(runLocalRandomArtists).toHaveBeenCalledWith('a', 8);
expect(runLocalRandomArtists).toHaveBeenCalledWith('b', 8);
expect(getArtistsForServer).toHaveBeenCalledTimes(1);
expect(getArtistsForServer).toHaveBeenCalledWith('b', HOME_REQUEST_TIMEOUT_MS);
expect(result.randomArtists.map(artist => `${artist.serverId}:${artist.id}`))
.toEqual(['a:local-a', 'b:network-b']);
const report = onSectionResult.mock.calls.find(([section]) => section === 'discoverArtists')?.[1];
expect(report.detail).toContain('a: ');
expect(report.detail).toContain('/local/rows');
expect(report.detail).toContain('b: ');
expect(report.detail).toContain('/network/rows');
});
it('uses per-server offsets, dedupes owner-qualified ids, and advances raw cursors', async () => {
const getAlbumListForServer = vi.fn(async (
serverId: string,
+45 -10
View File
@@ -6,7 +6,7 @@ import {
type LibraryScopePair,
} from '@/lib/api/library/scopeReads';
import { albumToAlbum } from '@/lib/library/advancedSearchLocal';
import { runLocalRandomSongs } from '@/lib/library/browseTextSearch';
import { runLocalRandomArtists, runLocalRandomSongs } from '@/lib/library/browseTextSearch';
import { shuffleArray } from '@/lib/util/shuffleArray';
import type { HomeFeedOffsets, HomeFeedSnapshot } from '@/features/home/store/homeFeedCache';
@@ -66,6 +66,7 @@ interface HomeFeedLoaderDeps {
getRandomSongsForServer: typeof getRandomSongsForServer;
libraryScopeListMainstageAlbums: typeof libraryScopeListMainstageAlbums;
runLocalRandomSongs: typeof runLocalRandomSongs;
runLocalRandomArtists: typeof runLocalRandomArtists;
filterAlbumsByMixRatingsAcrossServers: <T extends OwnedAlbum>(
albums: T[],
config: MixMinRatingsConfig,
@@ -79,6 +80,7 @@ const defaultDeps: Omit<HomeFeedLoaderDeps, 'filterAlbumsByMixRatingsAcrossServe
getRandomSongsForServer,
libraryScopeListMainstageAlbums,
runLocalRandomSongs,
runLocalRandomArtists,
shuffle: shuffleArray,
};
@@ -305,6 +307,7 @@ type TimedServerItems<T> = {
items: T[];
durationMs: number;
outcome: 'rows' | 'empty' | 'timeout' | 'error';
source?: 'local' | 'network';
};
async function loadServerAlbums(
@@ -333,12 +336,41 @@ async function loadServerAlbums(
};
}
async function loadServerArtists(serverId: string, deps: HomeFeedLoaderDeps): Promise<OwnedArtist[]> {
const artists = await withinHomeDeadline(
isolated(() => deps.getArtistsForServer(serverId, HOME_REQUEST_TIMEOUT_MS), []),
[] as SubsonicArtist[],
);
return artists.map(artist => ({ ...artist, serverId }));
async function loadServerArtists(
serverId: string,
size: number,
deps: HomeFeedLoaderDeps,
): Promise<TimedServerItems<OwnedArtist>> {
const startedAt = nowMs();
if (size <= 0) return { items: [], durationMs: 0, outcome: 'empty' };
const request: Promise<{
artists: SubsonicArtist[];
source: 'local' | 'network';
outcome: TimedServerItems<OwnedArtist>['outcome'];
}> = (async () => {
try {
const local = await deps.runLocalRandomArtists(serverId, size);
if (local != null) return { artists: local, source: 'local' as const, outcome: local.length > 0 ? 'rows' as const : 'empty' as const };
} catch {
// A local read failure must not prevent the existing server fallback.
}
try {
const artists = await deps.getArtistsForServer(serverId, HOME_REQUEST_TIMEOUT_MS);
return { artists, source: 'network' as const, outcome: artists.length > 0 ? 'rows' as const : 'empty' as const };
} catch {
return { artists: [] as SubsonicArtist[], source: 'network' as const, outcome: 'error' as const };
}
})();
const result = await withinHomeDeadline(request, {
artists: [] as SubsonicArtist[], source: 'local' as const, outcome: 'timeout' as const,
});
const items = result.artists.map(artist => ({ ...artist, serverId }));
return {
items,
durationMs: elapsedMs(startedAt),
outcome: result.outcome,
source: result.source,
};
}
async function loadServerSongs(
@@ -364,7 +396,7 @@ function formatServerTimings<T>(
): string {
return serverIds.map((serverId, index) => {
const group = groups[index];
return `${serverId}: ${group?.durationMs ?? 0}ms/${group?.items.length ?? 0}/${group?.outcome ?? 'empty'}`;
return `${serverId}: ${group?.durationMs ?? 0}ms/${group?.items.length ?? 0}/${group?.source ?? 'network'}/${group?.outcome ?? 'empty'}`;
}).join(', ');
}
@@ -459,13 +491,16 @@ export async function loadHomeFeed(options: LoadHomeFeedOptions): Promise<HomeFe
const artistsStartedAt = nowMs();
const artistsPromise = enabled.discoverArtists
? Promise.all(options.serverIds.map(serverId => loadServerArtists(serverId, deps))).then(groups => {
? Promise.all(options.serverIds.map((serverId, index) => (
loadServerArtists(serverId, artistQuotas[index] ?? 0, deps)
))).then(groups => {
const items = dedupeOwned(stableRoundRobin(
groups.map((group, index) => deps.shuffle(group).slice(0, artistQuotas[index] ?? 0)),
groups.map((group, index) => deps.shuffle(group.items).slice(0, artistQuotas[index] ?? 0)),
HOME_DISCOVER_ARTISTS_SIZE,
));
report('discoverArtists', {
status: 'success', durationMs: elapsedMs(artistsStartedAt), itemCount: items.length,
detail: formatServerTimings(options.serverIds, groups),
});
return items;
})
+16
View File
@@ -18,6 +18,7 @@ import type {
LibraryLosslessAlbumsResponse,
LibraryArtistLosslessBrowseRequest,
LibraryArtistLosslessBrowseResponse,
LibraryArtistDto,
LibraryCrossServerSearchResponse,
LibraryTrackDto,
TrackRefDto,
@@ -103,6 +104,21 @@ export function libraryLiveSearch(request: LibraryLiveSearchRequest): Promise<Li
}));
}
/** Bounded random artist sample from one complete local server index. */
export function libraryListRandomArtists(
serverId: string,
limit: number,
): Promise<LibraryArtistDto[]> {
const indexKey = serverIndexKeyForId(serverId);
return invoke<LibraryArtistDto[]>('library_list_random_artists', {
serverId: indexKey,
limit,
}).then(artists => artists.map(artist => ({
...artist,
serverId: mapServerIdFromIndexKey(artist.serverId, serverId),
})));
}
/** Paginated lossless albums from the local track index. */
export function libraryListLosslessAlbums(
request: LibraryLosslessAlbumsRequest,
+54 -2
View File
@@ -1,5 +1,28 @@
import { describe, expect, it } from 'vitest';
import { browseRaceCountsArtists, filterBrowseArtistsByNameQuery, raceBrowseWithLocalFallback } from './browseTextSearch';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const libraryListRandomArtists = vi.fn();
const librarySelectionForServer = vi.fn();
const libraryIsReady = vi.fn();
vi.mock('@/lib/api/library', () => ({
libraryListRandomArtists: (...args: unknown[]) => libraryListRandomArtists(...args),
}));
vi.mock('@/lib/api/subsonicClient', () => ({
libraryScopeForServer: vi.fn(),
libraryScopePairsForServer: vi.fn(),
librarySelectionForServer: (...args: unknown[]) => librarySelectionForServer(...args),
}));
vi.mock('./libraryReady', () => ({
libraryIsReady: (...args: unknown[]) => libraryIsReady(...args),
waitForLibraryBrowseReady: vi.fn(),
}));
import {
browseRaceCountsArtists,
filterBrowseArtistsByNameQuery,
raceBrowseWithLocalFallback,
runLocalRandomArtists,
} from './browseTextSearch';
describe('filterBrowseArtistsByNameQuery', () => {
it('matches Cyrillic names regardless of query case', () => {
@@ -51,3 +74,32 @@ describe('raceBrowseWithLocalFallback', () => {
expect(outcome?.result).toEqual(['network']);
});
});
describe('runLocalRandomArtists', () => {
beforeEach(() => {
libraryListRandomArtists.mockReset();
librarySelectionForServer.mockReset();
libraryIsReady.mockReset();
});
it('uses the local command for a ready whole-library server', async () => {
librarySelectionForServer.mockReturnValue([]);
libraryIsReady.mockResolvedValue(true);
libraryListRandomArtists.mockResolvedValue([
{ serverId: 'server-a', id: 'artist-a', name: 'Artist A', syncedAt: 1, rawJson: {} },
]);
await expect(runLocalRandomArtists('server-a', 16)).resolves.toEqual([
expect.objectContaining({ serverId: 'server-a', id: 'artist-a', name: 'Artist A' }),
]);
expect(libraryListRandomArtists).toHaveBeenCalledWith('server-a', 16);
});
it('keeps scoped selections on the network path', async () => {
librarySelectionForServer.mockReturnValue(['library-a']);
await expect(runLocalRandomArtists('server-a', 16)).resolves.toBeNull();
expect(libraryIsReady).not.toHaveBeenCalled();
expect(libraryListRandomArtists).not.toHaveBeenCalled();
});
});
+24 -1
View File
@@ -6,7 +6,12 @@ import { getArtists } from '@/lib/api/subsonicArtists';
import type { ArtistCreditMode } from '@/lib/api/library';
import { search, searchSongsPaged } from '@/lib/api/subsonicSearch';
import type { SearchResults, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes';
import { libraryAdvancedSearch, libraryGetArtistLosslessBrowse, libraryListLosslessAlbums } from '@/lib/api/library';
import {
libraryAdvancedSearch,
libraryGetArtistLosslessBrowse,
libraryListLosslessAlbums,
libraryListRandomArtists,
} from '@/lib/api/library';
import {
libraryScopeForServer,
libraryScopePairsForServer,
@@ -522,6 +527,24 @@ export async function runLocalRandomAlbums(
}
}
/**
* Random artist sample from a complete local index. Scoped selections must use
* the server so the selection remains authoritative.
*/
export async function runLocalRandomArtists(
serverId: string | null | undefined,
limit: number,
): Promise<SubsonicArtist[] | null> {
if (!serverId || librarySelectionForServer(serverId).length > 0 || !(await libraryIsReady(serverId))) {
return null;
}
try {
return (await libraryListRandomArtists(serverId, limit)).map(artistToArtist);
} catch {
return null;
}
}
/** Paginated All Albums browse from the local `album` table (F1). */
export async function runLocalAlbumBrowsePage(
serverId: string | null | undefined,