Skip to main content

qobuz_api/api/content/
playlists.rs

1//! Playlist search and browse operations.
2
3use serde::{Deserialize, Serialize};
4
5use crate::{
6    api::{
7        auth::User,
8        content::{
9            albums::Image,
10            catalog::{ItemSearchResult, PlaylistSearchResponse},
11            get_by_id, search,
12            tracks::Track,
13        },
14        response::deserialize_flexible_string_id,
15        service::QobuzApiService,
16    },
17    errors::QobuzApiError,
18};
19
20/// A curated list of tracks.
21#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
22pub struct Playlist {
23    /// Unique playlist identifier (may be a string or number from the API).
24    #[serde(default, deserialize_with = "deserialize_flexible_string_id")]
25    pub id: Option<String>,
26    /// Playlist name.
27    pub name: Option<String>,
28    /// Description text.
29    pub description: Option<String>,
30    /// Number of tracks.
31    pub tracks_count: Option<i32>,
32    /// Total duration in seconds.
33    pub duration: Option<i32>,
34    /// Public visibility.
35    pub is_public: Option<bool>,
36    /// Playlist creator (full user object, from `/playlist/get`).
37    pub creator: Option<User>,
38    /// Playlist owner (simpler object with name, from search endpoints).
39    pub owner: Option<PlaylistOwner>,
40    /// Playlist cover art (always `null` for playlists; use `images`/`image_rectangle` instead).
41    pub image: Option<Image>,
42    /// Dedicated playlist rectangle banner image URL.
43    pub image_rectangle: Option<Vec<String>>,
44    /// Smaller playlist rectangle banner image URL.
45    pub image_rectangle_mini: Option<Vec<String>>,
46    /// Array of 50px track cover URLs (used for collage display).
47    pub images: Option<Vec<String>>,
48    /// Array of 150px track cover URLs.
49    pub images150: Option<Vec<String>>,
50    /// Array of 300px track cover URLs.
51    pub images300: Option<Vec<String>>,
52    /// Contained tracks.
53    pub tracks: Option<ItemSearchResult<Box<Track>>>,
54    /// Creation timestamp.
55    pub created_at: Option<i64>,
56    /// Last update timestamp.
57    pub updated_at: Option<i64>,
58}
59
60impl Playlist {
61    /// Returns the display name of the playlist creator/owner, checking
62    /// `creator.display_name` first (from `/playlist/get`), then `owner.name`
63    /// (from search endpoints).
64    ///
65    /// # Returns
66    ///
67    /// `Some(&str)` with the display name if available, `None` otherwise.
68    #[must_use]
69    pub fn creator_name(&self) -> Option<&str> {
70        self.creator
71            .as_ref()
72            .and_then(|c| c.display_name.as_deref())
73            .or_else(|| {
74                let o = self.owner.as_ref()?;
75                o.name.as_deref()
76            })
77    }
78
79    /// Returns the best available cover image URL for the playlist.
80    ///
81    /// For detail views (`large = true`) prefers the dedicated rectangle banner
82    /// or the largest track cover. For thumbnails (`large = false`) prefers
83    /// the mini rectangle or a medium track cover.
84    ///
85    /// # Arguments
86    ///
87    /// * `large` - If `true`, prefers larger image sizes suitable for detail views.
88    ///
89    /// # Returns
90    ///
91    /// An optional image URL string.
92    #[must_use]
93    pub fn best_image_url(&self, large: bool) -> Option<String> {
94        if large {
95            first_img_url(self.image_rectangle.as_ref())
96                .or_else(|| first_img_url(self.images300.as_ref()))
97                .or_else(|| first_img_url(self.images150.as_ref()))
98                .or_else(|| first_img_url(self.images.as_ref()))
99        } else {
100            first_img_url(self.image_rectangle_mini.as_ref())
101                .or_else(|| first_img_url(self.image_rectangle.as_ref()))
102                .or_else(|| first_img_url(self.images150.as_ref()))
103                .or_else(|| first_img_url(self.images.as_ref()))
104        }
105    }
106}
107
108/// Playlist owner (returned by search endpoints).
109#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
110pub struct PlaylistOwner {
111    /// Owner user ID.
112    pub id: Option<i32>,
113    /// Owner display name.
114    pub name: Option<String>,
115}
116
117/// Extracts the first URL from an optional vector of image URLs.
118///
119/// # Returns
120///
121/// `Some(String)` with the first image URL if the vector is non-empty, `None` otherwise.
122fn first_img_url(v: Option<&Vec<String>>) -> Option<String> {
123    v?.first().cloned()
124}
125
126/// Searches for playlists matching the query.
127///
128/// # Arguments
129///
130/// * `service` - Authenticated API service
131/// * `query` - Search query string
132/// * `limit` - Maximum number of results to return
133/// * `offset` - Pagination offset
134///
135/// # Returns
136///
137/// A paginated `ItemSearchResult` containing matching playlists.
138///
139/// # Errors
140///
141/// Returns a `QobuzApiError` if not authenticated or the API request fails.
142pub async fn search_playlists(
143    service: &QobuzApiService,
144    query: &str,
145    limit: Option<i32>,
146    offset: Option<i32>,
147) -> Result<ItemSearchResult<Box<Playlist>>, QobuzApiError> {
148    let resp: PlaylistSearchResponse =
149        search(service, "/playlist/search", query, limit, offset).await?;
150    Ok(resp.playlists)
151}
152
153/// Retrieves playlist details by ID.
154///
155/// # Arguments
156///
157/// * `service` - Authenticated API service
158/// * `playlist_id` - Playlist identifier
159/// * `extra` - Optional extra fields to include
160///
161/// # Returns
162///
163/// The playlist details.
164///
165/// # Errors
166///
167/// Returns a `QobuzApiError` if not authenticated or the API request fails.
168pub async fn get_playlist(
169    service: &QobuzApiService,
170    playlist_id: &str,
171    extra: Option<&str>,
172) -> Result<Playlist, QobuzApiError> {
173    get_by_id(service, "/playlist/get", "playlist_id", playlist_id, extra).await
174}
175
176#[cfg(test)]
177mod tests {
178    use {
179        anyhow::{Result, anyhow, ensure},
180        tokio::runtime::Runtime,
181    };
182
183    use crate::{
184        api::{
185            auth::User,
186            content::playlists::{Playlist, PlaylistOwner, get_playlist, search_playlists},
187        },
188        assert_empty_search_test, setup_test,
189    };
190
191    /// Tests creator name prefers display name.
192    ///
193    /// # Errors
194    ///
195    /// Returns an error if the assertion fails.
196    #[test]
197    fn creator_name_prefers_display() -> Result<()> {
198        let playlist = Playlist {
199            id: None,
200            name: None,
201            description: None,
202            tracks_count: None,
203            duration: None,
204            is_public: None,
205            creator: Some(User {
206                id: None,
207                credential: None,
208                subscription: None,
209                display_name: Some("Creator".to_string()),
210            }),
211            owner: Some(PlaylistOwner {
212                id: None,
213                name: Some("Owner".to_string()),
214            }),
215            image: None,
216            image_rectangle: None,
217            image_rectangle_mini: None,
218            images: None,
219            images150: None,
220            images300: None,
221            tracks: None,
222            created_at: None,
223            updated_at: None,
224        };
225        ensure!(
226            playlist.creator_name() == Some("Creator"),
227            "should prefer creator"
228        );
229        Ok(())
230    }
231
232    /// Tests best image URL prefers rectangle banner.
233    ///
234    /// # Errors
235    ///
236    /// Returns an error if the assertion fails.
237    #[test]
238    fn best_image_url_prefers_banner() -> Result<()> {
239        let playlist = Playlist {
240            id: None,
241            name: None,
242            description: None,
243            tracks_count: None,
244            duration: None,
245            is_public: None,
246            creator: None,
247            owner: None,
248            image: None,
249            image_rectangle: Some(vec!["large.jpg".to_string()]),
250            image_rectangle_mini: Some(vec!["mini.jpg".to_string()]),
251            images: Some(vec!["small.jpg".to_string()]),
252            images150: None,
253            images300: None,
254            tracks: None,
255            created_at: None,
256            updated_at: None,
257        };
258        ensure!(
259            playlist.best_image_url(true).as_deref() == Some("large.jpg"),
260            "large should prefer banner"
261        );
262        ensure!(
263            playlist.best_image_url(false).as_deref() == Some("mini.jpg"),
264            "thumbnail should prefer mini"
265        );
266        Ok(())
267    }
268
269    /// Tests search playlists deserializes results.
270    ///
271    /// # Errors
272    ///
273    /// Returns an error if the test setup or assertion fails.
274    #[test]
275    fn search_playlists_deserializes_results() -> Result<()> {
276        setup_test!(
277            200,
278            r#"{"playlists":{"items":[{"id":"pl1","name":"Jazz Mix"}],"total":1}}"#,
279            server,
280            service,
281            rt
282        );
283        let result = rt.block_on(search_playlists(&service, "Jazz", Some(5), None))?;
284        let items = result.items.ok_or_else(|| anyhow!("no items"))?;
285        ensure!(items.len() == 1);
286        ensure!(items.first().and_then(|i| i.name.as_deref()) == Some("Jazz Mix"));
287        Ok(())
288    }
289
290    /// Tests search playlists empty results.
291    ///
292    /// # Errors
293    ///
294    /// Returns an error if the test setup or assertion fails.
295    #[test]
296    fn search_playlists_empty_results() -> Result<()> {
297        assert_empty_search_test!(
298            search_playlists,
299            "Nothing",
300            r#"{"playlists":{"items":[],"total":0}}"#
301        );
302        Ok(())
303    }
304
305    /// Tests get playlist by id.
306    ///
307    /// # Errors
308    ///
309    /// Returns an error if the test setup or assertion fails.
310    #[test]
311    fn get_playlist_by_id() -> Result<()> {
312        setup_test!(
313            200,
314            r#"{"id":"pl42","name":"My Playlist","tracks_count":10}"#,
315            server,
316            service,
317            rt
318        );
319        let playlist = rt.block_on(get_playlist(&service, "pl42", None))?;
320        ensure!(playlist.name.as_deref() == Some("My Playlist"));
321        let playlist = rt.block_on(get_playlist(&service, "pl42", Some("tracks")))?;
322        ensure!(playlist.name.as_deref() == Some("My Playlist"));
323        ensure!(playlist.tracks_count == Some(10));
324        Ok(())
325    }
326
327    /// Tests search playlists error response.
328    ///
329    /// # Errors
330    ///
331    /// Returns an error if the test setup or assertion fails.
332    #[test]
333    fn search_playlists_error_response() -> Result<()> {
334        setup_test!(
335            401,
336            r#"{"status":"error","code":401,"message":"Unauthorized"}"#,
337            server,
338            service,
339            rt
340        );
341        let result = rt.block_on(search_playlists(&service, "fail", None, None));
342        ensure!(result.is_err());
343        Ok(())
344    }
345
346    /// Tests get playlist not found.
347    ///
348    /// # Errors
349    ///
350    /// Returns an error if the test setup or assertion fails.
351    #[test]
352    fn get_playlist_not_found() -> Result<()> {
353        setup_test!(
354            404,
355            r#"{"status":"error","code":404,"message":"Playlist not found"}"#,
356            server,
357            service,
358            rt
359        );
360        let result = rt.block_on(get_playlist(&service, "nonexistent", None));
361        ensure!(result.is_err());
362        Ok(())
363    }
364}