wolframe_spotify_canvas/
lib.rs1mod error;
2mod token;
3
4pub use error::{CanvasError, Result};
5use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
6use serde::{Deserialize, Serialize};
7use serde_json::json;
8use token::TokenManager;
9
10pub const DEFAULT_PATHFINDER_URL: &str = "https://api-partner.spotify.com/pathfinder/v2/query";
12pub const DEFAULT_CANVAS_HASH: &str =
14 "575138ab27cd5c1b3e54da54d0a7cc8d85485402de26340c2145f0f6bb5e7a9f";
15pub const DEFAULT_OPERATION_NAME: &str = "canvas";
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct Canvas {
19 pub mp4_url: String,
21 pub uri: Option<String>,
23 pub track_uri: String,
25}
26
27#[derive(Debug, Clone)]
28pub struct CanvasConfig {
29 pub pathfinder_url: String,
30 pub operation_name: String,
31 pub query_hash: String,
32}
33
34impl Default for CanvasConfig {
35 fn default() -> Self {
36 Self {
37 pathfinder_url: DEFAULT_PATHFINDER_URL.to_string(),
38 operation_name: DEFAULT_OPERATION_NAME.to_string(),
39 query_hash: DEFAULT_CANVAS_HASH.to_string(),
40 }
41 }
42}
43
44#[derive(Debug, Clone)]
48pub struct CanvasClient {
49 http: reqwest::Client,
50 token_manager: TokenManager,
51 config: CanvasConfig,
52}
53
54impl Default for CanvasClient {
55 fn default() -> Self {
56 Self::new()
57 }
58}
59
60impl CanvasClient {
61 pub fn new() -> Self {
63 Self::with_config(CanvasConfig::default())
64 }
65
66 pub fn with_config(config: CanvasConfig) -> Self {
68 Self {
69 http: reqwest::Client::new(),
70 token_manager: TokenManager::new(),
71 config,
72 }
73 }
74
75 pub fn with_client(client: reqwest::Client, config: CanvasConfig) -> Self {
79 Self {
80 http: client,
81 token_manager: TokenManager::new(),
82 config,
83 }
84 }
85
86 #[tracing::instrument(skip(self, access_token), fields(track_uri = %track_uri))]
118 pub async fn get_canvas(&mut self, track_uri: &str, access_token: &str) -> Result<Canvas> {
119 tracing::debug!("Starting canvas fetch");
120
121 let client_token = self
123 .token_manager
124 .get_token(&self.http)
125 .await
126 .map_err(|e| {
127 tracing::error!(error = %e, "Failed to get client-token");
128 e
129 })?;
130
131 let mut headers = HeaderMap::new();
133 headers.insert(
134 AUTHORIZATION,
135 HeaderValue::from_str(&format!("Bearer {}", access_token))
136 .map_err(|e| CanvasError::InvalidInput(format!("Invalid access token: {}", e)))?,
137 );
138 headers.insert(
139 "client-token",
140 HeaderValue::from_str(&client_token)
141 .map_err(|e| CanvasError::InvalidInput(format!("Invalid client token: {}", e)))?,
142 );
143 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
144
145 let request_body = json!({
147 "operationName": self.config.operation_name,
148 "variables": {
149 "trackUri": track_uri,
150 },
151 "extensions": {
152 "persistedQuery": {
153 "version": 1,
154 "sha256Hash": self.config.query_hash
155 }
156 }
157 });
158
159 let response = self
161 .http
162 .post(&self.config.pathfinder_url)
163 .headers(headers)
164 .json(&request_body)
165 .send()
166 .await?;
167
168 let status = response.status();
169
170 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
173 let retry_after = response
174 .headers()
175 .get("Retry-After")
176 .and_then(|v| v.to_str().ok())
177 .and_then(|s| s.parse::<u64>().ok())
178 .map(|s| s * 1000); tracing::warn!(retry_after = ?retry_after, "Rate limited by Spotify API");
181 return Err(CanvasError::RateLimited { retry_after });
182 }
183
184 if !status.is_success() {
185 let text = response.text().await.unwrap_or_default();
186 tracing::error!(status = %status, response = %text, "GraphQL request failed");
187 return Err(CanvasError::SpotifyApi {
188 status: status.as_u16(),
189 message: text,
190 });
191 }
192
193 let body_text = response.text().await?;
194
195 let graph_response: GraphResponse = serde_json::from_str(&body_text)?;
197
198 let canvas_data = graph_response
200 .data
201 .and_then(|d| d.track_union)
202 .and_then(|t| t.canvas);
203
204 match canvas_data {
205 Some(cd) => {
206 if let Some(url) = cd.url {
207 tracing::info!(track_uri = %track_uri, canvas_url = %url, "Canvas fetched successfully");
208 Ok(Canvas {
209 mp4_url: url,
210 uri: cd.uri,
211 track_uri: track_uri.to_string(),
212 })
213 } else if let Some(uri) = cd.uri {
214 tracing::warn!(track_uri = %track_uri, uri = %uri, "Canvas found but no URL");
216 Err(CanvasError::NotFound(track_uri.to_string()))
217 } else {
218 tracing::warn!(track_uri = %track_uri, "Canvas object empty");
219 Err(CanvasError::NotFound(track_uri.to_string()))
220 }
221 }
222 None => {
223 tracing::debug!(track_uri = %track_uri, "No canvas entry in response");
224 Err(CanvasError::NotFound(track_uri.to_string()))
225 }
226 }
227 }
228}
229
230#[derive(Deserialize)]
233struct GraphResponse {
234 data: Option<GraphData>,
235 #[allow(dead_code)]
236 errors: Option<Vec<GraphError>>,
237}
238
239#[derive(Deserialize)]
240struct GraphData {
241 #[serde(rename = "trackUnion")]
242 track_union: Option<TrackUnion>,
243}
244
245#[derive(Deserialize)]
246struct TrackUnion {
247 canvas: Option<CanvasData>,
248}
249
250#[derive(Deserialize)]
251struct CanvasData {
252 url: Option<String>,
253 uri: Option<String>,
254}
255
256#[derive(Deserialize)]
257struct GraphError {
258 #[allow(dead_code)]
259 message: String,
260}