qobuz_api/api/content.rs
1//! Content API operations: search and browse for albums, artists, tracks, playlists.
2
3pub mod albums;
4pub mod artists;
5pub mod bundle;
6pub mod catalog;
7pub mod cover;
8pub mod discography;
9pub mod mixtape;
10pub mod persistence;
11pub mod playlists;
12pub mod stream;
13pub mod tracks;
14
15use std::sync::atomic::{AtomicBool, Ordering::Relaxed};
16
17use serde::de::DeserializeOwned;
18
19use crate::{
20 api::{
21 requests::{RequestAuth, signed_get},
22 service::QobuzApiService,
23 },
24 errors::QobuzApiError::{self, Canceled},
25};
26
27/// Checks the cancellation flag and returns `Canceled` if the download was cancelled.
28///
29/// # Arguments
30///
31/// * `cancel` - Optional cancellation flag
32///
33/// # Returns
34///
35/// `Ok(())` if not cancelled.
36///
37/// # Errors
38///
39/// Returns `QobuzApiError::Canceled` if the cancellation flag is set.
40pub fn check_cancel(cancel: Option<&AtomicBool>) -> Result<(), QobuzApiError> {
41 if cancel.is_some_and(|c| c.load(Relaxed)) {
42 return Err(Canceled);
43 }
44 Ok(())
45}
46
47/// Checks cancellation before and after an async fetch, discarding the fetch result on cancel.
48///
49/// # Arguments
50///
51/// * `cancel` - Optional cancellation flag
52/// * `f` - Async closure producing the value to fetch
53///
54/// # Returns
55///
56/// The fetch result if not cancelled.
57///
58/// # Errors
59///
60/// Returns `QobuzApiError::Canceled` if cancelled before or after the fetch.
61pub async fn fetch_with_cancel<T, F, Fut>(
62 cancel: Option<&AtomicBool>,
63 f: F,
64) -> Result<T, QobuzApiError>
65where
66 F: FnOnce() -> Fut,
67 Fut: Future<Output = Result<T, QobuzApiError>>,
68{
69 check_cancel(cancel)?;
70 let result = f().await?;
71 check_cancel(cancel)?;
72 Ok(result)
73}
74
75/// Appends optional `limit` and `offset` pagination parameters to a params vector.
76///
77/// # Arguments
78///
79/// * `params` - Parameter vector to modify
80/// * `limit` - Maximum number of results (if provided)
81/// * `offset` - Pagination offset (if provided)
82pub fn push_pagination_params(
83 params: &mut Vec<(String, String)>,
84 limit: Option<i32>,
85 offset: Option<i32>,
86) {
87 if let Some(l) = limit {
88 params.push(("limit".to_string(), l.to_string()));
89 }
90 if let Some(o) = offset {
91 params.push(("offset".to_string(), o.to_string()));
92 }
93}
94
95/// Retrieves the auth token from the service and sends a signed GET request.
96///
97/// # Arguments
98///
99/// * `service` - Authenticated API service
100/// * `endpoint` - API endpoint path
101/// * `params` - Key-value parameter pairs (will be sorted for signing)
102///
103/// # Returns
104///
105/// The deserialized response of type `T`.
106///
107/// # Errors
108///
109/// Returns a `QobuzApiError` if not authenticated or the API request fails.
110async fn do_signed_get<T: DeserializeOwned>(
111 service: &QobuzApiService,
112 endpoint: &str,
113 params: &mut Vec<(String, String)>,
114) -> Result<T, QobuzApiError> {
115 let token = service.require_auth_token()?;
116
117 signed_get(
118 service.http_client(),
119 service.base_url(),
120 endpoint,
121 params,
122 &RequestAuth {
123 app_id: &service.app_id,
124 app_secret: service.app_secret(),
125 user_auth_token: token,
126 },
127 )
128 .await
129}
130
131/// Sends a search request with query, limit, and offset parameters.
132///
133/// # Arguments
134///
135/// * `service` - Authenticated API service
136/// * `endpoint` - API endpoint path (e.g., `"/album/search"`)
137/// * `query` - Search query string
138/// * `limit` - Maximum number of results to return
139/// * `offset` - Pagination offset
140///
141/// # Returns
142///
143/// The deserialized response of type `T`.
144///
145/// # Errors
146///
147/// Returns a `QobuzApiError` if not authenticated or the API request fails.
148pub async fn search<T: DeserializeOwned>(
149 service: &QobuzApiService,
150 endpoint: &str,
151 query: &str,
152 limit: Option<i32>,
153 offset: Option<i32>,
154) -> Result<T, QobuzApiError> {
155 let mut params = vec![("query".to_string(), query.to_string())];
156 push_pagination_params(&mut params, limit, offset);
157
158 do_signed_get(service, endpoint, &mut params).await
159}
160
161/// Sends a get-by-ID request with an optional extra parameter.
162///
163/// # Arguments
164///
165/// * `service` - Authenticated API service
166/// * `endpoint` - API endpoint path (e.g., `"/album/get"`)
167/// * `id_field` - Parameter name for the ID (e.g., `"album_id"`)
168/// * `id` - Resource identifier
169/// * `extra` - Optional extra fields to include
170///
171/// # Returns
172///
173/// The deserialized response of type `T`.
174///
175/// # Errors
176///
177/// Returns a `QobuzApiError` if not authenticated or the API request fails.
178pub async fn get_by_id<T: DeserializeOwned, I: ToString>(
179 service: &QobuzApiService,
180 endpoint: &str,
181 id_field: &str,
182 id: I,
183 extra: Option<&str>,
184) -> Result<T, QobuzApiError> {
185 let mut params = vec![(id_field.to_string(), id.to_string())];
186 if let Some(e) = extra {
187 params.push(("extra".to_string(), e.to_string()));
188 }
189
190 do_signed_get(service, endpoint, &mut params).await
191}
192
193/// Sends a paginated request by ID with limit and offset parameters.
194///
195/// # Arguments
196///
197/// * `service` - Authenticated API service
198/// * `endpoint` - API endpoint path (e.g., `"/artist/getReleasesList"`)
199/// * `id_field` - Parameter name for the ID (e.g., `"artist_id"`)
200/// * `id` - Resource identifier
201/// * `limit` - Maximum number of results to return
202/// * `offset` - Pagination offset
203///
204/// # Returns
205///
206/// The deserialized response of type `T`.
207///
208/// # Errors
209///
210/// Returns a `QobuzApiError` if not authenticated or the API request fails.
211pub async fn paginated<T: DeserializeOwned, I: ToString>(
212 service: &QobuzApiService,
213 endpoint: &str,
214 id_field: &str,
215 id: I,
216 limit: Option<i32>,
217 offset: Option<i32>,
218) -> Result<T, QobuzApiError> {
219 let mut params = vec![(id_field.to_string(), id.to_string())];
220 push_pagination_params(&mut params, limit, offset);
221
222 do_signed_get(service, endpoint, &mut params).await
223}
224
225#[cfg(test)]
226mod tests {
227 use std::sync::atomic::AtomicBool;
228
229 use {
230 anyhow::{Result, anyhow, ensure},
231 tokio::runtime::Runtime,
232 };
233
234 use crate::{
235 api::content::{check_cancel, fetch_with_cancel},
236 errors::QobuzApiError::{self, Canceled as CanceledVariant},
237 };
238
239 /// Tests cancellation check passes without a flag.
240 ///
241 /// # Errors
242 ///
243 /// Returns an error if the assertion fails.
244 #[test]
245 fn check_cancel_without_flag_succeeds() -> Result<()> {
246 check_cancel(None)?;
247 Ok(())
248 }
249
250 /// Tests cancellation check fails when the flag is set.
251 ///
252 /// # Errors
253 ///
254 /// Returns an error if the assertion fails.
255 #[test]
256 fn check_cancel_with_flag_fails() -> Result<()> {
257 let cancel = AtomicBool::new(true);
258 let result = check_cancel(Some(&cancel));
259 ensure!(result.is_err(), "expected canceled error");
260 let err = result.err().ok_or_else(|| anyhow!("expected error"))?;
261 ensure!(matches!(err, CanceledVariant), "expected Canceled variant");
262 Ok(())
263 }
264
265 /// Tests fetch wrapper returns the value when not cancelled.
266 ///
267 /// # Errors
268 ///
269 /// Returns an error if the assertion fails.
270 #[test]
271 fn fetch_with_cancel_returns_value() -> Result<()> {
272 let rt = Runtime::new()?;
273 let value = rt.block_on(fetch_with_cancel(None, async || {
274 Ok::<i32, QobuzApiError>(7)
275 }))?;
276 ensure!(value == 7, "unexpected fetch value");
277 Ok(())
278 }
279
280 /// Tests fetch wrapper fails when cancelled before fetch.
281 ///
282 /// # Errors
283 ///
284 /// Returns an error if the assertion fails.
285 #[test]
286 fn fetch_with_cancel_respects_cancel() -> Result<()> {
287 let cancel = AtomicBool::new(true);
288 let rt = Runtime::new()?;
289 let result = rt.block_on(fetch_with_cancel(Some(&cancel), async || {
290 Ok::<i32, QobuzApiError>(7)
291 }));
292 ensure!(result.is_err(), "expected canceled error");
293 Ok(())
294 }
295}