spotify_cli/output/
json.rs

1//! JSON output formatting for machine-readable responses.
2use 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}