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:
1 parent
340fb2a72a
commit
f4e8d68538
10 files changed
+1429
-72
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { X } from 'lucide-svelte';
|
||||
import { logPlaylistAdd } from '$lib/services/recoService';
|
||||
|
||||
interface Playlist {
|
||||
id: number;
|
||||
@@ -121,6 +122,7 @@
|
||||
playlistId: selectedPlaylistId,
|
||||
trackIds
|
||||
});
|
||||
void logPlaylistAdd(trackIds, selectedPlaylistId);
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -154,6 +156,7 @@
|
||||
playlistId: newPlaylist.id,
|
||||
trackIds
|
||||
});
|
||||
void logPlaylistAdd(trackIds, newPlaylist.id);
|
||||
}
|
||||
|
||||
onSuccess?.(newPlaylist);
|
||||
|
||||
@@ -1,57 +1,248 @@
|
||||
<script lang="ts">
|
||||
import { Music } from 'lucide-svelte';
|
||||
import HeroSection from '../HeroSection.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Music, User } from 'lucide-svelte';
|
||||
import HorizontalScrollRow from '../HorizontalScrollRow.svelte';
|
||||
import AlbumCard from '../AlbumCard.svelte';
|
||||
import TrackRow from '../TrackRow.svelte';
|
||||
import { formatDuration, formatQuality, getQobuzImage } from '$lib/adapters/qobuzAdapters';
|
||||
import type { QobuzAlbum, QobuzArtist, QobuzTrack, DisplayTrack } from '$lib/types';
|
||||
|
||||
interface Album {
|
||||
id?: string;
|
||||
interface TopArtistSeed {
|
||||
artistId: number;
|
||||
playCount: number;
|
||||
}
|
||||
|
||||
interface HomeSeeds {
|
||||
recentlyPlayedAlbumIds: string[];
|
||||
continueListeningTrackIds: number[];
|
||||
topArtistIds: TopArtistSeed[];
|
||||
favoriteAlbumIds: string[];
|
||||
favoriteTrackIds: number[];
|
||||
}
|
||||
|
||||
interface AlbumCardData {
|
||||
id: string;
|
||||
artwork: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
quality?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
featuredAlbum?: {
|
||||
artwork: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
year: string;
|
||||
};
|
||||
recentAlbums?: Album[];
|
||||
recommendedAlbums?: Album[];
|
||||
newReleases?: Album[];
|
||||
onAlbumClick?: (albumId: string) => void;
|
||||
interface ArtistCardData {
|
||||
id: number;
|
||||
name: string;
|
||||
image?: string;
|
||||
playCount?: number;
|
||||
}
|
||||
|
||||
let { featuredAlbum, recentAlbums = [], recommendedAlbums = [], newReleases = [], onAlbumClick }: Props = $props();
|
||||
interface Props {
|
||||
onAlbumClick?: (albumId: string) => void;
|
||||
onArtistClick?: (artistId: number) => void;
|
||||
onTrackPlay?: (track: DisplayTrack) => void;
|
||||
}
|
||||
|
||||
// Only show featured if we have actual data
|
||||
const hasFeatured = $derived(featuredAlbum || recentAlbums.length > 0);
|
||||
const featured = $derived(featuredAlbum ?? (recentAlbums[0] ? {
|
||||
artwork: recentAlbums[0].artwork,
|
||||
title: recentAlbums[0].title,
|
||||
artist: recentAlbums[0].artist,
|
||||
year: '2024'
|
||||
} : null));
|
||||
let { onAlbumClick, onArtistClick, onTrackPlay }: Props = $props();
|
||||
|
||||
const hasContent = $derived(recentAlbums.length > 0 || recommendedAlbums.length > 0 || newReleases.length > 0);
|
||||
const LIMITS = {
|
||||
recentAlbums: 12,
|
||||
continueTracks: 8,
|
||||
topArtists: 10,
|
||||
favoriteAlbums: 12,
|
||||
favoriteTracks: 10
|
||||
};
|
||||
|
||||
let isLoading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let recentAlbums = $state<AlbumCardData[]>([]);
|
||||
let continueTracks = $state<DisplayTrack[]>([]);
|
||||
let topArtists = $state<ArtistCardData[]>([]);
|
||||
let favoriteAlbums = $state<AlbumCardData[]>([]);
|
||||
|
||||
let failedArtistImages = $state<Set<number>>(new Set());
|
||||
|
||||
const hasContent = $derived(
|
||||
recentAlbums.length > 0
|
||||
|| continueTracks.length > 0
|
||||
|| topArtists.length > 0
|
||||
|| favoriteAlbums.length > 0
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
loadHome();
|
||||
});
|
||||
|
||||
function handleArtistImageError(artistId: number) {
|
||||
failedArtistImages = new Set([...failedArtistImages, artistId]);
|
||||
}
|
||||
|
||||
function normalizeAlbumIds(ids: Array<string | undefined | null>): string[] {
|
||||
const filtered = ids.filter((id): id is string => !!id && id.trim().length > 0);
|
||||
return Array.from(new Set(filtered));
|
||||
}
|
||||
|
||||
async function fetchAlbums(ids: string[]): Promise<AlbumCardData[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const results = await Promise.allSettled(
|
||||
ids.map(albumId => invoke<QobuzAlbum>('get_album', { albumId }))
|
||||
);
|
||||
|
||||
return results.flatMap(result => {
|
||||
if (result.status !== 'fulfilled') return [];
|
||||
return [toAlbumCard(result.value)];
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchTracks(ids: number[]): Promise<DisplayTrack[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const results = await Promise.allSettled(
|
||||
ids.map(trackId => invoke<QobuzTrack>('get_track', { trackId }))
|
||||
);
|
||||
|
||||
return results.flatMap(result => {
|
||||
if (result.status !== 'fulfilled') return [];
|
||||
return [toDisplayTrack(result.value)];
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchArtists(seeds: TopArtistSeed[]): Promise<ArtistCardData[]> {
|
||||
if (seeds.length === 0) return [];
|
||||
const results = await Promise.allSettled(
|
||||
seeds.map(seed => invoke<QobuzArtist>('get_artist', { artistId: seed.artistId }))
|
||||
);
|
||||
|
||||
const artists: ArtistCardData[] = [];
|
||||
results.forEach((result, index) => {
|
||||
if (result.status !== 'fulfilled') return;
|
||||
const seed = seeds[index];
|
||||
artists.push(toArtistCard(result.value, seed.playCount));
|
||||
});
|
||||
|
||||
return artists;
|
||||
}
|
||||
|
||||
function toAlbumCard(album: QobuzAlbum): AlbumCardData {
|
||||
return {
|
||||
id: album.id,
|
||||
artwork: getQobuzImage(album.image),
|
||||
title: album.title,
|
||||
artist: album.artist?.name || 'Unknown Artist',
|
||||
quality: formatQuality(album.hires_streamable, album.maximum_bit_depth, album.maximum_sampling_rate)
|
||||
};
|
||||
}
|
||||
|
||||
function toDisplayTrack(track: QobuzTrack): DisplayTrack {
|
||||
return {
|
||||
id: track.id,
|
||||
title: track.title,
|
||||
artist: track.performer?.name || 'Unknown Artist',
|
||||
album: track.album?.title,
|
||||
albumArt: getQobuzImage(track.album?.image),
|
||||
albumId: track.album?.id,
|
||||
artistId: track.performer?.id,
|
||||
duration: formatDuration(track.duration),
|
||||
durationSeconds: track.duration,
|
||||
hires: track.hires_streamable,
|
||||
bitDepth: track.maximum_bit_depth,
|
||||
samplingRate: track.maximum_sampling_rate,
|
||||
isrc: track.isrc
|
||||
};
|
||||
}
|
||||
|
||||
function toArtistCard(artist: QobuzArtist, playCount?: number): ArtistCardData {
|
||||
return {
|
||||
id: artist.id,
|
||||
name: artist.name,
|
||||
image: getQobuzImage(artist.image),
|
||||
playCount
|
||||
};
|
||||
}
|
||||
|
||||
function getTrackQuality(track: DisplayTrack): string {
|
||||
return formatQuality(track.hires, track.bitDepth, track.samplingRate);
|
||||
}
|
||||
|
||||
function buildTopArtistSeedsFromTracks(tracks: DisplayTrack[]): TopArtistSeed[] {
|
||||
const counts = new Map<number, number>();
|
||||
for (const track of tracks) {
|
||||
if (!track.artistId) continue;
|
||||
counts.set(track.artistId, (counts.get(track.artistId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return Array.from(counts.entries())
|
||||
.map(([artistId, playCount]) => ({ artistId, playCount }))
|
||||
.sort((a, b) => b.playCount - a.playCount)
|
||||
.slice(0, LIMITS.topArtists);
|
||||
}
|
||||
|
||||
async function loadHome() {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const seeds = await invoke<HomeSeeds>('reco_get_home', {
|
||||
limitRecentAlbums: LIMITS.recentAlbums,
|
||||
limitContinueTracks: LIMITS.continueTracks,
|
||||
limitTopArtists: LIMITS.topArtists,
|
||||
limitFavorites: Math.max(LIMITS.favoriteAlbums, LIMITS.favoriteTracks)
|
||||
});
|
||||
|
||||
const fetchedContinueTracks = await fetchTracks(seeds.continueListeningTrackIds);
|
||||
|
||||
let recentAlbumIds = normalizeAlbumIds(seeds.recentlyPlayedAlbumIds);
|
||||
if (recentAlbumIds.length === 0) {
|
||||
recentAlbumIds = normalizeAlbumIds(fetchedContinueTracks.map(track => track.albumId));
|
||||
}
|
||||
|
||||
const favoriteTrackDetails = await fetchTracks(seeds.favoriteTrackIds.slice(0, LIMITS.favoriteTracks));
|
||||
const favoriteAlbumIds = normalizeAlbumIds([
|
||||
...seeds.favoriteAlbumIds,
|
||||
...favoriteTrackDetails.map(track => track.albumId)
|
||||
]);
|
||||
|
||||
let artistSeeds = seeds.topArtistIds;
|
||||
if (artistSeeds.length === 0) {
|
||||
artistSeeds = buildTopArtistSeedsFromTracks(fetchedContinueTracks);
|
||||
}
|
||||
|
||||
const [recentAlbumCards, favoriteAlbumCards, artistCards] = await Promise.all([
|
||||
fetchAlbums(recentAlbumIds.slice(0, LIMITS.recentAlbums)),
|
||||
fetchAlbums(favoriteAlbumIds.slice(0, LIMITS.favoriteAlbums)),
|
||||
fetchArtists(artistSeeds.slice(0, LIMITS.topArtists))
|
||||
]);
|
||||
|
||||
recentAlbums = recentAlbumCards;
|
||||
continueTracks = fetchedContinueTracks;
|
||||
favoriteAlbums = favoriteAlbumCards;
|
||||
topArtists = artistCards;
|
||||
} catch (err) {
|
||||
console.error('Failed to load home data:', err);
|
||||
error = String(err);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="home-view">
|
||||
{#if hasContent}
|
||||
<!-- Hero/Featured Section -->
|
||||
{#if featured}
|
||||
<HeroSection
|
||||
artwork={featured.artwork}
|
||||
title={featured.title}
|
||||
artist={featured.artist}
|
||||
year={featured.year}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Recently Played -->
|
||||
{#if isLoading}
|
||||
<div class="home-state">
|
||||
<div class="state-icon">
|
||||
<Music size={36} />
|
||||
</div>
|
||||
<h1>Loading your home</h1>
|
||||
<p>Building recommendations from your listening history.</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="home-state">
|
||||
<div class="state-icon">
|
||||
<Music size={36} />
|
||||
</div>
|
||||
<h1>Could not load Home</h1>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
{:else if hasContent}
|
||||
{#if recentAlbums.length > 0}
|
||||
<HorizontalScrollRow title="Escuchado recientemente">
|
||||
{#snippet children()}
|
||||
@@ -61,7 +252,7 @@
|
||||
title={album.title}
|
||||
artist={album.artist}
|
||||
quality={album.quality}
|
||||
onclick={() => onAlbumClick?.(album.id ?? '')}
|
||||
onclick={() => onAlbumClick?.(album.id)}
|
||||
/>
|
||||
{/each}
|
||||
<div class="spacer"></div>
|
||||
@@ -69,36 +260,65 @@
|
||||
</HorizontalScrollRow>
|
||||
{/if}
|
||||
|
||||
<!-- Recommended For You -->
|
||||
{#if recommendedAlbums.length > 0}
|
||||
<HorizontalScrollRow title="Recomendado para ti">
|
||||
{#snippet children()}
|
||||
{#each recommendedAlbums as album}
|
||||
<AlbumCard
|
||||
artwork={album.artwork}
|
||||
title={album.title}
|
||||
artist={album.artist}
|
||||
quality={album.quality}
|
||||
size="large"
|
||||
onclick={() => onAlbumClick?.(album.id ?? '')}
|
||||
{#if continueTracks.length > 0}
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h2>Continuar escuchando</h2>
|
||||
</div>
|
||||
<div class="track-list">
|
||||
{#each continueTracks as track, index}
|
||||
<TrackRow
|
||||
number={index + 1}
|
||||
title={track.title}
|
||||
artist={track.artist}
|
||||
duration={track.duration}
|
||||
quality={getTrackQuality(track)}
|
||||
hideDownload={true}
|
||||
onPlay={() => onTrackPlay?.(track)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if topArtists.length > 0}
|
||||
<HorizontalScrollRow title="Tus artistas mas escuchados">
|
||||
{#snippet children()}
|
||||
{#each topArtists as artist}
|
||||
<button class="artist-card" onclick={() => onArtistClick?.(artist.id)}>
|
||||
{#if failedArtistImages.has(artist.id) || !artist.image}
|
||||
<div class="artist-image-placeholder">
|
||||
<User size={32} />
|
||||
</div>
|
||||
{:else}
|
||||
<img
|
||||
src={artist.image}
|
||||
alt={artist.name}
|
||||
class="artist-image"
|
||||
onerror={() => handleArtistImageError(artist.id)}
|
||||
/>
|
||||
{/if}
|
||||
<div class="artist-name">{artist.name}</div>
|
||||
{#if artist.playCount}
|
||||
<div class="artist-meta">{artist.playCount} reproducciones</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="spacer"></div>
|
||||
{/snippet}
|
||||
</HorizontalScrollRow>
|
||||
{/if}
|
||||
|
||||
<!-- New Releases -->
|
||||
{#if newReleases.length > 0}
|
||||
<HorizontalScrollRow title="Nuevos lanzamientos">
|
||||
{#if favoriteAlbums.length > 0}
|
||||
<HorizontalScrollRow title="Mas de tus favoritos">
|
||||
{#snippet children()}
|
||||
{#each newReleases as album}
|
||||
{#each favoriteAlbums as album}
|
||||
<AlbumCard
|
||||
artwork={album.artwork}
|
||||
title={album.title}
|
||||
artist={album.artist}
|
||||
quality={album.quality}
|
||||
onclick={() => onAlbumClick?.(album.id ?? '')}
|
||||
onclick={() => onAlbumClick?.(album.id)}
|
||||
/>
|
||||
{/each}
|
||||
<div class="spacer"></div>
|
||||
@@ -106,11 +326,12 @@
|
||||
</HorizontalScrollRow>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Welcome/Empty State -->
|
||||
<div class="welcome-state">
|
||||
<Music size={64} />
|
||||
<h1>Welcome to QBZ</h1>
|
||||
<p>Use the search to discover music on Qobuz</p>
|
||||
<div class="home-state">
|
||||
<div class="state-icon">
|
||||
<Music size={48} />
|
||||
</div>
|
||||
<h1>Empieza a escuchar</h1>
|
||||
<p>Reproduce musica o anade favoritos para activar recomendaciones.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -118,7 +339,7 @@
|
||||
<style>
|
||||
.home-view {
|
||||
width: 100%;
|
||||
min-height: calc(100vh - 160px);
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
@@ -126,26 +347,112 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.welcome-state {
|
||||
.section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.track-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.artist-card {
|
||||
width: 160px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--bg-tertiary);
|
||||
border-radius: 12px;
|
||||
padding: 16px 12px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease, border-color 150ms ease, background-color 150ms ease;
|
||||
}
|
||||
|
||||
.artist-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--accent-primary);
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.artist-image,
|
||||
.artist-image-placeholder {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.artist-image {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.artist-image-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.artist-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.artist-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.home-state {
|
||||
min-height: calc(100vh - 240px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 200px);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.welcome-state h1 {
|
||||
font-size: 28px;
|
||||
.home-state h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.welcome-state p {
|
||||
font-size: 16px;
|
||||
.home-state p {
|
||||
font-size: 15px;
|
||||
margin: 0;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.state-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 16px;
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -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<boolean> {
|
||||
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);
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<HomeSeeds> {
|
||||
return invoke<HomeSeeds>('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<void> {
|
||||
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<HomeSeeds> {
|
||||
return invoke<HomeSeeds>('reco_get_home_ml', {
|
||||
limitRecentAlbums: limits?.recentAlbums,
|
||||
limitContinueTracks: limits?.continueTracks,
|
||||
limitTopArtists: limits?.topArtists,
|
||||
limitFavorites: limits?.favorites
|
||||
});
|
||||
}
|
||||
@@ -21,6 +21,9 @@ export interface PlayingTrack {
|
||||
bitDepth?: number;
|
||||
samplingRate?: number;
|
||||
isLocal?: boolean;
|
||||
// Optional IDs for recommendation tracking
|
||||
albumId?: string;
|
||||
artistId?: number;
|
||||
}
|
||||
|
||||
interface BackendPlaybackState {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user