diff --git a/web/src/app/api/lastfm/_creds.ts b/web/src/app/api/lastfm/_creds.ts new file mode 100644 index 0000000..803bda9 --- /dev/null +++ b/web/src/app/api/lastfm/_creds.ts @@ -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"; +} diff --git a/web/src/app/api/lastfm/artist-plays/route.ts b/web/src/app/api/lastfm/artist-plays/route.ts index 20ddf3f..693411d 100644 --- a/web/src/app/api/lastfm/artist-plays/route.ts +++ b/web/src/app/api/lastfm/artist-plays/route.ts @@ -1,30 +1,24 @@ -import { prisma } from "@/lib/db"; -import { decryptSecret } from "@/lib/crypto"; import { artistPlays } from "@/lib/lastfm"; - -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 { cached, normKey, CACHE_TTL } from "@/lib/apicache"; +import { getLastfmCreds, wantsRefresh } from "../_creds"; 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 artist = new URL(request.url).searchParams.get("artist")?.trim(); + const url = new URL(request.url); + const artist = url.searchParams.get("artist")?.trim(); if (!artist) return Response.json({ error: "artist is required" }, { status: 400 }); - let plays; try { - plays = await artistPlays(creds.username, artist, creds.apiKey); + const plays = await cached( + `lfm-artist-plays:${normKey(creds.username)}:${normKey(artist)}`, + 12 * CACHE_TTL.HOUR, + () => artistPlays(creds.username, artist, creds.apiKey), + { force: wantsRefresh(url) }, + ); + return Response.json(plays); } catch (e) { return Response.json({ error: e instanceof Error ? e.message : "Last.fm request failed" }, { status: 502 }); } - - return Response.json(plays); } diff --git a/web/src/app/api/lastfm/top-albums/route.ts b/web/src/app/api/lastfm/top-albums/route.ts index f6baa89..7031ab1 100644 --- a/web/src/app/api/lastfm/top-albums/route.ts +++ b/web/src/app/api/lastfm/top-albums/route.ts @@ -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 }); } diff --git a/web/src/app/api/lastfm/top-artists/route.test.ts b/web/src/app/api/lastfm/top-artists/route.test.ts index 2f6948b..4f6a91e 100644 --- a/web/src/app/api/lastfm/top-artists/route.test.ts +++ b/web/src/app/api/lastfm/top-artists/route.test.ts @@ -64,6 +64,23 @@ describe("GET /api/lastfm/top-artists", () => { expect(body.artists[0]).toMatchObject({ followed: true }); }); + it("caches the raw payload (2nd load doesn't re-hit Last.fm) but re-annotates own-state live", async () => { + await seedConfig(); + mockLfmArtists([{ name: "Cached Band", playcount: "9", mbid: "mb-cache" }]); + await (await GET(req())).json(); // first load → Last.fm hit + cached + expect(global.fetch).toHaveBeenCalledTimes(1); + + // follow the artist AFTER the payload was cached + await prisma.watchedArtist.create({ data: { mbid: "mb-cache", name: "Cached Band" } }); + const body = await (await GET(req())).json(); + expect(global.fetch).toHaveBeenCalledTimes(1); // served from cache, no 2nd Last.fm call + expect(body.artists[0]).toMatchObject({ followed: true }); // own-state annotated live + + // ?refresh=1 bypasses the cache and re-hits Last.fm + await (await GET(req("?refresh=1"))).json(); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + it("passes period/page query params through and returns 502 when Last.fm throws", async () => { await seedConfig(); vi.spyOn(global, "fetch").mockResolvedValue( diff --git a/web/src/app/api/lastfm/top-artists/route.ts b/web/src/app/api/lastfm/top-artists/route.ts index ae478e4..5d5d2d9 100644 --- a/web/src/app/api/lastfm/top-artists/route.ts +++ b/web/src/app/api/lastfm/top-artists/route.ts @@ -1,32 +1,26 @@ import { prisma } from "@/lib/db"; -import { decryptSecret } from "@/lib/crypto"; -import { topArtists, 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 { topArtists } from "@/lib/lastfm"; +import { cached, normKey, CACHE_TTL } from "@/lib/apicache"; +import { LIMIT, getLastfmCreds, parsePeriod, wantsRefresh } from "../_creds"; 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 (followed/owned) is annotated live below so + // a follow made after the payload was cached still shows immediately. let result; try { - result = await topArtists(creds.username, creds.apiKey, { period, page, limit: LIMIT }); + result = await cached( + `lfm-top-artists:${normKey(creds.username)}:${period}:${page}`, + 12 * CACHE_TTL.HOUR, + () => topArtists(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 }); } diff --git a/web/src/app/lastfm/lastfm-client.tsx b/web/src/app/lastfm/lastfm-client.tsx index f704990..724705a 100644 --- a/web/src/app/lastfm/lastfm-client.tsx +++ b/web/src/app/lastfm/lastfm-client.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { PageHead } from "../_ui/page-head"; import { ArtistModal } from "../_ui/artist-modal"; import { AlbumModal } from "../_ui/album-modal"; @@ -45,6 +45,8 @@ export function LastfmClient() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [reloadKey, setReloadKey] = useState(0); + const [refreshTick, setRefreshTick] = useState(0); + const forceRef = useRef(false); // next load should bypass the server cache const [artistModal, setArtistModal] = useState<{ mbid: string; name: string } | null>(null); const [albumTarget, setAlbumTarget] = useState<{ row: AlbumRow; match: ReleaseGroupMatch } | null>(null); @@ -61,13 +63,19 @@ export function LastfmClient() { function retry() { setReloadKey((k) => k + 1); } + function refresh() { + forceRef.current = true; // force a live re-fetch, bypassing the server cache + setRefreshTick((t) => t + 1); + } useEffect(() => { let cancelled = false; setLoading(true); setError(null); const path = tab === "Artists" ? "/api/lastfm/top-artists" : "/api/lastfm/top-albums"; - fetch(`${path}?period=${period}&page=${page}`) + const force = forceRef.current; + forceRef.current = false; + fetch(`${path}?period=${period}&page=${page}${force ? "&refresh=1" : ""}`) .then(async (res) => { if (cancelled) return; if (res.status === 400) { @@ -96,7 +104,7 @@ export function LastfmClient() { return () => { cancelled = true; }; - }, [tab, period, page, reloadKey]); + }, [tab, period, page, reloadKey, refreshTick]); const shownArtists = useMemo(() => { const needle = filter.trim().toLowerCase(); @@ -210,6 +218,9 @@ export function LastfmClient() { /> Hide items in my library + {error?.kind === "creds" ? ( diff --git a/web/src/lib/apicache.test.ts b/web/src/lib/apicache.test.ts index 42c633e..d66e523 100644 --- a/web/src/lib/apicache.test.ts +++ b/web/src/lib/apicache.test.ts @@ -48,6 +48,23 @@ describe("cached (read-through)", () => { expect(await prisma.apiCache.findUnique({ where: { key: "k5" } })).toBeNull(); }); + it("force bypasses a fresh cached row and re-fetches + overwrites", async () => { + let n = 0; + const fetchFresh = () => Promise.resolve({ n: ++n }); + expect(await cached("k6", 3600, fetchFresh)).toEqual({ n: 1 }); + expect(await cached("k6", 3600, fetchFresh)).toEqual({ n: 1 }); // cache hit + expect(await cached("k6", 3600, fetchFresh, { force: true })).toEqual({ n: 2 }); // bypassed + expect(await cached("k6", 3600, fetchFresh)).toEqual({ n: 2 }); // overwrote the row + }); + + it("force still serves the stale row if the forced fetch fails", async () => { + await cached("k7", 3600, async () => ({ v: "cached" })); + const result = await cached("k7", 3600, async () => { + throw new Error("down"); + }, { force: true }); + expect(result).toEqual({ v: "cached" }); // stale-on-outage even on a forced refresh + }); + it("normKey trims and lowercases", () => { expect(normKey(" Radiohead ")).toBe("radiohead"); }); diff --git a/web/src/lib/apicache.ts b/web/src/lib/apicache.ts index 53a769c..d9cf189 100644 --- a/web/src/lib/apicache.ts +++ b/web/src/lib/apicache.ts @@ -6,14 +6,22 @@ import { prisma } from "@/lib/db"; // Null/undefined results are NOT cached (keeps negative caching out + avoids Prisma JsonNull). export const CACHE_TTL = { + HOUR: 3_600, DAY: 86_400, WEEK: 7 * 86_400, MONTH: 30 * 86_400, }; -export async function cached(key: string, ttlSec: number, fetchFresh: () => Promise): Promise { +/** Read-through cache. Pass `{ force: true }` to bypass a fresh cached row and re-fetch + * (a manual Refresh) — a stale row is still served if the forced fetch then fails. */ +export async function cached( + key: string, + ttlSec: number, + fetchFresh: () => Promise, + opts?: { force?: boolean }, +): Promise { const hit = await prisma.apiCache.findUnique({ where: { key } }); - if (hit && (Date.now() - hit.fetchedAt.getTime()) / 1000 < ttlSec) { + if (!opts?.force && hit && (Date.now() - hit.fetchedAt.getTime()) / 1000 < ttlSec) { return hit.json as T; } try {