wolframe_spotify_canvas/
lib.rs

1mod 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
10/// Default GraphQL endpoint for Spotify Pathfinder API.
11pub const DEFAULT_PATHFINDER_URL: &str = "https://api-partner.spotify.com/pathfinder/v2/query";
12/// Known working hash for the Canvas operation (as of Jan 2026).
13pub 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    /// Direct URL to the MP4 video file.
20    pub mp4_url: String,
21    /// The Spotify URI of the canvas.
22    pub uri: Option<String>,
23    /// The Track URI mismatch warning (if any).
24    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/// The main client for interacting with Spotify's private Canvas API.
45///
46/// Handles authentication (client-token exchange) and GraphQL queries internally.
47#[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    /// Create a new `CanvasClient` with default configuration and a new `reqwest::Client`.
62    pub fn new() -> Self {
63        Self::with_config(CanvasConfig::default())
64    }
65
66    /// Create a new `CanvasClient` with a custom configuration.
67    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    /// Creates a new `CanvasClient` using an existing `reqwest::Client`.
76    ///
77    /// Useful if you want to share a connection pool or proxy configuration.
78    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    /// Fetch the Canvas video URL for a given Spotify Track URI.
87    ///
88    /// # Arguments
89    ///
90    /// * `track_uri` - The Spotify Track URI (e.g., "spotify:track:...")
91    /// * `access_token` - A valid Spotify Access Token (Bearer).
92    /// Fetches the Spotify Canvas (looping video) for a given track URI.
93    ///
94    /// # Arguments
95    ///
96    /// * `track_uri` - The Spotify URI of the track (e.g., `"spotify:track:..."`).
97    /// * `access_token` - A valid Spotify Web Player access token (starts with `Bearer ...`).
98    ///
99    /// # Errors
100    ///
101    /// Returns a `CanvasError` if:
102    /// * The `access_token` is invalid or expired (`CanvasError::TokenExpired`).
103    /// * The track has no canvas (`CanvasError::NotFound`).
104    /// * Rate limited by Spotify (`CanvasError::RateLimited`).
105    /// * Network or parsing errors occur.
106    ///
107    /// # Example
108    ///
109    /// ```rust,no_run
110    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
111    /// # let mut client = wolframe_spotify_canvas::CanvasClient::new();
112    /// let canvas = client.get_canvas("spotify:track:...", "Bearer ...").await?;
113    /// println!("Canvas URL: {}", canvas.mp4_url);
114    /// # Ok(())
115    /// # }
116    /// ```
117    #[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        // 1. Ensure we have a valid client-token
122        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        // 2. Prepare headers
132        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        // 3. Prepare GraphQL body
146        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        // 4. Send Request
160        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        // Handle Rate Limiting (429)
171        // Handle Rate Limiting (429)
172        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); // convert to ms
179
180            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        // 5. Parse Response
196        let graph_response: GraphResponse = serde_json::from_str(&body_text)?;
197
198        // Deep matching to extract URL
199        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                    // Sometimes only URI is returned (rare)
215                    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// Internal GraphQL Response Structures
231
232#[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}