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:
1 parent
591b194b25
commit
95fcb4ed4e
3 files changed
+159
-10
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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 }>();
|
||||
|
||||
Reference in new issue
Block a user