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
This commit is contained in:
vicrodh committed 2026-01-10 08:10:34 -06:00
1 parent 340fb2a72a
commit f4e8d68538
10 files changed
+1429 -72

No files matched your search

+10
View File
@@ -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");
+342
View File
@@ -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<u32>,
limit_continue_tracks: Option<u32>,
limit_top_artists: Option<u32>,
limit_favorites: Option<u32>,
state: State<'_, RecoState>,
) -> Result<HomeSeeds, String> {
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<i64>,
half_life_days: Option<f64>,
max_events: Option<u32>,
max_per_type: Option<u32>,
state: State<'_, RecoState>,
) -> Result<RecoTrainStats, String> {
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<u32>,
limit_continue_tracks: Option<u32>,
limit_top_artists: Option<u32>,
limit_favorites: Option<u32>,
state: State<'_, RecoState>,
) -> Result<HomeSeeds, String> {
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<u64, f64>,
albums: HashMap<String, f64>,
artists: HashMap<u64, f64>,
}
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<u64, f64>, limit: u32) -> Vec<RecoScoreEntry> {
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<String, f64>, limit: u32) -> Vec<RecoScoreEntry> {
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<u64, f64>, limit: u32) -> Vec<RecoScoreEntry> {
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<K: std::cmp::Eq + std::hash::Hash>(map: &mut HashMap<K, f64>, 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)
}
+467
View File
@@ -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<u64>,
pub album_id: Option<String>,
pub artist_id: Option<u64>,
pub created_at: i64,
}
#[derive(Debug, Clone)]
pub struct RecoScoreEntry {
pub track_id: Option<u64>,
pub album_id: Option<String>,
pub artist_id: Option<u64>,
pub score: f64,
}
pub struct RecoStoreDb {
conn: Connection,
}
impl RecoStoreDb {
pub fn new(path: &Path) -> Result<Self, String> {
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<Vec<String>, 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<Vec<u64>, 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<u32>,
) -> Result<Vec<RecoEventRecord>, 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<Vec<TopArtistSeed>, 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<Vec<String>, 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<Vec<u64>, 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<bool, String> {
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<Vec<String>, 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<Vec<u64>, 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<Vec<(u64, f64)>, 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)
}
}
+100
View File
@@ -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<u64>,
pub album_id: Option<String>,
pub artist_id: Option<u64>,
pub playlist_id: Option<u64>,
}
#[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<String>,
pub continue_listening_track_ids: Vec<u64>,
pub top_artist_ids: Vec<TopArtistSeed>,
pub favorite_album_ids: Vec<String>,
pub favorite_track_ids: Vec<u64>,
}
/// Recommendation store state shared across commands
pub struct RecoState {
pub db: Arc<Mutex<RecoStoreDb>>,
}
impl RecoState {
pub fn new() -> Result<Self, String> {
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)),
})
}
}