Repository navigation
Stream Redis-backed Spotify data and reserve homepage track space #174
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,7 @@ export type NowPlayingTrack = { | |
|
|
||
| const SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token"; | ||
| const SPOTIFY_API_BASE = "https://api.spotify.com/v1"; | ||
| const SPOTIFY_REQUEST_TIMEOUT_MS = 3000; | ||
|
|
||
| export async function getAccessToken(): Promise<string | null> { | ||
| const clientId = process.env.SPOTIFY_CLIENT_ID; | ||
|
|
@@ -29,13 +30,14 @@ export async function getAccessToken(): Promise<string | null> { | |
|
|
||
| const res = await fetch(SPOTIFY_TOKEN_URL, { | ||
| cache: "no-store", | ||
| signal: AbortSignal.timeout(SPOTIFY_REQUEST_TIMEOUT_MS), | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/x-www-form-urlencoded" }, | ||
| body: body.toString(), | ||
| }); | ||
|
|
||
| if (!res.ok) { | ||
| return null; | ||
| throw new Error(`Spotify request failed (${res.status})`); | ||
| } | ||
|
|
||
| const data = (await res.json()) as { access_token?: string }; | ||
|
|
@@ -62,13 +64,18 @@ function normalizeTrack(item: { | |
| export async function getCurrentlyPlaying(accessToken: string): Promise<NowPlayingTrack | null> { | ||
| const res = await fetch(`${SPOTIFY_API_BASE}/me/player/currently-playing`, { | ||
| cache: "no-store", | ||
| signal: AbortSignal.timeout(SPOTIFY_REQUEST_TIMEOUT_MS), | ||
| headers: { Authorization: `Bearer ${accessToken}` }, | ||
| }); | ||
|
|
||
| if (res.status === 204 || !res.ok) { | ||
| if (res.status === 204) { | ||
| return null; | ||
| } | ||
|
|
||
| if (!res.ok) { | ||
| throw new Error(`Spotify request failed (${res.status})`); | ||
| } | ||
|
|
||
| const data = (await res.json()) as { | ||
| item?: { | ||
| name: string; | ||
|
|
@@ -88,11 +95,12 @@ export async function getCurrentlyPlaying(accessToken: string): Promise<NowPlayi | |
| export async function getRecentlyPlayed(accessToken: string, limit = 1): Promise<NowPlayingTrack | null> { | ||
| const res = await fetch(`${SPOTIFY_API_BASE}/me/player/recently-played?limit=${limit}`, { | ||
| cache: "no-store", | ||
| signal: AbortSignal.timeout(SPOTIFY_REQUEST_TIMEOUT_MS), | ||
| headers: { Authorization: `Bearer ${accessToken}` }, | ||
| }); | ||
|
|
||
| if (!res.ok) { | ||
| return null; | ||
| throw new Error(`Spotify request failed (${res.status})`); | ||
| } | ||
|
|
||
| const data = (await res.json()) as { | ||
|
|
@@ -115,23 +123,21 @@ export async function getRecentlyPlayed(accessToken: string, limit = 1): Promise | |
| } | ||
|
|
||
| export async function getNowPlaying(): Promise<NowPlayingTrack | null> { | ||
| const accessToken = await getAccessToken(); | ||
| if (!accessToken) { | ||
| return null; | ||
| } | ||
|
|
||
| if (process.env.NODE_ENV === "production") { | ||
| const recentlyPlayedCache = await redis.get<NowPlayingTrack | null>(getSpotifyRecentlyPlayedRedisKey()); | ||
| if (recentlyPlayedCache) { | ||
| return recentlyPlayedCache; | ||
| const cachedTrack = await redis.get<NowPlayingTrack | null>(getSpotifyRecentlyPlayedRedisKey()); | ||
| if (cachedTrack) { | ||
| return cachedTrack; | ||
|
Comment on lines
+127
to
+129
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In production, this cache lookup now occurs before confirming that the optional Spotify credentials are configured. If a deployment removes or incompletely configures those variables, an old listening record can continue to be published until the Redis TTL expires; with an empty cache, the disabled widget also performs a needless Redis operation. The previous ordering returned AGENTS.md reference: AGENTS.md:L95-L97 Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
|
|
||
| let track: NowPlayingTrack | null = await getCurrentlyPlaying(accessToken); | ||
| if (!track) { | ||
| track = await getRecentlyPlayed(accessToken, 1); | ||
| const accessToken = await getAccessToken(); | ||
| if (!accessToken) { | ||
| return null; | ||
| } | ||
|
|
||
| const currentTrack = await getCurrentlyPlaying(accessToken); | ||
| const track = currentTrack ?? (await getRecentlyPlayed(accessToken, 1)); | ||
|
|
||
| if (process.env.NODE_ENV === "production") { | ||
| await redis.set(getSpotifyRecentlyPlayedRedisKey(), track, SPOTIFY_RECENTLY_PLAYED_TTL_S); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the refresh token grants
user-read-recently-playedbut not the distinctuser-read-currently-playingscope, this optional current-track request can return 403 even though the recently-played request remains authorized; Spotify documents the endpoints with separate required scopes (current track, recent tracks). Throwing here preventsgetNowPlaying()from reaching its recently-played fallback, whereas the previous implementation treated a non-OK current-track response as a miss, so preserve that fallback for this case.Useful? React with 👍 / 👎.