diff --git a/src/components/home/playing/spotify/index.ts b/src/components/home/playing/spotify/index.ts index 3301797..47797cc 100644 --- a/src/components/home/playing/spotify/index.ts +++ b/src/components/home/playing/spotify/index.ts @@ -1,127 +1,145 @@ /** * types and logic for getting now playing information */ - import { + DefaultResponseDeserializer, + DefaultResponseValidator, + DocumentLocationRedirectionStrategy, + InMemoryCachingStrategy, + NoOpErrorHandler, SpotifyApi, type AccessToken, + type IAuthStrategy, type IHandleErrors, + type SdkConfiguration, } from "@spotify/web-api-ts-sdk"; -import { ProvidedAccessTokenStrategy } from "@spotify/web-api-ts-sdk"; import { SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET } from "astro:env/server"; -import fs from "node:fs/promises"; -import { isObj, type Prettify } from "/utils"; - -/** - * the refresh_token field is not checked as it might not be returned from the api - */ -const isSpotifyAccessToken = ( - token: any, - // token could have an ommited refresh token field -): token is Prettify< - Omit & { refresh_token?: string } -> => - isObj(token) && - "access_token" in token && - typeof token.access_token === "string" && - "token_type" in token && - token.token_type === "Bearer" && - "expires_in" in token && - typeof token.expires_in === "number" && - ("refresh_token" in token ? typeof token.refresh_token === "string" : true); - -const refresh = (() => { - let token = ""; - - return { - async get() { - const refresh = - token === "" - ? await fs - .readFile("./.refreshToken", { encoding: "utf-8" }) - .catch((err) => console.error(err)) - : token; - - if (!refresh) throw "Could not load refresh token"; - this.set(refresh); // dispatch update whenever its loaded to make sure its up to date - return refresh; - }, - - // by making this not async it makes sure the token update is blocking - set(arg_token: string) { - token = arg_token; - - return fs - .writeFile("./.refreshToken", token, { - encoding: "utf-8", - }) - .catch((err) => - console.warn("Could not write to ./.refreshToken:", err), - ); - }, - - async reload() { - const res = await fetch("https://accounts.spotify.com/api/token", { - method: "post", - headers: { - "content-type": "application/x-www-form-urlencoded", - Authorization: - "Basic " + - Buffer.from( - SPOTIFY_CLIENT_ID + ":" + SPOTIFY_CLIENT_SECRET, - ).toString("base64"), - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: await this.get(), - }).toString(), - }).catch((err) => console.error(err)); - - if (!res) return res; - - const refresh_token = await res - .json() - .then((token) => - isSpotifyAccessToken(token) - ? token - : console.error("Response was not a valid access token:", token), - ); - - if (!refresh_token) return refresh_token; - - // update the refresh token if it exists - refresh_token.refresh_token && this.set(refresh_token.refresh_token); - - return { refresh_token: token, ...refresh_token }; - }, - }; -})(); - -// MAIN LOGIC HERE -// try load last known refresh token from file and use it to generate new token -await refresh.get(); -const accessToken = await refresh.reload(); -if (!accessToken) throw "Could not load access token"; - -export const sdk = new SpotifyApi( - new ProvidedAccessTokenStrategy( - SPOTIFY_CLIENT_ID, - accessToken, - async (_, prev) => { - const token = await refresh.reload(); - if (!token) return prev; // will cause issues but probably more robust - return token; - }, - ), - { - errorHandler: new (class implements IHandleErrors { - // log the error and pretend it was handled - // this way it always returns null - async handleErrors(error: any): Promise { - console.error(error); - return true - } - })(), - }, -); +import fs from "fs/promises"; +import { isObj, throws, type Prettify } from "/utils"; + +type AuthAccessToken = Prettify; + +const defaultConfig: SdkConfiguration = { + fetch: (req: string | Request | URL, init?: RequestInit) => fetch(req, init), + beforeRequest: () => {}, + afterRequest: () => {}, + deserializer: new DefaultResponseDeserializer(), + responseValidator: new DefaultResponseValidator(), + errorHandler: new NoOpErrorHandler(), + redirectionStrategy: new DocumentLocationRedirectionStrategy(), + cachingStrategy: new InMemoryCachingStrategy(), +}; + +class Auth implements IAuthStrategy { + config: SdkConfiguration = { ...defaultConfig }; + + accessToken: AuthAccessToken | null = null; + + /** update the stored config with the new config */ + setConfiguration(config: SdkConfiguration): void { + this.config = { ...defaultConfig, ...config }; + } + + /** return the stored access token. if no token is stored, authenticate + * will always include the `expires` field + */ + async getOrCreateAccessToken(): Promise { + // if the token is stored and the token hasn't yet expired, return it + if (this.accessToken && this.accessToken.expires > Date.now()) + return this.accessToken; + + // no token is stored OR the token has expired, so we ignore the stored token and refresh it + // if we cannot read the file there is no way to recover this and we should let the error throw + // this will get caught in the `catch` branch of SpotifyAPI.makeRequest() and handled by the handler + const refreshToken = await fs.readFile("./.refreshToken", { + encoding: "utf8", + }); + + const res = await fetch("https://accounts.spotify.com/api/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: + "Basic " + + Buffer.from(SPOTIFY_CLIENT_ID + ":" + SPOTIFY_CLIENT_SECRET).toString( + "base64", + ), + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + }).toString(), + }); + + // if its not a 200 code, throw + if (!res.ok) + throw new Error( + `Token request recived error ${res.status}: ${res.statusText}`, + ); + + // dont handle the exception, this is covered by the user handler + const result = await res.json(); + // check the result is formed correctly + if (!isObj(result)) throw new Error("Didnt recive an object from spotify"); + + this.accessToken = { + access_token: + "access_token" in result && typeof result.access_token === "string" + ? result.access_token + : throws(new Error("Invalid access_token field")), + token_type: + "access_token" in result && typeof result.access_token === "string" + ? result.access_token + : throws(new Error("Invalid access_token field")), + expires_in: + "expires_in" in result && typeof result.expires_in === "number" + ? result.expires_in + : throws(new Error("Invalid expires_in field")), + refresh_token: + "refresh_token" in result + ? typeof result.refresh_token === "string" + ? result.refresh_token + : throws(new Error("Invalid refresh_token field")) + : refreshToken, + expires: + // get current time + Date.now() - + // subtract 1 minute to account for all possible latency + 60 * 1000 + + // get the expires_in time in ms + ("expires_in" in result && typeof result.expires_in === "number" + ? result.expires_in * 1000 + : throws(new Error("Invalid expires_in field"))), + }; + + // write the new refresh token to a file. we dont need to save this one + fs.writeFile("./.refreshToken", this.accessToken.refresh_token, { + encoding: "utf8", + }); + + return this.accessToken; + } + + /** get the current stored token. null means no token is stored */ + async getAccessToken(): Promise { + return this.accessToken; + } + + /** delete the access token */ + removeAccessToken(): void { + this.accessToken = null; + } +} + +class ErrHandler implements IHandleErrors { + async handleErrors(error: any): Promise { + console.trace("sdk threw error:", error); + // always claim its handled so it gets a null value + return true; + } +} + +export const sdk = new SpotifyApi(new Auth(), { + errorHandler: new ErrHandler(), +});