use std::cmp::Ordering;
use std::collections::{BTreeMap, HashMap};
use std::fmt;
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use crate::types::utils::{bool_from_str, u32_from_str};
use super::track_list::TrackList;
#[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(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ScoredTrack {
pub name: String,
pub artist: String,
pub artist_mbid: String,
pub album: String,
pub mbid: String,
pub url: String,
pub image: Vec<TrackImage>,
pub play_count: u32,
pub rank: u32,
}
impl fmt::Display for ScoredTrack {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"#{} {} - {} ({} play{})",
self.rank,
self.name,
self.artist,
self.play_count,
if self.play_count == 1 { "" } else { "s" }
)
}
}
impl PartialEq for ScoredTrack {
fn eq(&self, other: &Self) -> bool {
self.play_count == other.play_count
}
}
impl Eq for ScoredTrack {}
impl PartialOrd for ScoredTrack {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for ScoredTrack {
fn cmp(&self, other: &Self) -> Ordering {
self.play_count.cmp(&other.play_count)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ScoredArtist {
pub name: String,
pub mbid: String,
pub play_count: u32,
pub rank: u32,
}
impl fmt::Display for ScoredArtist {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"#{} {} ({} play{})",
self.rank,
self.name,
self.play_count,
if self.play_count == 1 { "" } else { "s" }
)
}
}
impl PartialEq for ScoredArtist {
fn eq(&self, other: &Self) -> bool {
self.play_count == other.play_count
}
}
impl Eq for ScoredArtist {}
impl PartialOrd for ScoredArtist {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for ScoredArtist {
fn cmp(&self, other: &Self) -> Ordering {
self.play_count.cmp(&other.play_count)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ScoredAlbum {
pub name: String,
pub mbid: String,
pub artist: String,
pub play_count: u32,
pub rank: u32,
}
impl fmt::Display for ScoredAlbum {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"#{} {} — {} ({} play{})",
self.rank,
self.name,
self.artist,
self.play_count,
if self.play_count == 1 { "" } else { "s" }
)
}
}
impl PartialEq for ScoredAlbum {
fn eq(&self, other: &Self) -> bool {
self.play_count == other.play_count
}
}
impl Eq for ScoredAlbum {}
impl PartialOrd for ScoredAlbum {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for ScoredAlbum {
fn cmp(&self, other: &Self) -> Ordering {
self.play_count.cmp(&other.play_count)
}
}
#[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)
}
}
impl TrackList<RecentTrack> {
#[must_use]
pub fn to_set(&self) -> TrackList<ScoredTrack> {
let mut groups: HashMap<(String, String), (RecentTrack, u32)> = HashMap::new();
for track in self {
let key = (track.name.clone(), track.artist.text.clone());
let entry = groups.entry(key).or_insert_with(|| (track.clone(), 0));
entry.1 += 1;
}
let mut scored: Vec<ScoredTrack> = groups
.into_values()
.map(|(rep, play_count)| ScoredTrack {
name: rep.name,
artist: rep.artist.text,
artist_mbid: rep.artist.mbid,
album: rep.album.text,
mbid: rep.mbid,
url: rep.url,
image: rep.image,
play_count,
rank: 0,
})
.collect();
scored.sort_unstable_by(|a, b| b.play_count.cmp(&a.play_count));
for (i, track) in scored.iter_mut().enumerate() {
track.rank = u32::try_from(i).map_or(u32::MAX, |n| n.saturating_add(1));
}
TrackList::from(scored)
}
#[must_use]
pub fn top_artists(&self) -> TrackList<ScoredArtist> {
let mut groups: HashMap<(String, String), u32> = HashMap::new();
for track in self {
let key = (track.artist.text.clone(), track.artist.mbid.clone());
*groups.entry(key).or_insert(0) += 1;
}
let mut scored: Vec<ScoredArtist> = groups
.into_iter()
.map(|((name, mbid), play_count)| ScoredArtist {
name,
mbid,
play_count,
rank: 0,
})
.collect();
scored.sort_unstable_by(|a, b| b.play_count.cmp(&a.play_count));
for (i, artist) in scored.iter_mut().enumerate() {
artist.rank = u32::try_from(i).map_or(u32::MAX, |n| n.saturating_add(1));
}
TrackList::from(scored)
}
#[must_use]
pub fn top_albums(&self) -> TrackList<ScoredAlbum> {
let mut groups: HashMap<(String, String, String), u32> = HashMap::new();
for track in self {
if track.album.text.is_empty() {
continue;
}
let key = (
track.album.text.clone(),
track.album.mbid.clone(),
track.artist.text.clone(),
);
*groups.entry(key).or_insert(0) += 1;
}
let mut scored: Vec<ScoredAlbum> = groups
.into_iter()
.map(|((name, mbid, artist), play_count)| ScoredAlbum {
name,
mbid,
artist,
play_count,
rank: 0,
})
.collect();
scored.sort_unstable_by(|a, b| b.play_count.cmp(&a.play_count));
for (i, album) in scored.iter_mut().enumerate() {
album.rank = u32::try_from(i).map_or(u32::MAX, |n| n.saturating_add(1));
}
TrackList::from(scored)
}
#[must_use]
pub fn by_hour(&self) -> [u32; 24] {
let mut counts = [0u32; 24];
for track in self {
if let Some(date) = &track.date {
let hour = usize::try_from(date.uts % 86_400 / 3600).unwrap_or(0);
counts[hour] = counts[hour].saturating_add(1);
}
}
counts
}
#[must_use]
pub fn by_date(&self) -> BTreeMap<NaiveDate, u32> {
let mut counts: BTreeMap<NaiveDate, u32> = BTreeMap::new();
for track in self {
if let Some(date) = &track.date
&& let Some(dt) = DateTime::<Utc>::from_timestamp(i64::from(date.uts), 0)
{
*counts.entry(dt.date_naive()).or_insert(0) += 1;
}
}
counts
}
#[must_use]
pub fn streak(&self) -> u32 {
let dates = self.by_date();
if dates.is_empty() {
return 0;
}
let sorted: Vec<NaiveDate> = dates.into_keys().collect();
let mut max_streak = 1u32;
let mut current = 1u32;
for window in sorted.windows(2) {
if let [prev, next] = window {
if next.signed_duration_since(*prev).num_days() == 1 {
current += 1;
if current > max_streak {
max_streak = current;
}
} else {
current = 1;
}
}
}
max_streak
}
#[must_use]
pub fn without_now_playing(&self) -> Self {
self.iter()
.filter(|t| t.attr.as_ref().is_none_or(|a| a.nowplaying != "true"))
.cloned()
.collect()
}
#[must_use]
pub fn unique_artist_count(&self) -> usize {
self.iter()
.map(|t| &t.artist.text)
.collect::<std::collections::HashSet<_>>()
.len()
}
#[must_use]
pub fn unique_track_count(&self) -> usize {
self.iter()
.map(|t| (&t.name, &t.artist.text))
.collect::<std::collections::HashSet<_>>()
.len()
}
}
impl TrackList<RecentTrackExtended> {
#[must_use]
pub fn to_set(&self) -> TrackList<ScoredTrack> {
let mut groups: HashMap<(String, String), (RecentTrackExtended, u32)> = HashMap::new();
for track in self {
let key = (track.name.clone(), track.artist.name.clone());
let entry = groups.entry(key).or_insert_with(|| (track.clone(), 0));
entry.1 += 1;
}
let mut scored: Vec<ScoredTrack> = groups
.into_values()
.map(|(rep, play_count)| ScoredTrack {
name: rep.name,
artist: rep.artist.name,
artist_mbid: rep.artist.mbid,
album: rep.album.name,
mbid: rep.mbid,
url: rep.url,
image: rep.image,
play_count,
rank: 0,
})
.collect();
scored.sort_unstable_by(|a, b| b.play_count.cmp(&a.play_count));
for (i, track) in scored.iter_mut().enumerate() {
track.rank = u32::try_from(i).map_or(u32::MAX, |n| n.saturating_add(1));
}
TrackList::from(scored)
}
#[must_use]
pub fn top_artists(&self) -> TrackList<ScoredArtist> {
let mut groups: HashMap<(String, String), u32> = HashMap::new();
for track in self {
let key = (track.artist.name.clone(), track.artist.mbid.clone());
*groups.entry(key).or_insert(0) += 1;
}
let mut scored: Vec<ScoredArtist> = groups
.into_iter()
.map(|((name, mbid), play_count)| ScoredArtist {
name,
mbid,
play_count,
rank: 0,
})
.collect();
scored.sort_unstable_by(|a, b| b.play_count.cmp(&a.play_count));
for (i, artist) in scored.iter_mut().enumerate() {
artist.rank = u32::try_from(i).map_or(u32::MAX, |n| n.saturating_add(1));
}
TrackList::from(scored)
}
#[must_use]
pub fn top_albums(&self) -> TrackList<ScoredAlbum> {
let mut groups: HashMap<(String, String, String), u32> = HashMap::new();
for track in self {
if track.album.name.is_empty() {
continue;
}
let key = (
track.album.name.clone(),
track.album.mbid.clone(),
track.artist.name.clone(),
);
*groups.entry(key).or_insert(0) += 1;
}
let mut scored: Vec<ScoredAlbum> = groups
.into_iter()
.map(|((name, mbid, artist), play_count)| ScoredAlbum {
name,
mbid,
artist,
play_count,
rank: 0,
})
.collect();
scored.sort_unstable_by(|a, b| b.play_count.cmp(&a.play_count));
for (i, album) in scored.iter_mut().enumerate() {
album.rank = u32::try_from(i).map_or(u32::MAX, |n| n.saturating_add(1));
}
TrackList::from(scored)
}
#[must_use]
pub fn by_hour(&self) -> [u32; 24] {
let mut counts = [0u32; 24];
for track in self {
if let Some(date) = &track.date {
let hour = usize::try_from(date.uts % 86_400 / 3600).unwrap_or(0);
counts[hour] = counts[hour].saturating_add(1);
}
}
counts
}
#[must_use]
pub fn by_date(&self) -> BTreeMap<NaiveDate, u32> {
let mut counts: BTreeMap<NaiveDate, u32> = BTreeMap::new();
for track in self {
if let Some(date) = &track.date
&& let Some(dt) = DateTime::<Utc>::from_timestamp(i64::from(date.uts), 0)
{
*counts.entry(dt.date_naive()).or_insert(0) += 1;
}
}
counts
}
#[must_use]
pub fn streak(&self) -> u32 {
let dates = self.by_date();
if dates.is_empty() {
return 0;
}
let sorted: Vec<NaiveDate> = dates.into_keys().collect();
let mut max_streak = 1u32;
let mut current = 1u32;
for window in sorted.windows(2) {
if let [prev, next] = window {
if next.signed_duration_since(*prev).num_days() == 1 {
current += 1;
if current > max_streak {
max_streak = current;
}
} else {
current = 1;
}
}
}
max_streak
}
#[must_use]
pub fn without_now_playing(&self) -> Self {
self.iter()
.filter(|t| {
t.attr
.as_ref()
.is_none_or(|a| a.get("nowplaying").is_none_or(|v| v != "true"))
})
.cloned()
.collect()
}
#[must_use]
pub fn unique_artist_count(&self) -> usize {
self.iter()
.map(|t| &t.artist.name)
.collect::<std::collections::HashSet<_>>()
.len()
}
#[must_use]
pub fn unique_track_count(&self) -> usize {
self.iter()
.map(|t| (&t.name, &t.artist.name))
.collect::<std::collections::HashSet<_>>()
.len()
}
}
#[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(feature = "sqlite")]
impl crate::sqlite::SqliteLoadable for RecentTrack {
fn select_sql() -> &'static str {
"SELECT name, url, artist, artist_mbid, album, album_mbid, date_uts
FROM recent_tracks
ORDER BY CASE WHEN date_uts IS NULL THEN 0 ELSE 1 END, date_uts DESC"
}
fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
let date_uts: Option<u32> = row.get(6)?;
Ok(Self {
name: row.get(0)?,
url: row.get(1)?,
artist: BaseMbidText {
text: row.get(2)?,
mbid: row.get(3)?,
},
album: BaseMbidText {
text: row.get(4)?,
mbid: row.get(5)?,
},
date: date_uts.map(|uts| Date {
uts,
text: String::new(),
}),
mbid: String::new(),
streamable: false,
image: vec![],
attr: None,
})
}
}
#[cfg(feature = "sqlite")]
impl crate::sqlite::SqliteLoadable for RecentTrackExtended {
fn select_sql() -> &'static str {
"SELECT name, url, mbid, artist, artist_mbid, artist_url,
album, album_mbid, album_url, date_uts
FROM recent_tracks_extended
ORDER BY CASE WHEN date_uts IS NULL THEN 0 ELSE 1 END, date_uts DESC"
}
fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
let date_uts: Option<u32> = row.get(9)?;
Ok(Self {
name: row.get(0)?,
url: row.get(1)?,
mbid: row.get(2)?,
artist: BaseObject {
name: row.get(3)?,
mbid: row.get(4)?,
url: row.get(5)?,
},
album: BaseObject {
name: row.get(6)?,
mbid: row.get(7)?,
url: row.get(8)?,
},
date: date_uts.map(|uts| Date {
uts,
text: String::new(),
}),
streamable: false,
image: vec![],
attr: None,
})
}
}
#[cfg(feature = "sqlite")]
impl crate::sqlite::SqliteLoadable for LovedTrack {
fn select_sql() -> &'static str {
"SELECT name, url, artist, artist_mbid, date_uts
FROM loved_tracks
ORDER BY date_uts DESC"
}
fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
Ok(Self {
name: row.get(0)?,
url: row.get(1)?,
artist: BaseObject {
name: row.get(2)?,
mbid: row.get(3)?,
url: String::new(),
},
date: Date {
uts: row.get(4)?,
text: String::new(),
},
mbid: String::new(),
image: vec![],
streamable: Streamable {
fulltrack: String::new(),
text: String::new(),
},
})
}
}
#[cfg(feature = "sqlite")]
impl crate::sqlite::SqliteLoadable for TopTrack {
fn select_sql() -> &'static str {
"SELECT name, url, artist, mbid, playcount, rank
FROM top_tracks
ORDER BY rank ASC"
}
fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
Ok(Self {
name: row.get(0)?,
url: row.get(1)?,
artist: BaseObject {
name: row.get(2)?,
mbid: String::new(),
url: String::new(),
},
mbid: row.get(3)?,
playcount: row.get(4)?,
attr: RankAttr {
rank: row.get::<_, u32>(5)?.to_string(),
},
duration: 0,
streamable: Streamable {
fulltrack: String::new(),
text: String::new(),
},
image: vec![],
})
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn make_track(name: &str, artist: &str, album: &str, uts: Option<u32>) -> RecentTrack {
RecentTrack {
artist: BaseMbidText {
mbid: String::new(),
text: artist.to_string(),
},
streamable: false,
image: vec![],
album: BaseMbidText {
mbid: String::new(),
text: album.to_string(),
},
attr: None,
date: uts.map(|u| Date {
uts: u,
text: String::new(),
}),
name: name.to_string(),
mbid: String::new(),
url: String::new(),
}
}
fn make_now_playing(name: &str, artist: &str) -> RecentTrack {
RecentTrack {
attr: Some(Attributes {
nowplaying: "true".to_string(),
}),
date: None,
..make_track(name, artist, "", None)
}
}
#[test]
fn test_to_set_counts_and_ranks() {
let list = TrackList::from(vec![
make_track("Song A", "Artist 1", "Album", Some(300)),
make_track("Song B", "Artist 1", "Album", Some(200)),
make_track("Song A", "Artist 1", "Album", Some(100)),
]);
let set = list.to_set();
assert_eq!(set.len(), 2);
let top = set.iter().find(|t| t.name == "Song A").unwrap();
assert_eq!(top.play_count, 2);
assert_eq!(top.rank, 1);
}
#[test]
fn test_top_artists() {
let list = TrackList::from(vec![
make_track("T1", "Radiohead", "OK Computer", Some(100)),
make_track("T2", "Radiohead", "OK Computer", Some(200)),
make_track("T3", "Portishead", "Dummy", Some(300)),
]);
let artists = list.top_artists();
assert_eq!(artists.len(), 2);
assert_eq!(artists[0].name, "Radiohead");
assert_eq!(artists[0].play_count, 2);
assert_eq!(artists[0].rank, 1);
assert_eq!(artists[1].play_count, 1);
assert_eq!(artists[1].rank, 2);
}
#[test]
fn test_top_albums_excludes_empty_album() {
let list = TrackList::from(vec![
make_track("T1", "Artist", "Dummy", Some(100)),
make_track("T2", "Artist", "Dummy", Some(200)),
make_track("T3", "Artist", "", Some(300)), ]);
let albums = list.top_albums();
assert_eq!(albums.len(), 1);
assert_eq!(albums[0].name, "Dummy");
assert_eq!(albums[0].play_count, 2);
}
#[test]
fn test_by_hour() {
let list = TrackList::from(vec![
make_track("T1", "A", "", Some(3_600)),
make_track("T2", "A", "", Some(7_200)),
make_track("T3", "A", "", Some(7_300)), ]);
let hours = list.by_hour();
assert_eq!(hours[1], 1);
assert_eq!(hours[2], 2);
assert_eq!(hours[0], 0);
}
#[test]
fn test_by_date_and_streak() {
let list = TrackList::from(vec![
make_track("T1", "A", "", Some(0)), make_track("T2", "A", "", Some(86_400)), make_track("T3", "A", "", Some(86_400 * 3)), ]);
let by_date = list.by_date();
assert_eq!(by_date.len(), 3);
assert_eq!(list.streak(), 2);
}
#[test]
fn test_without_now_playing() {
let list = TrackList::from(vec![
make_track("T1", "A", "", Some(100)),
make_now_playing("Live Track", "A"),
]);
let filtered = list.without_now_playing();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name, "T1");
}
#[test]
fn test_unique_counts() {
let list = TrackList::from(vec![
make_track("Song", "Artist 1", "", Some(100)),
make_track("Song", "Artist 1", "", Some(200)), make_track("Song", "Artist 2", "", Some(300)), ]);
assert_eq!(list.unique_artist_count(), 2);
assert_eq!(list.unique_track_count(), 2);
}
fn make_ext(name: &str, artist: &str, album: &str, uts: Option<u32>) -> RecentTrackExtended {
RecentTrackExtended {
artist: BaseObject {
name: artist.to_string(),
mbid: String::new(),
url: String::new(),
},
streamable: false,
image: vec![],
album: BaseObject {
name: album.to_string(),
mbid: String::new(),
url: String::new(),
},
attr: None,
date: uts.map(|u| Date {
uts: u,
text: String::new(),
}),
name: name.to_string(),
mbid: String::new(),
url: String::new(),
}
}
fn make_ext_now_playing(name: &str, artist: &str) -> RecentTrackExtended {
use std::collections::HashMap;
RecentTrackExtended {
attr: Some(HashMap::from([(
"nowplaying".to_string(),
"true".to_string(),
)])),
date: None,
..make_ext(name, artist, "", None)
}
}
#[test]
fn test_ext_to_set() {
let list = TrackList::from(vec![
make_ext("Song A", "Artist 1", "Album", Some(300)),
make_ext("Song B", "Artist 1", "Album", Some(200)),
make_ext("Song A", "Artist 1", "Album", Some(100)),
]);
let set = list.to_set();
assert_eq!(set.len(), 2);
let top = set.iter().find(|t| t.name == "Song A").unwrap();
assert_eq!(top.play_count, 2);
assert_eq!(top.rank, 1);
assert_eq!(top.artist, "Artist 1");
}
#[test]
fn test_ext_top_artists() {
let list = TrackList::from(vec![
make_ext("T1", "Radiohead", "OK Computer", Some(100)),
make_ext("T2", "Radiohead", "OK Computer", Some(200)),
make_ext("T3", "Portishead", "Dummy", Some(300)),
]);
let artists = list.top_artists();
assert_eq!(artists.len(), 2);
assert_eq!(artists[0].name, "Radiohead");
assert_eq!(artists[0].play_count, 2);
assert_eq!(artists[0].rank, 1);
}
#[test]
fn test_ext_top_albums_excludes_empty() {
let list = TrackList::from(vec![
make_ext("T1", "Artist", "Dummy", Some(100)),
make_ext("T2", "Artist", "Dummy", Some(200)),
make_ext("T3", "Artist", "", Some(300)),
]);
let albums = list.top_albums();
assert_eq!(albums.len(), 1);
assert_eq!(albums[0].name, "Dummy");
assert_eq!(albums[0].play_count, 2);
}
#[test]
fn test_ext_by_hour_and_streak() {
let list = TrackList::from(vec![
make_ext("T1", "A", "", Some(3_600)), make_ext("T2", "A", "", Some(86_400)), make_ext("T3", "A", "", Some(86_400 * 3)), ]);
let hours = list.by_hour();
assert_eq!(hours[1], 1); assert_eq!(list.streak(), 2); }
#[test]
fn test_ext_without_now_playing() {
let list = TrackList::from(vec![
make_ext("T1", "A", "", Some(100)),
make_ext_now_playing("Live", "A"),
]);
let filtered = list.without_now_playing();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name, "T1");
}
#[test]
fn test_ext_unique_counts() {
let list = TrackList::from(vec![
make_ext("Song", "Artist 1", "", Some(100)),
make_ext("Song", "Artist 1", "", Some(200)),
make_ext("Song", "Artist 2", "", Some(300)),
]);
assert_eq!(list.unique_artist_count(), 2);
assert_eq!(list.unique_track_count(), 2);
}
#[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));
}
}