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::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}