lastfm_client/types/
tracks.rs

1use serde::{Deserialize, Deserializer, Serialize};
2use std::collections::HashMap;
3
4// UTILS - Custom deserializers
5
6/// Custom deserializer that accepts both string and numeric u32 values
7///
8/// The Last.fm API sometimes returns numeric values as strings (e.g., "12345" instead of 12345).
9/// This deserializer handles both formats and also removes underscores from string representations.
10fn u32_from_str<'de, D>(deserializer: D) -> Result<u32, D::Error>
11where
12    D: Deserializer<'de>,
13{
14    #[derive(Deserialize)]
15    #[serde(untagged)]
16    enum StringOrNum {
17        String(String),
18        Number(u32),
19    }
20
21    match StringOrNum::deserialize(deserializer)? {
22        StringOrNum::String(s) => s
23            .replace('_', "")
24            .parse::<u32>()
25            .map_err(serde::de::Error::custom),
26        StringOrNum::Number(n) => Ok(n),
27    }
28}
29
30/// Custom deserializer that accepts both string and boolean values
31///
32/// The Last.fm API returns boolean values as strings ("0"/"1" or "true"/"false").
33/// This deserializer converts them to proper Rust boolean values.
34fn bool_from_str<'de, D>(deserializer: D) -> Result<bool, D::Error>
35where
36    D: Deserializer<'de>,
37{
38    #[derive(Deserialize)]
39    #[serde(untagged)]
40    enum StringOrBool {
41        String(String),
42        Bool(bool),
43    }
44
45    match StringOrBool::deserialize(deserializer)? {
46        StringOrBool::String(s) => match s.to_lowercase().as_str() {
47            "1" | "true" => Ok(true),
48            "0" | "false" => Ok(false),
49            _ => Err(serde::de::Error::custom("Invalid boolean value")),
50        },
51        StringOrBool::Bool(b) => Ok(b),
52    }
53}
54
55// BASE TYPES =================================================================
56
57/// Basic type containing `MusicBrainz` ID and text content
58///
59/// Used for artist and album information in track responses
60#[derive(Serialize, Deserialize, Debug, Clone)]
61pub struct BaseMbidText {
62    /// `MusicBrainz` Identifier (may be empty string if not available)
63    pub mbid: String,
64    /// Text content (artist name, album name, etc.)
65    #[serde(rename = "#text")]
66    pub text: String,
67}
68
69/// Extended object type with `MusicBrainz` ID, URL, and name
70///
71/// Used for artist and album information in extended track responses
72#[derive(Serialize, Deserialize, Debug, Clone)]
73pub struct BaseObject {
74    /// `MusicBrainz` Identifier (may be empty string if not available)
75    pub mbid: String,
76    /// Last.fm URL for this object
77    #[serde(default)]
78    pub url: String,
79    /// Name of the object (artist name, album name, etc.)
80    #[serde(alias = "#text")]
81    pub name: String,
82}
83
84/// Image information for tracks and albums
85#[derive(Serialize, Deserialize, Debug, Clone)]
86pub struct TrackImage {
87    /// Image size (e.g., "small", "medium", "large", "extralarge")
88    pub size: String,
89    /// URL to the image
90    #[serde(rename = "#text")]
91    pub text: String,
92}
93
94/// Streamability information for a track
95#[derive(Serialize, Deserialize, Debug, Clone)]
96pub struct Streamable {
97    /// Whether the full track is streamable ("0" or "1")
98    pub fulltrack: String,
99    /// Additional streamability information
100    #[serde(rename = "#text")]
101    pub text: String,
102}
103
104/// Detailed artist information
105#[derive(Serialize, Deserialize, Debug, Clone)]
106pub struct Artist {
107    /// Artist name
108    pub name: String,
109    /// `MusicBrainz` Identifier (may be empty string if not available)
110    pub mbid: String,
111    /// Last.fm URL for this artist
112    #[serde(default)]
113    pub url: String,
114    /// Artist images in various sizes
115    pub image: Vec<TrackImage>,
116}
117
118// DATE TYPE ==================================================================
119// Unified - handles both API deserialization and storage
120
121/// Date/timestamp information for tracks
122#[derive(Serialize, Deserialize, Debug, Clone)]
123pub struct Date {
124    /// Unix timestamp in seconds (not milliseconds) since January 1, 1970 UTC
125    #[serde(deserialize_with = "u32_from_str")]
126    pub uts: u32,
127    /// Human-readable date string (e.g., "31 Jan 2024, 12:00")
128    #[serde(rename = "#text")]
129    pub text: String,
130}
131
132// ATTRIBUTES =================================================================
133
134/// Attributes for recent tracks indicating current playback status
135#[derive(Serialize, Deserialize, Debug, Clone)]
136pub struct Attributes {
137    /// Whether this track is currently playing ("true" or "false")
138    pub nowplaying: String,
139}
140
141/// Rank attributes for top tracks
142#[derive(Serialize, Deserialize, Debug, Clone)]
143pub struct RankAttr {
144    /// Numeric rank as a string (e.g., "1", "2", "3")
145    pub rank: String,
146}
147
148// RECENT TRACK ===============================================================
149// Unified - no more ApiRecentTrack vs RecentTrack split!
150
151/// A track from a user's recent listening history
152///
153/// Retrieved from the `user.getrecenttracks` API endpoint
154#[derive(Serialize, Deserialize, Debug, Clone)]
155pub struct RecentTrack {
156    /// Artist information
157    pub artist: BaseMbidText,
158    /// Whether the track is streamable on Last.fm
159    #[serde(deserialize_with = "bool_from_str")]
160    pub streamable: bool,
161    /// Track/album images in various sizes
162    pub image: Vec<TrackImage>,
163    /// Album information
164    pub album: BaseMbidText,
165    /// Attributes (present if track is currently playing)
166    #[serde(rename = "@attr")]
167    pub attr: Option<Attributes>,
168    /// When the track was played (None if currently playing)
169    pub date: Option<Date>,
170    /// Track name
171    pub name: String,
172    /// `MusicBrainz` track identifier (may be empty string)
173    pub mbid: String,
174    /// Last.fm URL for this track
175    pub url: String,
176}
177
178/// A track from recent listening history with extended artist/album information
179///
180/// Retrieved when using the `extended=1` parameter with `user.getrecenttracks`
181#[derive(Serialize, Deserialize, Debug, Clone)]
182pub struct RecentTrackExtended {
183    /// Extended artist information (includes URL)
184    pub artist: BaseObject,
185    /// Whether the track is streamable on Last.fm
186    #[serde(deserialize_with = "bool_from_str")]
187    pub streamable: bool,
188    /// Track/album images in various sizes
189    pub image: Vec<TrackImage>,
190    /// Extended album information (includes URL)
191    pub album: BaseObject,
192    /// Additional attributes (format varies, use `HashMap`)
193    #[serde(rename = "@attr")]
194    pub attr: Option<HashMap<String, String>>,
195    /// When the track was played (None if currently playing)
196    pub date: Option<Date>,
197    /// Track name
198    pub name: String,
199    /// `MusicBrainz` track identifier (may be empty string)
200    pub mbid: String,
201    /// Last.fm URL for this track
202    #[serde(default)]
203    pub url: String,
204}
205
206// LOVED TRACK ================================================================
207
208/// A track that a user has marked as "loved" on Last.fm
209///
210/// Retrieved from the `user.getlovedtracks` API endpoint
211#[derive(Serialize, Deserialize, Debug, Clone)]
212pub struct LovedTrack {
213    /// Artist information with URL
214    pub artist: BaseObject,
215    /// When the track was loved
216    pub date: Date,
217    /// Track/album images in various sizes
218    pub image: Vec<TrackImage>,
219    /// Streamability information
220    pub streamable: Streamable,
221    /// Track name
222    pub name: String,
223    /// `MusicBrainz` track identifier (may be empty string)
224    pub mbid: String,
225    /// Last.fm URL for this track
226    pub url: String,
227}
228
229// TOP TRACK ==================================================================
230
231/// A track from a user's top tracks, ranked by play count
232///
233/// Retrieved from the `user.gettoptracks` API endpoint
234#[derive(Serialize, Deserialize, Debug, Clone)]
235pub struct TopTrack {
236    /// Streamability information
237    pub streamable: Streamable,
238    /// `MusicBrainz` track identifier (may be empty string)
239    pub mbid: String,
240    /// Track name
241    pub name: String,
242    /// Track/album images in various sizes
243    pub image: Vec<TrackImage>,
244    /// Artist information with URL
245    pub artist: BaseObject,
246    /// Last.fm URL for this track
247    pub url: String,
248    /// Track duration in seconds
249    #[serde(deserialize_with = "u32_from_str")]
250    pub duration: u32,
251    /// Rank attributes (position in top tracks)
252    #[serde(rename = "@attr")]
253    pub attr: RankAttr,
254    /// Total number of times this track has been played
255    #[serde(deserialize_with = "u32_from_str")]
256    pub playcount: u32,
257}
258
259// RESPONSE WRAPPERS ==========================================================
260
261/// Base response metadata included in all paginated API responses
262#[derive(Serialize, Deserialize, Debug, Clone)]
263pub struct BaseResponse {
264    /// Username the request was made for
265    pub user: String,
266    /// Total number of pages available
267    #[serde(deserialize_with = "u32_from_str", rename = "totalPages")]
268    pub total_pages: u32,
269    /// Current page number (1-indexed)
270    #[serde(deserialize_with = "u32_from_str")]
271    pub page: u32,
272    /// Number of items per page
273    #[serde(deserialize_with = "u32_from_str", rename = "perPage")]
274    pub per_page: u32,
275    /// Total number of items available across all pages
276    #[serde(deserialize_with = "u32_from_str")]
277    pub total: u32,
278}
279
280// Recent tracks response
281#[derive(Serialize, Deserialize, Debug)]
282pub struct RecentTracks {
283    pub track: Vec<RecentTrack>,
284    #[serde(rename = "@attr")]
285    pub attr: BaseResponse,
286}
287
288#[derive(Serialize, Deserialize, Debug)]
289pub struct UserRecentTracks {
290    pub recenttracks: RecentTracks,
291}
292
293// Recent tracks extended response
294#[derive(Serialize, Deserialize, Debug)]
295pub struct RecentTracksExtended {
296    pub track: Vec<RecentTrackExtended>,
297    #[serde(rename = "@attr")]
298    pub attr: BaseResponse,
299}
300
301#[derive(Serialize, Deserialize, Debug)]
302pub struct UserRecentTracksExtended {
303    pub recenttracks: RecentTracksExtended,
304}
305
306// Loved tracks response
307#[derive(Serialize, Deserialize, Debug, Clone)]
308pub struct LovedTracks {
309    pub track: Vec<LovedTrack>,
310    #[serde(rename = "@attr")]
311    pub attr: BaseResponse,
312}
313
314#[derive(Serialize, Deserialize, Debug, Clone)]
315pub struct UserLovedTracks {
316    pub lovedtracks: LovedTracks,
317}
318
319// Top tracks response
320#[derive(Serialize, Deserialize, Debug, Clone)]
321pub struct TopTracks {
322    pub track: Vec<TopTrack>,
323    #[serde(rename = "@attr")]
324    pub attr: BaseResponse,
325}
326
327#[derive(Serialize, Deserialize, Debug, Clone)]
328pub struct UserTopTracks {
329    pub toptracks: TopTracks,
330}
331
332// TRAITS =====================================================================
333
334pub trait Timestamped {
335    fn get_timestamp(&self) -> Option<u32>;
336}
337
338impl Timestamped for RecentTrack {
339    fn get_timestamp(&self) -> Option<u32> {
340        self.date.as_ref().map(|d| d.uts)
341    }
342}
343
344impl Timestamped for LovedTrack {
345    fn get_timestamp(&self) -> Option<u32> {
346        Some(self.date.uts)
347    }
348}
349
350impl Timestamped for RecentTrackExtended {
351    fn get_timestamp(&self) -> Option<u32> {
352        self.date.as_ref().map(|d| d.uts)
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn test_date_deserialization() {
362        use serde_json::json;
363        let json_value = json!({
364            "uts": "1_234_567_890",
365            "#text": "2009-02-13 23:31:30"
366        });
367        let date: Date = serde_json::from_value(json_value).unwrap();
368        assert_eq!(date.uts, 1_234_567_890);
369        assert_eq!(date.text, "2009-02-13 23:31:30");
370    }
371
372    #[test]
373    fn test_bool_from_str() {
374        use serde_json::json;
375        // Test that "1" deserializes to true
376        let json_value = json!({
377            "artist": {"mbid": "", "#text": "Test"},
378            "streamable": "1",
379            "image": [],
380            "album": {"mbid": "", "#text": ""},
381            "name": "Test",
382            "mbid": "",
383            "url": ""
384        });
385        let track: RecentTrack = serde_json::from_value(json_value).unwrap();
386        assert!(track.streamable);
387    }
388
389    #[test]
390    fn test_timestamped_trait() {
391        let track = RecentTrack {
392            artist: BaseMbidText {
393                mbid: String::new(),
394                text: "Artist".to_string(),
395            },
396            streamable: false,
397            image: vec![],
398            album: BaseMbidText {
399                mbid: String::new(),
400                text: String::new(),
401            },
402            attr: None,
403            date: Some(Date {
404                uts: 1_234_567_890,
405                text: "test".to_string(),
406            }),
407            name: "Track".to_string(),
408            mbid: String::new(),
409            url: String::new(),
410        };
411
412        assert_eq!(track.get_timestamp(), Some(1_234_567_890));
413    }
414}