Browse Source

add /api/music Spotify endpoint (top tracks and now playing)

pull/418/head
Jake Jarvis 8 months ago
parent
commit
96a644da85
Signed by: jake GPG Key ID: 2B0C9CF251E69A39
  1. 23
      .env.example
  2. 10
      .github/dependabot.yml
  3. 2
      api/hits.ts
  4. 163
      api/music.ts
  5. 6
      api/projects.ts
  6. 17
      api/stats.ts
  7. 2
      package.json
  8. 10
      yarn.lock

23
.env.example

@ -1,17 +1,12 @@
ALGOLIA_APP_ID=
ALGOLIA_API_KEY=
ALGOLIA_INDEX_NAME=
ALGOLIA_INDEX_FILE=
ALGOLIA_BASE_URL=
LHCI_SERVER_BASE_URL=
LHCI_TOKEN=
PERCY_TOKEN=
WEBMENTIONS_TOKEN=
FAUNADB_ADMIN_SECRET=
FAUNADB_SERVER_SECRET=
GH_PUBLIC_TOKEN=
SPOTIFY_REFRESH_TOKEN=
SPOTIFY_CLIENT_SECRET=
SPOTIFY_CLIENT_ID=
WEBMENTIONS_TOKEN=
PERCY_TOKEN=
LHCI_SERVER_BASE_URL=
LHCI_TOKEN=
LHCI_ADMIN_TOKEN=
LHCI_GITHUB_APP_TOKEN=

10
.github/dependabot.yml

@ -7,17 +7,13 @@ updates:
interval: daily
versioning-strategy: increase
ignore:
- dependency-name: "faunadb"
- dependency-name: "hugo-extended"
- dependency-name: "@types/*"
- dependency-name: "@fontsource/*"
commit-message:
prefix: "📦 npm:"
- package-ecosystem: docker
directory: "/"
schedule:
interval: daily
commit-message:
prefix: "📦 docker:"
- package-ecosystem: github-actions
directory: "/"
schedule:

2
api/hits.ts

@ -50,7 +50,7 @@ export default async (req: VercelRequest, res: VercelResponse) => {
res.setHeader("Access-Control-Allow-Origin", "*");
// send client the *new* hit count
res.json(hits);
res.status(200).json(hits);
} catch (error) {
console.error(error);

163
api/music.ts

@ -0,0 +1,163 @@
"use strict";
// Heavily inspired by @leerob: https://leerob.io/snippets/spotify
import { VercelRequest, VercelResponse } from "@vercel/node";
import fetch from "node-fetch";
import querystring from "querystring";
const { SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, SPOTIFY_REFRESH_TOKEN } = process.env;
const basic = Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString("base64");
const TOKEN_ENDPOINT = `https://accounts.spotify.com/api/token`;
// https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-the-users-currently-playing-track
const NOW_PLAYING_ENDPOINT = `https://api.spotify.com/v1/me/player/currently-playing`;
// https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-users-top-artists-and-tracks
const TOP_TRACKS_ENDPOINT = `https://api.spotify.com/v1/me/top/tracks?time_range=long_term&limit=10`;
type TrackSchema = {
name: string;
artists: Array<{
name: string;
}>;
album: {
name: string;
images: Array<{
url: string;
}>;
};
imageUrl?: string;
external_urls: {
spotify: string;
};
};
type Track = {
isPlaying: boolean;
artist?: string;
title?: string;
album?: string;
imageUrl?: string;
songUrl?: string;
};
const getAccessToken = async () => {
const response = await fetch(TOKEN_ENDPOINT, {
method: "POST",
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: querystring.stringify({
grant_type: "refresh_token",
refresh_token: SPOTIFY_REFRESH_TOKEN,
}),
});
return response.json();
};
const getNowPlaying = async (): Promise<Track> => {
const { access_token } = await getAccessToken();
const response = await fetch(NOW_PLAYING_ENDPOINT, {
headers: {
Authorization: `Bearer ${access_token}`,
Accept: "application/json",
"Content-Type": "application/json",
},
});
type Activity = {
is_playing: boolean;
item?: TrackSchema;
};
if (response.status === 204 || response.status > 400) {
return { isPlaying: false };
}
const active: Activity = await response.json();
if (active.is_playing === true && active.item) {
const isPlaying = active.is_playing;
const artist = active.item.artists.map((_artist) => _artist.name).join(", ");
const title = active.item.name;
const album = active.item.album.name;
const imageUrl = active.item.album.images[0].url;
const songUrl = active.item.external_urls.spotify;
return {
isPlaying,
artist,
title,
album,
imageUrl,
songUrl,
};
} else {
return { isPlaying: false };
}
};
const getTopTracks = async (): Promise<Track[]> => {
const { access_token } = await getAccessToken();
const response = await fetch(TOP_TRACKS_ENDPOINT, {
headers: {
Authorization: `Bearer ${access_token}`,
Accept: "application/json",
"Content-Type": "application/json",
},
});
const { items } = await response.json();
const tracks: Track[] = items.map((track: TrackSchema) => ({
artist: track.artists.map((_artist) => _artist.name).join(", "),
title: track.name,
album: track.album.name,
imageUrl: track.album.images[0].url,
songUrl: track.external_urls.spotify,
}));
return tracks;
};
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export default async (req: VercelRequest, res: VercelResponse) => {
try {
// some rudimentary error handling
if (req.method !== "GET") {
throw new Error(`Method ${req.method} not allowed.`);
}
if (!process.env.SPOTIFY_CLIENT_ID || !process.env.SPOTIFY_CLIENT_SECRET || !process.env.SPOTIFY_REFRESH_TOKEN) {
throw new Error("Spotify API credentials aren't set.");
}
// default to top tracks
let response;
// get currently playing track (/music/?now), otherwise top 10 tracks
if (typeof req.query.now !== "undefined") {
response = await getNowPlaying();
// let Vercel edge cache results for 5 mins
res.setHeader("Cache-Control", "public, s-maxage=300, stale-while-revalidate");
} else {
response = await getTopTracks();
// let Vercel edge cache results for 3 hours
res.setHeader("Cache-Control", "public, s-maxage=10800, stale-while-revalidate");
}
res.setHeader("Access-Control-Allow-Methods", "GET");
res.setHeader("Access-Control-Allow-Origin", "*");
res.status(200).json(response);
} catch (error) {
console.error(error);
res.status(400).json({ message: error.message });
}
};

6
api/projects.ts

@ -70,7 +70,7 @@ async function fetchRepos(sort: string, limit: number) {
};
const response = await client.request(query, { sort, limit });
const currentRepos: Array<Repository> = response.user.repositories.edges.map(
const currentRepos: Repository[] = response.user.repositories.edges.map(
({ node: repo }: { [key: string]: Repository }) => ({
...repo,
description: escape(repo.description),
@ -96,7 +96,7 @@ export default async (req: VercelRequest, res: VercelResponse) => {
// default to latest repos
let sortBy = "PUSHED_AT";
// get most popular repos (/projects?top)
// get most popular repos (/projects/?top)
if (typeof req.query.top !== "undefined") sortBy = "STARGAZERS";
const repos = await fetchRepos(sortBy, 16);
@ -106,7 +106,7 @@ export default async (req: VercelRequest, res: VercelResponse) => {
res.setHeader("Access-Control-Allow-Methods", "GET");
res.setHeader("Access-Control-Allow-Origin", "*");
res.json(repos);
res.status(200).json(repos);
} catch (error) {
console.error(error);

17
api/stats.ts

@ -38,11 +38,6 @@ export default async (req: VercelRequest, res: VercelResponse) => {
),
]);
type SiteStats = {
hits: number;
pretty_hits?: string;
pretty_unit?: string;
};
type PageStats = {
title: string;
url: string;
@ -53,11 +48,15 @@ export default async (req: VercelRequest, res: VercelResponse) => {
pretty_unit: string;
};
type OverallStats = {
total: SiteStats;
pages: Array<PageStats>;
total: {
hits: number;
pretty_hits?: string;
pretty_unit?: string;
};
pages: PageStats[];
};
const pages: Array<PageStats> = result.data;
const pages: PageStats[] = result.data;
const stats: OverallStats = {
total: { hits: 0 },
pages,
@ -97,7 +96,7 @@ export default async (req: VercelRequest, res: VercelResponse) => {
res.setHeader("Access-Control-Allow-Methods", "GET");
res.setHeader("Access-Control-Allow-Origin", "*");
res.json(stats);
res.status(200).json(stats);
} catch (error) {
console.error(error);

2
package.json

@ -44,6 +44,7 @@
"node-fetch": "^2.6.1",
"numeral": "^2.0.6",
"pluralize": "^8.0.0",
"querystring": "^0.2.1",
"rss-parser": "^3.12.0",
"twemoji": "13.1.0",
"twemoji-emojis": "13.1.0"
@ -59,6 +60,7 @@
"@types/node-fetch": "^2.5.10",
"@types/numeral": "^2.0.1",
"@types/pluralize": "^0.0.29",
"@types/twemoji": "^12.1.1",
"@types/xml2js": "^0.4.8",
"@typescript-eslint/eslint-plugin": "^4.26.0",
"@typescript-eslint/parser": "^4.26.0",

10
yarn.lock

@ -1108,6 +1108,11 @@
resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.4.tgz#15925414e0ad2cd765bfef58842f7e26a7accb24"
integrity sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug==
"@types/twemoji@^12.1.1":
version "12.1.1"
resolved "https://registry.yarnpkg.com/@types/twemoji/-/twemoji-12.1.1.tgz#34c5dcecff438b5be173889a6ee8ad51ba90445f"
integrity sha512-dW1B1WHTfrWmEzXb/tp8xsZqQHAyMB9JwLwbBqkIQVzmNUI02R7lJqxUpKFM114ygNZHKA1r74oPugCAiYHt1A==
"@types/unist@*", "@types/unist@^2.0.0", "@types/unist@^2.0.2":
version "2.0.3"
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e"
@ -6190,6 +6195,11 @@ query-string@^5.0.1:
object-assign "^4.1.0"
strict-uri-encode "^1.0.0"
querystring@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.1.tgz#40d77615bb09d16902a85c3e38aa8b5ed761c2dd"
integrity sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==
queue-microtask@^1.2.2:
version "1.2.3"
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"

Loading…
Cancel
Save