From f4e8d6853851b28040e8370cfd05bfe1e0ad3f73 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sat, 10 Jan 2026 08:10:34 -0600 Subject: [PATCH] feat(home): add recommendation store and data-driven home Backend: - Add reco_store module with SQLite-backed event storage - Add reco_log_event, reco_get_home commands - Add ML-lite scoring: reco_train_scores, reco_get_home_ml - Score decay based on event age (configurable half-life) Frontend: - Add recoService.ts for event logging - Log play/favorite/playlist_add events - Add albumId/artistId to PlayingTrack for tracking - Rework HomeView to fetch recommendations from reco_store - Display recently played, continue listening, top artists, favorites Integrated from home-reco and home-reco-ml branches --- src-tauri/src/lib.rs | 10 + src-tauri/src/reco_store/commands.rs | 342 +++++++++++++++++ src-tauri/src/reco_store/db.rs | 467 +++++++++++++++++++++++ src-tauri/src/reco_store/mod.rs | 100 +++++ src/lib/components/PlaylistModal.svelte | 3 + src/lib/components/views/HomeView.svelte | 445 +++++++++++++++++---- src/lib/services/playbackService.ts | 24 ++ src/lib/services/recoService.ts | 95 +++++ src/lib/stores/playerStore.ts | 3 + src/routes/+page.svelte | 12 +- 10 files changed, 1429 insertions(+), 72 deletions(-) create mode 100644 src-tauri/src/reco_store/commands.rs create mode 100644 src-tauri/src/reco_store/db.rs create mode 100644 src-tauri/src/reco_store/mod.rs create mode 100644 src/lib/services/recoService.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a1e457026..825eda93b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -17,6 +17,7 @@ pub mod lyrics; pub mod media_controls; pub mod player; pub mod queue; +pub mod reco_store; pub mod share; use std::sync::Arc; @@ -112,6 +113,9 @@ pub fn run() { // Initialize lyrics cache state let lyrics_state = lyrics::LyricsState::new() .expect("Failed to initialize lyrics cache"); + // Initialize recommendation store state + let reco_state = reco_store::RecoState::new() + .expect("Failed to initialize recommendation store"); tauri::Builder::default() .plugin(tauri_plugin_opener::init()) @@ -169,6 +173,7 @@ pub fn run() { .manage(airplay_state) .manage(download_cache_state) .manage(lyrics_state) + .manage(reco_state) .invoke_handler(tauri::generate_handler![ // Auth commands commands::init_client, @@ -331,6 +336,11 @@ pub fn run() { // Lyrics commands lyrics::commands::lyrics_get, lyrics::commands::lyrics_clear_cache, + // Recommendation store commands + reco_store::commands::reco_log_event, + reco_store::commands::reco_get_home, + reco_store::commands::reco_train_scores, + reco_store::commands::reco_get_home_ml, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/reco_store/commands.rs b/src-tauri/src/reco_store/commands.rs new file mode 100644 index 000000000..7a97396e6 --- /dev/null +++ b/src-tauri/src/reco_store/commands.rs @@ -0,0 +1,342 @@ +//! Tauri commands for recommendation store + +use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +use tauri::State; + +use crate::reco_store::db::{RecoEventRecord, RecoScoreEntry}; +use crate::reco_store::{HomeSeeds, RecoEventInput, RecoState, TopArtistSeed}; + +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 db = state.db.lock().await; + db.insert_event(&event) +} + +#[tauri::command] +pub async fn reco_get_home( + limit_recent_albums: Option, + limit_continue_tracks: Option, + limit_top_artists: Option, + limit_favorites: Option, + state: State<'_, RecoState>, +) -> Result { + let limit_recent_albums = limit_recent_albums.unwrap_or(12); + let limit_continue_tracks = limit_continue_tracks.unwrap_or(10); + let limit_top_artists = limit_top_artists.unwrap_or(10); + let limit_favorites = limit_favorites.unwrap_or(12); + + let db = state.db.lock().await; + + let recently_played_album_ids = db.get_recent_album_ids(limit_recent_albums)?; + let continue_listening_track_ids = db.get_recent_track_ids(limit_continue_tracks)?; + let top_artist_ids = db.get_top_artist_ids(limit_top_artists)?; + let favorite_album_ids = db.get_favorite_album_ids(limit_favorites)?; + let favorite_track_ids = db.get_favorite_track_ids(limit_favorites)?; + + Ok(HomeSeeds { + recently_played_album_ids, + continue_listening_track_ids, + top_artist_ids, + favorite_album_ids, + favorite_track_ids, + }) +} + +#[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 db = state.db.lock().await; + 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(), + }, + }) +} + +#[tauri::command] +pub async fn reco_get_home_ml( + limit_recent_albums: Option, + limit_continue_tracks: Option, + limit_top_artists: Option, + limit_favorites: Option, + state: State<'_, RecoState>, +) -> Result { + let limit_recent_albums = limit_recent_albums.unwrap_or(12); + let limit_continue_tracks = limit_continue_tracks.unwrap_or(10); + let limit_top_artists = limit_top_artists.unwrap_or(10); + let limit_favorites = limit_favorites.unwrap_or(12); + + let db = state.db.lock().await; + let has_scores = db.has_scores("all")?; + + let mut recently_played_album_ids = if has_scores { + db.get_scored_album_ids("all", limit_recent_albums)? + } else { + Vec::new() + }; + let mut continue_listening_track_ids = if has_scores { + db.get_scored_track_ids("all", limit_continue_tracks)? + } else { + Vec::new() + }; + let mut top_artist_ids = if has_scores { + db.get_scored_artist_scores("all", limit_top_artists)? + .into_iter() + .map(|(artist_id, score)| TopArtistSeed { + artist_id, + play_count: score.round().max(1.0) as u32, + }) + .collect() + } else { + Vec::new() + }; + let mut favorite_album_ids = if has_scores { + db.get_scored_album_ids("favorite", limit_favorites)? + } else { + Vec::new() + }; + let mut favorite_track_ids = if has_scores { + db.get_scored_track_ids("favorite", limit_favorites)? + } else { + Vec::new() + }; + + if recently_played_album_ids.is_empty() { + recently_played_album_ids = db.get_recent_album_ids(limit_recent_albums)?; + } + if continue_listening_track_ids.is_empty() { + continue_listening_track_ids = db.get_recent_track_ids(limit_continue_tracks)?; + } + if top_artist_ids.is_empty() { + top_artist_ids = db.get_top_artist_ids(limit_top_artists)?; + } + if favorite_album_ids.is_empty() { + favorite_album_ids = db.get_favorite_album_ids(limit_favorites)?; + } + if favorite_track_ids.is_empty() { + favorite_track_ids = db.get_favorite_track_ids(limit_favorites)?; + } + + Ok(HomeSeeds { + recently_played_album_ids, + continue_listening_track_ids, + top_artist_ids, + favorite_album_ids, + favorite_track_ids, + }) +} + +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) +} diff --git a/src-tauri/src/reco_store/db.rs b/src-tauri/src/reco_store/db.rs new file mode 100644 index 000000000..1c88b66d9 --- /dev/null +++ b/src-tauri/src/reco_store/db.rs @@ -0,0 +1,467 @@ +//! SQLite storage for recommendation events + +use rusqlite::{params, Connection}; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::reco_store::{RecoEventInput, TopArtistSeed}; + +#[derive(Debug, Clone)] +pub struct RecoEventRecord { + pub event_type: String, + pub item_type: String, + pub track_id: Option, + pub album_id: Option, + pub artist_id: Option, + pub created_at: i64, +} + +#[derive(Debug, Clone)] +pub struct RecoScoreEntry { + pub track_id: Option, + pub album_id: Option, + pub artist_id: Option, + pub score: f64, +} + +pub struct RecoStoreDb { + conn: Connection, +} + +impl RecoStoreDb { + pub fn new(path: &Path) -> Result { + let conn = Connection::open(path) + .map_err(|e| format!("Failed to open reco database: {}", e))?; + let db = Self { conn }; + db.init()?; + Ok(db) + } + + fn init(&self) -> Result<(), String> { + self.conn + .execute_batch( + r#" + CREATE TABLE IF NOT EXISTS reco_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + item_type TEXT NOT NULL, + track_id INTEGER, + album_id TEXT, + artist_id INTEGER, + playlist_id INTEGER, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_reco_events_type ON reco_events(event_type); + CREATE INDEX IF NOT EXISTS idx_reco_events_track ON reco_events(track_id); + CREATE INDEX IF NOT EXISTS idx_reco_events_album ON reco_events(album_id); + CREATE INDEX IF NOT EXISTS idx_reco_events_artist ON reco_events(artist_id); + CREATE INDEX IF NOT EXISTS idx_reco_events_created ON reco_events(created_at); + + CREATE TABLE IF NOT EXISTS reco_scores ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + score_type TEXT NOT NULL, + item_type TEXT NOT NULL, + track_id INTEGER, + album_id TEXT, + artist_id INTEGER, + score REAL NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_reco_scores_type ON reco_scores(score_type); + CREATE INDEX IF NOT EXISTS idx_reco_scores_item ON reco_scores(item_type); + CREATE INDEX IF NOT EXISTS idx_reco_scores_track ON reco_scores(track_id); + CREATE INDEX IF NOT EXISTS idx_reco_scores_album ON reco_scores(album_id); + CREATE INDEX IF NOT EXISTS idx_reco_scores_artist ON reco_scores(artist_id); + "#, + ) + .map_err(|e| format!("Failed to initialize reco database: {}", e))?; + Ok(()) + } + + pub fn insert_event(&self, event: &RecoEventInput) -> Result<(), String> { + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + self.conn + .execute( + r#" + INSERT INTO reco_events ( + event_type, + item_type, + track_id, + album_id, + artist_id, + playlist_id, + created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + "#, + params![ + event.event_type.as_str(), + event.item_type.as_str(), + event.track_id, + event.album_id.as_deref(), + event.artist_id, + event.playlist_id, + created_at, + ], + ) + .map_err(|e| format!("Failed to insert reco event: {}", e))?; + + Ok(()) + } + + pub fn get_recent_album_ids(&self, limit: u32) -> Result, String> { + let mut stmt = self.conn + .prepare( + r#" + SELECT album_id, MAX(created_at) AS last_played + FROM reco_events + WHERE event_type = 'play' AND album_id IS NOT NULL + GROUP BY album_id + ORDER BY last_played DESC + LIMIT ? + "#, + ) + .map_err(|e| format!("Failed to prepare recent albums query: {}", e))?; + + let rows = stmt + .query_map(params![limit], |row| row.get::<_, String>(0)) + .map_err(|e| format!("Failed to query recent albums: {}", e))?; + + let mut albums = Vec::new(); + for row in rows { + albums.push(row.map_err(|e| format!("Failed to read recent album row: {}", e))?); + } + Ok(albums) + } + + pub fn get_recent_track_ids(&self, limit: u32) -> Result, String> { + let mut stmt = self.conn + .prepare( + r#" + SELECT track_id, MAX(created_at) AS last_played + FROM reco_events + WHERE event_type = 'play' AND track_id IS NOT NULL + GROUP BY track_id + ORDER BY last_played DESC + LIMIT ? + "#, + ) + .map_err(|e| format!("Failed to prepare recent tracks query: {}", e))?; + + let rows = stmt + .query_map(params![limit], |row| row.get::<_, u64>(0)) + .map_err(|e| format!("Failed to query recent tracks: {}", e))?; + + let mut tracks = Vec::new(); + for row in rows { + tracks.push(row.map_err(|e| format!("Failed to read recent track row: {}", e))?); + } + Ok(tracks) + } + + pub fn get_events_since( + &self, + since_ts: i64, + limit: Option, + ) -> Result, String> { + let mut events = Vec::new(); + + if let Some(limit) = limit { + let mut stmt = self.conn + .prepare( + r#" + SELECT event_type, item_type, track_id, album_id, artist_id, created_at + FROM reco_events + WHERE created_at >= ? + ORDER BY created_at DESC + LIMIT ? + "#, + ) + .map_err(|e| format!("Failed to prepare reco events query: {}", e))?; + + let rows = stmt + .query_map(params![since_ts, limit], |row| { + Ok(RecoEventRecord { + event_type: row.get(0)?, + item_type: row.get(1)?, + track_id: row.get(2)?, + album_id: row.get(3)?, + artist_id: row.get(4)?, + created_at: row.get(5)?, + }) + }) + .map_err(|e| format!("Failed to query reco events: {}", e))?; + + for row in rows { + events.push(row.map_err(|e| format!("Failed to read reco event row: {}", e))?); + } + } else { + let mut stmt = self.conn + .prepare( + r#" + SELECT event_type, item_type, track_id, album_id, artist_id, created_at + FROM reco_events + WHERE created_at >= ? + ORDER BY created_at DESC + "#, + ) + .map_err(|e| format!("Failed to prepare reco events query: {}", e))?; + + let rows = stmt + .query_map(params![since_ts], |row| { + Ok(RecoEventRecord { + event_type: row.get(0)?, + item_type: row.get(1)?, + track_id: row.get(2)?, + album_id: row.get(3)?, + artist_id: row.get(4)?, + created_at: row.get(5)?, + }) + }) + .map_err(|e| format!("Failed to query reco events: {}", e))?; + + for row in rows { + events.push(row.map_err(|e| format!("Failed to read reco event row: {}", e))?); + } + } + + Ok(events) + } + + pub fn get_top_artist_ids(&self, limit: u32) -> Result, String> { + let mut stmt = self.conn + .prepare( + r#" + SELECT artist_id, COUNT(*) AS play_count, MAX(created_at) AS last_played + FROM reco_events + WHERE event_type = 'play' AND artist_id IS NOT NULL + GROUP BY artist_id + ORDER BY play_count DESC, last_played DESC + LIMIT ? + "#, + ) + .map_err(|e| format!("Failed to prepare top artists query: {}", e))?; + + let rows = stmt + .query_map(params![limit], |row| { + Ok(TopArtistSeed { + artist_id: row.get::<_, u64>(0)?, + play_count: row.get::<_, u32>(1)?, + }) + }) + .map_err(|e| format!("Failed to query top artists: {}", e))?; + + let mut artists = Vec::new(); + for row in rows { + artists.push(row.map_err(|e| format!("Failed to read top artist row: {}", e))?); + } + Ok(artists) + } + + pub fn get_favorite_album_ids(&self, limit: u32) -> Result, String> { + let mut stmt = self.conn + .prepare( + r#" + SELECT album_id, MAX(created_at) AS last_favorite + FROM reco_events + WHERE event_type = 'favorite' AND album_id IS NOT NULL + GROUP BY album_id + ORDER BY last_favorite DESC + LIMIT ? + "#, + ) + .map_err(|e| format!("Failed to prepare favorite albums query: {}", e))?; + + let rows = stmt + .query_map(params![limit], |row| row.get::<_, String>(0)) + .map_err(|e| format!("Failed to query favorite albums: {}", e))?; + + let mut albums = Vec::new(); + for row in rows { + albums.push(row.map_err(|e| format!("Failed to read favorite album row: {}", e))?); + } + Ok(albums) + } + + pub fn get_favorite_track_ids(&self, limit: u32) -> Result, String> { + let mut stmt = self.conn + .prepare( + r#" + SELECT track_id, MAX(created_at) AS last_favorite + FROM reco_events + WHERE event_type = 'favorite' AND track_id IS NOT NULL + GROUP BY track_id + ORDER BY last_favorite DESC + LIMIT ? + "#, + ) + .map_err(|e| format!("Failed to prepare favorite tracks query: {}", e))?; + + let rows = stmt + .query_map(params![limit], |row| row.get::<_, u64>(0)) + .map_err(|e| format!("Failed to query favorite tracks: {}", e))?; + + let mut tracks = Vec::new(); + for row in rows { + tracks.push(row.map_err(|e| format!("Failed to read favorite track row: {}", e))?); + } + Ok(tracks) + } + + pub fn replace_scores( + &mut self, + score_type: &str, + item_type: &str, + entries: &[RecoScoreEntry], + ) -> Result<(), String> { + let updated_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + let tx = self + .conn + .transaction() + .map_err(|e| format!("Failed to start reco scores transaction: {}", e))?; + + tx.execute( + "DELETE FROM reco_scores WHERE score_type = ? AND item_type = ?", + params![score_type, item_type], + ) + .map_err(|e| format!("Failed to clear reco scores: {}", e))?; + + if !entries.is_empty() { + let mut stmt = tx + .prepare( + r#" + INSERT INTO reco_scores ( + score_type, + item_type, + track_id, + album_id, + artist_id, + score, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + "#, + ) + .map_err(|e| format!("Failed to prepare reco scores insert: {}", e))?; + + for entry in entries { + stmt.execute(params![ + score_type, + item_type, + entry.track_id, + entry.album_id.as_deref(), + entry.artist_id, + entry.score, + updated_at + ]) + .map_err(|e| format!("Failed to insert reco score: {}", e))?; + } + } + + tx.commit() + .map_err(|e| format!("Failed to commit reco scores: {}", e))?; + + Ok(()) + } + + pub fn has_scores(&self, score_type: &str) -> Result { + let mut stmt = self + .conn + .prepare( + r#" + SELECT COUNT(*) FROM reco_scores WHERE score_type = ? + "#, + ) + .map_err(|e| format!("Failed to prepare reco scores count: {}", e))?; + + let count: i64 = stmt + .query_row(params![score_type], |row| row.get(0)) + .map_err(|e| format!("Failed to query reco scores count: {}", e))?; + + Ok(count > 0) + } + + pub fn get_scored_album_ids(&self, score_type: &str, limit: u32) -> Result, String> { + let mut stmt = self.conn + .prepare( + r#" + SELECT album_id + FROM reco_scores + WHERE score_type = ? AND item_type = 'album' AND album_id IS NOT NULL + ORDER BY score DESC + LIMIT ? + "#, + ) + .map_err(|e| format!("Failed to prepare scored albums query: {}", e))?; + + let rows = stmt + .query_map(params![score_type, limit], |row| row.get::<_, String>(0)) + .map_err(|e| format!("Failed to query scored albums: {}", e))?; + + let mut albums = Vec::new(); + for row in rows { + albums.push(row.map_err(|e| format!("Failed to read scored album row: {}", e))?); + } + Ok(albums) + } + + pub fn get_scored_track_ids(&self, score_type: &str, limit: u32) -> Result, String> { + let mut stmt = self.conn + .prepare( + r#" + SELECT track_id + FROM reco_scores + WHERE score_type = ? AND item_type = 'track' AND track_id IS NOT NULL + ORDER BY score DESC + LIMIT ? + "#, + ) + .map_err(|e| format!("Failed to prepare scored tracks query: {}", e))?; + + let rows = stmt + .query_map(params![score_type, limit], |row| row.get::<_, u64>(0)) + .map_err(|e| format!("Failed to query scored tracks: {}", e))?; + + let mut tracks = Vec::new(); + for row in rows { + tracks.push(row.map_err(|e| format!("Failed to read scored track row: {}", e))?); + } + Ok(tracks) + } + + pub fn get_scored_artist_scores( + &self, + score_type: &str, + limit: u32, + ) -> Result, String> { + let mut stmt = self.conn + .prepare( + r#" + SELECT artist_id, score + FROM reco_scores + WHERE score_type = ? AND item_type = 'artist' AND artist_id IS NOT NULL + ORDER BY score DESC + LIMIT ? + "#, + ) + .map_err(|e| format!("Failed to prepare scored artists query: {}", e))?; + + let rows = stmt + .query_map(params![score_type, limit], |row| { + Ok((row.get::<_, u64>(0)?, row.get::<_, f64>(1)?)) + }) + .map_err(|e| format!("Failed to query scored artists: {}", e))?; + + let mut artists = Vec::new(); + for row in rows { + artists.push(row.map_err(|e| format!("Failed to read scored artist row: {}", e))?); + } + Ok(artists) + } +} diff --git a/src-tauri/src/reco_store/mod.rs b/src-tauri/src/reco_store/mod.rs new file mode 100644 index 000000000..862dc0315 --- /dev/null +++ b/src-tauri/src/reco_store/mod.rs @@ -0,0 +1,100 @@ +//! Recommendation store module +//! +//! Persists lightweight usage events for home recommendations. + +pub mod commands; +pub mod db; + +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::Mutex; + +use db::RecoStoreDb; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RecoEventType { + Play, + Favorite, + PlaylistAdd, +} + +impl RecoEventType { + pub fn as_str(&self) -> &'static str { + match self { + Self::Play => "play", + Self::Favorite => "favorite", + Self::PlaylistAdd => "playlist_add", + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RecoItemType { + Track, + Album, + Artist, +} + +impl RecoItemType { + pub fn as_str(&self) -> &'static str { + match self { + Self::Track => "track", + Self::Album => "album", + Self::Artist => "artist", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecoEventInput { + pub event_type: RecoEventType, + pub item_type: RecoItemType, + pub track_id: Option, + pub album_id: Option, + pub artist_id: Option, + pub playlist_id: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TopArtistSeed { + pub artist_id: u64, + pub play_count: u32, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HomeSeeds { + pub recently_played_album_ids: Vec, + pub continue_listening_track_ids: Vec, + pub top_artist_ids: Vec, + pub favorite_album_ids: Vec, + pub favorite_track_ids: Vec, +} + +/// Recommendation store state shared across commands +pub struct RecoState { + pub db: Arc>, +} + +impl RecoState { + pub fn new() -> Result { + let data_dir = dirs::data_dir() + .ok_or("Could not determine data directory")? + .join("qbz-nix") + .join("reco"); + + std::fs::create_dir_all(&data_dir) + .map_err(|e| format!("Failed to create reco directory: {}", e))?; + + let db_path = data_dir.join("events.db"); + let db = RecoStoreDb::new(&db_path)?; + + Ok(Self { + db: Arc::new(Mutex::new(db)), + }) + } +} diff --git a/src/lib/components/PlaylistModal.svelte b/src/lib/components/PlaylistModal.svelte index 13cf33137..f75caf754 100644 --- a/src/lib/components/PlaylistModal.svelte +++ b/src/lib/components/PlaylistModal.svelte @@ -1,6 +1,7 @@
- {#if hasContent} - - {#if featured} - - {/if} - - + {#if isLoading} +
+
+ +
+

Loading your home

+

Building recommendations from your listening history.

+
+ {:else if error} +
+
+ +
+

Could not load Home

+

{error}

+
+ {:else if hasContent} {#if recentAlbums.length > 0} {#snippet children()} @@ -61,7 +252,7 @@ title={album.title} artist={album.artist} quality={album.quality} - onclick={() => onAlbumClick?.(album.id ?? '')} + onclick={() => onAlbumClick?.(album.id)} /> {/each}
@@ -69,36 +260,65 @@
{/if} - - {#if recommendedAlbums.length > 0} - - {#snippet children()} - {#each recommendedAlbums as album} - onAlbumClick?.(album.id ?? '')} + {#if continueTracks.length > 0} +
+
+

Continuar escuchando

+
+
+ {#each continueTracks as track, index} + onTrackPlay?.(track)} /> {/each} +
+
+ {/if} + + {#if topArtists.length > 0} + + {#snippet children()} + {#each topArtists as artist} + + {/each}
{/snippet}
{/if} - - {#if newReleases.length > 0} - + {#if favoriteAlbums.length > 0} + {#snippet children()} - {#each newReleases as album} + {#each favoriteAlbums as album} onAlbumClick?.(album.id ?? '')} + onclick={() => onAlbumClick?.(album.id)} /> {/each}
@@ -106,11 +326,12 @@
{/if} {:else} - -
- -

Welcome to QBZ

-

Use the search to discover music on Qobuz

+
+
+ +
+

Empieza a escuchar

+

Reproduce musica o anade favoritos para activar recomendaciones.

{/if}
@@ -118,7 +339,7 @@ diff --git a/src/lib/services/playbackService.ts b/src/lib/services/playbackService.ts index d32474d74..b2a430b4c 100644 --- a/src/lib/services/playbackService.ts +++ b/src/lib/services/playbackService.ts @@ -17,6 +17,7 @@ import { type PlayingTrack } from '$lib/stores/playerStore'; import { syncQueueState } from '$lib/stores/queueStore'; +import { logRecoEvent } from '$lib/services/recoService'; // ============ Types ============ @@ -77,6 +78,17 @@ export async function playTrack( setIsPlaying(true); showToast(`Playing: ${track.title}`, 'success'); + // Log play event for recommendations (fire-and-forget) + if (!isLocal) { + void logRecoEvent({ + eventType: 'play', + itemType: 'track', + trackId: track.id, + albumId: track.albumId, + artistId: track.artistId + }); + } + // Update MPRIS metadata await updateMediaMetadata({ title: track.title, @@ -246,6 +258,12 @@ export async function toggleTrackFavorite( try { if (newFavoriteState) { await invoke('add_favorite', { favType: 'track', itemId: String(trackId) }); + // Log favorite event for recommendations + void logRecoEvent({ + eventType: 'favorite', + itemType: 'track', + trackId + }); } else { await invoke('remove_favorite', { favType: 'track', itemId: String(trackId) }); } @@ -262,6 +280,12 @@ export async function toggleTrackFavorite( export async function addTrackToFavorites(trackId: number): Promise { try { await invoke('add_favorite', { favType: 'track', itemId: String(trackId) }); + // Log favorite event for recommendations + void logRecoEvent({ + eventType: 'favorite', + itemType: 'track', + trackId + }); return true; } catch (err) { console.error('Failed to add to favorites:', err); diff --git a/src/lib/services/recoService.ts b/src/lib/services/recoService.ts new file mode 100644 index 000000000..13355f09f --- /dev/null +++ b/src/lib/services/recoService.ts @@ -0,0 +1,95 @@ +/** + * Recommendation Event Service + * + * Logs lightweight usage events for home recommendations. + */ + +import { invoke } from '@tauri-apps/api/core'; + +export type RecoEventType = 'play' | 'favorite' | 'playlist_add'; +export type RecoItemType = 'track' | 'album' | 'artist'; + +export interface RecoEventInput { + eventType: RecoEventType; + itemType: RecoItemType; + trackId?: number; + albumId?: string; + artistId?: number; + playlistId?: number; +} + +export interface TopArtistSeed { + artistId: number; + playCount: number; +} + +export interface HomeSeeds { + recentlyPlayedAlbumIds: string[]; + continueListeningTrackIds: number[]; + topArtistIds: TopArtistSeed[]; + favoriteAlbumIds: string[]; + favoriteTrackIds: number[]; +} + +export async function logRecoEvent(event: RecoEventInput): Promise { + try { + await invoke('reco_log_event', { event }); + } catch (err) { + console.debug('Reco event log failed:', err); + } +} + +export async function logPlaylistAdd(trackIds: number[], playlistId: number): Promise { + if (trackIds.length === 0) return; + const tasks = trackIds.map(trackId => + logRecoEvent({ + eventType: 'playlist_add', + itemType: 'track', + trackId, + playlistId + }) + ); + await Promise.allSettled(tasks); +} + +export async function getHomeSeeds(limits?: { + recentAlbums?: number; + continueTracks?: number; + topArtists?: number; + favorites?: number; +}): Promise { + return invoke('reco_get_home', { + limitRecentAlbums: limits?.recentAlbums, + limitContinueTracks: limits?.continueTracks, + limitTopArtists: limits?.topArtists, + limitFavorites: limits?.favorites + }); +} + +export async function trainScores(options?: { + lookbackDays?: number; + halfLifeDays?: number; + maxEvents?: number; + maxPerType?: number; +}): Promise { + await invoke('reco_train_scores', { + lookbackDays: options?.lookbackDays, + halfLifeDays: options?.halfLifeDays, + maxEvents: options?.maxEvents, + maxPerType: options?.maxPerType + }); +} + +export async function getHomeSeedsML(limits?: { + recentAlbums?: number; + continueTracks?: number; + topArtists?: number; + favorites?: number; +}): Promise { + return invoke('reco_get_home_ml', { + limitRecentAlbums: limits?.recentAlbums, + limitContinueTracks: limits?.continueTracks, + limitTopArtists: limits?.topArtists, + limitFavorites: limits?.favorites + }); +} diff --git a/src/lib/stores/playerStore.ts b/src/lib/stores/playerStore.ts index 756662aee..0028175f8 100644 --- a/src/lib/stores/playerStore.ts +++ b/src/lib/stores/playerStore.ts @@ -21,6 +21,9 @@ export interface PlayingTrack { bitDepth?: number; samplingRate?: number; isLocal?: boolean; + // Optional IDs for recommendation tracking + albumId?: string; + artistId?: number; } interface BackendPlaybackState { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 7cd80c865..6f12f294d 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -318,7 +318,9 @@ duration: track.duration, quality, bitDepth: track.maximum_bit_depth, - samplingRate: track.maximum_sampling_rate + samplingRate: track.maximum_sampling_rate, + albumId: track.album?.id, + artistId: track.performer?.id }); } @@ -355,7 +357,9 @@ album: selectedAlbum?.title || '', artwork, duration: track.durationSeconds, - quality + quality, + albumId: selectedAlbum?.id, + artistId: track.artistId }); } @@ -678,7 +682,9 @@ album: track.album || 'Playlist', artwork: track.albumArt || '', duration: track.durationSeconds, - quality + quality, + albumId: track.albumId, + artistId: track.artistId }); }