perf(suggestions): optimize loading with backend MBID resolution
- Move MBID resolution from frontend to backend - Eliminates multiple sequential IPC calls - Uses MusicBrainz cache for faster resolution - Logs resolution progress for debugging - Implement progressive loading in UI - Initial load: 18 tracks (3 pages) - "Load more" expands to 60 tracks on demand - Better feedback during loading states - Fix refresh button styling - Remove background while spinning - Update title based on available actions - Add "Load more" link in pagination area
This commit is contained in:
1 parent
f0aa539849
commit
e9a5dfcb80
3 files changed
+180
-78
No files matched your search
@@ -10,14 +10,24 @@ use crate::artist_vectors::{
|
||||
ArtistVectorBuilder, ArtistVectorStoreState, RelationshipWeights, StoreStats,
|
||||
SuggestionConfig, SuggestionResult, SuggestionsEngine,
|
||||
};
|
||||
use crate::musicbrainz::MusicBrainzSharedState;
|
||||
use crate::musicbrainz::{MatchConfidence, MusicBrainzSharedState, ResolvedArtist};
|
||||
use crate::AppState;
|
||||
|
||||
/// Artist info from the frontend
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct PlaylistArtist {
|
||||
/// Artist name (required)
|
||||
pub name: String,
|
||||
/// Qobuz artist ID (optional, for better matching)
|
||||
#[serde(default)]
|
||||
pub qobuz_id: Option<u64>,
|
||||
}
|
||||
|
||||
/// Input for generating suggestions
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct SuggestionsInput {
|
||||
/// Artist MBIDs from the playlist
|
||||
pub artist_mbids: Vec<String>,
|
||||
/// Artists from the playlist (names + optional Qobuz IDs)
|
||||
pub artists: Vec<PlaylistArtist>,
|
||||
/// Track IDs already in the playlist (to exclude)
|
||||
pub exclude_track_ids: Vec<u64>,
|
||||
/// Whether to include reason strings (dev mode)
|
||||
@@ -35,6 +45,83 @@ pub async fn get_playlist_suggestions_v2(
|
||||
mb_state: State<'_, MusicBrainzSharedState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<SuggestionResult, String> {
|
||||
// Resolve artist names to MBIDs (with caching)
|
||||
let mut artist_mbids = Vec::new();
|
||||
|
||||
for artist in &input.artists {
|
||||
// Check cache first
|
||||
let cached = {
|
||||
let cache = mb_state.cache.lock().await;
|
||||
cache.get_artist(&artist.name).ok().flatten()
|
||||
};
|
||||
|
||||
if let Some(resolved) = cached {
|
||||
if let Some(mbid) = resolved.mbid {
|
||||
if resolved.confidence != MatchConfidence::None
|
||||
&& resolved.confidence != MatchConfidence::Low
|
||||
{
|
||||
artist_mbids.push(mbid);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to resolve via MusicBrainz search
|
||||
if mb_state.client.is_enabled().await {
|
||||
match mb_state.client.search_artist(&artist.name).await {
|
||||
Ok(response) => {
|
||||
if let Some(mb_artist) = response
|
||||
.artists
|
||||
.iter()
|
||||
.find(|a| a.score.unwrap_or(0) >= 90)
|
||||
{
|
||||
let mbid = mb_artist.id.clone();
|
||||
// Cache the resolved artist
|
||||
let resolved = ResolvedArtist {
|
||||
mbid: Some(mbid.clone()),
|
||||
name: Some(mb_artist.name.clone()),
|
||||
sort_name: mb_artist.sort_name.clone(),
|
||||
artist_type: None,
|
||||
country: mb_artist.country.clone(),
|
||||
disambiguation: mb_artist.disambiguation.clone(),
|
||||
confidence: MatchConfidence::High,
|
||||
};
|
||||
{
|
||||
let cache = mb_state.cache.lock().await;
|
||||
let _ = cache.set_artist(&artist.name, &resolved);
|
||||
}
|
||||
artist_mbids.push(mbid);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("Failed to resolve artist '{}': {}", artist.name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache negative result
|
||||
{
|
||||
let cache = mb_state.cache.lock().await;
|
||||
let _ = cache.set_artist(&artist.name, &ResolvedArtist::empty());
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Resolved {}/{} artists to MBIDs for suggestions",
|
||||
artist_mbids.len(),
|
||||
input.artists.len()
|
||||
);
|
||||
|
||||
if artist_mbids.is_empty() {
|
||||
return Ok(SuggestionResult {
|
||||
tracks: Vec::new(),
|
||||
source_artists: Vec::new(),
|
||||
playlist_artists_count: input.artists.len(),
|
||||
similar_artists_count: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Create the builder (just cloning Arcs, cheap)
|
||||
let builder = Arc::new(ArtistVectorBuilder::new(
|
||||
store_state.store.clone(),
|
||||
@@ -58,7 +145,7 @@ pub async fn get_playlist_suggestions_v2(
|
||||
|
||||
// Generate suggestions
|
||||
engine
|
||||
.generate_suggestions(&input.artist_mbids, &exclude_set, input.include_reasons)
|
||||
.generate_suggestions(&artist_mbids, &exclude_set, input.include_reasons)
|
||||
.await
|
||||
}
|
||||
|
||||
|
||||
@@ -29,13 +29,17 @@
|
||||
|
||||
// State
|
||||
let loading = $state(false);
|
||||
let loadingMore = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let pool = $state<SuggestedTrack[]>([]);
|
||||
let currentPage = $state(0);
|
||||
let result = $state<SuggestionResult | null>(null);
|
||||
let hasLoadedOnce = $state(false);
|
||||
|
||||
// Configuration
|
||||
const VISIBLE_COUNT = 6;
|
||||
const INITIAL_POOL = 18; // 3 pages worth
|
||||
const EXPANDED_POOL = 60; // Full pool on demand
|
||||
|
||||
// Derived state
|
||||
const dismissedIds = $derived(getDismissedTrackIds(playlistId));
|
||||
@@ -46,17 +50,18 @@
|
||||
const visibleTracks = $derived(
|
||||
filteredPool.slice(currentPage * VISIBLE_COUNT, (currentPage + 1) * VISIBLE_COUNT)
|
||||
);
|
||||
const hasMore = $derived(currentPage < totalPages - 1);
|
||||
const isEmpty = $derived(filteredPool.length === 0 && !loading);
|
||||
const hasMorePages = $derived(currentPage < totalPages - 1);
|
||||
const canLoadMore = $derived(hasLoadedOnce && pool.length < EXPANDED_POOL && !loadingMore);
|
||||
const isEmpty = $derived(filteredPool.length === 0 && !loading && hasLoadedOnce);
|
||||
|
||||
// Load suggestions when artists change
|
||||
$effect(() => {
|
||||
if (artists.length > 0) {
|
||||
void loadSuggestions();
|
||||
if (artists.length > 0 && !hasLoadedOnce) {
|
||||
void loadSuggestions(false);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadSuggestions() {
|
||||
async function loadSuggestions(expanded: boolean) {
|
||||
if (loading) return;
|
||||
|
||||
loading = true;
|
||||
@@ -67,10 +72,11 @@
|
||||
artists,
|
||||
excludeTrackIds,
|
||||
showReasons,
|
||||
{ max_pool_size: 60 }
|
||||
{ max_pool_size: expanded ? EXPANDED_POOL : INITIAL_POOL }
|
||||
);
|
||||
pool = result.tracks;
|
||||
currentPage = 0;
|
||||
hasLoadedOnce = true;
|
||||
} catch (err) {
|
||||
console.error('Failed to load suggestions:', err);
|
||||
error = err instanceof Error ? err.message : 'Failed to load suggestions';
|
||||
@@ -80,13 +86,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoadMore() {
|
||||
if (loadingMore || pool.length >= EXPANDED_POOL) return;
|
||||
|
||||
loadingMore = true;
|
||||
try {
|
||||
const moreResult = await getPlaylistSuggestionsV2(
|
||||
artists,
|
||||
excludeTrackIds,
|
||||
showReasons,
|
||||
{ max_pool_size: EXPANDED_POOL }
|
||||
);
|
||||
// Merge new tracks, avoiding duplicates
|
||||
const existingIds = new Set(pool.map(t => t.track_id));
|
||||
const newTracks = moreResult.tracks.filter(t => !existingIds.has(t.track_id));
|
||||
pool = [...pool, ...newTracks];
|
||||
result = moreResult;
|
||||
} catch (err) {
|
||||
console.error('Failed to load more suggestions:', err);
|
||||
} finally {
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
if (hasMore) {
|
||||
if (hasMorePages) {
|
||||
// Rotate to next page
|
||||
currentPage = (currentPage + 1) % totalPages;
|
||||
} else if (canLoadMore) {
|
||||
// Load more tracks
|
||||
void handleLoadMore();
|
||||
} else {
|
||||
// Reload from scratch
|
||||
void loadSuggestions();
|
||||
hasLoadedOnce = false;
|
||||
void loadSuggestions(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,10 +162,10 @@
|
||||
</div>
|
||||
<button
|
||||
class="refresh-btn"
|
||||
class:spinning={loading}
|
||||
class:spinning={loading || loadingMore}
|
||||
onclick={handleRefresh}
|
||||
disabled={loading}
|
||||
title={hasMore ? 'Show more' : 'Refresh suggestions'}
|
||||
disabled={loading || loadingMore}
|
||||
title={hasMorePages ? 'Show more' : canLoadMore ? 'Load more suggestions' : 'Refresh suggestions'}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
@@ -146,7 +179,7 @@
|
||||
{:else if error}
|
||||
<div class="error-state">
|
||||
<p>{error}</p>
|
||||
<button onclick={() => loadSuggestions()}>Retry</button>
|
||||
<button onclick={() => loadSuggestions(false)}>Retry</button>
|
||||
</div>
|
||||
{:else if isEmpty}
|
||||
<div class="empty-state">
|
||||
@@ -215,9 +248,16 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if totalPages > 1}
|
||||
{#if filteredPool.length > VISIBLE_COUNT || canLoadMore}
|
||||
<div class="pagination-info">
|
||||
Showing {currentPage * VISIBLE_COUNT + 1}-{Math.min((currentPage + 1) * VISIBLE_COUNT, filteredPool.length)} of {filteredPool.length}
|
||||
{#if loadingMore}
|
||||
Loading more...
|
||||
{:else}
|
||||
Showing {currentPage * VISIBLE_COUNT + 1}-{Math.min((currentPage + 1) * VISIBLE_COUNT, filteredPool.length)} of {filteredPool.length}
|
||||
{#if canLoadMore}
|
||||
<button class="load-more-link" onclick={handleLoadMore}>Load more</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -281,10 +321,14 @@
|
||||
}
|
||||
|
||||
.refresh-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.refresh-btn.spinning {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.refresh-btn.spinning :global(svg) {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@@ -469,4 +513,20 @@
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.load-more-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent-primary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.load-more-link:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -43,46 +43,6 @@ export interface VectorStoreStats {
|
||||
db_size_bytes: number;
|
||||
}
|
||||
|
||||
interface MbidResolution {
|
||||
mbid?: string;
|
||||
name?: string;
|
||||
confidence: string;
|
||||
}
|
||||
|
||||
// ============ MBID Resolution ============
|
||||
|
||||
/**
|
||||
* Resolve artist names to MusicBrainz IDs
|
||||
* Results are cached by the backend
|
||||
*/
|
||||
async function resolveArtistMbids(
|
||||
artistNames: string[]
|
||||
): Promise<Map<string, string>> {
|
||||
const mbidMap = new Map<string, string>();
|
||||
|
||||
// Resolve in parallel with concurrency limit
|
||||
const batchSize = 5;
|
||||
for (let i = 0; i < artistNames.length; i += batchSize) {
|
||||
const batch = artistNames.slice(i, i + batchSize);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(name =>
|
||||
invoke<MbidResolution>('musicbrainz_resolve_artist', { name })
|
||||
)
|
||||
);
|
||||
|
||||
results.forEach((result, idx) => {
|
||||
if (result.status === 'fulfilled' && result.value?.mbid) {
|
||||
// Only include high-confidence matches
|
||||
if (result.value.confidence !== 'none' && result.value.confidence !== 'weak') {
|
||||
mbidMap.set(batch[idx], result.value.mbid);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return mbidMap;
|
||||
}
|
||||
|
||||
// ============ Local Storage for Dismissed Tracks ============
|
||||
|
||||
const DISMISSED_STORAGE_PREFIX = 'playlist_suggestions_dismissed_';
|
||||
@@ -142,10 +102,16 @@ export async function getPlaylistSuggestionsV2(
|
||||
includeReasons: boolean = false,
|
||||
config?: SuggestionConfig
|
||||
): Promise<SuggestionResult> {
|
||||
// Extract unique artist names
|
||||
const uniqueNames = [...new Set(artists.map(a => a.name).filter(Boolean))];
|
||||
// Filter out empty names and deduplicate
|
||||
const uniqueArtists = artists.filter(a => a.name?.trim());
|
||||
const seen = new Set<string>();
|
||||
const dedupedArtists = uniqueArtists.filter(a => {
|
||||
if (seen.has(a.name)) return false;
|
||||
seen.add(a.name);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (uniqueNames.length === 0) {
|
||||
if (dedupedArtists.length === 0) {
|
||||
return {
|
||||
tracks: [],
|
||||
source_artists: [],
|
||||
@@ -154,24 +120,13 @@ export async function getPlaylistSuggestionsV2(
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve artist names to MBIDs
|
||||
const mbidMap = await resolveArtistMbids(uniqueNames);
|
||||
const artistMbids = [...mbidMap.values()];
|
||||
|
||||
if (artistMbids.length === 0) {
|
||||
console.debug('No artist MBIDs resolved for suggestions');
|
||||
return {
|
||||
tracks: [],
|
||||
source_artists: [],
|
||||
playlist_artists_count: uniqueNames.length,
|
||||
similar_artists_count: 0
|
||||
};
|
||||
}
|
||||
|
||||
// Call backend
|
||||
// Call backend - MBID resolution happens server-side with caching
|
||||
const result = await invoke<SuggestionResult>('get_playlist_suggestions_v2', {
|
||||
input: {
|
||||
artist_mbids: artistMbids,
|
||||
artists: dedupedArtists.map(a => ({
|
||||
name: a.name,
|
||||
qobuz_id: a.qobuzId ?? null
|
||||
})),
|
||||
exclude_track_ids: excludeTrackIds,
|
||||
include_reasons: includeReasons,
|
||||
config: config ?? null
|
||||
|
||||
Reference in new issue
Block a user