1use serde::Serialize;
3
4use crate::domain::album::Album;
5use crate::domain::artist::Artist;
6use crate::domain::auth::{AuthScopes, AuthStatus};
7use crate::domain::device::Device;
8use crate::domain::player::PlayerStatus;
9use crate::domain::pin::PinnedPlaylist;
10use crate::domain::playlist::{Playlist, PlaylistDetail};
11use crate::domain::search::{SearchItem, SearchResults, SearchType};
12use crate::error::Result;
13
14#[derive(Serialize)]
15struct AuthStatusPayload {
16 logged_in: bool,
17 expires_at: Option<u64>,
18}
19
20pub fn auth_status(status: AuthStatus) -> Result<()> {
21 let payload = auth_status_payload(status);
22 println!("{}", serde_json::to_string(&payload)?);
23 Ok(())
24}
25
26fn auth_status_payload(status: AuthStatus) -> AuthStatusPayload {
27 AuthStatusPayload {
28 logged_in: status.logged_in,
29 expires_at: status.expires_at,
30 }
31}
32
33#[derive(Serialize)]
34struct AuthScopesPayload {
35 required: Vec<String>,
36 granted: Option<Vec<String>>,
37 missing: Vec<String>,
38}
39
40pub fn auth_scopes(scopes: AuthScopes) -> Result<()> {
41 let payload = auth_scopes_payload(scopes);
42 println!("{}", serde_json::to_string(&payload)?);
43 Ok(())
44}
45
46fn auth_scopes_payload(scopes: AuthScopes) -> AuthScopesPayload {
47 AuthScopesPayload {
48 required: scopes.required,
49 granted: scopes.granted,
50 missing: scopes.missing,
51 }
52}
53
54#[derive(Serialize)]
55struct PlayerStatusPayload {
56 is_playing: bool,
57 track: Option<TrackPayload>,
58 device: Option<DevicePayload>,
59 context: Option<PlaybackContextPayload>,
60 progress_ms: Option<u32>,
61 repeat_state: Option<String>,
62 shuffle_state: Option<bool>,
63}
64
65#[derive(Serialize)]
66struct TrackPayload {
67 id: String,
68 name: String,
69 artists: Vec<String>,
70 album: Option<String>,
71 album_id: Option<String>,
72 duration_ms: Option<u32>,
73}
74
75#[derive(Serialize)]
76struct PlaybackContextPayload {
77 kind: String,
78 uri: String,
79}
80
81pub fn player_status(status: PlayerStatus) -> Result<()> {
82 let payload = player_status_payload(status);
83 println!("{}", serde_json::to_string(&payload)?);
84 Ok(())
85}
86
87fn player_status_payload(status: PlayerStatus) -> PlayerStatusPayload {
88 let track = status.track.map(track_payload);
89 let device = status.device.map(device_payload);
90 let context = status.context.map(|context| PlaybackContextPayload {
91 kind: context.kind,
92 uri: context.uri,
93 });
94
95 PlayerStatusPayload {
96 is_playing: status.is_playing,
97 track,
98 device,
99 context,
100 progress_ms: status.progress_ms,
101 repeat_state: status.repeat_state,
102 shuffle_state: status.shuffle_state,
103 }
104}
105
106#[derive(Serialize)]
107struct NowPlayingPayload {
108 event: &'static str,
109 status: PlayerStatusPayload,
110}
111
112pub fn now_playing(status: PlayerStatus) -> Result<()> {
113 let payload = now_playing_payload(status);
114 println!("{}", serde_json::to_string(&payload)?);
115 Ok(())
116}
117
118fn now_playing_payload(status: PlayerStatus) -> NowPlayingPayload {
119 let track = status.track.map(track_payload);
120 let device = status.device.map(device_payload);
121 let context = status.context.map(|context| PlaybackContextPayload {
122 kind: context.kind,
123 uri: context.uri,
124 });
125
126 let status_payload = PlayerStatusPayload {
127 is_playing: status.is_playing,
128 track,
129 device,
130 context,
131 progress_ms: status.progress_ms,
132 repeat_state: status.repeat_state,
133 shuffle_state: status.shuffle_state,
134 };
135
136 NowPlayingPayload {
137 event: "now_playing",
138 status: status_payload,
139 }
140}
141
142#[derive(Serialize)]
143struct DevicePayload {
144 id: String,
145 name: String,
146 volume_percent: Option<u32>,
147}
148
149#[derive(Serialize)]
150struct ActionPayload<'a> {
151 event: &'a str,
152 message: &'a str,
153}
154
155pub fn action(event: &str, message: &str) -> Result<()> {
156 let payload = action_payload(event, message);
157 println!("{}", serde_json::to_string(&payload)?);
158 Ok(())
159}
160
161fn action_payload<'a>(event: &'a str, message: &'a str) -> ActionPayload<'a> {
162 ActionPayload { event, message }
163}
164
165#[derive(Serialize)]
166struct AlbumPayload {
167 id: String,
168 name: String,
169 uri: String,
170 artists: Vec<String>,
171 release_date: Option<String>,
172 total_tracks: Option<u32>,
173 duration_ms: Option<u64>,
174 tracks: Vec<AlbumTrackPayload>,
175}
176
177pub fn album_info(album: Album) -> Result<()> {
178 let payload = album_info_payload(album);
179 println!("{}", serde_json::to_string(&payload)?);
180 Ok(())
181}
182
183fn album_info_payload(album: Album) -> AlbumPayload {
184 AlbumPayload {
185 id: album.id,
186 name: album.name,
187 uri: album.uri,
188 artists: album.artists,
189 release_date: album.release_date,
190 total_tracks: album.total_tracks,
191 duration_ms: album.duration_ms,
192 tracks: album
193 .tracks
194 .into_iter()
195 .map(|track| AlbumTrackPayload {
196 name: track.name,
197 duration_ms: track.duration_ms,
198 track_number: track.track_number,
199 })
200 .collect(),
201 }
202}
203
204#[derive(Serialize)]
205struct AlbumTrackPayload {
206 name: String,
207 duration_ms: u32,
208 track_number: u32,
209}
210
211#[derive(Serialize)]
212struct ArtistPayload {
213 id: String,
214 name: String,
215 uri: String,
216 genres: Vec<String>,
217 followers: Option<u64>,
218}
219
220pub fn artist_info(artist: Artist) -> Result<()> {
221 let payload = artist_info_payload(artist);
222 println!("{}", serde_json::to_string(&payload)?);
223 Ok(())
224}
225
226fn artist_info_payload(artist: Artist) -> ArtistPayload {
227 ArtistPayload {
228 id: artist.id,
229 name: artist.name,
230 uri: artist.uri,
231 genres: artist.genres,
232 followers: artist.followers,
233 }
234}
235
236#[derive(Serialize)]
237struct PlaylistPayload {
238 id: String,
239 name: String,
240 owner: Option<String>,
241 collaborative: bool,
242 public: Option<bool>,
243}
244
245pub fn playlist_list(playlists: Vec<Playlist>) -> Result<()> {
246 let payload = playlist_list_payload(playlists);
247 println!("{}", serde_json::to_string(&payload)?);
248 Ok(())
249}
250
251fn playlist_list_payload(playlists: Vec<Playlist>) -> Vec<PlaylistPayload> {
252 playlists
253 .into_iter()
254 .map(playlist_payload)
255 .collect()
256}
257
258#[derive(Serialize)]
259struct PlaylistListPayload {
260 playlists: Vec<PlaylistPayload>,
261 pinned: Vec<PinPayload>,
262}
263
264#[derive(Serialize)]
265struct PinPayload {
266 name: String,
267 url: String,
268}
269
270pub fn playlist_list_with_pins(
271 playlists: Vec<Playlist>,
272 pins: Vec<PinnedPlaylist>,
273) -> Result<()> {
274 let payload = playlist_list_with_pins_payload(playlists, pins);
275 println!("{}", serde_json::to_string(&payload)?);
276 Ok(())
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use crate::domain::album::AlbumTrack;
283 use crate::domain::artist::Artist;
284 use crate::domain::auth::{AuthScopes, AuthStatus};
285 use crate::domain::device::Device;
286 use crate::domain::player::PlayerStatus;
287 use crate::domain::playlist::{Playlist, PlaylistDetail};
288 use crate::domain::search::{SearchItem, SearchResults, SearchType};
289
290 #[test]
291 fn auth_status_payload_shape() {
292 let payload = auth_status_payload(AuthStatus {
293 logged_in: true,
294 expires_at: Some(1),
295 });
296 assert!(payload.logged_in);
297 assert_eq!(payload.expires_at, Some(1));
298 }
299
300 #[test]
301 fn auth_scopes_payload_shape() {
302 let payload = auth_scopes_payload(AuthScopes {
303 required: vec!["a".into()],
304 granted: Some(vec!["a".into()]),
305 missing: vec![],
306 });
307 assert_eq!(payload.required.len(), 1);
308 }
309
310 #[test]
311 fn player_status_payload_shape() {
312 let payload = player_status_payload(PlayerStatus {
313 is_playing: true,
314 track: None,
315 device: None,
316 context: None,
317 progress_ms: None,
318 repeat_state: Some("off".into()),
319 shuffle_state: Some(false),
320 });
321 assert!(payload.is_playing);
322 }
323
324 #[test]
325 fn now_playing_payload_shape() {
326 let payload = now_playing_payload(PlayerStatus {
327 is_playing: false,
328 track: None,
329 device: None,
330 context: None,
331 progress_ms: None,
332 repeat_state: Some("context".into()),
333 shuffle_state: Some(true),
334 });
335 assert_eq!(payload.event, "now_playing");
336 }
337
338 #[test]
339 fn action_payload_shape() {
340 let payload = action_payload("event", "message");
341 assert_eq!(payload.event, "event");
342 assert_eq!(payload.message, "message");
343 }
344
345 #[test]
346 fn album_info_payload_shape() {
347 let payload = album_info_payload(Album {
348 id: "1".into(),
349 name: "Album".into(),
350 uri: "uri".into(),
351 artists: vec!["Artist".into()],
352 release_date: None,
353 total_tracks: Some(1),
354 tracks: vec![AlbumTrack {
355 name: "Track".into(),
356 duration_ms: 1000,
357 track_number: 1,
358 }],
359 duration_ms: Some(1000),
360 });
361 assert_eq!(payload.tracks.len(), 1);
362 }
363
364 #[test]
365 fn artist_info_payload_shape() {
366 let payload = artist_info_payload(Artist {
367 id: "1".into(),
368 name: "Artist".into(),
369 uri: "uri".into(),
370 genres: vec![],
371 followers: Some(10),
372 });
373 assert_eq!(payload.followers, Some(10));
374 }
375
376 #[test]
377 fn playlist_list_payload_shape() {
378 let payload = playlist_list_payload(vec![Playlist {
379 id: "1".into(),
380 name: "List".into(),
381 owner: None,
382 collaborative: false,
383 public: Some(true),
384 }]);
385 assert_eq!(payload.len(), 1);
386 }
387
388 #[test]
389 fn playlist_list_with_pins_payload_shape() {
390 let payload = playlist_list_with_pins_payload(
391 vec![Playlist {
392 id: "1".into(),
393 name: "List".into(),
394 owner: None,
395 collaborative: false,
396 public: Some(true),
397 }],
398 vec![PinnedPlaylist {
399 name: "Pin".into(),
400 url: "url".into(),
401 }],
402 );
403 assert_eq!(payload.pinned.len(), 1);
404 }
405
406 #[test]
407 fn playlist_info_payload_shape() {
408 let payload = playlist_info_payload(PlaylistDetail {
409 id: "1".into(),
410 name: "List".into(),
411 uri: "uri".into(),
412 owner: None,
413 tracks_total: Some(2),
414 collaborative: false,
415 public: Some(true),
416 });
417 assert_eq!(payload.tracks_total, Some(2));
418 }
419
420 #[test]
421 fn device_list_payload_shape() {
422 let payload = device_list_payload(vec![Device {
423 id: "1".into(),
424 name: "Device".into(),
425 volume_percent: Some(10),
426 }]);
427 assert_eq!(payload.len(), 1);
428 }
429
430 #[test]
431 fn search_results_payload_shape() {
432 let payload = search_results_payload(SearchResults {
433 kind: SearchType::All,
434 items: vec![SearchItem {
435 id: "1".into(),
436 name: "Track".into(),
437 uri: "uri".into(),
438 kind: SearchType::Track,
439 artists: vec!["Artist".into()],
440 album: Some("Album".into()),
441 duration_ms: Some(1000),
442 owner: None,
443 score: None,
444 }],
445 });
446 assert_eq!(payload.kind, "all");
447 assert_eq!(payload.items[0].kind, "track");
448 assert_eq!(payload.items.len(), 1);
449 }
450
451 #[test]
452 fn help_payload_shape() {
453 let payload = help_payload();
454 assert!(payload.objects.contains(&"auth"));
455 }
456}
457fn playlist_list_with_pins_payload(
458 playlists: Vec<Playlist>,
459 pins: Vec<PinnedPlaylist>,
460) -> PlaylistListPayload {
461 let playlists = playlists
462 .into_iter()
463 .map(playlist_payload)
464 .collect();
465
466 let pinned = pins
467 .into_iter()
468 .map(pin_payload)
469 .collect();
470
471 PlaylistListPayload { playlists, pinned }
472}
473
474#[derive(Serialize)]
475struct HelpPayload {
476 usage: &'static str,
477 objects: Vec<&'static str>,
478 examples: Vec<&'static str>,
479}
480
481pub fn help() -> Result<()> {
482 let payload = help_payload();
483 println!("{}", serde_json::to_string(&payload)?);
484 Ok(())
485}
486
487fn help_payload() -> HelpPayload {
488 HelpPayload {
489 usage: "spotify-cli <object> <verb> [target] [flags]",
490 objects: vec![
491 "auth", "device", "info", "search", "nowplaying", "player", "playlist", "pin", "sync",
492 "queue", "recentlyplayed",
493 ],
494 examples: vec![
495 "spotify-cli auth status",
496 "spotify-cli search track \"boards of canada\" --play",
497 "spotify-cli search \"boards of canada\"",
498 "spotify-cli info album \"geogaddi\"",
499 "spotify-cli nowplaying",
500 "spotify-cli nowplaying like",
501 "spotify-cli playlist list",
502 "spotify-cli nowplaying addto \"MyRadar\"",
503 "spotify-cli pin add \"Release Radar\" \"<url>\"",
504 ],
505 }
506}
507
508#[derive(Serialize)]
509struct PlaylistDetailPayload {
510 id: String,
511 name: String,
512 uri: String,
513 owner: Option<String>,
514 tracks_total: Option<u32>,
515 collaborative: bool,
516 public: Option<bool>,
517}
518
519pub fn playlist_info(playlist: PlaylistDetail) -> Result<()> {
520 let payload = playlist_info_payload(playlist);
521 println!("{}", serde_json::to_string(&payload)?);
522 Ok(())
523}
524
525fn playlist_info_payload(playlist: PlaylistDetail) -> PlaylistDetailPayload {
526 PlaylistDetailPayload {
527 id: playlist.id,
528 name: playlist.name,
529 uri: playlist.uri,
530 owner: playlist.owner,
531 tracks_total: playlist.tracks_total,
532 collaborative: playlist.collaborative,
533 public: playlist.public,
534 }
535}
536
537pub fn device_list(devices: Vec<Device>) -> Result<()> {
538 let payload = device_list_payload(devices);
539 println!("{}", serde_json::to_string(&payload)?);
540 Ok(())
541}
542
543fn device_list_payload(devices: Vec<Device>) -> Vec<DevicePayload> {
544 devices
545 .into_iter()
546 .map(device_payload)
547 .collect()
548}
549
550
551#[derive(Serialize)]
552struct SearchResultsPayload {
553 kind: &'static str,
554 items: Vec<SearchItemPayload>,
555}
556
557#[derive(Serialize)]
558struct SearchItemPayload {
559 id: String,
560 name: String,
561 uri: String,
562 kind: &'static str,
563 artists: Vec<String>,
564 album: Option<String>,
565 duration_ms: Option<u32>,
566 owner: Option<String>,
567 score: Option<f32>,
568 #[serde(skip_serializing_if = "Option::is_none")]
569 now_playing: Option<bool>,
570}
571
572pub fn search_results(results: SearchResults) -> Result<()> {
573 let payload = search_results_payload(results);
574 println!("{}", serde_json::to_string(&payload)?);
575 Ok(())
576}
577
578fn search_results_payload(results: SearchResults) -> SearchResultsPayload {
579 let items = results
580 .items
581 .into_iter()
582 .map(search_item_payload)
583 .collect();
584
585 SearchResultsPayload {
586 kind: search_type_label(results.kind),
587 items,
588 }
589}
590
591pub fn queue(now_playing_id: Option<&str>, items: Vec<SearchItem>) -> Result<()> {
592 let payload = search_results_payload_with_now(
593 SearchResults {
594 kind: SearchType::Track,
595 items,
596 },
597 now_playing_id,
598 );
599 println!("{}", serde_json::to_string(&payload)?);
600 Ok(())
601}
602
603pub fn recently_played(now_playing_id: Option<&str>, items: Vec<SearchItem>) -> Result<()> {
604 let payload = search_results_payload_with_now(
605 SearchResults {
606 kind: SearchType::Track,
607 items,
608 },
609 now_playing_id,
610 );
611 println!("{}", serde_json::to_string(&payload)?);
612 Ok(())
613}
614
615fn search_results_payload_with_now(
616 results: SearchResults,
617 now_playing_id: Option<&str>,
618) -> SearchResultsPayload {
619 let items = results
620 .items
621 .into_iter()
622 .map(|item| search_item_payload_with_now(item, now_playing_id))
623 .collect();
624
625 SearchResultsPayload {
626 kind: search_type_label(results.kind),
627 items,
628 }
629}
630
631fn track_payload(track: crate::domain::track::Track) -> TrackPayload {
632 TrackPayload {
633 id: track.id,
634 name: track.name,
635 artists: track.artists,
636 album: track.album,
637 album_id: track.album_id,
638 duration_ms: track.duration_ms,
639 }
640}
641
642fn device_payload(device: Device) -> DevicePayload {
643 DevicePayload {
644 id: device.id,
645 name: device.name,
646 volume_percent: device.volume_percent,
647 }
648}
649
650fn playlist_payload(playlist: Playlist) -> PlaylistPayload {
651 PlaylistPayload {
652 id: playlist.id,
653 name: playlist.name,
654 owner: playlist.owner,
655 collaborative: playlist.collaborative,
656 public: playlist.public,
657 }
658}
659
660fn pin_payload(pin: PinnedPlaylist) -> PinPayload {
661 PinPayload {
662 name: pin.name,
663 url: pin.url,
664 }
665}
666
667fn search_item_payload(item: crate::domain::search::SearchItem) -> SearchItemPayload {
668 SearchItemPayload {
669 id: item.id,
670 name: item.name,
671 uri: item.uri,
672 kind: search_type_label(item.kind),
673 artists: item.artists,
674 album: item.album,
675 duration_ms: item.duration_ms,
676 owner: item.owner,
677 score: item.score,
678 now_playing: None,
679 }
680}
681
682fn search_item_payload_with_now(
683 item: crate::domain::search::SearchItem,
684 now_playing_id: Option<&str>,
685) -> SearchItemPayload {
686 let is_now_playing = now_playing_id.is_some_and(|id| id == item.id);
687 SearchItemPayload {
688 id: item.id,
689 name: item.name,
690 uri: item.uri,
691 kind: search_type_label(item.kind),
692 artists: item.artists,
693 album: item.album,
694 duration_ms: item.duration_ms,
695 owner: item.owner,
696 score: item.score,
697 now_playing: if is_now_playing { Some(true) } else { None },
698 }
699}
700
701fn search_type_label(kind: SearchType) -> &'static str {
702 match kind {
703 SearchType::All => "all",
704 SearchType::Track => "track",
705 SearchType::Album => "album",
706 SearchType::Artist => "artist",
707 SearchType::Playlist => "playlist",
708 }
709}