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.
This commit is contained in:
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { Modal } from "./modal";
|
import { Modal } from "./modal";
|
||||||
import { CoverArt } from "./cover-art";
|
import { CoverArt } from "./cover-art";
|
||||||
import { toast } from "./toast";
|
import { toast } from "./toast";
|
||||||
|
import type { ArtistPlays } from "@/lib/lastfm";
|
||||||
|
|
||||||
type Rel = {
|
type Rel = {
|
||||||
rgMbid: string;
|
rgMbid: string;
|
||||||
@@ -16,23 +17,39 @@ type Rel = {
|
|||||||
};
|
};
|
||||||
type Preview = { artistName: string; followed: boolean; releases: 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
|
/** 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
|
* 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({
|
export function ArtistModal({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
mbid,
|
mbid,
|
||||||
initialName,
|
initialName,
|
||||||
|
lastfmName,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
mbid: string;
|
mbid: string;
|
||||||
initialName: string;
|
initialName: string;
|
||||||
|
lastfmName?: string;
|
||||||
}) {
|
}) {
|
||||||
const [data, setData] = useState<Preview | "loading" | "error">("loading");
|
const [data, setData] = useState<Preview | "loading" | "error">("loading");
|
||||||
const [followed, setFollowed] = useState(false);
|
const [followed, setFollowed] = useState(false);
|
||||||
const [wanted, setWanted] = useState<Set<string>>(new Set());
|
const [wanted, setWanted] = useState<Set<string>>(new Set());
|
||||||
|
const [plays, setPlays] = useState<ArtistPlays | "loading" | "error" | null>(null);
|
||||||
|
const [wantedAlbums, setWantedAlbums] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -54,6 +71,26 @@ export function ArtistModal({
|
|||||||
};
|
};
|
||||||
}, [open, mbid, initialName]);
|
}, [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;
|
const name = typeof data === "object" ? data.artistName || initialName : initialName;
|
||||||
|
|
||||||
async function follow() {
|
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 (
|
return (
|
||||||
<Modal open={open} onClose={onClose} title="Artist" wide>
|
<Modal open={open} onClose={onClose} title="Artist" wide>
|
||||||
<div className="artist-modal-head">
|
<div className="artist-modal-head">
|
||||||
@@ -102,6 +167,76 @@ export function ArtistModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{lastfmName ? (
|
||||||
|
<div className="am-lastfm">
|
||||||
|
{plays === "loading" ? <p className="muted-note">Loading your plays…</p> : null}
|
||||||
|
{plays === "error" ? <p className="muted-note">Couldn’t load your Last.fm plays.</p> : null}
|
||||||
|
{plays && typeof plays === "object" ? (
|
||||||
|
plays.topTracks.length === 0 && plays.topAlbums.length === 0 ? (
|
||||||
|
<p className="muted-note">No play data.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{plays.topTracks.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<h3 className="subhead">Most played songs</h3>
|
||||||
|
<ol className="tracks">
|
||||||
|
{plays.topTracks.map((t, i) => (
|
||||||
|
<li key={t.name}>
|
||||||
|
<span className="tnum">{i + 1}</span>
|
||||||
|
<span className="tname">{t.name}</span>
|
||||||
|
<span>{t.plays} plays</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{plays.topAlbums.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<h3 className="subhead">Most played albums</h3>
|
||||||
|
<ul className="list">
|
||||||
|
{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 (
|
||||||
|
<li key={album.name} className="list-row">
|
||||||
|
<div className="main">
|
||||||
|
<div className="rtitle">{album.name}</div>
|
||||||
|
<div className="rmeta">
|
||||||
|
<span>{album.plays} plays</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="actions">
|
||||||
|
{owned ? (
|
||||||
|
<span className="chip done">In library</span>
|
||||||
|
) : working ? (
|
||||||
|
<span className="chip working">Wanted</span>
|
||||||
|
) : (
|
||||||
|
<button className="btn sm" onClick={() => wantAlbum(album.name)}>
|
||||||
|
Want
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{plays.sampled ? (
|
||||||
|
<p className="muted-note">Top of your most recent ~1000 scrobbles.</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{data === "loading" ? <p className="muted-note">Loading…</p> : null}
|
{data === "loading" ? <p className="muted-note">Loading…</p> : null}
|
||||||
{data === "error" ? <p className="muted-note">Couldn’t load this artist.</p> : null}
|
{data === "error" ? <p className="muted-note">Couldn’t load this artist.</p> : null}
|
||||||
{typeof data === "object" ? (
|
{typeof data === "object" ? (
|
||||||
|
|||||||
@@ -304,7 +304,13 @@ export function LastfmClient() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{artistModal ? (
|
{artistModal ? (
|
||||||
<ArtistModal open onClose={() => setArtistModal(null)} mbid={artistModal.mbid} initialName={artistModal.name} />
|
<ArtistModal
|
||||||
|
open
|
||||||
|
onClose={() => setArtistModal(null)}
|
||||||
|
mbid={artistModal.mbid}
|
||||||
|
initialName={artistModal.name}
|
||||||
|
lastfmName={artistModal.name}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{albumTarget ? (
|
{albumTarget ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user