Spotify
Spotify's Web API without an SDK: client-credentials tokens kept until they run out, PKCE sign-in with its verifier, challenge, address and code exchange, search, tracks asked for in fifties, playlists read through `next`, made and added to in hundreds, 429s waited out by their Retry-After, and ids taken from any link or URI.
Version 1.0.0 · 13 functions · 4 crates
Functions
What use spotify.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use spotify_challenge, spotify_pkce from spotify (Quillo 0.6).
spotify_challenge(verifier: str) → strThe S256 challenge of a verifier: the unpadded base64url of its SHA-256.
spotify_pkce() → anyA new PKCE pair: { verifier, challenge, state }. Keep verifier and state in the visitor's session until they come back.
spotify_authorize_url(redirect_uri: str, scopes: list, state: str, challenge: str) → stror an errorThe address to send a visitor to, for PKCE sign-in: scopes a list like ["playlist-modify-private"].
spotify_exchange_code(code: str, redirect_uri: str, verifier: str) → anyor an errorTrades the code from the redirect for tokens: { access_token, refresh_token, expires_in, scope }. Needs the same redirect_uri, and the verifier that made the challenge.
spotify_refresh(refresh_token: str) → anyor an errorA new access token from a user's refresh token. Under PKCE the answer may hold a new refresh_token: keep that one if it does.
spotify_id(link: str) → stror an errorThe id in a link or URI: "https://open.spotify.com/track/ID?si=x", "spotify:track:ID", or an id already.
spotify_search(q: str, types: list, params: any, max_items: int) → anyor an errorSearches: types a list from "track", "album", "artist", "playlist"; params adds market; max_items is the most of each type. Spotify stops at 1000 results, and q may use its filters (artist:"Nina Simone" year:1965). Gives { track: { total, items }, ... }.
spotify_track(id: str) → anyor an errorOne track, by id, link or URI.
spotify_tracks(ids: list) → listor an errorTracks by id, link or URI, as many as you like, asked for in fifties. An id Spotify does not know is left out.
spotify_playlist(id: str, user_token: str) → anyor an errorA playlist's details, with user_token for a private one ("" for none). tracks is left to spotify_playlist_tracks.
spotify_playlist_tracks(id: str, user_token: str, max_items: int) → listor an errorA playlist's items through every next: each { added_at, track }, with removed tracks' null left out; max_items caps them (0: all).
spotify_playlist_create(user_id: str, name: str, params: any, user_token: str) → anyor an errorMakes a playlist for the signed-in user: params adds description, public. Needs a user_token with playlist-modify scopes.
spotify_playlist_add(id: str, tracks: list, user_token: str) → stror an errorAdds tracks to a playlist, by link, URI or id, in hundreds as Spotify wants, in order. Gives the last snapshot_id.
Get it
$ quillo install spotify # spotify.qwer into this folder$ quillo install spotify --example # and its example, spotify.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/spotify.qwer$ curl -O https://quillolang.org/plugins/files/spotify.qweThen, in your program, all of it or only what you call:
use spotify.qweruse spotify_challenge, spotify_pkce from spotify // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
ureq = "2"sha2 = "0.10"base64 = "0.22"serde_json = { version = "1", features = ["preserve_order"] }Example
// Music from Spotify: a search, tracks, a long playlist, and a visitor signed in by PKCE to make a// playlist of their own.//// SPOTIFY_CLIENT_ID=... SPOTIFY_CLIENT_SECRET=... quillo run spotify.qwe// then open /search, /tracks, /playlist, /ids, /pkce, /sign-in and /failures//// SPOTIFY_API_URL and SPOTIFY_ACCOUNTS_URL reach a test server. Add http://localhost:8080/callback to// the app's redirect addresses for a real sign-in, and keep `verifier` in the visitor's session.use "spotify.qwer"route GET /search { let r = spotify_search("artist:\"Nina Simone\" year:1965", ["track", "artist"], { market: "NL" }, 120)? { tracks: r.track.total, got: len(r.track.items), first: r.track.items[0].name, artists: len(r.artist.items), empty: spotify_search(" ", ["track"], {}, 0).error }}route GET /tracks { let ids = map(range(0, 120), (i) => `4uLU6hMCjMI75M1A2tK${100 + i}`) let many = spotify_tracks(ids)? { got: len(many), name: spotify_track("https://open.spotify.com/track/4uLU6hMCjMI75M1A2tKU00?si=abc")?.name, by_uri: spotify_track("spotify:track:4uLU6hMCjMI75M1A2tKU00")?.id, missing: spotify_track("0000000000000000000000").error, }}route GET /playlist { let list = "37i9dQZF1DXcBWIGoYBM5M" let info = spotify_playlist(list, "")? let items = spotify_playlist_tracks(list, "", 0)? { name: info.name, total: info.tracks.total, got: len(items), first: items[0].track.name, capped: len(spotify_playlist_tracks(list, "", 150)?) }}route GET /ids = { link: spotify_id("https://open.spotify.com/track/4uLU6hMCjMI75M1A2tKU00?si=1")?, uri: spotify_id("spotify:album:4uLU6hMCjMI75M1A2tKU00")?, intl: spotify_id("https://open.spotify.com/intl-de/track/4uLU6hMCjMI75M1A2tKU00/")?, plain: spotify_id("4uLU6hMCjMI75M1A2tKU00")?, bad: spotify_id("https://example.com/x").error,}route GET /pkce { let pair = spotify_pkce() let url = spotify_authorize_url("http://localhost:8080/callback", ["playlist-modify-private", "user-read-email"], pair.state, pair.challenge)? { // The test vector of RFC 7636, appendix B. rfc: spotify_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"), sound: spotify_challenge(pair.verifier) == pair.challenge, long_enough: len(pair.verifier) >= 43 && len(pair.verifier) <= 128, url_has: [contains(url, "code_challenge_method=S256"), contains(url, "scope=playlist-modify-private%20user-read-email"), contains(url, "redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback")], two_differ: spotify_pkce().verifier != pair.verifier, }}// Where a sign-in starts: keep `pair` in the visitor's session, and send them to `url`.route GET /sign-in { let pair = spotify_pkce() { pair: pair, url: spotify_authorize_url(`http://${request.headers.host}/callback`, ["playlist-modify-private"], pair.state, pair.challenge)? }}// What the redirect back leads to: the code for tokens, then a playlist made with the visitor's own.route POST /signed-in { let f = request.json let t = spotify_exchange_code(f.code, f.redirect_uri, f.verifier)? let list = spotify_playlist_create("ada", "Quillo mix", { public: false, description: "Made here" }, t.access_token)? let tracks = map(range(0, 230), (i) => "spotify:track:4uLU6hMCjMI75M1A2tKU00") let snap = spotify_playlist_add(list.id, tracks, t.access_token)? { scope: t.scope, name: list.name, added: spotify_playlist(list.id, t.access_token)?.tracks.total, snapshot: len(snap) > 0, fresh: spotify_refresh(t.refresh_token)?.access_token != t.access_token, wrong: spotify_exchange_code(f.code, f.redirect_uri, repeat("x", 43)).error, }}route GET /failures = { app_token: spotify_playlist_create("ada", "x", {}, "").error, bad_token: spotify_playlist_tracks("37i9dQZF1DXcBWIGoYBM5M", "not-a-token", 0).error, short: spotify_exchange_code("c", "http://x", "short").error, waited: spotify_track("busybusybusybusybusy00")?.name, long_wait: spotify_track("longlonglonglonglong00").error,}$ quillo run spotify.qwe$ curl 'localhost:8080/search'$ curl 'localhost:8080/tracks'$ curl 'localhost:8080/playlist'$ curl 'localhost:8080/ids'$ curl 'localhost:8080/pkce'$ curl 'localhost:8080/signed-in'$ curl 'localhost:8080/failures'It talks to the Redis that REDIS_URL names, else Redis on this machine. The site's test runs it against a stand-in.
Source
//! Spotify's Web API without an SDK: client-credentials tokens kept until they run out, PKCE sign-in with its verifier, challenge, address and code exchange, search, tracks in fifties, playlists read through `next`, made and added to in hundreds, 429s waited out by their Retry-After, and ids taken from any link or URI.//! version: 1.0.0//!//! [dependencies]//! ureq = "2"//! sha2 = "0.10"//! base64 = "0.22"//! serde_json = { version = "1", features = ["preserve_order"] }use base64::{engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, Engine};use serde_json::{json, Value};use sha2::{Digest, Sha256};use std::sync::atomic::{AtomicU64, Ordering};use std::sync::Mutex;use std::time::{Duration, SystemTime, UNIX_EPOCH};// SPOTIFY_CLIENT_ID is the app's id, SPOTIFY_CLIENT_SECRET its secret (client credentials only; PKCE// needs none). SPOTIFY_API_URL is the API (https://api.spotify.com/v1), SPOTIFY_ACCOUNTS_URL the// sign-in (https://accounts.spotify.com). Pass a signed-in user's token as `user_token` on calls that// act for them; "" uses the app's own token, which cannot touch a user's library.fn setting(k: &str) -> Option<String> { std::env::var(k).ok().filter(|v| !v.is_empty())}fn api() -> String { setting("SPOTIFY_API_URL").unwrap_or_else(|| "https://api.spotify.com/v1".into()).trim_end_matches('/').to_string()}fn accounts() -> String { setting("SPOTIFY_ACCOUNTS_URL").unwrap_or_else(|| "https://accounts.spotify.com".into()).trim_end_matches('/').to_string()}fn enc(s: &str) -> String { s.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{:02X}", b) }).collect()}fn now() -> i64 { SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)}fn token_post(form: &[(&str, &str)], basic: bool) -> Result<Value, String> { let mut req = ureq::post(&format!("{}/api/token", accounts())).timeout(Duration::from_secs(20)); if basic { let id = setting("SPOTIFY_CLIENT_ID").ok_or("SPOTIFY_CLIENT_ID is not set")?; let secret = setting("SPOTIFY_CLIENT_SECRET").ok_or("SPOTIFY_CLIENT_SECRET is not set")?; req = req.set("Authorization", &format!("Basic {}", STANDARD.encode(format!("{}:{}", id, secret)))); } match req.send_form(form) { Ok(r) => serde_json::from_str(&r.into_string().map_err(|e| e.to_string())?).map_err(|e| e.to_string()), Err(ureq::Error::Status(c, r)) => { let b = r.into_string().unwrap_or_default(); let v: Value = serde_json::from_str(&b).unwrap_or(Value::Null); Err(format!("Spotify refused the token, {}: {} {}", c, v["error"].as_str().unwrap_or(&b), v["error_description"].as_str().unwrap_or(""))) } Err(e) => Err(format!("cannot reach Spotify's accounts service: {}", e)), }}static APP_TOKEN: Mutex<Option<(String, i64)>> = Mutex::new(None);fn app_token(force: bool) -> Result<String, String> { let mut g = APP_TOKEN.lock().unwrap_or_else(|e| e.into_inner()); if let (false, Some((t, until))) = (force, g.as_ref()) { if *until > now() + 60 { return Ok(t.clone()); } } let v = token_post(&[("grant_type", "client_credentials")], true)?; let t = v["access_token"].as_str().ok_or("Spotify gave no access_token")?.to_string(); *g = Some((t.clone(), now() + v["expires_in"].as_i64().unwrap_or(3600))); Ok(t)}fn error_text(status: u16, text: &str) -> String { let v: Value = serde_json::from_str(text).unwrap_or(Value::Null); let m = v["error"]["message"].as_str().map(str::to_string).unwrap_or_else(|| text.trim().chars().take(300).collect()); let hint = match status { 401 => " (the token is wrong or expired)", 403 => " (the app or user may not do this: check the token's scopes)", _ => "", }; format!("Spotify {}: {}{}", status, m, hint)}// `url` is whole, as `next` is.fn fetch(method: &str, url: &str, body: Option<&Value>, user_token: &str) -> Result<Value, String> { let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(30)).build(); let mut renewed = false; let mut waited = 0; loop { let t = if user_token.is_empty() { app_token(false)? } else { user_token.to_string() }; let req = agent.request(method, url).set("Authorization", &format!("Bearer {}", t)).set("Accept", "application/json"); let res = match body { Some(b) => req.set("Content-Type", "application/json").send_string(&b.to_string()), None => req.call(), }; match res { Ok(r) => { let text = r.into_string().map_err(|e| e.to_string())?; return if text.trim().is_empty() { Ok(Value::Null) } else { serde_json::from_str(&text).map_err(|_| format!("Spotify's answer is not JSON: {}", text.chars().take(200).collect::<String>())) }; } Err(ureq::Error::Status(401, _)) if !renewed && user_token.is_empty() => { renewed = true; app_token(true)?; } Err(ureq::Error::Status(429, r)) if waited < 5 => { let secs = r.header("Retry-After").and_then(|s| s.parse::<u64>().ok()).unwrap_or(1); if secs > 60 { return Err(format!("Spotify says to wait {} s, longer than a request should", secs)); } waited += 1; std::thread::sleep(Duration::from_secs(secs)); } Err(ureq::Error::Status(c, r)) => return Err(error_text(c, &r.into_string().unwrap_or_default())), Err(e) => return Err(format!("cannot reach Spotify at {}: {}", api(), e)), } }}fn get(path: &str, user_token: &str) -> Result<Value, String> { fetch("GET", &format!("{}{}", api(), path), None, user_token)}static COUNTER: AtomicU64 = AtomicU64::new(0);fn random_text() -> String { let n = COUNTER.fetch_add(1, Ordering::Relaxed); let t = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_nanos()); let a = Sha256::digest(format!("{}:{}:{}:{:?}", t, n, std::process::id(), std::collections::hash_map::RandomState::new())); let b = Sha256::digest(format!("{:?}:{:?}", a, std::collections::hash_map::RandomState::new())); URL_SAFE_NO_PAD.encode([a.as_slice(), b.as_slice()].concat())}/// The S256 challenge of a verifier: the unpadded base64url of its SHA-256.pub fn spotify_challenge(verifier: &str) -> String { URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))}/// A new PKCE pair: { verifier, challenge, state }. Keep `verifier` and `state` in the visitor's session until they come back.pub fn spotify_pkce() -> serde_json::Value { let verifier = random_text(); json!({ "challenge": spotify_challenge(&verifier), "verifier": verifier, "state": random_text()[..24] })}/// The address to send a visitor to, for PKCE sign-in: `scopes` a list like ["playlist-modify-private"].pub fn spotify_authorize_url(redirect_uri: &str, scopes: Vec<String>, state: &str, challenge: &str) -> Result<String, String> { let id = setting("SPOTIFY_CLIENT_ID").ok_or("SPOTIFY_CLIENT_ID is not set")?; Ok(format!("{}/authorize?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&code_challenge_method=S256&code_challenge={}", accounts(), enc(&id), enc(redirect_uri), enc(&scopes.join(" ")), enc(state), enc(challenge)))}/// Trades the `code` from the redirect for tokens: { access_token, refresh_token, expires_in, scope }. Needs the same `redirect_uri`, and the `verifier` that made the challenge.pub fn spotify_exchange_code(code: &str, redirect_uri: &str, verifier: &str) -> Result<serde_json::Value, String> { let id = setting("SPOTIFY_CLIENT_ID").ok_or("SPOTIFY_CLIENT_ID is not set")?; if verifier.len() < 43 || verifier.len() > 128 { return Err(format!("a PKCE verifier is 43 to 128 characters, not {}", verifier.len())); } token_post(&[("grant_type", "authorization_code"), ("code", code), ("redirect_uri", redirect_uri), ("client_id", &id), ("code_verifier", verifier)], false)}/// A new access token from a user's refresh token. Under PKCE the answer may hold a new refresh_token: keep that one if it does.pub fn spotify_refresh(refresh_token: &str) -> Result<serde_json::Value, String> { let id = setting("SPOTIFY_CLIENT_ID").ok_or("SPOTIFY_CLIENT_ID is not set")?; token_post(&[("grant_type", "refresh_token"), ("refresh_token", refresh_token), ("client_id", &id)], false)}/// The id in a link or URI: "https://open.spotify.com/track/ID?si=x", "spotify:track:ID", or an id already.pub fn spotify_id(link: &str) -> Result<String, String> { let t = link.trim(); let id = if let Some(rest) = t.strip_prefix("spotify:") { rest.rsplit(':').next().unwrap_or("") } else if t.contains("open.spotify.com/") { t.split(|c| c == '?' || c == '#').next().unwrap_or("").trim_end_matches('/').rsplit('/').next().unwrap_or("") } else { t }; if id.len() != 22 || !id.bytes().all(|b| b.is_ascii_alphanumeric()) { return Err(format!("no Spotify id in `{}`: an id is 22 letters and digits", link)); } Ok(id.to_string())}/// Searches: `types` a list from "track", "album", "artist", "playlist"; `params` adds `market`; `max_items` is the most of each type. Spotify stops at 1000 results, and `q` may use its filters (`artist:"Nina Simone" year:1965`). Gives { track: { total, items }, ... }.pub fn spotify_search(q: &str, types: Vec<String>, params: serde_json::Value, max_items: i64) -> Result<serde_json::Value, String> { if q.trim().is_empty() || types.is_empty() { return Err("a search needs a query and at least one type".into()); } let cap = if max_items > 0 { max_items.min(1000) } else { 1000 }; let extra: String = params.as_object().map_or(String::new(), |m| m.iter().map(|(k, v)| format!("&{}={}", enc(k), enc(v.as_str().unwrap_or("")))).collect()); let mut out = serde_json::Map::new(); for ty in &types { let mut items = vec![]; let mut total = Value::Null; let mut offset = 0; loop { let limit = (cap - offset).min(50); let v = get(&format!("/search?q={}&type={}&limit={}&offset={}{}", enc(q), enc(ty), limit, offset, extra), "")?; let page = &v[format!("{}s", ty)]; total = page["total"].clone(); let got = page["items"].as_array().cloned().unwrap_or_default(); let n = got.len() as i64; items.extend(got); offset += n; if n == 0 || offset >= cap || page["next"].is_null() { break; } } out.insert(ty.clone(), json!({ "total": total, "items": items })); } Ok(Value::Object(out))}/// One track, by id, link or URI.pub fn spotify_track(id: &str) -> Result<serde_json::Value, String> { get(&format!("/tracks/{}", spotify_id(id)?), "")}/// Tracks by id, link or URI, as many as you like, asked for in fifties. An id Spotify does not know is left out.pub fn spotify_tracks(ids: Vec<String>) -> Result<Vec<serde_json::Value>, String> { let clean: Vec<String> = ids.iter().map(|i| spotify_id(i)).collect::<Result<_, _>>()?; let mut out = vec![]; for chunk in clean.chunks(50) { let v = get(&format!("/tracks?ids={}", chunk.join(",")), "")?; out.extend(v["tracks"].as_array().cloned().unwrap_or_default().into_iter().filter(|t| !t.is_null())); } Ok(out)}/// A playlist's details, with `user_token` for a private one ("" for none). `tracks` is left to spotify_playlist_tracks.pub fn spotify_playlist(id: &str, user_token: &str) -> Result<serde_json::Value, String> { get(&format!("/playlists/{}?fields=id,name,description,public,owner(id),tracks(total),snapshot_id", spotify_id(id)?), user_token)}/// A playlist's items through every `next`: each { added_at, track }, with removed tracks' null left out; `max_items` caps them (0: all).pub fn spotify_playlist_tracks(id: &str, user_token: &str, max_items: i64) -> Result<Vec<serde_json::Value>, String> { let mut url = format!("{}/playlists/{}/tracks?limit=100", api(), spotify_id(id)?); let mut out = vec![]; loop { let v = fetch("GET", &url, None, user_token)?; out.extend(v["items"].as_array().cloned().unwrap_or_default().into_iter().filter(|i| !i["track"].is_null())); if max_items > 0 && out.len() as i64 >= max_items { out.truncate(max_items as usize); return Ok(out); } match v["next"].as_str() { Some(n) => url = n.to_string(), None => return Ok(out), } }}/// Makes a playlist for the signed-in user: `params` adds `description`, `public`. Needs a `user_token` with playlist-modify scopes.pub fn spotify_playlist_create(user_id: &str, name: &str, params: serde_json::Value, user_token: &str) -> Result<serde_json::Value, String> { if user_token.is_empty() { return Err("making a playlist needs a user's token: an app's own cannot".into()); } let mut b = if params.is_object() { params } else { json!({}) }; b["name"] = json!(name); fetch("POST", &format!("{}/users/{}/playlists", api(), enc(user_id)), Some(&b), user_token)}/// Adds tracks to a playlist, by link, URI or id, in hundreds as Spotify wants, in order. Gives the last snapshot_id.pub fn spotify_playlist_add(id: &str, tracks: Vec<String>, user_token: &str) -> Result<String, String> { if user_token.is_empty() { return Err("changing a playlist needs a user's token: an app's own cannot".into()); } let uris: Vec<String> = tracks.iter().map(|t| spotify_id(t).map(|i| format!("spotify:track:{}", i))).collect::<Result<_, _>>()?; let mut last = String::new(); let url = format!("{}/playlists/{}/tracks", api(), spotify_id(id)?); for chunk in uris.chunks(100) { let v = fetch("POST", &url, Some(&json!({ "uris": chunk })), user_token)?; last = v["snapshot_id"].as_str().unwrap_or("").to_string(); } Ok(last)}Related
reddit.qwer · 1.0.0Reddit's API without an SDK: OAuth by a script app's password or an app-only token, the User-Agent form Reddit insists on checked first, listings walked with `after`, comment trees flattened with their depth, posts and comments made with errors dug out of their 200 answers, and the rate-limit headers obeyed before Reddit has to refuse.2 views · 6 downloadsBigCommerce bigcommerce.qwer · 1.0.0A BigCommerce store run from outside it: the catalog on V3 and its data and meta envelope, orders on V2 with bare lists and an empty 204, both shapes of error, batches of ten with their partial failures, the rate-limit headers waited out and reported, and an app's signed payloads checked in both the old form and the JWT.3 views · 7 downloadsQuickBooks Online quickbooks.qwer · 1.0.0QuickBooks Online's Accounting API without an SDK: queries in Intuit's own language with values quoted and pages walked by start position, customers and invoices from plain lines, sparse updates through SyncToken with stale writes caught, voids and deletes, refresh tokens that move on and are kept, Fault answers read in either case, and webhook signatures with both payload shapes flattened.2 views · 7 downloadsSalesforce salesforce.qwer · 1.0.0Salesforce's REST API without an SDK: sign-in by client credentials or a JWT bearer, renewed when a session dies, SOQL with values quoted and nextRecordsUrl followed, sObjects made, read, changed, upserted by external id and deleted, composite and collection calls with each item's result, Salesforce's lists of errors read, and the day's API use reported.3 views · 7 downloadsXero xero.qwer · 1.0.0Xero's Accounting API without an SDK: refresh tokens that change on every use, kept in a file for you, the tenant header, invoices, contacts and payments, pages walked to the end, per-record refusals in a batch, /Date()/ values read, minute limits waited out and the day's reported, and webhook signatures with the intent-to-receive answer.4 views · 7 downloadsZoom zoom.qwer · 1.0.0Zoom without an SDK: Server-to-Server OAuth with its token kept until it runs out, meetings made, read, changed, ended and deleted, lists through next_page_token, meeting UUIDs double-encoded when Zoom wants that, per-second limits waited out and the daily one reported, and webhooks with the x-zm-signature checked and the endpoint.url_validation challenge answered.2 views · 7 downloadsThread 0 posts
On Spotify: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.