feat(suggestions): adaptive artist selection and duplicate filtering

- Add extractAdaptiveArtists() for dynamic seed artist count based on playlist size
- Mix 60% top artists (coherence) + 40% random (discovery) for variety
- Filter duplicates by title+artist, not just track ID
- Catches same song from compilations, live albums, remasters, etc.
- Deduplicate within suggestion pool itself
This commit is contained in:
vicrodh committed 2026-01-28 21:14:32 -06:00
1 parent 591b194b25
commit 95fcb4ed4e
3 files changed
+159 -10

No files matched your search

+57 -3
View File
@@ -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<void>;
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<string>();
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<number | null>(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<string>();
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)
@@ -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}
+97 -4
View File
@@ -161,18 +161,111 @@ export async function cleanupVectorStore(maxAgeDays?: number): Promise<number> {
// ============ 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<T>(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<string, { count: number; qobuzId?: number }>();
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<string, { count: number; qobuzId?: number }>();