Skip to main content

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}