perf: optimize ML/reco module for home view loading
- Add composite indexes on reco_events for GROUP BY + ORDER BY queries (event_type + album_id/track_id/artist_id + created_at DESC) - Add composite index on reco_scores for scored lookups (score_type + item_type + score DESC) - Eliminate duplicate fallback queries in get_home_seeds_internal by inlining the empty check into the scored branch - Parallelize resolve_favorites with recent albums, tracks, and artists (4-way tokio::try_join instead of 3 + sequential) - Batch cache writes: collect all Tier 3 API results first, then write to API cache and reco meta with single lock acquisitions instead of locking per-item inside spawned tasks
This commit is contained in:
2 files changed
+194
-135
No files matched your search
@@ -671,64 +671,74 @@ fn get_home_seeds_internal(
|
|||||||
) -> Result<HomeSeeds, String> {
|
) -> Result<HomeSeeds, String> {
|
||||||
let has_scores = db.has_scores("all")?;
|
let has_scores = db.has_scores("all")?;
|
||||||
|
|
||||||
let recent_fresh_albums = db.get_recent_album_ids(4)?;
|
// When scores exist, merge scored results with recent items.
|
||||||
let recent_fresh_tracks = db.get_recent_track_ids(4)?;
|
// 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 mut recently_played_album_ids = if has_scores {
|
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)?;
|
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 {
|
} else {
|
||||||
db.get_recent_album_ids(limit_recent_albums)?
|
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)?;
|
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 {
|
} else {
|
||||||
db.get_recent_track_ids(limit_continue_tracks)?
|
db.get_recent_track_ids(limit_continue_tracks)?
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut top_artist_ids = if has_scores {
|
let top_artist_ids = if has_scores {
|
||||||
db.get_scored_artist_scores("all", limit_top_artists)?
|
let scored: Vec<TopArtistSeed> = db.get_scored_artist_scores("all", limit_top_artists)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(artist_id, score)| TopArtistSeed {
|
.map(|(artist_id, score)| TopArtistSeed {
|
||||||
artist_id,
|
artist_id,
|
||||||
play_count: score.round().max(1.0) as u32,
|
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 {
|
} else {
|
||||||
Vec::new()
|
db.get_top_artist_ids(limit_top_artists)?
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut favorite_album_ids = if has_scores {
|
let favorite_album_ids = if has_scores {
|
||||||
db.get_scored_album_ids("favorite", limit_favorites)?
|
let scored = db.get_scored_album_ids("favorite", limit_favorites)?;
|
||||||
|
if scored.is_empty() {
|
||||||
|
db.get_favorite_album_ids(limit_favorites)?
|
||||||
|
} else {
|
||||||
|
scored
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
db.get_favorite_album_ids(limit_favorites)?
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut favorite_track_ids = if has_scores {
|
let favorite_track_ids = if has_scores {
|
||||||
db.get_scored_track_ids("favorite", limit_favorites)?
|
let scored = db.get_scored_track_ids("favorite", limit_favorites)?;
|
||||||
|
if scored.is_empty() {
|
||||||
|
db.get_favorite_track_ids(limit_favorites)?
|
||||||
|
} else {
|
||||||
|
scored
|
||||||
|
}
|
||||||
} else {
|
} 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 {
|
Ok(HomeSeeds {
|
||||||
recently_played_album_ids,
|
recently_played_album_ids,
|
||||||
continue_listening_track_ids,
|
continue_listening_track_ids,
|
||||||
@@ -794,14 +804,20 @@ pub async fn reco_get_home_resolved(
|
|||||||
.map(|s| (s.artist_id, s.play_count))
|
.map(|s| (s.artist_id, s.play_count))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Step 3: Resolve albums, tracks, AND artists in parallel
|
// Step 3: Resolve all entity types in parallel.
|
||||||
// Artists are independent of albums/tracks, so start them concurrently.
|
// Recent albums, favorite albums, tracks, and artists are all independent.
|
||||||
let albums_fut = resolve_albums(
|
let recent_albums_fut = 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 = 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 tracks_fut = resolve_tracks(&all_track_ids, &reco_state, &app_state, &cache_state);
|
||||||
let artists_fut = resolve_artists(
|
let artists_fut = resolve_artists(
|
||||||
&all_artist_ids,
|
&all_artist_ids,
|
||||||
@@ -811,8 +827,8 @@ pub async fn reco_get_home_resolved(
|
|||||||
&cache_state,
|
&cache_state,
|
||||||
);
|
);
|
||||||
|
|
||||||
let (recently_played_albums, all_tracks, top_artists) =
|
let (recently_played_albums, resolved_favorites, all_tracks, top_artists) =
|
||||||
tokio::try_join!(albums_fut, tracks_fut, artists_fut)?;
|
tokio::try_join!(recent_albums_fut, favorite_albums_fut, tracks_fut, artists_fut)?;
|
||||||
|
|
||||||
// Build track lookup
|
// Build track lookup
|
||||||
let track_map: HashMap<u64, &TrackDisplayMeta> =
|
let track_map: HashMap<u64, &TrackDisplayMeta> =
|
||||||
@@ -824,18 +840,9 @@ pub async fn reco_get_home_resolved(
|
|||||||
.filter_map(|id| track_map.get(id).map(|tr| (*tr).clone()))
|
.filter_map(|id| track_map.get(id).map(|tr| (*tr).clone()))
|
||||||
.collect();
|
.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.
|
// 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
|
// 5b: Collect unique artist IDs from favorite albums + favorite tracks
|
||||||
let favorite_artist_ids: Vec<u64> = {
|
let favorite_artist_ids: Vec<u64> = {
|
||||||
let mut ids = Vec::new();
|
let mut ids = Vec::new();
|
||||||
@@ -989,6 +996,7 @@ async fn resolve_albums(
|
|||||||
|
|
||||||
// Tier 2: API cache (24h TTL)
|
// Tier 2: API cache (24h TTL)
|
||||||
let mut api_cache_resolved: HashMap<String, AlbumCardMeta> = HashMap::new();
|
let mut api_cache_resolved: HashMap<String, AlbumCardMeta> = HashMap::new();
|
||||||
|
let mut tier2_album_metas: Vec<AlbumCardMeta> = Vec::new();
|
||||||
if !missing_ids.is_empty() {
|
if !missing_ids.is_empty() {
|
||||||
let cached = {
|
let cached = {
|
||||||
let guard__ = cache_state.cache.lock().await;
|
let guard__ = cache_state.cache.lock().await;
|
||||||
@@ -1000,16 +1008,19 @@ async fn resolve_albums(
|
|||||||
for (album_id, json_str) in cached {
|
for (album_id, json_str) in cached {
|
||||||
if let Ok(album) = serde_json::from_str::<Album>(&json_str) {
|
if let Ok(album) = serde_json::from_str::<Album>(&json_str) {
|
||||||
let meta = album_to_card_meta(&album);
|
let meta = album_to_card_meta(&album);
|
||||||
// Write through to reco meta for future instant hits
|
tier2_album_metas.push(meta.clone());
|
||||||
{
|
|
||||||
let guard__ = reco_state.db.lock().await;
|
|
||||||
if let Some(db) = guard__.as_ref() {
|
|
||||||
let _ = db.set_album_meta(&meta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
api_cache_resolved.insert(album_id, meta);
|
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<String> = missing_ids
|
let still_missing: Vec<String> = missing_ids
|
||||||
@@ -1018,7 +1029,7 @@ async fn resolve_albums(
|
|||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.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<String, AlbumCardMeta> = HashMap::new();
|
let mut api_resolved: HashMap<String, AlbumCardMeta> = HashMap::new();
|
||||||
if !still_missing.is_empty() {
|
if !still_missing.is_empty() {
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -1027,15 +1038,11 @@ async fn resolve_albums(
|
|||||||
);
|
);
|
||||||
let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(10));
|
let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(10));
|
||||||
let client = app_state.client.clone();
|
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();
|
let mut handles = Vec::new();
|
||||||
for album_id in &still_missing {
|
for album_id in &still_missing {
|
||||||
let sem = sem.clone();
|
let sem = sem.clone();
|
||||||
let client = client.clone();
|
let client = client.clone();
|
||||||
let cache_arc = cache_arc.clone();
|
|
||||||
let reco_arc = reco_arc.clone();
|
|
||||||
let album_id = album_id.clone();
|
let album_id = album_id.clone();
|
||||||
|
|
||||||
handles.push(tokio::spawn(async move {
|
handles.push(tokio::spawn(async move {
|
||||||
@@ -1045,31 +1052,42 @@ async fn resolve_albums(
|
|||||||
c.get_album(&album_id).await.ok()?
|
c.get_album(&album_id).await.ok()?
|
||||||
};
|
};
|
||||||
let meta = album_to_card_meta(&album);
|
let meta = album_to_card_meta(&album);
|
||||||
|
let json = serde_json::to_string(&album).ok();
|
||||||
// Cache the full API response
|
Some((album_id, meta, json))
|
||||||
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))
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect results, then batch-write caches
|
||||||
|
let mut api_albums_to_cache: Vec<(String, String)> = Vec::new();
|
||||||
|
let mut api_album_metas: Vec<AlbumCardMeta> = Vec::new();
|
||||||
for handle in handles {
|
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);
|
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
|
// Assemble in seed order
|
||||||
@@ -1117,6 +1135,7 @@ async fn resolve_tracks(
|
|||||||
|
|
||||||
// Tier 2: API cache
|
// Tier 2: API cache
|
||||||
let mut api_cache_resolved: HashMap<u64, TrackDisplayMeta> = HashMap::new();
|
let mut api_cache_resolved: HashMap<u64, TrackDisplayMeta> = HashMap::new();
|
||||||
|
let mut tier2_track_metas: Vec<TrackDisplayMeta> = Vec::new();
|
||||||
if !missing_ids.is_empty() {
|
if !missing_ids.is_empty() {
|
||||||
let cached = {
|
let cached = {
|
||||||
let guard__ = cache_state.cache.lock().await;
|
let guard__ = cache_state.cache.lock().await;
|
||||||
@@ -1128,15 +1147,19 @@ async fn resolve_tracks(
|
|||||||
for (track_id, json_str) in cached {
|
for (track_id, json_str) in cached {
|
||||||
if let Ok(track) = serde_json::from_str::<Track>(&json_str) {
|
if let Ok(track) = serde_json::from_str::<Track>(&json_str) {
|
||||||
let meta = track_to_display_meta(&track);
|
let meta = track_to_display_meta(&track);
|
||||||
{
|
tier2_track_metas.push(meta.clone());
|
||||||
let guard__ = reco_state.db.lock().await;
|
|
||||||
if let Some(db) = guard__.as_ref() {
|
|
||||||
let _ = db.set_track_meta(&meta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
api_cache_resolved.insert(track_id, meta);
|
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<u64> = missing_ids
|
let still_missing: Vec<u64> = missing_ids
|
||||||
@@ -1145,7 +1168,7 @@ async fn resolve_tracks(
|
|||||||
.copied()
|
.copied()
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Tier 3: Qobuz API
|
// Tier 3: Qobuz API — fetch in parallel, defer cache writes
|
||||||
let mut api_resolved: HashMap<u64, TrackDisplayMeta> = HashMap::new();
|
let mut api_resolved: HashMap<u64, TrackDisplayMeta> = HashMap::new();
|
||||||
if !still_missing.is_empty() {
|
if !still_missing.is_empty() {
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -1154,15 +1177,11 @@ async fn resolve_tracks(
|
|||||||
);
|
);
|
||||||
let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(10));
|
let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(10));
|
||||||
let client = app_state.client.clone();
|
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();
|
let mut handles = Vec::new();
|
||||||
for track_id in &still_missing {
|
for track_id in &still_missing {
|
||||||
let sem = sem.clone();
|
let sem = sem.clone();
|
||||||
let client = client.clone();
|
let client = client.clone();
|
||||||
let cache_arc = cache_arc.clone();
|
|
||||||
let reco_arc = reco_arc.clone();
|
|
||||||
let track_id = *track_id;
|
let track_id = *track_id;
|
||||||
|
|
||||||
handles.push(tokio::spawn(async move {
|
handles.push(tokio::spawn(async move {
|
||||||
@@ -1172,29 +1191,42 @@ async fn resolve_tracks(
|
|||||||
c.get_track(track_id).await.ok()?
|
c.get_track(track_id).await.ok()?
|
||||||
};
|
};
|
||||||
let meta = track_to_display_meta(&track);
|
let meta = track_to_display_meta(&track);
|
||||||
|
let json = serde_json::to_string(&track).ok();
|
||||||
if let Ok(json) = serde_json::to_string(&track) {
|
Some((track_id, meta, json))
|
||||||
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))
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect results, then batch-write caches
|
||||||
|
let mut api_tracks_to_cache: Vec<(u64, String)> = Vec::new();
|
||||||
|
let mut api_track_metas: Vec<TrackDisplayMeta> = Vec::new();
|
||||||
for handle in handles {
|
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);
|
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<TrackDisplayMeta> = ids
|
let result: Vec<TrackDisplayMeta> = ids
|
||||||
@@ -1246,6 +1278,7 @@ async fn resolve_artists(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut api_cache_resolved: HashMap<u64, ArtistCardMeta> = HashMap::new();
|
let mut api_cache_resolved: HashMap<u64, ArtistCardMeta> = HashMap::new();
|
||||||
|
let mut tier2_metas_to_write: Vec<ArtistCardMeta> = Vec::new();
|
||||||
if !missing_ids.is_empty() {
|
if !missing_ids.is_empty() {
|
||||||
let cached = {
|
let cached = {
|
||||||
let guard__ = cache_state.cache.lock().await;
|
let guard__ = cache_state.cache.lock().await;
|
||||||
@@ -1257,15 +1290,19 @@ async fn resolve_artists(
|
|||||||
for (artist_id, json_str) in cached {
|
for (artist_id, json_str) in cached {
|
||||||
if let Ok(artist) = serde_json::from_str::<Artist>(&json_str) {
|
if let Ok(artist) = serde_json::from_str::<Artist>(&json_str) {
|
||||||
let meta = artist_to_card_meta(&artist, None);
|
let meta = artist_to_card_meta(&artist, None);
|
||||||
{
|
tier2_metas_to_write.push(meta.clone());
|
||||||
let guard__ = reco_state.db.lock().await;
|
|
||||||
if let Some(db) = guard__.as_ref() {
|
|
||||||
let _ = db.set_artist_meta(&meta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
api_cache_resolved.insert(artist_id, meta);
|
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<u64> = missing_ids
|
let still_missing: Vec<u64> = missing_ids
|
||||||
@@ -1274,7 +1311,7 @@ async fn resolve_artists(
|
|||||||
.copied()
|
.copied()
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Tier 3: Qobuz API
|
// Tier 3: Qobuz API — fetch in parallel, defer cache writes to after all complete
|
||||||
let mut api_resolved: HashMap<u64, ArtistCardMeta> = HashMap::new();
|
let mut api_resolved: HashMap<u64, ArtistCardMeta> = HashMap::new();
|
||||||
if !still_missing.is_empty() {
|
if !still_missing.is_empty() {
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -1283,17 +1320,11 @@ async fn resolve_artists(
|
|||||||
);
|
);
|
||||||
let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(10));
|
let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(10));
|
||||||
let client = app_state.client.clone();
|
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();
|
let mut handles = Vec::new();
|
||||||
for artist_id in &still_missing {
|
for artist_id in &still_missing {
|
||||||
let sem = sem.clone();
|
let sem = sem.clone();
|
||||||
let client = client.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;
|
let artist_id = *artist_id;
|
||||||
|
|
||||||
handles.push(tokio::spawn(async move {
|
handles.push(tokio::spawn(async move {
|
||||||
@@ -1303,29 +1334,42 @@ async fn resolve_artists(
|
|||||||
c.get_artist_basic(artist_id).await.ok()?
|
c.get_artist_basic(artist_id).await.ok()?
|
||||||
};
|
};
|
||||||
let meta = artist_to_card_meta(&artist, None);
|
let meta = artist_to_card_meta(&artist, None);
|
||||||
|
let json = serde_json::to_string(&artist).ok();
|
||||||
if let Ok(json) = serde_json::to_string(&artist) {
|
Some((artist_id, meta, json))
|
||||||
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))
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect results without holding any locks
|
||||||
|
let mut api_artists_to_cache: Vec<(u64, String)> = Vec::new();
|
||||||
|
let mut api_metas_to_write: Vec<ArtistCardMeta> = Vec::new();
|
||||||
for handle in handles {
|
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);
|
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
|
// Assemble in seed order, attaching play_counts
|
||||||
|
|||||||
@@ -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_artist ON reco_events(artist_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_reco_events_created ON reco_events(created_at);
|
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 (
|
CREATE TABLE IF NOT EXISTS reco_scores (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
score_type TEXT NOT NULL,
|
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_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_album ON reco_scores(album_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_reco_scores_artist ON reco_scores(artist_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))?;
|
.map_err(|e| format!("Failed to initialize reco database: {}", e))?;
|
||||||
|
|||||||
Reference in new issue
Block a user