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; playlistId: number;
artists: PlaylistArtist[]; artists: PlaylistArtist[];
excludeTrackIds: number[]; excludeTrackIds: number[];
/** Existing tracks in playlist for title+artist deduplication */
existingTracks?: Array<{ title: string; artist?: string }>;
onAddTrack?: (trackId: number) => Promise<void>; onAddTrack?: (trackId: number) => Promise<void>;
onGoToAlbum?: (albumId: string) => void; onGoToAlbum?: (albumId: string) => void;
onGoToArtist?: (artistId: number) => void; onGoToArtist?: (artistId: number) => void;
@@ -27,6 +29,7 @@
playlistId, playlistId,
artists, artists,
excludeTrackIds, excludeTrackIds,
existingTracks = [],
onAddTrack, onAddTrack,
onGoToAlbum, onGoToAlbum,
onGoToArtist, onGoToArtist,
@@ -34,6 +37,42 @@
showReasons = false showReasons = false
}: Props = $props(); }: 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 // TrackInfo modal state
let trackInfoOpen = $state(false); let trackInfoOpen = $state(false);
let trackInfoId = $state<number | null>(null); let trackInfoId = $state<number | null>(null);
@@ -81,9 +120,24 @@
// Derived state // Derived state
const dismissedIds = $derived(getDismissedTrackIds(playlistId)); const dismissedIds = $derived(getDismissedTrackIds(playlistId));
const filteredPool = $derived( const filteredPool = $derived.by(() => {
pool.filter(t => !dismissedIds.has(t.track_id) && !excludeTrackIds.includes(t.track_id)) // 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 totalPages = $derived(Math.ceil(filteredPool.length / VISIBLE_COUNT));
const visibleTracks = $derived( const visibleTracks = $derived(
filteredPool.slice(currentPage * VISIBLE_COUNT, (currentPage + 1) * VISIBLE_COUNT) filteredPool.slice(currentPage * VISIBLE_COUNT, (currentPage + 1) * VISIBLE_COUNT)
@@ -8,7 +8,7 @@
import { open } from '@tauri-apps/plugin-dialog'; import { open } from '@tauri-apps/plugin-dialog';
import TrackRow from '../TrackRow.svelte'; import TrackRow from '../TrackRow.svelte';
import PlaylistSuggestions from '../PlaylistSuggestions.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 { type OfflineCacheStatus } from '$lib/stores/offlineCacheState';
import { import {
subscribe as subscribeOffline, subscribe as subscribeOffline,
@@ -194,9 +194,10 @@
let localTracksDuration = $derived(localTracks.reduce((sum, t) => sum + t.duration_secs, 0)); let localTracksDuration = $derived(localTracks.reduce((sum, t) => sum + t.duration_secs, 0));
let totalDuration = $derived((playlist?.duration ?? 0) + localTracksDuration); 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( 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) // Track IDs to exclude from suggestions (already in playlist)
const excludeTrackIds = $derived( const excludeTrackIds = $derived(
@@ -1702,6 +1703,7 @@
playlistId={playlistId} playlistId={playlistId}
artists={playlistArtists} artists={playlistArtists}
excludeTrackIds={excludeTrackIds} excludeTrackIds={excludeTrackIds}
existingTracks={tracks.filter(t => !t.isLocal).map(t => ({ title: t.title, artist: t.artist }))}
onAddTrack={handleAddSuggestedTrack} onAddTrack={handleAddSuggestedTrack}
onGoToAlbum={onTrackGoToAlbum} onGoToAlbum={onTrackGoToAlbum}
onGoToArtist={onTrackGoToArtist} onGoToArtist={onTrackGoToArtist}
+97 -4
View File
@@ -161,18 +161,111 @@ export async function cleanupVectorStore(maxAgeDays?: number): Promise<number> {
// ============ Helpers ============ // ============ 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) * Extract top artists from playlist tracks, sorted by frequency (track count)
* @deprecated Use extractAdaptiveArtists for better discovery
* *
* @param tracks - Playlist tracks * @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( export function extractTopArtists(
tracks: Array<{ artist?: string; artistId?: number }>, tracks: Array<{ artist?: string; artistId?: number }>,
limit: number = DEFAULT_TOP_ARTISTS limit: number = 30
): PlaylistArtist[] { ): PlaylistArtist[] {
// Count tracks per artist // Count tracks per artist
const artistCounts = new Map<string, { count: number; qobuzId?: number }>(); const artistCounts = new Map<string, { count: number; qobuzId?: number }>();