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 follow(&self, playlist_id: &str) -> Result<()> {
90        let token = self.auth.token()?;
91        let url = format!("{}/playlists/{playlist_id}/followers", api_base());
92
93        let response = self
94            .http
95            .put(url)
96            .bearer_auth(token.access_token)
97            .body(Vec::new())
98            .send()?;
99
100        if !response.status().is_success() {
101            let status = response.status();
102            let body = response.text().unwrap_or_else(|_| "<no body>".to_string());
103            bail!(format_api_error("spotify playlist follow failed", status, &body));
104        }
105        Ok(())
106    }
107
108    pub fn unfollow(&self, playlist_id: &str) -> Result<()> {
109        let token = self.auth.token()?;
110        let url = format!("{}/playlists/{playlist_id}/followers", api_base());
111
112        let response = self
113            .http
114            .delete(url)
115            .bearer_auth(token.access_token)
116            .body(Vec::new())
117            .send()?;
118
119        if !response.status().is_success() {
120            let status = response.status();
121            let body = response.text().unwrap_or_else(|_| "<no body>".to_string());
122            bail!(format_api_error("spotify playlist unfollow failed", status, &body));
123        }
124        Ok(())
125    }
126
127    pub fn add_tracks(&self, playlist_id: &str, uris: &[String]) -> Result<()> {
128        let token = self.auth.token()?;
129        let url = format!("{}/playlists/{playlist_id}/tracks", api_base());
130
131        let response = self
132            .http
133            .post(url)
134            .bearer_auth(token.access_token)
135            .json(&serde_json::json!({ "uris": uris }))
136            .send()?;
137
138        if !response.status().is_success() {
139            let status = response.status();
140            let body = response.text().unwrap_or_else(|_| "<no body>".to_string());
141            bail!(format_api_error("spotify playlist add failed", status, &body));
142        }
143        Ok(())
144    }
145}
146
147#[derive(Debug, Deserialize)]
148struct PlaylistsResponse {
149    items: Vec<SpotifyPlaylist>,
150    next: Option<String>,
151}
152
153#[derive(Debug, Deserialize)]
154struct SpotifyPlaylist {
155    id: String,
156    name: String,
157    owner: Option<SpotifyOwner>,
158    #[serde(default)]
159    collaborative: bool,
160    public: Option<bool>,
161}
162
163#[derive(Debug, Deserialize)]
164struct SpotifyOwner {
165    display_name: Option<String>,
166}
167
168#[derive(Debug, Deserialize)]
169struct PlaylistDetailResponse {
170    id: String,
171    name: String,
172    uri: String,
173    owner: Option<SpotifyOwner>,
174    tracks: Option<SpotifyTracks>,
175    #[serde(default)]
176    collaborative: bool,
177    public: Option<bool>,
178}
179
180#[derive(Debug, Deserialize)]
181struct SpotifyTracks {
182    total: u32,
183}