Files
qbz/src-tauri/src/commands_v2/catalog.rs
T
vicrodh ce375008ab fix(api): Release Watch lives at /favorite/getNewReleases?type=artists
The inferred OpenAPI spec
(qbz-nix-docs/qobuz-api-inferred-openapi-v9.7.0.3.yaml, source:
x20/b.java) documents the endpoint as /favorite/getNewReleases with
three required query params:

  @GET favorite/getNewReleases
  - type: String   (artists | labels | awards — matches the three
                    tabs in the mobile UI)
  - limit: Integer
  - offset: Integer

My earlier attempts at /albums/releaseWatch were chasing the Android
route-table entry for the Release Watch *screen*, not the HTTP call
that screen makes. Different layer.

Also removes my made-up speculation about mobile app_id gating — the
/favorite/* endpoints are standard web-API calls the existing client
already knows how to sign (via authenticated_headers + /0.2/ base URL).

v2_get_release_watch now takes an optional releaseType arg defaulting
to 'artists' so callers can switch tabs later without another command.

cc
2026-04-14 07:47:42 -06:00

466 lines
14 KiB
Rust

use tauri::State;
use qbz_models::{
Album, Artist, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse, DiscoverResponse,
GenreInfo, LabelDetail, LabelExploreResponse, LabelPageData, PageArtistResponse,
PlaylistTag, SearchResultsPage, Track,
};
use crate::artist_blacklist::BlacklistState;
use crate::core_bridge::CoreBridgeState;
use crate::runtime::{CommandRequirement, RuntimeError, RuntimeManagerState};
// ==================== Extended Catalog Commands (V2) ====================
/// Get tracks batch by IDs (V2 - uses QbzCore)
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_get_tracks_batch(
trackIds: Vec<u64>,
bridge: State<'_, CoreBridgeState>,
runtime: State<'_, RuntimeManagerState>,
) -> Result<Vec<Track>, RuntimeError> {
runtime
.manager()
.check_requirements(CommandRequirement::RequiresCoreBridgeAuth)
.await?;
log::info!("[V2] get_tracks_batch: {} tracks", trackIds.len());
let bridge = bridge.get().await;
bridge
.get_tracks_batch(&trackIds)
.await
.map_err(RuntimeError::Internal)
}
/// Get genres (V2 - uses QbzCore)
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_get_genres(
parentId: Option<u64>,
bridge: State<'_, CoreBridgeState>,
runtime: State<'_, RuntimeManagerState>,
) -> Result<Vec<GenreInfo>, RuntimeError> {
runtime
.manager()
.check_requirements(CommandRequirement::RequiresCoreBridgeAuth)
.await?;
log::info!("[V2] get_genres: parent={:?}", parentId);
let bridge = bridge.get().await;
bridge
.get_genres(parentId)
.await
.map_err(RuntimeError::Internal)
}
/// Get discover index (V2 - uses QbzCore)
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_get_discover_index(
genreIds: Option<Vec<u64>>,
bridge: State<'_, CoreBridgeState>,
blacklist_state: State<'_, BlacklistState>,
runtime: State<'_, RuntimeManagerState>,
) -> Result<DiscoverResponse, RuntimeError> {
runtime
.manager()
.check_requirements(CommandRequirement::RequiresCoreBridgeAuth)
.await?;
log::info!("[V2] get_discover_index: genres={:?}", genreIds);
let bridge = bridge.get().await;
let mut response = bridge
.get_discover_index(genreIds)
.await
.map_err(RuntimeError::Internal)?;
let mut filtered_count: usize = 0;
let mut filter_container =
|container: &mut Option<qbz_models::DiscoverContainer<DiscoverAlbum>>| {
if let Some(section) = container.as_mut() {
let before = section.data.items.len();
section.data.items.retain(|album| {
!album
.artists
.iter()
.any(|artist| blacklist_state.is_blacklisted(artist.id))
});
filtered_count += before.saturating_sub(section.data.items.len());
}
};
filter_container(&mut response.containers.ideal_discography);
filter_container(&mut response.containers.new_releases);
filter_container(&mut response.containers.qobuzissims);
filter_container(&mut response.containers.most_streamed);
filter_container(&mut response.containers.press_awards);
filter_container(&mut response.containers.album_of_the_week);
if filtered_count > 0 {
log::debug!(
"[V2/Blacklist] Filtered {} discover index albums from home containers",
filtered_count
);
}
Ok(response)
}
/// Get discover playlists (V2 - uses QbzCore)
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_get_discover_playlists(
tag: Option<String>,
genreIds: Option<Vec<u64>>,
limit: Option<u32>,
offset: Option<u32>,
bridge: State<'_, CoreBridgeState>,
runtime: State<'_, RuntimeManagerState>,
) -> Result<DiscoverPlaylistsResponse, RuntimeError> {
runtime
.manager()
.check_requirements(CommandRequirement::RequiresCoreBridgeAuth)
.await?;
log::info!("[V2] get_discover_playlists: tag={:?}", tag);
let bridge = bridge.get().await;
bridge
.get_discover_playlists(tag, genreIds, limit, offset)
.await
.map_err(RuntimeError::Internal)
}
/// Get playlist tags (V2 - uses QbzCore)
#[tauri::command]
pub async fn v2_get_playlist_tags(
bridge: State<'_, CoreBridgeState>,
runtime: State<'_, RuntimeManagerState>,
) -> Result<Vec<PlaylistTag>, RuntimeError> {
runtime
.manager()
.check_requirements(CommandRequirement::RequiresCoreBridgeAuth)
.await?;
log::info!("[V2] get_playlist_tags");
let bridge = bridge.get().await;
bridge
.get_playlist_tags()
.await
.map_err(RuntimeError::Internal)
}
/// Get discover albums from a browse endpoint (V2 - uses QbzCore)
/// Supports: newReleases, idealDiscography, mostStreamed, qobuzissimes, albumOfTheWeek, pressAward
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_get_discover_albums(
endpointType: String,
genreIds: Option<Vec<u64>>,
offset: Option<u32>,
limit: Option<u32>,
bridge: State<'_, CoreBridgeState>,
blacklist_state: State<'_, BlacklistState>,
runtime: State<'_, RuntimeManagerState>,
) -> Result<DiscoverData<DiscoverAlbum>, RuntimeError> {
runtime
.manager()
.check_requirements(CommandRequirement::RequiresCoreBridgeAuth)
.await?;
// Map endpoint type to actual path
let endpoint = match endpointType.as_str() {
"newReleases" => "/discover/newReleases",
"idealDiscography" => "/discover/idealDiscography",
"mostStreamed" => "/discover/mostStreamed",
"qobuzissimes" => "/discover/qobuzissims",
"albumOfTheWeek" => "/discover/albumOfTheWeek",
"pressAward" => "/discover/pressAward",
_ => {
return Err(RuntimeError::Internal(format!(
"Unknown discover endpoint type: {}",
endpointType
)))
}
};
log::info!("[V2] get_discover_albums: type={}", endpointType);
let bridge = bridge.get().await;
let mut results = bridge
.get_discover_albums(endpoint, genreIds, offset.unwrap_or(0), limit.unwrap_or(50))
.await
.map_err(RuntimeError::Internal)?;
// Filter out albums from blacklisted artists
let original_count = results.items.len();
results.items.retain(|album| {
// Check if any of the album's artists are blacklisted
!album
.artists
.iter()
.any(|artist| blacklist_state.is_blacklisted(artist.id))
});
let filtered_count = original_count - results.items.len();
if filtered_count > 0 {
log::debug!(
"[V2/Blacklist] Filtered {} albums from discover results",
filtered_count
);
}
Ok(results)
}
/// Get featured albums (V2 - uses QbzCore)
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_get_featured_albums(
featuredType: String,
limit: u32,
offset: u32,
genreId: Option<u64>,
bridge: State<'_, CoreBridgeState>,
blacklist_state: State<'_, BlacklistState>,
runtime: State<'_, RuntimeManagerState>,
) -> Result<SearchResultsPage<Album>, RuntimeError> {
runtime
.manager()
.check_requirements(CommandRequirement::RequiresCoreBridgeAuth)
.await?;
log::info!(
"[V2] get_featured_albums: type={}, genre={:?}",
featuredType,
genreId
);
let bridge = bridge.get().await;
let mut results = bridge
.get_featured_albums(&featuredType, limit, offset, genreId)
.await
.map_err(RuntimeError::Internal)?;
// Filter out albums from blacklisted artists
let original_count = results.items.len();
results
.items
.retain(|album| !blacklist_state.is_blacklisted(album.artist.id));
let filtered_count = original_count - results.items.len();
if filtered_count > 0 {
log::debug!(
"[V2/Blacklist] Filtered {} albums from featured results",
filtered_count
);
results.total = results.total.saturating_sub(filtered_count as u32);
}
Ok(results)
}
/// Get Release Watch (V2 — releases from followed artists/labels/awards).
///
/// Calls `/favorite/getNewReleases?type=...` — the same endpoint the mobile
/// client uses for its "Radar de Novedades" feed. `release_type` defaults to
/// `"artists"` (the mobile default tab) and accepts `"labels"` or `"awards"`.
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_get_release_watch(
releaseType: Option<String>,
limit: Option<u32>,
offset: Option<u32>,
bridge: State<'_, CoreBridgeState>,
blacklist_state: State<'_, BlacklistState>,
runtime: State<'_, RuntimeManagerState>,
) -> Result<SearchResultsPage<Album>, RuntimeError> {
runtime
.manager()
.check_requirements(CommandRequirement::RequiresCoreBridgeAuth)
.await?;
let release_type = releaseType.unwrap_or_else(|| "artists".to_string());
let limit = limit.unwrap_or(20);
let offset = offset.unwrap_or(0);
log::info!(
"[V2] get_release_watch: type={}, limit={}, offset={}",
release_type,
limit,
offset
);
let bridge = bridge.get().await;
let mut results = bridge
.get_release_watch(&release_type, limit, offset)
.await
.map_err(RuntimeError::Internal)?;
let original_count = results.items.len();
results
.items
.retain(|album| !blacklist_state.is_blacklisted(album.artist.id));
let filtered_count = original_count - results.items.len();
if filtered_count > 0 {
log::debug!(
"[V2/Blacklist] Filtered {} albums from release watch",
filtered_count
);
results.total = results.total.saturating_sub(filtered_count as u32);
}
Ok(results)
}
/// Get artist page (V2 - uses QbzCore)
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_get_artist_page(
artistId: u64,
sort: Option<String>,
bridge: State<'_, CoreBridgeState>,
runtime: State<'_, RuntimeManagerState>,
) -> Result<PageArtistResponse, RuntimeError> {
runtime
.manager()
.check_requirements(CommandRequirement::RequiresCoreBridgeAuth)
.await?;
log::info!("[V2] get_artist_page: {} sort={:?}", artistId, sort);
let bridge = bridge.get().await;
bridge
.get_artist_page(artistId, sort.as_deref())
.await
.map_err(RuntimeError::Internal)
}
/// Get similar artists (V2 - uses QbzCore)
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_get_similar_artists(
artistId: u64,
limit: u32,
offset: u32,
bridge: State<'_, CoreBridgeState>,
blacklist_state: State<'_, BlacklistState>,
runtime: State<'_, RuntimeManagerState>,
) -> Result<SearchResultsPage<Artist>, RuntimeError> {
runtime
.manager()
.check_requirements(CommandRequirement::RequiresCoreBridgeAuth)
.await?;
log::info!("[V2] get_similar_artists: {}", artistId);
let bridge = bridge.get().await;
let mut results = bridge
.get_similar_artists(artistId, limit, offset)
.await
.map_err(RuntimeError::Internal)?;
// Filter out blacklisted artists
let original_count = results.items.len();
results
.items
.retain(|artist| !blacklist_state.is_blacklisted(artist.id));
let filtered_count = original_count - results.items.len();
if filtered_count > 0 {
log::debug!("[V2/Blacklist] Filtered {} similar artists", filtered_count);
results.total = results.total.saturating_sub(filtered_count as u32);
}
Ok(results)
}
/// Get artist with albums (V2 - uses QbzCore)
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_get_artist_with_albums(
artistId: u64,
limit: Option<u32>,
offset: Option<u32>,
bridge: State<'_, CoreBridgeState>,
runtime: State<'_, RuntimeManagerState>,
) -> Result<Artist, RuntimeError> {
runtime
.manager()
.check_requirements(CommandRequirement::RequiresCoreBridgeAuth)
.await?;
log::info!(
"[V2] get_artist_with_albums: {} limit={:?} offset={:?}",
artistId,
limit,
offset
);
let bridge = bridge.get().await;
bridge
.get_artist_with_albums(artistId, limit, offset)
.await
.map_err(RuntimeError::Internal)
}
/// Get label details (V2 - uses QbzCore)
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_get_label(
labelId: u64,
limit: u32,
offset: u32,
bridge: State<'_, CoreBridgeState>,
runtime: State<'_, RuntimeManagerState>,
) -> Result<LabelDetail, RuntimeError> {
runtime
.manager()
.check_requirements(CommandRequirement::RequiresCoreBridgeAuth)
.await?;
log::info!("[V2] get_label: {}", labelId);
let bridge = bridge.get().await;
bridge
.get_label(labelId, limit, offset)
.await
.map_err(RuntimeError::Internal)
}
/// Get label page (aggregated: top tracks, releases, playlists, artists)
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_get_label_page(
labelId: u64,
bridge: State<'_, CoreBridgeState>,
runtime: State<'_, RuntimeManagerState>,
) -> Result<LabelPageData, RuntimeError> {
runtime
.manager()
.check_requirements(CommandRequirement::RequiresCoreBridgeAuth)
.await?;
log::info!("[V2] get_label_page: {}", labelId);
let bridge = bridge.get().await;
bridge
.get_label_page(labelId)
.await
.map_err(RuntimeError::Internal)
}
/// Get label explore (discover more labels)
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_get_label_explore(
limit: u32,
offset: u32,
bridge: State<'_, CoreBridgeState>,
runtime: State<'_, RuntimeManagerState>,
) -> Result<LabelExploreResponse, RuntimeError> {
runtime
.manager()
.check_requirements(CommandRequirement::RequiresCoreBridgeAuth)
.await?;
log::info!("[V2] get_label_explore: limit={} offset={}", limit, offset);
let bridge = bridge.get().await;
bridge
.get_label_explore(limit, offset)
.await
.map_err(RuntimeError::Internal)
}