feat(suggestions): improve vector store and add cache management
- Add get_all_related_artists for direct relationship lookup - Add get_related_artists helper method - Add clear_all method to vector store for cache management - Add clear_vector_store Tauri command - Add Artist Vectors cache section to Settings view - Use artist_info tuples (mbid, name) for better track searching
This commit is contained in:
1 parent
9a89074b5f
commit
79c68d292e
4 files changed
+177
-7
No files matched your search
@@ -390,6 +390,94 @@ impl ArtistVectorStore {
|
|||||||
.flatten()
|
.flatten()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get related artists for a given artist (from their vector entries)
|
||||||
|
///
|
||||||
|
/// This returns artists that the given artist is connected to via
|
||||||
|
/// MusicBrainz relationships (members, collaborators, etc.)
|
||||||
|
pub fn get_related_artists(&self, mbid: &str) -> Result<Vec<SimilarArtist>, String> {
|
||||||
|
let Some(artist_idx) = self.get_idx(mbid) else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut stmt = self
|
||||||
|
.conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT target_idx, SUM(weight) as total_weight
|
||||||
|
FROM vector_entries
|
||||||
|
WHERE artist_idx = ?1
|
||||||
|
GROUP BY target_idx
|
||||||
|
ORDER BY total_weight DESC",
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to prepare query: {}", e))?;
|
||||||
|
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![artist_idx], |row| {
|
||||||
|
Ok((row.get::<_, u32>(0)?, row.get::<_, f32>(1)?))
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("Failed to query relations: {}", e))?;
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for row in rows.flatten() {
|
||||||
|
let (target_idx, weight) = row;
|
||||||
|
if let Some(target_mbid) = self.get_mbid(target_idx) {
|
||||||
|
let name = self.get_artist_name(target_mbid);
|
||||||
|
results.push(SimilarArtist {
|
||||||
|
mbid: target_mbid.to_string(),
|
||||||
|
name,
|
||||||
|
similarity: weight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all related artists for multiple source artists, excluding specified MBIDs
|
||||||
|
///
|
||||||
|
/// Returns a deduplicated list sorted by total weight across all sources
|
||||||
|
pub fn get_all_related_artists(
|
||||||
|
&self,
|
||||||
|
source_mbids: &[String],
|
||||||
|
exclude_mbids: &[String],
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<SimilarArtist>, String> {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
let exclude_set: std::collections::HashSet<_> = exclude_mbids.iter().collect();
|
||||||
|
let mut artist_weights: HashMap<String, (Option<String>, f32)> = HashMap::new();
|
||||||
|
|
||||||
|
for mbid in source_mbids {
|
||||||
|
let related = self.get_related_artists(mbid)?;
|
||||||
|
for artist in related {
|
||||||
|
if exclude_set.contains(&artist.mbid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let entry = artist_weights
|
||||||
|
.entry(artist.mbid.clone())
|
||||||
|
.or_insert((artist.name.clone(), 0.0));
|
||||||
|
entry.1 += artist.similarity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut results: Vec<_> = artist_weights
|
||||||
|
.into_iter()
|
||||||
|
.map(|(mbid, (name, weight))| SimilarArtist {
|
||||||
|
mbid,
|
||||||
|
name,
|
||||||
|
similarity: weight,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
results.sort_by(|a, b| {
|
||||||
|
b.similarity
|
||||||
|
.partial_cmp(&a.similarity)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
results.truncate(limit);
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
/// Get statistics about the store
|
/// Get statistics about the store
|
||||||
pub fn get_stats(&self) -> Result<StoreStats, String> {
|
pub fn get_stats(&self) -> Result<StoreStats, String> {
|
||||||
let artist_count = self.artist_to_idx.len();
|
let artist_count = self.artist_to_idx.len();
|
||||||
@@ -435,6 +523,30 @@ impl ArtistVectorStore {
|
|||||||
|
|
||||||
Ok(deleted)
|
Ok(deleted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clear all data from the store
|
||||||
|
pub fn clear_all(&mut self) -> Result<usize, String> {
|
||||||
|
let deleted = self
|
||||||
|
.conn
|
||||||
|
.execute("DELETE FROM vector_entries", [])
|
||||||
|
.map_err(|e| format!("Failed to delete vector entries: {}", e))?;
|
||||||
|
|
||||||
|
self.conn
|
||||||
|
.execute("DELETE FROM vector_metadata", [])
|
||||||
|
.map_err(|e| format!("Failed to delete metadata: {}", e))?;
|
||||||
|
|
||||||
|
self.conn
|
||||||
|
.execute("DELETE FROM artist_index", [])
|
||||||
|
.map_err(|e| format!("Failed to delete artist index: {}", e))?;
|
||||||
|
|
||||||
|
// Reset in-memory state
|
||||||
|
self.artist_to_idx.clear();
|
||||||
|
self.idx_to_artist.clear();
|
||||||
|
self.next_idx = 0;
|
||||||
|
|
||||||
|
log::info!("Artist vector store cleared: {} entries deleted", deleted);
|
||||||
|
Ok(deleted)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Statistics about the vector store
|
/// Statistics about the vector store
|
||||||
|
|||||||
@@ -59,8 +59,9 @@ pub async fn get_playlist_suggestions_v2(
|
|||||||
let skip_network = config.skip_vector_build;
|
let skip_network = config.skip_vector_build;
|
||||||
|
|
||||||
// Resolve artist names to MBIDs (with caching)
|
// Resolve artist names to MBIDs (with caching)
|
||||||
|
// Store (mbid, name) tuples so we can search for tracks by name
|
||||||
let resolve_start = Instant::now();
|
let resolve_start = Instant::now();
|
||||||
let mut artist_mbids = Vec::new();
|
let mut artist_info: Vec<(String, String)> = Vec::new(); // (mbid, name)
|
||||||
let mut cached_count = 0;
|
let mut cached_count = 0;
|
||||||
let mut resolved_count = 0;
|
let mut resolved_count = 0;
|
||||||
let mut skipped_count = 0;
|
let mut skipped_count = 0;
|
||||||
@@ -77,7 +78,7 @@ pub async fn get_playlist_suggestions_v2(
|
|||||||
if resolved.confidence != MatchConfidence::None
|
if resolved.confidence != MatchConfidence::None
|
||||||
&& resolved.confidence != MatchConfidence::Low
|
&& resolved.confidence != MatchConfidence::Low
|
||||||
{
|
{
|
||||||
artist_mbids.push(mbid);
|
artist_info.push((mbid, artist.name.clone()));
|
||||||
cached_count += 1;
|
cached_count += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -117,7 +118,7 @@ pub async fn get_playlist_suggestions_v2(
|
|||||||
let cache = mb_state.cache.lock().await;
|
let cache = mb_state.cache.lock().await;
|
||||||
let _ = cache.set_artist(&artist.name, &resolved);
|
let _ = cache.set_artist(&artist.name, &resolved);
|
||||||
}
|
}
|
||||||
artist_mbids.push(mbid);
|
artist_info.push((mbid, artist.name.clone()));
|
||||||
resolved_count += 1;
|
resolved_count += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -148,7 +149,7 @@ pub async fn get_playlist_suggestions_v2(
|
|||||||
skip_network
|
skip_network
|
||||||
);
|
);
|
||||||
|
|
||||||
if artist_mbids.is_empty() {
|
if artist_info.is_empty() {
|
||||||
log::warn!("[Suggestions] No MBIDs resolved, returning empty result");
|
log::warn!("[Suggestions] No MBIDs resolved, returning empty result");
|
||||||
return Ok(SuggestionResult {
|
return Ok(SuggestionResult {
|
||||||
tracks: Vec::new(),
|
tracks: Vec::new(),
|
||||||
@@ -182,11 +183,11 @@ pub async fn get_playlist_suggestions_v2(
|
|||||||
let exclude_set: HashSet<u64> = input.exclude_track_ids.into_iter().collect();
|
let exclude_set: HashSet<u64> = input.exclude_track_ids.into_iter().collect();
|
||||||
|
|
||||||
// Generate suggestions
|
// Generate suggestions
|
||||||
log::info!("[Suggestions] Calling engine.generate_suggestions with {} MBIDs", artist_mbids.len());
|
log::info!("[Suggestions] Calling engine.generate_suggestions with {} artists", artist_info.len());
|
||||||
let engine_start = std::time::Instant::now();
|
let engine_start = std::time::Instant::now();
|
||||||
|
|
||||||
let result = engine
|
let result = engine
|
||||||
.generate_suggestions(&artist_mbids, &exclude_set, input.include_reasons)
|
.generate_suggestions(&artist_info, &exclude_set, input.include_reasons)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let engine_elapsed = engine_start.elapsed();
|
let engine_elapsed = engine_start.elapsed();
|
||||||
@@ -229,3 +230,12 @@ pub async fn cleanup_vector_store(
|
|||||||
let mut store = store_state.store.lock().await;
|
let mut store = store_state.store.lock().await;
|
||||||
store.cleanup_expired(max_age_secs)
|
store.cleanup_expired(max_age_secs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clear all vector store data
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn clear_vector_store(
|
||||||
|
store_state: State<'_, ArtistVectorStoreState>,
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
let mut store = store_state.store.lock().await;
|
||||||
|
store.clear_all()
|
||||||
|
}
|
||||||
@@ -536,6 +536,7 @@ pub fn run() {
|
|||||||
commands::get_playlist_suggestions_v2,
|
commands::get_playlist_suggestions_v2,
|
||||||
commands::get_vector_store_stats,
|
commands::get_vector_store_stats,
|
||||||
commands::cleanup_vector_store,
|
commands::cleanup_vector_store,
|
||||||
|
commands::clear_vector_store,
|
||||||
// Favorites commands
|
// Favorites commands
|
||||||
commands::get_favorites,
|
commands::get_favorites,
|
||||||
commands::add_favorite,
|
commands::add_favorite,
|
||||||
|
|||||||
@@ -127,6 +127,8 @@
|
|||||||
let lyricsCacheStats = $state<{ entries: number; sizeBytes: number } | null>(null);
|
let lyricsCacheStats = $state<{ entries: number; sizeBytes: number } | null>(null);
|
||||||
let isClearingMusicBrainz = $state(false);
|
let isClearingMusicBrainz = $state(false);
|
||||||
let musicBrainzCacheStats = $state<{ recordings: number; artists: number; releases: number; relations: number } | null>(null);
|
let musicBrainzCacheStats = $state<{ recordings: number; artists: number; releases: number; relations: number } | null>(null);
|
||||||
|
let isClearingVectorStore = $state(false);
|
||||||
|
let vectorStoreStats = $state<{ artist_count: number; vector_count: number; entry_count: number } | null>(null);
|
||||||
|
|
||||||
// Migration state
|
// Migration state
|
||||||
let showMigrationModal = $state(false);
|
let showMigrationModal = $state(false);
|
||||||
@@ -556,6 +558,9 @@
|
|||||||
// Load MusicBrainz cache stats
|
// Load MusicBrainz cache stats
|
||||||
loadMusicBrainzCacheStats();
|
loadMusicBrainzCacheStats();
|
||||||
|
|
||||||
|
// Load vector store stats
|
||||||
|
loadVectorStoreStats();
|
||||||
|
|
||||||
// Load audio devices first (includes PipeWire sinks), then settings
|
// Load audio devices first (includes PipeWire sinks), then settings
|
||||||
// Also load backends and ALSA plugins
|
// Also load backends and ALSA plugins
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -1615,6 +1620,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadVectorStoreStats() {
|
||||||
|
try {
|
||||||
|
vectorStoreStats = await invoke('get_vector_store_stats');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load vector store stats:', err);
|
||||||
|
vectorStoreStats = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClearVectorStore() {
|
||||||
|
if (isClearingVectorStore) return;
|
||||||
|
isClearingVectorStore = true;
|
||||||
|
try {
|
||||||
|
await invoke('clear_vector_store');
|
||||||
|
console.log('Artist vector store cleared');
|
||||||
|
await loadVectorStoreStats();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to clear vector store:', err);
|
||||||
|
} finally {
|
||||||
|
isClearingVectorStore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
@@ -2399,7 +2427,7 @@
|
|||||||
{isClearingLyrics ? $t('settings.storage.clearing') : $t('actions.clear')}
|
{isClearingLyrics ? $t('settings.storage.clearing') : $t('actions.clear')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-row last">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">MusicBrainz Cache</span>
|
<span class="setting-label">MusicBrainz Cache</span>
|
||||||
<small class="setting-note">
|
<small class="setting-note">
|
||||||
@@ -2418,6 +2446,25 @@
|
|||||||
{isClearingMusicBrainz ? $t('settings.storage.clearing') : $t('actions.clear')}
|
{isClearingMusicBrainz ? $t('settings.storage.clearing') : $t('actions.clear')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-row last">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">Artist Vectors (Suggestions)</span>
|
||||||
|
<small class="setting-note">
|
||||||
|
{#if vectorStoreStats}
|
||||||
|
{vectorStoreStats.artist_count} artists, {vectorStoreStats.entry_count} relations
|
||||||
|
{:else}
|
||||||
|
-
|
||||||
|
{/if}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="clear-btn"
|
||||||
|
onclick={handleClearVectorStore}
|
||||||
|
disabled={isClearingVectorStore || !vectorStoreStats || vectorStoreStats.entry_count === 0}
|
||||||
|
>
|
||||||
|
{isClearingVectorStore ? $t('settings.storage.clearing') : $t('actions.clear')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Flatpak Section (only shown when running in Flatpak) -->
|
<!-- Flatpak Section (only shown when running in Flatpak) -->
|
||||||
|
|||||||
Reference in new issue
Block a user