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::pin::PinnedPlaylist;
9use crate::domain::player::PlayerStatus;
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.into_iter().map(playlist_payload).collect()
253}
254
255#[derive(Serialize)]
256struct PlaylistListPayload {
257 playlists: Vec<PlaylistPayload>,
258 pinned: Vec<PinPayload>,
259}
260
261#[derive(Serialize)]
262struct PinPayload {
263 name: String,
264 url: String,
265}
266
267pub fn playlist_list_with_pins(playlists: Vec<Playlist>, pins: Vec<PinnedPlaylist>) -> Result<()> {
268 let payload = playlist_list_with_pins_payload(playlists, pins);
269 println!("{}", serde_json::to_string(&payload)?);
270 Ok(())
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276 use crate::domain::album::AlbumTrack;
277 use crate::domain::artist::Artist;
278 use crate::domain::auth::{AuthScopes, AuthStatus};
279 use crate::domain::device::Device;
280 use crate::domain::player::PlayerStatus;
281 use crate::domain::playlist::{Playlist, PlaylistDetail};
282 use crate::domain::search::{SearchItem, SearchResults, SearchType};
283
284 #[test]
285 fn auth_status_payload_shape() {
286 let payload = auth_status_payload(AuthStatus {
287 logged_in: true,
288 expires_at: Some(1),
289 });
290 assert!(payload.logged_in);
291 assert_eq!(payload.expires_at, Some(1));
292 }
293
294 #[test]
295 fn auth_scopes_payload_shape() {
296 let payload = auth_scopes_payload(AuthScopes {
297 required: vec!["a".into()],
298 granted: Some(vec!["a".into()]),
299 missing: vec![],
300 });
301 assert_eq!(payload.required.len(), 1);
302 }
303
304 #[test]
305 fn player_status_payload_shape() {
306 let payload = player_status_payload(PlayerStatus {
307 is_playing: true,
308 track: None,
309 device: None,
310 context: None,
311 progress_ms: None,
312 repeat_state: Some("off".into()),
313 shuffle_state: Some(false),
314 });
315 assert!(payload.is_playing);
316 }
317
318 #[test]
319 fn now_playing_payload_shape() {
320 let payload = now_playing_payload(PlayerStatus {
321 is_playing: false,
322 track: None,
323 device: None,
324 context: None,
325 progress_ms: None,
326 repeat_state: Some("context".into()),
327 shuffle_state: Some(true),
328 });
329 assert_eq!(payload.event, "now_playing");
330 }
331
332 #[test]
333 fn action_payload_shape() {
334 let payload = action_payload("event", "message");
335 assert_eq!(payload.event, "event");
336 assert_eq!(payload.message, "message");
337 }
338
339 #[test]
340 fn album_info_payload_shape() {
341 let payload = album_info_payload(Album {
342 id: "1".into(),
343 name: "Album".into(),
344 uri: "uri".into(),
345 artists: vec!["Artist".into()],
346 release_date: None,
347 total_tracks: Some(1),
348 tracks: vec![AlbumTrack {
349 name: "Track".into(),
350 duration_ms: 1000,
351 track_number: 1,
352 }],
353 duration_ms: Some(1000),
354 });
355 assert_eq!(payload.tracks.len(), 1);
356 }
357
358 #[test]
359 fn artist_info_payload_shape() {
360 let payload = artist_info_payload(Artist {
361 id: "1".into(),
362 name: "Artist".into(),
363 uri: "uri".into(),
364 genres: vec![],
365 followers: Some(10),
366 });
367 assert_eq!(payload.followers, Some(10));
368 }
369
370 #[test]
371 fn playlist_list_payload_shape() {
372 let payload = playlist_list_payload(vec![Playlist {
373 id: "1".into(),
374 name: "List".into(),
375 owner: None,
376 collaborative: false,
377 public: Some(true),
378 }]);
379 assert_eq!(payload.len(), 1);
380 }
381
382 #[test]
383 fn playlist_list_with_pins_payload_shape() {
384 let payload = playlist_list_with_pins_payload(
385 vec![Playlist {
386 id: "1".into(),
387 name: "List".into(),
388 owner: None,
389 collaborative: false,
390 public: Some(true),
391 }],
392 vec![PinnedPlaylist {
393 name: "Pin".into(),
394 url: "url".into(),
395 }],
396 );
397 assert_eq!(payload.pinned.len(), 1);
398 }
399
400 #[test]
401 fn playlist_info_payload_shape() {
402 let payload = playlist_info_payload(PlaylistDetail {
403 id: "1".into(),
404 name: "List".into(),
405 uri: "uri".into(),
406 owner: None,
407 tracks_total: Some(2),
408 collaborative: false,
409 public: Some(true),
410 });
411 assert_eq!(payload.tracks_total, Some(2));
412 }
413
414 #[test]
415 fn device_list_payload_shape() {
416 let payload = device_list_payload(vec![Device {
417 id: "1".into(),
418 name: "Device".into(),
419 volume_percent: Some(10),
420 }]);
421 assert_eq!(payload.len(), 1);
422 }
423
424 #[test]
425 fn search_results_payload_shape() {
426 let payload = search_results_payload(SearchResults {
427 kind: SearchType::All,
428 items: vec![SearchItem {
429 id: "1".into(),
430 name: "Track".into(),
431 uri: "uri".into(),
432 kind: SearchType::Track,
433 artists: vec!["Artist".into()],
434 album: Some("Album".into()),
435 duration_ms: Some(1000),
436 owner: None,
437 score: None,
438 }],
439 });
440 assert_eq!(payload.kind, "all");
441 assert_eq!(payload.items[0].kind, "track");
442 assert_eq!(payload.items.len(), 1);
443 }
444
445 #[test]
446 fn help_payload_shape() {
447 let payload = help_payload();
448 assert!(payload.objects.contains(&"auth"));
449 }
450}
451fn playlist_list_with_pins_payload(
452 playlists: Vec<Playlist>,
453 pins: Vec<PinnedPlaylist>,
454) -> PlaylistListPayload {
455 let playlists = playlists.into_iter().map(playlist_payload).collect();
456
457 let pinned = pins.into_iter().map(pin_payload).collect();
458
459 PlaylistListPayload { playlists, pinned }
460}
461
462#[derive(Serialize)]
463struct HelpPayload {
464 usage: &'static str,
465 objects: Vec<&'static str>,
466 examples: Vec<&'static str>,
467}
468
469pub fn help() -> Result<()> {
470 let payload = help_payload();
471 println!("{}", serde_json::to_string(&payload)?);
472 Ok(())
473}
474
475fn help_payload() -> HelpPayload {
476 HelpPayload {
477 usage: "spotify-cli <object> <verb> [target] [flags]",
478 objects: vec![
479 "auth",
480 "device",
481 "info",
482 "search",
483 "nowplaying",
484 "player",
485 "playlist",
486 "pin",
487 "sync",
488 "queue",
489 "recentlyplayed",
490 ],
491 examples: vec![
492 "spotify-cli auth status",
493 "spotify-cli search track \"boards of canada\" --play",
494 "spotify-cli search \"boards of canada\"",
495 "spotify-cli info album \"geogaddi\"",
496 "spotify-cli nowplaying",
497 "spotify-cli nowplaying like",
498 "spotify-cli playlist list",
499 "spotify-cli nowplaying addto \"MyRadar\"",
500 "spotify-cli pin add \"Release Radar\" \"<url>\"",
501 ],
502 }
503}
504
505#[derive(Serialize)]
506struct PlaylistDetailPayload {
507 id: String,
508 name: String,
509 uri: String,
510 owner: Option<String>,
511 tracks_total: Option<u32>,
512 collaborative: bool,
513 public: Option<bool>,
514}
515
516pub fn playlist_info(playlist: PlaylistDetail) -> Result<()> {
517 let payload = playlist_info_payload(playlist);
518 println!("{}", serde_json::to_string(&payload)?);
519 Ok(())
520}
521
522fn playlist_info_payload(playlist: PlaylistDetail) -> PlaylistDetailPayload {
523 PlaylistDetailPayload {
524 id: playlist.id,
525 name: playlist.name,
526 uri: playlist.uri,
527 owner: playlist.owner,
528 tracks_total: playlist.tracks_total,
529 collaborative: playlist.collaborative,
530 public: playlist.public,
531 }
532}
533
534pub fn device_list(devices: Vec<Device>) -> Result<()> {
535 let payload = device_list_payload(devices);
536 println!("{}", serde_json::to_string(&payload)?);
537 Ok(())
538}
539
540fn device_list_payload(devices: Vec<Device>) -> Vec<DevicePayload> {
541 devices.into_iter().map(device_payload).collect()
542}
543
544#[derive(Serialize)]
545struct SearchResultsPayload {
546 kind: &'static str,
547 items: Vec<SearchItemPayload>,
548}
549
550#[derive(Serialize)]
551struct SearchItemPayload {
552 id: String,
553 name: String,
554 uri: String,
555 kind: &'static str,
556 artists: Vec<String>,
557 album: Option<String>,
558 duration_ms: Option<u32>,
559 owner: Option<String>,
560 score: Option<f32>,
561 #[serde(skip_serializing_if = "Option::is_none")]
562 now_playing: Option<bool>,
563}
564
565pub fn search_results(results: SearchResults) -> Result<()> {
566 let payload = search_results_payload(results);
567 println!("{}", serde_json::to_string(&payload)?);
568 Ok(())
569}
570
571fn search_results_payload(results: SearchResults) -> SearchResultsPayload {
572 let items = results.items.into_iter().map(search_item_payload).collect();
573
574 SearchResultsPayload {
575 kind: search_type_label(results.kind),
576 items,
577 }
578}
579
580pub fn queue(now_playing_id: Option<&str>, items: Vec<SearchItem>) -> Result<()> {
581 let payload = search_results_payload_with_now(
582 SearchResults {
583 kind: SearchType::Track,
584 items,
585 },
586 now_playing_id,
587 );
588 println!("{}", serde_json::to_string(&payload)?);
589 Ok(())
590}
591
592pub fn recently_played(now_playing_id: Option<&str>, items: Vec<SearchItem>) -> Result<()> {
593 let payload = search_results_payload_with_now(
594 SearchResults {
595 kind: SearchType::Track,
596 items,
597 },
598 now_playing_id,
599 );
600 println!("{}", serde_json::to_string(&payload)?);
601 Ok(())
602}
603
604fn search_results_payload_with_now(
605 results: SearchResults,
606 now_playing_id: Option<&str>,
607) -> SearchResultsPayload {
608 let items = results
609 .items
610 .into_iter()
611 .map(|item| search_item_payload_with_now(item, now_playing_id))
612 .collect();
613
614 SearchResultsPayload {
615 kind: search_type_label(results.kind),
616 items,
617 }
618}
619
620fn track_payload(track: crate::domain::track::Track) -> TrackPayload {
621 TrackPayload {
622 id: track.id,
623 name: track.name,
624 artists: track.artists,
625 album: track.album,
626 album_id: track.album_id,
627 duration_ms: track.duration_ms,
628 }
629}
630
631fn device_payload(device: Device) -> DevicePayload {
632 DevicePayload {
633 id: device.id,
634 name: device.name,
635 volume_percent: device.volume_percent,
636 }
637}
638
639fn playlist_payload(playlist: Playlist) -> PlaylistPayload {
640 PlaylistPayload {
641 id: playlist.id,
642 name: playlist.name,
643 owner: playlist.owner,
644 collaborative: playlist.collaborative,
645 public: playlist.public,
646 }
647}
648
649fn pin_payload(pin: PinnedPlaylist) -> PinPayload {
650 PinPayload {
651 name: pin.name,
652 url: pin.url,
653 }
654}
655
656fn search_item_payload(item: crate::domain::search::SearchItem) -> SearchItemPayload {
657 SearchItemPayload {
658 id: item.id,
659 name: item.name,
660 uri: item.uri,
661 kind: search_type_label(item.kind),
662 artists: item.artists,
663 album: item.album,
664 duration_ms: item.duration_ms,
665 owner: item.owner,
666 score: item.score,
667 now_playing: None,
668 }
669}
670
671fn search_item_payload_with_now(
672 item: crate::domain::search::SearchItem,
673 now_playing_id: Option<&str>,
674) -> SearchItemPayload {
675 let is_now_playing = now_playing_id.is_some_and(|id| id == item.id);
676 SearchItemPayload {
677 id: item.id,
678 name: item.name,
679 uri: item.uri,
680 kind: search_type_label(item.kind),
681 artists: item.artists,
682 album: item.album,
683 duration_ms: item.duration_ms,
684 owner: item.owner,
685 score: item.score,
686 now_playing: if is_now_playing { Some(true) } else { None },
687 }
688}
689
690fn search_type_label(kind: SearchType) -> &'static str {
691 match kind {
692 SearchType::All => "all",
693 SearchType::Track => "track",
694 SearchType::Album => "album",
695 SearchType::Artist => "artist",
696 SearchType::Playlist => "playlist",
697 }
698}