diff --git a/src-tauri/src/reco_store/commands.rs b/src-tauri/src/reco_store/commands.rs index 93f222df3..182dc1b63 100644 --- a/src-tauri/src/reco_store/commands.rs +++ b/src-tauri/src/reco_store/commands.rs @@ -671,64 +671,74 @@ fn get_home_seeds_internal( ) -> Result { let has_scores = db.has_scores("all")?; - let recent_fresh_albums = db.get_recent_album_ids(4)?; - let recent_fresh_tracks = db.get_recent_track_ids(4)?; - - let mut recently_played_album_ids = if has_scores { + // When scores exist, merge scored results with recent items. + // When no scores (or scored results empty), fall back to event-based queries. + // Avoid duplicate queries: only call fallback if scored path was taken but empty. + let recently_played_album_ids = if has_scores { + let recent_fresh = db.get_recent_album_ids(4)?; let scored = db.get_scored_album_ids("all", limit_recent_albums + 4)?; - merge_unique_preserve_order(recent_fresh_albums, scored, limit_recent_albums as usize) + let merged = merge_unique_preserve_order(recent_fresh, scored, limit_recent_albums as usize); + if merged.is_empty() { + db.get_recent_album_ids(limit_recent_albums)? + } else { + merged + } } else { db.get_recent_album_ids(limit_recent_albums)? }; - let mut continue_listening_track_ids = if has_scores { + let continue_listening_track_ids = if has_scores { + let recent_fresh = db.get_recent_track_ids(4)?; let scored = db.get_scored_track_ids("all", limit_continue_tracks + 4)?; - merge_unique_preserve_order(recent_fresh_tracks, scored, limit_continue_tracks as usize) + let merged = merge_unique_preserve_order(recent_fresh, scored, limit_continue_tracks as usize); + if merged.is_empty() { + db.get_recent_track_ids(limit_continue_tracks)? + } else { + merged + } } else { db.get_recent_track_ids(limit_continue_tracks)? }; - let mut top_artist_ids = if has_scores { - db.get_scored_artist_scores("all", limit_top_artists)? + let top_artist_ids = if has_scores { + let scored: Vec = 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() + .collect(); + if scored.is_empty() { + db.get_top_artist_ids(limit_top_artists)? + } else { + scored + } } else { - Vec::new() + db.get_top_artist_ids(limit_top_artists)? }; - let mut favorite_album_ids = if has_scores { - db.get_scored_album_ids("favorite", limit_favorites)? + let favorite_album_ids = if has_scores { + let scored = db.get_scored_album_ids("favorite", limit_favorites)?; + if scored.is_empty() { + db.get_favorite_album_ids(limit_favorites)? + } else { + scored + } } else { - Vec::new() + db.get_favorite_album_ids(limit_favorites)? }; - let mut favorite_track_ids = if has_scores { - db.get_scored_track_ids("favorite", limit_favorites)? + let favorite_track_ids = if has_scores { + let scored = db.get_scored_track_ids("favorite", limit_favorites)?; + if scored.is_empty() { + db.get_favorite_track_ids(limit_favorites)? + } else { + scored + } } else { - Vec::new() + db.get_favorite_track_ids(limit_favorites)? }; - // Fallbacks for empty results - 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, @@ -794,14 +804,20 @@ pub async fn reco_get_home_resolved( .map(|s| (s.artist_id, s.play_count)) .collect(); - // Step 3: Resolve albums, tracks, AND artists in parallel - // Artists are independent of albums/tracks, so start them concurrently. - let albums_fut = resolve_albums( + // Step 3: Resolve all entity types in parallel. + // Recent albums, favorite albums, tracks, and artists are all independent. + let recent_albums_fut = resolve_albums( &seeds.recently_played_album_ids, &reco_state, &app_state, &cache_state, ); + let favorite_albums_fut = resolve_albums( + &seeds.favorite_album_ids, + &reco_state, + &app_state, + &cache_state, + ); let tracks_fut = resolve_tracks(&all_track_ids, &reco_state, &app_state, &cache_state); let artists_fut = resolve_artists( &all_artist_ids, @@ -811,8 +827,8 @@ pub async fn reco_get_home_resolved( &cache_state, ); - let (recently_played_albums, all_tracks, top_artists) = - tokio::try_join!(albums_fut, tracks_fut, artists_fut)?; + let (recently_played_albums, resolved_favorites, all_tracks, top_artists) = + tokio::try_join!(recent_albums_fut, favorite_albums_fut, tracks_fut, artists_fut)?; // Build track lookup let track_map: HashMap = @@ -824,18 +840,9 @@ pub async fn reco_get_home_resolved( .filter_map(|id| track_map.get(id).map(|tr| (*tr).clone())) .collect(); - // Step 5: "More From Favorites" = discover albums by favorite artists, + // Step 4: "More From Favorites" = discover albums by favorite artists, // excluding albums the user already has in favorites / recently played. - // 5a: Resolve favorite albums to get their artist IDs - let resolved_favorites = resolve_albums( - &seeds.favorite_album_ids, - &reco_state, - &app_state, - &cache_state, - ) - .await?; - // 5b: Collect unique artist IDs from favorite albums + favorite tracks let favorite_artist_ids: Vec = { let mut ids = Vec::new(); @@ -989,6 +996,7 @@ async fn resolve_albums( // Tier 2: API cache (24h TTL) let mut api_cache_resolved: HashMap = HashMap::new(); + let mut tier2_album_metas: Vec = Vec::new(); if !missing_ids.is_empty() { let cached = { let guard__ = cache_state.cache.lock().await; @@ -1000,16 +1008,19 @@ async fn resolve_albums( for (album_id, json_str) in cached { if let Ok(album) = serde_json::from_str::(&json_str) { let meta = album_to_card_meta(&album); - // Write through to reco meta for future instant hits - { - let guard__ = reco_state.db.lock().await; - if let Some(db) = guard__.as_ref() { - let _ = db.set_album_meta(&meta); - } - } + tier2_album_metas.push(meta.clone()); api_cache_resolved.insert(album_id, meta); } } + // Batch write tier-2 hits to reco meta (single lock) + if !tier2_album_metas.is_empty() { + let guard__ = reco_state.db.lock().await; + if let Some(db) = guard__.as_ref() { + for meta in &tier2_album_metas { + let _ = db.set_album_meta(meta); + } + } + } } let still_missing: Vec = missing_ids @@ -1018,7 +1029,7 @@ async fn resolve_albums( .cloned() .collect(); - // Tier 3: Qobuz API (parallel with semaphore) + // Tier 3: Qobuz API — fetch in parallel, defer cache writes to after all complete let mut api_resolved: HashMap = HashMap::new(); if !still_missing.is_empty() { log::info!( @@ -1027,15 +1038,11 @@ async fn resolve_albums( ); let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(10)); let client = app_state.client.clone(); - let cache_arc = cache_state.cache.clone(); - let reco_arc = reco_state.db.clone(); let mut handles = Vec::new(); for album_id in &still_missing { let sem = sem.clone(); let client = client.clone(); - let cache_arc = cache_arc.clone(); - let reco_arc = reco_arc.clone(); let album_id = album_id.clone(); handles.push(tokio::spawn(async move { @@ -1045,31 +1052,42 @@ async fn resolve_albums( c.get_album(&album_id).await.ok()? }; let meta = album_to_card_meta(&album); - - // Cache the full API response - if let Ok(json) = serde_json::to_string(&album) { - let guard__ = cache_arc.lock().await; - if let Some(cache) = guard__.as_ref() { - let _ = cache.set_album(&album_id, &json); - } - } - // Write to reco meta - { - let guard__ = reco_arc.lock().await; - if let Some(db) = guard__.as_ref() { - let _ = db.set_album_meta(&meta); - } - } - - Some((album_id, meta)) + let json = serde_json::to_string(&album).ok(); + Some((album_id, meta, json)) })); } + // Collect results, then batch-write caches + let mut api_albums_to_cache: Vec<(String, String)> = Vec::new(); + let mut api_album_metas: Vec = Vec::new(); for handle in handles { - if let Ok(Some((id, meta))) = handle.await { + if let Ok(Some((id, meta, json))) = handle.await { + if let Some(j) = json { + api_albums_to_cache.push((id.clone(), j)); + } + api_album_metas.push(meta.clone()); api_resolved.insert(id, meta); } } + + // Batch write to API cache (single lock) + if !api_albums_to_cache.is_empty() { + let guard__ = cache_state.cache.lock().await; + if let Some(cache) = guard__.as_ref() { + for (album_id, json) in &api_albums_to_cache { + let _ = cache.set_album(album_id, json); + } + } + } + // Batch write to reco meta (single lock) + if !api_album_metas.is_empty() { + let guard__ = reco_state.db.lock().await; + if let Some(db) = guard__.as_ref() { + for meta in &api_album_metas { + let _ = db.set_album_meta(meta); + } + } + } } // Assemble in seed order @@ -1117,6 +1135,7 @@ async fn resolve_tracks( // Tier 2: API cache let mut api_cache_resolved: HashMap = HashMap::new(); + let mut tier2_track_metas: Vec = Vec::new(); if !missing_ids.is_empty() { let cached = { let guard__ = cache_state.cache.lock().await; @@ -1128,15 +1147,19 @@ async fn resolve_tracks( for (track_id, json_str) in cached { if let Ok(track) = serde_json::from_str::(&json_str) { let meta = track_to_display_meta(&track); - { - let guard__ = reco_state.db.lock().await; - if let Some(db) = guard__.as_ref() { - let _ = db.set_track_meta(&meta); - } - } + tier2_track_metas.push(meta.clone()); api_cache_resolved.insert(track_id, meta); } } + // Batch write tier-2 hits to reco meta (single lock) + if !tier2_track_metas.is_empty() { + let guard__ = reco_state.db.lock().await; + if let Some(db) = guard__.as_ref() { + for meta in &tier2_track_metas { + let _ = db.set_track_meta(meta); + } + } + } } let still_missing: Vec = missing_ids @@ -1145,7 +1168,7 @@ async fn resolve_tracks( .copied() .collect(); - // Tier 3: Qobuz API + // Tier 3: Qobuz API — fetch in parallel, defer cache writes let mut api_resolved: HashMap = HashMap::new(); if !still_missing.is_empty() { log::info!( @@ -1154,15 +1177,11 @@ async fn resolve_tracks( ); let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(10)); let client = app_state.client.clone(); - let cache_arc = cache_state.cache.clone(); - let reco_arc = reco_state.db.clone(); let mut handles = Vec::new(); for track_id in &still_missing { let sem = sem.clone(); let client = client.clone(); - let cache_arc = cache_arc.clone(); - let reco_arc = reco_arc.clone(); let track_id = *track_id; handles.push(tokio::spawn(async move { @@ -1172,29 +1191,42 @@ async fn resolve_tracks( c.get_track(track_id).await.ok()? }; let meta = track_to_display_meta(&track); - - if let Ok(json) = serde_json::to_string(&track) { - let guard__ = cache_arc.lock().await; - if let Some(cache) = guard__.as_ref() { - let _ = cache.set_track(track_id, &json); - } - } - { - let guard__ = reco_arc.lock().await; - if let Some(db) = guard__.as_ref() { - let _ = db.set_track_meta(&meta); - } - } - - Some((track_id, meta)) + let json = serde_json::to_string(&track).ok(); + Some((track_id, meta, json)) })); } + // Collect results, then batch-write caches + let mut api_tracks_to_cache: Vec<(u64, String)> = Vec::new(); + let mut api_track_metas: Vec = Vec::new(); for handle in handles { - if let Ok(Some((id, meta))) = handle.await { + if let Ok(Some((id, meta, json))) = handle.await { + if let Some(j) = json { + api_tracks_to_cache.push((id, j)); + } + api_track_metas.push(meta.clone()); api_resolved.insert(id, meta); } } + + // Batch write to API cache (single lock) + if !api_tracks_to_cache.is_empty() { + let guard__ = cache_state.cache.lock().await; + if let Some(cache) = guard__.as_ref() { + for (track_id, json) in &api_tracks_to_cache { + let _ = cache.set_track(*track_id, json); + } + } + } + // Batch write to reco meta (single lock) + if !api_track_metas.is_empty() { + let guard__ = reco_state.db.lock().await; + if let Some(db) = guard__.as_ref() { + for meta in &api_track_metas { + let _ = db.set_track_meta(meta); + } + } + } } let result: Vec = ids @@ -1246,6 +1278,7 @@ async fn resolve_artists( }; let mut api_cache_resolved: HashMap = HashMap::new(); + let mut tier2_metas_to_write: Vec = Vec::new(); if !missing_ids.is_empty() { let cached = { let guard__ = cache_state.cache.lock().await; @@ -1257,15 +1290,19 @@ async fn resolve_artists( for (artist_id, json_str) in cached { if let Ok(artist) = serde_json::from_str::(&json_str) { let meta = artist_to_card_meta(&artist, None); - { - let guard__ = reco_state.db.lock().await; - if let Some(db) = guard__.as_ref() { - let _ = db.set_artist_meta(&meta); - } - } + tier2_metas_to_write.push(meta.clone()); api_cache_resolved.insert(artist_id, meta); } } + // Batch write tier-2 hits to reco meta (single lock acquisition) + if !tier2_metas_to_write.is_empty() { + let guard__ = reco_state.db.lock().await; + if let Some(db) = guard__.as_ref() { + for meta in &tier2_metas_to_write { + let _ = db.set_artist_meta(meta); + } + } + } } let still_missing: Vec = missing_ids @@ -1274,7 +1311,7 @@ async fn resolve_artists( .copied() .collect(); - // Tier 3: Qobuz API + // Tier 3: Qobuz API — fetch in parallel, defer cache writes to after all complete let mut api_resolved: HashMap = HashMap::new(); if !still_missing.is_empty() { log::info!( @@ -1283,17 +1320,11 @@ async fn resolve_artists( ); let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(10)); let client = app_state.client.clone(); - let cache_arc = cache_state.cache.clone(); - let reco_arc = reco_state.db.clone(); - let locale_clone = locale.clone(); let mut handles = Vec::new(); for artist_id in &still_missing { let sem = sem.clone(); let client = client.clone(); - let cache_arc = cache_arc.clone(); - let reco_arc = reco_arc.clone(); - let locale = locale_clone.clone(); let artist_id = *artist_id; handles.push(tokio::spawn(async move { @@ -1303,29 +1334,42 @@ async fn resolve_artists( c.get_artist_basic(artist_id).await.ok()? }; let meta = artist_to_card_meta(&artist, None); - - if let Ok(json) = serde_json::to_string(&artist) { - let guard__ = cache_arc.lock().await; - if let Some(cache) = guard__.as_ref() { - let _ = cache.set_artist(artist_id, &locale, &json); - } - } - { - let guard__ = reco_arc.lock().await; - if let Some(db) = guard__.as_ref() { - let _ = db.set_artist_meta(&meta); - } - } - - Some((artist_id, meta)) + let json = serde_json::to_string(&artist).ok(); + Some((artist_id, meta, json)) })); } + // Collect results without holding any locks + let mut api_artists_to_cache: Vec<(u64, String)> = Vec::new(); + let mut api_metas_to_write: Vec = Vec::new(); for handle in handles { - if let Ok(Some((id, meta))) = handle.await { + if let Ok(Some((id, meta, json))) = handle.await { + if let Some(j) = json { + api_artists_to_cache.push((id, j)); + } + api_metas_to_write.push(meta.clone()); api_resolved.insert(id, meta); } } + + // Batch write to API cache (single lock) + if !api_artists_to_cache.is_empty() { + let guard__ = cache_state.cache.lock().await; + if let Some(cache) = guard__.as_ref() { + for (artist_id, json) in &api_artists_to_cache { + let _ = cache.set_artist(*artist_id, &locale, json); + } + } + } + // Batch write to reco meta (single lock) + if !api_metas_to_write.is_empty() { + let guard__ = reco_state.db.lock().await; + if let Some(db) = guard__.as_ref() { + for meta in &api_metas_to_write { + let _ = db.set_artist_meta(meta); + } + } + } } // Assemble in seed order, attaching play_counts diff --git a/src-tauri/src/reco_store/db.rs b/src-tauri/src/reco_store/db.rs index 19c01bfaf..b16c1c878 100644 --- a/src-tauri/src/reco_store/db.rs +++ b/src-tauri/src/reco_store/db.rs @@ -64,6 +64,17 @@ impl RecoStoreDb { 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); + -- Composite indexes for GROUP BY + ORDER BY queries in get_home_seeds + CREATE INDEX IF NOT EXISTS idx_reco_events_play_albums + ON reco_events(event_type, album_id, created_at DESC) + WHERE album_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_reco_events_play_tracks + ON reco_events(event_type, track_id, created_at DESC) + WHERE track_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_reco_events_play_artists + ON reco_events(event_type, artist_id, created_at DESC) + WHERE artist_id IS NOT NULL; + CREATE TABLE IF NOT EXISTS reco_scores ( id INTEGER PRIMARY KEY AUTOINCREMENT, score_type TEXT NOT NULL, @@ -79,6 +90,10 @@ impl RecoStoreDb { 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); + + -- Composite index for scored lookups (score_type + item_type + ORDER BY score) + CREATE INDEX IF NOT EXISTS idx_reco_scores_lookup + ON reco_scores(score_type, item_type, score DESC); "#, ) .map_err(|e| format!("Failed to initialize reco database: {}", e))?;