Files
Lyra/web/src/app/api/lastfm/top-albums/route.ts
T
Jonathan c63f765c1f 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>
2026-07-14 11:31:27 +02:00

50 lines
1.9 KiB
TypeScript

import { prisma } from "@/lib/db";
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 getLastfmCreds();
if (!creds) return Response.json({ error: "Last.fm not configured" }, { status: 400 });
const url = new URL(request.url);
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 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 });
}
const [libraryItems, monitoredReleases] = await Promise.all([
prisma.libraryItem.findMany({ select: { artist: true, album: true } }),
prisma.monitoredRelease.findMany({
where: { OR: [{ monitored: true }, { currentQualityClass: { not: null } }] },
select: { artistName: true, album: true },
}),
]);
const ownedKeys = new Set(libraryItems.map((r) => albumKey(r.artist, r.album)));
const wantedKeys = new Set(monitoredReleases.map((r) => albumKey(r.artistName, r.album)));
const albums = result.items.map((a) => {
const key = albumKey(a.artist, a.name);
const owned = ownedKeys.has(key);
const wanted = wantedKeys.has(key);
return { ...a, inLibrary: owned || wanted, owned, wanted };
});
return Response.json({ albums, totalPages: result.totalPages, page: result.page });
}