Skip to main content

cider_api/
types.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Types for the Cider REST API.
6//!
7//! This module contains all request and response types used by
8//! [`CiderClient`](crate::CiderClient). Response types use `#[serde(default)]`
9//! on fields that may be absent so deserialization succeeds even when the API
10//! omits them (e.g. radio stations may omit `artist_name`).
11//!
12//! The response shapes match the [Cider RPC documentation](https://cider.sh/docs/client/rpc).
13
14use serde::{Deserialize, Serialize};
15
16// ─── Response wrapper ────────────────────────────────────────────────────────
17
18/// Generic wrapper for Cider API JSON responses.
19///
20/// Most endpoints return `{ "status": "ok", ...fields }`. The inner payload is
21/// flattened so its fields sit alongside `status`.
22///
23/// # Example (JSON)
24///
25/// ```json
26/// { "status": "ok", "is_playing": true }
27/// ```
28#[derive(Debug, Clone, Deserialize)]
29pub struct ApiResponse<T> {
30    /// Status string, typically `"ok"`.
31    pub status: String,
32
33    /// Endpoint-specific payload, flattened into the same JSON object.
34    #[serde(flatten)]
35    pub data: T,
36}
37
38// ─── Common types ────────────────────────────────────────────────────────────
39
40/// Artwork metadata for a track, album, or station.
41///
42/// The `url` field may contain `{w}` and `{h}` placeholders for the desired
43/// image dimensions. Use [`Artwork::url_for_size`] to get a ready-to-use URL.
44///
45/// Color fields (`text_color1`–`text_color4`, `bg_color`) are hex color strings
46/// present on certain container artwork (e.g. radio stations).
47///
48/// # Examples
49///
50/// ```
51/// # use cider_api::Artwork;
52/// let art = Artwork {
53///     width: 600,
54///     height: 600,
55///     url: "https://example.com/img/{w}x{h}bb.jpg".into(),
56///     ..Default::default()
57/// };
58/// assert_eq!(
59///     art.url_for_size(300),
60///     "https://example.com/img/300x300bb.jpg"
61/// );
62/// ```
63#[derive(Debug, Clone, Default, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct Artwork {
66    /// Image width in pixels.
67    #[serde(default)]
68    pub width: u32,
69
70    /// Image height in pixels.
71    #[serde(default)]
72    pub height: u32,
73
74    /// URL template — may contain `{w}` and `{h}` size placeholders.
75    #[serde(default)]
76    pub url: String,
77
78    /// Primary text color (hex, e.g. `"eaccc1"`). Present on station artwork.
79    #[serde(default)]
80    pub text_color1: Option<String>,
81
82    /// Secondary text color (hex). Present on station artwork.
83    #[serde(default)]
84    pub text_color2: Option<String>,
85
86    /// Tertiary text color (hex). Present on station artwork.
87    #[serde(default)]
88    pub text_color3: Option<String>,
89
90    /// Quaternary text color (hex). Present on station artwork.
91    #[serde(default)]
92    pub text_color4: Option<String>,
93
94    /// Background color (hex, e.g. `"0c0e0d"`). Present on station artwork.
95    #[serde(default)]
96    pub bg_color: Option<String>,
97
98    /// Whether the artwork uses the Display P3 color space.
99    #[serde(default)]
100    pub has_p3: Option<bool>,
101}
102
103impl Artwork {
104    /// Return the artwork URL with `{w}` and `{h}` replaced by `size`.
105    ///
106    /// If the URL has no placeholders the original URL is returned unchanged.
107    #[must_use]
108    pub fn url_for_size(&self, size: u32) -> String {
109        let s = size.to_string();
110        self.url.replace("{w}", &s).replace("{h}", &s)
111    }
112}
113
114/// Play parameters identifying a playable item.
115///
116/// Every playable track, album, or station carries an `id` (Apple Music
117/// catalog ID) and a `kind` (e.g. `"song"`, `"album"`, `"radioStation"`).
118///
119/// # Examples
120///
121/// ```
122/// # use cider_api::PlayParams;
123/// let pp = PlayParams { id: "1719861213".into(), kind: "song".into() };
124/// assert_eq!(pp.id, "1719861213");
125/// ```
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct PlayParams {
128    /// Apple Music catalog ID.
129    pub id: String,
130
131    /// Item kind — `"song"`, `"album"`, `"playlist"`, `"radioStation"`, etc.
132    pub kind: String,
133}
134
135/// A track audio preview.
136///
137/// The `url` points to a short AAC preview clip hosted on Apple's CDN.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct Preview {
140    /// Direct URL to the preview audio file.
141    pub url: String,
142}
143
144// ─── Now Playing ─────────────────────────────────────────────────────────────
145
146/// Currently playing track information returned by `GET /now-playing`.
147///
148/// This is an Apple Music API–style resource enriched with live playback
149/// state (`current_playback_time`, `remaining_time`, `shuffle_mode`, etc.).
150///
151/// All fields use `#[serde(default)]` so deserialization succeeds even when
152/// the API omits fields (e.g. radio stations may lack `artist_name`).
153///
154/// # Examples
155///
156/// ```
157/// # use cider_api::NowPlaying;
158/// # fn example(track: &NowPlaying) {
159/// println!("{} — {} ({})", track.name, track.artist_name, track.album_name);
160/// println!("Position: {:.1}s / {}ms", track.current_playback_time, track.duration_in_millis);
161/// if let Some(id) = track.song_id() {
162///     println!("Song ID: {id}");
163/// }
164/// println!("Artwork: {}", track.artwork_url(600));
165/// # }
166/// ```
167#[derive(Debug, Clone, Serialize, Deserialize)]
168#[serde(rename_all = "camelCase")]
169#[allow(clippy::struct_excessive_bools)]
170pub struct NowPlaying {
171    /// Song name.
172    #[serde(default)]
173    pub name: String,
174
175    /// Artist name.
176    #[serde(default)]
177    pub artist_name: String,
178
179    /// Album name.
180    #[serde(default)]
181    pub album_name: String,
182
183    /// Artwork information.
184    #[serde(default)]
185    pub artwork: Artwork,
186
187    /// Total duration in milliseconds.
188    #[serde(default)]
189    pub duration_in_millis: u64,
190
191    // ── Identifiers ──
192
193    /// Play parameters containing the song ID and kind.
194    #[serde(default)]
195    pub play_params: Option<PlayParams>,
196
197    /// Apple Music web URL for the track.
198    #[serde(default)]
199    pub url: Option<String>,
200
201    /// International Standard Recording Code.
202    #[serde(default)]
203    pub isrc: Option<String>,
204
205    // ── Playback state (injected by Cider, not in the Apple Music catalog) ──
206
207    /// Current playback position in seconds.
208    #[serde(default)]
209    pub current_playback_time: f64,
210
211    /// Remaining playback time in seconds.
212    #[serde(default)]
213    pub remaining_time: f64,
214
215    /// Shuffle mode — `0` = off, `1` = on.
216    #[serde(default)]
217    pub shuffle_mode: u8,
218
219    /// Repeat mode — `0` = off, `1` = repeat one, `2` = repeat all.
220    #[serde(default)]
221    pub repeat_mode: u8,
222
223    /// Whether the track is in the user's favorites.
224    #[serde(default)]
225    pub in_favorites: bool,
226
227    /// Whether the track is in the user's library.
228    #[serde(default)]
229    pub in_library: bool,
230
231    // ── Catalog metadata ──
232
233    /// Genre names (e.g. `["Electronic", "Music"]`).
234    #[serde(default)]
235    pub genre_names: Vec<String>,
236
237    /// Track number on the album.
238    #[serde(default)]
239    pub track_number: u32,
240
241    /// Disc number on the album.
242    #[serde(default)]
243    pub disc_number: u32,
244
245    /// Release date as an ISO-8601 string (e.g. `"2016-05-27T12:00:00Z"`).
246    #[serde(default)]
247    pub release_date: Option<String>,
248
249    /// Audio locale code (e.g. `"en-US"`).
250    #[serde(default)]
251    pub audio_locale: Option<String>,
252
253    /// Composer / songwriter name.
254    #[serde(default)]
255    pub composer_name: Option<String>,
256
257    /// Whether the track has lyrics.
258    #[serde(default)]
259    pub has_lyrics: bool,
260
261    /// Whether the track has time-synced (karaoke-style) lyrics.
262    #[serde(default)]
263    pub has_time_synced_lyrics: bool,
264
265    /// Whether vocal attenuation (sing-along mode) is available.
266    #[serde(default)]
267    pub is_vocal_attenuation_allowed: bool,
268
269    /// Legacy flag — replaced by [`is_apple_digital_master`](Self::is_apple_digital_master).
270    #[serde(default)]
271    pub is_mastered_for_itunes: bool,
272
273    /// Whether the track is an Apple Digital Master (high-resolution master).
274    #[serde(default)]
275    pub is_apple_digital_master: bool,
276
277    /// Audio traits (e.g. `["atmos", "lossless", "lossy-stereo", "spatial"]`).
278    #[serde(default)]
279    pub audio_traits: Vec<String>,
280
281    /// Audio preview URLs.
282    #[serde(default)]
283    pub previews: Vec<Preview>,
284}
285
286impl NowPlaying {
287    /// Get the song ID from [`play_params`](Self::play_params), if present.
288    #[must_use]
289    pub fn song_id(&self) -> Option<&str> {
290        self.play_params.as_ref().map(|p| p.id.as_str())
291    }
292
293    /// Get the current playback position in milliseconds.
294    ///
295    /// Negative `current_playback_time` values (possible at seek boundaries)
296    /// are clamped to zero.
297    #[must_use]
298    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
299    pub fn current_position_ms(&self) -> u64 {
300        // max(0.0) guards against negative values; truncation is intentional.
301        (self.current_playback_time.max(0.0) * 1000.0).round() as u64
302    }
303
304    /// Get the artwork URL at the specified square size (in pixels).
305    ///
306    /// Shorthand for `self.artwork.url_for_size(size)`.
307    #[must_use]
308    pub fn artwork_url(&self, size: u32) -> String {
309        self.artwork.url_for_size(size)
310    }
311}
312
313// ─── Queue types ─────────────────────────────────────────────────────────────
314
315/// A single item in the Cider playback queue.
316///
317/// Returned as part of the array from `GET /queue`. The queue includes
318/// history items, the currently playing track, and upcoming items. Use
319/// [`QueueItem::is_current`] to identify the active track.
320///
321/// Most useful data lives in [`attributes`](Self::attributes). Top-level
322/// fields like `asset_url`, `assets`, and `key_urls` are Apple Music
323/// streaming internals.
324///
325/// # Examples
326///
327/// ```no_run
328/// # use cider_api::{CiderClient, QueueItem};
329/// # async fn example() -> Result<(), cider_api::CiderError> {
330/// let queue = CiderClient::new().get_queue().await?;
331///
332/// // Find the currently playing item
333/// if let Some(current) = queue.iter().find(|i| i.is_current()) {
334///     if let Some(attrs) = &current.attributes {
335///         println!("Now playing: {} — {}", attrs.name, attrs.artist_name);
336///     }
337/// }
338///
339/// // List upcoming tracks
340/// let current_idx = queue.iter().position(|i| i.is_current()).unwrap_or(0);
341/// for item in &queue[current_idx + 1..] {
342///     if let Some(attrs) = &item.attributes {
343///         println!("  Up next: {}", attrs.name);
344///     }
345/// }
346/// # Ok(())
347/// # }
348/// ```
349#[derive(Debug, Clone, Serialize, Deserialize)]
350#[serde(rename_all = "camelCase")]
351pub struct QueueItem {
352    /// Apple Music catalog ID for this item.
353    #[serde(default)]
354    pub id: Option<String>,
355
356    /// Item type (e.g. `"song"`).
357    #[serde(default, rename = "type")]
358    pub item_type: Option<String>,
359
360    /// HLS streaming URL for the asset.
361    #[serde(default, rename = "assetURL")]
362    pub asset_url: Option<String>,
363
364    /// HLS metadata (opaque object).
365    #[serde(default)]
366    pub hls_metadata: Option<serde_json::Value>,
367
368    /// Audio flavor / codec descriptor (e.g. `"28:ctrp256"`).
369    #[serde(default)]
370    pub flavor: Option<String>,
371
372    /// Track metadata attributes.
373    #[serde(default)]
374    pub attributes: Option<QueueItemAttributes>,
375
376    /// Playback type identifier.
377    #[serde(default)]
378    pub playback_type: Option<u32>,
379
380    /// The container this item was queued from (e.g. a station or playlist).
381    #[serde(default, rename = "_container")]
382    pub container: Option<QueueContainer>,
383
384    /// Context information about how this item was queued.
385    #[serde(default, rename = "_context")]
386    pub context: Option<QueueContext>,
387
388    /// Playback state — `current == Some(2)` means currently playing.
389    #[serde(default, rename = "_state")]
390    pub state: Option<QueueItemState>,
391
392    /// Song ID (may differ from `id` for library vs. catalog tracks).
393    #[serde(default, rename = "_songId")]
394    pub song_id: Option<String>,
395
396    /// Available audio assets with different codec flavors and metadata.
397    #[serde(default)]
398    pub assets: Option<Vec<serde_json::Value>>,
399
400    /// DRM key URLs for HLS playback.
401    #[serde(default, rename = "keyURLs")]
402    pub key_urls: Option<KeyUrls>,
403}
404
405impl QueueItem {
406    /// Returns `true` if this is the currently playing item.
407    #[must_use]
408    pub fn is_current(&self) -> bool {
409        self.state
410            .as_ref()
411            .and_then(|s| s.current)
412            .is_some_and(|c| c == 2)
413    }
414}
415
416/// Track attributes within a [`QueueItem`].
417///
418/// Contains the same catalog metadata as [`NowPlaying`] plus
419/// live playback state injected by Cider.
420#[derive(Debug, Clone, Serialize, Deserialize)]
421#[serde(rename_all = "camelCase")]
422#[allow(clippy::struct_excessive_bools)]
423pub struct QueueItemAttributes {
424    /// Song name.
425    #[serde(default)]
426    pub name: String,
427
428    /// Artist name.
429    #[serde(default)]
430    pub artist_name: String,
431
432    /// Album name.
433    #[serde(default)]
434    pub album_name: String,
435
436    /// Total duration in milliseconds.
437    #[serde(default)]
438    pub duration_in_millis: u64,
439
440    // ── Identifiers ──
441
442    /// Artwork information.
443    #[serde(default)]
444    pub artwork: Option<Artwork>,
445
446    /// Play parameters containing the song ID and kind.
447    #[serde(default)]
448    pub play_params: Option<PlayParams>,
449
450    /// Apple Music web URL for the track.
451    #[serde(default)]
452    pub url: Option<String>,
453
454    /// International Standard Recording Code.
455    #[serde(default)]
456    pub isrc: Option<String>,
457
458    // ── Catalog metadata ──
459
460    /// Genre names.
461    #[serde(default)]
462    pub genre_names: Vec<String>,
463
464    /// Track number on the album.
465    #[serde(default)]
466    pub track_number: u32,
467
468    /// Disc number on the album.
469    #[serde(default)]
470    pub disc_number: u32,
471
472    /// Release date as an ISO-8601 string.
473    #[serde(default)]
474    pub release_date: Option<String>,
475
476    /// Audio locale code (e.g. `"en-US"`).
477    #[serde(default)]
478    pub audio_locale: Option<String>,
479
480    /// Composer / songwriter name.
481    #[serde(default)]
482    pub composer_name: Option<String>,
483
484    /// Whether the track has lyrics.
485    #[serde(default)]
486    pub has_lyrics: bool,
487
488    /// Whether the track has time-synced lyrics.
489    #[serde(default)]
490    pub has_time_synced_lyrics: bool,
491
492    /// Whether vocal attenuation is available.
493    #[serde(default)]
494    pub is_vocal_attenuation_allowed: bool,
495
496    /// Legacy Mastered for iTunes flag.
497    #[serde(default)]
498    pub is_mastered_for_itunes: bool,
499
500    /// Whether the track is an Apple Digital Master.
501    #[serde(default)]
502    pub is_apple_digital_master: bool,
503
504    /// Audio traits (e.g. `["lossless", "lossy-stereo"]`).
505    #[serde(default)]
506    pub audio_traits: Vec<String>,
507
508    /// Audio preview URLs.
509    #[serde(default)]
510    pub previews: Vec<Preview>,
511
512    // ── Playback state (injected by Cider) ──
513
514    /// Current playback position in seconds.
515    #[serde(default)]
516    pub current_playback_time: f64,
517
518    /// Remaining playback time in seconds.
519    #[serde(default)]
520    pub remaining_time: f64,
521}
522
523/// Playback state of a [`QueueItem`].
524#[derive(Debug, Clone, Serialize, Deserialize)]
525pub struct QueueItemState {
526    /// `2` indicates this is the currently playing item.
527    #[serde(default)]
528    pub current: Option<u8>,
529}
530
531/// The container (playlist, station, album) a queue item was sourced from.
532///
533/// Container `attributes` vary by type and are exposed as raw JSON.
534#[derive(Debug, Clone, Serialize, Deserialize)]
535#[serde(rename_all = "camelCase")]
536pub struct QueueContainer {
537    /// Container ID (e.g. `"ra.cp-1055074639"`).
538    #[serde(default)]
539    pub id: Option<String>,
540
541    /// Container type (e.g. `"stations"`, `"playlists"`, `"albums"`).
542    #[serde(default, rename = "type")]
543    pub container_type: Option<String>,
544
545    /// Apple Music API href for the container.
546    #[serde(default)]
547    pub href: Option<String>,
548
549    /// Display name / context label (e.g. `"now_playing"`).
550    #[serde(default)]
551    pub name: Option<String>,
552
553    /// Container-specific attributes (varies by type).
554    #[serde(default)]
555    pub attributes: Option<serde_json::Value>,
556}
557
558/// Context metadata for a [`QueueItem`].
559#[derive(Debug, Clone, Serialize, Deserialize)]
560#[serde(rename_all = "camelCase")]
561pub struct QueueContext {
562    /// Feature that queued this item (e.g. `"now_playing"`).
563    #[serde(default)]
564    pub feature_name: Option<String>,
565}
566
567/// DRM / streaming key URLs for HLS playback.
568#[derive(Debug, Clone, Serialize, Deserialize)]
569pub struct KeyUrls {
570    /// URL for the HLS `FairPlay` certificate bundle.
571    #[serde(default, rename = "hls-key-cert-url")]
572    pub hls_key_cert_url: Option<String>,
573
574    /// URL for the HLS `FairPlay` license server.
575    #[serde(default, rename = "hls-key-server-url")]
576    pub hls_key_server_url: Option<String>,
577
578    /// URL for the Widevine certificate.
579    #[serde(default, rename = "widevine-cert-url")]
580    pub widevine_cert_url: Option<String>,
581}
582
583// ─── Endpoint-specific response payloads ─────────────────────────────────────
584
585/// Payload for `GET /is-playing`.
586#[derive(Debug, Clone, Deserialize)]
587pub struct IsPlayingResponse {
588    /// `true` if music is currently playing.
589    pub is_playing: bool,
590}
591
592/// Payload for `GET /now-playing`.
593#[derive(Debug, Clone, Deserialize)]
594pub struct NowPlayingResponse {
595    /// Currently playing track info.
596    pub info: NowPlaying,
597}
598
599/// Payload for `GET /volume`.
600#[derive(Debug, Clone, Deserialize)]
601pub struct VolumeResponse {
602    /// Current volume level (`0.0`–`1.0`).
603    pub volume: f32,
604}
605
606/// Payload for `GET /repeat-mode`.
607#[derive(Debug, Clone, Deserialize)]
608pub struct RepeatModeResponse {
609    /// `0` = off, `1` = repeat one, `2` = repeat all.
610    pub value: u8,
611}
612
613/// Payload for `GET /shuffle-mode`.
614#[derive(Debug, Clone, Deserialize)]
615pub struct ShuffleModeResponse {
616    /// `0` = off, `1` = on.
617    pub value: u8,
618}
619
620/// Payload for `GET /autoplay`.
621#[derive(Debug, Clone, Deserialize)]
622pub struct AutoplayResponse {
623    /// `true` = autoplay enabled.
624    pub value: bool,
625}
626
627// ─── Request bodies ──────────────────────────────────────────────────────────
628
629/// Request body for `POST /play-url`.
630#[derive(Debug, Clone, Serialize)]
631pub struct PlayUrlRequest {
632    /// Apple Music URL to play (e.g. `"https://music.apple.com/…"`).
633    pub url: String,
634}
635
636/// Request body for `POST /play-item` / `POST /play-next` / `POST /play-later`.
637#[derive(Debug, Clone, Serialize)]
638pub struct PlayItemRequest {
639    /// Item type (e.g. `"songs"`, `"albums"`, `"playlists"`).
640    #[serde(rename = "type")]
641    pub item_type: String,
642
643    /// Apple Music catalog ID (must be a string, not a number).
644    pub id: String,
645}
646
647/// Request body for `POST /play-item-href`.
648#[derive(Debug, Clone, Serialize)]
649pub struct PlayItemHrefRequest {
650    /// Apple Music API href (e.g. `"/v1/catalog/ca/songs/1719861213"`).
651    pub href: String,
652}
653
654/// Request body for `POST /seek`.
655#[derive(Debug, Clone, Serialize)]
656pub struct SeekRequest {
657    /// Target position in **seconds**.
658    pub position: f64,
659}
660
661/// Request body for `POST /volume`.
662#[derive(Debug, Clone, Serialize)]
663pub struct VolumeRequest {
664    /// Target volume (`0.0`–`1.0`).
665    pub volume: f32,
666}
667
668/// Request body for `POST /set-rating`.
669#[derive(Debug, Clone, Serialize)]
670pub struct RatingRequest {
671    /// `-1` = dislike, `0` = unset, `1` = like.
672    pub rating: i8,
673}
674
675/// Request body for `POST /queue/move-to-position`.
676#[derive(Debug, Clone, Serialize)]
677#[serde(rename_all = "camelCase")]
678pub struct QueueMoveRequest {
679    /// Current 1-based index of the item to move.
680    pub start_index: u32,
681
682    /// Target 1-based index.
683    pub destination_index: u32,
684
685    /// If `true`, the response includes the updated queue.
686    #[serde(skip_serializing_if = "Option::is_none")]
687    pub return_queue: Option<bool>,
688}
689
690/// Request body for `POST /queue/remove-by-index`.
691#[derive(Debug, Clone, Serialize)]
692pub struct QueueRemoveRequest {
693    /// 1-based index of the item to remove.
694    pub index: u32,
695}
696
697/// Request body for `POST /api/v1/amapi/run-v3`.
698#[derive(Debug, Clone, Serialize)]
699pub struct AmApiRequest {
700    /// Apple Music API path (e.g. `"/v1/catalog/ca/search?term=…"`).
701    pub path: String,
702}
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707
708    // ── NowPlaying deserialization ──
709
710    #[test]
711    fn deserialize_now_playing_full() {
712        let json = r#"{
713            "name": "Never Be Like You",
714            "artistName": "Flume",
715            "albumName": "Skin",
716            "artwork": {
717                "width": 3000,
718                "height": 3000,
719                "url": "https://example.com/{w}x{h}bb.jpg"
720            },
721            "durationInMillis": 234000,
722            "playParams": { "id": "1719861213", "kind": "song" },
723            "url": "https://music.apple.com/ca/album/skin/1719860281",
724            "isrc": "AUUM71600506",
725            "currentPlaybackTime": 42.5,
726            "remainingTime": 191.5,
727            "shuffleMode": 1,
728            "repeatMode": 0,
729            "inFavorites": true,
730            "inLibrary": true,
731            "genreNames": ["Electronic", "Music"],
732            "trackNumber": 3,
733            "discNumber": 1,
734            "releaseDate": "2016-05-27T12:00:00Z",
735            "hasLyrics": true,
736            "isAppleDigitalMaster": true,
737            "audioTraits": ["lossless", "lossy-stereo"],
738            "previews": [{"url": "https://audio-ssl.itunes.apple.com/preview.m4a"}]
739        }"#;
740
741        let track: NowPlaying = serde_json::from_str(json).unwrap();
742        assert_eq!(track.name, "Never Be Like You");
743        assert_eq!(track.artist_name, "Flume");
744        assert_eq!(track.album_name, "Skin");
745        assert_eq!(track.duration_in_millis, 234000);
746        assert_eq!(track.song_id(), Some("1719861213"));
747        assert!(track.in_favorites);
748        assert!(track.in_library);
749        assert_eq!(track.genre_names.len(), 2);
750        assert_eq!(track.track_number, 3);
751        assert!(track.has_lyrics);
752        assert!(track.is_apple_digital_master);
753        assert_eq!(track.previews.len(), 1);
754    }
755
756    #[test]
757    fn deserialize_now_playing_minimal() {
758        let json = r#"{"name": "Some Station"}"#;
759        let track: NowPlaying = serde_json::from_str(json).unwrap();
760        assert_eq!(track.name, "Some Station");
761        assert_eq!(track.artist_name, "");
762        assert_eq!(track.duration_in_millis, 0);
763        assert!(track.song_id().is_none());
764        assert!(!track.in_favorites);
765        assert!(track.genre_names.is_empty());
766    }
767
768    #[test]
769    fn deserialize_now_playing_empty_object() {
770        let track: NowPlaying = serde_json::from_str("{}").unwrap();
771        assert_eq!(track.name, "");
772        assert!(track.play_params.is_none());
773    }
774
775    // ── Helper methods ──
776
777    #[test]
778    fn artwork_url_for_size_replaces_placeholders() {
779        let art = Artwork {
780            url: "https://example.com/{w}x{h}bb.jpg".into(),
781            width: 3000,
782            height: 3000,
783            ..Default::default()
784        };
785        assert_eq!(art.url_for_size(300), "https://example.com/300x300bb.jpg");
786    }
787
788    #[test]
789    fn artwork_url_for_size_no_placeholders() {
790        let art = Artwork {
791            url: "https://example.com/static.jpg".into(),
792            ..Default::default()
793        };
794        assert_eq!(art.url_for_size(300), "https://example.com/static.jpg");
795    }
796
797    #[test]
798    fn now_playing_current_position_ms() {
799        let track: NowPlaying = serde_json::from_str(r#"{"currentPlaybackTime": 42.567}"#).unwrap();
800        assert_eq!(track.current_position_ms(), 42567);
801    }
802
803    #[test]
804    fn now_playing_current_position_ms_zero() {
805        let track: NowPlaying = serde_json::from_str("{}").unwrap();
806        assert_eq!(track.current_position_ms(), 0);
807    }
808
809    #[test]
810    fn now_playing_current_position_ms_negative_clamped() {
811        let track: NowPlaying = serde_json::from_str(r#"{"currentPlaybackTime": -0.5}"#).unwrap();
812        assert_eq!(track.current_position_ms(), 0);
813    }
814
815    #[test]
816    fn now_playing_artwork_url_delegates() {
817        let track: NowPlaying = serde_json::from_str(
818            r#"{"artwork": {"url": "https://example.com/{w}x{h}bb.jpg"}}"#,
819        )
820        .unwrap();
821        assert_eq!(
822            track.artwork_url(600),
823            "https://example.com/600x600bb.jpg"
824        );
825    }
826
827    #[test]
828    fn queue_item_is_current_true() {
829        let item: QueueItem =
830            serde_json::from_str(r#"{"_state": {"current": 2}}"#).unwrap();
831        assert!(item.is_current());
832    }
833
834    #[test]
835    fn queue_item_is_current_false_when_not_2() {
836        let item: QueueItem =
837            serde_json::from_str(r#"{"_state": {"current": 1}}"#).unwrap();
838        assert!(!item.is_current());
839    }
840
841    #[test]
842    fn queue_item_is_current_false_when_no_state() {
843        let item: QueueItem = serde_json::from_str("{}").unwrap();
844        assert!(!item.is_current());
845    }
846
847    // ── Request body serialization ──
848
849    #[test]
850    fn play_item_request_renames_type() {
851        let req = PlayItemRequest {
852            item_type: "songs".into(),
853            id: "123".into(),
854        };
855        let json: serde_json::Value = serde_json::to_value(&req).unwrap();
856        assert_eq!(json["type"], "songs");
857        assert_eq!(json["id"], "123");
858        assert!(json.get("item_type").is_none());
859    }
860
861    #[test]
862    fn seek_request_serialization() {
863        let req = SeekRequest { position: 30.5 };
864        let json: serde_json::Value = serde_json::to_value(&req).unwrap();
865        assert!((json["position"].as_f64().unwrap() - 30.5).abs() < 0.001);
866    }
867
868    #[test]
869    fn volume_request_serialization() {
870        let req = VolumeRequest { volume: 0.75 };
871        let json: serde_json::Value = serde_json::to_value(&req).unwrap();
872        assert!((json["volume"].as_f64().unwrap() - 0.75).abs() < 0.001);
873    }
874
875    #[test]
876    fn rating_request_serialization() {
877        let req = RatingRequest { rating: -1 };
878        let json: serde_json::Value = serde_json::to_value(&req).unwrap();
879        assert_eq!(json["rating"], -1);
880    }
881
882    #[test]
883    fn queue_move_request_omits_none_return_queue() {
884        let req = QueueMoveRequest {
885            start_index: 3,
886            destination_index: 1,
887            return_queue: None,
888        };
889        let json = serde_json::to_string(&req).unwrap();
890        assert!(!json.contains("returnQueue"));
891        let val: serde_json::Value = serde_json::from_str(&json).unwrap();
892        assert_eq!(val["startIndex"], 3);
893        assert_eq!(val["destinationIndex"], 1);
894    }
895
896    #[test]
897    fn queue_move_request_includes_return_queue_when_some() {
898        let req = QueueMoveRequest {
899            start_index: 1,
900            destination_index: 5,
901            return_queue: Some(true),
902        };
903        let json: serde_json::Value = serde_json::to_value(&req).unwrap();
904        assert_eq!(json["returnQueue"], true);
905    }
906
907    #[test]
908    fn queue_remove_request_serialization() {
909        let req = QueueRemoveRequest { index: 7 };
910        let json: serde_json::Value = serde_json::to_value(&req).unwrap();
911        assert_eq!(json["index"], 7);
912    }
913
914    #[test]
915    fn amapi_request_serialization() {
916        let req = AmApiRequest {
917            path: "/v1/catalog/us/search?term=flume".into(),
918        };
919        let json: serde_json::Value = serde_json::to_value(&req).unwrap();
920        assert_eq!(json["path"], "/v1/catalog/us/search?term=flume");
921    }
922
923    #[test]
924    fn play_url_request_serialization() {
925        let req = PlayUrlRequest {
926            url: "https://music.apple.com/ca/album/skin/1719860281".into(),
927        };
928        let json: serde_json::Value = serde_json::to_value(&req).unwrap();
929        assert_eq!(
930            json["url"],
931            "https://music.apple.com/ca/album/skin/1719860281"
932        );
933    }
934
935    #[test]
936    fn play_item_href_request_serialization() {
937        let req = PlayItemHrefRequest {
938            href: "/v1/catalog/ca/songs/123".into(),
939        };
940        let json: serde_json::Value = serde_json::to_value(&req).unwrap();
941        assert_eq!(json["href"], "/v1/catalog/ca/songs/123");
942    }
943
944    // ── ApiResponse flatten deserialization ──
945
946    #[test]
947    fn api_response_is_playing() {
948        let json = r#"{"status":"ok","is_playing":true}"#;
949        let resp: ApiResponse<IsPlayingResponse> = serde_json::from_str(json).unwrap();
950        assert_eq!(resp.status, "ok");
951        assert!(resp.data.is_playing);
952    }
953
954    #[test]
955    fn api_response_volume() {
956        let json = r#"{"status":"ok","volume":0.65}"#;
957        let resp: ApiResponse<VolumeResponse> = serde_json::from_str(json).unwrap();
958        assert!((resp.data.volume - 0.65).abs() < 0.001);
959    }
960
961    #[test]
962    fn api_response_repeat_mode() {
963        let json = r#"{"status":"ok","value":2}"#;
964        let resp: ApiResponse<RepeatModeResponse> = serde_json::from_str(json).unwrap();
965        assert_eq!(resp.data.value, 2);
966    }
967
968    #[test]
969    fn api_response_shuffle_mode() {
970        let json = r#"{"status":"ok","value":1}"#;
971        let resp: ApiResponse<ShuffleModeResponse> = serde_json::from_str(json).unwrap();
972        assert_eq!(resp.data.value, 1);
973    }
974
975    #[test]
976    fn api_response_autoplay() {
977        let json = r#"{"status":"ok","value":true}"#;
978        let resp: ApiResponse<AutoplayResponse> = serde_json::from_str(json).unwrap();
979        assert!(resp.data.value);
980    }
981
982    #[test]
983    fn api_response_now_playing() {
984        let json = r#"{"status":"ok","info":{"name":"Test Track","artistName":"Artist"}}"#;
985        let resp: ApiResponse<NowPlayingResponse> = serde_json::from_str(json).unwrap();
986        assert_eq!(resp.data.info.name, "Test Track");
987        assert_eq!(resp.data.info.artist_name, "Artist");
988    }
989
990    // ── Queue item deserialization ──
991
992    #[test]
993    fn deserialize_queue_item_array() {
994        let json = r#"[
995            {"id": "123", "type": "song", "_state": {"current": 2}, "attributes": {"name": "Track 1", "artistName": "Artist"}},
996            {"id": "456", "type": "song", "attributes": {"name": "Track 2", "artistName": "Artist"}}
997        ]"#;
998        let items: Vec<QueueItem> = serde_json::from_str(json).unwrap();
999        assert_eq!(items.len(), 2);
1000        assert!(items[0].is_current());
1001        assert!(!items[1].is_current());
1002        assert_eq!(items[0].attributes.as_ref().unwrap().name, "Track 1");
1003    }
1004}