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:
Jonathan
2026-07-14 11:31:27 +02:00
parent 034ae40084
commit c63f765c1f
8 changed files with 127 additions and 61 deletions
+32
View File
@@ -0,0 +1,32 @@
import { prisma } from "@/lib/db";
import { decryptSecret } from "@/lib/crypto";
import type { LfmPeriod } from "@/lib/lastfm";
// Shared by the three /api/lastfm routes (was copy-pasted three times). Route-only module
// (underscore prefix ⇒ not a route), so importing prisma here never reaches a client bundle.
export const PERIODS: LfmPeriod[] = ["overall", "7day", "1month", "3month", "6month", "12month"];
export const LIMIT = 50;
export async function getLastfmCreds(): 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 };
}
/** The `period` query param, validated to a supported LfmPeriod (default "overall"). */
export function parsePeriod(url: URL): LfmPeriod {
const p = url.searchParams.get("period") ?? "overall";
return (PERIODS as string[]).includes(p) ? (p as LfmPeriod) : "overall";
}
/** Whether a manual Refresh (cache bypass) was requested. */
export function wantsRefresh(url: URL): boolean {
return url.searchParams.get("refresh") === "1";
}