spotify_cli/spotify/
playlists.rs1use 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#[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}