From 951782cfb226954bd8978b05f5bb38a98c3a00d4 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 29 Jul 2026 15:25:44 -0500 Subject: [PATCH 1/2] fix: enrich the create-time Spotify attach with the real profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveOrCreateArtist's create path saved the URL path segment as the username, so verify-socials rendered 'Spotify @artist · 0 followers'. Fetch the real Spotify profile after the attach and reuse enrichSearchedArtistProfile to write the real handle, follower count and avatar. chat#1889 row 16. Co-Authored-By: Claude Fable 5 --- .../enrichArtistSpotifyProfile.test.ts | 76 +++++++++++++++++++ .../__tests__/resolveOrCreateArtist.test.ts | 38 ++++++++++ lib/artists/enrichArtistSpotifyProfile.ts | 34 +++++++++ lib/artists/resolveOrCreateArtist.ts | 9 +++ 4 files changed, 157 insertions(+) create mode 100644 lib/artists/__tests__/enrichArtistSpotifyProfile.test.ts create mode 100644 lib/artists/enrichArtistSpotifyProfile.ts diff --git a/lib/artists/__tests__/enrichArtistSpotifyProfile.test.ts b/lib/artists/__tests__/enrichArtistSpotifyProfile.test.ts new file mode 100644 index 00000000..d567bc78 --- /dev/null +++ b/lib/artists/__tests__/enrichArtistSpotifyProfile.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { enrichArtistSpotifyProfile } from "../enrichArtistSpotifyProfile"; +import generateAccessToken from "@/lib/spotify/generateAccessToken"; +import getArtist from "@/lib/spotify/getArtist"; +import { enrichSearchedArtistProfile } from "@/lib/valuation/enrichSearchedArtistProfile"; + +vi.mock("@/lib/spotify/generateAccessToken", () => ({ default: vi.fn() })); +vi.mock("@/lib/spotify/getArtist", () => ({ default: vi.fn() })); +vi.mock("@/lib/valuation/enrichSearchedArtistProfile", () => ({ + enrichSearchedArtistProfile: vi.fn(), +})); + +const artistId = "b1814076-8e19-4a77-9dea-2ec150e26aaa"; +const spotifyArtistId = "4Z8W4fKeB5YxbusRsdQVPb"; + +const okToken = () => + vi.mocked(generateAccessToken).mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + expires_in: 3600, + error: null, + }); + +describe("enrichArtistSpotifyProfile", () => { + beforeEach(() => vi.clearAllMocks()); + + it("fetches the real profile and delegates to enrichSearchedArtistProfile", async () => { + okToken(); + const spotifyArtist = { + name: "Radiohead", + followers: { total: 1800000 }, + images: [{ url: "https://i.scdn.co/image/x" }], + }; + vi.mocked(getArtist).mockResolvedValue({ artist: spotifyArtist as never, error: null }); + + await enrichArtistSpotifyProfile({ artistId, spotifyArtistId }); + + expect(getArtist).toHaveBeenCalledWith(spotifyArtistId, "tok"); + expect(enrichSearchedArtistProfile).toHaveBeenCalledWith({ + artistId, + spotifyArtistId, + spotifyArtist, + }); + }); + + it("skips enrichment when the token cannot be minted", async () => { + vi.mocked(generateAccessToken).mockResolvedValue({ + access_token: null, + token_type: null, + expires_in: null, + error: new Error("down"), + }); + + await enrichArtistSpotifyProfile({ artistId, spotifyArtistId }); + + expect(getArtist).not.toHaveBeenCalled(); + expect(enrichSearchedArtistProfile).not.toHaveBeenCalled(); + }); + + it("skips enrichment when the Spotify profile fetch fails", async () => { + okToken(); + vi.mocked(getArtist).mockResolvedValue({ artist: null, error: new Error("404") }); + + await enrichArtistSpotifyProfile({ artistId, spotifyArtistId }); + + expect(enrichSearchedArtistProfile).not.toHaveBeenCalled(); + }); + + it("never throws (best-effort)", async () => { + vi.mocked(generateAccessToken).mockRejectedValue(new Error("boom")); + + await expect( + enrichArtistSpotifyProfile({ artistId, spotifyArtistId }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/lib/artists/__tests__/resolveOrCreateArtist.test.ts b/lib/artists/__tests__/resolveOrCreateArtist.test.ts index b34e9723..2d84666f 100644 --- a/lib/artists/__tests__/resolveOrCreateArtist.test.ts +++ b/lib/artists/__tests__/resolveOrCreateArtist.test.ts @@ -6,6 +6,7 @@ import { selectAccountArtistId } from "@/lib/supabase/account_artist_ids/selectA import { insertAccountArtistId } from "@/lib/supabase/account_artist_ids/insertAccountArtistId"; import { selectAccountWithSocials } from "@/lib/supabase/accounts/selectAccountWithSocials"; import { updateArtistSocials } from "@/lib/artist/updateArtistSocials"; +import { enrichArtistSpotifyProfile } from "@/lib/artists/enrichArtistSpotifyProfile"; vi.mock("@/lib/artists/createArtistInDb", () => ({ createArtistInDb: vi.fn() })); vi.mock("@/lib/valuation/findCanonicalArtistBySpotifyId", () => ({ @@ -21,6 +22,9 @@ vi.mock("@/lib/supabase/accounts/selectAccountWithSocials", () => ({ selectAccountWithSocials: vi.fn(), })); vi.mock("@/lib/artist/updateArtistSocials", () => ({ updateArtistSocials: vi.fn() })); +vi.mock("@/lib/artists/enrichArtistSpotifyProfile", () => ({ + enrichArtistSpotifyProfile: vi.fn(), +})); const SPOTIFY_ID = "0xPoVNPnxIIUS1vrxAYV00"; const created = { id: "new-1", account_id: "new-1", name: "Del Water Gap" }; @@ -51,6 +55,40 @@ describe("resolveOrCreateArtist", () => { expect(result).toEqual({ artist: created, created: true }); }); + // chat#1889 row 16: the create-time attach stores the URL path segment as + // the username ("@artist · 0 followers"). Enrich with the real Spotify + // profile right after the attach so verify-socials shows real metadata. + it("enriches the attached social with the real Spotify profile", async () => { + await resolveOrCreateArtist({ + name: "Del Water Gap", + accountId: "acct-1", + spotifyArtistId: SPOTIFY_ID, + }); + + expect(enrichArtistSpotifyProfile).toHaveBeenCalledWith({ + artistId: "new-1", + spotifyArtistId: SPOTIFY_ID, + }); + }); + + it("does not enrich on the plain create path (no spotify id)", async () => { + await resolveOrCreateArtist({ name: "X", accountId: "acct-1" }); + + expect(enrichArtistSpotifyProfile).not.toHaveBeenCalled(); + }); + + it("does not enrich when the social attach fails (nothing to enrich)", async () => { + vi.mocked(updateArtistSocials).mockRejectedValue(new Error("nope")); + + await resolveOrCreateArtist({ + name: "Del Water Gap", + accountId: "acct-1", + spotifyArtistId: SPOTIFY_ID, + }); + + expect(enrichArtistSpotifyProfile).not.toHaveBeenCalled(); + }); + // One canonical artist per Spotify id (chat#1889, decision 2026-07-29): // when it exists, link it to the account — never mint a second row. it("links and returns the existing canonical instead of creating", async () => { diff --git a/lib/artists/enrichArtistSpotifyProfile.ts b/lib/artists/enrichArtistSpotifyProfile.ts new file mode 100644 index 00000000..01dd08ea --- /dev/null +++ b/lib/artists/enrichArtistSpotifyProfile.ts @@ -0,0 +1,34 @@ +import generateAccessToken from "@/lib/spotify/generateAccessToken"; +import getArtist from "@/lib/spotify/getArtist"; +import { enrichSearchedArtistProfile } from "@/lib/valuation/enrichSearchedArtistProfile"; + +/** + * Fetch an artist's real Spotify profile and enrich its attached social + + * avatar with it (chat#1889 row 16). Callers that don't already hold a + * resolved SpotifyArtist (like resolveOrCreateArtist's create path, which + * only has the id) use this; callers that do should call + * enrichSearchedArtistProfile directly and skip the extra fetch. + * + * Best-effort: never throws — enrichment must not fail the artist add. The + * social stays fixable in verify-socials if Spotify is unreachable. + * + * @param params.artistId - The artist account id whose social to enrich + * @param params.spotifyArtistId - The Spotify artist id to fetch + */ +export async function enrichArtistSpotifyProfile(params: { + artistId: string; + spotifyArtistId: string; +}): Promise { + const { artistId, spotifyArtistId } = params; + try { + const token = await generateAccessToken(); + if (!token.access_token) return; + + const { artist: spotifyArtist } = await getArtist(spotifyArtistId, token.access_token); + if (!spotifyArtist) return; + + await enrichSearchedArtistProfile({ artistId, spotifyArtistId, spotifyArtist }); + } catch (error) { + console.error("Error enriching artist Spotify profile:", error); + } +} diff --git a/lib/artists/resolveOrCreateArtist.ts b/lib/artists/resolveOrCreateArtist.ts index 14fe4f36..a9b3eb8b 100644 --- a/lib/artists/resolveOrCreateArtist.ts +++ b/lib/artists/resolveOrCreateArtist.ts @@ -4,6 +4,7 @@ import { selectAccountArtistId } from "@/lib/supabase/account_artist_ids/selectA import { insertAccountArtistId } from "@/lib/supabase/account_artist_ids/insertAccountArtistId"; import { selectAccountWithSocials } from "@/lib/supabase/accounts/selectAccountWithSocials"; import { updateArtistSocials } from "@/lib/artist/updateArtistSocials"; +import { enrichArtistSpotifyProfile } from "@/lib/artists/enrichArtistSpotifyProfile"; export interface ResolveOrCreateArtistParams { name: string; @@ -59,6 +60,14 @@ export async function resolveOrCreateArtist( await updateArtistSocials(created.account_id, { SPOTIFY: `https://open.spotify.com/artist/${spotifyArtistId}`, }); + // The attach stores the URL path segment as the username, which renders + // as "@artist · 0 followers" in verify-socials (chat#1889 row 16). + // Overwrite it with the real Spotify handle/followers/avatar. Inside the + // same try: if the attach failed there is no linked social to enrich. + await enrichArtistSpotifyProfile({ + artistId: created.account_id, + spotifyArtistId, + }); } catch { // Non-fatal by design — see docstring. } From 8d8949f8139676faab0eed70c6bf0a48be642f01 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 29 Jul 2026 18:06:05 -0500 Subject: [PATCH 2/2] fix: backfill a blank canonical image when reusing a canonical artist A reused canonical with no image rendered a grey roster card - forever for accounts that already have a catalog (no seeding fires), ~30s for new signups. Enrich from the real Spotify profile at add-time, only when the image is blank. chat#1911 row 4. Co-Authored-By: Claude Fable 5 --- .../__tests__/resolveOrCreateArtist.test.ts | 70 +++++++++++++++++++ lib/artists/resolveOrCreateArtist.ts | 14 +++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/lib/artists/__tests__/resolveOrCreateArtist.test.ts b/lib/artists/__tests__/resolveOrCreateArtist.test.ts index 2d84666f..58e91685 100644 --- a/lib/artists/__tests__/resolveOrCreateArtist.test.ts +++ b/lib/artists/__tests__/resolveOrCreateArtist.test.ts @@ -107,6 +107,76 @@ describe("resolveOrCreateArtist", () => { expect(result.artist).toMatchObject({ id: "canonical-1", account_id: "canonical-1" }); }); + // chat#1911 row 4: a reused canonical with no image renders a grey roster + // card until seeding self-heals it (~30s), and never heals for accounts + // that already have a catalog. Backfill the blank at add-time. + describe("blank canonical image backfill (reuse path)", () => { + beforeEach(() => vi.mocked(findCanonicalArtistBySpotifyId).mockResolvedValue("canonical-1")); + + it("enriches and re-fetches when the canonical has no image", async () => { + const blank = { id: "canonical-1", name: "Del Water Gap", account_info: [{ image: null }] }; + const healed = { + id: "canonical-1", + name: "Del Water Gap", + account_info: [{ image: "https://i.scdn.co/image/x" }], + }; + vi.mocked(selectAccountWithSocials) + .mockResolvedValueOnce(blank as never) + .mockResolvedValueOnce(healed as never); + + const result = await resolveOrCreateArtist({ + name: "Del Water Gap", + accountId: "acct-1", + spotifyArtistId: SPOTIFY_ID, + }); + + expect(enrichArtistSpotifyProfile).toHaveBeenCalledWith({ + artistId: "canonical-1", + spotifyArtistId: SPOTIFY_ID, + }); + expect(selectAccountWithSocials).toHaveBeenCalledTimes(2); + expect(result.artist).toMatchObject({ + id: "canonical-1", + account_info: [{ image: "https://i.scdn.co/image/x" }], + }); + }); + + it("does not enrich when the canonical already has an image (no shared-write)", async () => { + vi.mocked(selectAccountWithSocials).mockResolvedValue({ + id: "canonical-1", + name: "Del Water Gap", + account_info: [{ image: "https://existing.jpg" }], + } as never); + + await resolveOrCreateArtist({ + name: "Del Water Gap", + accountId: "acct-1", + spotifyArtistId: SPOTIFY_ID, + }); + + expect(enrichArtistSpotifyProfile).not.toHaveBeenCalled(); + expect(selectAccountWithSocials).toHaveBeenCalledTimes(1); + }); + + it("returns the original canonical when the backfill throws (best-effort)", async () => { + vi.mocked(selectAccountWithSocials).mockResolvedValue({ + id: "canonical-1", + name: "Del Water Gap", + account_info: [], + } as never); + vi.mocked(enrichArtistSpotifyProfile).mockRejectedValue(new Error("spotify down")); + + const result = await resolveOrCreateArtist({ + name: "Del Water Gap", + accountId: "acct-1", + spotifyArtistId: SPOTIFY_ID, + }); + + expect(result.created).toBe(false); + expect(result.artist).toMatchObject({ id: "canonical-1", account_id: "canonical-1" }); + }); + }); + it("does not re-link a canonical the account already rosters", async () => { vi.mocked(findCanonicalArtistBySpotifyId).mockResolvedValue("canonical-1"); vi.mocked(selectAccountArtistId).mockResolvedValue({ id: "link-1" } as never); diff --git a/lib/artists/resolveOrCreateArtist.ts b/lib/artists/resolveOrCreateArtist.ts index a9b3eb8b..07c1b6da 100644 --- a/lib/artists/resolveOrCreateArtist.ts +++ b/lib/artists/resolveOrCreateArtist.ts @@ -45,7 +45,19 @@ export async function resolveOrCreateArtist( if (!alreadyRostered) { await insertAccountArtistId(accountId, canonicalId); } - const artist = await selectAccountWithSocials(canonicalId); + let artist = await selectAccountWithSocials(canonicalId); + // Backfill a blank canonical image at add-time (chat#1911 row 4). + // Filling a blank is not the chat#1866 shared-write problem — nothing + // is overwritten, and a canonical with an image is left untouched. + // Best-effort: a Spotify outage must not fail the add. + if (artist && !artist.account_info?.[0]?.image) { + try { + await enrichArtistSpotifyProfile({ artistId: canonicalId, spotifyArtistId }); + artist = (await selectAccountWithSocials(canonicalId)) ?? artist; + } catch { + // Keep the un-enriched canonical — the roster card self-heals later. + } + } return { artist: artist ? { ...artist, account_id: artist.id } : null, created: false,