Personal Site
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Completely fix the sdk? seems to have worked for ~5 hours with no issues so ?

Required a complete rewrite of the sdk file but this seems more robust and intentional anyway SO...

+121 -103
+121 -103
src/components/home/playing/spotify/index.ts
··· 1 1 /** 2 2 * types and logic for getting now playing information 3 3 */ 4 - 5 4 import { 5 + DefaultResponseDeserializer, 6 + DefaultResponseValidator, 7 + DocumentLocationRedirectionStrategy, 8 + InMemoryCachingStrategy, 9 + NoOpErrorHandler, 6 10 SpotifyApi, 7 11 type AccessToken, 12 + type IAuthStrategy, 8 13 type IHandleErrors, 14 + type SdkConfiguration, 9 15 } from "@spotify/web-api-ts-sdk"; 10 - import { ProvidedAccessTokenStrategy } from "@spotify/web-api-ts-sdk"; 11 16 import { SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET } from "astro:env/server"; 12 17 13 - import fs from "node:fs/promises"; 14 - import { isObj, type Prettify } from "/utils"; 18 + import fs from "fs/promises"; 19 + import { isObj, throws, type Prettify } from "/utils"; 15 20 16 - /** 17 - * the refresh_token field is not checked as it might not be returned from the api 18 - */ 19 - const isSpotifyAccessToken = ( 20 - token: any, 21 - // token could have an ommited refresh token field 22 - ): token is Prettify< 23 - Omit<AccessToken, "refresh_token"> & { refresh_token?: string } 24 - > => 25 - isObj(token) && 26 - "access_token" in token && 27 - typeof token.access_token === "string" && 28 - "token_type" in token && 29 - token.token_type === "Bearer" && 30 - "expires_in" in token && 31 - typeof token.expires_in === "number" && 32 - ("refresh_token" in token ? typeof token.refresh_token === "string" : true); 21 + type AuthAccessToken = Prettify<AccessToken & { expires: number }>; 33 22 34 - const refresh = (() => { 35 - let token = ""; 23 + const defaultConfig: SdkConfiguration = { 24 + fetch: (req: string | Request | URL, init?: RequestInit) => fetch(req, init), 25 + beforeRequest: () => {}, 26 + afterRequest: () => {}, 27 + deserializer: new DefaultResponseDeserializer(), 28 + responseValidator: new DefaultResponseValidator(), 29 + errorHandler: new NoOpErrorHandler(), 30 + redirectionStrategy: new DocumentLocationRedirectionStrategy(), 31 + cachingStrategy: new InMemoryCachingStrategy(), 32 + }; 36 33 37 - return { 38 - async get() { 39 - const refresh = 40 - token === "" 41 - ? await fs 42 - .readFile("./.refreshToken", { encoding: "utf-8" }) 43 - .catch((err) => console.error(err)) 44 - : token; 34 + class Auth implements IAuthStrategy { 35 + config: SdkConfiguration = { ...defaultConfig }; 45 36 46 - if (!refresh) throw "Could not load refresh token"; 47 - this.set(refresh); // dispatch update whenever its loaded to make sure its up to date 48 - return refresh; 49 - }, 37 + accessToken: AuthAccessToken | null = null; 50 38 51 - // by making this not async it makes sure the token update is blocking 52 - set(arg_token: string) { 53 - token = arg_token; 39 + /** update the stored config with the new config */ 40 + setConfiguration(config: SdkConfiguration): void { 41 + this.config = { ...defaultConfig, ...config }; 42 + } 54 43 55 - return fs 56 - .writeFile("./.refreshToken", token, { 57 - encoding: "utf-8", 58 - }) 59 - .catch((err) => 60 - console.warn("Could not write to ./.refreshToken:", err), 61 - ); 62 - }, 44 + /** return the stored access token. if no token is stored, authenticate 45 + * will always include the `expires` field 46 + */ 47 + async getOrCreateAccessToken(): Promise<AuthAccessToken> { 48 + // if the token is stored and the token hasn't yet expired, return it 49 + if (this.accessToken && this.accessToken.expires > Date.now()) 50 + return this.accessToken; 63 51 64 - async reload() { 65 - const res = await fetch("https://accounts.spotify.com/api/token", { 66 - method: "post", 67 - headers: { 68 - "content-type": "application/x-www-form-urlencoded", 69 - Authorization: 70 - "Basic " + 71 - Buffer.from( 72 - SPOTIFY_CLIENT_ID + ":" + SPOTIFY_CLIENT_SECRET, 73 - ).toString("base64"), 74 - }, 75 - body: new URLSearchParams({ 76 - grant_type: "refresh_token", 77 - refresh_token: await this.get(), 78 - }).toString(), 79 - }).catch((err) => console.error(err)); 52 + // no token is stored OR the token has expired, so we ignore the stored token and refresh it 53 + // if we cannot read the file there is no way to recover this and we should let the error throw 54 + // this will get caught in the `catch` branch of SpotifyAPI.makeRequest() and handled by the handler 55 + const refreshToken = await fs.readFile("./.refreshToken", { 56 + encoding: "utf8", 57 + }); 80 58 81 - if (!res) return res; 59 + const res = await fetch("https://accounts.spotify.com/api/token", { 60 + method: "POST", 61 + headers: { 62 + "Content-Type": "application/x-www-form-urlencoded", 63 + Authorization: 64 + "Basic " + 65 + Buffer.from(SPOTIFY_CLIENT_ID + ":" + SPOTIFY_CLIENT_SECRET).toString( 66 + "base64", 67 + ), 68 + }, 69 + body: new URLSearchParams({ 70 + grant_type: "refresh_token", 71 + refresh_token: refreshToken, 72 + }).toString(), 73 + }); 82 74 83 - const refresh_token = await res 84 - .json() 85 - .then((token) => 86 - isSpotifyAccessToken(token) 87 - ? token 88 - : console.error("Response was not a valid access token:", token), 89 - ); 75 + // if its not a 200 code, throw 76 + if (!res.ok) 77 + throw new Error( 78 + `Token request recived error ${res.status}: ${res.statusText}`, 79 + ); 90 80 91 - if (!refresh_token) return refresh_token; 81 + // dont handle the exception, this is covered by the user handler 82 + const result = await res.json(); 83 + // check the result is formed correctly 84 + if (!isObj(result)) throw new Error("Didnt recive an object from spotify"); 92 85 93 - // update the refresh token if it exists 94 - refresh_token.refresh_token && this.set(refresh_token.refresh_token); 86 + this.accessToken = { 87 + access_token: 88 + "access_token" in result && typeof result.access_token === "string" 89 + ? result.access_token 90 + : throws(new Error("Invalid access_token field")), 91 + token_type: 92 + "access_token" in result && typeof result.access_token === "string" 93 + ? result.access_token 94 + : throws(new Error("Invalid access_token field")), 95 + expires_in: 96 + "expires_in" in result && typeof result.expires_in === "number" 97 + ? result.expires_in 98 + : throws(new Error("Invalid expires_in field")), 99 + refresh_token: 100 + "refresh_token" in result 101 + ? typeof result.refresh_token === "string" 102 + ? result.refresh_token 103 + : throws(new Error("Invalid refresh_token field")) 104 + : refreshToken, 105 + expires: 106 + // get current time 107 + Date.now() - 108 + // subtract 1 minute to account for all possible latency 109 + 60 * 1000 + 110 + // get the expires_in time in ms 111 + ("expires_in" in result && typeof result.expires_in === "number" 112 + ? result.expires_in * 1000 113 + : throws(new Error("Invalid expires_in field"))), 114 + }; 95 115 96 - return { refresh_token: token, ...refresh_token }; 97 - }, 98 - }; 99 - })(); 116 + // write the new refresh token to a file. we dont need to save this one 117 + fs.writeFile("./.refreshToken", this.accessToken.refresh_token, { 118 + encoding: "utf8", 119 + }); 100 120 101 - // MAIN LOGIC HERE 102 - // try load last known refresh token from file and use it to generate new token 103 - await refresh.get(); 104 - const accessToken = await refresh.reload(); 105 - if (!accessToken) throw "Could not load access token"; 121 + return this.accessToken; 122 + } 106 123 107 - export const sdk = new SpotifyApi( 108 - new ProvidedAccessTokenStrategy( 109 - SPOTIFY_CLIENT_ID, 110 - accessToken, 111 - async (_, prev) => { 112 - const token = await refresh.reload(); 113 - if (!token) return prev; // will cause issues but probably more robust 114 - return token; 115 - }, 116 - ), 117 - { 118 - errorHandler: new (class implements IHandleErrors { 119 - // log the error and pretend it was handled 120 - // this way it always returns null 121 - async handleErrors(error: any): Promise<boolean> { 122 - console.error(error); 123 - return true 124 - } 125 - })(), 126 - }, 127 - ); 124 + /** get the current stored token. null means no token is stored */ 125 + async getAccessToken(): Promise<AccessToken | null> { 126 + return this.accessToken; 127 + } 128 + 129 + /** delete the access token */ 130 + removeAccessToken(): void { 131 + this.accessToken = null; 132 + } 133 + } 134 + 135 + class ErrHandler implements IHandleErrors { 136 + async handleErrors(error: any): Promise<boolean> { 137 + console.trace("sdk threw error:", error); 138 + // always claim its handled so it gets a null value 139 + return true; 140 + } 141 + } 142 + 143 + export const sdk = new SpotifyApi(new Auth(), { 144 + errorHandler: new ErrHandler(), 145 + });