diff --git a/src/lib/components/PlaylistSuggestions.svelte b/src/lib/components/PlaylistSuggestions.svelte index 750f581eb..1c24149e0 100644 --- a/src/lib/components/PlaylistSuggestions.svelte +++ b/src/lib/components/PlaylistSuggestions.svelte @@ -16,6 +16,8 @@ playlistId: number; artists: PlaylistArtist[]; excludeTrackIds: number[]; + /** Existing tracks in playlist for title+artist deduplication */ + existingTracks?: Array<{ title: string; artist?: string }>; onAddTrack?: (trackId: number) => Promise; onGoToAlbum?: (albumId: string) => void; onGoToArtist?: (artistId: number) => void; @@ -27,6 +29,7 @@ playlistId, artists, excludeTrackIds, + existingTracks = [], onAddTrack, onGoToAlbum, onGoToArtist, @@ -34,6 +37,42 @@ showReasons = false }: Props = $props(); + /** + * Normalize a string for comparison (lowercase, trim, remove extra whitespace) + */ + function normalizeForComparison(str: string): string { + return str.toLowerCase().trim().replace(/\s+/g, ' '); + } + + /** + * Create a key for title+artist deduplication + */ + function makeTrackKey(title: string, artist: string): string { + return `${normalizeForComparison(title)}|${normalizeForComparison(artist)}`; + } + + /** + * Build set of existing track keys for fast lookup + */ + const existingTrackKeys = $derived.by(() => { + const keys = new Set(); + for (const track of existingTracks) { + if (track.title && track.artist) { + keys.add(makeTrackKey(track.title, track.artist)); + } + } + return keys; + }); + + /** + * Check if a suggested track is a duplicate of an existing track + */ + function isDuplicateOfExisting(track: SuggestedTrack): boolean { + if (!track.title || !track.artist_name) return false; + const key = makeTrackKey(track.title, track.artist_name); + return existingTrackKeys.has(key); + } + // TrackInfo modal state let trackInfoOpen = $state(false); let trackInfoId = $state(null); @@ -81,9 +120,24 @@ // Derived state const dismissedIds = $derived(getDismissedTrackIds(playlistId)); - const filteredPool = $derived( - pool.filter(t => !dismissedIds.has(t.track_id) && !excludeTrackIds.includes(t.track_id)) - ); + const filteredPool = $derived.by(() => { + // First filter by ID and dismissed + const byId = pool.filter(t => + !dismissedIds.has(t.track_id) && !excludeTrackIds.includes(t.track_id) + ); + + // Then filter by title+artist to avoid duplicates from compilations, live albums, etc. + const filtered = byId.filter(t => !isDuplicateOfExisting(t)); + + // Also deduplicate within the pool itself (same song from different albums) + const seenKeys = new Set(); + return filtered.filter(t => { + const key = makeTrackKey(t.title, t.artist_name); + if (seenKeys.has(key)) return false; + seenKeys.add(key); + return true; + }); + }); const totalPages = $derived(Math.ceil(filteredPool.length / VISIBLE_COUNT)); const visibleTracks = $derived( filteredPool.slice(currentPage * VISIBLE_COUNT, (currentPage + 1) * VISIBLE_COUNT) diff --git a/src/lib/components/views/PlaylistDetailView.svelte b/src/lib/components/views/PlaylistDetailView.svelte index d423411e8..b33843b19 100644 --- a/src/lib/components/views/PlaylistDetailView.svelte +++ b/src/lib/components/views/PlaylistDetailView.svelte @@ -8,7 +8,7 @@ import { open } from '@tauri-apps/plugin-dialog'; import TrackRow from '../TrackRow.svelte'; import PlaylistSuggestions from '../PlaylistSuggestions.svelte'; - import { extractTopArtists } from '$lib/services/playlistSuggestionsService'; + import { extractAdaptiveArtists } from '$lib/services/playlistSuggestionsService'; import { type OfflineCacheStatus } from '$lib/stores/offlineCacheState'; import { subscribe as subscribeOffline, @@ -194,9 +194,10 @@ let localTracksDuration = $derived(localTracks.reduce((sum, t) => sum + t.duration_secs, 0)); let totalDuration = $derived((playlist?.duration ?? 0) + localTracksDuration); - // Playlist suggestions: extract top 5 artists by track count (not local tracks) + // Playlist suggestions: adaptive artist selection (quantity scales with playlist size, + // mix of top artists for coherence + random artists for discovery) const playlistArtists = $derived( - extractTopArtists(tracks.filter(t => !t.isLocal), 5) + extractAdaptiveArtists(tracks.filter(t => !t.isLocal)) ); // Track IDs to exclude from suggestions (already in playlist) const excludeTrackIds = $derived( @@ -1702,6 +1703,7 @@ playlistId={playlistId} artists={playlistArtists} excludeTrackIds={excludeTrackIds} + existingTracks={tracks.filter(t => !t.isLocal).map(t => ({ title: t.title, artist: t.artist }))} onAddTrack={handleAddSuggestedTrack} onGoToAlbum={onTrackGoToAlbum} onGoToArtist={onTrackGoToArtist} diff --git a/src/lib/services/playlistSuggestionsService.ts b/src/lib/services/playlistSuggestionsService.ts index dadf99415..25ad1ba1d 100644 --- a/src/lib/services/playlistSuggestionsService.ts +++ b/src/lib/services/playlistSuggestionsService.ts @@ -161,18 +161,111 @@ export async function cleanupVectorStore(maxAgeDays?: number): Promise { // ============ Helpers ============ -/** Default number of top artists to use for suggestions */ -const DEFAULT_TOP_ARTISTS = 30; +/** + * Calculate adaptive artist limit based on playlist size + * Larger playlists = more seed artists for better discovery + */ +function calculateAdaptiveLimit(trackCount: number): number { + if (trackCount < 15) return Math.max(3, Math.min(5, trackCount)); + if (trackCount < 50) return Math.min(10, Math.ceil(trackCount * 0.3)); + if (trackCount < 100) return Math.min(15, Math.ceil(trackCount * 0.2)); + return Math.min(20, Math.ceil(trackCount * 0.15)); +} + +/** + * Shuffle array using Fisher-Yates algorithm + */ +function shuffleArray(array: T[]): T[] { + const result = [...array]; + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +} + +/** + * Extract artists from playlist tracks with adaptive quantity and mixed selection + * + * This function balances coherence (top artists by frequency) with discovery + * (random selection from less frequent artists). + * + * @param tracks - Playlist tracks + * @param options - Configuration options + * @param options.topRatio - Ratio of top artists vs random (default: 0.6 = 60% top, 40% random) + * @param options.forceLimit - Override adaptive limit with fixed number + */ +export function extractAdaptiveArtists( + tracks: Array<{ artist?: string; artistId?: number }>, + options: { topRatio?: number; forceLimit?: number } = {} +): PlaylistArtist[] { + const { topRatio = 0.6, forceLimit } = options; + + // Count tracks per artist + const artistCounts = new Map(); + + for (const track of tracks) { + if (track.artist) { + const existing = artistCounts.get(track.artist); + if (existing) { + existing.count++; + } else { + artistCounts.set(track.artist, { count: 1, qobuzId: track.artistId }); + } + } + } + + const uniqueArtistCount = artistCounts.size; + if (uniqueArtistCount === 0) return []; + + // Calculate how many artists to select + const limit = forceLimit ?? calculateAdaptiveLimit(tracks.length); + const actualLimit = Math.min(limit, uniqueArtistCount); + + // If we have few artists, just return all of them shuffled + if (uniqueArtistCount <= actualLimit) { + const all = [...artistCounts.entries()].map(([name, data]) => ({ + name, + qobuzId: data.qobuzId + })); + return shuffleArray(all); + } + + // Sort by count descending + const sorted = [...artistCounts.entries()] + .sort((a, b) => b[1].count - a[1].count); + + // Split into top artists and the rest + const topCount = Math.max(1, Math.floor(actualLimit * topRatio)); + const randomCount = actualLimit - topCount; + + // Take top artists (coherence) + const topArtists = sorted.slice(0, topCount); + + // Randomly select from remaining artists (discovery) + const remaining = sorted.slice(topCount); + const randomArtists = shuffleArray(remaining).slice(0, randomCount); + + // Combine and shuffle the final selection + const combined = [...topArtists, ...randomArtists]; + const shuffled = shuffleArray(combined); + + return shuffled.map(([name, data]) => ({ + name, + qobuzId: data.qobuzId + })); +} /** * Extract top artists from playlist tracks, sorted by frequency (track count) + * @deprecated Use extractAdaptiveArtists for better discovery * * @param tracks - Playlist tracks - * @param limit - Maximum number of artists to return (default: 5) + * @param limit - Maximum number of artists to return */ export function extractTopArtists( tracks: Array<{ artist?: string; artistId?: number }>, - limit: number = DEFAULT_TOP_ARTISTS + limit: number = 30 ): PlaylistArtist[] { // Count tracks per artist const artistCounts = new Map();