From 1a138d8e2fad5367bd82e03c323bbdd2cfe40af4 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:39:05 -0600 Subject: [PATCH] chore(reco_store): rename commands.rs to helpers.rs and drop dead commands The reco_store/commands.rs module no longer hosts any registered Tauri commands. Its surviving content is purely helper logic consumed by V2 home recommendations in commands_v2/library.rs. - Rename reco_store/commands.rs -> reco_store/helpers.rs - Drop the unregistered/uncalled #[tauri::command] entries (reco_log_event, reco_train_scores, get_playlist_suggestions, reco_backfill_genres, reco_needs_genre_backfill) and their dead scoring helpers (build_scores, build_track_entries, decay_factor, current_timestamp, etc.) along with RecoTrainStats / RecoScoreCounts / BackfillStats structs that only those commands referenced. - Update commands_v2/library.rs to import from crate::reco_store::helpers::* (still uses get_home_seeds_internal, resolve_albums, resolve_tracks, resolve_artists, album_to_card_meta). cc --- src-tauri/src/commands_v2/library.rs | 16 +- .../reco_store/{commands.rs => helpers.rs} | 467 +----------------- src-tauri/src/reco_store/mod.rs | 2 +- 3 files changed, 14 insertions(+), 471 deletions(-) rename src-tauri/src/reco_store/{commands.rs => helpers.rs} (58%) diff --git a/src-tauri/src/commands_v2/library.rs b/src-tauri/src/commands_v2/library.rs index 37084b40c..0e6a896a9 100644 --- a/src-tauri/src/commands_v2/library.rs +++ b/src-tauri/src/commands_v2/library.rs @@ -1868,7 +1868,7 @@ pub async fn v2_reco_get_home_ml( .as_ref() .ok_or("No active session - please log in")?; - crate::reco_store::commands::get_home_seeds_internal( + crate::reco_store::helpers::get_home_seeds_internal( db, limit_recent_albums, limit_continue_tracks, @@ -1902,7 +1902,7 @@ pub async fn v2_reco_get_home_resolved( let db = guard__ .as_ref() .ok_or("No active session - please log in")?; - crate::reco_store::commands::get_home_seeds_internal( + crate::reco_store::helpers::get_home_seeds_internal( db, limit_recent_albums, limit_continue_tracks, @@ -1933,25 +1933,25 @@ pub async fn v2_reco_get_home_resolved( // Step 3: Resolve all entity types in parallel. // Recent albums, favorite albums, tracks, and artists are all independent. - let recent_albums_fut = crate::reco_store::commands::resolve_albums( + let recent_albums_fut = crate::reco_store::helpers::resolve_albums( &seeds.recently_played_album_ids, &reco_state, &app_state, &cache_state, ); - let favorite_albums_fut = crate::reco_store::commands::resolve_albums( + let favorite_albums_fut = crate::reco_store::helpers::resolve_albums( &seeds.favorite_album_ids, &reco_state, &app_state, &cache_state, ); - let tracks_fut = crate::reco_store::commands::resolve_tracks( + let tracks_fut = crate::reco_store::helpers::resolve_tracks( &all_track_ids, &reco_state, &app_state, &cache_state, ); - let artists_fut = crate::reco_store::commands::resolve_artists( + let artists_fut = crate::reco_store::helpers::resolve_artists( &all_artist_ids, &artist_play_counts, &reco_state, @@ -2061,7 +2061,7 @@ pub async fn v2_reco_get_home_resolved( break; } if !exclusion.contains(&album.id) { - let meta = crate::reco_store::commands::album_to_card_meta(album); + let meta = crate::reco_store::helpers::album_to_card_meta(album); { let guard__ = reco_arc.lock().await; if let Some(db) = guard__.as_ref() { @@ -2154,7 +2154,7 @@ pub async fn v2_reco_get_forgotten_favorites( } // Resolve album IDs to metadata using the same 3-tier cache as home - crate::reco_store::commands::resolve_albums(&album_ids, &reco_state, &app_state, &cache_state) + crate::reco_store::helpers::resolve_albums(&album_ids, &reco_state, &app_state, &cache_state) .await } diff --git a/src-tauri/src/reco_store/commands.rs b/src-tauri/src/reco_store/helpers.rs similarity index 58% rename from src-tauri/src/reco_store/commands.rs rename to src-tauri/src/reco_store/helpers.rs index 20a60e4c2..2ce8a5fe2 100644 --- a/src-tauri/src/reco_store/commands.rs +++ b/src-tauri/src/reco_store/helpers.rs @@ -1,121 +1,19 @@ -//! Tauri commands for recommendation store +//! Helper functions for the recommendation store. +//! +//! These are non-command helpers consumed by V2 commands in +//! `commands_v2/library.rs` (specifically the home-recommendation paths). use std::collections::HashMap; -use std::time::{SystemTime, UNIX_EPOCH}; use tauri::State; use crate::api::models::{Album, Artist, ImageSet, Track}; use crate::api_cache::ApiCacheState; -use crate::reco_store::db::{RecoEventRecord, RecoScoreEntry}; use crate::reco_store::{ - AlbumCardMeta, ArtistCardMeta, HomeSeeds, RecoEventInput, RecoState, TopArtistSeed, - TrackDisplayMeta, + AlbumCardMeta, ArtistCardMeta, HomeSeeds, RecoState, TopArtistSeed, TrackDisplayMeta, }; use crate::AppState; -const DEFAULT_LOOKBACK_DAYS: i64 = 90; -const DEFAULT_HALF_LIFE_DAYS: f64 = 21.0; -const DEFAULT_MAX_EVENTS: u32 = 5000; -const DEFAULT_MAX_PER_TYPE: u32 = 200; - -#[derive(Debug, Clone, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RecoScoreCounts { - pub tracks: usize, - pub albums: usize, - pub artists: usize, -} - -#[derive(Debug, Clone, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RecoTrainStats { - pub total_events: usize, - pub total_favorite_events: usize, - pub all_scores: RecoScoreCounts, - pub favorite_scores: RecoScoreCounts, -} - -#[tauri::command] -pub async fn reco_log_event( - event: RecoEventInput, - state: State<'_, RecoState>, -) -> Result<(), String> { - log::info!( - "Command: reco_log_event type={} item={}", - event.event_type.as_str(), - event.item_type.as_str() - ); - - let guard__ = state.db.lock().await; - let db = guard__ - .as_ref() - .ok_or("No active session - please log in")?; - db.insert_event(&event) -} - -#[tauri::command] -pub async fn reco_train_scores( - lookback_days: Option, - half_life_days: Option, - max_events: Option, - max_per_type: Option, - state: State<'_, RecoState>, -) -> Result { - let lookback_days = lookback_days.unwrap_or(DEFAULT_LOOKBACK_DAYS); - let half_life_days = half_life_days.unwrap_or(DEFAULT_HALF_LIFE_DAYS); - let max_events = max_events.unwrap_or(DEFAULT_MAX_EVENTS); - let max_per_type = max_per_type.unwrap_or(DEFAULT_MAX_PER_TYPE); - - let now_ts = current_timestamp(); - let since_ts = now_ts.saturating_sub(lookback_days * 86_400); - - let mut guard__ = state.db.lock().await; - let db = guard__ - .as_mut() - .ok_or("No active session - please log in")?; - let events = db.get_events_since(since_ts, Some(max_events))?; - let total_events = events.len(); - let total_favorite_events = events - .iter() - .filter(|event| event.event_type == "favorite") - .count(); - - let all_scores = build_scores(&events, now_ts, half_life_days, false); - let favorite_scores = build_scores(&events, now_ts, half_life_days, true); - - let all_track_entries = build_track_entries(all_scores.tracks, max_per_type); - let all_album_entries = build_album_entries(all_scores.albums, max_per_type); - let all_artist_entries = build_artist_entries(all_scores.artists, max_per_type); - - let fav_track_entries = build_track_entries(favorite_scores.tracks, max_per_type); - let fav_album_entries = build_album_entries(favorite_scores.albums, max_per_type); - let fav_artist_entries = build_artist_entries(favorite_scores.artists, max_per_type); - - db.replace_scores("all", "track", &all_track_entries)?; - db.replace_scores("all", "album", &all_album_entries)?; - db.replace_scores("all", "artist", &all_artist_entries)?; - - db.replace_scores("favorite", "track", &fav_track_entries)?; - db.replace_scores("favorite", "album", &fav_album_entries)?; - db.replace_scores("favorite", "artist", &fav_artist_entries)?; - - Ok(RecoTrainStats { - total_events, - total_favorite_events, - all_scores: RecoScoreCounts { - tracks: all_track_entries.len(), - albums: all_album_entries.len(), - artists: all_artist_entries.len(), - }, - favorite_scores: RecoScoreCounts { - tracks: fav_track_entries.len(), - albums: fav_album_entries.len(), - artists: fav_artist_entries.len(), - }, - }) -} - /// Merge two lists preserving order: fresh items first, then scored items (excluding duplicates) fn merge_unique_preserve_order( fresh: Vec, @@ -149,360 +47,6 @@ fn merge_unique_preserve_order( result } -struct ScoreMaps { - tracks: HashMap, - albums: HashMap, - artists: HashMap, -} - -fn build_scores( - events: &[RecoEventRecord], - now_ts: i64, - half_life_days: f64, - favorites_only: bool, -) -> ScoreMaps { - let mut scores = ScoreMaps { - tracks: HashMap::new(), - albums: HashMap::new(), - artists: HashMap::new(), - }; - - for event in events { - if favorites_only && event.event_type != "favorite" { - continue; - } - - let age_secs = (now_ts - event.created_at).max(0); - let decay = decay_factor(age_secs, half_life_days); - let base_weight = event_weight(&event.event_type) * decay; - - if let Some(track_id) = event.track_id { - let weight = base_weight * item_weight("track", event.item_type == "track"); - add_score(&mut scores.tracks, track_id, weight); - } - - if let Some(album_id) = event.album_id.as_ref() { - let weight = base_weight * item_weight("album", event.item_type == "album"); - add_score(&mut scores.albums, album_id.clone(), weight); - } - - if let Some(artist_id) = event.artist_id { - let weight = base_weight * item_weight("artist", event.item_type == "artist"); - add_score(&mut scores.artists, artist_id, weight); - } - } - - scores -} - -fn build_track_entries(scores: HashMap, limit: u32) -> Vec { - let mut entries: Vec<(u64, f64)> = scores.into_iter().collect(); - entries.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); - entries - .into_iter() - .take(limit as usize) - .map(|(track_id, score)| RecoScoreEntry { - track_id: Some(track_id), - album_id: None, - artist_id: None, - score, - }) - .collect() -} - -fn build_album_entries(scores: HashMap, limit: u32) -> Vec { - let mut entries: Vec<(String, f64)> = scores.into_iter().collect(); - entries.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); - entries - .into_iter() - .take(limit as usize) - .map(|(album_id, score)| RecoScoreEntry { - track_id: None, - album_id: Some(album_id), - artist_id: None, - score, - }) - .collect() -} - -fn build_artist_entries(scores: HashMap, limit: u32) -> Vec { - let mut entries: Vec<(u64, f64)> = scores.into_iter().collect(); - entries.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); - entries - .into_iter() - .take(limit as usize) - .map(|(artist_id, score)| RecoScoreEntry { - track_id: None, - album_id: None, - artist_id: Some(artist_id), - score, - }) - .collect() -} - -fn add_score(map: &mut HashMap, key: K, value: f64) { - let entry = map.entry(key).or_insert(0.0); - *entry += value; -} - -fn event_weight(event_type: &str) -> f64 { - match event_type { - "play" => 1.0, - "favorite" => 3.0, - "playlist_add" => 1.2, - _ => 1.0, - } -} - -fn item_weight(item_type: &str, primary: bool) -> f64 { - if primary { - return 1.0; - } - - match item_type { - "album" => 0.7, - "artist" => 0.5, - "track" => 0.85, - _ => 0.6, - } -} - -fn decay_factor(age_secs: i64, half_life_days: f64) -> f64 { - if half_life_days <= 0.0 { - return 1.0; - } - let half_life_secs = half_life_days * 86_400.0; - let exponent = age_secs as f64 / half_life_secs; - 0.5_f64.powf(exponent) -} - -fn current_timestamp() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0) -} - -/// Get playlist suggestions based on ML scores -/// Returns track IDs that would fit well in the playlist based on: -/// 1. User's top scored tracks (from play/favorite history) -/// 2. Tracks from the same artists as the playlist -/// 3. Excluding tracks already in the playlist -#[tauri::command] -pub async fn get_playlist_suggestions( - playlist_track_ids: Vec, - playlist_artist_ids: Vec, - limit: Option, - state: State<'_, RecoState>, -) -> Result, String> { - let limit = limit.unwrap_or(10); - let guard__ = state.db.lock().await; - let db = guard__ - .as_ref() - .ok_or("No active session - please log in")?; - - // Get user's top scored tracks (get more than needed for filtering) - let fetch_limit = (limit * 5).max(50); - let scored_tracks = db.get_scored_track_ids("all", fetch_limit)?; - - // Also get favorite tracks for higher priority - let favorite_tracks = db.get_scored_track_ids("favorite", fetch_limit)?; - - // Get tracks by top artists from the playlist (artist affinity) - let artist_scored = db.get_scored_artist_scores("all", 50)?; - - // Build a set of tracks already in playlist for fast lookup - let existing: std::collections::HashSet = playlist_track_ids.iter().cloned().collect(); - let playlist_artists: std::collections::HashSet = - playlist_artist_ids.iter().cloned().collect(); - - // Score candidates: favorites get bonus, same-artist tracks get bonus - let mut candidates: HashMap = HashMap::new(); - - // Add scored tracks with base score - for (idx, track_id) in scored_tracks.iter().enumerate() { - if !existing.contains(track_id) { - let position_score = 1.0 - (idx as f64 / fetch_limit as f64) * 0.5; - candidates.insert(*track_id, position_score); - } - } - - // Boost favorite tracks - for (idx, track_id) in favorite_tracks.iter().enumerate() { - if !existing.contains(track_id) { - let position_score = 1.0 - (idx as f64 / fetch_limit as f64) * 0.3; - let entry = candidates.entry(*track_id).or_insert(0.0); - *entry += position_score * 1.5; // 1.5x bonus for favorites - } - } - - // Boost tracks from artists in the playlist - for &artist_id in &playlist_artists { - if let Some((_, artist_score)) = artist_scored.iter().find(|(id, _)| *id == artist_id) { - // Find tracks by this artist in our candidates and boost them - // Note: We don't have track->artist mapping here, so this is implicit - // through the scoring system (tracks played when artist was played) - for (_track_id, score) in candidates.iter_mut() { - // Boost slightly based on artist affinity (implicit) - *score += artist_score * 0.1; - } - } - } - - // Sort by score descending and take top N - let mut sorted: Vec<(u64, f64)> = candidates.into_iter().collect(); - sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); - - let suggestions: Vec = sorted - .into_iter() - .take(limit as usize) - .map(|(track_id, _)| track_id) - .collect(); - - Ok(suggestions) -} - -#[derive(Debug, Clone, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BackfillStats { - pub albums_processed: u32, - pub events_updated: u64, - pub albums_failed: u32, - pub albums_remaining: u32, -} - -/// Backfill genre_id for existing events by fetching album details from Qobuz -/// This runs in batches to avoid overwhelming the API -#[tauri::command] -pub async fn reco_backfill_genres( - batch_size: Option, - reco_state: State<'_, RecoState>, - app_state: State<'_, AppState>, -) -> Result { - let batch_size = batch_size.unwrap_or(50); - log::info!("Command: reco_backfill_genres batch_size={}", batch_size); - - // Get album IDs first (scoped lock to avoid Send issue) - let album_ids = { - let guard__ = reco_state.db.lock().await; - let db = guard__ - .as_ref() - .ok_or("No active session - please log in")?; - db.get_album_ids_without_genre(batch_size)? - }; - - if album_ids.is_empty() { - log::info!("No albums need genre backfill"); - return Ok(BackfillStats { - albums_processed: 0, - events_updated: 0, - albums_failed: 0, - albums_remaining: 0, - }); - } - - log::info!("Backfilling genres for {} albums", album_ids.len()); - - let mut albums_processed = 0u32; - let mut events_updated = 0u64; - let mut albums_failed = 0u32; - - for album_id in &album_ids { - // Fetch album from Qobuz API (lock client, then drop before DB) - let genre_info = { - let client = app_state.client.read().await; - match client.get_album(album_id).await { - Ok(album) => Some(album.genre.map(|g| (g.id, g.name))), - Err(e) => { - log::warn!("Failed to fetch album {}: {}", album_id, e); - albums_failed += 1; - None - } - } - }; - - if let Some(genre_opt) = genre_info { - let genre_id = genre_opt.as_ref().map(|(id, _)| *id).unwrap_or(0); - let genre_name = genre_opt - .as_ref() - .map(|(_, name)| name.as_str()) - .unwrap_or("none"); - - // Re-acquire DB lock for update - let guard__ = reco_state.db.lock().await; - let db = guard__ - .as_ref() - .ok_or("No active session - please log in")?; - match db.update_genre_for_album(album_id, genre_id) { - Ok(updated) => { - events_updated += updated; - albums_processed += 1; - if genre_id > 0 { - log::debug!( - "Updated {} events for album {} with genre {} ({})", - updated, - album_id, - genre_id, - genre_name - ); - } else { - log::debug!("Album {} has no genre, set to 0", album_id); - } - } - Err(e) => { - log::warn!("Failed to update genre for album {}: {}", album_id, e); - albums_failed += 1; - } - } - } - - // Small delay to avoid rate limiting - tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; - } - - // Check how many albums still need backfill - let albums_remaining = { - let guard__ = reco_state.db.lock().await; - let db = guard__ - .as_ref() - .ok_or("No active session - please log in")?; - let remaining = db.get_album_ids_without_genre(1)?; - if remaining.is_empty() { - 0 - } else { - db.get_album_ids_without_genre(10000)?.len() as u32 - } - }; - - log::info!( - "Backfill complete: processed={}, updated={}, failed={}, remaining={}", - albums_processed, - events_updated, - albums_failed, - albums_remaining - ); - - Ok(BackfillStats { - albums_processed, - events_updated, - albums_failed, - albums_remaining, - }) -} - -/// Check if genre backfill is needed -#[tauri::command] -pub async fn reco_needs_genre_backfill(reco_state: State<'_, RecoState>) -> Result { - let guard__ = reco_state.db.lock().await; - let db = guard__ - .as_ref() - .ok_or("No active session - please log in")?; - let albums = db.get_album_ids_without_genre(1)?; - Ok(!albums.is_empty()) -} - -// ============ Home Resolved Command ============ - fn format_duration(seconds: u32) -> String { let mins = seconds / 60; let secs = seconds % 60; @@ -689,7 +233,6 @@ pub(crate) fn get_home_seeds_internal( }) } - /// Resolve album IDs → AlbumCardMeta with 3-tier cache pub async fn resolve_albums( ids: &[String], diff --git a/src-tauri/src/reco_store/mod.rs b/src-tauri/src/reco_store/mod.rs index aba269146..3960c61b7 100644 --- a/src-tauri/src/reco_store/mod.rs +++ b/src-tauri/src/reco_store/mod.rs @@ -2,8 +2,8 @@ //! //! Persists lightweight usage events for home recommendations. -pub mod commands; pub mod db; +pub mod helpers; use serde::{Deserialize, Serialize}; use std::path::Path;