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};
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    pub track: Vec<RecentTrack>,
584    /// Response metadata
585    #[serde(rename = "@attr")]
586    pub attr: BaseResponse,
587}
588
589/// Top-level recent tracks API response
590#[derive(Serialize, Deserialize, Debug)]
591#[non_exhaustive]
592pub struct UserRecentTracks {
593    /// Recent tracks data
594    pub recenttracks: RecentTracks,
595}
596
597/// Recent tracks extended response wrapper
598#[derive(Serialize, Deserialize, Debug)]
599#[non_exhaustive]
600pub struct RecentTracksExtended {
601    /// List of extended recent tracks
602    pub track: Vec<RecentTrackExtended>,
603    /// Response metadata
604    #[serde(rename = "@attr")]
605    pub attr: BaseResponse,
606}
607
608/// Top-level extended recent tracks API response
609#[derive(Serialize, Deserialize, Debug)]
610#[non_exhaustive]
611pub struct UserRecentTracksExtended {
612    /// Extended recent tracks data
613    pub recenttracks: RecentTracksExtended,
614}
615
616/// Loved tracks response wrapper
617#[derive(Serialize, Deserialize, Debug, Clone)]
618#[non_exhaustive]
619pub struct LovedTracks {
620    /// List of loved tracks
621    pub track: Vec<LovedTrack>,
622    /// Response metadata
623    #[serde(rename = "@attr")]
624    pub attr: BaseResponse,
625}
626
627/// Top-level loved tracks API response
628#[derive(Serialize, Deserialize, Debug, Clone)]
629#[non_exhaustive]
630pub struct UserLovedTracks {
631    /// Loved tracks data
632    pub lovedtracks: LovedTracks,
633}
634
635/// Top tracks response wrapper
636#[derive(Serialize, Deserialize, Debug, Clone)]
637#[non_exhaustive]
638pub struct TopTracks {
639    /// List of top tracks
640    pub track: Vec<TopTrack>,
641    /// Response metadata
642    #[serde(rename = "@attr")]
643    pub attr: BaseResponse,
644}
645
646/// Top-level top tracks API response
647#[derive(Serialize, Deserialize, Debug, Clone)]
648#[non_exhaustive]
649pub struct UserTopTracks {
650    /// Top tracks data
651    pub toptracks: TopTracks,
652}
653
654// ANALYTICS ==================================================================
655
656/// Represents a track's play count information
657#[derive(Debug, Serialize)]
658#[non_exhaustive]
659pub struct TrackPlayInfo {
660    /// Track name
661    pub name: String,
662    /// Number of times played
663    pub play_count: u32,
664    /// Artist name
665    pub artist: String,
666    /// Album name (if available)
667    pub album: Option<String>,
668    /// Image URL (if available)
669    pub image_url: Option<String>,
670    /// Whether the track is currently playing
671    pub currently_playing: bool,
672    /// Unix timestamp of when played
673    pub date: Option<u32>,
674    /// Last.fm URL
675    pub url: String,
676}
677
678// TRAITS =====================================================================
679
680/// Trait for types that have a timestamp
681pub trait Timestamped {
682    /// Get the timestamp as a Unix epoch in seconds
683    fn get_timestamp(&self) -> Option<u32>;
684}
685
686impl Timestamped for RecentTrack {
687    fn get_timestamp(&self) -> Option<u32> {
688        self.date.as_ref().map(|d| d.uts)
689    }
690}
691
692impl Timestamped for LovedTrack {
693    fn get_timestamp(&self) -> Option<u32> {
694        Some(self.date.uts)
695    }
696}
697
698impl Timestamped for RecentTrackExtended {
699    fn get_timestamp(&self) -> Option<u32> {
700        self.date.as_ref().map(|d| d.uts)
701    }
702}
703
704// AGGREGATION ================================================================
705
706impl TrackList<RecentTrack> {
707    /// Aggregate recent tracks into unique entries with computed play counts.
708    ///
709    /// Groups tracks by `(name, artist)` pair and counts occurrences, producing
710    /// a list equivalent to what the Top Tracks API returns — but computed
711    /// locally from your recent listening history. This is particularly useful
712    /// when none of the predefined [`crate::types::Period`] options cover the
713    /// exact date range you care about.
714    ///
715    /// - Tracks are identified by an exact `(name, artist)` match (as returned
716    ///   by Last.fm, which normalises both fields).
717    /// - The representative metadata (image, URL, album, etc.) is taken from
718    ///   the **most recently played** scrobble of that track.
719    /// - Currently-playing tracks (no timestamp) are included in the count.
720    /// - The returned list is sorted by `play_count` descending; `rank` is
721    ///   1-indexed (rank 1 = most played).
722    ///
723    /// # Example
724    ///
725    /// ```ignore
726    /// use chrono::{Duration, Utc};
727    ///
728    /// let now = Utc::now();
729    /// let one_week_ago = now - Duration::weeks(1);
730    ///
731    /// let recent = client
732    ///     .recent_tracks("username")
733    ///     .between(one_week_ago.timestamp(), now.timestamp())
734    ///     .fetch()
735    ///     .await?;
736    ///
737    /// let top = recent.to_set();
738    /// println!("{top}"); // prints tracks sorted by play count
739    /// ```
740    #[must_use]
741    pub fn to_set(&self) -> TrackList<ScoredTrack> {
742        // Map (name, artist) → (representative RecentTrack, count).
743        // The first occurrence is kept as the representative because Last.fm
744        // returns tracks in reverse-chronological order, so index 0 is the
745        // most recently played scrobble of that track.
746        let mut groups: HashMap<(String, String), (RecentTrack, u32)> = HashMap::new();
747
748        for track in self {
749            let key = (track.name.clone(), track.artist.text.clone());
750            let entry = groups.entry(key).or_insert_with(|| (track.clone(), 0));
751            entry.1 += 1;
752        }
753
754        let mut scored: Vec<ScoredTrack> = groups
755            .into_values()
756            .map(|(rep, play_count)| ScoredTrack {
757                name: rep.name,
758                artist: rep.artist.text,
759                artist_mbid: rep.artist.mbid,
760                album: rep.album.text,
761                mbid: rep.mbid,
762                url: rep.url,
763                image: rep.image,
764                play_count,
765                rank: 0,
766            })
767            .collect();
768
769        scored.sort_unstable_by(|a, b| b.play_count.cmp(&a.play_count));
770
771        for (i, track) in scored.iter_mut().enumerate() {
772            track.rank = u32::try_from(i).map_or(u32::MAX, |n| n.saturating_add(1));
773        }
774
775        TrackList::from(scored)
776    }
777
778    /// Aggregate recent tracks by artist, computing play counts.
779    ///
780    /// Groups all tracks by artist name and counts scrobbles per artist.
781    /// Useful as a local substitute for the Top Artists API when the
782    /// built-in [`crate::types::Period`] options don't suit your needs.
783    ///
784    /// The returned list is sorted by play count descending, with 1-indexed
785    /// ranks (rank 1 = most played).
786    ///
787    /// # Example
788    ///
789    /// ```ignore
790    /// let top_artists = recent.top_artists();
791    /// for artist in &top_artists {
792    ///     println!("{artist}"); // "#1 Radiohead (42 plays)"
793    /// }
794    /// ```
795    #[must_use]
796    pub fn top_artists(&self) -> TrackList<ScoredArtist> {
797        let mut groups: HashMap<(String, String), u32> = HashMap::new();
798
799        for track in self {
800            let key = (track.artist.text.clone(), track.artist.mbid.clone());
801            *groups.entry(key).or_insert(0) += 1;
802        }
803
804        let mut scored: Vec<ScoredArtist> = groups
805            .into_iter()
806            .map(|((name, mbid), play_count)| ScoredArtist {
807                name,
808                mbid,
809                play_count,
810                rank: 0,
811            })
812            .collect();
813
814        scored.sort_unstable_by(|a, b| b.play_count.cmp(&a.play_count));
815
816        for (i, artist) in scored.iter_mut().enumerate() {
817            artist.rank = u32::try_from(i).map_or(u32::MAX, |n| n.saturating_add(1));
818        }
819
820        TrackList::from(scored)
821    }
822
823    /// Aggregate recent tracks by album, computing play counts.
824    ///
825    /// Groups all tracks by `(album name, artist)` pair and counts scrobbles.
826    /// Tracks with an empty album field are excluded. Useful as a local
827    /// substitute for the Top Albums API.
828    ///
829    /// The returned list is sorted by play count descending, with 1-indexed
830    /// ranks (rank 1 = most played).
831    ///
832    /// # Example
833    ///
834    /// ```ignore
835    /// let top_albums = recent.top_albums();
836    /// for album in &top_albums {
837    ///     println!("{album}"); // "#1 OK Computer — Radiohead (12 plays)"
838    /// }
839    /// ```
840    #[must_use]
841    pub fn top_albums(&self) -> TrackList<ScoredAlbum> {
842        let mut groups: HashMap<(String, String, String), u32> = HashMap::new();
843
844        for track in self {
845            if track.album.text.is_empty() {
846                continue;
847            }
848            let key = (
849                track.album.text.clone(),
850                track.album.mbid.clone(),
851                track.artist.text.clone(),
852            );
853            *groups.entry(key).or_insert(0) += 1;
854        }
855
856        let mut scored: Vec<ScoredAlbum> = groups
857            .into_iter()
858            .map(|((name, mbid, artist), play_count)| ScoredAlbum {
859                name,
860                mbid,
861                artist,
862                play_count,
863                rank: 0,
864            })
865            .collect();
866
867        scored.sort_unstable_by(|a, b| b.play_count.cmp(&a.play_count));
868
869        for (i, album) in scored.iter_mut().enumerate() {
870            album.rank = u32::try_from(i).map_or(u32::MAX, |n| n.saturating_add(1));
871        }
872
873        TrackList::from(scored)
874    }
875
876    /// Count plays per hour of the day (UTC).
877    ///
878    /// Returns a fixed-size array of 24 counters indexed by UTC hour
879    /// (index 0 = midnight, index 23 = 11 PM). Currently-playing tracks
880    /// (no timestamp) are excluded.
881    ///
882    /// Useful for building a "listening clock" visualisation.
883    ///
884    /// # Example
885    ///
886    /// ```ignore
887    /// let hours = recent.by_hour();
888    /// let peak = hours.iter().enumerate().max_by_key(|(_, &c)| c);
889    /// if let Some((hour, count)) = peak {
890    ///     println!("Most active at {hour}:00 UTC ({count} plays)");
891    /// }
892    /// ```
893    #[must_use]
894    pub fn by_hour(&self) -> [u32; 24] {
895        let mut counts = [0u32; 24];
896        for track in self {
897            if let Some(date) = &track.date {
898                let hour = usize::try_from(date.uts % 86_400 / 3600).unwrap_or(0);
899                counts[hour] = counts[hour].saturating_add(1);
900            }
901        }
902        counts
903    }
904
905    /// Count plays per calendar date (UTC).
906    ///
907    /// Returns a [`BTreeMap`] from [`NaiveDate`] to play count, sorted
908    /// chronologically. Currently-playing tracks (no timestamp) are excluded.
909    ///
910    /// Useful for heatmap data, streak analysis, and day-by-day history.
911    ///
912    /// # Example
913    ///
914    /// ```ignore
915    /// use chrono::NaiveDate;
916    ///
917    /// let by_date = recent.by_date();
918    /// for (date, count) in &by_date {
919    ///     println!("{date}: {count} plays");
920    /// }
921    /// ```
922    #[must_use]
923    pub fn by_date(&self) -> BTreeMap<NaiveDate, u32> {
924        let mut counts: BTreeMap<NaiveDate, u32> = BTreeMap::new();
925        for track in self {
926            if let Some(date) = &track.date
927                && let Some(dt) = DateTime::<Utc>::from_timestamp(i64::from(date.uts), 0)
928            {
929                *counts.entry(dt.date_naive()).or_insert(0) += 1;
930            }
931        }
932        counts
933    }
934
935    /// Return the length of the longest consecutive listening-day streak.
936    ///
937    /// A streak is a run of calendar days (UTC) on which at least one track
938    /// was scrobbled. Returns `0` if the list is empty or contains only
939    /// currently-playing tracks (no timestamps).
940    ///
941    /// # Example
942    ///
943    /// ```ignore
944    /// let streak = recent.streak();
945    /// println!("Longest streak: {streak} day(s)");
946    /// ```
947    #[must_use]
948    pub fn streak(&self) -> u32 {
949        let dates = self.by_date();
950        if dates.is_empty() {
951            return 0;
952        }
953
954        // BTreeMap keys are already sorted chronologically.
955        let sorted: Vec<NaiveDate> = dates.into_keys().collect();
956        let mut max_streak = 1u32;
957        let mut current = 1u32;
958
959        for window in sorted.windows(2) {
960            if let [prev, next] = window {
961                if next.signed_duration_since(*prev).num_days() == 1 {
962                    current += 1;
963                    if current > max_streak {
964                        max_streak = current;
965                    }
966                } else {
967                    current = 1;
968                }
969            }
970        }
971
972        max_streak
973    }
974
975    /// Return a new list with the currently-playing track removed.
976    ///
977    /// The currently-playing track is identified by `attr.nowplaying == "true"`
978    /// and has no timestamp. All other tracks are preserved in their original
979    /// order.
980    #[must_use]
981    pub fn without_now_playing(&self) -> Self {
982        self.iter()
983            .filter(|t| t.attr.as_ref().is_none_or(|a| a.nowplaying != "true"))
984            .cloned()
985            .collect()
986    }
987
988    /// Count the number of distinct artists in the list.
989    ///
990    /// Artists are matched by their exact name as returned by Last.fm.
991    #[must_use]
992    pub fn unique_artist_count(&self) -> usize {
993        self.iter()
994            .map(|t| &t.artist.text)
995            .collect::<std::collections::HashSet<_>>()
996            .len()
997    }
998
999    /// Count the number of distinct `(track name, artist)` pairs in the list.
1000    #[must_use]
1001    pub fn unique_track_count(&self) -> usize {
1002        self.iter()
1003            .map(|t| (&t.name, &t.artist.text))
1004            .collect::<std::collections::HashSet<_>>()
1005            .len()
1006    }
1007}
1008
1009impl TrackList<RecentTrackExtended> {
1010    /// Aggregate extended recent tracks into unique entries with computed play counts.
1011    ///
1012    /// Identical semantics to [`TrackList<RecentTrack>::to_set`] but operates on
1013    /// extended track data. Groups by `(name, artist)` pair; the representative
1014    /// metadata is taken from the **most recently played** scrobble of each track.
1015    ///
1016    /// The returned list is sorted by `play_count` descending with 1-indexed ranks.
1017    #[must_use]
1018    pub fn to_set(&self) -> TrackList<ScoredTrack> {
1019        let mut groups: HashMap<(String, String), (RecentTrackExtended, u32)> = HashMap::new();
1020
1021        for track in self {
1022            let key = (track.name.clone(), track.artist.name.clone());
1023            let entry = groups.entry(key).or_insert_with(|| (track.clone(), 0));
1024            entry.1 += 1;
1025        }
1026
1027        let mut scored: Vec<ScoredTrack> = groups
1028            .into_values()
1029            .map(|(rep, play_count)| ScoredTrack {
1030                name: rep.name,
1031                artist: rep.artist.name,
1032                artist_mbid: rep.artist.mbid,
1033                album: rep.album.name,
1034                mbid: rep.mbid,
1035                url: rep.url,
1036                image: rep.image,
1037                play_count,
1038                rank: 0,
1039            })
1040            .collect();
1041
1042        scored.sort_unstable_by(|a, b| b.play_count.cmp(&a.play_count));
1043
1044        for (i, track) in scored.iter_mut().enumerate() {
1045            track.rank = u32::try_from(i).map_or(u32::MAX, |n| n.saturating_add(1));
1046        }
1047
1048        TrackList::from(scored)
1049    }
1050
1051    /// Aggregate extended recent tracks by artist, computing play counts.
1052    ///
1053    /// Identical semantics to [`TrackList<RecentTrack>::top_artists`].
1054    /// The returned list is sorted by play count descending with 1-indexed ranks.
1055    #[must_use]
1056    pub fn top_artists(&self) -> TrackList<ScoredArtist> {
1057        let mut groups: HashMap<(String, String), u32> = HashMap::new();
1058
1059        for track in self {
1060            let key = (track.artist.name.clone(), track.artist.mbid.clone());
1061            *groups.entry(key).or_insert(0) += 1;
1062        }
1063
1064        let mut scored: Vec<ScoredArtist> = groups
1065            .into_iter()
1066            .map(|((name, mbid), play_count)| ScoredArtist {
1067                name,
1068                mbid,
1069                play_count,
1070                rank: 0,
1071            })
1072            .collect();
1073
1074        scored.sort_unstable_by(|a, b| b.play_count.cmp(&a.play_count));
1075
1076        for (i, artist) in scored.iter_mut().enumerate() {
1077            artist.rank = u32::try_from(i).map_or(u32::MAX, |n| n.saturating_add(1));
1078        }
1079
1080        TrackList::from(scored)
1081    }
1082
1083    /// Aggregate extended recent tracks by album, computing play counts.
1084    ///
1085    /// Identical semantics to [`TrackList<RecentTrack>::top_albums`].
1086    /// Tracks with an empty album field are excluded. The returned list is
1087    /// sorted by play count descending with 1-indexed ranks.
1088    #[must_use]
1089    pub fn top_albums(&self) -> TrackList<ScoredAlbum> {
1090        let mut groups: HashMap<(String, String, String), u32> = HashMap::new();
1091
1092        for track in self {
1093            if track.album.name.is_empty() {
1094                continue;
1095            }
1096            let key = (
1097                track.album.name.clone(),
1098                track.album.mbid.clone(),
1099                track.artist.name.clone(),
1100            );
1101            *groups.entry(key).or_insert(0) += 1;
1102        }
1103
1104        let mut scored: Vec<ScoredAlbum> = groups
1105            .into_iter()
1106            .map(|((name, mbid, artist), play_count)| ScoredAlbum {
1107                name,
1108                mbid,
1109                artist,
1110                play_count,
1111                rank: 0,
1112            })
1113            .collect();
1114
1115        scored.sort_unstable_by(|a, b| b.play_count.cmp(&a.play_count));
1116
1117        for (i, album) in scored.iter_mut().enumerate() {
1118            album.rank = u32::try_from(i).map_or(u32::MAX, |n| n.saturating_add(1));
1119        }
1120
1121        TrackList::from(scored)
1122    }
1123
1124    /// Count plays per hour of the day (UTC).
1125    ///
1126    /// Identical semantics to [`TrackList<RecentTrack>::by_hour`].
1127    /// Returns a fixed-size array of 24 counters indexed by UTC hour (0–23).
1128    /// Currently-playing tracks (no timestamp) are excluded.
1129    #[must_use]
1130    pub fn by_hour(&self) -> [u32; 24] {
1131        let mut counts = [0u32; 24];
1132        for track in self {
1133            if let Some(date) = &track.date {
1134                let hour = usize::try_from(date.uts % 86_400 / 3600).unwrap_or(0);
1135                counts[hour] = counts[hour].saturating_add(1);
1136            }
1137        }
1138        counts
1139    }
1140
1141    /// Count plays per calendar date (UTC).
1142    ///
1143    /// Identical semantics to [`TrackList<RecentTrack>::by_date`].
1144    /// Returns a [`BTreeMap`] from [`NaiveDate`] to play count, sorted chronologically.
1145    /// Currently-playing tracks (no timestamp) are excluded.
1146    #[must_use]
1147    pub fn by_date(&self) -> BTreeMap<NaiveDate, u32> {
1148        let mut counts: BTreeMap<NaiveDate, u32> = BTreeMap::new();
1149        for track in self {
1150            if let Some(date) = &track.date
1151                && let Some(dt) = DateTime::<Utc>::from_timestamp(i64::from(date.uts), 0)
1152            {
1153                *counts.entry(dt.date_naive()).or_insert(0) += 1;
1154            }
1155        }
1156        counts
1157    }
1158
1159    /// Return the length of the longest consecutive listening-day streak.
1160    ///
1161    /// Identical semantics to [`TrackList<RecentTrack>::streak`].
1162    #[must_use]
1163    pub fn streak(&self) -> u32 {
1164        let dates = self.by_date();
1165        if dates.is_empty() {
1166            return 0;
1167        }
1168
1169        let sorted: Vec<NaiveDate> = dates.into_keys().collect();
1170        let mut max_streak = 1u32;
1171        let mut current = 1u32;
1172
1173        for window in sorted.windows(2) {
1174            if let [prev, next] = window {
1175                if next.signed_duration_since(*prev).num_days() == 1 {
1176                    current += 1;
1177                    if current > max_streak {
1178                        max_streak = current;
1179                    }
1180                } else {
1181                    current = 1;
1182                }
1183            }
1184        }
1185
1186        max_streak
1187    }
1188
1189    /// Return a new list with the currently-playing track removed.
1190    ///
1191    /// For extended tracks the currently-playing entry is identified by
1192    /// `attr["nowplaying"] == "true"`. All other tracks are preserved in
1193    /// their original order.
1194    #[must_use]
1195    pub fn without_now_playing(&self) -> Self {
1196        self.iter()
1197            .filter(|t| {
1198                t.attr
1199                    .as_ref()
1200                    .is_none_or(|a| a.get("nowplaying").is_none_or(|v| v != "true"))
1201            })
1202            .cloned()
1203            .collect()
1204    }
1205
1206    /// Count the number of distinct artists in the list.
1207    ///
1208    /// Artists are matched by their exact name as returned by Last.fm.
1209    #[must_use]
1210    pub fn unique_artist_count(&self) -> usize {
1211        self.iter()
1212            .map(|t| &t.artist.name)
1213            .collect::<std::collections::HashSet<_>>()
1214            .len()
1215    }
1216
1217    /// Count the number of distinct `(track name, artist)` pairs in the list.
1218    #[must_use]
1219    pub fn unique_track_count(&self) -> usize {
1220        self.iter()
1221            .map(|t| (&t.name, &t.artist.name))
1222            .collect::<std::collections::HashSet<_>>()
1223            .len()
1224    }
1225}
1226
1227// SQLITE EXPORT ==============================================================
1228
1229#[cfg(feature = "sqlite")]
1230impl crate::sqlite::SqliteExportable for RecentTrack {
1231    fn table_name() -> &'static str {
1232        "recent_tracks"
1233    }
1234
1235    fn create_table_sql() -> &'static str {
1236        "CREATE TABLE IF NOT EXISTS recent_tracks (
1237            id         INTEGER PRIMARY KEY AUTOINCREMENT,
1238            name       TEXT    NOT NULL,
1239            url        TEXT    NOT NULL,
1240            artist     TEXT    NOT NULL,
1241            artist_mbid TEXT   NOT NULL,
1242            album      TEXT    NOT NULL,
1243            album_mbid TEXT    NOT NULL,
1244            date_uts   INTEGER,
1245            loved      INTEGER NOT NULL DEFAULT 0
1246        )"
1247    }
1248
1249    fn insert_sql() -> &'static str {
1250        "INSERT INTO recent_tracks (name, url, artist, artist_mbid, album, album_mbid, date_uts, loved)
1251         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"
1252    }
1253
1254    fn bind_and_execute(&self, stmt: &mut rusqlite::Statement<'_>) -> rusqlite::Result<usize> {
1255        stmt.execute(rusqlite::params![
1256            self.name,
1257            self.url,
1258            self.artist.text,
1259            self.artist.mbid,
1260            self.album.text,
1261            self.album.mbid,
1262            self.date.as_ref().map(|d| d.uts),
1263            0_i32,
1264        ])
1265    }
1266}
1267
1268#[cfg(feature = "sqlite")]
1269impl crate::sqlite::SqliteExportable for RecentTrackExtended {
1270    fn table_name() -> &'static str {
1271        "recent_tracks_extended"
1272    }
1273
1274    fn create_table_sql() -> &'static str {
1275        "CREATE TABLE IF NOT EXISTS recent_tracks_extended (
1276            id          INTEGER PRIMARY KEY AUTOINCREMENT,
1277            name        TEXT    NOT NULL,
1278            url         TEXT    NOT NULL,
1279            mbid        TEXT    NOT NULL,
1280            artist      TEXT    NOT NULL,
1281            artist_mbid TEXT    NOT NULL,
1282            artist_url  TEXT    NOT NULL,
1283            album       TEXT    NOT NULL,
1284            album_mbid  TEXT    NOT NULL,
1285            album_url   TEXT    NOT NULL,
1286            date_uts    INTEGER,
1287            loved       INTEGER NOT NULL DEFAULT 0
1288        )"
1289    }
1290
1291    fn insert_sql() -> &'static str {
1292        "INSERT INTO recent_tracks_extended
1293             (name, url, mbid, artist, artist_mbid, artist_url, album, album_mbid, album_url, date_uts, loved)
1294         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)"
1295    }
1296
1297    fn bind_and_execute(&self, stmt: &mut rusqlite::Statement<'_>) -> rusqlite::Result<usize> {
1298        stmt.execute(rusqlite::params![
1299            self.name,
1300            self.url,
1301            self.mbid,
1302            self.artist.name,
1303            self.artist.mbid,
1304            self.artist.url,
1305            self.album.name,
1306            self.album.mbid,
1307            self.album.url,
1308            self.date.as_ref().map(|d| d.uts),
1309            0_i32,
1310        ])
1311    }
1312}
1313
1314#[cfg(feature = "sqlite")]
1315impl crate::sqlite::SqliteExportable for LovedTrack {
1316    fn table_name() -> &'static str {
1317        "loved_tracks"
1318    }
1319
1320    fn create_table_sql() -> &'static str {
1321        "CREATE TABLE IF NOT EXISTS loved_tracks (
1322            id          INTEGER PRIMARY KEY AUTOINCREMENT,
1323            name        TEXT    NOT NULL,
1324            url         TEXT    NOT NULL,
1325            artist      TEXT    NOT NULL,
1326            artist_mbid TEXT    NOT NULL,
1327            date_uts    INTEGER NOT NULL
1328        )"
1329    }
1330
1331    fn insert_sql() -> &'static str {
1332        "INSERT INTO loved_tracks (name, url, artist, artist_mbid, date_uts)
1333         VALUES (?1, ?2, ?3, ?4, ?5)"
1334    }
1335
1336    fn bind_and_execute(&self, stmt: &mut rusqlite::Statement<'_>) -> rusqlite::Result<usize> {
1337        stmt.execute(rusqlite::params![
1338            self.name,
1339            self.url,
1340            self.artist.name,
1341            self.artist.mbid,
1342            self.date.uts,
1343        ])
1344    }
1345}
1346
1347#[cfg(feature = "sqlite")]
1348impl crate::sqlite::SqliteExportable for TopTrack {
1349    fn table_name() -> &'static str {
1350        "top_tracks"
1351    }
1352
1353    fn create_table_sql() -> &'static str {
1354        "CREATE TABLE IF NOT EXISTS top_tracks (
1355            id        INTEGER PRIMARY KEY AUTOINCREMENT,
1356            name      TEXT    NOT NULL,
1357            url       TEXT    NOT NULL,
1358            artist    TEXT    NOT NULL,
1359            mbid      TEXT    NOT NULL,
1360            playcount INTEGER NOT NULL,
1361            rank      INTEGER NOT NULL
1362        )"
1363    }
1364
1365    fn insert_sql() -> &'static str {
1366        "INSERT INTO top_tracks (name, url, artist, mbid, playcount, rank)
1367         VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
1368    }
1369
1370    fn bind_and_execute(&self, stmt: &mut rusqlite::Statement<'_>) -> rusqlite::Result<usize> {
1371        let rank: u32 = self.attr.rank.parse().unwrap_or_default();
1372        stmt.execute(rusqlite::params![
1373            self.name,
1374            self.url,
1375            self.artist.name,
1376            self.mbid,
1377            self.playcount,
1378            rank,
1379        ])
1380    }
1381}
1382
1383// SQLITE LOAD ================================================================
1384
1385#[cfg(feature = "sqlite")]
1386impl crate::sqlite::SqliteLoadable for RecentTrack {
1387    fn select_sql() -> &'static str {
1388        // Columns: name(0) url(1) artist(2) artist_mbid(3) album(4) album_mbid(5) date_uts(6)
1389        // NULL date_uts → now-playing; ORDER puts those first (NULLS FIRST in DESC).
1390        "SELECT name, url, artist, artist_mbid, album, album_mbid, date_uts
1391         FROM recent_tracks
1392         ORDER BY CASE WHEN date_uts IS NULL THEN 0 ELSE 1 END, date_uts DESC"
1393    }
1394
1395    fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
1396        let date_uts: Option<u32> = row.get(6)?;
1397        Ok(Self {
1398            name: row.get(0)?,
1399            url: row.get(1)?,
1400            artist: BaseMbidText {
1401                text: row.get(2)?,
1402                mbid: row.get(3)?,
1403            },
1404            album: BaseMbidText {
1405                text: row.get(4)?,
1406                mbid: row.get(5)?,
1407            },
1408            date: date_uts.map(|uts| Date {
1409                uts,
1410                text: String::new(),
1411            }),
1412            // Fields not stored in the schema — reconstructed with defaults.
1413            mbid: String::new(),
1414            streamable: false,
1415            image: vec![],
1416            attr: None,
1417        })
1418    }
1419}
1420
1421#[cfg(feature = "sqlite")]
1422impl crate::sqlite::SqliteLoadable for RecentTrackExtended {
1423    fn select_sql() -> &'static str {
1424        // Columns: name(0) url(1) mbid(2) artist(3) artist_mbid(4) artist_url(5)
1425        //          album(6) album_mbid(7) album_url(8) date_uts(9)
1426        "SELECT name, url, mbid, artist, artist_mbid, artist_url,
1427                album, album_mbid, album_url, date_uts
1428         FROM recent_tracks_extended
1429         ORDER BY CASE WHEN date_uts IS NULL THEN 0 ELSE 1 END, date_uts DESC"
1430    }
1431
1432    fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
1433        let date_uts: Option<u32> = row.get(9)?;
1434        Ok(Self {
1435            name: row.get(0)?,
1436            url: row.get(1)?,
1437            mbid: row.get(2)?,
1438            artist: BaseObject {
1439                name: row.get(3)?,
1440                mbid: row.get(4)?,
1441                url: row.get(5)?,
1442            },
1443            album: BaseObject {
1444                name: row.get(6)?,
1445                mbid: row.get(7)?,
1446                url: row.get(8)?,
1447            },
1448            date: date_uts.map(|uts| Date {
1449                uts,
1450                text: String::new(),
1451            }),
1452            // Fields not stored in the schema — reconstructed with defaults.
1453            streamable: false,
1454            image: vec![],
1455            attr: None,
1456        })
1457    }
1458}
1459
1460#[cfg(feature = "sqlite")]
1461impl crate::sqlite::SqliteLoadable for LovedTrack {
1462    fn select_sql() -> &'static str {
1463        // Columns: name(0) url(1) artist(2) artist_mbid(3) date_uts(4)
1464        "SELECT name, url, artist, artist_mbid, date_uts
1465         FROM loved_tracks
1466         ORDER BY date_uts DESC"
1467    }
1468
1469    fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
1470        Ok(Self {
1471            name: row.get(0)?,
1472            url: row.get(1)?,
1473            artist: BaseObject {
1474                name: row.get(2)?,
1475                mbid: row.get(3)?,
1476                url: String::new(),
1477            },
1478            date: Date {
1479                uts: row.get(4)?,
1480                text: String::new(),
1481            },
1482            // Fields not stored in the schema — reconstructed with defaults.
1483            mbid: String::new(),
1484            image: vec![],
1485            streamable: Streamable {
1486                fulltrack: String::new(),
1487                text: String::new(),
1488            },
1489        })
1490    }
1491}
1492
1493#[cfg(feature = "sqlite")]
1494impl crate::sqlite::SqliteLoadable for TopTrack {
1495    fn select_sql() -> &'static str {
1496        // Columns: name(0) url(1) artist(2) mbid(3) playcount(4) rank(5)
1497        "SELECT name, url, artist, mbid, playcount, rank
1498         FROM top_tracks
1499         ORDER BY rank ASC"
1500    }
1501
1502    fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
1503        Ok(Self {
1504            name: row.get(0)?,
1505            url: row.get(1)?,
1506            artist: BaseObject {
1507                name: row.get(2)?,
1508                mbid: String::new(),
1509                url: String::new(),
1510            },
1511            mbid: row.get(3)?,
1512            playcount: row.get(4)?,
1513            attr: RankAttr {
1514                rank: row.get::<_, u32>(5)?.to_string(),
1515            },
1516            // Fields not stored in the schema — reconstructed with defaults.
1517            duration: 0,
1518            streamable: Streamable {
1519                fulltrack: String::new(),
1520                text: String::new(),
1521            },
1522            image: vec![],
1523        })
1524    }
1525}
1526
1527#[cfg(test)]
1528#[allow(clippy::unwrap_used)]
1529mod tests {
1530    use super::*;
1531
1532    fn make_track(name: &str, artist: &str, album: &str, uts: Option<u32>) -> RecentTrack {
1533        RecentTrack {
1534            artist: BaseMbidText {
1535                mbid: String::new(),
1536                text: artist.to_string(),
1537            },
1538            streamable: false,
1539            image: vec![],
1540            album: BaseMbidText {
1541                mbid: String::new(),
1542                text: album.to_string(),
1543            },
1544            attr: None,
1545            date: uts.map(|u| Date {
1546                uts: u,
1547                text: String::new(),
1548            }),
1549            name: name.to_string(),
1550            mbid: String::new(),
1551            url: String::new(),
1552        }
1553    }
1554
1555    fn make_now_playing(name: &str, artist: &str) -> RecentTrack {
1556        RecentTrack {
1557            attr: Some(Attributes {
1558                nowplaying: "true".to_string(),
1559            }),
1560            date: None,
1561            ..make_track(name, artist, "", None)
1562        }
1563    }
1564
1565    #[test]
1566    fn test_to_set_counts_and_ranks() {
1567        let list = TrackList::from(vec![
1568            make_track("Song A", "Artist 1", "Album", Some(300)),
1569            make_track("Song B", "Artist 1", "Album", Some(200)),
1570            make_track("Song A", "Artist 1", "Album", Some(100)),
1571        ]);
1572        let set = list.to_set();
1573        assert_eq!(set.len(), 2);
1574        // Song A has 2 plays → rank 1
1575        let top = set.iter().find(|t| t.name == "Song A").unwrap();
1576        assert_eq!(top.play_count, 2);
1577        assert_eq!(top.rank, 1);
1578    }
1579
1580    #[test]
1581    fn test_top_artists() {
1582        let list = TrackList::from(vec![
1583            make_track("T1", "Radiohead", "OK Computer", Some(100)),
1584            make_track("T2", "Radiohead", "OK Computer", Some(200)),
1585            make_track("T3", "Portishead", "Dummy", Some(300)),
1586        ]);
1587        let artists = list.top_artists();
1588        assert_eq!(artists.len(), 2);
1589        assert_eq!(artists[0].name, "Radiohead");
1590        assert_eq!(artists[0].play_count, 2);
1591        assert_eq!(artists[0].rank, 1);
1592        assert_eq!(artists[1].play_count, 1);
1593        assert_eq!(artists[1].rank, 2);
1594    }
1595
1596    #[test]
1597    fn test_top_albums_excludes_empty_album() {
1598        let list = TrackList::from(vec![
1599            make_track("T1", "Artist", "Dummy", Some(100)),
1600            make_track("T2", "Artist", "Dummy", Some(200)),
1601            make_track("T3", "Artist", "", Some(300)), // no album → excluded
1602        ]);
1603        let albums = list.top_albums();
1604        assert_eq!(albums.len(), 1);
1605        assert_eq!(albums[0].name, "Dummy");
1606        assert_eq!(albums[0].play_count, 2);
1607    }
1608
1609    #[test]
1610    fn test_by_hour() {
1611        // 3600 = 01:00 UTC, 7200 = 02:00 UTC
1612        let list = TrackList::from(vec![
1613            make_track("T1", "A", "", Some(3_600)),
1614            make_track("T2", "A", "", Some(7_200)),
1615            make_track("T3", "A", "", Some(7_300)), // also 02:xx
1616        ]);
1617        let hours = list.by_hour();
1618        assert_eq!(hours[1], 1);
1619        assert_eq!(hours[2], 2);
1620        assert_eq!(hours[0], 0);
1621    }
1622
1623    #[test]
1624    fn test_by_date_and_streak() {
1625        // Day 0 = 1970-01-01, day 1 = 1970-01-02, day 3 = 1970-01-04 (gap)
1626        let list = TrackList::from(vec![
1627            make_track("T1", "A", "", Some(0)),          // 1970-01-01
1628            make_track("T2", "A", "", Some(86_400)),     // 1970-01-02
1629            make_track("T3", "A", "", Some(86_400 * 3)), // 1970-01-04 (gap)
1630        ]);
1631        let by_date = list.by_date();
1632        assert_eq!(by_date.len(), 3);
1633        // Streak: days 01 + 02 = 2; then day 04 alone = 1
1634        assert_eq!(list.streak(), 2);
1635    }
1636
1637    #[test]
1638    fn test_without_now_playing() {
1639        let list = TrackList::from(vec![
1640            make_track("T1", "A", "", Some(100)),
1641            make_now_playing("Live Track", "A"),
1642        ]);
1643        let filtered = list.without_now_playing();
1644        assert_eq!(filtered.len(), 1);
1645        assert_eq!(filtered[0].name, "T1");
1646    }
1647
1648    #[test]
1649    fn test_unique_counts() {
1650        let list = TrackList::from(vec![
1651            make_track("Song", "Artist 1", "", Some(100)),
1652            make_track("Song", "Artist 1", "", Some(200)), // duplicate
1653            make_track("Song", "Artist 2", "", Some(300)), // same name, different artist
1654        ]);
1655        assert_eq!(list.unique_artist_count(), 2);
1656        assert_eq!(list.unique_track_count(), 2);
1657    }
1658
1659    // ── RecentTrackExtended helpers ──────────────────────────────────────────
1660
1661    fn make_ext(name: &str, artist: &str, album: &str, uts: Option<u32>) -> RecentTrackExtended {
1662        RecentTrackExtended {
1663            artist: BaseObject {
1664                name: artist.to_string(),
1665                mbid: String::new(),
1666                url: String::new(),
1667            },
1668            streamable: false,
1669            image: vec![],
1670            album: BaseObject {
1671                name: album.to_string(),
1672                mbid: String::new(),
1673                url: String::new(),
1674            },
1675            attr: None,
1676            date: uts.map(|u| Date {
1677                uts: u,
1678                text: String::new(),
1679            }),
1680            name: name.to_string(),
1681            mbid: String::new(),
1682            url: String::new(),
1683        }
1684    }
1685
1686    fn make_ext_now_playing(name: &str, artist: &str) -> RecentTrackExtended {
1687        use std::collections::HashMap;
1688        RecentTrackExtended {
1689            attr: Some(HashMap::from([(
1690                "nowplaying".to_string(),
1691                "true".to_string(),
1692            )])),
1693            date: None,
1694            ..make_ext(name, artist, "", None)
1695        }
1696    }
1697
1698    #[test]
1699    fn test_ext_to_set() {
1700        let list = TrackList::from(vec![
1701            make_ext("Song A", "Artist 1", "Album", Some(300)),
1702            make_ext("Song B", "Artist 1", "Album", Some(200)),
1703            make_ext("Song A", "Artist 1", "Album", Some(100)),
1704        ]);
1705        let set = list.to_set();
1706        assert_eq!(set.len(), 2);
1707        let top = set.iter().find(|t| t.name == "Song A").unwrap();
1708        assert_eq!(top.play_count, 2);
1709        assert_eq!(top.rank, 1);
1710        assert_eq!(top.artist, "Artist 1");
1711    }
1712
1713    #[test]
1714    fn test_ext_top_artists() {
1715        let list = TrackList::from(vec![
1716            make_ext("T1", "Radiohead", "OK Computer", Some(100)),
1717            make_ext("T2", "Radiohead", "OK Computer", Some(200)),
1718            make_ext("T3", "Portishead", "Dummy", Some(300)),
1719        ]);
1720        let artists = list.top_artists();
1721        assert_eq!(artists.len(), 2);
1722        assert_eq!(artists[0].name, "Radiohead");
1723        assert_eq!(artists[0].play_count, 2);
1724        assert_eq!(artists[0].rank, 1);
1725    }
1726
1727    #[test]
1728    fn test_ext_top_albums_excludes_empty() {
1729        let list = TrackList::from(vec![
1730            make_ext("T1", "Artist", "Dummy", Some(100)),
1731            make_ext("T2", "Artist", "Dummy", Some(200)),
1732            make_ext("T3", "Artist", "", Some(300)),
1733        ]);
1734        let albums = list.top_albums();
1735        assert_eq!(albums.len(), 1);
1736        assert_eq!(albums[0].name, "Dummy");
1737        assert_eq!(albums[0].play_count, 2);
1738    }
1739
1740    #[test]
1741    fn test_ext_by_hour_and_streak() {
1742        let list = TrackList::from(vec![
1743            make_ext("T1", "A", "", Some(3_600)),      // 01:00 UTC
1744            make_ext("T2", "A", "", Some(86_400)),     // 1970-01-02
1745            make_ext("T3", "A", "", Some(86_400 * 3)), // 1970-01-04 (gap)
1746        ]);
1747        let hours = list.by_hour();
1748        assert_eq!(hours[1], 1); // one play at 01:xx
1749        assert_eq!(list.streak(), 2); // days 01+02, then gap
1750    }
1751
1752    #[test]
1753    fn test_ext_without_now_playing() {
1754        let list = TrackList::from(vec![
1755            make_ext("T1", "A", "", Some(100)),
1756            make_ext_now_playing("Live", "A"),
1757        ]);
1758        let filtered = list.without_now_playing();
1759        assert_eq!(filtered.len(), 1);
1760        assert_eq!(filtered[0].name, "T1");
1761    }
1762
1763    #[test]
1764    fn test_ext_unique_counts() {
1765        let list = TrackList::from(vec![
1766            make_ext("Song", "Artist 1", "", Some(100)),
1767            make_ext("Song", "Artist 1", "", Some(200)),
1768            make_ext("Song", "Artist 2", "", Some(300)),
1769        ]);
1770        assert_eq!(list.unique_artist_count(), 2);
1771        assert_eq!(list.unique_track_count(), 2);
1772    }
1773
1774    #[test]
1775    fn test_date_deserialization() {
1776        use serde_json::json;
1777        let json_value = json!({
1778            "uts": "1_234_567_890",
1779            "#text": "2009-02-13 23:31:30"
1780        });
1781        let date: Date = serde_json::from_value(json_value).unwrap();
1782        assert_eq!(date.uts, 1_234_567_890);
1783        assert_eq!(date.text, "2009-02-13 23:31:30");
1784    }
1785
1786    #[test]
1787    fn test_bool_from_str() {
1788        use serde_json::json;
1789        // Test that "1" deserializes to true
1790        let json_value = json!({
1791            "artist": {"mbid": "", "#text": "Test"},
1792            "streamable": "1",
1793            "image": [],
1794            "album": {"mbid": "", "#text": ""},
1795            "name": "Test",
1796            "mbid": "",
1797            "url": ""
1798        });
1799        let track: RecentTrack = serde_json::from_value(json_value).unwrap();
1800        assert!(track.streamable);
1801    }
1802
1803    #[test]
1804    fn test_timestamped_trait() {
1805        let track = RecentTrack {
1806            artist: BaseMbidText {
1807                mbid: String::new(),
1808                text: "Artist".to_string(),
1809            },
1810            streamable: false,
1811            image: vec![],
1812            album: BaseMbidText {
1813                mbid: String::new(),
1814                text: String::new(),
1815            },
1816            attr: None,
1817            date: Some(Date {
1818                uts: 1_234_567_890,
1819                text: "test".to_string(),
1820            }),
1821            name: "Track".to_string(),
1822            mbid: String::new(),
1823            url: String::new(),
1824        };
1825
1826        assert_eq!(track.get_timestamp(), Some(1_234_567_890));
1827    }
1828}