Personal Site

Completely refactor spotify API because i forgot how it worked in < 1 day + should be more robust

vielle.dev a01f51cb b00e30c9

verified
+395 -213
-213
src/components/playing/spotify.ts
··· 1 - import { 2 - SPOTIFY_CLIENT_ID, 3 - SPOTIFY_CLIENT_SECRET, 4 - SPOTIFY_REDIRECT_URI, 5 - } from "astro:env/server"; 6 - import fs from "fs/promises"; 7 - 8 - const throws = (val: unknown) => { 9 - throw val; 10 - }; 11 - 12 - /** via: https://www.totaltypescript.com/concepts/the-prettify-helper */ 13 - type Prettify<T> = { 14 - [K in keyof T]: T[K]; 15 - } & {}; 16 - 17 - type AuthToken = { 18 - access_token: string; 19 - token_type: "Bearer"; 20 - scope: string; 21 - expires_in: number; 22 - refresh_token: string; 23 - }; 24 - 25 - type RefreshToken = Prettify< 26 - Omit<AuthToken, "refresh_token"> & { refresh_token?: string } 27 - >; 28 - 29 - const isRefreshToken = (obj: unknown): obj is RefreshToken => 30 - // validate is object 31 - typeof obj === "object" && 32 - obj !== null && 33 - // validate properties 34 - "access_token" in obj && 35 - typeof obj.access_token === "string" && 36 - "token_type" in obj && 37 - obj.token_type === "Bearer" && 38 - "scope" in obj && 39 - typeof obj.scope === "string" && 40 - "expires_in" in obj && 41 - typeof obj.expires_in === "number" && 42 - // either refresh token exists as string or not at all 43 - (("refresh_token" in obj && typeof obj.refresh_token === "string") || 44 - !("refresh_token" in obj)); 45 - 46 - // auth token is just refresh with a non optional refresh_token 47 - const isAuthToken = (obj: unknown): obj is AuthToken => 48 - isRefreshToken(obj) && "refresh_token" in obj; 49 - 50 - export async function getAccessCode(userAuthCode?: string) { 51 - const refreshToken = await fs 52 - .readFile("./.refreshToken", { encoding: "utf-8" }) 53 - .catch((_) => undefined) 54 - .then((x) => (x === "" || x === "REFRESH_TOKEN" ? undefined : x)); 55 - if (!(userAuthCode || refreshToken)) 56 - throw new Error( 57 - "No auth code or refresh token.\nGenerate an auth code at `/src/pages/_callback`\nA refresh token will be generated from this auth token.", 58 - ); 59 - 60 - // prefer auth codes over refresh tokens 61 - // since the auth code may have updated scopes. 62 - 63 - const accessFrom: 64 - | { 65 - userAuthCode: string; 66 - } 67 - | { 68 - refreshToken: string; 69 - } = userAuthCode 70 - ? { userAuthCode } 71 - : refreshToken 72 - ? { refreshToken } 73 - : (undefined as never); 74 - 75 - const req = fetch("https://accounts.spotify.com/api/token", { 76 - method: "POST", 77 - headers: { 78 - "Content-Type": "application/x-www-form-urlencoded", 79 - Authorization: `Basic ${Buffer.from(SPOTIFY_CLIENT_ID + ":" + SPOTIFY_CLIENT_SECRET).toString("base64")}`, 80 - }, 81 - body: new URLSearchParams({ 82 - grant_type: 83 - "userAuthCode" in accessFrom ? "authorization_code" : "refresh_token", 84 - ...("userAuthCode" in accessFrom 85 - ? { 86 - code: accessFrom.userAuthCode, 87 - redirect_uri: SPOTIFY_REDIRECT_URI, 88 - } 89 - : { 90 - refresh_token: accessFrom.refreshToken, 91 - }), 92 - }).toString(), 93 - }); 94 - 95 - return ( 96 - req 97 - // if res isn't 200 handle it in the catch 98 - .then((res) => (res.ok ? res : throws(res))) 99 - // request is 200-299 100 - // json can throw SyntaxError in this case 101 - .then((res) => res.json()) 102 - .then((res) => 103 - "userAuthCode" in accessFrom 104 - ? isAuthToken(res) 105 - ? { code: res.access_token, refresh: res.refresh_token } 106 - : throws({ err: "INVALID_RESPONSE", res }) 107 - : isRefreshToken(res) 108 - ? { 109 - code: res.access_token, 110 - refresh: res.refresh_token ?? accessFrom.refreshToken, 111 - } 112 - : throws({ err: "INVALID_RESPONSE", res }), 113 - ) 114 - // res is now an access token and refresh token 115 - .then((res) => { 116 - fs.writeFile("./.refreshToken", res.refresh, { encoding: "utf-8" }); 117 - return res.code; 118 - }) 119 - .catch((err) => { 120 - // SyntaxError 121 - // Response 122 - // {err: string, res: Response} 123 - if (err instanceof Response) console.error("Request failed:", err); 124 - else if (err instanceof SyntaxError) 125 - console.error("Response JSON failed", err); 126 - else if (err.err === "INVALID_RESPONSE") 127 - console.error("Response malformed:", err); 128 - else { 129 - console.error("Unhandled exception."); 130 - throw err; 131 - } 132 - }) 133 - ); 134 - } 135 - 136 - export async function getSpotifyApi(url: string) { 137 - const accessToken = await getAccessCode(); 138 - if (!accessToken) 139 - return new Error( 140 - "Failed to get access code. try using src/pages/_callback", 141 - ); 142 - const res = await fetch("https://api.spotify.com/v1" + url, { 143 - headers: { 144 - Authorization: `Bearer ${accessToken}`, 145 - }, 146 - }) 147 - .then((res) => (!res.ok ? throws(res) : res)) 148 - .catch((err) => { 149 - if (err instanceof Response) { 150 - if (err.status === 401) 151 - return new Error("Bad token. Try using /_callback"); 152 - if (err.status === 403) 153 - return new Error("Bad OAuth. Cry about it (???)"); 154 - if (err.status === 429) return new Error("Rate limited. Cry about it"); 155 - console.error(err); 156 - return new Error("Unexpected status code"); 157 - } 158 - if (err instanceof Error) return err; 159 - console.log("Unexpected exception."); 160 - throw err; 161 - }); 162 - 163 - return res; 164 - } 165 - 166 - export async function nowPlayingSongID() { 167 - const res = await getSpotifyApi("/me/player/currently-playing"); 168 - if (res instanceof Error) return res; 169 - 170 - const output = await Promise.resolve(res) 171 - // send "not modified to catch" 172 - .then((res) => (res.status === 204 ? throws(res) : res)) 173 - .then((res) => res.json() as Promise<Record<string, unknown>>) 174 - // res code is 204 175 - .catch((res) => undefined); 176 - 177 - if (!output) return undefined; 178 - 179 - // https://developer.spotify.com/documentation/web-api/reference/get-the-users-currently-playing-track 180 - return (output as { item: { id: string } | null }).item?.id ?? null; 181 - } 182 - 183 - export async function getTrack(id: string) { 184 - const res = await getSpotifyApi("/tracks/" + id); 185 - if (res instanceof Error) return res; 186 - 187 - const output = await Promise.resolve(res) 188 - // send "not modified to catch" 189 - .then((res) => (res.status === 204 ? throws(res) : res)) 190 - .then((res) => res.json() as Promise<Record<string, unknown>>) 191 - // res code is 204 192 - .catch((res) => undefined); 193 - 194 - if (!output) return undefined; 195 - 196 - return output as { 197 - external_urls: { 198 - spotify: string; 199 - }; 200 - album: { 201 - images: { 202 - url: string; 203 - width: number; 204 - height: number; 205 - }[]; 206 - }; 207 - name: string; 208 - artists: { 209 - id: string; 210 - name: string; 211 - }[]; 212 - }; 213 - }
+115
src/components/playing/spotify/access.ts
··· 1 + import fs from "fs/promises"; 2 + import { 3 + SPOTIFY_CLIENT_ID, 4 + SPOTIFY_CLIENT_SECRET, 5 + SPOTIFY_REDIRECT_URI, 6 + } from "astro:env/server"; 7 + import { SpotifyError, throws } from "./errors"; 8 + import { isAuthToken, isRefreshToken } from "./types"; 9 + 10 + /** 11 + * Get an access code which can be used to authenticate requests on behalf of the user. 12 + * @param userAuthCode Authentication code for the user (via callback). Uses the stored refresh token if not provided 13 + * @returns `string`: access code to authorize requests 14 + * @returns `undefined`: failed to authenticate user. 15 + * @throws `SpotifyError<NO_AUTH>` when no refresh token is stored and no auth code is provided 16 + */ 17 + export default async function getAccessCode(userAuthCode?: string) { 18 + const refreshToken = await fs 19 + .readFile("./.refreshToken", { encoding: "utf-8" }) 20 + .catch((_) => undefined) 21 + .then((x) => (x === "" || x === "REFRESH_TOKEN" ? undefined : x)); 22 + if (!(userAuthCode || refreshToken)) 23 + throw new SpotifyError( 24 + "NO_AUTH", 25 + null, 26 + `No auth code or refresh token. 27 + Generate an auth code at \`/src/pages/_callback\` 28 + A refresh token will be generated from this auth token.`, 29 + ); 30 + 31 + // prefer auth codes over refresh tokens 32 + // since the auth code may have updated scopes. 33 + 34 + const accessFrom: 35 + | { 36 + userAuthCode: string; 37 + } 38 + | { 39 + refreshToken: string; 40 + } = userAuthCode 41 + ? { userAuthCode } 42 + : refreshToken 43 + ? { refreshToken } 44 + : (undefined as never); 45 + 46 + const req = fetch("https://accounts.spotify.com/api/token", { 47 + method: "POST", 48 + headers: { 49 + "Content-Type": "application/x-www-form-urlencoded", 50 + Authorization: `Basic ${Buffer.from(SPOTIFY_CLIENT_ID + ":" + SPOTIFY_CLIENT_SECRET).toString("base64")}`, 51 + }, 52 + body: new URLSearchParams({ 53 + grant_type: 54 + "userAuthCode" in accessFrom ? "authorization_code" : "refresh_token", 55 + ...("userAuthCode" in accessFrom 56 + ? { 57 + code: accessFrom.userAuthCode, 58 + redirect_uri: SPOTIFY_REDIRECT_URI, 59 + } 60 + : { 61 + refresh_token: accessFrom.refreshToken, 62 + }), 63 + }).toString(), 64 + }); 65 + 66 + return ( 67 + req 68 + // if res isn't 200 handle it in the catch 69 + .then((res) => (res.ok ? res : throws(res))) 70 + // request is 200-299 71 + // json can throw SyntaxError in this case 72 + .then((res) => res.json()) 73 + .then((res) => { 74 + if ("userAuthCode" in accessFrom) { 75 + if (isAuthToken(res)) { 76 + return { 77 + code: res.access_token, 78 + refresh: res.refresh_token, 79 + }; 80 + } 81 + } else { 82 + if (isRefreshToken(res)) { 83 + return { 84 + code: res.access_token, 85 + refresh: res.refresh_token ?? accessFrom.refreshToken, 86 + }; 87 + } 88 + } 89 + throw new SpotifyError( 90 + "INVALID_AUTH_RES", 91 + res, 92 + "Could not parse access token response", 93 + ); 94 + }) 95 + // res is now an access token and refresh token 96 + .then((res) => { 97 + fs.writeFile("./.refreshToken", res.refresh, { encoding: "utf-8" }); 98 + return res.code; 99 + }) 100 + .catch((err) => { 101 + // SyntaxError 102 + // Response 103 + // SpotifyError<"INVALID_AUTH_RES"> 104 + if (err instanceof Response) console.error("Request failed:", err); 105 + else if (err instanceof SyntaxError) 106 + console.error("Response JSON failed", err); 107 + else if (err instanceof SpotifyError && err.code === "INVALID_AUTH_RES") 108 + console.error("Response malformed:", err); 109 + else { 110 + console.error("Unhandled exception."); 111 + throw err; 112 + } 113 + }) 114 + ); 115 + }
+155
src/components/playing/spotify/api.ts
··· 1 + import getAccessCode from "./access"; 2 + import { SpotifyError, throws } from "./errors"; 3 + import { isNowPlaying, type nowPlaying } from "./types"; 4 + 5 + /** 6 + * Wrapper for authorizing a spotify API with default headers etc 7 + * @param url API endpoint to call. Pass a leading slash 8 + * @returns `Response` 9 + * @throws `SpotifyError<NO_AUTH>` when auth fails 10 + * @throws `Response` on non 200-299 status codes 11 + */ 12 + export async function getSpotifyApi(url: string) { 13 + // get the access code 14 + const accessToken = await getAccessCode(); 15 + // check its valid 16 + if (!accessToken) 17 + throw new SpotifyError( 18 + "NO_AUTH", 19 + null, 20 + "Failed to get access code. try using src/pages/_callback", 21 + ); 22 + 23 + // fetch the api and throw on non 2** code 24 + return fetch(`https://api.spotify.com/v1${url}`, { 25 + headers: { 26 + Authorization: `Bearer ${accessToken}`, 27 + }, 28 + }).then((res) => (res.ok ? res : throws(res))); 29 + } 30 + /** 31 + * Get the current playing track 32 + * @returns `nowPlaying` 33 + * @throws `SpotifyError` of NO_AUTH | UNHANDLED_API_ERR | INVALID_AUTH_RES | RATE_LIMITED | NO_CONTENT | MALFORMED_SPOTIFY_RES 34 + */ 35 + export async function spotifyNowPlaying() { 36 + type success = nowPlaying["item"]; 37 + let res: (v: success) => void, rej: (v: unknown) => void; 38 + const output = new Promise<success>((_res, _rej) => { 39 + (res = _res), (rej = _rej); 40 + }); 41 + const nowPlaying = getSpotifyApi("/me/player/currently-playing"); 42 + 43 + // auth failed 44 + nowPlaying.catch((err) => { 45 + if (err instanceof SpotifyError && err.code === "NO_AUTH") { 46 + console.error("Authentication failed:", err.human); 47 + rej(err); 48 + } 49 + }); 50 + 51 + /** 52 + * request failed. 53 + * https://developer.spotify.com/documentation/web-api/concepts/api-calls 54 + * 400 Bad Request - The request could not be understood by the server due to malformed syntax. The message body will contain more information; see Response Schema. 55 + * 401 Unauthorized - The request requires user authentication or, if the request included authorization credentials, authorization has been refused for those credentials. 56 + * 403 Forbidden - The server understood the request, but is refusing to fulfill it. 57 + * 404 Not Found - The requested resource could not be found. This error can be due to a temporary or permanent condition. 58 + * 429 Too Many Requests - Rate limiting has been applied. 59 + * 500 Internal Server Error. You should never receive this error because our clever coders catch them all ... but if you are unlucky enough to get one, please report it to us through a comment at the bottom of this page. 60 + * 502 Bad Gateway - The server was acting as a gateway or proxy and received an invalid response from the upstream server. 61 + * 503 Service Unavailable - The server is currently unable to handle the request due to a temporary condition which will be alleviated after some delay. You can choose to resend the request again. 62 + */ 63 + nowPlaying.catch((res) => { 64 + switch (res.status) { 65 + // handle req error 66 + case 400: { 67 + rej(new SpotifyError("UNHANDLED_API_ERR", res, "400: Bad request")); 68 + break; 69 + } 70 + case 401: { 71 + rej(new SpotifyError("INVALID_AUTH_RES", res, "401: Unauthorized")); 72 + break; 73 + } 74 + case 403: { 75 + rej(new SpotifyError("UNHANDLED_API_ERR", res, "403: Forbidden")); 76 + break; 77 + } 78 + case 404: { 79 + rej(new SpotifyError("UNHANDLED_API_ERR", res, "404: Not found")); 80 + break; 81 + } 82 + case 429: { 83 + rej(new SpotifyError("RATE_LIMITED", res, "429: Rate Limited")); 84 + break; 85 + } 86 + case 500: { 87 + rej(new SpotifyError("UNHANDLED_API_ERR", res, "500: Internal Error")); 88 + break; 89 + } 90 + case 502: { 91 + rej(new SpotifyError("UNHANDLED_API_ERR", res, "502: Bad Gateway")); 92 + break; 93 + } 94 + case 503: { 95 + rej( 96 + new SpotifyError( 97 + "UNHANDLED_API_ERR", 98 + res, 99 + "503: Service Unavaliable", 100 + ), 101 + ); 102 + break; 103 + } 104 + } 105 + }); 106 + 107 + /** 108 + * request succeeded 109 + * https://developer.spotify.com/documentation/web-api/concepts/api-calls 110 + * 200 OK - The request has succeeded. The client can read the result of the request in the body and the headers of the response. 111 + * 201 Created - The request has been fulfilled and resulted in a new resource being created. 112 + * 202 Accepted - The request has been accepted for processing, but the processing has not been completed. 113 + * 204 No Content - The request has succeeded but returns no message body. 114 + */ 115 + nowPlaying 116 + .then((res) => { 117 + if (res instanceof Error) return; 118 + switch (res.status) { 119 + // handle 200 codes 120 + case 200: { 121 + return res; 122 + } 123 + case 201: { 124 + rej(new SpotifyError("UNHANDLED_API_ERR", res, "201: Created")); 125 + return; 126 + } 127 + case 202: { 128 + rej(new SpotifyError("UNHANDLED_API_ERR", res, "202: Accepted")); 129 + return; 130 + } 131 + case 204: { 132 + rej(new SpotifyError("NO_CONTENT", res, "204: No Content")); 133 + return; 134 + } 135 + } 136 + }) 137 + .then(async (resp) => { 138 + // quit early if it rejected last time 139 + if (!resp) return; 140 + try { 141 + const json = await resp.json(); 142 + 143 + 144 + // verify structure 145 + if (!isNowPlaying(json)) { 146 + rej(new SpotifyError("MALFORMED_SPOTIFY_RES", json, "Response missing required fields.")); 147 + return; 148 + } 149 + 150 + res(json.item) 151 + } catch (e) {} 152 + }); 153 + 154 + return output; 155 + }
+23
src/components/playing/spotify/errors.ts
··· 1 + export class SpotifyError { 2 + code: "NO_AUTH" | "INVALID_AUTH_RES" | "UNHANDLED_API_ERR" | "RATE_LIMITED" | "NO_CONTENT" | "MALFORMED_SPOTIFY_RES"; 3 + details: unknown; 4 + human: string; 5 + 6 + constructor( 7 + code: SpotifyError["code"], 8 + details: unknown, 9 + human: string = code, 10 + ) { 11 + this.code = code; 12 + this.details = details; 13 + this.human = human; 14 + } 15 + } 16 + 17 + /** 18 + * Throw passed value (for ternaries/etc) 19 + * @param val value to throw 20 + */ 21 + export function throws(val: unknown): never { 22 + throw val; 23 + }
+5
src/components/playing/spotify/index.ts
··· 1 + import getAccessCode from "./access"; 2 + import { SpotifyError } from "./errors"; 3 + import { getSpotifyApi, spotifyNowPlaying as nowPlaying } from "./api"; 4 + 5 + export { getAccessCode, getSpotifyApi, nowPlaying, SpotifyError };
+97
src/components/playing/spotify/types.ts
··· 1 + import { isObj, type Prettify } from "../../../utils"; 2 + 3 + export type AuthToken = { 4 + access_token: string; 5 + token_type: "Bearer"; 6 + scope: string; 7 + expires_in: number; 8 + refresh_token: string; 9 + }; 10 + 11 + export type RefreshToken = Prettify< 12 + Omit<AuthToken, "refresh_token"> & { refresh_token?: string } 13 + >; 14 + 15 + export function isRefreshToken(obj: unknown): obj is RefreshToken { 16 + return ( 17 + // validate is object 18 + typeof obj === "object" && 19 + obj !== null && 20 + // validate properties 21 + "access_token" in obj && 22 + typeof obj.access_token === "string" && 23 + "token_type" in obj && 24 + obj.token_type === "Bearer" && 25 + "scope" in obj && 26 + typeof obj.scope === "string" && 27 + "expires_in" in obj && 28 + typeof obj.expires_in === "number" && 29 + // either refresh token exists as string or not at all 30 + (("refresh_token" in obj && typeof obj.refresh_token === "string") || 31 + !("refresh_token" in obj)) 32 + ); 33 + } 34 + 35 + // auth token is just refresh with a non optional refresh_token 36 + export function isAuthToken(obj: unknown): obj is AuthToken { 37 + return isRefreshToken(obj) && "refresh_token" in obj; 38 + } 39 + 40 + export type nowPlaying = { 41 + item: null | { 42 + type: "track"; 43 + id: string; 44 + name: string; 45 + album: { 46 + images: { 47 + url: string; 48 + height: number; 49 + width: number; 50 + }[]; 51 + }; 52 + artists: { 53 + name: string; 54 + id: string; 55 + }[]; 56 + }; 57 + }; 58 + 59 + export function isNowPlaying(obj: object): obj is nowPlaying { 60 + return ( 61 + isObj(obj) && 62 + "item" in obj && 63 + isObj(obj.item) && 64 + "type" in obj.item && 65 + obj.item.type === "track" && 66 + "id" in obj.item && 67 + typeof obj.item.id === "string" && 68 + "name" in obj.item && 69 + typeof obj.item.name === "string" && 70 + "album" in obj.item && 71 + isObj(obj.item.album) && 72 + "images" in obj.item.album && 73 + Array.isArray(obj.item.album.images) && 74 + obj.item.album.images.reduce( 75 + (acc, curr) => 76 + acc && 77 + "url" in curr && 78 + typeof curr.url === "string" && 79 + "height" in curr && 80 + typeof curr.height === "number" && 81 + "width" in curr && 82 + typeof curr.width === "number", 83 + true, 84 + ) && 85 + "artists" in obj.item && 86 + Array.isArray(obj.item.artists) && 87 + obj.item.artists.reduce( 88 + (acc, curr) => 89 + acc && 90 + "name" in curr && 91 + typeof curr.name === "string" && 92 + "id" in curr && 93 + typeof curr.id === "string", 94 + true, 95 + ) 96 + ); 97 + }