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, vec_or_single};
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 #[serde(deserialize_with = "vec_or_single")]
584 pub track: Vec<RecentTrack>,
585 #[serde(rename = "@attr")]
587 pub attr: BaseResponse,
588}
589
590#[derive(Serialize, Deserialize, Debug)]
592#[non_exhaustive]
593pub struct UserRecentTracks {
594 pub recenttracks: RecentTracks,
596}
597
598#[derive(Serialize, Deserialize, Debug)]
600#[non_exhaustive]
601pub struct RecentTracksExtended {
602 #[serde(deserialize_with = "vec_or_single")]
604 pub track: Vec<RecentTrackExtended>,
605 #[serde(rename = "@attr")]
607 pub attr: BaseResponse,
608}
609
610#[derive(Serialize, Deserialize, Debug)]
612#[non_exhaustive]
613pub struct UserRecentTracksExtended {
614 pub recenttracks: RecentTracksExtended,
616}
617
618#[derive(Serialize, Deserialize, Debug, Clone)]
620#[non_exhaustive]
621pub struct LovedTracks {
622 #[serde(deserialize_with = "vec_or_single")]
624 pub track: Vec<LovedTrack>,
625 #[serde(rename = "@attr")]
627 pub attr: BaseResponse,
628}
629
630#[derive(Serialize, Deserialize, Debug, Clone)]
632#[non_exhaustive]
633pub struct UserLovedTracks {
634 pub lovedtracks: LovedTracks,
636}
637
638#[derive(Serialize, Deserialize, Debug, Clone)]
640#[non_exhaustive]
641pub struct TopTracks {
642 #[serde(deserialize_with = "vec_or_single")]
644 pub track: Vec<TopTrack>,
645 #[serde(rename = "@attr")]
647 pub attr: BaseResponse,
648}
649
650#[derive(Serialize, Deserialize, Debug, Clone)]
652#[non_exhaustive]
653pub struct UserTopTracks {
654 pub toptracks: TopTracks,
656}
657
658#[derive(Debug, Serialize)]
662#[non_exhaustive]
663pub struct TrackPlayInfo {
664 pub name: String,
666 pub play_count: u32,
668 pub artist: String,
670 pub album: Option<String>,
672 pub image_url: Option<String>,
674 pub currently_playing: bool,
676 pub date: Option<u32>,
678 pub url: String,
680}
681
682pub trait Timestamped {
686 fn get_timestamp(&self) -> Option<u32>;
688}
689
690impl Timestamped for RecentTrack {
691 fn get_timestamp(&self) -> Option<u32> {
692 self.date.as_ref().map(|d| d.uts)
693 }
694}
695
696impl Timestamped for LovedTrack {
697 fn get_timestamp(&self) -> Option<u32> {
698 Some(self.date.uts)
699 }
700}
701
702impl Timestamped for RecentTrackExtended {
703 fn get_timestamp(&self) -> Option<u32> {
704 self.date.as_ref().map(|d| d.uts)
705 }
706}
707
708impl TrackList<RecentTrack> {
711 #[must_use]
745 pub fn to_set(&self) -> TrackList<ScoredTrack> {
746 let mut groups: HashMap<(String, String), (RecentTrack, u32)> = HashMap::new();
751
752 for track in self {
753 let key = (track.name.clone(), track.artist.text.clone());
754 let entry = groups.entry(key).or_insert_with(|| (track.clone(), 0));
755 entry.1 += 1;
756 }
757
758 let mut scored: Vec<ScoredTrack> = groups
759 .into_values()
760 .map(|(rep, play_count)| ScoredTrack {
761 name: rep.name,
762 artist: rep.artist.text,
763 artist_mbid: rep.artist.mbid,
764 album: rep.album.text,
765 mbid: rep.mbid,
766 url: rep.url,
767 image: rep.image,
768 play_count,
769 rank: 0,
770 })
771 .collect();
772
773 scored.sort_unstable_by_key(|b| std::cmp::Reverse(b.play_count));
774
775 for (i, track) in scored.iter_mut().enumerate() {
776 track.rank = u32::try_from(i).map_or(u32::MAX, |n| n.saturating_add(1));
777 }
778
779 TrackList::from(scored)
780 }
781
782 #[must_use]
800 pub fn top_artists(&self) -> TrackList<ScoredArtist> {
801 let mut groups: HashMap<(String, String), u32> = HashMap::new();
802
803 for track in self {
804 let key = (track.artist.text.clone(), track.artist.mbid.clone());
805 *groups.entry(key).or_insert(0) += 1;
806 }
807
808 let mut scored: Vec<ScoredArtist> = groups
809 .into_iter()
810 .map(|((name, mbid), play_count)| ScoredArtist {
811 name,
812 mbid,
813 play_count,
814 rank: 0,
815 })
816 .collect();
817
818 scored.sort_unstable_by_key(|b| std::cmp::Reverse(b.play_count));
819
820 for (i, artist) in scored.iter_mut().enumerate() {
821 artist.rank = u32::try_from(i).map_or(u32::MAX, |n| n.saturating_add(1));
822 }
823
824 TrackList::from(scored)
825 }
826
827 #[must_use]
845 pub fn top_albums(&self) -> TrackList<ScoredAlbum> {
846 let mut groups: HashMap<(String, String, String), u32> = HashMap::new();
847
848 for track in self {
849 if track.album.text.is_empty() {
850 continue;
851 }
852 let key = (
853 track.album.text.clone(),
854 track.album.mbid.clone(),
855 track.artist.text.clone(),
856 );
857 *groups.entry(key).or_insert(0) += 1;
858 }
859
860 let mut scored: Vec<ScoredAlbum> = groups
861 .into_iter()
862 .map(|((name, mbid, artist), play_count)| ScoredAlbum {
863 name,
864 mbid,
865 artist,
866 play_count,
867 rank: 0,
868 })
869 .collect();
870
871 scored.sort_unstable_by_key(|b| std::cmp::Reverse(b.play_count));
872
873 for (i, album) in scored.iter_mut().enumerate() {
874 album.rank = u32::try_from(i).map_or(u32::MAX, |n| n.saturating_add(1));
875 }
876
877 TrackList::from(scored)
878 }
879
880 #[must_use]
898 pub fn by_hour(&self) -> [u32; 24] {
899 let mut counts = [0u32; 24];
900 for track in self {
901 if let Some(date) = &track.date {
902 let hour = usize::try_from(date.uts % 86_400 / 3600).unwrap_or(0);
903 counts[hour] = counts[hour].saturating_add(1);
904 }
905 }
906 counts
907 }
908
909 #[must_use]
927 pub fn by_date(&self) -> BTreeMap<NaiveDate, u32> {
928 let mut counts: BTreeMap<NaiveDate, u32> = BTreeMap::new();
929 for track in self {
930 if let Some(date) = &track.date
931 && let Some(dt) = DateTime::<Utc>::from_timestamp(i64::from(date.uts), 0)
932 {
933 *counts.entry(dt.date_naive()).or_insert(0) += 1;
934 }
935 }
936 counts
937 }
938
939 #[must_use]
952 pub fn streak(&self) -> u32 {
953 let dates = self.by_date();
954 if dates.is_empty() {
955 return 0;
956 }
957
958 let sorted: Vec<NaiveDate> = dates.into_keys().collect();
960 let mut max_streak = 1u32;
961 let mut current = 1u32;
962
963 for window in sorted.windows(2) {
964 if let [prev, next] = window {
965 if next.signed_duration_since(*prev).num_days() == 1 {
966 current += 1;
967 if current > max_streak {
968 max_streak = current;
969 }
970 } else {
971 current = 1;
972 }
973 }
974 }
975
976 max_streak
977 }
978
979 #[must_use]
985 pub fn without_now_playing(&self) -> Self {
986 self.iter()
987 .filter(|t| t.attr.as_ref().is_none_or(|a| a.nowplaying != "true"))
988 .cloned()
989 .collect()
990 }
991
992 #[must_use]
996 pub fn unique_artist_count(&self) -> usize {
997 self.iter()
998 .map(|t| &t.artist.text)
999 .collect::<std::collections::HashSet<_>>()
1000 .len()
1001 }
1002
1003 #[must_use]
1005 pub fn unique_track_count(&self) -> usize {
1006 self.iter()
1007 .map(|t| (&t.name, &t.artist.text))
1008 .collect::<std::collections::HashSet<_>>()
1009 .len()
1010 }
1011}
1012
1013impl TrackList<RecentTrackExtended> {
1014 #[must_use]
1022 pub fn to_set(&self) -> TrackList<ScoredTrack> {
1023 let mut groups: HashMap<(String, String), (RecentTrackExtended, u32)> = HashMap::new();
1024
1025 for track in self {
1026 let key = (track.name.clone(), track.artist.name.clone());
1027 let entry = groups.entry(key).or_insert_with(|| (track.clone(), 0));
1028 entry.1 += 1;
1029 }
1030
1031 let mut scored: Vec<ScoredTrack> = groups
1032 .into_values()
1033 .map(|(rep, play_count)| ScoredTrack {
1034 name: rep.name,
1035 artist: rep.artist.name,
1036 artist_mbid: rep.artist.mbid,
1037 album: rep.album.name,
1038 mbid: rep.mbid,
1039 url: rep.url,
1040 image: rep.image,
1041 play_count,
1042 rank: 0,
1043 })
1044 .collect();
1045
1046 scored.sort_unstable_by_key(|b| std::cmp::Reverse(b.play_count));
1047
1048 for (i, track) in scored.iter_mut().enumerate() {
1049 track.rank = u32::try_from(i).map_or(u32::MAX, |n| n.saturating_add(1));
1050 }
1051
1052 TrackList::from(scored)
1053 }
1054
1055 #[must_use]
1060 pub fn top_artists(&self) -> TrackList<ScoredArtist> {
1061 let mut groups: HashMap<(String, String), u32> = HashMap::new();
1062
1063 for track in self {
1064 let key = (track.artist.name.clone(), track.artist.mbid.clone());
1065 *groups.entry(key).or_insert(0) += 1;
1066 }
1067
1068 let mut scored: Vec<ScoredArtist> = groups
1069 .into_iter()
1070 .map(|((name, mbid), play_count)| ScoredArtist {
1071 name,
1072 mbid,
1073 play_count,
1074 rank: 0,
1075 })
1076 .collect();
1077
1078 scored.sort_unstable_by_key(|b| std::cmp::Reverse(b.play_count));
1079
1080 for (i, artist) in scored.iter_mut().enumerate() {
1081 artist.rank = u32::try_from(i).map_or(u32::MAX, |n| n.saturating_add(1));
1082 }
1083
1084 TrackList::from(scored)
1085 }
1086
1087 #[must_use]
1093 pub fn top_albums(&self) -> TrackList<ScoredAlbum> {
1094 let mut groups: HashMap<(String, String, String), u32> = HashMap::new();
1095
1096 for track in self {
1097 if track.album.name.is_empty() {
1098 continue;
1099 }
1100 let key = (
1101 track.album.name.clone(),
1102 track.album.mbid.clone(),
1103 track.artist.name.clone(),
1104 );
1105 *groups.entry(key).or_insert(0) += 1;
1106 }
1107
1108 let mut scored: Vec<ScoredAlbum> = groups
1109 .into_iter()
1110 .map(|((name, mbid, artist), play_count)| ScoredAlbum {
1111 name,
1112 mbid,
1113 artist,
1114 play_count,
1115 rank: 0,
1116 })
1117 .collect();
1118
1119 scored.sort_unstable_by_key(|b| std::cmp::Reverse(b.play_count));
1120
1121 for (i, album) in scored.iter_mut().enumerate() {
1122 album.rank = u32::try_from(i).map_or(u32::MAX, |n| n.saturating_add(1));
1123 }
1124
1125 TrackList::from(scored)
1126 }
1127
1128 #[must_use]
1134 pub fn by_hour(&self) -> [u32; 24] {
1135 let mut counts = [0u32; 24];
1136 for track in self {
1137 if let Some(date) = &track.date {
1138 let hour = usize::try_from(date.uts % 86_400 / 3600).unwrap_or(0);
1139 counts[hour] = counts[hour].saturating_add(1);
1140 }
1141 }
1142 counts
1143 }
1144
1145 #[must_use]
1151 pub fn by_date(&self) -> BTreeMap<NaiveDate, u32> {
1152 let mut counts: BTreeMap<NaiveDate, u32> = BTreeMap::new();
1153 for track in self {
1154 if let Some(date) = &track.date
1155 && let Some(dt) = DateTime::<Utc>::from_timestamp(i64::from(date.uts), 0)
1156 {
1157 *counts.entry(dt.date_naive()).or_insert(0) += 1;
1158 }
1159 }
1160 counts
1161 }
1162
1163 #[must_use]
1167 pub fn streak(&self) -> u32 {
1168 let dates = self.by_date();
1169 if dates.is_empty() {
1170 return 0;
1171 }
1172
1173 let sorted: Vec<NaiveDate> = dates.into_keys().collect();
1174 let mut max_streak = 1u32;
1175 let mut current = 1u32;
1176
1177 for window in sorted.windows(2) {
1178 if let [prev, next] = window {
1179 if next.signed_duration_since(*prev).num_days() == 1 {
1180 current += 1;
1181 if current > max_streak {
1182 max_streak = current;
1183 }
1184 } else {
1185 current = 1;
1186 }
1187 }
1188 }
1189
1190 max_streak
1191 }
1192
1193 #[must_use]
1199 pub fn without_now_playing(&self) -> Self {
1200 self.iter()
1201 .filter(|t| {
1202 t.attr
1203 .as_ref()
1204 .is_none_or(|a| a.get("nowplaying").is_none_or(|v| v != "true"))
1205 })
1206 .cloned()
1207 .collect()
1208 }
1209
1210 #[must_use]
1214 pub fn unique_artist_count(&self) -> usize {
1215 self.iter()
1216 .map(|t| &t.artist.name)
1217 .collect::<std::collections::HashSet<_>>()
1218 .len()
1219 }
1220
1221 #[must_use]
1223 pub fn unique_track_count(&self) -> usize {
1224 self.iter()
1225 .map(|t| (&t.name, &t.artist.name))
1226 .collect::<std::collections::HashSet<_>>()
1227 .len()
1228 }
1229}
1230
1231#[cfg(feature = "sqlite")]
1234impl crate::sqlite::SqliteExportable for RecentTrack {
1235 fn table_name() -> &'static str {
1236 "recent_tracks"
1237 }
1238
1239 fn create_table_sql() -> &'static str {
1240 "CREATE TABLE IF NOT EXISTS recent_tracks (
1241 id INTEGER PRIMARY KEY AUTOINCREMENT,
1242 name TEXT NOT NULL,
1243 url TEXT NOT NULL,
1244 artist TEXT NOT NULL,
1245 artist_mbid TEXT NOT NULL,
1246 album TEXT NOT NULL,
1247 album_mbid TEXT NOT NULL,
1248 date_uts INTEGER,
1249 loved INTEGER NOT NULL DEFAULT 0
1250 )"
1251 }
1252
1253 fn insert_sql() -> &'static str {
1254 "INSERT INTO recent_tracks (name, url, artist, artist_mbid, album, album_mbid, date_uts, loved)
1255 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"
1256 }
1257
1258 fn bind_and_execute(&self, stmt: &mut rusqlite::Statement<'_>) -> rusqlite::Result<usize> {
1259 stmt.execute(rusqlite::params![
1260 self.name,
1261 self.url,
1262 self.artist.text,
1263 self.artist.mbid,
1264 self.album.text,
1265 self.album.mbid,
1266 self.date.as_ref().map(|d| d.uts),
1267 0_i32,
1268 ])
1269 }
1270}
1271
1272#[cfg(feature = "sqlite")]
1273impl crate::sqlite::SqliteExportable for RecentTrackExtended {
1274 fn table_name() -> &'static str {
1275 "recent_tracks_extended"
1276 }
1277
1278 fn create_table_sql() -> &'static str {
1279 "CREATE TABLE IF NOT EXISTS recent_tracks_extended (
1280 id INTEGER PRIMARY KEY AUTOINCREMENT,
1281 name TEXT NOT NULL,
1282 url TEXT NOT NULL,
1283 mbid TEXT NOT NULL,
1284 artist TEXT NOT NULL,
1285 artist_mbid TEXT NOT NULL,
1286 artist_url TEXT NOT NULL,
1287 album TEXT NOT NULL,
1288 album_mbid TEXT NOT NULL,
1289 album_url TEXT NOT NULL,
1290 date_uts INTEGER,
1291 loved INTEGER NOT NULL DEFAULT 0
1292 )"
1293 }
1294
1295 fn insert_sql() -> &'static str {
1296 "INSERT INTO recent_tracks_extended
1297 (name, url, mbid, artist, artist_mbid, artist_url, album, album_mbid, album_url, date_uts, loved)
1298 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)"
1299 }
1300
1301 fn bind_and_execute(&self, stmt: &mut rusqlite::Statement<'_>) -> rusqlite::Result<usize> {
1302 stmt.execute(rusqlite::params![
1303 self.name,
1304 self.url,
1305 self.mbid,
1306 self.artist.name,
1307 self.artist.mbid,
1308 self.artist.url,
1309 self.album.name,
1310 self.album.mbid,
1311 self.album.url,
1312 self.date.as_ref().map(|d| d.uts),
1313 0_i32,
1314 ])
1315 }
1316}
1317
1318#[cfg(feature = "sqlite")]
1319impl crate::sqlite::SqliteExportable for LovedTrack {
1320 fn table_name() -> &'static str {
1321 "loved_tracks"
1322 }
1323
1324 fn create_table_sql() -> &'static str {
1325 "CREATE TABLE IF NOT EXISTS loved_tracks (
1326 id INTEGER PRIMARY KEY AUTOINCREMENT,
1327 name TEXT NOT NULL,
1328 url TEXT NOT NULL,
1329 artist TEXT NOT NULL,
1330 artist_mbid TEXT NOT NULL,
1331 date_uts INTEGER NOT NULL
1332 )"
1333 }
1334
1335 fn insert_sql() -> &'static str {
1336 "INSERT INTO loved_tracks (name, url, artist, artist_mbid, date_uts)
1337 VALUES (?1, ?2, ?3, ?4, ?5)"
1338 }
1339
1340 fn bind_and_execute(&self, stmt: &mut rusqlite::Statement<'_>) -> rusqlite::Result<usize> {
1341 stmt.execute(rusqlite::params![
1342 self.name,
1343 self.url,
1344 self.artist.name,
1345 self.artist.mbid,
1346 self.date.uts,
1347 ])
1348 }
1349}
1350
1351#[cfg(feature = "sqlite")]
1352impl crate::sqlite::SqliteExportable for TopTrack {
1353 fn table_name() -> &'static str {
1354 "top_tracks"
1355 }
1356
1357 fn create_table_sql() -> &'static str {
1358 "CREATE TABLE IF NOT EXISTS top_tracks (
1359 id INTEGER PRIMARY KEY AUTOINCREMENT,
1360 name TEXT NOT NULL,
1361 url TEXT NOT NULL,
1362 artist TEXT NOT NULL,
1363 mbid TEXT NOT NULL,
1364 playcount INTEGER NOT NULL,
1365 rank INTEGER NOT NULL
1366 )"
1367 }
1368
1369 fn insert_sql() -> &'static str {
1370 "INSERT INTO top_tracks (name, url, artist, mbid, playcount, rank)
1371 VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
1372 }
1373
1374 fn bind_and_execute(&self, stmt: &mut rusqlite::Statement<'_>) -> rusqlite::Result<usize> {
1375 let rank: u32 = self.attr.rank.parse().unwrap_or_default();
1376 stmt.execute(rusqlite::params![
1377 self.name,
1378 self.url,
1379 self.artist.name,
1380 self.mbid,
1381 self.playcount,
1382 rank,
1383 ])
1384 }
1385}
1386
1387#[cfg(feature = "sqlite")]
1390impl crate::sqlite::SqliteLoadable for RecentTrack {
1391 fn select_sql() -> &'static str {
1392 "SELECT name, url, artist, artist_mbid, album, album_mbid, date_uts
1395 FROM recent_tracks
1396 ORDER BY CASE WHEN date_uts IS NULL THEN 0 ELSE 1 END, date_uts DESC"
1397 }
1398
1399 fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
1400 let date_uts: Option<u32> = row.get(6)?;
1401 Ok(Self {
1402 name: row.get(0)?,
1403 url: row.get(1)?,
1404 artist: BaseMbidText {
1405 text: row.get(2)?,
1406 mbid: row.get(3)?,
1407 },
1408 album: BaseMbidText {
1409 text: row.get(4)?,
1410 mbid: row.get(5)?,
1411 },
1412 date: date_uts.map(|uts| Date {
1413 uts,
1414 text: String::new(),
1415 }),
1416 mbid: String::new(),
1418 streamable: false,
1419 image: vec![],
1420 attr: None,
1421 })
1422 }
1423}
1424
1425#[cfg(feature = "sqlite")]
1426impl crate::sqlite::SqliteLoadable for RecentTrackExtended {
1427 fn select_sql() -> &'static str {
1428 "SELECT name, url, mbid, artist, artist_mbid, artist_url,
1431 album, album_mbid, album_url, date_uts
1432 FROM recent_tracks_extended
1433 ORDER BY CASE WHEN date_uts IS NULL THEN 0 ELSE 1 END, date_uts DESC"
1434 }
1435
1436 fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
1437 let date_uts: Option<u32> = row.get(9)?;
1438 Ok(Self {
1439 name: row.get(0)?,
1440 url: row.get(1)?,
1441 mbid: row.get(2)?,
1442 artist: BaseObject {
1443 name: row.get(3)?,
1444 mbid: row.get(4)?,
1445 url: row.get(5)?,
1446 },
1447 album: BaseObject {
1448 name: row.get(6)?,
1449 mbid: row.get(7)?,
1450 url: row.get(8)?,
1451 },
1452 date: date_uts.map(|uts| Date {
1453 uts,
1454 text: String::new(),
1455 }),
1456 streamable: false,
1458 image: vec![],
1459 attr: None,
1460 })
1461 }
1462}
1463
1464#[cfg(feature = "sqlite")]
1465impl crate::sqlite::SqliteLoadable for LovedTrack {
1466 fn select_sql() -> &'static str {
1467 "SELECT name, url, artist, artist_mbid, date_uts
1469 FROM loved_tracks
1470 ORDER BY date_uts DESC"
1471 }
1472
1473 fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
1474 Ok(Self {
1475 name: row.get(0)?,
1476 url: row.get(1)?,
1477 artist: BaseObject {
1478 name: row.get(2)?,
1479 mbid: row.get(3)?,
1480 url: String::new(),
1481 },
1482 date: Date {
1483 uts: row.get(4)?,
1484 text: String::new(),
1485 },
1486 mbid: String::new(),
1488 image: vec![],
1489 streamable: Streamable {
1490 fulltrack: String::new(),
1491 text: String::new(),
1492 },
1493 })
1494 }
1495}
1496
1497#[cfg(feature = "sqlite")]
1498impl crate::sqlite::SqliteLoadable for TopTrack {
1499 fn select_sql() -> &'static str {
1500 "SELECT name, url, artist, mbid, playcount, rank
1502 FROM top_tracks
1503 ORDER BY rank ASC"
1504 }
1505
1506 fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
1507 Ok(Self {
1508 name: row.get(0)?,
1509 url: row.get(1)?,
1510 artist: BaseObject {
1511 name: row.get(2)?,
1512 mbid: String::new(),
1513 url: String::new(),
1514 },
1515 mbid: row.get(3)?,
1516 playcount: row.get(4)?,
1517 attr: RankAttr {
1518 rank: row.get::<_, u32>(5)?.to_string(),
1519 },
1520 duration: 0,
1522 streamable: Streamable {
1523 fulltrack: String::new(),
1524 text: String::new(),
1525 },
1526 image: vec![],
1527 })
1528 }
1529}
1530
1531#[cfg(test)]
1532#[allow(clippy::unwrap_used)]
1533mod tests {
1534 use super::*;
1535
1536 fn make_track(name: &str, artist: &str, album: &str, uts: Option<u32>) -> RecentTrack {
1537 RecentTrack {
1538 artist: BaseMbidText {
1539 mbid: String::new(),
1540 text: artist.to_string(),
1541 },
1542 streamable: false,
1543 image: vec![],
1544 album: BaseMbidText {
1545 mbid: String::new(),
1546 text: album.to_string(),
1547 },
1548 attr: None,
1549 date: uts.map(|u| Date {
1550 uts: u,
1551 text: String::new(),
1552 }),
1553 name: name.to_string(),
1554 mbid: String::new(),
1555 url: String::new(),
1556 }
1557 }
1558
1559 fn make_now_playing(name: &str, artist: &str) -> RecentTrack {
1560 RecentTrack {
1561 attr: Some(Attributes {
1562 nowplaying: "true".to_string(),
1563 }),
1564 date: None,
1565 ..make_track(name, artist, "", None)
1566 }
1567 }
1568
1569 #[test]
1570 fn test_to_set_counts_and_ranks() {
1571 let list = TrackList::from(vec![
1572 make_track("Song A", "Artist 1", "Album", Some(300)),
1573 make_track("Song B", "Artist 1", "Album", Some(200)),
1574 make_track("Song A", "Artist 1", "Album", Some(100)),
1575 ]);
1576 let set = list.to_set();
1577 assert_eq!(set.len(), 2);
1578 let top = set.iter().find(|t| t.name == "Song A").unwrap();
1580 assert_eq!(top.play_count, 2);
1581 assert_eq!(top.rank, 1);
1582 }
1583
1584 #[test]
1585 fn test_top_artists() {
1586 let list = TrackList::from(vec![
1587 make_track("T1", "Radiohead", "OK Computer", Some(100)),
1588 make_track("T2", "Radiohead", "OK Computer", Some(200)),
1589 make_track("T3", "Portishead", "Dummy", Some(300)),
1590 ]);
1591 let artists = list.top_artists();
1592 assert_eq!(artists.len(), 2);
1593 assert_eq!(artists[0].name, "Radiohead");
1594 assert_eq!(artists[0].play_count, 2);
1595 assert_eq!(artists[0].rank, 1);
1596 assert_eq!(artists[1].play_count, 1);
1597 assert_eq!(artists[1].rank, 2);
1598 }
1599
1600 #[test]
1601 fn test_top_albums_excludes_empty_album() {
1602 let list = TrackList::from(vec![
1603 make_track("T1", "Artist", "Dummy", Some(100)),
1604 make_track("T2", "Artist", "Dummy", Some(200)),
1605 make_track("T3", "Artist", "", Some(300)), ]);
1607 let albums = list.top_albums();
1608 assert_eq!(albums.len(), 1);
1609 assert_eq!(albums[0].name, "Dummy");
1610 assert_eq!(albums[0].play_count, 2);
1611 }
1612
1613 #[test]
1614 fn test_by_hour() {
1615 let list = TrackList::from(vec![
1617 make_track("T1", "A", "", Some(3_600)),
1618 make_track("T2", "A", "", Some(7_200)),
1619 make_track("T3", "A", "", Some(7_300)), ]);
1621 let hours = list.by_hour();
1622 assert_eq!(hours[1], 1);
1623 assert_eq!(hours[2], 2);
1624 assert_eq!(hours[0], 0);
1625 }
1626
1627 #[test]
1628 fn test_by_date_and_streak() {
1629 let list = TrackList::from(vec![
1631 make_track("T1", "A", "", Some(0)), make_track("T2", "A", "", Some(86_400)), make_track("T3", "A", "", Some(86_400 * 3)), ]);
1635 let by_date = list.by_date();
1636 assert_eq!(by_date.len(), 3);
1637 assert_eq!(list.streak(), 2);
1639 }
1640
1641 #[test]
1642 fn test_without_now_playing() {
1643 let list = TrackList::from(vec![
1644 make_track("T1", "A", "", Some(100)),
1645 make_now_playing("Live Track", "A"),
1646 ]);
1647 let filtered = list.without_now_playing();
1648 assert_eq!(filtered.len(), 1);
1649 assert_eq!(filtered[0].name, "T1");
1650 }
1651
1652 #[test]
1653 fn test_unique_counts() {
1654 let list = TrackList::from(vec![
1655 make_track("Song", "Artist 1", "", Some(100)),
1656 make_track("Song", "Artist 1", "", Some(200)), make_track("Song", "Artist 2", "", Some(300)), ]);
1659 assert_eq!(list.unique_artist_count(), 2);
1660 assert_eq!(list.unique_track_count(), 2);
1661 }
1662
1663 fn make_ext(name: &str, artist: &str, album: &str, uts: Option<u32>) -> RecentTrackExtended {
1666 RecentTrackExtended {
1667 artist: BaseObject {
1668 name: artist.to_string(),
1669 mbid: String::new(),
1670 url: String::new(),
1671 },
1672 streamable: false,
1673 image: vec![],
1674 album: BaseObject {
1675 name: album.to_string(),
1676 mbid: String::new(),
1677 url: String::new(),
1678 },
1679 attr: None,
1680 date: uts.map(|u| Date {
1681 uts: u,
1682 text: String::new(),
1683 }),
1684 name: name.to_string(),
1685 mbid: String::new(),
1686 url: String::new(),
1687 }
1688 }
1689
1690 fn make_ext_now_playing(name: &str, artist: &str) -> RecentTrackExtended {
1691 use std::collections::HashMap;
1692 RecentTrackExtended {
1693 attr: Some(HashMap::from([(
1694 "nowplaying".to_string(),
1695 "true".to_string(),
1696 )])),
1697 date: None,
1698 ..make_ext(name, artist, "", None)
1699 }
1700 }
1701
1702 #[test]
1703 fn test_ext_to_set() {
1704 let list = TrackList::from(vec![
1705 make_ext("Song A", "Artist 1", "Album", Some(300)),
1706 make_ext("Song B", "Artist 1", "Album", Some(200)),
1707 make_ext("Song A", "Artist 1", "Album", Some(100)),
1708 ]);
1709 let set = list.to_set();
1710 assert_eq!(set.len(), 2);
1711 let top = set.iter().find(|t| t.name == "Song A").unwrap();
1712 assert_eq!(top.play_count, 2);
1713 assert_eq!(top.rank, 1);
1714 assert_eq!(top.artist, "Artist 1");
1715 }
1716
1717 #[test]
1718 fn test_ext_top_artists() {
1719 let list = TrackList::from(vec![
1720 make_ext("T1", "Radiohead", "OK Computer", Some(100)),
1721 make_ext("T2", "Radiohead", "OK Computer", Some(200)),
1722 make_ext("T3", "Portishead", "Dummy", Some(300)),
1723 ]);
1724 let artists = list.top_artists();
1725 assert_eq!(artists.len(), 2);
1726 assert_eq!(artists[0].name, "Radiohead");
1727 assert_eq!(artists[0].play_count, 2);
1728 assert_eq!(artists[0].rank, 1);
1729 }
1730
1731 #[test]
1732 fn test_ext_top_albums_excludes_empty() {
1733 let list = TrackList::from(vec![
1734 make_ext("T1", "Artist", "Dummy", Some(100)),
1735 make_ext("T2", "Artist", "Dummy", Some(200)),
1736 make_ext("T3", "Artist", "", Some(300)),
1737 ]);
1738 let albums = list.top_albums();
1739 assert_eq!(albums.len(), 1);
1740 assert_eq!(albums[0].name, "Dummy");
1741 assert_eq!(albums[0].play_count, 2);
1742 }
1743
1744 #[test]
1745 fn test_ext_by_hour_and_streak() {
1746 let list = TrackList::from(vec![
1747 make_ext("T1", "A", "", Some(3_600)), make_ext("T2", "A", "", Some(86_400)), make_ext("T3", "A", "", Some(86_400 * 3)), ]);
1751 let hours = list.by_hour();
1752 assert_eq!(hours[1], 1); assert_eq!(list.streak(), 2); }
1755
1756 #[test]
1757 fn test_ext_without_now_playing() {
1758 let list = TrackList::from(vec![
1759 make_ext("T1", "A", "", Some(100)),
1760 make_ext_now_playing("Live", "A"),
1761 ]);
1762 let filtered = list.without_now_playing();
1763 assert_eq!(filtered.len(), 1);
1764 assert_eq!(filtered[0].name, "T1");
1765 }
1766
1767 #[test]
1768 fn test_ext_unique_counts() {
1769 let list = TrackList::from(vec![
1770 make_ext("Song", "Artist 1", "", Some(100)),
1771 make_ext("Song", "Artist 1", "", Some(200)),
1772 make_ext("Song", "Artist 2", "", Some(300)),
1773 ]);
1774 assert_eq!(list.unique_artist_count(), 2);
1775 assert_eq!(list.unique_track_count(), 2);
1776 }
1777
1778 #[test]
1779 fn test_date_deserialization() {
1780 use serde_json::json;
1781 let json_value = json!({
1782 "uts": "1_234_567_890",
1783 "#text": "2009-02-13 23:31:30"
1784 });
1785 let date: Date = serde_json::from_value(json_value).unwrap();
1786 assert_eq!(date.uts, 1_234_567_890);
1787 assert_eq!(date.text, "2009-02-13 23:31:30");
1788 }
1789
1790 #[test]
1791 fn test_bool_from_str() {
1792 use serde_json::json;
1793 let json_value = json!({
1795 "artist": {"mbid": "", "#text": "Test"},
1796 "streamable": "1",
1797 "image": [],
1798 "album": {"mbid": "", "#text": ""},
1799 "name": "Test",
1800 "mbid": "",
1801 "url": ""
1802 });
1803 let track: RecentTrack = serde_json::from_value(json_value).unwrap();
1804 assert!(track.streamable);
1805 }
1806
1807 #[test]
1808 fn test_timestamped_trait() {
1809 let track = RecentTrack {
1810 artist: BaseMbidText {
1811 mbid: String::new(),
1812 text: "Artist".to_string(),
1813 },
1814 streamable: false,
1815 image: vec![],
1816 album: BaseMbidText {
1817 mbid: String::new(),
1818 text: String::new(),
1819 },
1820 attr: None,
1821 date: Some(Date {
1822 uts: 1_234_567_890,
1823 text: "test".to_string(),
1824 }),
1825 name: "Track".to_string(),
1826 mbid: String::new(),
1827 url: String::new(),
1828 };
1829
1830 assert_eq!(track.get_timestamp(), Some(1_234_567_890));
1831 }
1832}