spotify_cli/spotify/
playback.rs

1use anyhow::{Context, bail};
2use reqwest::Method;
3use reqwest::blocking::Client as HttpClient;
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/// Spotify playback API client.
16#[derive(Debug, Clone)]
17pub struct PlaybackClient {
18    http: HttpClient,
19    auth: AuthService,
20}
21
22#[derive(Debug)]
23pub struct QueueState {
24    pub now_playing: Option<Track>,
25    pub queue: Vec<Track>,
26}
27
28impl PlaybackClient {
29    pub fn new(http: HttpClient, auth: AuthService) -> Self {
30        Self { http, auth }
31    }
32
33    pub fn play(&self) -> Result<()> {
34        self.send(Method::PUT, "/me/player/play", None)
35    }
36
37    pub fn pause(&self) -> Result<()> {
38        self.send(Method::PUT, "/me/player/pause", None)
39    }
40
41    pub fn next(&self) -> Result<()> {
42        self.send(Method::POST, "/me/player/next", None)
43    }
44
45    pub fn previous(&self) -> Result<()> {
46        self.send(Method::POST, "/me/player/previous", None)
47    }
48
49    pub fn play_context(&self, uri: &str) -> Result<()> {
50        let body = json!({ "context_uri": uri });
51        self.send(Method::PUT, "/me/player/play", Some(body))
52    }
53
54    pub fn play_track(&self, uri: &str) -> Result<()> {
55        let body = json!({ "uris": [uri] });
56        self.send(Method::PUT, "/me/player/play", Some(body))
57    }
58
59    pub fn status(&self) -> Result<PlayerStatus> {
60        let token = self.auth.token()?;
61        let url = format!("{}/me/player", api_base());
62
63        let response = self
64            .http
65            .get(url)
66            .bearer_auth(token.access_token)
67            .send()
68            .context("spotify status request failed")?;
69
70        if response.status() == reqwest::StatusCode::NO_CONTENT {
71            return Ok(PlayerStatus {
72                is_playing: false,
73                track: None,
74                device: None,
75                context: None,
76                progress_ms: None,
77                repeat_state: None,
78                shuffle_state: None,
79            });
80        }
81
82        if !response.status().is_success() {
83            let status = response.status();
84            let body = response.text().unwrap_or_else(|_| "<no body>".to_string());
85            bail!(format_api_error("spotify status failed", status, &body));
86        }
87
88        let payload: SpotifyPlayerStatus = response.json()?;
89        Ok(payload.into())
90    }
91
92    pub fn shuffle(&self, state: bool) -> Result<()> {
93        let path = format!("/me/player/shuffle?state={}", state);
94        self.send(Method::PUT, &path, None)
95    }
96
97    pub fn repeat(&self, state: &str) -> Result<()> {
98        let path = format!("/me/player/repeat?state={}", state);
99        self.send(Method::PUT, &path, None)
100    }
101
102    pub fn queue(&self, limit: u32) -> Result<QueueState> {
103        let token = self.auth.token()?;
104        let url = format!("{}/me/player/queue", api_base());
105
106        let response = self
107            .http
108            .get(url)
109            .bearer_auth(token.access_token)
110            .send()
111            .context("spotify queue request failed")?;
112
113        if !response.status().is_success() {
114            let status = response.status();
115            let body = response.text().unwrap_or_else(|_| "<no body>".to_string());
116            bail!(format_api_error("spotify queue failed", status, &body));
117        }
118
119        let payload: SpotifyQueueResponse = response.json()?;
120        let now_playing = payload.currently_playing.and_then(map_track);
121        let mut queue = Vec::new();
122        for track in payload.queue {
123            if let Some(track) = map_track(track) {
124                queue.push(track);
125            }
126            if queue.len() >= limit as usize {
127                break;
128            }
129        }
130        Ok(QueueState { now_playing, queue })
131    }
132
133    fn send(&self, method: Method, path: &str, body: Option<serde_json::Value>) -> Result<()> {
134        let token = self.auth.token()?;
135        let url = format!("{}{}", api_base(), path);
136
137        let mut request = self
138            .http
139            .request(method, url)
140            .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.artists.into_iter().filter_map(|a| a.id).collect(),
229                    duration_ms: item.duration_ms,
230                }
231            })
232        });
233
234        let device = value.device.map(|device| Device {
235            id: device.id,
236            name: device.name,
237            volume_percent: device.volume_percent,
238        });
239
240        let context = value.context.and_then(|context| {
241            let kind = context.kind?;
242            let uri = context.uri?;
243            Some(PlaybackContext { kind, uri })
244        });
245
246        PlayerStatus {
247            is_playing: value.is_playing,
248            track,
249            device,
250            context,
251            progress_ms: value.progress_ms,
252            repeat_state: value.repeat_state,
253            shuffle_state: value.shuffle_state,
254        }
255    }
256}
257
258fn map_track(item: SpotifyTrack) -> Option<Track> {
259    item.id.map(|id| {
260        let (album, album_id) = match item.album {
261            Some(album) => (Some(album.name), album.id),
262            None => (None, None),
263        };
264
265        Track {
266            id,
267            name: item.name,
268            album,
269            album_id,
270            artists: item.artists.iter().map(|a| a.name.clone()).collect(),
271            artist_ids: item.artists.into_iter().filter_map(|a| a.id).collect(),
272            duration_ms: item.duration_ms,
273        }
274    })
275}