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:
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')}`;
|
||||
}
|
||||
Reference in new issue
Block a user