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:
@@ -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";
|
||||||
|
}
|
||||||
@@ -1,30 +1,24 @@
|
|||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { decryptSecret } from "@/lib/crypto";
|
|
||||||
import { artistPlays } from "@/lib/lastfm";
|
import { artistPlays } from "@/lib/lastfm";
|
||||||
|
import { cached, normKey, CACHE_TTL } from "@/lib/apicache";
|
||||||
async function getCreds(): Promise<{ username: string; apiKey: string } | null> {
|
import { getLastfmCreds, wantsRefresh } from "../_creds";
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
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 });
|
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 });
|
if (!artist) return Response.json({ error: "artist is required" }, { status: 400 });
|
||||||
|
|
||||||
let plays;
|
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
return Response.json({ error: e instanceof Error ? e.message : "Last.fm request failed" }, { status: 502 });
|
return Response.json({ error: e instanceof Error ? e.message : "Last.fm request failed" }, { status: 502 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.json(plays);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,29 @@
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { decryptSecret } from "@/lib/crypto";
|
import { topAlbums } from "@/lib/lastfm";
|
||||||
import { topAlbums, type LfmPeriod } from "@/lib/lastfm";
|
import { cached, normKey, CACHE_TTL } from "@/lib/apicache";
|
||||||
|
import { LIMIT, getLastfmCreds, parsePeriod, wantsRefresh } from "../_creds";
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function albumKey(artist: string, album: string): string {
|
function albumKey(artist: string, album: string): string {
|
||||||
return `${artist} ${album}`.trim().toLowerCase();
|
return `${artist} ${album}`.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
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 });
|
if (!creds) return Response.json({ error: "Last.fm not configured" }, { status: 400 });
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const periodParam = url.searchParams.get("period") ?? "overall";
|
const period = parsePeriod(url);
|
||||||
const period: LfmPeriod = (PERIODS as string[]).includes(periodParam) ? (periodParam as LfmPeriod) : "overall";
|
|
||||||
const page = Number(url.searchParams.get("page")) || 1;
|
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;
|
let result;
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
return Response.json({ error: e instanceof Error ? e.message : "Last.fm request failed" }, { status: 502 });
|
return Response.json({ error: e instanceof Error ? e.message : "Last.fm request failed" }, { status: 502 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,23 @@ describe("GET /api/lastfm/top-artists", () => {
|
|||||||
expect(body.artists[0]).toMatchObject({ followed: true });
|
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 () => {
|
it("passes period/page query params through and returns 502 when Last.fm throws", async () => {
|
||||||
await seedConfig();
|
await seedConfig();
|
||||||
vi.spyOn(global, "fetch").mockResolvedValue(
|
vi.spyOn(global, "fetch").mockResolvedValue(
|
||||||
|
|||||||
@@ -1,32 +1,26 @@
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { decryptSecret } from "@/lib/crypto";
|
import { topArtists } from "@/lib/lastfm";
|
||||||
import { topArtists, type LfmPeriod } from "@/lib/lastfm";
|
import { cached, normKey, CACHE_TTL } from "@/lib/apicache";
|
||||||
|
import { LIMIT, getLastfmCreds, parsePeriod, wantsRefresh } from "../_creds";
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
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 });
|
if (!creds) return Response.json({ error: "Last.fm not configured" }, { status: 400 });
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const periodParam = url.searchParams.get("period") ?? "overall";
|
const period = parsePeriod(url);
|
||||||
const period: LfmPeriod = (PERIODS as string[]).includes(periodParam) ? (periodParam as LfmPeriod) : "overall";
|
|
||||||
const page = Number(url.searchParams.get("page")) || 1;
|
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;
|
let result;
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
return Response.json({ error: e instanceof Error ? e.message : "Last.fm request failed" }, { status: 502 });
|
return Response.json({ error: e instanceof Error ? e.message : "Last.fm request failed" }, { status: 502 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { PageHead } from "../_ui/page-head";
|
import { PageHead } from "../_ui/page-head";
|
||||||
import { ArtistModal } from "../_ui/artist-modal";
|
import { ArtistModal } from "../_ui/artist-modal";
|
||||||
import { AlbumModal } from "../_ui/album-modal";
|
import { AlbumModal } from "../_ui/album-modal";
|
||||||
@@ -45,6 +45,8 @@ export function LastfmClient() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<LoadError | null>(null);
|
const [error, setError] = useState<LoadError | null>(null);
|
||||||
const [reloadKey, setReloadKey] = useState(0);
|
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 [artistModal, setArtistModal] = useState<{ mbid: string; name: string } | null>(null);
|
||||||
const [albumTarget, setAlbumTarget] = useState<{ row: AlbumRow; match: ReleaseGroupMatch } | null>(null);
|
const [albumTarget, setAlbumTarget] = useState<{ row: AlbumRow; match: ReleaseGroupMatch } | null>(null);
|
||||||
@@ -61,13 +63,19 @@ export function LastfmClient() {
|
|||||||
function retry() {
|
function retry() {
|
||||||
setReloadKey((k) => k + 1);
|
setReloadKey((k) => k + 1);
|
||||||
}
|
}
|
||||||
|
function refresh() {
|
||||||
|
forceRef.current = true; // force a live re-fetch, bypassing the server cache
|
||||||
|
setRefreshTick((t) => t + 1);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const path = tab === "Artists" ? "/api/lastfm/top-artists" : "/api/lastfm/top-albums";
|
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) => {
|
.then(async (res) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (res.status === 400) {
|
if (res.status === 400) {
|
||||||
@@ -96,7 +104,7 @@ export function LastfmClient() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [tab, period, page, reloadKey]);
|
}, [tab, period, page, reloadKey, refreshTick]);
|
||||||
|
|
||||||
const shownArtists = useMemo(() => {
|
const shownArtists = useMemo(() => {
|
||||||
const needle = filter.trim().toLowerCase();
|
const needle = filter.trim().toLowerCase();
|
||||||
@@ -210,6 +218,9 @@ export function LastfmClient() {
|
|||||||
/>
|
/>
|
||||||
Hide items in my library
|
Hide items in my library
|
||||||
</label>
|
</label>
|
||||||
|
<button type="button" className="btn sm ghost" onClick={refresh} disabled={loading}>
|
||||||
|
{loading ? "Refreshing…" : "Refresh"}
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{error?.kind === "creds" ? (
|
{error?.kind === "creds" ? (
|
||||||
|
|||||||
@@ -48,6 +48,23 @@ describe("cached (read-through)", () => {
|
|||||||
expect(await prisma.apiCache.findUnique({ where: { key: "k5" } })).toBeNull();
|
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", () => {
|
it("normKey trims and lowercases", () => {
|
||||||
expect(normKey(" Radiohead ")).toBe("radiohead");
|
expect(normKey(" Radiohead ")).toBe("radiohead");
|
||||||
});
|
});
|
||||||
|
|||||||
+10
-2
@@ -6,14 +6,22 @@ import { prisma } from "@/lib/db";
|
|||||||
// Null/undefined results are NOT cached (keeps negative caching out + avoids Prisma JsonNull).
|
// Null/undefined results are NOT cached (keeps negative caching out + avoids Prisma JsonNull).
|
||||||
|
|
||||||
export const CACHE_TTL = {
|
export const CACHE_TTL = {
|
||||||
|
HOUR: 3_600,
|
||||||
DAY: 86_400,
|
DAY: 86_400,
|
||||||
WEEK: 7 * 86_400,
|
WEEK: 7 * 86_400,
|
||||||
MONTH: 30 * 86_400,
|
MONTH: 30 * 86_400,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function cached<T>(key: string, ttlSec: number, fetchFresh: () => Promise<T>): Promise<T> {
|
/** 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<T>(
|
||||||
|
key: string,
|
||||||
|
ttlSec: number,
|
||||||
|
fetchFresh: () => Promise<T>,
|
||||||
|
opts?: { force?: boolean },
|
||||||
|
): Promise<T> {
|
||||||
const hit = await prisma.apiCache.findUnique({ where: { key } });
|
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;
|
return hit.json as T;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user