From 2291308b90744ae175a858f4ca3160851517a216 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Mon, 13 Jul 2026 21:27:18 +0200 Subject: [PATCH] feat(web): Last.fm most-played songs + albums in the artist modal Adds an optional lastfmName prop to ArtistModal. When set (only on /lastfm) it fetches /api/lastfm/artist-plays independently of the existing preview fetch and renders a most-played-songs list plus a most-played-albums list above the release grid. Album Want resolves via /api/mb/release-group then POSTs /api/discover/want, mirroring the existing want(r) flow. Own-state chips name-match Last.fm albums against the already-loaded release grid. Other ArtistModal callers (discover-client.tsx) pass no lastfmName, so their behavior is unchanged. --- web/src/app/_ui/artist-modal.tsx | 137 ++++++++++++++++++++++++++- web/src/app/lastfm/lastfm-client.tsx | 8 +- 2 files changed, 143 insertions(+), 2 deletions(-) diff --git a/web/src/app/_ui/artist-modal.tsx b/web/src/app/_ui/artist-modal.tsx index 2cefece..c61ffde 100644 --- a/web/src/app/_ui/artist-modal.tsx +++ b/web/src/app/_ui/artist-modal.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { Modal } from "./modal"; import { CoverArt } from "./cover-art"; import { toast } from "./toast"; +import type { ArtistPlays } from "@/lib/lastfm"; type Rel = { rgMbid: string; @@ -16,23 +17,39 @@ type Rel = { }; type Preview = { artistName: string; followed: boolean; releases: Rel[] }; +type ReleaseGroupMatch = { + rgMbid: string; + title: string; + primaryType: string | null; + secondaryTypes: string[]; + firstReleaseDate: string | null; + artistMbid: string; + artistName: string; +}; + /** Lightweight artist preview in a modal: follow + a cover-art grid of their releases with * per-album Want. Reuses the discovery preview endpoint. The full preview page stays for - * deep links (linked here as "Full page"). */ + * deep links (linked here as "Full page"). When `lastfmName` is set (the /lastfm page only) + * it also shows the caller's most-played songs/albums for this artist, fetched independently + * of the preview above so it never blocks the existing grid. */ export function ArtistModal({ open, onClose, mbid, initialName, + lastfmName, }: { open: boolean; onClose: () => void; mbid: string; initialName: string; + lastfmName?: string; }) { const [data, setData] = useState("loading"); const [followed, setFollowed] = useState(false); const [wanted, setWanted] = useState>(new Set()); + const [plays, setPlays] = useState(null); + const [wantedAlbums, setWantedAlbums] = useState>(new Set()); useEffect(() => { if (!open) return; @@ -54,6 +71,26 @@ export function ArtistModal({ }; }, [open, mbid, initialName]); + useEffect(() => { + if (!open || !lastfmName) { + setPlays(null); + setWantedAlbums(new Set()); + return; + } + setPlays("loading"); + setWantedAlbums(new Set()); + let cancelled = false; + fetch(`/api/lastfm/artist-plays?artist=${encodeURIComponent(lastfmName)}`) + .then((r) => (r.ok ? r.json() : Promise.reject())) + .then((d: ArtistPlays) => { + if (!cancelled) setPlays(d); + }) + .catch(() => !cancelled && setPlays("error")); + return () => { + cancelled = true; + }; + }, [open, lastfmName]); + const name = typeof data === "object" ? data.artistName || initialName : initialName; async function follow() { @@ -88,6 +125,34 @@ export function ArtistModal({ } } + async function wantAlbum(albumName: string) { + const res = await fetch( + `/api/mb/release-group?artist=${encodeURIComponent(lastfmName!)}&album=${encodeURIComponent(albumName)}`, + ); + if (!res.ok) { + toast(`No MusicBrainz match for ${albumName}`); + return; + } + const m: ReleaseGroupMatch = await res.json(); + const wantRes = await fetch("/api/discover/want", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + rgMbid: m.rgMbid, + artistMbid: mbid, + artistName: name, + album: m.title, + primaryType: m.primaryType, + secondaryTypes: m.secondaryTypes, + firstReleaseDate: m.firstReleaseDate, + }), + }); + if (wantRes.ok) { + setWantedAlbums((s) => new Set(s).add(albumName)); + toast(`Added ${m.title}`); + } + } + return (
@@ -102,6 +167,76 @@ export function ArtistModal({
+ {lastfmName ? ( +
+ {plays === "loading" ?

Loading your plays…

: null} + {plays === "error" ?

Couldn’t load your Last.fm plays.

: null} + {plays && typeof plays === "object" ? ( + plays.topTracks.length === 0 && plays.topAlbums.length === 0 ? ( +

No play data.

+ ) : ( + <> + {plays.topTracks.length > 0 ? ( + <> +

Most played songs

+
    + {plays.topTracks.map((t, i) => ( +
  1. + {i + 1} + {t.name} + {t.plays} plays +
  2. + ))} +
+ + ) : null} + {plays.topAlbums.length > 0 ? ( + <> +

Most played albums

+
    + {plays.topAlbums.map((album) => { + const rel = + typeof data === "object" + ? data.releases.find( + (r) => r.album.toLowerCase().trim() === album.name.toLowerCase().trim(), + ) + : undefined; + const owned = rel?.have ?? false; + const working = (rel?.monitored ?? false) || wantedAlbums.has(album.name); + return ( +
  • +
    +
    {album.name}
    +
    + {album.plays} plays +
    +
    +
    + {owned ? ( + In library + ) : working ? ( + Wanted + ) : ( + + )} +
    +
  • + ); + })} +
+ + ) : null} + {plays.sampled ? ( +

Top of your most recent ~1000 scrobbles.

+ ) : null} + + ) + ) : null} +
+ ) : null} + {data === "loading" ?

Loading…

: null} {data === "error" ?

Couldn’t load this artist.

: null} {typeof data === "object" ? ( diff --git a/web/src/app/lastfm/lastfm-client.tsx b/web/src/app/lastfm/lastfm-client.tsx index 87e951d..f704990 100644 --- a/web/src/app/lastfm/lastfm-client.tsx +++ b/web/src/app/lastfm/lastfm-client.tsx @@ -304,7 +304,13 @@ export function LastfmClient() { ) : null} {artistModal ? ( - setArtistModal(null)} mbid={artistModal.mbid} initialName={artistModal.name} /> + setArtistModal(null)} + mbid={artistModal.mbid} + initialName={artistModal.name} + lastfmName={artistModal.name} + /> ) : null} {albumTarget ? (