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