Skip to main content

lastfm_client/types/
tracks.rs

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// BASE TYPES =================================================================
13
14/// Basic type containing `MusicBrainz` ID and text content
15///
16/// Used for artist and album information in track responses
17#[derive(Serialize, Deserialize, Debug, Clone)]
18#[non_exhaustive]
19pub struct BaseMbidText {
20    /// `MusicBrainz` Identifier (may be empty string if not available)
21    pub mbid: String,
22    /// Text content (artist name, album name, etc.)
23    #[serde(rename = "#text")]
24    pub text: String,
25}
26
27/// Extended object type with `MusicBrainz` ID, URL, and name
28///
29/// Used for artist and album information in extended track responses
30#[derive(Serialize, Deserialize, Debug, Clone)]
31#[non_exhaustive]
32pub struct BaseObject {
33    /// `MusicBrainz` Identifier (may be empty string if not available)
34    pub mbid: String,
35    /// Last.fm URL for this object
36    #[serde(default)]
37    pub url: String,
38    /// Name of the object (artist name, album name, etc.)
39    #[serde(alias = "#text")]
40    pub name: String,
41}
42
43/// Image information for tracks and albums
44#[derive(Serialize, Deserialize, Debug, Clone)]
45#[non_exhaustive]
46pub struct TrackImage {
47    /// Image size (e.g., "small", "medium", "large", "extralarge")
48    pub size: String,
49    /// URL to the image
50    #[serde(rename = "#text")]
51    pub text: String,
52}
53
54/// Streamability information for a track
55#[derive(Serialize, Deserialize, Debug, Clone)]
56#[non_exhaustive]
57pub struct Streamable {
58    /// Whether the full track is streamable ("0" or "1")
59    pub fulltrack: String,
60    /// Additional streamability information
61    #[serde(rename = "#text")]
62    pub text: String,
63}
64
65/// Detailed artist information
66#[derive(Serialize, Deserialize, Debug, Clone)]
67#[non_exhaustive]
68pub struct Artist {
69    /// Artist name
70    pub name: String,
71    /// `MusicBrainz` Identifier (may be empty string if not available)
72    pub mbid: String,
73    /// Last.fm URL for this artist
74    #[serde(default)]
75    pub url: String,
76    /// Artist images in various sizes
77    pub image: Vec<TrackImage>,
78}
79
80// DATE TYPE ==================================================================
81// Unified - handles both API deserialization and storage
82
83/// Date/timestamp information for tracks
84#[derive(Serialize, Deserialize, Debug, Clone)]
85#[non_exhaustive]
86pub struct Date {
87    /// Unix timestamp in seconds (not milliseconds) since January 1, 1970 UTC
88    #[serde(deserialize_with = "u32_from_str")]
89    pub uts: u32,
90    /// Human-readable date string (e.g., "31 Jan 2024, 12:00")
91    #[serde(rename = "#text")]
92    pub text: String,
93}
94
95// ATTRIBUTES =================================================================
96
97/// Attributes for recent tracks indicating current playback status
98#[derive(Serialize, Deserialize, Debug, Clone)]
99#[non_exhaustive]
100pub struct Attributes {
101    /// Whether this track is currently playing ("true" or "false")
102    pub nowplaying: String,
103}
104
105/// Rank attributes for top tracks
106#[derive(Serialize, Deserialize, Debug, Clone)]
107#[non_exhaustive]
108pub struct RankAttr {
109    /// Numeric rank as a string (e.g., "1", "2", "3")
110    pub rank: String,
111}
112
113// RECENT TRACK ===============================================================
114// Unified - no more ApiRecentTrack vs RecentTrack split!
115
116/// A track from a user's recent listening history
117///
118/// Retrieved from the `user.getrecenttracks` API endpoint
119#[derive(Serialize, Deserialize, Debug, Clone)]
120#[non_exhaustive]
121pub struct RecentTrack {
122    /// Artist information
123    pub artist: BaseMbidText,
124    /// Whether the track is streamable on Last.fm
125    #[serde(deserialize_with = "bool_from_str")]
126    pub streamable: bool,
127    /// Track/album images in various sizes
128    pub image: Vec<TrackImage>,
129    /// Album information
130    pub album: BaseMbidText,
131    /// Attributes (present if track is currently playing)
132    #[serde(rename = "@attr")]
133    pub attr: Option<Attributes>,
134    /// When the track was played (None if currently playing)
135    pub date: Option<Date>,
136    /// Track name
137    pub name: String,
138    /// `MusicBrainz` track identifier (may be empty string)
139    pub mbid: String,
140    /// Last.fm URL for this track
141    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        // None (now playing) is treated as the most recent
181        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/// A track from recent listening history with extended artist/album information
191///
192/// Retrieved when using the `extended=1` parameter with `user.getrecenttracks`
193#[derive(Serialize, Deserialize, Debug, Clone)]
194#[non_exhaustive]
195pub struct RecentTrackExtended {
196    /// Extended artist information (includes URL)
197    pub artist: BaseObject,
198    /// Whether the track is streamable on Last.fm
199    #[serde(deserialize_with = "bool_from_str")]
200    pub streamable: bool,
201    /// Track/album images in various sizes
202    pub image: Vec<TrackImage>,
203    /// Extended album information (includes URL)
204    pub album: BaseObject,
205    /// Additional attributes (format varies, use `HashMap`)
206    #[serde(rename = "@attr")]
207    pub attr: Option<HashMap<String, String>>,
208    /// When the track was played (None if currently playing)
209    pub date: Option<Date>,
210    /// Track name
211    pub name: String,
212    /// `MusicBrainz` track identifier (may be empty string)
213    pub mbid: String,
214    /// Last.fm URL for this track
215    #[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        // None (now playing) is treated as the most recent
257        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// LOVED TRACK ================================================================
267
268/// A track that a user has marked as "loved" on Last.fm
269///
270/// Retrieved from the `user.getlovedtracks` API endpoint
271#[derive(Serialize, Deserialize, Debug, Clone)]
272#[non_exhaustive]
273pub struct LovedTrack {
274    /// Artist information with URL
275    pub artist: BaseObject,
276    /// When the track was loved
277    pub date: Date,
278    /// Track/album images in various sizes
279    pub image: Vec<TrackImage>,
280    /// Streamability information
281    pub streamable: Streamable,
282    /// Track name
283    pub name: String,
284    /// `MusicBrainz` track identifier (may be empty string)
285    pub mbid: String,
286    /// Last.fm URL for this track
287    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// TOP TRACK ==================================================================
321
322/// A track from a user's top tracks, ranked by play count
323///
324/// Retrieved from the `user.gettoptracks` API endpoint
325#[derive(Serialize, Deserialize, Debug, Clone)]
326#[non_exhaustive]
327pub struct TopTrack {
328    /// Streamability information
329    pub streamable: Streamable,
330    /// `MusicBrainz` track identifier (may be empty string)
331    pub mbid: String,
332    /// Track name
333    pub name: String,
334    /// Track/album images in various sizes
335    pub image: Vec<TrackImage>,
336    /// Artist information with URL
337    pub artist: BaseObject,
338    /// Last.fm URL for this track
339    pub url: String,
340    /// Track duration in seconds
341    #[serde(deserialize_with = "u32_from_str")]
342    pub duration: u32,
343    /// Rank attributes (position in top tracks)
344    #[serde(rename = "@attr")]
345    pub attr: RankAttr,
346    /// Total number of times this track has been played
347    #[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// SCORED TRACK ===============================================================
382
383/// A track aggregated from a [`RecentTrack`] list with a computed play count.
384///
385/// This is the output of [`TrackList<RecentTrack>::to_set`]. Tracks sharing
386/// the same `(name, artist)` pair are merged; `play_count` reflects how many
387/// times that track appears in the source list. Useful as a substitute for
388/// the Top Tracks API when the built-in [`crate::types::Period`] options don't
389/// cover the time range you need.
390#[derive(Debug, Clone, Serialize, Deserialize)]
391#[non_exhaustive]
392pub struct ScoredTrack {
393    /// Track name
394    pub name: String,
395    /// Artist name
396    pub artist: String,
397    /// Artist `MusicBrainz` ID (may be empty string)
398    pub artist_mbid: String,
399    /// Album name (may be empty string)
400    pub album: String,
401    /// `MusicBrainz` track ID (may be empty string)
402    pub mbid: String,
403    /// Last.fm URL for this track
404    pub url: String,
405    /// Track images in various sizes
406    pub image: Vec<TrackImage>,
407    /// Number of times this track appears in the source recent tracks list
408    pub play_count: u32,
409    /// 1-indexed rank ordered by `play_count` descending (1 = most played)
410    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// SCORED ARTIST ==============================================================
448
449/// An artist aggregated from a [`RecentTrack`] list with a computed play count.
450///
451/// This is the output of [`TrackList<RecentTrack>::top_artists`]. Every track
452/// by the same artist is counted; `play_count` is the total number of
453/// scrobbles by that artist in the source list.
454#[derive(Debug, Clone, Serialize, Deserialize)]
455#[non_exhaustive]
456pub struct ScoredArtist {
457    /// Artist name
458    pub name: String,
459    /// `MusicBrainz` artist ID (may be empty string)
460    pub mbid: String,
461    /// Number of scrobbles by this artist in the source list
462    pub play_count: u32,
463    /// 1-indexed rank ordered by `play_count` descending (1 = most played)
464    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// SCORED ALBUM ===============================================================
501
502/// An album aggregated from a [`RecentTrack`] list with a computed play count.
503///
504/// This is the output of [`TrackList<RecentTrack>::top_albums`]. Tracks are
505/// grouped by `(album name, artist)` pair; tracks with an empty album field
506/// are excluded.
507#[derive(Debug, Clone, Serialize, Deserialize)]
508#[non_exhaustive]
509pub struct ScoredAlbum {
510    /// Album name
511    pub name: String,
512    /// `MusicBrainz` album ID (may be empty string)
513    pub mbid: String,
514    /// Artist name
515    pub artist: String,
516    /// Number of scrobbles from this album in the source list
517    pub play_count: u32,
518    /// 1-indexed rank ordered by `play_count` descending (1 = most played)
519    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// RESPONSE WRAPPERS ==========================================================
557
558/// Base response metadata included in all paginated API responses
559#[derive(Serialize, Deserialize, Debug, Clone)]
560#[non_exhaustive]
561pub struct BaseResponse {
562    /// Username the request was made for
563    pub user: String,
564    /// Total number of pages available
565    #[serde(deserialize_with = "u32_from_str", rename = "totalPages")]
566    pub total_pages: u32,
567    /// Current page number (1-indexed)
568    #[serde(deserialize_with = "u32_from_str")]
569    pub page: u32,
570    /// Number of items per page
571    #[serde(deserialize_with = "u32_from_str", rename = "perPage")]
572    pub per_page: u32,
573    /// Total number of items available across all pages
574    #[serde(deserialize_with = "u32_from_str")]
575    pub total: u32,
576}
577
578/// Recent tracks response wrapper
579#[derive(Serialize, Deserialize, Debug)]
580#[non_exhaustive]
581pub struct RecentTracks {
582    /// List of recent tracks
583    #[serde(deserialize_with = "vec_or_single")]
584    pub track: Vec<RecentTrack>,
585    /// Response metadata
586    #[serde(rename = "@attr")]
587    pub attr: BaseResponse,
588}
589
590/// Top-level recent tracks API response
591#[derive(Serialize, Deserialize, Debug)]
592#[non_exhaustive]
593pub struct UserRecentTracks {
594    /// Recent tracks data
595    pub recenttracks: RecentTracks,
596}
597
598/// Recent tracks extended response wrapper
599#[derive(Serialize, Deserialize, Debug)]
600#[non_exhaustive]
601pub struct RecentTracksExtended {
602    /// List of extended recent tracks
603    #[serde(deserialize_with = "vec_or_single")]
604    pub track: Vec<RecentTrackExtended>,
605    /// Response metadata
606    #[serde(rename = "@attr")]
607    pub attr: BaseResponse,
608}
609
610/// Top-level extended recent tracks API response
611#[derive(Serialize, Deserialize, Debug)]
612#[non_exhaustive]
613pub struct UserRecentTracksExtended {
614    /// Extended recent tracks data
615    pub recenttracks: RecentTracksExtended,
616}
617
618/// Loved tracks response wrapper
619#[derive(Serialize, Deserialize, Debug, Clone)]
620#[non_exhaustive]
621pub struct LovedTracks {
622    /// List of loved tracks
623    #[serde(deserialize_with = "vec_or_single")]
624    pub track: Vec<LovedTrack>,
625    /// Response metadata
626    #[serde(rename = "@attr")]
627    pub attr: BaseResponse,
628}
629
630/// Top-level loved tracks API response
631#[derive(Serialize, Deserialize, Debug, Clone)]
632#[non_exhaustive]
633pub struct UserLovedTracks {
634    /// Loved tracks data
635    pub lovedtracks: LovedTracks,
636}
637
638/// Top tracks response wrapper
639#[derive(Serialize, Deserialize, Debug, Clone)]
640#[non_exhaustive]
641pub struct TopTracks {
642    /// List of top tracks
643    #[serde(deserialize_with = "vec_or_single")]
644    pub track: Vec<TopTrack>,
645    /// Response metadata
646    #[serde(rename = "@attr")]
647    pub attr: BaseResponse,
648}
649
650/// Top-level top tracks API response
651#[derive(Serialize, Deserialize, Debug, Clone)]
652#[non_exhaustive]
653pub struct UserTopTracks {
654    /// Top tracks data
655    pub toptracks: TopTracks,
656}
657
658// ANALYTICS ==================================================================
659
660/// Represents a track's play count information
661#[derive(Debug, Serialize)]
662#[non_exhaustive]
663pub struct TrackPlayInfo {
664    /// Track name
665    pub name: String,
666    /// Number of times played
667    pub play_count: u32,
668    /// Artist name
669    pub artist: String,
670    /// Album name (if available)
671    pub album: Option<String>,
672    /// Image URL (if available)
673    pub image_url: Option<String>,
674    /// Whether the track is currently playing
675    pub currently_playing: bool,
676    /// Unix timestamp of when played
677    pub date: Option<u32>,
678    /// Last.fm URL
679    pub url: String,
680}
681
682// TRAITS =====================================================================
683
684/// Trait for types that have a timestamp
685pub trait Timestamped {
686    /// Get the timestamp as a Unix epoch in seconds
687    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
708// AGGREGATION ================================================================
709
710impl TrackList<RecentTrack> {
711    /// Aggregate recent tracks into unique entries with computed play counts.
712    ///
713    /// Groups tracks by `(name, artist)` pair and counts occurrences, producing
714    /// a list equivalent to what the Top Tracks API returns — but computed
715    /// locally from your recent listening history. This is particularly useful
716    /// when none of the predefined [`crate::types::Period`] options cover the
717    /// exact date range you care about.
718    ///
719    /// - Tracks are identified by an exact `(name, artist)` match (as returned
720    ///   by Last.fm, which normalises both fields).
721    /// - The representative metadata (image, URL, album, etc.) is taken from
722    ///   the **most recently played** scrobble of that track.
723    /// - Currently-playing tracks (no timestamp) are included in the count.
724    /// - The returned list is sorted by `play_count` descending; `rank` is
725    ///   1-indexed (rank 1 = most played).
726    ///
727    /// # Example
728    ///
729    /// ```ignore
730    /// use chrono::{Duration, Utc};
731    ///
732    /// let now = Utc::now();
733    /// let one_week_ago = now - Duration::weeks(1);
734    ///
735    /// let recent = client
736    ///     .recent_tracks("username")
737    ///     .between(one_week_ago.timestamp(), now.timestamp())
738    ///     .fetch()
739    ///     .await?;
740    ///
741    /// let top = recent.to_set();
742    /// println!("{top}"); // prints tracks sorted by play count
743    /// ```
744    #[must_use]
745    pub fn to_set(&self) -> TrackList<ScoredTrack> {
746        // Map (name, artist) → (representative RecentTrack, count).
747        // The first occurrence is kept as the representative because Last.fm
748        // returns tracks in reverse-chronological order, so index 0 is the
749        // most recently played scrobble of that track.
750        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    /// Aggregate recent tracks by artist, computing play counts.
783    ///
784    /// Groups all tracks by artist name and counts scrobbles per artist.
785    /// Useful as a local substitute for the Top Artists API when the
786    /// built-in [`crate::types::Period`] options don't suit your needs.
787    ///
788    /// The returned list is sorted by play count descending, with 1-indexed
789    /// ranks (rank 1 = most played).
790    ///
791    /// # Example
792    ///
793    /// ```ignore
794    /// let top_artists = recent.top_artists();
795    /// for artist in &top_artists {
796    ///     println!("{artist}"); // "#1 Radiohead (42 plays)"
797    /// }
798    /// ```
799    #[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    /// Aggregate recent tracks by album, computing play counts.
828    ///
829    /// Groups all tracks by `(album name, artist)` pair and counts scrobbles.
830    /// Tracks with an empty album field are excluded. Useful as a local
831    /// substitute for the Top Albums API.
832    ///
833    /// The returned list is sorted by play count descending, with 1-indexed
834    /// ranks (rank 1 = most played).
835    ///
836    /// # Example
837    ///
838    /// ```ignore
839    /// let top_albums = recent.top_albums();
840    /// for album in &top_albums {
841    ///     println!("{album}"); // "#1 OK Computer — Radiohead (12 plays)"
842    /// }
843    /// ```
844    #[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    /// Count plays per hour of the day (UTC).
881    ///
882    /// Returns a fixed-size array of 24 counters indexed by UTC hour
883    /// (index 0 = midnight, index 23 = 11 PM). Currently-playing tracks
884    /// (no timestamp) are excluded.
885    ///
886    /// Useful for building a "listening clock" visualisation.
887    ///
888    /// # Example
889    ///
890    /// ```ignore
891    /// let hours = recent.by_hour();
892    /// let peak = hours.iter().enumerate().max_by_key(|(_, &c)| c);
893    /// if let Some((hour, count)) = peak {
894    ///     println!("Most active at {hour}:00 UTC ({count} plays)");
895    /// }
896    /// ```
897    #[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    /// Count plays per calendar date (UTC).
910    ///
911    /// Returns a [`BTreeMap`] from [`NaiveDate`] to play count, sorted
912    /// chronologically. Currently-playing tracks (no timestamp) are excluded.
913    ///
914    /// Useful for heatmap data, streak analysis, and day-by-day history.
915    ///
916    /// # Example
917    ///
918    /// ```ignore
919    /// use chrono::NaiveDate;
920    ///
921    /// let by_date = recent.by_date();
922    /// for (date, count) in &by_date {
923    ///     println!("{date}: {count} plays");
924    /// }
925    /// ```
926    #[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    /// Return the length of the longest consecutive listening-day streak.
940    ///
941    /// A streak is a run of calendar days (UTC) on which at least one track
942    /// was scrobbled. Returns `0` if the list is empty or contains only
943    /// currently-playing tracks (no timestamps).
944    ///
945    /// # Example
946    ///
947    /// ```ignore
948    /// let streak = recent.streak();
949    /// println!("Longest streak: {streak} day(s)");
950    /// ```
951    #[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        // BTreeMap keys are already sorted chronologically.
959        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    /// Return a new list with the currently-playing track removed.
980    ///
981    /// The currently-playing track is identified by `attr.nowplaying == "true"`
982    /// and has no timestamp. All other tracks are preserved in their original
983    /// order.
984    #[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    /// Count the number of distinct artists in the list.
993    ///
994    /// Artists are matched by their exact name as returned by Last.fm.
995    #[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    /// Count the number of distinct `(track name, artist)` pairs in the list.
1004    #[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    /// Aggregate extended recent tracks into unique entries with computed play counts.
1015    ///
1016    /// Identical semantics to [`TrackList<RecentTrack>::to_set`] but operates on
1017    /// extended track data. Groups by `(name, artist)` pair; the representative
1018    /// metadata is taken from the **most recently played** scrobble of each track.
1019    ///
1020    /// The returned list is sorted by `play_count` descending with 1-indexed ranks.
1021    #[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    /// Aggregate extended recent tracks by artist, computing play counts.
1056    ///
1057    /// Identical semantics to [`TrackList<RecentTrack>::top_artists`].
1058    /// The returned list is sorted by play count descending with 1-indexed ranks.
1059    #[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    /// Aggregate extended recent tracks by album, computing play counts.
1088    ///
1089    /// Identical semantics to [`TrackList<RecentTrack>::top_albums`].
1090    /// Tracks with an empty album field are excluded. The returned list is
1091    /// sorted by play count descending with 1-indexed ranks.
1092    #[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    /// Count plays per hour of the day (UTC).
1129    ///
1130    /// Identical semantics to [`TrackList<RecentTrack>::by_hour`].
1131    /// Returns a fixed-size array of 24 counters indexed by UTC hour (0–23).
1132    /// Currently-playing tracks (no timestamp) are excluded.
1133    #[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    /// Count plays per calendar date (UTC).
1146    ///
1147    /// Identical semantics to [`TrackList<RecentTrack>::by_date`].
1148    /// Returns a [`BTreeMap`] from [`NaiveDate`] to play count, sorted chronologically.
1149    /// Currently-playing tracks (no timestamp) are excluded.
1150    #[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    /// Return the length of the longest consecutive listening-day streak.
1164    ///
1165    /// Identical semantics to [`TrackList<RecentTrack>::streak`].
1166    #[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    /// Return a new list with the currently-playing track removed.
1194    ///
1195    /// For extended tracks the currently-playing entry is identified by
1196    /// `attr["nowplaying"] == "true"`. All other tracks are preserved in
1197    /// their original order.
1198    #[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    /// Count the number of distinct artists in the list.
1211    ///
1212    /// Artists are matched by their exact name as returned by Last.fm.
1213    #[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    /// Count the number of distinct `(track name, artist)` pairs in the list.
1222    #[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// SQLITE EXPORT ==============================================================
1232
1233#[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// SQLITE LOAD ================================================================
1388
1389#[cfg(feature = "sqlite")]
1390impl crate::sqlite::SqliteLoadable for RecentTrack {
1391    fn select_sql() -> &'static str {
1392        // Columns: name(0) url(1) artist(2) artist_mbid(3) album(4) album_mbid(5) date_uts(6)
1393        // NULL date_uts → now-playing; ORDER puts those first (NULLS FIRST in DESC).
1394        "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            // Fields not stored in the schema — reconstructed with defaults.
1417            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        // Columns: name(0) url(1) mbid(2) artist(3) artist_mbid(4) artist_url(5)
1429        //          album(6) album_mbid(7) album_url(8) date_uts(9)
1430        "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            // Fields not stored in the schema — reconstructed with defaults.
1457            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        // Columns: name(0) url(1) artist(2) artist_mbid(3) date_uts(4)
1468        "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            // Fields not stored in the schema — reconstructed with defaults.
1487            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        // Columns: name(0) url(1) artist(2) mbid(3) playcount(4) rank(5)
1501        "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            // Fields not stored in the schema — reconstructed with defaults.
1521            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        // Song A has 2 plays → rank 1
1579        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)), // no album → excluded
1606        ]);
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        // 3600 = 01:00 UTC, 7200 = 02:00 UTC
1616        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)), // also 02:xx
1620        ]);
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        // Day 0 = 1970-01-01, day 1 = 1970-01-02, day 3 = 1970-01-04 (gap)
1630        let list = TrackList::from(vec![
1631            make_track("T1", "A", "", Some(0)),          // 1970-01-01
1632            make_track("T2", "A", "", Some(86_400)),     // 1970-01-02
1633            make_track("T3", "A", "", Some(86_400 * 3)), // 1970-01-04 (gap)
1634        ]);
1635        let by_date = list.by_date();
1636        assert_eq!(by_date.len(), 3);
1637        // Streak: days 01 + 02 = 2; then day 04 alone = 1
1638        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)), // duplicate
1657            make_track("Song", "Artist 2", "", Some(300)), // same name, different artist
1658        ]);
1659        assert_eq!(list.unique_artist_count(), 2);
1660        assert_eq!(list.unique_track_count(), 2);
1661    }
1662
1663    // ── RecentTrackExtended helpers ──────────────────────────────────────────
1664
1665    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)),      // 01:00 UTC
1748            make_ext("T2", "A", "", Some(86_400)),     // 1970-01-02
1749            make_ext("T3", "A", "", Some(86_400 * 3)), // 1970-01-04 (gap)
1750        ]);
1751        let hours = list.by_hour();
1752        assert_eq!(hours[1], 1); // one play at 01:xx
1753        assert_eq!(list.streak(), 2); // days 01+02, then gap
1754    }
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        // Test that "1" deserializes to true
1794        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}