diff --git a/.env.example b/.env.example index ef4f84f..a564697 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/CLAUDE.md b/CLAUDE.md index f0588e3..d1305cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` | diff --git a/README.md b/README.md index 3ea1111..2f7f9c8 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 | @@ -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 | @@ -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` | 可选错误上报 | diff --git a/app/api/spotify/last-month-top/route.ts b/app/api/spotify/last-month-top/route.ts index 9fabc08..e8f74bc 100644 --- a/app/api/spotify/last-month-top/route.ts +++ b/app/api/spotify/last-month-top/route.ts @@ -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", + }); +} + 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(); + } } diff --git a/app/components/listening-last-month-top.tsx b/app/components/listening-last-month-top.tsx index 55f16f7..b360a7f 100644 --- a/app/components/listening-last-month-top.tsx +++ b/app/components/listening-last-month-top.tsx @@ -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"; diff --git a/lib/listening-monthly-top.test.ts b/lib/listening-monthly-top.test.ts deleted file mode 100644 index 3af7750..0000000 --- a/lib/listening-monthly-top.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { getLastMonthUtcRange, pickTopFiveTracks } from "./listening-monthly-top"; - -describe("getLastMonthUtcRange", () => { - it("returns previous calendar month bounds", () => { - const { start, end } = getLastMonthUtcRange(new Date("2026-03-15T12:00:00.000Z")); - expect(start).toBe("2026-02-01T00:00:00.000Z"); - expect(end).toBe("2026-03-01T00:00:00.000Z"); - }); -}); - -describe("pickTopFiveTracks", () => { - it("returns top five by play count without counts in output", () => { - const rows = [ - { track_id: "a", track_name: "A", artist_name: "X", album_art: "", song_url: "https://a" }, - { track_id: "b", track_name: "B", artist_name: "Y", album_art: "", song_url: "https://b" }, - { track_id: "a", track_name: "A", artist_name: "X", album_art: "", song_url: "https://a" }, - { track_id: "a", track_name: "A", artist_name: "X", album_art: "", song_url: "https://a" }, - { track_id: "c", track_name: "C", artist_name: "Z", album_art: "", song_url: "https://c" }, - ]; - const top = pickTopFiveTracks(rows); - expect(top).toHaveLength(3); - expect(top[0]).toEqual({ - title: "A", - artist: "X", - albumArt: "", - songUrl: "https://a", - }); - expect(top.every((t) => !("count" in t))).toBe(true); - }); -}); diff --git a/lib/listening-monthly-top.ts b/lib/listening-monthly-top.ts deleted file mode 100644 index 8cac69c..0000000 --- a/lib/listening-monthly-top.ts +++ /dev/null @@ -1,54 +0,0 @@ -export type ListeningHighlightTrack = { - title: string; - artist: string; - albumArt: string; - songUrl: string; -}; - -export type ListeningHistoryPlayRow = { - track_id: string; - track_name: string; - artist_name: string; - album_art: string; - song_url: string; -}; - -/** Previous calendar month in UTC — [start, end) ISO bounds for Supabase filters. */ -export function getLastMonthUtcRange(now = new Date()): { start: string; end: string } { - const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1)); - const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); - return { start: start.toISOString(), end: end.toISOString() }; -} - -/** Rank by play count; ties keep first-seen order. Returns at most five tracks (no counts). */ -export function pickTopFiveTracks(rows: ListeningHistoryPlayRow[]): ListeningHighlightTrack[] { - const ranked = new Map< - string, - { track: ListeningHighlightTrack; count: number; order: number } - >(); - let order = 0; - - for (const row of rows) { - const key = row.track_id || `${row.track_name}::${row.artist_name}`; - const existing = ranked.get(key); - if (existing) { - existing.count += 1; - } else { - ranked.set(key, { - track: { - title: row.track_name, - artist: row.artist_name, - albumArt: row.album_art, - songUrl: row.song_url, - }, - count: 1, - order: order++, - }); - } - } - - return [...ranked.values()] - .sort((a, b) => b.count - a.count || a.order - b.order) - .slice(0, 5) - .map(({ track }) => track); -} diff --git a/lib/listening-supabase.ts b/lib/listening-supabase.ts index 1e0e2d3..ecdb381 100644 --- a/lib/listening-supabase.ts +++ b/lib/listening-supabase.ts @@ -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; @@ -34,50 +28,6 @@ export async function getLastPlayedListeningRow(db: SupabaseClient | null | unde } } -export async function getLastMonthTopFiveTracks( - db: SupabaseClient | null | undefined -): Promise { - 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; diff --git a/lib/spotify-top-tracks.test.ts b/lib/spotify-top-tracks.test.ts new file mode 100644 index 0000000..500c6ae --- /dev/null +++ b/lib/spotify-top-tracks.test.ts @@ -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([]); + }); +}); diff --git a/lib/spotify-top-tracks.ts b/lib/spotify-top-tracks.ts new file mode 100644 index 0000000..ae951d9 --- /dev/null +++ b/lib/spotify-top-tracks.ts @@ -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!)}`, + })); +}