1use std::cmp::Ordering;
2use std::collections::{BTreeMap, HashMap};
3use std::fmt;
4
5use chrono::{DateTime, NaiveDate, Utc};
6use serde::{Deserialize, Serialize};
7
8use crate::types::utils::{bool_from_str, u32_from_str};
9
10use super::track_list::TrackList;
11
12#[derive(Serialize, Deserialize, Debug, Clone)]
18#[non_exhaustive]
19pub struct BaseMbidText {
20 pub mbid: String,
22 #[serde(rename = "#text")]
24 pub text: String,
25}
26
27#[derive(Serialize, Deserialize, Debug, Clone)]
31#[non_exhaustive]
32pub struct BaseObject {
33 pub mbid: String,
35 #[serde(default)]
37 pub url: String,
38 #[serde(alias = "#text")]
40 pub name: String,
41}
42
43#[derive(Serialize, Deserialize, Debug, Clone)]
45#[non_exhaustive]
46pub struct TrackImage {
47 pub size: String,
49 #[serde(rename = "#text")]
51 pub text: String,
52}
53
54#[derive(Serialize, Deserialize, Debug, Clone)]
56#[non_exhaustive]
57pub struct Streamable {
58 pub fulltrack: String,
60 #[serde(rename = "#text")]
62 pub text: String,
63}
64
65#[derive(Serialize, Deserialize, Debug, Clone)]
67#[non_exhaustive]
68pub struct Artist {
69 pub name: String,
71 pub mbid: String,
73 #[serde(default)]
75 pub url: String,
76 pub image: Vec<TrackImage>,
78}
79
80#[derive(Serialize, Deserialize, Debug, Clone)]
85#[non_exhaustive]
86pub struct Date {
87 #[serde(deserialize_with = "u32_from_str")]
89 pub uts: u32,
90 #[serde(rename = "#text")]
92 pub text: String,
93}
94
95#[derive(Serialize, Deserialize, Debug, Clone)]
99#[non_exhaustive]
100pub struct Attributes {
101 pub nowplaying: String,
103}
104
105#[derive(Serialize, Deserialize, Debug, Clone)]
107#[non_exhaustive]
108pub struct RankAttr {
109 pub rank: String,
111}
112
113#[derive(Serialize, Deserialize, Debug, Clone)]
120#[non_exhaustive]
121pub struct RecentTrack {
122 pub artist: BaseMbidText,
124 #[serde(deserialize_with = "bool_from_str")]
126 pub streamable: bool,
127 pub image: Vec<TrackImage>,
129 pub album: BaseMbidText,
131 #[serde(rename = "@attr")]
133 pub attr: Option<Attributes>,
134 pub date: Option<Date>,
136 pub name: String,
138 pub mbid: String,
140 pub url: String,
142}
143
144impl fmt::Display for RecentTrack {
145 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146 let status = if self.attr.is_some() {
147 " [NOW PLAYING]"
148 } else {
149 ""
150 };
151 let date_str = self
152 .date
153 .as_ref()
154 .map_or(String::new(), |d| format!(" ({})", d.text));
155
156 write!(
157 f,
158 "{} - {} [{}]{date_str}{status}",
159 self.name, self.artist.text, self.album.text
160 )
161 }
162}
163
164impl PartialEq for RecentTrack {
165 fn eq(&self, other: &Self) -> bool {
166 self.date.as_ref().map(|d| d.uts) == other.date.as_ref().map(|d| d.uts)
167 }
168}
169
170impl Eq for RecentTrack {}
171
172impl PartialOrd for RecentTrack {
173 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
174 Some(self.cmp(other))
175 }
176}
177
178impl Ord for RecentTrack {
179 fn cmp(&self, other: &Self) -> Ordering {
180 match (self.date.as_ref(), other.date.as_ref()) {
182 (None, None) => Ordering::Equal,
183 (None, Some(_)) => Ordering::Greater,
184 (Some(_), None) => Ordering::Less,
185 (Some(a), Some(b)) => a.uts.cmp(&b.uts),
186 }
187 }
188}
189
190#[derive(Serialize, Deserialize, Debug, Clone)]
194#[non_exhaustive]
195pub struct RecentTrackExtended {
196 pub artist: BaseObject,
198 #[serde(deserialize_with = "bool_from_str")]
200 pub streamable: bool,
201 pub image: Vec<TrackImage>,
203 pub album: BaseObject,
205 #[serde(rename = "@attr")]
207 pub attr: Option<HashMap<String, String>>,
208 pub date: Option<Date>,
210 pub name: String,
212 pub mbid: String,
214 #[serde(default)]
216 pub url: String,
217}
218
219impl fmt::Display for RecentTrackExtended {
220 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
221 let is_now_playing = self
222 .attr
223 .as_ref()
224 .and_then(|a| a.get("nowplaying"))
225 .is_some_and(|v| v == "true");
226 let status = if is_now_playing { " [NOW PLAYING]" } else { "" };
227 let date_str = self
228 .date
229 .as_ref()
230 .map_or(String::new(), |d| format!(" ({})", d.text));
231
232 write!(
233 f,
234 "{} - {} [{}]{date_str}{status}",
235 self.name, self.artist.name, self.album.name
236 )
237 }
238}
239
240impl PartialEq for RecentTrackExtended {
241 fn eq(&self, other: &Self) -> bool {
242 self.date.as_ref().map(|d| d.uts) == other.date.as_ref().map(|d| d.uts)
243 }
244}
245
246impl Eq for RecentTrackExtended {}
247
248impl PartialOrd for RecentTrackExtended {
249 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
250 Some(self.cmp(other))
251 }
252}
253
254impl Ord for RecentTrackExtended {
255 fn cmp(&self, other: &Self) -> Ordering {
256 match (self.date.as_ref(), other.date.as_ref()) {
258 (None, None) => Ordering::Equal,
259 (None, Some(_)) => Ordering::Greater,
260 (Some(_), None) => Ordering::Less,
261 (Some(a), Some(b)) => a.uts.cmp(&b.uts),
262 }
263 }
264}
265
266#[derive(Serialize, Deserialize, Debug, Clone)]
272#[non_exhaustive]
273pub struct LovedTrack {
274 pub artist: BaseObject,
276 pub date: Date,
278 pub image: Vec<TrackImage>,
280 pub streamable: Streamable,
282 pub name: String,
284 pub mbid: String,
286 pub url: String,
288}
289
290impl fmt::Display for LovedTrack {
291 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
292 write!(
293 f,
294 "{} - {} (loved {})",
295 self.name, self.artist.name, self.date.text
296 )
297 }
298}
299
300impl PartialEq for LovedTrack {
301 fn eq(&self, other: &Self) -> bool {
302 self.date.uts == other.date.uts
303 }
304}
305
306impl Eq for LovedTrack {}
307
308impl PartialOrd for LovedTrack {
309 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
310 Some(self.cmp(other))
311 }
312}
313
314impl Ord for LovedTrack {
315 fn cmp(&self, other: &Self) -> Ordering {
316 self.date.uts.cmp(&other.date.uts)
317 }
318}
319
320#[derive(Serialize, Deserialize, Debug, Clone)]
326#[non_exhaustive]
327pub struct TopTrack {
328 pub streamable: Streamable,
330 pub mbid: String,
332 pub name: String,
334 pub image: Vec<TrackImage>,
336 pub artist: BaseObject,
338 pub url: String,
340 #[serde(deserialize_with = "u32_from_str")]
342 pub duration: u32,
343 #[serde(rename = "@attr")]
345 pub attr: RankAttr,
346 #[serde(deserialize_with = "u32_from_str")]
348 pub playcount: u32,
349}
350
351impl fmt::Display for TopTrack {
352 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
353 write!(
354 f,
355 "#{} - {} by {} ({} plays)",
356 self.attr.rank, self.name, self.artist.name, self.playcount
357 )
358 }
359}
360
361impl PartialEq for TopTrack {
362 fn eq(&self, other: &Self) -> bool {
363 self.playcount == other.playcount
364 }
365}
366
367impl Eq for TopTrack {}
368
369impl PartialOrd for TopTrack {
370 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
371 Some(self.cmp(other))
372 }
373}
374
375impl Ord for TopTrack {
376 fn cmp(&self, other: &Self) -> Ordering {
377 self.playcount.cmp(&other.playcount)
378 }
379}
380
381#[derive(Debug, Clone, Serialize, Deserialize)]
391#[non_exhaustive]
392pub struct ScoredTrack {
393 pub name: String,
395 pub artist: String,
397 pub artist_mbid: String,
399 pub album: String,
401 pub mbid: String,
403 pub url: String,
405 pub image: Vec<TrackImage>,
407 pub play_count: u32,
409 pub rank: u32,
411}
412
413impl fmt::Display for ScoredTrack {
414 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
415 write!(
416 f,
417 "#{} {} - {} ({} play{})",
418 self.rank,
419 self.name,
420 self.artist,
421 self.play_count,
422 if self.play_count == 1 { "" } else { "s" }
423 )
424 }
425}
426
427impl PartialEq for ScoredTrack {
428 fn eq(&self, other: &Self) -> bool {
429 self.play_count == other.play_count
430 }
431}
432
433impl Eq for ScoredTrack {}
434
435impl PartialOrd for ScoredTrack {
436 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
437 Some(self.cmp(other))
438 }
439}
440
441impl Ord for ScoredTrack {
442 fn cmp(&self, other: &Self) -> Ordering {
443 self.play_count.cmp(&other.play_count)
444 }
445}
446
447#[derive(Debug, Clone, Serialize, Deserialize)]
455#[non_exhaustive]
456pub struct ScoredArtist {
457 pub name: String,
459 pub mbid: String,
461 pub play_count: u32,
463 pub rank: u32,
465}
466
467impl fmt::Display for ScoredArtist {
468 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
469 write!(
470 f,
471 "#{} {} ({} play{})",
472 self.rank,
473 self.name,
474 self.play_count,
475 if self.play_count == 1 { "" } else { "s" }
476 )
477 }
478}
479
480impl PartialEq for ScoredArtist {
481 fn eq(&self, other: &Self) -> bool {
482 self.play_count == other.play_count
483 }
484}
485
486impl Eq for ScoredArtist {}
487
488impl PartialOrd for ScoredArtist {
489 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
490 Some(self.cmp(other))
491 }
492}
493
494impl Ord for ScoredArtist {
495 fn cmp(&self, other: &Self) -> Ordering {
496 self.play_count.cmp(&other.play_count)
497 }
498}
499
500#[derive(Debug, Clone, Serialize, Deserialize)]
508#[non_exhaustive]
509pub struct ScoredAlbum {
510 pub name: String,
512 pub mbid: String,
514 pub artist: String,
516 pub play_count: u32,
518 pub rank: u32,
520}
521
522impl fmt::Display for ScoredAlbum {
523 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
524 write!(
525 f,
526 "#{} {} — {} ({} play{})",
527 self.rank,
528 self.name,
529 self.artist,
530 self.play_count,
531 if self.play_count == 1 { "" } else { "s" }
532 )
533 }
534}
535
536impl PartialEq for ScoredAlbum {
537 fn eq(&self, other: &Self) -> bool {
538 self.play_count == other.play_count
539 }
540}
541
542impl Eq for ScoredAlbum {}
543
544impl PartialOrd for ScoredAlbum {
545 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
546 Some(self.cmp(other))
547 }
548}
549
550impl Ord for ScoredAlbum {
551 fn cmp(&self, other: &Self) -> Ordering {
552 self.play_count.cmp(&other.play_count)
553 }
554}
555
556#[derive(Serialize, Deserialize, Debug, Clone)]
560#[non_exhaustive]
561pub struct BaseResponse {
562 pub user: String,
564 #[serde(deserialize_with = "u32_from_str", rename = "totalPages")]
566 pub total_pages: u32,
567 #[serde(deserialize_with = "u32_from_str")]
569 pub page: u32,
570 #[serde(deserialize_with = "u32_from_str", rename = "perPage")]
572 pub per_page: u32,
573 #[serde(deserialize_with = "u32_from_str")]
575 pub total: u32,
576}
577
578#[derive(Serialize, Deserialize, Debug)]
580#[non_exhaustive]
581pub struct RecentTracks {
582 pub track: Vec<RecentTrack>,
584 #[serde(rename = "@attr")]
586 pub attr: BaseResponse,
587}
588
589#[derive(Serialize, Deserialize, Debug)]
591#[non_exhaustive]
592pub struct UserRecentTracks {
593 pub recenttracks: RecentTracks,
595}
596
597#[derive(Serialize, Deserialize, Debug)]
599#[non_exhaustive]
600pub struct RecentTracksExtended {
601 pub track: Vec<RecentTrackExtended>,
603 #[serde(rename = "@attr")]
605 pub attr: BaseResponse,
606}
607
608#[derive(Serialize, Deserialize, Debug)]
610#[non_exhaustive]
611pub struct UserRecentTracksExtended {
612 pub recenttracks: RecentTracksExtended,
614}
615
616#[derive(Serialize, Deserialize, Debug, Clone)]
618#[non_exhaustive]
619pub struct LovedTracks {
620 pub track: Vec<LovedTrack>,
622 #[serde(rename = "@attr")]
624 pub attr: BaseResponse,
625}
626
627#[derive(Serialize, Deserialize, Debug, Clone)]
629#[non_exhaustive]
630pub struct UserLovedTracks {
631 pub lovedtracks: LovedTracks,
633}
634
635#[derive(Serialize, Deserialize, Debug, Clone)]
637#[non_exhaustive]
638pub struct TopTracks {
639 pub track: Vec<TopTrack>,
641 #[serde(rename = "@attr")]
643 pub attr: BaseResponse,
644}
645
646#[derive(Serialize, Deserialize, Debug, Clone)]
648#[non_exhaustive]
649pub struct UserTopTracks {
650 pub toptracks: TopTracks,
652}
653
654#[derive(Debug, Serialize)]
658#[non_exhaustive]
659pub struct TrackPlayInfo {
660 pub name: String,
662 pub play_count: u32,
664 pub artist: String,
666 pub album: Option<String>,
668 pub image_url: Option<String>,
670 pub currently_playing: bool,
672 pub date: Option<u32>,
674 pub url: String,
676}
677
678pub trait Timestamped {
682 fn get_timestamp(&self) -> Option<u32>;
684}
685
686impl Timestamped for RecentTrack {
687 fn get_timestamp(&self) -> Option<u32> {
688 self.date.as_ref().map(|d| d.uts)
689 }
690}
691
692impl Timestamped for LovedTrack {
693 fn get_timestamp(&self) -> Option<u32> {
694 Some(self.date.uts)
695 }
696}
697
698impl Timestamped for RecentTrackExtended {
699 fn get_timestamp(&self) -> Option<u32> {
700 self.date.as_ref().map(|d| d.uts)
701 }
702}
703
704impl TrackList<RecentTrack> {
707 #[must_use]
741 pub fn to_set(&self) -> TrackList<ScoredTrack> {
742 let mut groups: HashMap<(String, String), (RecentTrack, u32)> = HashMap::new();
747
748 for track in self {
749 let key = (track.name.clone(), track.artist.text.clone());
750 let entry = groups.entry(key).or_insert_with(|| (track.clone(), 0));
751 entry.1 += 1;
752 }
753
754 let mut scored: Vec<ScoredTrack> = groups
755 .into_values()
756 .map(|(rep, play_count)| ScoredTrack {
757 name: rep.name,
758 artist: rep.artist.text,
759 artist_mbid: rep.artist.mbid,
760 album: rep.album.text,
761 mbid: rep.mbid,
762 url: rep.url,
763 image: rep.image,
764 play_count,
765 rank: 0,
766 })
767 .collect();
768
769 scored.sort_unstable_by(|a, b| b.play_count.cmp(&a.play_count));
770
771 for (i, track) in scored.iter_mut().enumerate() {
772 track.rank = u32::try_from(i).map_or(u32::MAX, |n| n.saturating_add(1));
773 }
774
775 TrackList::from(scored)
776 }
777
778 #[must_use]
796 pub fn top_artists(&self) -> TrackList<ScoredArtist> {
797 let mut groups: HashMap<(String, String), u32> = HashMap::new();
798
799 for track in self {
800 let key = (track.artist.text.clone(), track.artist.mbid.clone());
801 *groups.entry(key).or_insert(0) += 1;
802 }
803
804 let mut scored: Vec<ScoredArtist> = groups
805 .into_iter()
806 .map(|((name, mbid), play_count)| ScoredArtist {
807 name,
808 mbid,
809 play_count,
810 rank: 0,
811 })
812 .collect();
813
814 scored.sort_unstable_by(|a, b| b.play_count.cmp(&a.play_count));
815
816 for (i, artist) in scored.iter_mut().enumerate() {
817 artist.rank = u32::try_from(i).map_or(u32::MAX, |n| n.saturating_add(1));
818 }
819
820 TrackList::from(scored)
821 }
822
823 #[must_use]
841 pub fn top_albums(&self) -> TrackList<ScoredAlbum> {
842 let mut groups: HashMap<(String, String, String), u32> = HashMap::new();
843
844 for track in self {
845 if track.album.text.is_empty() {
846 continue;
847 }
848 let key = (
849 track.album.text.clone(),
850 track.album.mbid.clone(),
851 track.artist.text.clone(),
852 );
853 *groups.entry(key).or_insert(0) += 1;
854 }
855
856 let mut scored: Vec<ScoredAlbum> = groups
857 .into_iter()
858 .map(|((name, mbid, artist), play_count)| ScoredAlbum {
859 name,
860 mbid,
861 artist,
862 play_count,
863 rank: 0,
864 })
865 .collect();
866
867 scored.sort_unstable_by(|a, b| b.play_count.cmp(&a.play_count));
868
869 for (i, album) in scored.iter_mut().enumerate() {
870 album.rank = u32::try_from(i).map_or(u32::MAX, |n| n.saturating_add(1));
871 }
872
873 TrackList::from(scored)
874 }
875
876 #[must_use]
894 pub fn by_hour(&self) -> [u32; 24] {
895 let mut counts = [0u32; 24];
896 for track in self {
897 if let Some(date) = &track.date {
898 let hour = usize::try_from(date.uts % 86_400 / 3600).unwrap_or(0);
899 counts[hour] = counts[hour].saturating_add(1);
900 }
901 }
902 counts
903 }
904
905 #[must_use]
923 pub fn by_date(&self) -> BTreeMap<NaiveDate, u32> {
924 let mut counts: BTreeMap<NaiveDate, u32> = BTreeMap::new();
925 for track in self {
926 if let Some(date) = &track.date
927 && let Some(dt) = DateTime::<Utc>::from_timestamp(i64::from(date.uts), 0)
928 {
929 *counts.entry(dt.date_naive()).or_insert(0) += 1;
930 }
931 }
932 counts
933 }
934
935 #[must_use]
948 pub fn streak(&self) -> u32 {
949 let dates = self.by_date();
950 if dates.is_empty() {
951 return 0;
952 }
953
954 let sorted: Vec<NaiveDate> = dates.into_keys().collect();
956 let mut max_streak = 1u32;
957 let mut current = 1u32;
958
959 for window in sorted.windows(2) {
960 if let [prev, next] = window {
961 if next.signed_duration_since(*prev).num_days() == 1 {
962 current += 1;
963 if current > max_streak {
964 max_streak = current;
965 }
966 } else {
967 current = 1;
968 }
969 }
970 }
971
972 max_streak
973 }
974
975 #[must_use]
981 pub fn without_now_playing(&self) -> Self {
982 self.iter()
983 .filter(|t| t.attr.as_ref().is_none_or(|a| a.nowplaying != "true"))
984 .cloned()
985 .collect()
986 }
987
988 #[must_use]
992 pub fn unique_artist_count(&self) -> usize {
993 self.iter()
994 .map(|t| &t.artist.text)
995 .collect::<std::collections::HashSet<_>>()
996 .len()
997 }
998
999 #[must_use]
1001 pub fn unique_track_count(&self) -> usize {
1002 self.iter()
1003 .map(|t| (&t.name, &t.artist.text))
1004 .collect::<std::collections::HashSet<_>>()
1005 .len()
1006 }
1007}
1008
1009impl TrackList<RecentTrackExtended> {
1010 #[must_use]
1018 pub fn to_set(&self) -> TrackList<ScoredTrack> {
1019 let mut groups: HashMap<(String, String), (RecentTrackExtended, u32)> = HashMap::new();
1020
1021 for track in self {
1022 let key = (track.name.clone(), track.artist.name.clone());
1023 let entry = groups.entry(key).or_insert_with(|| (track.clone(), 0));
1024 entry.1 += 1;
1025 }
1026
1027 let mut scored: Vec<ScoredTrack> = groups
1028 .into_values()
1029 .map(|(rep, play_count)| ScoredTrack {
1030 name: rep.name,
1031 artist: rep.artist.name,
1032 artist_mbid: rep.artist.mbid,
1033 album: rep.album.name,
1034 mbid: rep.mbid,
1035 url: rep.url,
1036 image: rep.image,
1037 play_count,
1038 rank: 0,
1039 })
1040 .collect();
1041
1042 scored.sort_unstable_by(|a, b| b.play_count.cmp(&a.play_count));
1043
1044 for (i, track) in scored.iter_mut().enumerate() {
1045 track.rank = u32::try_from(i).map_or(u32::MAX, |n| n.saturating_add(1));
1046 }
1047
1048 TrackList::from(scored)
1049 }
1050
1051 #[must_use]
1056 pub fn top_artists(&self) -> TrackList<ScoredArtist> {
1057 let mut groups: HashMap<(String, String), u32> = HashMap::new();
1058
1059 for track in self {
1060 let key = (track.artist.name.clone(), track.artist.mbid.clone());
1061 *groups.entry(key).or_insert(0) += 1;
1062 }
1063
1064 let mut scored: Vec<ScoredArtist> = groups
1065 .into_iter()
1066 .map(|((name, mbid), play_count)| ScoredArtist {
1067 name,
1068 mbid,
1069 play_count,
1070 rank: 0,
1071 })
1072 .collect();
1073
1074 scored.sort_unstable_by(|a, b| b.play_count.cmp(&a.play_count));
1075
1076 for (i, artist) in scored.iter_mut().enumerate() {
1077 artist.rank = u32::try_from(i).map_or(u32::MAX, |n| n.saturating_add(1));
1078 }
1079
1080 TrackList::from(scored)
1081 }
1082
1083 #[must_use]
1089 pub fn top_albums(&self) -> TrackList<ScoredAlbum> {
1090 let mut groups: HashMap<(String, String, String), u32> = HashMap::new();
1091
1092 for track in self {
1093 if track.album.name.is_empty() {
1094 continue;
1095 }
1096 let key = (
1097 track.album.name.clone(),
1098 track.album.mbid.clone(),
1099 track.artist.name.clone(),
1100 );
1101 *groups.entry(key).or_insert(0) += 1;
1102 }
1103
1104 let mut scored: Vec<ScoredAlbum> = groups
1105 .into_iter()
1106 .map(|((name, mbid, artist), play_count)| ScoredAlbum {
1107 name,
1108 mbid,
1109 artist,
1110 play_count,
1111 rank: 0,
1112 })
1113 .collect();
1114
1115 scored.sort_unstable_by(|a, b| b.play_count.cmp(&a.play_count));
1116
1117 for (i, album) in scored.iter_mut().enumerate() {
1118 album.rank = u32::try_from(i).map_or(u32::MAX, |n| n.saturating_add(1));
1119 }
1120
1121 TrackList::from(scored)
1122 }
1123
1124 #[must_use]
1130 pub fn by_hour(&self) -> [u32; 24] {
1131 let mut counts = [0u32; 24];
1132 for track in self {
1133 if let Some(date) = &track.date {
1134 let hour = usize::try_from(date.uts % 86_400 / 3600).unwrap_or(0);
1135 counts[hour] = counts[hour].saturating_add(1);
1136 }
1137 }
1138 counts
1139 }
1140
1141 #[must_use]
1147 pub fn by_date(&self) -> BTreeMap<NaiveDate, u32> {
1148 let mut counts: BTreeMap<NaiveDate, u32> = BTreeMap::new();
1149 for track in self {
1150 if let Some(date) = &track.date
1151 && let Some(dt) = DateTime::<Utc>::from_timestamp(i64::from(date.uts), 0)
1152 {
1153 *counts.entry(dt.date_naive()).or_insert(0) += 1;
1154 }
1155 }
1156 counts
1157 }
1158
1159 #[must_use]
1163 pub fn streak(&self) -> u32 {
1164 let dates = self.by_date();
1165 if dates.is_empty() {
1166 return 0;
1167 }
1168
1169 let sorted: Vec<NaiveDate> = dates.into_keys().collect();
1170 let mut max_streak = 1u32;
1171 let mut current = 1u32;
1172
1173 for window in sorted.windows(2) {
1174 if let [prev, next] = window {
1175 if next.signed_duration_since(*prev).num_days() == 1 {
1176 current += 1;
1177 if current > max_streak {
1178 max_streak = current;
1179 }
1180 } else {
1181 current = 1;
1182 }
1183 }
1184 }
1185
1186 max_streak
1187 }
1188
1189 #[must_use]
1195 pub fn without_now_playing(&self) -> Self {
1196 self.iter()
1197 .filter(|t| {
1198 t.attr
1199 .as_ref()
1200 .is_none_or(|a| a.get("nowplaying").is_none_or(|v| v != "true"))
1201 })
1202 .cloned()
1203 .collect()
1204 }
1205
1206 #[must_use]
1210 pub fn unique_artist_count(&self) -> usize {
1211 self.iter()
1212 .map(|t| &t.artist.name)
1213 .collect::<std::collections::HashSet<_>>()
1214 .len()
1215 }
1216
1217 #[must_use]
1219 pub fn unique_track_count(&self) -> usize {
1220 self.iter()
1221 .map(|t| (&t.name, &t.artist.name))
1222 .collect::<std::collections::HashSet<_>>()
1223 .len()
1224 }
1225}
1226
1227#[cfg(feature = "sqlite")]
1230impl crate::sqlite::SqliteExportable for RecentTrack {
1231 fn table_name() -> &'static str {
1232 "recent_tracks"
1233 }
1234
1235 fn create_table_sql() -> &'static str {
1236 "CREATE TABLE IF NOT EXISTS recent_tracks (
1237 id INTEGER PRIMARY KEY AUTOINCREMENT,
1238 name TEXT NOT NULL,
1239 url TEXT NOT NULL,
1240 artist TEXT NOT NULL,
1241 artist_mbid TEXT NOT NULL,
1242 album TEXT NOT NULL,
1243 album_mbid TEXT NOT NULL,
1244 date_uts INTEGER,
1245 loved INTEGER NOT NULL DEFAULT 0
1246 )"
1247 }
1248
1249 fn insert_sql() -> &'static str {
1250 "INSERT INTO recent_tracks (name, url, artist, artist_mbid, album, album_mbid, date_uts, loved)
1251 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"
1252 }
1253
1254 fn bind_and_execute(&self, stmt: &mut rusqlite::Statement<'_>) -> rusqlite::Result<usize> {
1255 stmt.execute(rusqlite::params![
1256 self.name,
1257 self.url,
1258 self.artist.text,
1259 self.artist.mbid,
1260 self.album.text,
1261 self.album.mbid,
1262 self.date.as_ref().map(|d| d.uts),
1263 0_i32,
1264 ])
1265 }
1266}
1267
1268#[cfg(feature = "sqlite")]
1269impl crate::sqlite::SqliteExportable for RecentTrackExtended {
1270 fn table_name() -> &'static str {
1271 "recent_tracks_extended"
1272 }
1273
1274 fn create_table_sql() -> &'static str {
1275 "CREATE TABLE IF NOT EXISTS recent_tracks_extended (
1276 id INTEGER PRIMARY KEY AUTOINCREMENT,
1277 name TEXT NOT NULL,
1278 url TEXT NOT NULL,
1279 mbid TEXT NOT NULL,
1280 artist TEXT NOT NULL,
1281 artist_mbid TEXT NOT NULL,
1282 artist_url TEXT NOT NULL,
1283 album TEXT NOT NULL,
1284 album_mbid TEXT NOT NULL,
1285 album_url TEXT NOT NULL,
1286 date_uts INTEGER,
1287 loved INTEGER NOT NULL DEFAULT 0
1288 )"
1289 }
1290
1291 fn insert_sql() -> &'static str {
1292 "INSERT INTO recent_tracks_extended
1293 (name, url, mbid, artist, artist_mbid, artist_url, album, album_mbid, album_url, date_uts, loved)
1294 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)"
1295 }
1296
1297 fn bind_and_execute(&self, stmt: &mut rusqlite::Statement<'_>) -> rusqlite::Result<usize> {
1298 stmt.execute(rusqlite::params![
1299 self.name,
1300 self.url,
1301 self.mbid,
1302 self.artist.name,
1303 self.artist.mbid,
1304 self.artist.url,
1305 self.album.name,
1306 self.album.mbid,
1307 self.album.url,
1308 self.date.as_ref().map(|d| d.uts),
1309 0_i32,
1310 ])
1311 }
1312}
1313
1314#[cfg(feature = "sqlite")]
1315impl crate::sqlite::SqliteExportable for LovedTrack {
1316 fn table_name() -> &'static str {
1317 "loved_tracks"
1318 }
1319
1320 fn create_table_sql() -> &'static str {
1321 "CREATE TABLE IF NOT EXISTS loved_tracks (
1322 id INTEGER PRIMARY KEY AUTOINCREMENT,
1323 name TEXT NOT NULL,
1324 url TEXT NOT NULL,
1325 artist TEXT NOT NULL,
1326 artist_mbid TEXT NOT NULL,
1327 date_uts INTEGER NOT NULL
1328 )"
1329 }
1330
1331 fn insert_sql() -> &'static str {
1332 "INSERT INTO loved_tracks (name, url, artist, artist_mbid, date_uts)
1333 VALUES (?1, ?2, ?3, ?4, ?5)"
1334 }
1335
1336 fn bind_and_execute(&self, stmt: &mut rusqlite::Statement<'_>) -> rusqlite::Result<usize> {
1337 stmt.execute(rusqlite::params![
1338 self.name,
1339 self.url,
1340 self.artist.name,
1341 self.artist.mbid,
1342 self.date.uts,
1343 ])
1344 }
1345}
1346
1347#[cfg(feature = "sqlite")]
1348impl crate::sqlite::SqliteExportable for TopTrack {
1349 fn table_name() -> &'static str {
1350 "top_tracks"
1351 }
1352
1353 fn create_table_sql() -> &'static str {
1354 "CREATE TABLE IF NOT EXISTS top_tracks (
1355 id INTEGER PRIMARY KEY AUTOINCREMENT,
1356 name TEXT NOT NULL,
1357 url TEXT NOT NULL,
1358 artist TEXT NOT NULL,
1359 mbid TEXT NOT NULL,
1360 playcount INTEGER NOT NULL,
1361 rank INTEGER NOT NULL
1362 )"
1363 }
1364
1365 fn insert_sql() -> &'static str {
1366 "INSERT INTO top_tracks (name, url, artist, mbid, playcount, rank)
1367 VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
1368 }
1369
1370 fn bind_and_execute(&self, stmt: &mut rusqlite::Statement<'_>) -> rusqlite::Result<usize> {
1371 let rank: u32 = self.attr.rank.parse().unwrap_or_default();
1372 stmt.execute(rusqlite::params![
1373 self.name,
1374 self.url,
1375 self.artist.name,
1376 self.mbid,
1377 self.playcount,
1378 rank,
1379 ])
1380 }
1381}
1382
1383#[cfg(feature = "sqlite")]
1386impl crate::sqlite::SqliteLoadable for RecentTrack {
1387 fn select_sql() -> &'static str {
1388 "SELECT name, url, artist, artist_mbid, album, album_mbid, date_uts
1391 FROM recent_tracks
1392 ORDER BY CASE WHEN date_uts IS NULL THEN 0 ELSE 1 END, date_uts DESC"
1393 }
1394
1395 fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
1396 let date_uts: Option<u32> = row.get(6)?;
1397 Ok(Self {
1398 name: row.get(0)?,
1399 url: row.get(1)?,
1400 artist: BaseMbidText {
1401 text: row.get(2)?,
1402 mbid: row.get(3)?,
1403 },
1404 album: BaseMbidText {
1405 text: row.get(4)?,
1406 mbid: row.get(5)?,
1407 },
1408 date: date_uts.map(|uts| Date {
1409 uts,
1410 text: String::new(),
1411 }),
1412 mbid: String::new(),
1414 streamable: false,
1415 image: vec![],
1416 attr: None,
1417 })
1418 }
1419}
1420
1421#[cfg(feature = "sqlite")]
1422impl crate::sqlite::SqliteLoadable for RecentTrackExtended {
1423 fn select_sql() -> &'static str {
1424 "SELECT name, url, mbid, artist, artist_mbid, artist_url,
1427 album, album_mbid, album_url, date_uts
1428 FROM recent_tracks_extended
1429 ORDER BY CASE WHEN date_uts IS NULL THEN 0 ELSE 1 END, date_uts DESC"
1430 }
1431
1432 fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
1433 let date_uts: Option<u32> = row.get(9)?;
1434 Ok(Self {
1435 name: row.get(0)?,
1436 url: row.get(1)?,
1437 mbid: row.get(2)?,
1438 artist: BaseObject {
1439 name: row.get(3)?,
1440 mbid: row.get(4)?,
1441 url: row.get(5)?,
1442 },
1443 album: BaseObject {
1444 name: row.get(6)?,
1445 mbid: row.get(7)?,
1446 url: row.get(8)?,
1447 },
1448 date: date_uts.map(|uts| Date {
1449 uts,
1450 text: String::new(),
1451 }),
1452 streamable: false,
1454 image: vec![],
1455 attr: None,
1456 })
1457 }
1458}
1459
1460#[cfg(feature = "sqlite")]
1461impl crate::sqlite::SqliteLoadable for LovedTrack {
1462 fn select_sql() -> &'static str {
1463 "SELECT name, url, artist, artist_mbid, date_uts
1465 FROM loved_tracks
1466 ORDER BY date_uts DESC"
1467 }
1468
1469 fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
1470 Ok(Self {
1471 name: row.get(0)?,
1472 url: row.get(1)?,
1473 artist: BaseObject {
1474 name: row.get(2)?,
1475 mbid: row.get(3)?,
1476 url: String::new(),
1477 },
1478 date: Date {
1479 uts: row.get(4)?,
1480 text: String::new(),
1481 },
1482 mbid: String::new(),
1484 image: vec![],
1485 streamable: Streamable {
1486 fulltrack: String::new(),
1487 text: String::new(),
1488 },
1489 })
1490 }
1491}
1492
1493#[cfg(feature = "sqlite")]
1494impl crate::sqlite::SqliteLoadable for TopTrack {
1495 fn select_sql() -> &'static str {
1496 "SELECT name, url, artist, mbid, playcount, rank
1498 FROM top_tracks
1499 ORDER BY rank ASC"
1500 }
1501
1502 fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
1503 Ok(Self {
1504 name: row.get(0)?,
1505 url: row.get(1)?,
1506 artist: BaseObject {
1507 name: row.get(2)?,
1508 mbid: String::new(),
1509 url: String::new(),
1510 },
1511 mbid: row.get(3)?,
1512 playcount: row.get(4)?,
1513 attr: RankAttr {
1514 rank: row.get::<_, u32>(5)?.to_string(),
1515 },
1516 duration: 0,
1518 streamable: Streamable {
1519 fulltrack: String::new(),
1520 text: String::new(),
1521 },
1522 image: vec![],
1523 })
1524 }
1525}
1526
1527#[cfg(test)]
1528#[allow(clippy::unwrap_used)]
1529mod tests {
1530 use super::*;
1531
1532 fn make_track(name: &str, artist: &str, album: &str, uts: Option<u32>) -> RecentTrack {
1533 RecentTrack {
1534 artist: BaseMbidText {
1535 mbid: String::new(),
1536 text: artist.to_string(),
1537 },
1538 streamable: false,
1539 image: vec![],
1540 album: BaseMbidText {
1541 mbid: String::new(),
1542 text: album.to_string(),
1543 },
1544 attr: None,
1545 date: uts.map(|u| Date {
1546 uts: u,
1547 text: String::new(),
1548 }),
1549 name: name.to_string(),
1550 mbid: String::new(),
1551 url: String::new(),
1552 }
1553 }
1554
1555 fn make_now_playing(name: &str, artist: &str) -> RecentTrack {
1556 RecentTrack {
1557 attr: Some(Attributes {
1558 nowplaying: "true".to_string(),
1559 }),
1560 date: None,
1561 ..make_track(name, artist, "", None)
1562 }
1563 }
1564
1565 #[test]
1566 fn test_to_set_counts_and_ranks() {
1567 let list = TrackList::from(vec![
1568 make_track("Song A", "Artist 1", "Album", Some(300)),
1569 make_track("Song B", "Artist 1", "Album", Some(200)),
1570 make_track("Song A", "Artist 1", "Album", Some(100)),
1571 ]);
1572 let set = list.to_set();
1573 assert_eq!(set.len(), 2);
1574 let top = set.iter().find(|t| t.name == "Song A").unwrap();
1576 assert_eq!(top.play_count, 2);
1577 assert_eq!(top.rank, 1);
1578 }
1579
1580 #[test]
1581 fn test_top_artists() {
1582 let list = TrackList::from(vec![
1583 make_track("T1", "Radiohead", "OK Computer", Some(100)),
1584 make_track("T2", "Radiohead", "OK Computer", Some(200)),
1585 make_track("T3", "Portishead", "Dummy", Some(300)),
1586 ]);
1587 let artists = list.top_artists();
1588 assert_eq!(artists.len(), 2);
1589 assert_eq!(artists[0].name, "Radiohead");
1590 assert_eq!(artists[0].play_count, 2);
1591 assert_eq!(artists[0].rank, 1);
1592 assert_eq!(artists[1].play_count, 1);
1593 assert_eq!(artists[1].rank, 2);
1594 }
1595
1596 #[test]
1597 fn test_top_albums_excludes_empty_album() {
1598 let list = TrackList::from(vec![
1599 make_track("T1", "Artist", "Dummy", Some(100)),
1600 make_track("T2", "Artist", "Dummy", Some(200)),
1601 make_track("T3", "Artist", "", Some(300)), ]);
1603 let albums = list.top_albums();
1604 assert_eq!(albums.len(), 1);
1605 assert_eq!(albums[0].name, "Dummy");
1606 assert_eq!(albums[0].play_count, 2);
1607 }
1608
1609 #[test]
1610 fn test_by_hour() {
1611 let list = TrackList::from(vec![
1613 make_track("T1", "A", "", Some(3_600)),
1614 make_track("T2", "A", "", Some(7_200)),
1615 make_track("T3", "A", "", Some(7_300)), ]);
1617 let hours = list.by_hour();
1618 assert_eq!(hours[1], 1);
1619 assert_eq!(hours[2], 2);
1620 assert_eq!(hours[0], 0);
1621 }
1622
1623 #[test]
1624 fn test_by_date_and_streak() {
1625 let list = TrackList::from(vec![
1627 make_track("T1", "A", "", Some(0)), make_track("T2", "A", "", Some(86_400)), make_track("T3", "A", "", Some(86_400 * 3)), ]);
1631 let by_date = list.by_date();
1632 assert_eq!(by_date.len(), 3);
1633 assert_eq!(list.streak(), 2);
1635 }
1636
1637 #[test]
1638 fn test_without_now_playing() {
1639 let list = TrackList::from(vec![
1640 make_track("T1", "A", "", Some(100)),
1641 make_now_playing("Live Track", "A"),
1642 ]);
1643 let filtered = list.without_now_playing();
1644 assert_eq!(filtered.len(), 1);
1645 assert_eq!(filtered[0].name, "T1");
1646 }
1647
1648 #[test]
1649 fn test_unique_counts() {
1650 let list = TrackList::from(vec![
1651 make_track("Song", "Artist 1", "", Some(100)),
1652 make_track("Song", "Artist 1", "", Some(200)), make_track("Song", "Artist 2", "", Some(300)), ]);
1655 assert_eq!(list.unique_artist_count(), 2);
1656 assert_eq!(list.unique_track_count(), 2);
1657 }
1658
1659 fn make_ext(name: &str, artist: &str, album: &str, uts: Option<u32>) -> RecentTrackExtended {
1662 RecentTrackExtended {
1663 artist: BaseObject {
1664 name: artist.to_string(),
1665 mbid: String::new(),
1666 url: String::new(),
1667 },
1668 streamable: false,
1669 image: vec![],
1670 album: BaseObject {
1671 name: album.to_string(),
1672 mbid: String::new(),
1673 url: String::new(),
1674 },
1675 attr: None,
1676 date: uts.map(|u| Date {
1677 uts: u,
1678 text: String::new(),
1679 }),
1680 name: name.to_string(),
1681 mbid: String::new(),
1682 url: String::new(),
1683 }
1684 }
1685
1686 fn make_ext_now_playing(name: &str, artist: &str) -> RecentTrackExtended {
1687 use std::collections::HashMap;
1688 RecentTrackExtended {
1689 attr: Some(HashMap::from([(
1690 "nowplaying".to_string(),
1691 "true".to_string(),
1692 )])),
1693 date: None,
1694 ..make_ext(name, artist, "", None)
1695 }
1696 }
1697
1698 #[test]
1699 fn test_ext_to_set() {
1700 let list = TrackList::from(vec![
1701 make_ext("Song A", "Artist 1", "Album", Some(300)),
1702 make_ext("Song B", "Artist 1", "Album", Some(200)),
1703 make_ext("Song A", "Artist 1", "Album", Some(100)),
1704 ]);
1705 let set = list.to_set();
1706 assert_eq!(set.len(), 2);
1707 let top = set.iter().find(|t| t.name == "Song A").unwrap();
1708 assert_eq!(top.play_count, 2);
1709 assert_eq!(top.rank, 1);
1710 assert_eq!(top.artist, "Artist 1");
1711 }
1712
1713 #[test]
1714 fn test_ext_top_artists() {
1715 let list = TrackList::from(vec![
1716 make_ext("T1", "Radiohead", "OK Computer", Some(100)),
1717 make_ext("T2", "Radiohead", "OK Computer", Some(200)),
1718 make_ext("T3", "Portishead", "Dummy", Some(300)),
1719 ]);
1720 let artists = list.top_artists();
1721 assert_eq!(artists.len(), 2);
1722 assert_eq!(artists[0].name, "Radiohead");
1723 assert_eq!(artists[0].play_count, 2);
1724 assert_eq!(artists[0].rank, 1);
1725 }
1726
1727 #[test]
1728 fn test_ext_top_albums_excludes_empty() {
1729 let list = TrackList::from(vec![
1730 make_ext("T1", "Artist", "Dummy", Some(100)),
1731 make_ext("T2", "Artist", "Dummy", Some(200)),
1732 make_ext("T3", "Artist", "", Some(300)),
1733 ]);
1734 let albums = list.top_albums();
1735 assert_eq!(albums.len(), 1);
1736 assert_eq!(albums[0].name, "Dummy");
1737 assert_eq!(albums[0].play_count, 2);
1738 }
1739
1740 #[test]
1741 fn test_ext_by_hour_and_streak() {
1742 let list = TrackList::from(vec![
1743 make_ext("T1", "A", "", Some(3_600)), make_ext("T2", "A", "", Some(86_400)), make_ext("T3", "A", "", Some(86_400 * 3)), ]);
1747 let hours = list.by_hour();
1748 assert_eq!(hours[1], 1); assert_eq!(list.streak(), 2); }
1751
1752 #[test]
1753 fn test_ext_without_now_playing() {
1754 let list = TrackList::from(vec![
1755 make_ext("T1", "A", "", Some(100)),
1756 make_ext_now_playing("Live", "A"),
1757 ]);
1758 let filtered = list.without_now_playing();
1759 assert_eq!(filtered.len(), 1);
1760 assert_eq!(filtered[0].name, "T1");
1761 }
1762
1763 #[test]
1764 fn test_ext_unique_counts() {
1765 let list = TrackList::from(vec![
1766 make_ext("Song", "Artist 1", "", Some(100)),
1767 make_ext("Song", "Artist 1", "", Some(200)),
1768 make_ext("Song", "Artist 2", "", Some(300)),
1769 ]);
1770 assert_eq!(list.unique_artist_count(), 2);
1771 assert_eq!(list.unique_track_count(), 2);
1772 }
1773
1774 #[test]
1775 fn test_date_deserialization() {
1776 use serde_json::json;
1777 let json_value = json!({
1778 "uts": "1_234_567_890",
1779 "#text": "2009-02-13 23:31:30"
1780 });
1781 let date: Date = serde_json::from_value(json_value).unwrap();
1782 assert_eq!(date.uts, 1_234_567_890);
1783 assert_eq!(date.text, "2009-02-13 23:31:30");
1784 }
1785
1786 #[test]
1787 fn test_bool_from_str() {
1788 use serde_json::json;
1789 let json_value = json!({
1791 "artist": {"mbid": "", "#text": "Test"},
1792 "streamable": "1",
1793 "image": [],
1794 "album": {"mbid": "", "#text": ""},
1795 "name": "Test",
1796 "mbid": "",
1797 "url": ""
1798 });
1799 let track: RecentTrack = serde_json::from_value(json_value).unwrap();
1800 assert!(track.streamable);
1801 }
1802
1803 #[test]
1804 fn test_timestamped_trait() {
1805 let track = RecentTrack {
1806 artist: BaseMbidText {
1807 mbid: String::new(),
1808 text: "Artist".to_string(),
1809 },
1810 streamable: false,
1811 image: vec![],
1812 album: BaseMbidText {
1813 mbid: String::new(),
1814 text: String::new(),
1815 },
1816 attr: None,
1817 date: Some(Date {
1818 uts: 1_234_567_890,
1819 text: "test".to_string(),
1820 }),
1821 name: "Track".to_string(),
1822 mbid: String::new(),
1823 url: String::new(),
1824 };
1825
1826 assert_eq!(track.get_timestamp(), Some(1_234_567_890));
1827 }
1828}