feat: add album suggestions and forgotten favorites backend commands

Add v2_get_album_suggestions (album/suggest endpoint with blacklist filtering)
and v2_reco_get_forgotten_favorites (favorites not played in N days) for the
For You tab's Similar to and Rediscover your Library sections.
This commit is contained in:
vicrodh committed 2026-03-09 23:50:45 -06:00
1 parent 61b1bba8d9
commit b34b809ad5
7 files changed
+145 -1

No files matched your search

+27
View File
@@ -631,6 +631,33 @@ impl QobuzClient {
Ok(serde_json::from_value(response)?)
}
/// Get album suggestions (similar albums) from /album/suggest
pub async fn get_album_suggest(
&self,
album_id: &str,
) -> Result<AlbumSuggestResponse> {
let url = endpoints::build_url(paths::ALBUM_SUGGEST);
let http_response = self
.http
.get(&url)
.headers(self.api_headers().await?)
.query(&[("album_id", album_id)])
.send()
.await?;
let status = http_response.status();
log::debug!("[API] get_album_suggest({}) status={}", album_id, status);
if !status.is_success() {
return Err(ApiError::ApiResponse(format!(
"get_album_suggest({}) status {}",
album_id, status
)));
}
let response: Value = http_response.json().await?;
Ok(serde_json::from_value(response)?)
}
/// Get featured albums by type (new-releases, press-awards, most-streamed)
pub async fn get_featured_albums(
&self,
+1
View File
@@ -21,6 +21,7 @@ pub mod paths {
pub const ALBUM_GET: &str = "/album/get";
pub const ALBUM_SEARCH: &str = "/album/search";
pub const ALBUM_GET_FEATURED: &str = "/album/getFeatured";
pub const ALBUM_SUGGEST: &str = "/album/suggest";
// Artist
pub const ARTIST_GET: &str = "/artist/get";
+7
View File
@@ -693,6 +693,13 @@ pub struct RadioResponse {
pub tracks: SearchResultsPage<Track>,
}
/// Album suggestion response from /album/suggest
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlbumSuggestResponse {
pub algorithm: Option<String>,
pub albums: Option<SearchResultsPage<Album>>,
}
/// Favorites container
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Favorites {
+69
View File
@@ -4368,6 +4368,75 @@ pub async fn v2_reco_get_home_resolved(
.await
}
/// Get album suggestions (similar albums) from Qobuz /album/suggest API
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_get_album_suggestions(
albumId: String,
limit: Option<u32>,
state: State<'_, AppState>,
blacklist_state: State<'_, BlacklistState>,
runtime: State<'_, RuntimeManagerState>,
) -> Result<Vec<crate::api::models::Album>, String> {
runtime
.manager()
.check_requirements(CommandRequirement::RequiresCoreBridgeAuth)
.await
.map_err(|e| e.to_string())?;
let client = state.client.read().await.clone();
let response = client
.get_album_suggest(&albumId)
.await
.map_err(|e| format!("Failed to get album suggestions: {}", e))?;
let mut albums = response.albums
.map(|page| page.items)
.unwrap_or_default();
// Apply blacklist
albums.retain(|album| {
!blacklist_state.is_blacklisted(album.artist.id)
});
let max = limit.unwrap_or(10) as usize;
albums.truncate(max);
Ok(albums)
}
/// Get "forgotten" favorite albums — favorites not played in recent N days
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_reco_get_forgotten_favorites(
limit: Option<u32>,
recencyDays: Option<u32>,
reco_state: State<'_, RecoState>,
app_state: State<'_, AppState>,
cache_state: State<'_, ApiCacheState>,
) -> Result<Vec<crate::reco_store::AlbumCardMeta>, String> {
let guard = reco_state.db.lock().await;
let db = guard.as_ref().ok_or("No active session")?;
let album_ids = db.get_forgotten_favorite_album_ids(
limit.unwrap_or(12),
recencyDays.unwrap_or(30),
)?;
drop(guard);
if album_ids.is_empty() {
return Ok(Vec::new());
}
// 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,
)
.await
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct V2LibraryCacheStats {
+2
View File
@@ -1385,6 +1385,8 @@ pub fn run() {
commands_v2::v2_reco_get_home,
commands_v2::v2_reco_get_home_ml,
commands_v2::v2_reco_get_home_resolved,
commands_v2::v2_get_album_suggestions,
commands_v2::v2_reco_get_forgotten_favorites,
commands_v2::v2_library_get_cache_stats,
commands_v2::v2_library_get_stats,
commands_v2::v2_library_get_albums,
+1 -1
View File
@@ -967,7 +967,7 @@ pub async fn reco_get_home_resolved(
}
/// Resolve album IDs → AlbumCardMeta with 3-tier cache
async fn resolve_albums(
pub async fn resolve_albums(
ids: &[String],
reco_state: &State<'_, RecoState>,
app_state: &State<'_, AppState>,
+38
View File
@@ -387,6 +387,44 @@ impl RecoStoreDb {
Ok(albums)
}
/// Get favorite album IDs that haven't been played recently ("forgotten favorites")
pub fn get_forgotten_favorite_album_ids(
&self,
limit: u32,
recency_days: u32,
) -> Result<Vec<String>, String> {
let mut stmt = self
.conn
.prepare(
r#"
SELECT f.album_id
FROM reco_events f
WHERE f.event_type = 'favorite' AND f.album_id IS NOT NULL
AND f.album_id NOT IN (
SELECT DISTINCT p.album_id
FROM reco_events p
WHERE p.event_type = 'play'
AND p.album_id IS NOT NULL
AND p.created_at > datetime('now', '-' || ? || ' days')
)
GROUP BY f.album_id
ORDER BY RANDOM()
LIMIT ?
"#,
)
.map_err(|e| format!("Failed to prepare forgotten favorites query: {}", e))?;
let rows = stmt
.query_map(params![recency_days, limit], |row| row.get::<_, String>(0))
.map_err(|e| format!("Failed to query forgotten favorites: {}", e))?;
let mut albums = Vec::new();
for row in rows {
albums.push(row.map_err(|e| format!("Failed to read forgotten favorite row: {}", e))?);
}
Ok(albums)
}
pub fn get_favorite_track_ids(&self, limit: u32) -> Result<Vec<u64>, String> {
let mut stmt = self
.conn