spotify_cli/spotify/
playlists.rs

1use anyhow::bail;
2use reqwest::blocking::Client as HttpClient;
3use serde::Deserialize;
4
5use crate::domain::playlist::{Playlist, PlaylistDetail};
6use crate::error::Result;
7use crate::spotify::auth::AuthService;
8use crate::spotify::base::api_base;
9use crate::spotify::error::format_api_error;
10
11/// Spotify playlists API client.
12#[derive(Debug, Clone)]
13pub struct PlaylistsClient {
14    http: HttpClient,
15    auth: AuthService,
16}
17
18impl PlaylistsClient {
19    pub fn new(http: HttpClient, auth: AuthService) -> Self {
20        Self { http, auth }
21    }
22
23    pub fn list_all(&self) -> Result<Vec<Playlist>> {
24        let token = self.auth.token()?;
25        let mut url = format!("{}/me/playlists?limit=50", api_base());
26        let mut playlists = Vec::new();
27
28        loop {
29            let response = self
30                .http
31                .get(&url)
32                .bearer_auth(token.access_token.clone())
33                .send()?;
34
35            if !response.status().is_success() {
36                let status = response.status();
37                let body = response.text().unwrap_or_else(|_| "<no body>".to_string());
38                bail!(format_api_error(
39                    "spotify playlists request failed",
40                    status,
41                    &body
42                ));
43            }
44
45            let payload: PlaylistsResponse = response.json()?;
46            playlists.extend(payload.items.into_iter().map(|item| Playlist {
47                id: item.id,
48                name: item.name,
49                owner: item.owner.and_then(|owner| owner.display_name),
50                collaborative: item.collaborative,
51                public: item.public,
52            }));
53
54            if let Some(next) = payload.next {
55                url = next;
56            } else {
57                break;
58            }
59        }
60
61        Ok(playlists)
62    }
63
64    pub fn get(&self, playlist_id: &str) -> Result<PlaylistDetail> {
65        let token = self.auth.token()?;
66        let url = format!("{}/playlists/{playlist_id}", api_base());
67
68        let response = self.http.get(url).bearer_auth(token.access_token).send()?;
69
70        if !response.status().is_success() {
71            let status = response.status();
72            let body = response.text().unwrap_or_else(|_| "<no body>".to_string());
73            bail!(format_api_error(
74                "spotify playlist request failed",
75                status,
76                &body
77            ));
78        }
79
80        let payload: PlaylistDetailResponse = response.json()?;
81        Ok(PlaylistDetail {
82            id: payload.id,
83            name: payload.name,
84            uri: payload.uri,
85            owner: payload.owner.and_then(|owner| owner.display_name),
86            tracks_total: payload.tracks.map(|tracks| tracks.total),
87            collaborative: payload.collaborative,
88            public: payload.public,
89        })
90    }
91
92    pub fn create(&self, name: &str, public: Option<bool>) -> Result<PlaylistDetail> {
93        let token = self.auth.token()?;
94        let user_id = self.current_user_id(&token.access_token)?;
95        let url = format!("{}/users/{user_id}/playlists", api_base());
96
97        let mut body = serde_json::json!({ "name": name });
98        if let Some(public) = public {
99            body["public"] = serde_json::json!(public);
100        }
101
102        let response = self
103            .http
104            .post(url)
105            .bearer_auth(token.access_token)
106            .json(&body)
107            .send()?;
108
109        if !response.status().is_success() {
110            let status = response.status();
111            let body = response.text().unwrap_or_else(|_| "<no body>".to_string());
112            bail!(format_api_error(
113                "spotify playlist create failed",
114                status,
115                &body
116            ));
117        }
118
119        let payload: PlaylistDetailResponse = response.json()?;
120        Ok(PlaylistDetail {
121            id: payload.id,
122            name: payload.name,
123            uri: payload.uri,
124            owner: payload.owner.and_then(|owner| owner.display_name),
125            tracks_total: payload.tracks.map(|tracks| tracks.total),
126            collaborative: payload.collaborative,
127            public: payload.public,
128        })
129    }
130
131    pub fn rename(&self, playlist_id: &str, name: &str) -> Result<()> {
132        let token = self.auth.token()?;
133        let url = format!("{}/playlists/{playlist_id}", api_base());
134        let body = serde_json::json!({ "name": name });
135
136        let response = self
137            .http
138            .put(url)
139            .bearer_auth(token.access_token)
140            .json(&body)
141            .send()?;
142
143        if !response.status().is_success() {
144            let status = response.status();
145            let body = response.text().unwrap_or_else(|_| "<no body>".to_string());
146            bail!(format_api_error(
147                "spotify playlist rename failed",
148                status,
149                &body
150            ));
151        }
152        Ok(())
153    }
154
155    pub fn delete(&self, playlist_id: &str) -> Result<()> {
156        self.unfollow(playlist_id)
157    }
158
159    pub fn follow(&self, playlist_id: &str) -> Result<()> {
160        let token = self.auth.token()?;
161        let url = format!("{}/playlists/{playlist_id}/followers", api_base());
162
163        let response = self
164            .http
165            .put(url)
166            .bearer_auth(token.access_token)
167            .body(Vec::new())
168            .send()?;
169
170        if !response.status().is_success() {
171            let status = response.status();
172            let body = response.text().unwrap_or_else(|_| "<no body>".to_string());
173            bail!(format_api_error(
174                "spotify playlist follow failed",
175                status,
176                &body
177            ));
178        }
179        Ok(())
180    }
181
182    pub fn unfollow(&self, playlist_id: &str) -> Result<()> {
183        let token = self.auth.token()?;
184        let url = format!("{}/playlists/{playlist_id}/followers", api_base());
185
186        let response = self
187            .http
188            .delete(url)
189            .bearer_auth(token.access_token)
190            .body(Vec::new())
191            .send()?;
192
193        if !response.status().is_success() {
194            let status = response.status();
195            let body = response.text().unwrap_or_else(|_| "<no body>".to_string());
196            bail!(format_api_error(
197                "spotify playlist unfollow failed",
198                status,
199                &body
200            ));
201        }
202        Ok(())
203    }
204
205    pub fn add_tracks(&self, playlist_id: &str, uris: &[String]) -> Result<()> {
206        let token = self.auth.token()?;
207        let url = format!("{}/playlists/{playlist_id}/tracks", api_base());
208
209        let response = self
210            .http
211            .post(url)
212            .bearer_auth(token.access_token)
213            .json(&serde_json::json!({ "uris": uris }))
214            .send()?;
215
216        if !response.status().is_success() {
217            let status = response.status();
218            let body = response.text().unwrap_or_else(|_| "<no body>".to_string());
219            bail!(format_api_error(
220                "spotify playlist add failed",
221                status,
222                &body
223            ));
224        }
225        Ok(())
226    }
227
228    fn current_user_id(&self, access_token: &str) -> Result<String> {
229        let url = format!("{}/me", api_base());
230        let response = self.http.get(url).bearer_auth(access_token).send()?;
231
232        if !response.status().is_success() {
233            let status = response.status();
234            let body = response.text().unwrap_or_else(|_| "<no body>".to_string());
235            bail!(format_api_error(
236                "spotify profile request failed",
237                status,
238                &body
239            ));
240        }
241
242        let payload: SpotifyUser = response.json()?;
243        Ok(payload.id)
244    }
245}
246
247#[derive(Debug, Deserialize)]
248struct PlaylistsResponse {
249    items: Vec<SpotifyPlaylist>,
250    next: Option<String>,
251}
252
253#[derive(Debug, Deserialize)]
254struct SpotifyPlaylist {
255    id: String,
256    name: String,
257    owner: Option<SpotifyOwner>,
258    #[serde(default)]
259    collaborative: bool,
260    public: Option<bool>,
261}
262
263#[derive(Debug, Deserialize)]
264struct SpotifyOwner {
265    display_name: Option<String>,
266}
267
268#[derive(Debug, Deserialize)]
269struct SpotifyUser {
270    id: String,
271}
272
273#[derive(Debug, Deserialize)]
274struct PlaylistDetailResponse {
275    id: String,
276    name: String,
277    uri: String,
278    owner: Option<SpotifyOwner>,
279    tracks: Option<SpotifyTracks>,
280    #[serde(default)]
281    collaborative: bool,
282    public: Option<bool>,
283}
284
285#[derive(Debug, Deserialize)]
286struct SpotifyTracks {
287    total: u32,
288}