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:
vicrodh committed 2026-01-27 11:58:54 -06:00
1 parent f0aa539849
commit e9a5dfcb80
3 files changed
+180 -78

No files matched your search

+75 -15
View File
@@ -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>
+14 -59
View File
@@ -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