use serde::{Deserialize, Serialize};
use crate::{
api::{
auth::User,
content::{
albums::Image,
catalog::{ItemSearchResult, PlaylistSearchResponse},
get_by_id, search,
tracks::Track,
},
response::deserialize_flexible_string_id,
service::QobuzApiService,
},
errors::QobuzApiError,
};
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Playlist {
#[serde(default, deserialize_with = "deserialize_flexible_string_id")]
pub id: Option<String>,
pub name: Option<String>,
pub description: Option<String>,
pub tracks_count: Option<i32>,
pub duration: Option<i32>,
pub is_public: Option<bool>,
pub creator: Option<User>,
pub owner: Option<PlaylistOwner>,
pub image: Option<Image>,
pub image_rectangle: Option<Vec<String>>,
pub image_rectangle_mini: Option<Vec<String>>,
pub images: Option<Vec<String>>,
pub images150: Option<Vec<String>>,
pub images300: Option<Vec<String>>,
pub tracks: Option<ItemSearchResult<Box<Track>>>,
pub created_at: Option<i64>,
pub updated_at: Option<i64>,
}
impl Playlist {
#[must_use]
pub fn creator_name(&self) -> Option<&str> {
self.creator
.as_ref()
.and_then(|c| c.display_name.as_deref())
.or_else(|| {
let o = self.owner.as_ref()?;
o.name.as_deref()
})
}
#[must_use]
pub fn best_image_url(&self, large: bool) -> Option<String> {
if large {
first_img_url(self.image_rectangle.as_ref())
.or_else(|| first_img_url(self.images300.as_ref()))
.or_else(|| first_img_url(self.images150.as_ref()))
.or_else(|| first_img_url(self.images.as_ref()))
} else {
first_img_url(self.image_rectangle_mini.as_ref())
.or_else(|| first_img_url(self.image_rectangle.as_ref()))
.or_else(|| first_img_url(self.images150.as_ref()))
.or_else(|| first_img_url(self.images.as_ref()))
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct PlaylistOwner {
pub id: Option<i32>,
pub name: Option<String>,
}
fn first_img_url(v: Option<&Vec<String>>) -> Option<String> {
v?.first().cloned()
}
pub async fn search_playlists(
service: &QobuzApiService,
query: &str,
limit: Option<i32>,
offset: Option<i32>,
) -> Result<ItemSearchResult<Box<Playlist>>, QobuzApiError> {
let resp: PlaylistSearchResponse =
search(service, "/playlist/search", query, limit, offset).await?;
Ok(resp.playlists)
}
pub async fn get_playlist(
service: &QobuzApiService,
playlist_id: &str,
extra: Option<&str>,
) -> Result<Playlist, QobuzApiError> {
get_by_id(service, "/playlist/get", "playlist_id", playlist_id, extra).await
}
#[cfg(test)]
mod tests {
use {
anyhow::{Result, anyhow, ensure},
tokio::runtime::Runtime,
};
use crate::{
api::{
auth::User,
content::playlists::{Playlist, PlaylistOwner, get_playlist, search_playlists},
},
assert_empty_search_test, setup_test,
};
#[test]
fn creator_name_prefers_display() -> Result<()> {
let playlist = Playlist {
id: None,
name: None,
description: None,
tracks_count: None,
duration: None,
is_public: None,
creator: Some(User {
id: None,
credential: None,
subscription: None,
display_name: Some("Creator".to_string()),
}),
owner: Some(PlaylistOwner {
id: None,
name: Some("Owner".to_string()),
}),
image: None,
image_rectangle: None,
image_rectangle_mini: None,
images: None,
images150: None,
images300: None,
tracks: None,
created_at: None,
updated_at: None,
};
ensure!(
playlist.creator_name() == Some("Creator"),
"should prefer creator"
);
Ok(())
}
#[test]
fn best_image_url_prefers_banner() -> Result<()> {
let playlist = Playlist {
id: None,
name: None,
description: None,
tracks_count: None,
duration: None,
is_public: None,
creator: None,
owner: None,
image: None,
image_rectangle: Some(vec!["large.jpg".to_string()]),
image_rectangle_mini: Some(vec!["mini.jpg".to_string()]),
images: Some(vec!["small.jpg".to_string()]),
images150: None,
images300: None,
tracks: None,
created_at: None,
updated_at: None,
};
ensure!(
playlist.best_image_url(true).as_deref() == Some("large.jpg"),
"large should prefer banner"
);
ensure!(
playlist.best_image_url(false).as_deref() == Some("mini.jpg"),
"thumbnail should prefer mini"
);
Ok(())
}
#[test]
fn search_playlists_deserializes_results() -> Result<()> {
setup_test!(
200,
r#"{"playlists":{"items":[{"id":"pl1","name":"Jazz Mix"}],"total":1}}"#,
server,
service,
rt
);
let result = rt.block_on(search_playlists(&service, "Jazz", Some(5), None))?;
let items = result.items.ok_or_else(|| anyhow!("no items"))?;
ensure!(items.len() == 1);
ensure!(items.first().and_then(|i| i.name.as_deref()) == Some("Jazz Mix"));
Ok(())
}
#[test]
fn search_playlists_empty_results() -> Result<()> {
assert_empty_search_test!(
search_playlists,
"Nothing",
r#"{"playlists":{"items":[],"total":0}}"#
);
Ok(())
}
#[test]
fn get_playlist_by_id() -> Result<()> {
setup_test!(
200,
r#"{"id":"pl42","name":"My Playlist","tracks_count":10}"#,
server,
service,
rt
);
let playlist = rt.block_on(get_playlist(&service, "pl42", None))?;
ensure!(playlist.name.as_deref() == Some("My Playlist"));
let playlist = rt.block_on(get_playlist(&service, "pl42", Some("tracks")))?;
ensure!(playlist.name.as_deref() == Some("My Playlist"));
ensure!(playlist.tracks_count == Some(10));
Ok(())
}
#[test]
fn search_playlists_error_response() -> Result<()> {
setup_test!(
401,
r#"{"status":"error","code":401,"message":"Unauthorized"}"#,
server,
service,
rt
);
let result = rt.block_on(search_playlists(&service, "fail", None, None));
ensure!(result.is_err());
Ok(())
}
#[test]
fn get_playlist_not_found() -> Result<()> {
setup_test!(
404,
r#"{"status":"error","code":404,"message":"Playlist not found"}"#,
server,
service,
rt
);
let result = rt.block_on(get_playlist(&service, "nonexistent", None));
ensure!(result.is_err());
Ok(())
}
}