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:
7 files changed
+145
-1
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user