mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
perf(home): sample discover artists from local index
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user