1use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone, Deserialize)]
29pub struct ApiResponse<T> {
30 pub status: String,
32
33 #[serde(flatten)]
35 pub data: T,
36}
37
38#[derive(Debug, Clone, Default, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct Artwork {
66 #[serde(default)]
68 pub width: u32,
69
70 #[serde(default)]
72 pub height: u32,
73
74 #[serde(default)]
76 pub url: String,
77
78 #[serde(default)]
80 pub text_color1: Option<String>,
81
82 #[serde(default)]
84 pub text_color2: Option<String>,
85
86 #[serde(default)]
88 pub text_color3: Option<String>,
89
90 #[serde(default)]
92 pub text_color4: Option<String>,
93
94 #[serde(default)]
96 pub bg_color: Option<String>,
97
98 #[serde(default)]
100 pub has_p3: Option<bool>,
101}
102
103impl Artwork {
104 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct PlayParams {
128 pub id: String,
130
131 pub kind: String,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct Preview {
140 pub url: String,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
168#[serde(rename_all = "camelCase")]
169#[allow(clippy::struct_excessive_bools)]
170pub struct NowPlaying {
171 #[serde(default)]
173 pub name: String,
174
175 #[serde(default)]
177 pub artist_name: String,
178
179 #[serde(default)]
181 pub album_name: String,
182
183 #[serde(default)]
185 pub artwork: Artwork,
186
187 #[serde(default)]
189 pub duration_in_millis: u64,
190
191 #[serde(default)]
195 pub play_params: Option<PlayParams>,
196
197 #[serde(default)]
199 pub url: Option<String>,
200
201 #[serde(default)]
203 pub isrc: Option<String>,
204
205 #[serde(default)]
209 pub current_playback_time: f64,
210
211 #[serde(default)]
213 pub remaining_time: f64,
214
215 #[serde(default)]
217 pub shuffle_mode: u8,
218
219 #[serde(default)]
221 pub repeat_mode: u8,
222
223 #[serde(default)]
225 pub in_favorites: bool,
226
227 #[serde(default)]
229 pub in_library: bool,
230
231 #[serde(default)]
235 pub genre_names: Vec<String>,
236
237 #[serde(default)]
239 pub track_number: u32,
240
241 #[serde(default)]
243 pub disc_number: u32,
244
245 #[serde(default)]
247 pub release_date: Option<String>,
248
249 #[serde(default)]
251 pub audio_locale: Option<String>,
252
253 #[serde(default)]
255 pub composer_name: Option<String>,
256
257 #[serde(default)]
259 pub has_lyrics: bool,
260
261 #[serde(default)]
263 pub has_time_synced_lyrics: bool,
264
265 #[serde(default)]
267 pub is_vocal_attenuation_allowed: bool,
268
269 #[serde(default)]
271 pub is_mastered_for_itunes: bool,
272
273 #[serde(default)]
275 pub is_apple_digital_master: bool,
276
277 #[serde(default)]
279 pub audio_traits: Vec<String>,
280
281 #[serde(default)]
283 pub previews: Vec<Preview>,
284}
285
286impl NowPlaying {
287 #[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 #[must_use]
298 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
299 pub fn current_position_ms(&self) -> u64 {
300 (self.current_playback_time.max(0.0) * 1000.0).round() as u64
302 }
303
304 #[must_use]
308 pub fn artwork_url(&self, size: u32) -> String {
309 self.artwork.url_for_size(size)
310 }
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
350#[serde(rename_all = "camelCase")]
351pub struct QueueItem {
352 #[serde(default)]
354 pub id: Option<String>,
355
356 #[serde(default, rename = "type")]
358 pub item_type: Option<String>,
359
360 #[serde(default, rename = "assetURL")]
362 pub asset_url: Option<String>,
363
364 #[serde(default)]
366 pub hls_metadata: Option<serde_json::Value>,
367
368 #[serde(default)]
370 pub flavor: Option<String>,
371
372 #[serde(default)]
374 pub attributes: Option<QueueItemAttributes>,
375
376 #[serde(default)]
378 pub playback_type: Option<u32>,
379
380 #[serde(default, rename = "_container")]
382 pub container: Option<QueueContainer>,
383
384 #[serde(default, rename = "_context")]
386 pub context: Option<QueueContext>,
387
388 #[serde(default, rename = "_state")]
390 pub state: Option<QueueItemState>,
391
392 #[serde(default, rename = "_songId")]
394 pub song_id: Option<String>,
395
396 #[serde(default)]
398 pub assets: Option<Vec<serde_json::Value>>,
399
400 #[serde(default, rename = "keyURLs")]
402 pub key_urls: Option<KeyUrls>,
403}
404
405impl QueueItem {
406 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
421#[serde(rename_all = "camelCase")]
422#[allow(clippy::struct_excessive_bools)]
423pub struct QueueItemAttributes {
424 #[serde(default)]
426 pub name: String,
427
428 #[serde(default)]
430 pub artist_name: String,
431
432 #[serde(default)]
434 pub album_name: String,
435
436 #[serde(default)]
438 pub duration_in_millis: u64,
439
440 #[serde(default)]
444 pub artwork: Option<Artwork>,
445
446 #[serde(default)]
448 pub play_params: Option<PlayParams>,
449
450 #[serde(default)]
452 pub url: Option<String>,
453
454 #[serde(default)]
456 pub isrc: Option<String>,
457
458 #[serde(default)]
462 pub genre_names: Vec<String>,
463
464 #[serde(default)]
466 pub track_number: u32,
467
468 #[serde(default)]
470 pub disc_number: u32,
471
472 #[serde(default)]
474 pub release_date: Option<String>,
475
476 #[serde(default)]
478 pub audio_locale: Option<String>,
479
480 #[serde(default)]
482 pub composer_name: Option<String>,
483
484 #[serde(default)]
486 pub has_lyrics: bool,
487
488 #[serde(default)]
490 pub has_time_synced_lyrics: bool,
491
492 #[serde(default)]
494 pub is_vocal_attenuation_allowed: bool,
495
496 #[serde(default)]
498 pub is_mastered_for_itunes: bool,
499
500 #[serde(default)]
502 pub is_apple_digital_master: bool,
503
504 #[serde(default)]
506 pub audio_traits: Vec<String>,
507
508 #[serde(default)]
510 pub previews: Vec<Preview>,
511
512 #[serde(default)]
516 pub current_playback_time: f64,
517
518 #[serde(default)]
520 pub remaining_time: f64,
521}
522
523#[derive(Debug, Clone, Serialize, Deserialize)]
525pub struct QueueItemState {
526 #[serde(default)]
528 pub current: Option<u8>,
529}
530
531#[derive(Debug, Clone, Serialize, Deserialize)]
535#[serde(rename_all = "camelCase")]
536pub struct QueueContainer {
537 #[serde(default)]
539 pub id: Option<String>,
540
541 #[serde(default, rename = "type")]
543 pub container_type: Option<String>,
544
545 #[serde(default)]
547 pub href: Option<String>,
548
549 #[serde(default)]
551 pub name: Option<String>,
552
553 #[serde(default)]
555 pub attributes: Option<serde_json::Value>,
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize)]
560#[serde(rename_all = "camelCase")]
561pub struct QueueContext {
562 #[serde(default)]
564 pub feature_name: Option<String>,
565}
566
567#[derive(Debug, Clone, Serialize, Deserialize)]
569pub struct KeyUrls {
570 #[serde(default, rename = "hls-key-cert-url")]
572 pub hls_key_cert_url: Option<String>,
573
574 #[serde(default, rename = "hls-key-server-url")]
576 pub hls_key_server_url: Option<String>,
577
578 #[serde(default, rename = "widevine-cert-url")]
580 pub widevine_cert_url: Option<String>,
581}
582
583#[derive(Debug, Clone, Deserialize)]
587pub struct IsPlayingResponse {
588 pub is_playing: bool,
590}
591
592#[derive(Debug, Clone, Deserialize)]
594pub struct NowPlayingResponse {
595 pub info: NowPlaying,
597}
598
599#[derive(Debug, Clone, Deserialize)]
601pub struct VolumeResponse {
602 pub volume: f32,
604}
605
606#[derive(Debug, Clone, Deserialize)]
608pub struct RepeatModeResponse {
609 pub value: u8,
611}
612
613#[derive(Debug, Clone, Deserialize)]
615pub struct ShuffleModeResponse {
616 pub value: u8,
618}
619
620#[derive(Debug, Clone, Deserialize)]
622pub struct AutoplayResponse {
623 pub value: bool,
625}
626
627#[derive(Debug, Clone, Serialize)]
631pub struct PlayUrlRequest {
632 pub url: String,
634}
635
636#[derive(Debug, Clone, Serialize)]
638pub struct PlayItemRequest {
639 #[serde(rename = "type")]
641 pub item_type: String,
642
643 pub id: String,
645}
646
647#[derive(Debug, Clone, Serialize)]
649pub struct PlayItemHrefRequest {
650 pub href: String,
652}
653
654#[derive(Debug, Clone, Serialize)]
656pub struct SeekRequest {
657 pub position: f64,
659}
660
661#[derive(Debug, Clone, Serialize)]
663pub struct VolumeRequest {
664 pub volume: f32,
666}
667
668#[derive(Debug, Clone, Serialize)]
670pub struct RatingRequest {
671 pub rating: i8,
673}
674
675#[derive(Debug, Clone, Serialize)]
677#[serde(rename_all = "camelCase")]
678pub struct QueueMoveRequest {
679 pub start_index: u32,
681
682 pub destination_index: u32,
684
685 #[serde(skip_serializing_if = "Option::is_none")]
687 pub return_queue: Option<bool>,
688}
689
690#[derive(Debug, Clone, Serialize)]
692pub struct QueueRemoveRequest {
693 pub index: u32,
695}
696
697#[derive(Debug, Clone, Serialize)]
699pub struct AmApiRequest {
700 pub path: String,
702}
703
704#[cfg(test)]
705mod tests {
706 use super::*;
707
708 #[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 #[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 #[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 #[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 #[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}