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
+14 -3
View File
@@ -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<LoadError | null>(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
</label>
<button type="button" className="btn sm ghost" onClick={refresh} disabled={loading}>
{loading ? "Refreshing…" : "Refresh"}
</button>
</form>
{error?.kind === "creds" ? (