feat(ui): add playlist suggestions component and service

- Create PlaylistSuggestions.svelte component with:
  - Pool management (60 tracks, show 6 at a time)
  - Pagination/rotation on refresh
  - Loading, empty, and error states
  - Add (+) and dismiss (-) track actions
  - Album art display
  - Reason tooltip support (dev mode)

- Create playlistSuggestionsService.ts with:
  - MBID resolution from artist names (batched)
  - TypeScript types for SuggestedTrack, SuggestionResult
  - localStorage helpers for dismissed tracks
  - Qobuz album art URL generation

- Integrate into PlaylistDetailView:
  - Show suggestions at bottom of playlist track list
  - Extract unique artists from playlist for suggestions
  - Add track to playlist via add_tracks_to_playlist command
  - Hide when searching
This commit is contained in:
vicrodh committed 2026-01-27 11:48:48 -06:00
1 parent cadb346e32
commit f0aa539849
3 files changed
+743

No files matched your search

@@ -0,0 +1,472 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { RefreshCw, Plus, X, Info, Sparkles } from 'lucide-svelte';
import {
type SuggestedTrack,
type SuggestionResult,
type PlaylistArtist,
getPlaylistSuggestionsV2,
getDismissedTrackIds,
dismissTrack,
formatDuration
} from '$lib/services/playlistSuggestionsService';
interface Props {
playlistId: number;
artists: PlaylistArtist[];
excludeTrackIds: number[];
onAddTrack?: (trackId: number) => Promise<void>;
showReasons?: boolean;
}
let {
playlistId,
artists,
excludeTrackIds,
onAddTrack,
showReasons = false
}: Props = $props();
// State
let loading = $state(false);
let error = $state<string | null>(null);
let pool = $state<SuggestedTrack[]>([]);
let currentPage = $state(0);
let result = $state<SuggestionResult | null>(null);
// Configuration
const VISIBLE_COUNT = 6;
// Derived state
const dismissedIds = $derived(getDismissedTrackIds(playlistId));
const filteredPool = $derived(
pool.filter(t => !dismissedIds.has(t.track_id) && !excludeTrackIds.includes(t.track_id))
);
const totalPages = $derived(Math.ceil(filteredPool.length / VISIBLE_COUNT));
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);
// Load suggestions when artists change
$effect(() => {
if (artists.length > 0) {
void loadSuggestions();
}
});
async function loadSuggestions() {
if (loading) return;
loading = true;
error = null;
try {
result = await getPlaylistSuggestionsV2(
artists,
excludeTrackIds,
showReasons,
{ max_pool_size: 60 }
);
pool = result.tracks;
currentPage = 0;
} catch (err) {
console.error('Failed to load suggestions:', err);
error = err instanceof Error ? err.message : 'Failed to load suggestions';
pool = [];
} finally {
loading = false;
}
}
function handleRefresh() {
if (hasMore) {
// Rotate to next page
currentPage = (currentPage + 1) % totalPages;
} else {
// Reload from scratch
void loadSuggestions();
}
}
async function handleAddTrack(track: SuggestedTrack) {
if (!onAddTrack) return;
try {
await onAddTrack(track.track_id);
// Remove from pool locally (will be excluded on next load anyway)
pool = pool.filter(t => t.track_id !== track.track_id);
} catch (err) {
console.error('Failed to add track:', err);
}
}
function handleDismiss(track: SuggestedTrack) {
dismissTrack(playlistId, track.track_id);
// Force reactivity by reassigning pool
pool = [...pool];
}
function getAlbumArt(albumId: string): string {
if (!albumId) return '';
// Qobuz album art URL pattern
return `https://static.qobuz.com/images/covers/${albumId}_50.jpg`;
}
</script>
{#if artists.length > 0}
<div class="suggestions-section" id="suggestions-anchor">
<div class="suggestions-header">
<div class="header-left">
<Sparkles size={18} class="sparkle-icon" />
<h3>Suggested songs</h3>
{#if result && !loading}
<span class="stats">
Based on {result.playlist_artists_count} artists
</span>
{/if}
</div>
<button
class="refresh-btn"
class:spinning={loading}
onclick={handleRefresh}
disabled={loading}
title={hasMore ? 'Show more' : 'Refresh suggestions'}
>
<RefreshCw size={16} />
</button>
</div>
{#if loading && pool.length === 0}
<div class="loading-state">
<div class="spinner"></div>
<p>Finding similar artists...</p>
</div>
{:else if error}
<div class="error-state">
<p>{error}</p>
<button onclick={() => loadSuggestions()}>Retry</button>
</div>
{:else if isEmpty}
<div class="empty-state">
<p>No suggestions available for this playlist.</p>
<p class="hint">Try adding more tracks with different artists.</p>
</div>
{:else}
<div class="suggestions-list">
{#each visibleTracks as track (track.track_id)}
<div class="suggestion-row">
<div class="album-art">
{#if track.album_id}
<img
src={getAlbumArt(track.album_id)}
alt=""
loading="lazy"
onerror={(e) => {
const target = e.currentTarget as HTMLImageElement;
target.style.display = 'none';
}}
/>
{/if}
</div>
<div class="track-info">
<div class="track-title">{track.title}</div>
<div class="track-meta">
<span class="artist">{track.artist_name}</span>
{#if track.album_title}
<span class="separator">·</span>
<span class="album">{track.album_title}</span>
{/if}
</div>
</div>
<div class="track-duration">
{formatDuration(track.duration)}
</div>
{#if showReasons && track.reason}
<button
class="reason-btn"
title={track.reason}
>
<Info size={14} />
</button>
{/if}
<div class="actions">
<button
class="action-btn add"
onclick={() => handleAddTrack(track)}
title="Add to playlist"
>
<Plus size={16} />
</button>
<button
class="action-btn dismiss"
onclick={() => handleDismiss(track)}
title="Not interested"
>
<X size={16} />
</button>
</div>
</div>
{/each}
</div>
{#if totalPages > 1}
<div class="pagination-info">
Showing {currentPage * VISIBLE_COUNT + 1}-{Math.min((currentPage + 1) * VISIBLE_COUNT, filteredPool.length)} of {filteredPool.length}
</div>
{/if}
{/if}
</div>
{/if}
<style>
.suggestions-section {
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid var(--bg-tertiary);
}
.suggestions-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.header-left h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.header-left :global(.sparkle-icon) {
color: var(--accent-primary);
}
.stats {
font-size: 12px;
color: var(--text-muted);
margin-left: 8px;
}
.refresh-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: var(--bg-tertiary);
border: none;
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: all 150ms ease;
}
.refresh-btn:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
}
.refresh-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.refresh-btn.spinning :global(svg) {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-state,
.error-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px;
text-align: center;
color: var(--text-muted);
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid var(--bg-tertiary);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 12px;
}
.error-state button {
margin-top: 12px;
padding: 6px 16px;
background: var(--accent-primary);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.empty-state .hint {
font-size: 12px;
margin-top: 4px;
}
.suggestions-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.suggestion-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-radius: 8px;
transition: background-color 150ms ease;
}
.suggestion-row:hover {
background-color: var(--bg-hover);
}
.album-art {
width: 40px;
height: 40px;
background: var(--bg-tertiary);
border-radius: 4px;
overflow: hidden;
flex-shrink: 0;
}
.album-art img {
width: 100%;
height: 100%;
object-fit: cover;
}
.track-info {
flex: 1;
min-width: 0;
}
.track-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track-meta {
font-size: 12px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.separator {
margin: 0 4px;
}
.track-duration {
font-size: 13px;
color: var(--text-muted);
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
width: 48px;
text-align: right;
flex-shrink: 0;
}
.reason-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: none;
color: var(--text-muted);
cursor: help;
border-radius: 4px;
opacity: 0.5;
transition: opacity 150ms ease;
}
.suggestion-row:hover .reason-btn {
opacity: 1;
}
.actions {
display: flex;
align-items: center;
gap: 4px;
opacity: 0;
transition: opacity 150ms ease;
}
.suggestion-row:hover .actions {
opacity: 1;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 150ms ease;
}
.action-btn.add {
color: var(--accent-primary);
}
.action-btn.add:hover {
background: var(--accent-primary);
color: white;
}
.action-btn.dismiss {
color: var(--text-muted);
}
.action-btn.dismiss:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.pagination-info {
margin-top: 12px;
text-align: center;
font-size: 12px;
color: var(--text-muted);
}
</style>
@@ -7,6 +7,8 @@
import { invoke } from '@tauri-apps/api/core';
import { open } from '@tauri-apps/plugin-dialog';
import TrackRow from '../TrackRow.svelte';
import PlaylistSuggestions from '../PlaylistSuggestions.svelte';
import { extractUniqueArtists } from '$lib/services/playlistSuggestionsService';
import { type OfflineCacheStatus } from '$lib/stores/offlineCacheState';
import {
subscribe as subscribeOffline,
@@ -192,6 +194,15 @@
let localTracksDuration = $derived(localTracks.reduce((sum, t) => sum + t.duration_secs, 0));
let totalDuration = $derived((playlist?.duration ?? 0) + localTracksDuration);
// Playlist suggestions: extract unique artists from Qobuz tracks (not local)
const playlistArtists = $derived(
extractUniqueArtists(tracks.filter(t => !t.isLocal))
);
// Track IDs to exclude from suggestions (already in playlist)
const excludeTrackIds = $derived(
tracks.filter(t => !t.isLocal).map(t => t.id)
);
let loading = $state(true);
let error = $state<string | null>(null);
let scrollContainer: HTMLDivElement | null = $state(null);
@@ -1175,6 +1186,23 @@
}
}
// Add a suggested track to the playlist
async function handleAddSuggestedTrack(trackId: number) {
try {
await invoke('add_tracks_to_playlist', {
playlistId,
trackIds: [trackId]
});
// Reload to show the new track
await loadPlaylist();
notifyParentOfCounts();
onPlaylistUpdated?.();
} catch (err) {
console.error('Failed to add suggested track:', err);
throw err; // Re-throw so the suggestions component knows it failed
}
}
async function handlePlayAll() {
// Get all display tracks (Qobuz + local, respecting search/sort)
const allTracks = displayTracks;
@@ -1647,6 +1675,17 @@
{/if}
</div>
<!-- Playlist Suggestions -->
{#if playlist && !searchQuery && playlistArtists.length > 0}
<PlaylistSuggestions
playlistId={playlistId}
artists={playlistArtists}
excludeTrackIds={excludeTrackIds}
onAddTrack={handleAddSuggestedTrack}
showReasons={false}
/>
{/if}
{/if}
</div>
@@ -0,0 +1,232 @@
/**
* Playlist Suggestions Service (v2)
*
* Uses vector-based artist similarity to suggest tracks for playlists.
* Combines MusicBrainz relationships and Qobuz similar artists.
*/
import { invoke } from '@tauri-apps/api/core';
// ============ Types ============
export interface SuggestionConfig {
max_artists?: number;
tracks_per_artist?: number;
max_pool_size?: number;
vector_max_age_days?: number;
min_similarity?: number;
}
export interface SuggestedTrack {
track_id: number;
title: string;
artist_name: string;
artist_mbid?: string;
album_title: string;
album_id: string;
duration: number;
similarity_score: number;
reason?: string;
}
export interface SuggestionResult {
tracks: SuggestedTrack[];
source_artists: string[];
playlist_artists_count: number;
similar_artists_count: number;
}
export interface VectorStoreStats {
artist_count: number;
vector_count: number;
total_entries: number;
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_';
/**
* Get dismissed track IDs for a playlist
*/
export function getDismissedTrackIds(playlistId: number): Set<number> {
try {
const stored = localStorage.getItem(`${DISMISSED_STORAGE_PREFIX}${playlistId}`);
if (stored) {
return new Set(JSON.parse(stored) as number[]);
}
} catch {
// Ignore parse errors
}
return new Set();
}
/**
* Add a track to the dismissed set for a playlist
*/
export function dismissTrack(playlistId: number, trackId: number): void {
const dismissed = getDismissedTrackIds(playlistId);
dismissed.add(trackId);
localStorage.setItem(
`${DISMISSED_STORAGE_PREFIX}${playlistId}`,
JSON.stringify([...dismissed])
);
}
/**
* Clear dismissed tracks for a playlist
*/
export function clearDismissedTracks(playlistId: number): void {
localStorage.removeItem(`${DISMISSED_STORAGE_PREFIX}${playlistId}`);
}
// ============ Main API ============
export interface PlaylistArtist {
name: string;
qobuzId?: number;
}
/**
* Get suggestions for a playlist based on artist similarity
*
* @param artists - Unique artists from playlist tracks
* @param excludeTrackIds - Track IDs to exclude (already in playlist)
* @param includeReasons - Whether to include explanation strings (dev mode)
* @param config - Optional configuration overrides
*/
export async function getPlaylistSuggestionsV2(
artists: PlaylistArtist[],
excludeTrackIds: number[],
includeReasons: boolean = false,
config?: SuggestionConfig
): Promise<SuggestionResult> {
// Extract unique artist names
const uniqueNames = [...new Set(artists.map(a => a.name).filter(Boolean))];
if (uniqueNames.length === 0) {
return {
tracks: [],
source_artists: [],
playlist_artists_count: 0,
similar_artists_count: 0
};
}
// 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
const result = await invoke<SuggestionResult>('get_playlist_suggestions_v2', {
input: {
artist_mbids: artistMbids,
exclude_track_ids: excludeTrackIds,
include_reasons: includeReasons,
config: config ?? null
}
});
return result;
}
/**
* Get vector store statistics (for debugging)
*/
export async function getVectorStoreStats(): Promise<VectorStoreStats> {
return invoke<VectorStoreStats>('get_vector_store_stats');
}
/**
* Clean up expired vectors from the store
*
* @param maxAgeDays - Remove vectors older than this (default: 30)
* @returns Number of vectors removed
*/
export async function cleanupVectorStore(maxAgeDays?: number): Promise<number> {
return invoke<number>('cleanup_vector_store', { maxAgeDays: maxAgeDays ?? null });
}
// ============ Helpers ============
/**
* Extract unique artists from playlist tracks
*/
export function extractUniqueArtists(
tracks: Array<{ artist?: string; artistId?: number }>
): PlaylistArtist[] {
const seen = new Set<string>();
const artists: PlaylistArtist[] = [];
for (const track of tracks) {
if (track.artist && !seen.has(track.artist)) {
seen.add(track.artist);
artists.push({
name: track.artist,
qobuzId: track.artistId
});
}
}
return artists;
}
/**
* Format duration in seconds to mm:ss
*/
export function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}