feat(web): cache Last.fm browse data + Refresh button (rides ApiCache)
/lastfm hit the Last.fm API live on every load / period switch / page. Cache the
raw Last.fm payload in the shared ApiCache table (12h TTL) while keeping the
own-state annotation (followed/owned/wanted) computed LIVE on top — so a follow
made after the payload was cached shows immediately.
- cached() gains a { force } option to bypass a fresh row (still serves stale if
the forced fetch fails); CACHE_TTL.HOUR added.
- All three routes wrap their Last.fm call: lfm-top-artists / lfm-top-albums
:{user}:{period}:{page}, lfm-artist-plays:{user}:{artist} (keys normalized).
- A "Refresh" button on /lastfm force-bypasses the cache (?refresh=1).
- Factored the copy-pasted getCreds/PERIODS/LIMIT into a route-only _creds.ts
(closes the Last.fm-routes DRY todo #19) — prisma stays out of any client
bundle since lib/lastfm.ts is imported by a client component.
web 150 tests (cache-hit + refresh + own-state-live asserted), tsc + build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,36 +1,29 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptSecret } from "@/lib/crypto";
|
||||
import { topAlbums, type LfmPeriod } from "@/lib/lastfm";
|
||||
|
||||
const PERIODS: LfmPeriod[] = ["overall", "7day", "1month", "3month", "6month", "12month"];
|
||||
const LIMIT = 50;
|
||||
|
||||
async function getCreds(): Promise<{ username: string; apiKey: string } | null> {
|
||||
const rows = await prisma.config.findMany({ where: { key: { in: ["lastfm.username", "lastfm.api_key"] } } });
|
||||
const byKey = new Map(rows.map((r) => [r.key, r]));
|
||||
const username = byKey.get("lastfm.username")?.value ?? "";
|
||||
const keyRow = byKey.get("lastfm.api_key");
|
||||
const apiKey = keyRow ? (keyRow.secret ? decryptSecret(keyRow.value) : keyRow.value) : "";
|
||||
if (!username || !apiKey) return null;
|
||||
return { username, apiKey };
|
||||
}
|
||||
import { topAlbums } from "@/lib/lastfm";
|
||||
import { cached, normKey, CACHE_TTL } from "@/lib/apicache";
|
||||
import { LIMIT, getLastfmCreds, parsePeriod, wantsRefresh } from "../_creds";
|
||||
|
||||
function albumKey(artist: string, album: string): string {
|
||||
return `${artist} ${album}`.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const creds = await getCreds();
|
||||
const creds = await getLastfmCreds();
|
||||
if (!creds) return Response.json({ error: "Last.fm not configured" }, { status: 400 });
|
||||
|
||||
const url = new URL(request.url);
|
||||
const periodParam = url.searchParams.get("period") ?? "overall";
|
||||
const period: LfmPeriod = (PERIODS as string[]).includes(periodParam) ? (periodParam as LfmPeriod) : "overall";
|
||||
const period = parsePeriod(url);
|
||||
const page = Number(url.searchParams.get("page")) || 1;
|
||||
|
||||
// Cache ONLY the raw Last.fm payload; own-state (owned/wanted) is annotated live below.
|
||||
let result;
|
||||
try {
|
||||
result = await topAlbums(creds.username, creds.apiKey, { period, page, limit: LIMIT });
|
||||
result = await cached(
|
||||
`lfm-top-albums:${normKey(creds.username)}:${period}:${page}`,
|
||||
12 * CACHE_TTL.HOUR,
|
||||
() => topAlbums(creds.username, creds.apiKey, { period, page, limit: LIMIT }),
|
||||
{ force: wantsRefresh(url) },
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.json({ error: e instanceof Error ? e.message : "Last.fm request failed" }, { status: 502 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user