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:
vicrodh committed 2026-01-27 16:54:11 -06:00
1 parent 9a89074b5f
commit 79c68d292e
4 files changed
+177 -7

No files matched your search

+112
View File
@@ -390,6 +390,94 @@ impl ArtistVectorStore {
.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
pub fn get_stats(&self) -> Result<StoreStats, String> {
let artist_count = self.artist_to_idx.len();
@@ -435,6 +523,30 @@ impl ArtistVectorStore {
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
+16 -6
View File
@@ -59,8 +59,9 @@ pub async fn get_playlist_suggestions_v2(
let skip_network = config.skip_vector_build;
// 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 mut artist_mbids = Vec::new();
let mut artist_info: Vec<(String, String)> = Vec::new(); // (mbid, name)
let mut cached_count = 0;
let mut resolved_count = 0;
let mut skipped_count = 0;
@@ -77,7 +78,7 @@ pub async fn get_playlist_suggestions_v2(
if resolved.confidence != MatchConfidence::None
&& resolved.confidence != MatchConfidence::Low
{
artist_mbids.push(mbid);
artist_info.push((mbid, artist.name.clone()));
cached_count += 1;
continue;
}
@@ -117,7 +118,7 @@ pub async fn get_playlist_suggestions_v2(
let cache = mb_state.cache.lock().await;
let _ = cache.set_artist(&artist.name, &resolved);
}
artist_mbids.push(mbid);
artist_info.push((mbid, artist.name.clone()));
resolved_count += 1;
continue;
}
@@ -148,7 +149,7 @@ pub async fn get_playlist_suggestions_v2(
skip_network
);
if artist_mbids.is_empty() {
if artist_info.is_empty() {
log::warn!("[Suggestions] No MBIDs resolved, returning empty result");
return Ok(SuggestionResult {
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();
// 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 result = engine
.generate_suggestions(&artist_mbids, &exclude_set, input.include_reasons)
.generate_suggestions(&artist_info, &exclude_set, input.include_reasons)
.await;
let engine_elapsed = engine_start.elapsed();
@@ -229,3 +230,12 @@ pub async fn cleanup_vector_store(
let mut store = store_state.store.lock().await;
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()
}
+1
View File
@@ -536,6 +536,7 @@ pub fn run() {
commands::get_playlist_suggestions_v2,
commands::get_vector_store_stats,
commands::cleanup_vector_store,
commands::clear_vector_store,
// Favorites commands
commands::get_favorites,
commands::add_favorite,
+48 -1
View File
@@ -127,6 +127,8 @@
let lyricsCacheStats = $state<{ entries: number; sizeBytes: number } | null>(null);
let isClearingMusicBrainz = $state(false);
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
let showMigrationModal = $state(false);
@@ -556,6 +558,9 @@
// Load MusicBrainz cache stats
loadMusicBrainzCacheStats();
// Load vector store stats
loadVectorStoreStats();
// Load audio devices first (includes PipeWire sinks), then settings
// Also load backends and ALSA plugins
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 {
if (bytes === 0) return '0 B';
const k = 1024;
@@ -2399,7 +2427,7 @@
{isClearingLyrics ? $t('settings.storage.clearing') : $t('actions.clear')}
</button>
</div>
<div class="setting-row last">
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">MusicBrainz Cache</span>
<small class="setting-note">
@@ -2418,6 +2446,25 @@
{isClearingMusicBrainz ? $t('settings.storage.clearing') : $t('actions.clear')}
</button>
</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>
<!-- Flatpak Section (only shown when running in Flatpak) -->