use std::cmp::Ordering;
use std::collections::HashMap;
use std::fmt;
use serde::{Deserialize, Serialize};
use crate::types::utils::{bool_from_str, u32_from_str};
#[derive(Serialize, Deserialize, Debug, Clone)]
#[non_exhaustive]
pub struct BaseMbidText {
pub mbid: String,
#[serde(rename = "#text")]
pub text: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[non_exhaustive]
pub struct BaseObject {
pub mbid: String,
#[serde(default)]
pub url: String,
#[serde(alias = "#text")]
pub name: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[non_exhaustive]
pub struct TrackImage {
pub size: String,
#[serde(rename = "#text")]
pub text: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[non_exhaustive]
pub struct Streamable {
pub fulltrack: String,
#[serde(rename = "#text")]
pub text: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[non_exhaustive]
pub struct Artist {
pub name: String,
pub mbid: String,
#[serde(default)]
pub url: String,
pub image: Vec<TrackImage>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[non_exhaustive]
pub struct Date {
#[serde(deserialize_with = "u32_from_str")]
pub uts: u32,
#[serde(rename = "#text")]
pub text: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[non_exhaustive]
pub struct Attributes {
pub nowplaying: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[non_exhaustive]
pub struct RankAttr {
pub rank: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[non_exhaustive]
pub struct RecentTrack {
pub artist: BaseMbidText,
#[serde(deserialize_with = "bool_from_str")]
pub streamable: bool,
pub image: Vec<TrackImage>,
pub album: BaseMbidText,
#[serde(rename = "@attr")]
pub attr: Option<Attributes>,
pub date: Option<Date>,
pub name: String,
pub mbid: String,
pub url: String,
}
impl fmt::Display for RecentTrack {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let status = if self.attr.is_some() {
" [NOW PLAYING]"
} else {
""
};
let date_str = self
.date
.as_ref()
.map_or(String::new(), |d| format!(" ({})", d.text));
write!(
f,
"{} - {} [{}]{date_str}{status}",
self.name, self.artist.text, self.album.text
)
}
}
impl PartialEq for RecentTrack {
fn eq(&self, other: &Self) -> bool {
self.date.as_ref().map(|d| d.uts) == other.date.as_ref().map(|d| d.uts)
}
}
impl Eq for RecentTrack {}
impl PartialOrd for RecentTrack {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for RecentTrack {
fn cmp(&self, other: &Self) -> Ordering {
match (self.date.as_ref(), other.date.as_ref()) {
(None, None) => Ordering::Equal,
(None, Some(_)) => Ordering::Greater,
(Some(_), None) => Ordering::Less,
(Some(a), Some(b)) => a.uts.cmp(&b.uts),
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[non_exhaustive]
pub struct RecentTrackExtended {
pub artist: BaseObject,
#[serde(deserialize_with = "bool_from_str")]
pub streamable: bool,
pub image: Vec<TrackImage>,
pub album: BaseObject,
#[serde(rename = "@attr")]
pub attr: Option<HashMap<String, String>>,
pub date: Option<Date>,
pub name: String,
pub mbid: String,
#[serde(default)]
pub url: String,
}
impl fmt::Display for RecentTrackExtended {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let is_now_playing = self
.attr
.as_ref()
.and_then(|a| a.get("nowplaying"))
.is_some_and(|v| v == "true");
let status = if is_now_playing { " [NOW PLAYING]" } else { "" };
let date_str = self
.date
.as_ref()
.map_or(String::new(), |d| format!(" ({})", d.text));
write!(
f,
"{} - {} [{}]{date_str}{status}",
self.name, self.artist.name, self.album.name
)
}
}
impl PartialEq for RecentTrackExtended {
fn eq(&self, other: &Self) -> bool {
self.date.as_ref().map(|d| d.uts) == other.date.as_ref().map(|d| d.uts)
}
}
impl Eq for RecentTrackExtended {}
impl PartialOrd for RecentTrackExtended {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for RecentTrackExtended {
fn cmp(&self, other: &Self) -> Ordering {
match (self.date.as_ref(), other.date.as_ref()) {
(None, None) => Ordering::Equal,
(None, Some(_)) => Ordering::Greater,
(Some(_), None) => Ordering::Less,
(Some(a), Some(b)) => a.uts.cmp(&b.uts),
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[non_exhaustive]
pub struct LovedTrack {
pub artist: BaseObject,
pub date: Date,
pub image: Vec<TrackImage>,
pub streamable: Streamable,
pub name: String,
pub mbid: String,
pub url: String,
}
impl fmt::Display for LovedTrack {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} - {} (loved {})",
self.name, self.artist.name, self.date.text
)
}
}
impl PartialEq for LovedTrack {
fn eq(&self, other: &Self) -> bool {
self.date.uts == other.date.uts
}
}
impl Eq for LovedTrack {}
impl PartialOrd for LovedTrack {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for LovedTrack {
fn cmp(&self, other: &Self) -> Ordering {
self.date.uts.cmp(&other.date.uts)
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[non_exhaustive]
pub struct TopTrack {
pub streamable: Streamable,
pub mbid: String,
pub name: String,
pub image: Vec<TrackImage>,
pub artist: BaseObject,
pub url: String,
#[serde(deserialize_with = "u32_from_str")]
pub duration: u32,
#[serde(rename = "@attr")]
pub attr: RankAttr,
#[serde(deserialize_with = "u32_from_str")]
pub playcount: u32,
}
impl fmt::Display for TopTrack {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"#{} - {} by {} ({} plays)",
self.attr.rank, self.name, self.artist.name, self.playcount
)
}
}
impl PartialEq for TopTrack {
fn eq(&self, other: &Self) -> bool {
self.playcount == other.playcount
}
}
impl Eq for TopTrack {}
impl PartialOrd for TopTrack {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for TopTrack {
fn cmp(&self, other: &Self) -> Ordering {
self.playcount.cmp(&other.playcount)
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[non_exhaustive]
pub struct BaseResponse {
pub user: String,
#[serde(deserialize_with = "u32_from_str", rename = "totalPages")]
pub total_pages: u32,
#[serde(deserialize_with = "u32_from_str")]
pub page: u32,
#[serde(deserialize_with = "u32_from_str", rename = "perPage")]
pub per_page: u32,
#[serde(deserialize_with = "u32_from_str")]
pub total: u32,
}
#[derive(Serialize, Deserialize, Debug)]
#[non_exhaustive]
pub struct RecentTracks {
pub track: Vec<RecentTrack>,
#[serde(rename = "@attr")]
pub attr: BaseResponse,
}
#[derive(Serialize, Deserialize, Debug)]
#[non_exhaustive]
pub struct UserRecentTracks {
pub recenttracks: RecentTracks,
}
#[derive(Serialize, Deserialize, Debug)]
#[non_exhaustive]
pub struct RecentTracksExtended {
pub track: Vec<RecentTrackExtended>,
#[serde(rename = "@attr")]
pub attr: BaseResponse,
}
#[derive(Serialize, Deserialize, Debug)]
#[non_exhaustive]
pub struct UserRecentTracksExtended {
pub recenttracks: RecentTracksExtended,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[non_exhaustive]
pub struct LovedTracks {
pub track: Vec<LovedTrack>,
#[serde(rename = "@attr")]
pub attr: BaseResponse,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[non_exhaustive]
pub struct UserLovedTracks {
pub lovedtracks: LovedTracks,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[non_exhaustive]
pub struct TopTracks {
pub track: Vec<TopTrack>,
#[serde(rename = "@attr")]
pub attr: BaseResponse,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[non_exhaustive]
pub struct UserTopTracks {
pub toptracks: TopTracks,
}
#[derive(Debug, Serialize)]
#[non_exhaustive]
pub struct TrackPlayInfo {
pub name: String,
pub play_count: u32,
pub artist: String,
pub album: Option<String>,
pub image_url: Option<String>,
pub currently_playing: bool,
pub date: Option<u32>,
pub url: String,
}
pub trait Timestamped {
fn get_timestamp(&self) -> Option<u32>;
}
impl Timestamped for RecentTrack {
fn get_timestamp(&self) -> Option<u32> {
self.date.as_ref().map(|d| d.uts)
}
}
impl Timestamped for LovedTrack {
fn get_timestamp(&self) -> Option<u32> {
Some(self.date.uts)
}
}
impl Timestamped for RecentTrackExtended {
fn get_timestamp(&self) -> Option<u32> {
self.date.as_ref().map(|d| d.uts)
}
}
#[cfg(feature = "sqlite")]
impl crate::sqlite::SqliteExportable for RecentTrack {
fn table_name() -> &'static str {
"recent_tracks"
}
fn create_table_sql() -> &'static str {
"CREATE TABLE IF NOT EXISTS recent_tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL,
artist TEXT NOT NULL,
artist_mbid TEXT NOT NULL,
album TEXT NOT NULL,
album_mbid TEXT NOT NULL,
date_uts INTEGER,
loved INTEGER NOT NULL DEFAULT 0
)"
}
fn insert_sql() -> &'static str {
"INSERT INTO recent_tracks (name, url, artist, artist_mbid, album, album_mbid, date_uts, loved)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"
}
fn bind_and_execute(&self, stmt: &mut rusqlite::Statement<'_>) -> rusqlite::Result<usize> {
stmt.execute(rusqlite::params![
self.name,
self.url,
self.artist.text,
self.artist.mbid,
self.album.text,
self.album.mbid,
self.date.as_ref().map(|d| d.uts),
0_i32,
])
}
}
#[cfg(feature = "sqlite")]
impl crate::sqlite::SqliteExportable for RecentTrackExtended {
fn table_name() -> &'static str {
"recent_tracks_extended"
}
fn create_table_sql() -> &'static str {
"CREATE TABLE IF NOT EXISTS recent_tracks_extended (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL,
mbid TEXT NOT NULL,
artist TEXT NOT NULL,
artist_mbid TEXT NOT NULL,
artist_url TEXT NOT NULL,
album TEXT NOT NULL,
album_mbid TEXT NOT NULL,
album_url TEXT NOT NULL,
date_uts INTEGER,
loved INTEGER NOT NULL DEFAULT 0
)"
}
fn insert_sql() -> &'static str {
"INSERT INTO recent_tracks_extended
(name, url, mbid, artist, artist_mbid, artist_url, album, album_mbid, album_url, date_uts, loved)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)"
}
fn bind_and_execute(&self, stmt: &mut rusqlite::Statement<'_>) -> rusqlite::Result<usize> {
stmt.execute(rusqlite::params![
self.name,
self.url,
self.mbid,
self.artist.name,
self.artist.mbid,
self.artist.url,
self.album.name,
self.album.mbid,
self.album.url,
self.date.as_ref().map(|d| d.uts),
0_i32,
])
}
}
#[cfg(feature = "sqlite")]
impl crate::sqlite::SqliteExportable for LovedTrack {
fn table_name() -> &'static str {
"loved_tracks"
}
fn create_table_sql() -> &'static str {
"CREATE TABLE IF NOT EXISTS loved_tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL,
artist TEXT NOT NULL,
artist_mbid TEXT NOT NULL,
date_uts INTEGER NOT NULL
)"
}
fn insert_sql() -> &'static str {
"INSERT INTO loved_tracks (name, url, artist, artist_mbid, date_uts)
VALUES (?1, ?2, ?3, ?4, ?5)"
}
fn bind_and_execute(&self, stmt: &mut rusqlite::Statement<'_>) -> rusqlite::Result<usize> {
stmt.execute(rusqlite::params![
self.name,
self.url,
self.artist.name,
self.artist.mbid,
self.date.uts,
])
}
}
#[cfg(feature = "sqlite")]
impl crate::sqlite::SqliteExportable for TopTrack {
fn table_name() -> &'static str {
"top_tracks"
}
fn create_table_sql() -> &'static str {
"CREATE TABLE IF NOT EXISTS top_tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL,
artist TEXT NOT NULL,
mbid TEXT NOT NULL,
playcount INTEGER NOT NULL,
rank INTEGER NOT NULL
)"
}
fn insert_sql() -> &'static str {
"INSERT INTO top_tracks (name, url, artist, mbid, playcount, rank)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
}
fn bind_and_execute(&self, stmt: &mut rusqlite::Statement<'_>) -> rusqlite::Result<usize> {
let rank: u32 = self.attr.rank.parse().unwrap_or_default();
stmt.execute(rusqlite::params![
self.name,
self.url,
self.artist.name,
self.mbid,
self.playcount,
rank,
])
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_date_deserialization() {
use serde_json::json;
let json_value = json!({
"uts": "1_234_567_890",
"#text": "2009-02-13 23:31:30"
});
let date: Date = serde_json::from_value(json_value).unwrap();
assert_eq!(date.uts, 1_234_567_890);
assert_eq!(date.text, "2009-02-13 23:31:30");
}
#[test]
fn test_bool_from_str() {
use serde_json::json;
let json_value = json!({
"artist": {"mbid": "", "#text": "Test"},
"streamable": "1",
"image": [],
"album": {"mbid": "", "#text": ""},
"name": "Test",
"mbid": "",
"url": ""
});
let track: RecentTrack = serde_json::from_value(json_value).unwrap();
assert!(track.streamable);
}
#[test]
fn test_timestamped_trait() {
let track = RecentTrack {
artist: BaseMbidText {
mbid: String::new(),
text: "Artist".to_string(),
},
streamable: false,
image: vec![],
album: BaseMbidText {
mbid: String::new(),
text: String::new(),
},
attr: None,
date: Some(Date {
uts: 1_234_567_890,
text: "test".to_string(),
}),
name: "Track".to_string(),
mbid: String::new(),
url: String::new(),
};
assert_eq!(track.get_timestamp(), Some(1_234_567_890));
}
}