spotify_cli/spotify/
playback.rs

1use anyhow::{bail, Context};
2use reqwest::blocking::Client as HttpClient;
3use reqwest::Method;
4use serde::Deserialize;
5use serde_json::json;
6
7use crate::domain::device::Device;
8use crate::domain::player::{PlaybackContext, PlayerStatus};
9use crate::domain::track::Track;
10use crate::error::Result;
11use crate::spotify::auth::AuthService;
12use crate::spotify::base::api_base;
13use crate::spotify::error::format_api_error;
14
15
16/// Spotify playback API client.
17#[derive(Debug, Clone)]
18pub struct PlaybackClient {
19    http: HttpClient,
20    auth: AuthService,
21}
22
23#[derive(Debug)]
24pub struct QueueState {
25    pub now_playing: Option<Track>,
26    pub queue: Vec<Track>,
27}
28
29impl PlaybackClient {
30    pub fn new(http: HttpClient, auth: AuthService) -> Self {
31        Self { http, auth }
32    }
33
34    pub fn play(&self) -> Result<()> {
35        self.send(Method::PUT, "/me/player/play", None)
36    }
37
38    pub fn pause(&self) -> Result<()> {
39        self.send(Method::PUT, "/me/player/pause", None)
40    }
41
42    pub fn next(&self) -> Result<()> {
43        self.send(Method::POST, "/me/player/next", None)
44    }
45
46    pub fn previous(&self) -> Result<()> {
47        self.send(Method::POST, "/me/player/previous", None)
48    }
49
50    pub fn play_context(&self, uri: &str) -> Result<()> {
51        let body = json!({ "context_uri": uri });
52        self.send(Method::PUT, "/me/player/play", Some(body))
53    }
54
55    pub fn play_track(&self, uri: &str) -> Result<()> {
56        let body = json!({ "uris": [uri] });
57        self.send(Method::PUT, "/me/player/play", Some(body))
58    }
59
60    pub fn status(&self) -> Result<PlayerStatus> {
61        let token = self.auth.token()?;
62        let url = format!("{}/me/player", api_base());
63
64        let response = self
65            .http
66            .get(url)
67            .bearer_auth(token.access_token)
68            .send()
69            .context("spotify status request failed")?;
70
71        if response.status() == reqwest::StatusCode::NO_CONTENT {
72            return Ok(PlayerStatus {
73                is_playing: false,
74                track: None,
75                device: None,
76                context: None,
77                progress_ms: None,
78                repeat_state: None,
79                shuffle_state: None,
80            });
81        }
82
83        if !response.status().is_success() {
84            let status = response.status();
85            let body = response.text().unwrap_or_else(|_| "<no body>".to_string());
86            bail!(format_api_error("spotify status failed", status, &body));
87        }
88
89        let payload: SpotifyPlayerStatus = response.json()?;
90        Ok(payload.into())
91    }
92
93    pub fn shuffle(&self, state: bool) -> Result<()> {
94        let path = format!("/me/player/shuffle?state={}", state);
95        self.send(Method::PUT, &path, None)
96    }
97
98    pub fn repeat(&self, state: &str) -> Result<()> {
99        let path = format!("/me/player/repeat?state={}", state);
100        self.send(Method::PUT, &path, None)
101    }
102
103    pub fn queue(&self, limit: u32) -> Result<QueueState> {
104        let token = self.auth.token()?;
105        let url = format!("{}/me/player/queue", api_base());
106
107        let response = self
108            .http
109            .get(url)
110            .bearer_auth(token.access_token)
111            .send()
112            .context("spotify queue request failed")?;
113
114        if !response.status().is_success() {
115            let status = response.status();
116            let body = response.text().unwrap_or_else(|_| "<no body>".to_string());
117            bail!(format_api_error("spotify queue failed", status, &body));
118        }
119
120        let payload: SpotifyQueueResponse = response.json()?;
121        let now_playing = payload
122            .currently_playing
123            .and_then(map_track);
124        let mut queue = Vec::new();
125        for track in payload.queue {
126            if let Some(track) = map_track(track) {
127                queue.push(track);
128            }
129            if queue.len() >= limit as usize {
130                break;
131            }
132        }
133        Ok(QueueState { now_playing, queue })
134    }
135
136    fn send(&self, method: Method, path: &str, body: Option<serde_json::Value>) -> Result<()> {
137        let token = self.auth.token()?;
138        let url = format!("{}{}", api_base(), path);
139
140        let mut request = self.http.request(method, url).bearer_auth(token.access_token);
141        if let Some(body) = body {
142            request = request.json(&body);
143        } else {
144            request = request.body(Vec::new());
145        }
146
147        let response = request.send().context("spotify request failed")?;
148
149        if response.status().is_success() {
150            return Ok(());
151        }
152
153        let status = response.status();
154        let body = response.text().unwrap_or_else(|_| "<no body>".to_string());
155        bail!(format_api_error("spotify request failed", status, &body))
156    }
157}
158
159#[derive(Debug, Deserialize)]
160struct SpotifyPlayerStatus {
161    #[serde(default)]
162    is_playing: bool,
163    progress_ms: Option<u32>,
164    item: Option<SpotifyTrack>,
165    device: Option<SpotifyDevice>,
166    context: Option<SpotifyContext>,
167    repeat_state: Option<String>,
168    shuffle_state: Option<bool>,
169}
170
171#[derive(Debug, Deserialize)]
172struct SpotifyTrack {
173    id: Option<String>,
174    name: String,
175    duration_ms: Option<u32>,
176    album: Option<SpotifyAlbum>,
177    artists: Vec<SpotifyArtist>,
178}
179
180#[derive(Debug, Deserialize)]
181struct SpotifyArtist {
182    id: Option<String>,
183    name: String,
184}
185
186#[derive(Debug, Deserialize)]
187struct SpotifyDevice {
188    id: String,
189    name: String,
190    volume_percent: Option<u32>,
191}
192
193#[derive(Debug, Deserialize)]
194struct SpotifyAlbum {
195    id: Option<String>,
196    name: String,
197}
198
199#[derive(Debug, Deserialize)]
200struct SpotifyContext {
201    #[serde(rename = "type")]
202    kind: Option<String>,
203    uri: Option<String>,
204}
205
206#[derive(Debug, Deserialize)]
207struct SpotifyQueueResponse {
208    currently_playing: Option<SpotifyTrack>,
209    #[serde(default)]
210    queue: Vec<SpotifyTrack>,
211}
212
213impl From<SpotifyPlayerStatus> for PlayerStatus {
214    fn from(value: SpotifyPlayerStatus) -> Self {
215        let track = value.item.and_then(|item| {
216            item.id.map(|id| {
217                let (album, album_id) = match item.album {
218                    Some(album) => (Some(album.name), album.id),
219                    None => (None, None),
220                };
221
222                Track {
223                    id,
224                    name: item.name,
225                    album,
226                    album_id,
227                    artists: item.artists.iter().map(|a| a.name.clone()).collect(),
228                    artist_ids: item
229                        .artists
230                        .into_iter()
231                        .filter_map(|a| a.id)
232                        .collect(),
233                    duration_ms: item.duration_ms,
234                }
235            })
236        });
237
238        let device = value.device.map(|device| Device {
239            id: device.id,
240            name: device.name,
241            volume_percent: device.volume_percent,
242        });
243
244        let context = value.context.and_then(|context| {
245            let kind = context.kind?;
246            let uri = context.uri?;
247            Some(PlaybackContext { kind, uri })
248        });
249
250        PlayerStatus {
251            is_playing: value.is_playing,
252            track,
253            device,
254            context,
255            progress_ms: value.progress_ms,
256            repeat_state: value.repeat_state,
257            shuffle_state: value.shuffle_state,
258        }
259    }
260}
261
262fn map_track(item: SpotifyTrack) -> Option<Track> {
263    item.id.map(|id| {
264        let (album, album_id) = match item.album {
265            Some(album) => (Some(album.name), album.id),
266            None => (None, None),
267        };
268
269        Track {
270            id,
271            name: item.name,
272            album,
273            album_id,
274            artists: item.artists.iter().map(|a| a.name.clone()).collect(),
275            artist_ids: item.artists.into_iter().filter_map(|a| a.id).collect(),
276            duration_ms: item.duration_ms,
277        }
278    })
279}