Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ SUPABASE_SERVICE_ROLE_KEY=

# --- Spotify ------------------------------------------------------------------
# Spotify app + refresh token (scopes: user-read-currently-playing,
# user-read-recently-played). If unset, the now-playing API falls back to
# in-memory + Supabase `listening_stats`.
# user-read-recently-played, user-top-read). If unset, the now-playing API
# falls back to in-memory + Supabase `listening_stats`, and last-month-top
# returns an empty list.
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
SPOTIFY_REFRESH_TOKEN=
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Canonical documentation: **[README.md](README.md)** (setup, routes, APIs, env, C
| Concern | Implementation |
| --- | --- |
| **Now playing** | `GET /api/spotify/now-playing` — Spotify Web API; optional Supabase writes (`listening_*`) with service role |
| **Last month top** | `GET /api/spotify/last-month-top` — Supabase top 5 tracks |
| **Last month top** | `GET /api/spotify/last-month-top` — Spotify top tracks (`short_term`, needs `user-top-read`) |
| **Client polling** | `app/hooks/use-now-playing.ts` — polls every **10s** (`cache: "no-store"`) |
| **GitHub** | `GET /api/github/contributions` — GraphQL calendar; pinned repos in `app/lib/github-pinned.ts` |
| **Weather** | `GET /api/weather` — Open-Meteo (Berkeley); `berkeley-time.tsx` |
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ All handlers under `app/api/`.
| Method & path | Behavior | Caching / env |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
| `GET /api/spotify/now-playing` | Spotify `currently-playing` + `recently-played`; optional Supabase `listening_`* writes while playing; in-memory `lastKnownTrack` fallback | `Cache-Control: public, s-maxage=10, stale-while-revalidate=5`; `SPOTIFY_*`; optional `SUPABASE_*` |
| `GET /api/spotify/last-month-top` | Previous calendar month top **5** tracks from Supabase `listening_history` (`recorded_at`, paginated) | `revalidate = 600`; optional `SUPABASE_*` |
| `GET /api/spotify/last-month-top` | Spotify official top **5** tracks, `time_range=short_term` (~last 4 weeks, computed by Spotify; requires `user-top-read` scope) | `revalidate = 600`; `SPOTIFY_*` |
| `GET /api/github/contributions` | GraphQL contribution calendar (12 months) → `{ weeks, totalContributions }` | `revalidate = 300`; `GITHUB_TOKEN` |
| `GET /api/weather` | Open-Meteo current conditions for Berkeley, CA (`[weather-open-meteo.ts](lib/weather-open-meteo.ts)`) | `revalidate = 600`; no API key |

Expand All @@ -272,7 +272,7 @@ Copy `[.env.example](.env.example)` to `.env.local`. **Never commit secrets.**
| ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase project URL (Spotify listening history) |
| `SUPABASE_SERVICE_ROLE_KEY` | **Server-only** — `listening_history` / `listening_stats` |
| `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` / `SPOTIFY_REFRESH_TOKEN` | Spotify app + refresh token (`user-read-currently-playing`, `user-read-recently-played`) |
| `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` / `SPOTIFY_REFRESH_TOKEN` | Spotify app + refresh token (`user-read-currently-playing`, `user-read-recently-played`, `user-top-read`) |
| `GITHUB_TOKEN` | GitHub API — contributions + pinned repos |
| `GITHUB_LOGIN` | Optional username (default `kaiiiichen`) |
| `NEXT_PUBLIC_SENTRY_DSN` / `SENTRY_DSN` | Optional error reporting |
Expand Down Expand Up @@ -588,7 +588,7 @@ kaichen.dev/
| 方法与路径 | 行为 | 缓存 / 环境变量 |
| ------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `GET /api/spotify/now-playing` | Spotify 当前播放 + 最近播放;播放中可选写入 Supabase `listening_`*;内存 `lastKnownTrack` 兜底 | `Cache-Control: public, s-maxage=10, stale-while-revalidate=5`;`SPOTIFY_*`;可选 `SUPABASE_*` |
| `GET /api/spotify/last-month-top` | 上自然月 Top **5**(Supabase `listening_history`,按 `recorded_at` 筛选并分页) | `revalidate = 600`;可选 `SUPABASE_*` |
| `GET /api/spotify/last-month-top` | Spotify 官方 Top **5**(`time_range=short_term`,约最近 4 周,由 Spotify 计算;需 `user-top-read` scope) | `revalidate = 600`;`SPOTIFY_*` |
| `GET /api/github/contributions` | GraphQL 贡献日历(12 个月)→ `{ weeks, totalContributions }` | `revalidate = 300`;`GITHUB_TOKEN` |
| `GET /api/weather` | Open-Meteo 伯克利当前天气(`[weather-open-meteo.ts](lib/weather-open-meteo.ts)`) | `revalidate = 600`;无需 API key |

Expand All @@ -604,7 +604,7 @@ kaichen.dev/
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase 项目 URL(Spotify 听歌记录) |
| `SUPABASE_SERVICE_ROLE_KEY` | **仅服务端** —— `listening_history` / `listening_stats` |
| `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` / `SPOTIFY_REFRESH_TOKEN` | Spotify 应用 + refresh token(`user-read-currently-playing`、`user-read-recently-played`) |
| `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` / `SPOTIFY_REFRESH_TOKEN` | Spotify 应用 + refresh token(`user-read-currently-playing`、`user-read-recently-played`、`user-top-read`) |
| `GITHUB_TOKEN` | GitHub API —— 贡献日历 + 置顶仓库 |
| `GITHUB_LOGIN` | 可选用户名(默认 `kaiiiichen`) |
| `NEXT_PUBLIC_SENTRY_DSN` / `SENTRY_DSN` | 可选错误上报 |
Expand Down
61 changes: 47 additions & 14 deletions app/api/spotify/last-month-top/route.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,54 @@
import { NextResponse } from "next/server";
import {
createListeningSupabase,
getLastMonthTopFiveTracks,
} from "@/lib/listening-supabase";
getSpotifyAccessToken,
invalidateSpotifyAccessTokenCache,
} from "@/lib/spotify-access-token";
import {
mapSpotifyTopTracks,
type SpotifyTopTracksPayload,
} from "@/lib/spotify-top-tracks";

// Spotify's official "last ~4 weeks" top tracks; requires the `user-top-read` scope.
const TOP_TRACKS_URL = "https://api.spotify.com/v1/me/top/tracks?time_range=short_term&limit=5";

export const revalidate = 600;

const responseHeaders = {
"Cache-Control": "public, s-maxage=600, stale-while-revalidate=120",
} as const;

function fetchTopTracks(accessToken: string) {
return fetch(TOP_TRACKS_URL, {
headers: { Authorization: `Bearer ${accessToken}` },
cache: "no-store",
});
}
Comment on lines +20 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add a timeout to the Spotify fetch.

fetchTopTracks has no AbortController/timeout. If Spotify's API stalls, the request will hang until the platform's function timeout kicks in, tying up the request thread on every retry path (initial call and the 401 retry).

🕐 Proposed fix: add a request timeout
 function fetchTopTracks(accessToken: string) {
-  return fetch(TOP_TRACKS_URL, {
-    headers: { Authorization: `Bearer ${accessToken}` },
-    cache: "no-store",
-  });
+  return fetch(TOP_TRACKS_URL, {
+    headers: { Authorization: `Bearer ${accessToken}` },
+    cache: "no-store",
+    signal: AbortSignal.timeout(5000),
+  });
 }

Also applies to: 34-41

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/spotify/last-month-top/route.ts` around lines 20 - 25, The
fetchTopTracks helper currently has no timeout, so Spotify calls can hang on
both the initial request and the 401 retry path. Update fetchTopTracks to use an
AbortController with a timeout, and make sure the caller in the last-month-top
route passes/handles the same timeout behavior for retries so stalled requests
are aborted promptly.


export async function GET() {
const db = createListeningSupabase();
const tracks = await getLastMonthTopFiveTracks(db);

return NextResponse.json(
{ tracks },
{
headers: {
"Cache-Control": "public, s-maxage=600, stale-while-revalidate=120",
},
}
);
const emptyResponse = () =>
NextResponse.json({ tracks: [] }, { headers: responseHeaders });

const accessToken = await getSpotifyAccessToken();
if (!accessToken) return emptyResponse();

let res = await fetchTopTracks(accessToken);

if (res.status === 401) {
invalidateSpotifyAccessTokenCache();
const retryToken = await getSpotifyAccessToken();
if (!retryToken) return emptyResponse();
res = await fetchTopTracks(retryToken);
}

if (!res.ok) return emptyResponse();

try {
const payload = (await res.json()) as SpotifyTopTracksPayload;
return NextResponse.json(
{ tracks: mapSpotifyTopTracks(payload) },
{ headers: responseHeaders }
);
} catch {
return emptyResponse();
}
}
2 changes: 1 addition & 1 deletion app/components/listening-last-month-top.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useEffect, useState } from "react";
import type { ListeningHighlightTrack } from "@/lib/listening-monthly-top";
import type { ListeningHighlightTrack } from "@/lib/spotify-top-tracks";
import HoverTip from "./hover-tip";
import ListeningTrackRow from "./listening-track-row";
import MagChip from "./mag-chip";
Expand Down
31 changes: 0 additions & 31 deletions lib/listening-monthly-top.test.ts

This file was deleted.

54 changes: 0 additions & 54 deletions lib/listening-monthly-top.ts

This file was deleted.

50 changes: 0 additions & 50 deletions lib/listening-supabase.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
import {
getLastMonthUtcRange,
pickTopFiveTracks,
type ListeningHighlightTrack,
type ListeningHistoryPlayRow,
} from "./listening-monthly-top";

export function createListeningSupabase(): SupabaseClient | null {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
Expand Down Expand Up @@ -34,50 +28,6 @@ export async function getLastPlayedListeningRow(db: SupabaseClient | null | unde
}
}

export async function getLastMonthTopFiveTracks(
db: SupabaseClient | null | undefined
): Promise<ListeningHighlightTrack[]> {
if (!db) return [];
const { start, end } = getLastMonthUtcRange();
try {
const rows = await fetchListeningHistoryRowsInRange(db, start, end);
if (!rows.length) return [];
return pickTopFiveTracks(rows);
} catch {
return [];
}
}

const HISTORY_PAGE_SIZE = 1000;

/** `listening_history` uses `recorded_at` (not `played_at`). Paginate past PostgREST's 1k row cap. */
async function fetchListeningHistoryRowsInRange(
db: SupabaseClient,
start: string,
end: string
) {
const rows: ListeningHistoryPlayRow[] = [];
let offset = 0;

while (true) {
const { data, error } = await db
.from("listening_history")
.select("track_id, track_name, artist_name, album_art, song_url")
.gte("recorded_at", start)
.lt("recorded_at", end)
.range(offset, offset + HISTORY_PAGE_SIZE - 1);

if (error) throw error;
if (!data?.length) break;

rows.push(...data);
if (data.length < HISTORY_PAGE_SIZE) break;
offset += HISTORY_PAGE_SIZE;
}

return rows;
}

type ListeningStatsUpsertPayload = {
track_id: string;
track_name: string;
Expand Down
57 changes: 57 additions & 0 deletions lib/spotify-top-tracks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { mapSpotifyTopTracks } from "./spotify-top-tracks";

describe("mapSpotifyTopTracks", () => {
it("maps items preserving Spotify's affinity order and caps at five", () => {
const payload = {
items: Array.from({ length: 7 }, (_, i) => ({
id: `id-${i}`,
name: `Track ${i}`,
artists: [{ name: "Artist A" }, { name: "Artist B" }],
album: {
images: [
{ url: `https://img/${i}/small`, width: 64, height: 64 },
{ url: `https://img/${i}/large`, width: 640, height: 640 },
],
},
external_urls: { spotify: `https://open.spotify.com/track/id-${i}` },
})),
};

const tracks = mapSpotifyTopTracks(payload);
expect(tracks).toHaveLength(5);
expect(tracks[0]).toEqual({
title: "Track 0",
artist: "Artist A, Artist B",
albumArt: "https://img/0/large",
songUrl: "https://open.spotify.com/track/id-0",
});
expect(tracks.map((t) => t.title)).toEqual([
"Track 0",
"Track 1",
"Track 2",
"Track 3",
"Track 4",
]);
});

it("skips items without an id and builds fallback song URLs", () => {
const tracks = mapSpotifyTopTracks({
items: [
{ name: "No id" },
{ id: "abc", name: "Has id" },
],
});
expect(tracks).toHaveLength(1);
expect(tracks[0]).toEqual({
title: "Has id",
artist: "",
albumArt: "",
songUrl: "https://open.spotify.com/track/abc",
});
});

it("returns an empty list for an empty payload", () => {
expect(mapSpotifyTopTracks({})).toEqual([]);
});
});
38 changes: 38 additions & 0 deletions lib/spotify-top-tracks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {
formatSpotifyArtistNames,
pickAlbumArtFromSpotifyImages,
type SpotifyArtist,
type SpotifyImage,
} from "./spotify-now-playing-helpers";

export type ListeningHighlightTrack = {
title: string;
artist: string;
albumArt: string;
songUrl: string;
};

export type SpotifyTopTracksPayload = {
items?: {
id?: string;
name?: string;
artists?: SpotifyArtist[];
album?: { images?: SpotifyImage[] };
external_urls?: { spotify?: string };
}[];
};

/** Maps Spotify `GET /me/top/tracks` items (already ranked by affinity) to card rows. */
export function mapSpotifyTopTracks(payload: SpotifyTopTracksPayload): ListeningHighlightTrack[] {
return (payload.items ?? [])
.filter((item) => item.id)
.slice(0, 5)
.map((item) => ({
title: item.name ?? "",
artist: formatSpotifyArtistNames(item.artists),
albumArt: pickAlbumArtFromSpotifyImages(item.album?.images),
songUrl:
item.external_urls?.spotify ??
`https://open.spotify.com/track/${encodeURIComponent(item.id!)}`,
}));
}