1use 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#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
22pub struct Playlist {
23 #[serde(default, deserialize_with = "deserialize_flexible_string_id")]
25 pub id: Option<String>,
26 pub name: Option<String>,
28 pub description: Option<String>,
30 pub tracks_count: Option<i32>,
32 pub duration: Option<i32>,
34 pub is_public: Option<bool>,
36 pub creator: Option<User>,
38 pub owner: Option<PlaylistOwner>,
40 pub image: Option<Image>,
42 pub image_rectangle: Option<Vec<String>>,
44 pub image_rectangle_mini: Option<Vec<String>>,
46 pub images: Option<Vec<String>>,
48 pub images150: Option<Vec<String>>,
50 pub images300: Option<Vec<String>>,
52 pub tracks: Option<ItemSearchResult<Box<Track>>>,
54 pub created_at: Option<i64>,
56 pub updated_at: Option<i64>,
58}
59
60impl Playlist {
61 #[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 #[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#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
110pub struct PlaylistOwner {
111 pub id: Option<i32>,
113 pub name: Option<String>,
115}
116
117fn first_img_url(v: Option<&Vec<String>>) -> Option<String> {
123 v?.first().cloned()
124}
125
126pub 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
153pub 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}