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;
|
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}
|
||||||
|
|||||||
@@ -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 }>();
|
||||||
|
|||||||
Reference in new issue
Block a user