···11-import fs from "fs/promises";
22-import {
33- SPOTIFY_CLIENT_ID,
44- SPOTIFY_CLIENT_SECRET,
55- SPOTIFY_REDIRECT_URI,
66-} from "astro:env/server";
77-import { SpotifyError } from "./errors";
88-import { throws } from "/utils";
99-import { isAuthToken, isRefreshToken } from "./types";
1010-1111-/**
1212- * Get an access code which can be used to authenticate requests on behalf of the user.
1313- * @param userAuthCode Authentication code for the user (via callback). Uses the stored refresh token if not provided
1414- * @returns `string`: access code to authorize requests
1515- * @returns `undefined`: failed to authenticate user.
1616- * @returns `SpotofyError<NETWORK_ERR>` when a network error occours and the fetch request fails.
1717- * @throws `SpotifyError<NO_AUTH>` when no refresh token is stored and no auth code is provided
1818- */
1919-export default async function getAccessCode(userAuthCode?: string) {
2020- const refreshToken = await fs
2121- .readFile("./.refreshToken", { encoding: "utf-8" })
2222- .catch((_) => undefined)
2323- .then((x) => (x === "" || x === "REFRESH_TOKEN" ? undefined : x));
2424- if (!(userAuthCode || refreshToken))
2525- throw new SpotifyError(
2626- "NO_AUTH",
2727- null,
2828- `No auth code or refresh token.
2929-Generate an auth code at \`/src/pages/_callback\`
3030-A refresh token will be generated from this auth token.`,
3131- );
3232-3333- // prefer auth codes over refresh tokens
3434- // since the auth code may have updated scopes.
3535-3636- const accessFrom:
3737- | {
3838- userAuthCode: string;
3939- }
4040- | {
4141- refreshToken: string;
4242- } = userAuthCode
4343- ? { userAuthCode }
4444- : refreshToken
4545- ? { refreshToken }
4646- : (undefined as never);
4747-4848- const req = fetch("https://accounts.spotify.com/api/token", {
4949- method: "POST",
5050- headers: {
5151- "Content-Type": "application/x-www-form-urlencoded",
5252- Authorization: `Basic ${Buffer.from(SPOTIFY_CLIENT_ID + ":" + SPOTIFY_CLIENT_SECRET).toString("base64")}`,
5353- },
5454- body: new URLSearchParams({
5555- grant_type:
5656- "userAuthCode" in accessFrom ? "authorization_code" : "refresh_token",
5757- ...("userAuthCode" in accessFrom
5858- ? {
5959- code: accessFrom.userAuthCode,
6060- redirect_uri: SPOTIFY_REDIRECT_URI,
6161- }
6262- : {
6363- refresh_token: accessFrom.refreshToken,
6464- }),
6565- }).toString(),
6666- });
6767-6868- return (
6969- req
7070- // if res isn't 200 handle it in the catch
7171- .then((res) => (res.ok ? res : throws(res)))
7272- // request is 200-299
7373- // json can throw SyntaxError in this case
7474- .then((res) => res.json())
7575- .then((res) => {
7676- if ("userAuthCode" in accessFrom) {
7777- if (isAuthToken(res)) {
7878- return {
7979- code: res.access_token,
8080- refresh: res.refresh_token,
8181- };
8282- }
8383- } else {
8484- if (isRefreshToken(res)) {
8585- return {
8686- code: res.access_token,
8787- refresh: res.refresh_token ?? accessFrom.refreshToken,
8888- };
8989- }
9090- }
9191- throw new SpotifyError(
9292- "INVALID_AUTH_RES",
9393- res,
9494- "Could not parse access token response",
9595- );
9696- })
9797- // res is now an access token and refresh token
9898- .then((res) => {
9999- fs.writeFile("./.refreshToken", res.refresh, { encoding: "utf-8" });
100100- return res.code;
101101- })
102102- .catch((err) => {
103103- // SyntaxError
104104- // Response
105105- // SpotifyError<"INVALID_AUTH_RES">
106106- if (err instanceof Response) console.error("access.ts", "Request failed:", err);
107107- else if (err instanceof SyntaxError)
108108- console.error("access.ts", "Response JSON failed", err);
109109- else if (err instanceof SpotifyError && err.code === "INVALID_AUTH_RES")
110110- console.error("access.ts", "Response malformed:", err);
111111- else if (err instanceof TypeError) {
112112- console.error("access.ts", "A network error occurred.", err);
113113- return new SpotifyError(
114114- "NETWORK_ERR",
115115- err,
116116- "Network error occurred. Could not reach spotify servers or something else.",
117117- );
118118- } else {
119119- console.error("access.ts", "Unhandled exception.");
120120- throw err;
121121- }
122122- })
123123- );
124124-}
-185
src/components/home/playing/spotify/api.ts
···11-import getAccessCode from "./access";
22-import { SpotifyError } from "./errors";
33-import { isNowPlaying, type nowPlaying } from "./types";
44-import { isObj, throws } from "/utils";
55-66-/**
77- * Wrapper for authorizing a spotify API with default headers etc
88- * @param url API endpoint to call. Pass a leading slash
99- * @returns `Response`
1010- * @throws `SpotifyError<NETWORK_ERR>` when a fetch request fails
1111- * @throws `SpotifyError<NO_AUTH>` when auth fails
1212- * @throws `Response` on non 200-299 status codes
1313- */
1414-export async function getSpotifyApi(url: string) {
1515- // get the access code
1616- const accessToken = await getAccessCode();
1717- // check its valid
1818- if (!accessToken)
1919- throw new SpotifyError(
2020- "NO_AUTH",
2121- null,
2222- "Failed to get access code. try using src/pages/_callback",
2323- );
2424-2525- if (accessToken instanceof SpotifyError) throw accessToken;
2626-2727- // fetch the api and throw on non 2** code
2828- return fetch(`https://api.spotify.com/v1${url}`, {
2929- headers: {
3030- Authorization: `Bearer ${accessToken}`,
3131- },
3232- })
3333- .catch((err) =>
3434- err instanceof TypeError
3535- ? throws(
3636- new SpotifyError("NETWORK_ERR", err, "Spotify API request failed"),
3737- )
3838- : throws(err),
3939- )
4040- .then((res) => (res.ok ? res : throws(res)));
4141-}
4242-/**
4343- * Get the current playing track
4444- * @returns `nowPlaying`
4545- * @throws `SpotifyError` of NO_AUTH | UNHANDLED_API_ERR | INVALID_AUTH_RES | RATE_LIMITED | NO_CONTENT | MALFORMED_SPOTIFY_RES | NETWORK_ERR
4646- */
4747-export async function spotifyNowPlaying() {
4848- type success = nowPlaying;
4949- let res: (v: success) => void, rej: (v: unknown) => void;
5050- const output = new Promise<success>((_res, _rej) => {
5151- ((res = _res), (rej = _rej));
5252- });
5353- const nowPlaying = getSpotifyApi("/me/player/currently-playing");
5454-5555- // auth failed
5656- nowPlaying.catch((err) => {
5757- if (err instanceof SpotifyError && err.code === "NO_AUTH") {
5858- console.error("api.ts", "Authentication failed:", err.human);
5959- rej(err);
6060- } else if (err instanceof SpotifyError && err.code === "NETWORK_ERR") {
6161- console.error("api.ts", "Network request failed:", err.human);
6262- rej(err)
6363- }
6464- });
6565-6666- /**
6767- * request failed.
6868- * https://developer.spotify.com/documentation/web-api/concepts/api-calls
6969- * 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.
7070- * 401 Unauthorized - The request requires user authentication or, if the request included authorization credentials, authorization has been refused for those credentials.
7171- * 403 Forbidden - The server understood the request, but is refusing to fulfill it.
7272- * 404 Not Found - The requested resource could not be found. This error can be due to a temporary or permanent condition.
7373- * 429 Too Many Requests - Rate limiting has been applied.
7474- * 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.
7575- * 502 Bad Gateway - The server was acting as a gateway or proxy and received an invalid response from the upstream server.
7676- * 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.
7777- */
7878- nowPlaying.catch((res) => {
7979- switch (res.status) {
8080- // handle req error
8181- case 400: {
8282- rej(new SpotifyError("UNHANDLED_API_ERR", res, "400: Bad request"));
8383- break;
8484- }
8585- case 401: {
8686- rej(new SpotifyError("INVALID_AUTH_RES", res, "401: Unauthorized"));
8787- break;
8888- }
8989- case 403: {
9090- rej(new SpotifyError("UNHANDLED_API_ERR", res, "403: Forbidden"));
9191- break;
9292- }
9393- case 404: {
9494- rej(new SpotifyError("UNHANDLED_API_ERR", res, "404: Not found"));
9595- break;
9696- }
9797- case 429: {
9898- rej(new SpotifyError("RATE_LIMITED", res, "429: Rate Limited"));
9999- break;
100100- }
101101- case 500: {
102102- rej(new SpotifyError("UNHANDLED_API_ERR", res, "500: Internal Error"));
103103- break;
104104- }
105105- case 502: {
106106- rej(new SpotifyError("UNHANDLED_API_ERR", res, "502: Bad Gateway"));
107107- break;
108108- }
109109- case 503: {
110110- rej(
111111- new SpotifyError(
112112- "UNHANDLED_API_ERR",
113113- res,
114114- "503: Service Unavaliable",
115115- ),
116116- );
117117- break;
118118- }
119119- }
120120- });
121121-122122- /**
123123- * request succeeded
124124- * https://developer.spotify.com/documentation/web-api/concepts/api-calls
125125- * 200 OK - The request has succeeded. The client can read the result of the request in the body and the headers of the response.
126126- * 201 Created - The request has been fulfilled and resulted in a new resource being created.
127127- * 202 Accepted - The request has been accepted for processing, but the processing has not been completed.
128128- * 204 No Content - The request has succeeded but returns no message body.
129129- */
130130- nowPlaying
131131- .then((res) => {
132132- if (res instanceof Error) return;
133133- switch (res.status) {
134134- // handle 200 codes
135135- case 200: {
136136- return res;
137137- }
138138- case 201: {
139139- rej(new SpotifyError("UNHANDLED_API_ERR", res, "201: Created"));
140140- return;
141141- }
142142- case 202: {
143143- rej(new SpotifyError("UNHANDLED_API_ERR", res, "202: Accepted"));
144144- return;
145145- }
146146- case 204: {
147147- rej(new SpotifyError("NO_CONTENT", res, "204: No Content"));
148148- return;
149149- }
150150- }
151151- })
152152- .then(async (resp) => {
153153- // quit early if it rejected last time
154154- if (!resp) return;
155155- try {
156156- const json = await resp
157157- .json()
158158- .then((res) =>
159159- isObj(res) && "item" in res && isObj(res.item)
160160- ? res.item
161161- : throws("Item field missing"),
162162- );
163163-164164- // verify structure
165165- if (!isNowPlaying(json)) {
166166- rej(
167167- new SpotifyError(
168168- "MALFORMED_SPOTIFY_RES",
169169- json,
170170- "Response missing required fields.",
171171- ),
172172- );
173173- return;
174174- }
175175-176176- res(json);
177177- } catch (e) {
178178- rej(
179179- new SpotifyError("MALFORMED_SPOTIFY_RES", e, "Could not parse JSON."),
180180- );
181181- }
182182- });
183183-184184- return output;
185185-}
+3-2
src/components/home/playing/spotify/client.ts
···11-export * from "./types";
22-export * from "./errors";
11+/**
22+ * types & type guards for front end logic when received from now-playing-sse
33+ */
···11-import getAccessCode from "./access";
22-export { getAccessCode };
33-export * from "./errors";
44-export * from "./api";
55-export * from "./types";
11+/**
22+ * types and logic for getting now playing information
33+ */
44+
-104
src/components/home/playing/spotify/types.ts
···11-import { isObj, type Prettify } from "/utils";
22-33-export type AuthToken = {
44- access_token: string;
55- token_type: "Bearer";
66- scope: string;
77- expires_in: number;
88- refresh_token: string;
99-};
1010-1111-export type RefreshToken = Prettify<
1212- Omit<AuthToken, "refresh_token"> & { refresh_token?: string }
1313->;
1414-1515-export function isRefreshToken(obj: unknown): obj is RefreshToken {
1616- return (
1717- // validate is object
1818- typeof obj === "object" &&
1919- obj !== null &&
2020- // validate properties
2121- "access_token" in obj &&
2222- typeof obj.access_token === "string" &&
2323- "token_type" in obj &&
2424- obj.token_type === "Bearer" &&
2525- "scope" in obj &&
2626- typeof obj.scope === "string" &&
2727- "expires_in" in obj &&
2828- typeof obj.expires_in === "number" &&
2929- // either refresh token exists as string or not at all
3030- (("refresh_token" in obj && typeof obj.refresh_token === "string") ||
3131- !("refresh_token" in obj))
3232- );
3333-}
3434-3535-// auth token is just refresh with a non optional refresh_token
3636-export function isAuthToken(obj: unknown): obj is AuthToken {
3737- return isRefreshToken(obj) && "refresh_token" in obj;
3838-}
3939-4040-type externalUrls = {
4141- spotify: string;
4242-};
4343-4444-const isExternalUrl = (obj: unknown): obj is externalUrls =>
4545- isObj(obj) && "spotify" in obj && typeof obj.spotify === "string";
4646-4747-export type nowPlaying = null | {
4848- type: "track";
4949- name: string;
5050- id: string;
5151-5252- external_urls: externalUrls;
5353-5454- album: {
5555- external_urls: externalUrls;
5656- name: string;
5757- images: {
5858- url: string;
5959- }[];
6060- };
6161- artists: {
6262- external_urls: externalUrls;
6363- name: string;
6464- }[];
6565-};
6666-6767-export function isNowPlaying(obj: unknown): obj is nowPlaying {
6868- return (
6969- obj === null ||
7070- (isObj(obj) &&
7171- "type" in obj &&
7272- obj.type === "track" &&
7373- "name" in obj &&
7474- typeof obj.name === "string" &&
7575- "id" in obj &&
7676- typeof obj.id === "string" &&
7777- "external_urls" in obj &&
7878- isExternalUrl(obj.external_urls) &&
7979- "album" in obj &&
8080- isObj(obj.album) &&
8181- "external_urls" in obj.album &&
8282- isExternalUrl(obj.album.external_urls) &&
8383- "name" in obj.album &&
8484- typeof obj.album.name === "string" &&
8585- "images" in obj.album &&
8686- Array.isArray(obj.album.images) &&
8787- obj.album.images.reduce(
8888- (acc, curr) =>
8989- acc && isObj(curr) && "url" in curr && typeof curr.url === "string",
9090- true,
9191- ) &&
9292- "artists" in obj &&
9393- Array.isArray(obj.artists) &&
9494- obj.artists.reduce(
9595- (acc, curr) =>
9696- acc &&
9797- "external_urls" in curr &&
9898- isExternalUrl(curr.external_urls) &&
9999- "name" in curr &&
100100- typeof curr.name === "string",
101101- true,
102102- ))
103103- );
104104-}