Skip to main content

lastfm_client/types/
tracks.rs

1use std::cmp::Ordering;
2use std::collections::HashMap;
3use std::fmt;
4
5use serde::{Deserialize, Serialize};
6
7use crate::types::utils::{bool_from_str, u32_from_str};
8
9// BASE TYPES =================================================================
10
11/// Basic type containing `MusicBrainz` ID and text content
12///
13/// Used for artist and album information in track responses
14#[derive(Serialize, Deserialize, Debug, Clone)]
15#[non_exhaustive]
16pub struct BaseMbidText {
17    /// `MusicBrainz` Identifier (may be empty string if not available)
18    pub mbid: String,
19    /// Text content (artist name, album name, etc.)
20    #[serde(rename = "#text")]
21    pub text: String,
22}
23
24/// Extended object type with `MusicBrainz` ID, URL, and name
25///
26/// Used for artist and album information in extended track responses
27#[derive(Serialize, Deserialize, Debug, Clone)]
28#[non_exhaustive]
29pub struct BaseObject {
30    /// `MusicBrainz` Identifier (may be empty string if not available)
31    pub mbid: String,
32    /// Last.fm URL for this object
33    #[serde(default)]
34    pub url: String,
35    /// Name of the object (artist name, album name, etc.)
36    #[serde(alias = "#text")]
37    pub name: String,
38}
39
40/// Image information for tracks and albums
41#[derive(Serialize, Deserialize, Debug, Clone)]
42#[non_exhaustive]
43pub struct TrackImage {
44    /// Image size (e.g., "small", "medium", "large", "extralarge")
45    pub size: String,
46    /// URL to the image
47    #[serde(rename = "#text")]
48    pub text: String,
49}
50
51/// Streamability information for a track
52#[derive(Serialize, Deserialize, Debug, Clone)]
53#[non_exhaustive]
54pub struct Streamable {
55    /// Whether the full track is streamable ("0" or "1")
56    pub fulltrack: String,
57    /// Additional streamability information
58    #[serde(rename = "#text")]
59    pub text: String,
60}
61
62/// Detailed artist information
63#[derive(Serialize, Deserialize, Debug, Clone)]
64#[non_exhaustive]
65pub struct Artist {
66    /// Artist name
67    pub name: String,
68    /// `MusicBrainz` Identifier (may be empty string if not available)
69    pub mbid: String,
70    /// Last.fm URL for this artist
71    #[serde(default)]
72    pub url: String,
73    /// Artist images in various sizes
74    pub image: Vec<TrackImage>,
75}
76
77// DATE TYPE ==================================================================
78// Unified - handles both API deserialization and storage
79
80/// Date/timestamp information for tracks
81#[derive(Serialize, Deserialize, Debug, Clone)]
82#[non_exhaustive]
83pub struct Date {
84    /// Unix timestamp in seconds (not milliseconds) since January 1, 1970 UTC
85    #[serde(deserialize_with = "u32_from_str")]
86    pub uts: u32,
87    /// Human-readable date string (e.g., "31 Jan 2024, 12:00")
88    #[serde(rename = "#text")]
89    pub text: String,
90}
91
92// ATTRIBUTES =================================================================
93
94/// Attributes for recent tracks indicating current playback status
95#[derive(Serialize, Deserialize, Debug, Clone)]
96#[non_exhaustive]
97pub struct Attributes {
98    /// Whether this track is currently playing ("true" or "false")
99    pub nowplaying: String,
100}
101
102/// Rank attributes for top tracks
103#[derive(Serialize, Deserialize, Debug, Clone)]
104#[non_exhaustive]
105pub struct RankAttr {
106    /// Numeric rank as a string (e.g., "1", "2", "3")
107    pub rank: String,
108}
109
110// RECENT TRACK ===============================================================
111// Unified - no more ApiRecentTrack vs RecentTrack split!
112
113/// A track from a user's recent listening history
114///
115/// Retrieved from the `user.getrecenttracks` API endpoint
116#[derive(Serialize, Deserialize, Debug, Clone)]
117#[non_exhaustive]
118pub struct RecentTrack {
119    /// Artist information
120    pub artist: BaseMbidText,
121    /// Whether the track is streamable on Last.fm
122    #[serde(deserialize_with = "bool_from_str")]
123    pub streamable: bool,
124    /// Track/album images in various sizes
125    pub image: Vec<TrackImage>,
126    /// Album information
127    pub album: BaseMbidText,
128    /// Attributes (present if track is currently playing)
129    #[serde(rename = "@attr")]
130    pub attr: Option<Attributes>,
131    /// When the track was played (None if currently playing)
132    pub date: Option<Date>,
133    /// Track name
134    pub name: String,
135    /// `MusicBrainz` track identifier (may be empty string)
136    pub mbid: String,
137    /// Last.fm URL for this track
138    pub url: String,
139}
140
141impl fmt::Display for RecentTrack {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        let status = if self.attr.is_some() {
144            " [NOW PLAYING]"
145        } else {
146            ""
147        };
148        let date_str = self
149            .date
150            .as_ref()
151            .map_or(String::new(), |d| format!(" ({})", d.text));
152
153        write!(
154            f,
155            "{} - {} [{}]{date_str}{status}",
156            self.name, self.artist.text, self.album.text
157        )
158    }
159}
160
161impl PartialEq for RecentTrack {
162    fn eq(&self, other: &Self) -> bool {
163        self.date.as_ref().map(|d| d.uts) == other.date.as_ref().map(|d| d.uts)
164    }
165}
166
167impl Eq for RecentTrack {}
168
169impl PartialOrd for RecentTrack {
170    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
171        Some(self.cmp(other))
172    }
173}
174
175impl Ord for RecentTrack {
176    fn cmp(&self, other: &Self) -> Ordering {
177        // None (now playing) is treated as the most recent
178        match (self.date.as_ref(), other.date.as_ref()) {
179            (None, None) => Ordering::Equal,
180            (None, Some(_)) => Ordering::Greater,
181            (Some(_), None) => Ordering::Less,
182            (Some(a), Some(b)) => a.uts.cmp(&b.uts),
183        }
184    }
185}
186
187/// A track from recent listening history with extended artist/album information
188///
189/// Retrieved when using the `extended=1` parameter with `user.getrecenttracks`
190#[derive(Serialize, Deserialize, Debug, Clone)]
191#[non_exhaustive]
192pub struct RecentTrackExtended {
193    /// Extended artist information (includes URL)
194    pub artist: BaseObject,
195    /// Whether the track is streamable on Last.fm
196    #[serde(deserialize_with = "bool_from_str")]
197    pub streamable: bool,
198    /// Track/album images in various sizes
199    pub image: Vec<TrackImage>,
200    /// Extended album information (includes URL)
201    pub album: BaseObject,
202    /// Additional attributes (format varies, use `HashMap`)
203    #[serde(rename = "@attr")]
204    pub attr: Option<HashMap<String, String>>,
205    /// When the track was played (None if currently playing)
206    pub date: Option<Date>,
207    /// Track name
208    pub name: String,
209    /// `MusicBrainz` track identifier (may be empty string)
210    pub mbid: String,
211    /// Last.fm URL for this track
212    #[serde(default)]
213    pub url: String,
214}
215
216impl fmt::Display for RecentTrackExtended {
217    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218        let is_now_playing = self
219            .attr
220            .as_ref()
221            .and_then(|a| a.get("nowplaying"))
222            .is_some_and(|v| v == "true");
223        let status = if is_now_playing { " [NOW PLAYING]" } else { "" };
224        let date_str = self
225            .date
226            .as_ref()
227            .map_or(String::new(), |d| format!(" ({})", d.text));
228
229        write!(
230            f,
231            "{} - {} [{}]{date_str}{status}",
232            self.name, self.artist.name, self.album.name
233        )
234    }
235}
236
237impl PartialEq for RecentTrackExtended {
238    fn eq(&self, other: &Self) -> bool {
239        self.date.as_ref().map(|d| d.uts) == other.date.as_ref().map(|d| d.uts)
240    }
241}
242
243impl Eq for RecentTrackExtended {}
244
245impl PartialOrd for RecentTrackExtended {
246    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
247        Some(self.cmp(other))
248    }
249}
250
251impl Ord for RecentTrackExtended {
252    fn cmp(&self, other: &Self) -> Ordering {
253        // None (now playing) is treated as the most recent
254        match (self.date.as_ref(), other.date.as_ref()) {
255            (None, None) => Ordering::Equal,
256            (None, Some(_)) => Ordering::Greater,
257            (Some(_), None) => Ordering::Less,
258            (Some(a), Some(b)) => a.uts.cmp(&b.uts),
259        }
260    }
261}
262
263// LOVED TRACK ================================================================
264
265/// A track that a user has marked as "loved" on Last.fm
266///
267/// Retrieved from the `user.getlovedtracks` API endpoint
268#[derive(Serialize, Deserialize, Debug, Clone)]
269#[non_exhaustive]
270pub struct LovedTrack {
271    /// Artist information with URL
272    pub artist: BaseObject,
273    /// When the track was loved
274    pub date: Date,
275    /// Track/album images in various sizes
276    pub image: Vec<TrackImage>,
277    /// Streamability information
278    pub streamable: Streamable,
279    /// Track name
280    pub name: String,
281    /// `MusicBrainz` track identifier (may be empty string)
282    pub mbid: String,
283    /// Last.fm URL for this track
284    pub url: String,
285}
286
287impl fmt::Display for LovedTrack {
288    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289        write!(
290            f,
291            "{} - {} (loved {})",
292            self.name, self.artist.name, self.date.text
293        )
294    }
295}
296
297impl PartialEq for LovedTrack {
298    fn eq(&self, other: &Self) -> bool {
299        self.date.uts == other.date.uts
300    }
301}
302
303impl Eq for LovedTrack {}
304
305impl PartialOrd for LovedTrack {
306    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
307        Some(self.cmp(other))
308    }
309}
310
311impl Ord for LovedTrack {
312    fn cmp(&self, other: &Self) -> Ordering {
313        self.date.uts.cmp(&other.date.uts)
314    }
315}
316
317// TOP TRACK ==================================================================
318
319/// A track from a user's top tracks, ranked by play count
320///
321/// Retrieved from the `user.gettoptracks` API endpoint
322#[derive(Serialize, Deserialize, Debug, Clone)]
323#[non_exhaustive]
324pub struct TopTrack {
325    /// Streamability information
326    pub streamable: Streamable,
327    /// `MusicBrainz` track identifier (may be empty string)
328    pub mbid: String,
329    /// Track name
330    pub name: String,
331    /// Track/album images in various sizes
332    pub image: Vec<TrackImage>,
333    /// Artist information with URL
334    pub artist: BaseObject,
335    /// Last.fm URL for this track
336    pub url: String,
337    /// Track duration in seconds
338    #[serde(deserialize_with = "u32_from_str")]
339    pub duration: u32,
340    /// Rank attributes (position in top tracks)
341    #[serde(rename = "@attr")]
342    pub attr: RankAttr,
343    /// Total number of times this track has been played
344    #[serde(deserialize_with = "u32_from_str")]
345    pub playcount: u32,
346}
347
348impl fmt::Display for TopTrack {
349    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
350        write!(
351            f,
352            "#{} - {} by {} ({} plays)",
353            self.attr.rank, self.name, self.artist.name, self.playcount
354        )
355    }
356}
357
358impl PartialEq for TopTrack {
359    fn eq(&self, other: &Self) -> bool {
360        self.playcount == other.playcount
361    }
362}
363
364impl Eq for TopTrack {}
365
366impl PartialOrd for TopTrack {
367    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
368        Some(self.cmp(other))
369    }
370}
371
372impl Ord for TopTrack {
373    fn cmp(&self, other: &Self) -> Ordering {
374        self.playcount.cmp(&other.playcount)
375    }
376}
377
378// RESPONSE WRAPPERS ==========================================================
379
380/// Base response metadata included in all paginated API responses
381#[derive(Serialize, Deserialize, Debug, Clone)]
382#[non_exhaustive]
383pub struct BaseResponse {
384    /// Username the request was made for
385    pub user: String,
386    /// Total number of pages available
387    #[serde(deserialize_with = "u32_from_str", rename = "totalPages")]
388    pub total_pages: u32,
389    /// Current page number (1-indexed)
390    #[serde(deserialize_with = "u32_from_str")]
391    pub page: u32,
392    /// Number of items per page
393    #[serde(deserialize_with = "u32_from_str", rename = "perPage")]
394    pub per_page: u32,
395    /// Total number of items available across all pages
396    #[serde(deserialize_with = "u32_from_str")]
397    pub total: u32,
398}
399
400/// Recent tracks response wrapper
401#[derive(Serialize, Deserialize, Debug)]
402#[non_exhaustive]
403pub struct RecentTracks {
404    /// List of recent tracks
405    pub track: Vec<RecentTrack>,
406    /// Response metadata
407    #[serde(rename = "@attr")]
408    pub attr: BaseResponse,
409}
410
411/// Top-level recent tracks API response
412#[derive(Serialize, Deserialize, Debug)]
413#[non_exhaustive]
414pub struct UserRecentTracks {
415    /// Recent tracks data
416    pub recenttracks: RecentTracks,
417}
418
419/// Recent tracks extended response wrapper
420#[derive(Serialize, Deserialize, Debug)]
421#[non_exhaustive]
422pub struct RecentTracksExtended {
423    /// List of extended recent tracks
424    pub track: Vec<RecentTrackExtended>,
425    /// Response metadata
426    #[serde(rename = "@attr")]
427    pub attr: BaseResponse,
428}
429
430/// Top-level extended recent tracks API response
431#[derive(Serialize, Deserialize, Debug)]
432#[non_exhaustive]
433pub struct UserRecentTracksExtended {
434    /// Extended recent tracks data
435    pub recenttracks: RecentTracksExtended,
436}
437
438/// Loved tracks response wrapper
439#[derive(Serialize, Deserialize, Debug, Clone)]
440#[non_exhaustive]
441pub struct LovedTracks {
442    /// List of loved tracks
443    pub track: Vec<LovedTrack>,
444    /// Response metadata
445    #[serde(rename = "@attr")]
446    pub attr: BaseResponse,
447}
448
449/// Top-level loved tracks API response
450#[derive(Serialize, Deserialize, Debug, Clone)]
451#[non_exhaustive]
452pub struct UserLovedTracks {
453    /// Loved tracks data
454    pub lovedtracks: LovedTracks,
455}
456
457/// Top tracks response wrapper
458#[derive(Serialize, Deserialize, Debug, Clone)]
459#[non_exhaustive]
460pub struct TopTracks {
461    /// List of top tracks
462    pub track: Vec<TopTrack>,
463    /// Response metadata
464    #[serde(rename = "@attr")]
465    pub attr: BaseResponse,
466}
467
468/// Top-level top tracks API response
469#[derive(Serialize, Deserialize, Debug, Clone)]
470#[non_exhaustive]
471pub struct UserTopTracks {
472    /// Top tracks data
473    pub toptracks: TopTracks,
474}
475
476// ANALYTICS ==================================================================
477
478/// Represents a track's play count information
479#[derive(Debug, Serialize)]
480#[non_exhaustive]
481pub struct TrackPlayInfo {
482    /// Track name
483    pub name: String,
484    /// Number of times played
485    pub play_count: u32,
486    /// Artist name
487    pub artist: String,
488    /// Album name (if available)
489    pub album: Option<String>,
490    /// Image URL (if available)
491    pub image_url: Option<String>,
492    /// Whether the track is currently playing
493    pub currently_playing: bool,
494    /// Unix timestamp of when played
495    pub date: Option<u32>,
496    /// Last.fm URL
497    pub url: String,
498}
499
500// TRAITS =====================================================================
501
502/// Trait for types that have a timestamp
503pub trait Timestamped {
504    /// Get the timestamp as a Unix epoch in seconds
505    fn get_timestamp(&self) -> Option<u32>;
506}
507
508impl Timestamped for RecentTrack {
509    fn get_timestamp(&self) -> Option<u32> {
510        self.date.as_ref().map(|d| d.uts)
511    }
512}
513
514impl Timestamped for LovedTrack {
515    fn get_timestamp(&self) -> Option<u32> {
516        Some(self.date.uts)
517    }
518}
519
520impl Timestamped for RecentTrackExtended {
521    fn get_timestamp(&self) -> Option<u32> {
522        self.date.as_ref().map(|d| d.uts)
523    }
524}
525
526// SQLITE EXPORT ==============================================================
527
528#[cfg(feature = "sqlite")]
529impl crate::sqlite::SqliteExportable for RecentTrack {
530    fn table_name() -> &'static str {
531        "recent_tracks"
532    }
533
534    fn create_table_sql() -> &'static str {
535        "CREATE TABLE IF NOT EXISTS recent_tracks (
536            id         INTEGER PRIMARY KEY AUTOINCREMENT,
537            name       TEXT    NOT NULL,
538            url        TEXT    NOT NULL,
539            artist     TEXT    NOT NULL,
540            artist_mbid TEXT   NOT NULL,
541            album      TEXT    NOT NULL,
542            album_mbid TEXT    NOT NULL,
543            date_uts   INTEGER,
544            loved      INTEGER NOT NULL DEFAULT 0
545        )"
546    }
547
548    fn insert_sql() -> &'static str {
549        "INSERT INTO recent_tracks (name, url, artist, artist_mbid, album, album_mbid, date_uts, loved)
550         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"
551    }
552
553    fn bind_and_execute(&self, stmt: &mut rusqlite::Statement<'_>) -> rusqlite::Result<usize> {
554        stmt.execute(rusqlite::params![
555            self.name,
556            self.url,
557            self.artist.text,
558            self.artist.mbid,
559            self.album.text,
560            self.album.mbid,
561            self.date.as_ref().map(|d| d.uts),
562            0_i32,
563        ])
564    }
565}
566
567#[cfg(feature = "sqlite")]
568impl crate::sqlite::SqliteExportable for RecentTrackExtended {
569    fn table_name() -> &'static str {
570        "recent_tracks_extended"
571    }
572
573    fn create_table_sql() -> &'static str {
574        "CREATE TABLE IF NOT EXISTS recent_tracks_extended (
575            id          INTEGER PRIMARY KEY AUTOINCREMENT,
576            name        TEXT    NOT NULL,
577            url         TEXT    NOT NULL,
578            mbid        TEXT    NOT NULL,
579            artist      TEXT    NOT NULL,
580            artist_mbid TEXT    NOT NULL,
581            artist_url  TEXT    NOT NULL,
582            album       TEXT    NOT NULL,
583            album_mbid  TEXT    NOT NULL,
584            album_url   TEXT    NOT NULL,
585            date_uts    INTEGER,
586            loved       INTEGER NOT NULL DEFAULT 0
587        )"
588    }
589
590    fn insert_sql() -> &'static str {
591        "INSERT INTO recent_tracks_extended
592             (name, url, mbid, artist, artist_mbid, artist_url, album, album_mbid, album_url, date_uts, loved)
593         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)"
594    }
595
596    fn bind_and_execute(&self, stmt: &mut rusqlite::Statement<'_>) -> rusqlite::Result<usize> {
597        stmt.execute(rusqlite::params![
598            self.name,
599            self.url,
600            self.mbid,
601            self.artist.name,
602            self.artist.mbid,
603            self.artist.url,
604            self.album.name,
605            self.album.mbid,
606            self.album.url,
607            self.date.as_ref().map(|d| d.uts),
608            0_i32,
609        ])
610    }
611}
612
613#[cfg(feature = "sqlite")]
614impl crate::sqlite::SqliteExportable for LovedTrack {
615    fn table_name() -> &'static str {
616        "loved_tracks"
617    }
618
619    fn create_table_sql() -> &'static str {
620        "CREATE TABLE IF NOT EXISTS loved_tracks (
621            id          INTEGER PRIMARY KEY AUTOINCREMENT,
622            name        TEXT    NOT NULL,
623            url         TEXT    NOT NULL,
624            artist      TEXT    NOT NULL,
625            artist_mbid TEXT    NOT NULL,
626            date_uts    INTEGER NOT NULL
627        )"
628    }
629
630    fn insert_sql() -> &'static str {
631        "INSERT INTO loved_tracks (name, url, artist, artist_mbid, date_uts)
632         VALUES (?1, ?2, ?3, ?4, ?5)"
633    }
634
635    fn bind_and_execute(&self, stmt: &mut rusqlite::Statement<'_>) -> rusqlite::Result<usize> {
636        stmt.execute(rusqlite::params![
637            self.name,
638            self.url,
639            self.artist.name,
640            self.artist.mbid,
641            self.date.uts,
642        ])
643    }
644}
645
646#[cfg(feature = "sqlite")]
647impl crate::sqlite::SqliteExportable for TopTrack {
648    fn table_name() -> &'static str {
649        "top_tracks"
650    }
651
652    fn create_table_sql() -> &'static str {
653        "CREATE TABLE IF NOT EXISTS top_tracks (
654            id        INTEGER PRIMARY KEY AUTOINCREMENT,
655            name      TEXT    NOT NULL,
656            url       TEXT    NOT NULL,
657            artist    TEXT    NOT NULL,
658            mbid      TEXT    NOT NULL,
659            playcount INTEGER NOT NULL,
660            rank      INTEGER NOT NULL
661        )"
662    }
663
664    fn insert_sql() -> &'static str {
665        "INSERT INTO top_tracks (name, url, artist, mbid, playcount, rank)
666         VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
667    }
668
669    fn bind_and_execute(&self, stmt: &mut rusqlite::Statement<'_>) -> rusqlite::Result<usize> {
670        let rank: u32 = self.attr.rank.parse().unwrap_or_default();
671        stmt.execute(rusqlite::params![
672            self.name,
673            self.url,
674            self.artist.name,
675            self.mbid,
676            self.playcount,
677            rank,
678        ])
679    }
680}
681
682#[cfg(test)]
683#[allow(clippy::unwrap_used)]
684mod tests {
685    use super::*;
686
687    #[test]
688    fn test_date_deserialization() {
689        use serde_json::json;
690        let json_value = json!({
691            "uts": "1_234_567_890",
692            "#text": "2009-02-13 23:31:30"
693        });
694        let date: Date = serde_json::from_value(json_value).unwrap();
695        assert_eq!(date.uts, 1_234_567_890);
696        assert_eq!(date.text, "2009-02-13 23:31:30");
697    }
698
699    #[test]
700    fn test_bool_from_str() {
701        use serde_json::json;
702        // Test that "1" deserializes to true
703        let json_value = json!({
704            "artist": {"mbid": "", "#text": "Test"},
705            "streamable": "1",
706            "image": [],
707            "album": {"mbid": "", "#text": ""},
708            "name": "Test",
709            "mbid": "",
710            "url": ""
711        });
712        let track: RecentTrack = serde_json::from_value(json_value).unwrap();
713        assert!(track.streamable);
714    }
715
716    #[test]
717    fn test_timestamped_trait() {
718        let track = RecentTrack {
719            artist: BaseMbidText {
720                mbid: String::new(),
721                text: "Artist".to_string(),
722            },
723            streamable: false,
724            image: vec![],
725            album: BaseMbidText {
726                mbid: String::new(),
727                text: String::new(),
728            },
729            attr: None,
730            date: Some(Date {
731                uts: 1_234_567_890,
732                text: "test".to_string(),
733            }),
734            name: "Track".to_string(),
735            mbid: String::new(),
736            url: String::new(),
737        };
738
739        assert_eq!(track.get_timestamp(), Some(1_234_567_890));
740    }
741}