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