From 6c027244efb8c9a47400c4daef8f09c444080bc5 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 21 Jul 2026 19:06:19 -0500 Subject: [PATCH 1/2] fix(artists): Add New Artist opens a shared Spotify search (kills the /artists loop) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Add New Artist" (and the sidebar/header equivalents) called toggleCreation, which pushed `/?q=create a new artist` and relied on the home CHAT to interpret it. The onboarding router (chat#1872) now intercepts the home for incomplete accounts and renders "Finish setting up" instead, so the query never ran — the "Open your roster" CTA bounced back to /artists → infinite loop. Replace the redirect with a shared Spotify artist-search dialog: - lib/spotify/parseSpotifyArtistResults.ts — pure parser for the GET /api/spotify/search?type=artist envelope (id/name/imageUrl/profileUrl/followers). - lib/artists/addSpotifyArtist.ts — create by name (POST /api/artists) then link the Spotify image + profile URL (PATCH), so the roster gets real data. - useSpotifyArtistSearch (debounced, abortable) + useAddSpotifyArtist hooks. - components/Artists/SpotifyArtistSearch.tsx — the shared typeahead (reused next for social enrichment + verify-socials). - components/Artists/AddArtistDialog.tsx — global dialog, mounted in layout. - useArtistMode.toggleCreation now opens the dialog (isCreationOpen/closeCreation) instead of the redirect — fixes /artists, sidebar, and header at once. TDD: parseSpotifyArtistResults 5/5, addSpotifyArtist 2/2, red→green. tsc clean on touched files, eslint/prettier clean. chat#1867 (/artists add-artist loop). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/layout.tsx | 2 + components/Artists/AddArtistDialog.tsx | 53 ++++++++++ components/Artists/SpotifyArtistSearch.tsx | 99 +++++++++++++++++++ hooks/useAddSpotifyArtist.ts | 48 +++++++++ hooks/useArtistMode.tsx | 16 ++- hooks/useSpotifyArtistSearch.ts | 60 +++++++++++ .../__tests__/addSpotifyArtist.test.ts | 52 ++++++++++ lib/artists/addSpotifyArtist.ts | 30 ++++++ .../parseSpotifyArtistResults.test.ts | 62 ++++++++++++ lib/spotify/parseSpotifyArtistResults.ts | 51 ++++++++++ 10 files changed, 468 insertions(+), 5 deletions(-) create mode 100644 components/Artists/AddArtistDialog.tsx create mode 100644 components/Artists/SpotifyArtistSearch.tsx create mode 100644 hooks/useAddSpotifyArtist.ts create mode 100644 hooks/useSpotifyArtistSearch.ts create mode 100644 lib/artists/__tests__/addSpotifyArtist.test.ts create mode 100644 lib/artists/addSpotifyArtist.ts create mode 100644 lib/spotify/__tests__/parseSpotifyArtistResults.test.ts create mode 100644 lib/spotify/parseSpotifyArtistResults.ts diff --git a/app/layout.tsx b/app/layout.tsx index 989428f61..6ed4b2d3c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -7,6 +7,7 @@ import Sidebar from "@/components/Sidebar"; import Header from "@/components/Header"; import { Suspense } from "react"; import ArtistSettingModal from "@/components/ArtistSettingModal"; +import AddArtistDialog from "@/components/Artists/AddArtistDialog"; import MobileDownloadModal from "@/components/ModalDownloadModal"; import ArtistsSidebar from "@/components/Artists/ArtistsSidebar"; import { ToastContainer } from "react-toastify"; @@ -85,6 +86,7 @@ export default function RootLayout({
+
diff --git a/components/Artists/AddArtistDialog.tsx b/components/Artists/AddArtistDialog.tsx new file mode 100644 index 000000000..2a855b0f7 --- /dev/null +++ b/components/Artists/AddArtistDialog.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useArtistProvider } from "@/providers/ArtistProvider"; +import { useAddSpotifyArtist } from "@/hooks/useAddSpotifyArtist"; +import SpotifyArtistSearch from "./SpotifyArtistSearch"; +import type { SpotifyArtistResult } from "@/lib/spotify/parseSpotifyArtistResults"; + +/** + * Global "Add New Artist" dialog: search Spotify -> pick -> add to the roster. + * Opened by `toggleCreation` (Artists page, sidebar, header), replacing the old + * `/?q=create a new artist` redirect that dead-ended on the onboarding sequence. + */ +const AddArtistDialog = () => { + const { isCreationOpen, closeCreation } = useArtistProvider(); + const { add, isAdding } = useAddSpotifyArtist(); + + const handleSelect = async (artist: SpotifyArtistResult) => { + const added = await add(artist); + if (added) closeCreation(); + }; + + return ( + { + if (!open) closeCreation(); + }} + > + + + Add a new artist + + Search Spotify and pick the artist to add them to your roster. + + + + + + ); +}; + +export default AddArtistDialog; diff --git a/components/Artists/SpotifyArtistSearch.tsx b/components/Artists/SpotifyArtistSearch.tsx new file mode 100644 index 000000000..7c875c53b --- /dev/null +++ b/components/Artists/SpotifyArtistSearch.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useState } from "react"; +import { Loader, Search } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { useSpotifyArtistSearch } from "@/hooks/useSpotifyArtistSearch"; +import formatFollowerCount from "@/lib/utils/formatFollowerCount"; +import type { SpotifyArtistResult } from "@/lib/spotify/parseSpotifyArtistResults"; + +interface SpotifyArtistSearchProps { + onSelect: (artist: SpotifyArtistResult) => void; + /** Disables input + results while an add is in flight. */ + isBusy?: boolean; + autoFocus?: boolean; +} + +/** + * Shared Spotify artist typeahead: type -> see matches (avatar · name · + * followers) -> pick. Reused by the "Add New Artist" dialog and (next slice) + * social enrichment, so it only searches and reports the choice — the caller + * owns what happens on select. + */ +const SpotifyArtistSearch = ({ + onSelect, + isBusy, + autoFocus, +}: SpotifyArtistSearchProps) => { + const [query, setQuery] = useState(""); + const { results, isSearching } = useSpotifyArtistSearch(query); + const hasQuery = query.trim().length >= 2; + + return ( +
+
+ + setQuery(e.target.value)} + placeholder="Search Spotify for an artist" + className="pl-9" + aria-label="Search Spotify for an artist" + disabled={isBusy} + /> +
+ +
+ {isSearching && results.length === 0 && ( +

+ Searching... +

+ )} + {!isSearching && hasQuery && results.length === 0 && ( +

+ No artists found. +

+ )} + {results.map((artist) => ( + + ))} +
+
+ ); +}; + +export default SpotifyArtistSearch; diff --git a/hooks/useAddSpotifyArtist.ts b/hooks/useAddSpotifyArtist.ts new file mode 100644 index 000000000..6549e4528 --- /dev/null +++ b/hooks/useAddSpotifyArtist.ts @@ -0,0 +1,48 @@ +"use client"; + +import { useState } from "react"; +import { usePrivy } from "@privy-io/react-auth"; +import { toast } from "sonner"; +import { addSpotifyArtist } from "@/lib/artists/addSpotifyArtist"; +import { useArtistProvider } from "@/providers/ArtistProvider"; +import { useOrganization } from "@/providers/OrganizationProvider"; +import type { SpotifyArtistResult } from "@/lib/spotify/parseSpotifyArtistResults"; + +/** + * Adds a Spotify-searched artist to the roster and refreshes + selects it. + * Wraps `addSpotifyArtist` with auth + the shared roster (ArtistProvider), + * mirroring `useAddRosterArtist`. + */ +export function useAddSpotifyArtist() { + const { getAccessToken } = usePrivy(); + const { getArtists } = useArtistProvider(); + const { selectedOrgId } = useOrganization(); + const [isAdding, setIsAdding] = useState(false); + + const add = async (artist: SpotifyArtistResult): Promise => { + setIsAdding(true); + try { + const accessToken = await getAccessToken(); + if (!accessToken) { + throw new Error("Please sign in to add an artist"); + } + const created = await addSpotifyArtist( + accessToken, + artist, + selectedOrgId, + ); + await getArtists(created.account_id); + toast.success(`${artist.name} added to your roster`); + return true; + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to add artist", + ); + return false; + } finally { + setIsAdding(false); + } + }; + + return { add, isAdding }; +} diff --git a/hooks/useArtistMode.tsx b/hooks/useArtistMode.tsx index 292ac04ea..308c8832b 100644 --- a/hooks/useArtistMode.tsx +++ b/hooks/useArtistMode.tsx @@ -1,24 +1,28 @@ import { useUserProvider } from "@/providers/UserProvder"; import { ArtistRecord } from "@/types/Artist"; import { SETTING_MODE } from "@/types/Setting"; -import { useRouter } from "next/navigation"; import { Dispatch, SetStateAction, useState } from "react"; const useArtistMode = ( clearParams: () => void, - setEditableArtist: Dispatch> + setEditableArtist: Dispatch>, ) => { const [settingMode, setSettingMode] = useState(SETTING_MODE.UPDATE); const [isOpenSettingModal, setIsOpenSettingModal] = useState(false); + const [isCreationOpen, setIsCreationOpen] = useState(false); const { email } = useUserProvider(); - const { push } = useRouter(); + // Opens the shared Spotify-search dialog (AddArtistDialog). Previously this + // pushed `/?q=create a new artist`, which the onboarding router now + // intercepts for incomplete accounts — dead-ending in an /artists loop. const toggleCreation = () => { - clearParams(); if (!email) return; - push("/?q=create a new artist"); + clearParams(); + setIsCreationOpen(true); }; + const closeCreation = () => setIsCreationOpen(false); + const toggleUpdate = (artist: ArtistRecord) => { setSettingMode(SETTING_MODE.UPDATE); setEditableArtist(artist); @@ -32,6 +36,8 @@ const useArtistMode = ( toggleUpdate, toggleSettingModal, toggleCreation, + closeCreation, + isCreationOpen, settingMode, setSettingMode, isOpenSettingModal, diff --git a/hooks/useSpotifyArtistSearch.ts b/hooks/useSpotifyArtistSearch.ts new file mode 100644 index 000000000..01c129045 --- /dev/null +++ b/hooks/useSpotifyArtistSearch.ts @@ -0,0 +1,60 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl"; +import { + parseSpotifyArtistResults, + type SpotifyArtistResult, +} from "@/lib/spotify/parseSpotifyArtistResults"; + +interface UseSpotifyArtistSearchResult { + results: SpotifyArtistResult[]; + isSearching: boolean; +} + +/** + * Debounced Spotify artist typeahead against the existing public + * `GET /api/spotify/search?type=artist` endpoint. Aborts the in-flight + * request when the query changes so results never arrive out of order. + */ +export function useSpotifyArtistSearch( + query: string, +): UseSpotifyArtistSearchResult { + const [results, setResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + + useEffect(() => { + const trimmed = query.trim(); + if (trimmed.length < 2) { + setResults([]); + setIsSearching(false); + return; + } + + setIsSearching(true); + const controller = new AbortController(); + const timer = setTimeout(async () => { + try { + const res = await fetch( + `${getClientApiBaseUrl()}/api/spotify/search?q=${encodeURIComponent( + trimmed, + )}&type=artist`, + { signal: controller.signal }, + ); + const json = await res.json(); + setResults(parseSpotifyArtistResults(json)); + } catch { + if (!controller.signal.aborted) setResults([]); + } finally { + if (!controller.signal.aborted) setIsSearching(false); + } + }, 300); + + return () => { + controller.abort(); + clearTimeout(timer); + }; + }, [query]); + + return { results, isSearching }; +} diff --git a/lib/artists/__tests__/addSpotifyArtist.test.ts b/lib/artists/__tests__/addSpotifyArtist.test.ts new file mode 100644 index 000000000..f00e36899 --- /dev/null +++ b/lib/artists/__tests__/addSpotifyArtist.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { addSpotifyArtist } from "@/lib/artists/addSpotifyArtist"; +import { createRosterArtist } from "@/lib/artists/createRosterArtist"; +import saveArtist from "@/lib/saveArtist"; + +vi.mock("@/lib/artists/createRosterArtist", () => ({ + createRosterArtist: vi.fn(), +})); +vi.mock("@/lib/saveArtist", () => ({ default: vi.fn() })); + +const spotifyArtist = { + id: "0xPoVNPnxIIUS1vrxAYV00", + name: "Del Water Gap", + imageUrl: "https://i.scdn.co/image/big", + profileUrl: "https://open.spotify.com/artist/0xPoVNPnxIIUS1vrxAYV00", + followers: 317952, +}; + +describe("addSpotifyArtist", () => { + const token = "tok"; + const created = { id: "acc-1", account_id: "acc-1", name: "Del Water Gap" }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createRosterArtist).mockResolvedValue(created as never); + vi.mocked(saveArtist).mockResolvedValue({ + artist: { ...created, image: spotifyArtist.imageUrl }, + } as never); + }); + + it("creates the artist by name then links its Spotify image + profile URL", async () => { + const result = await addSpotifyArtist(token, spotifyArtist, "org-1"); + + expect(createRosterArtist).toHaveBeenCalledWith( + token, + "Del Water Gap", + "org-1", + ); + expect(saveArtist).toHaveBeenCalledWith(token, "acc-1", { + image: "https://i.scdn.co/image/big", + profileUrls: { SPOTIFY: spotifyArtist.profileUrl }, + }); + expect(result.account_id).toBe("acc-1"); + }); + + it("omits the image when the Spotify result has none", async () => { + await addSpotifyArtist(token, { ...spotifyArtist, imageUrl: null }); + expect(saveArtist).toHaveBeenCalledWith(token, "acc-1", { + profileUrls: { SPOTIFY: spotifyArtist.profileUrl }, + }); + }); +}); diff --git a/lib/artists/addSpotifyArtist.ts b/lib/artists/addSpotifyArtist.ts new file mode 100644 index 000000000..6db49a0d4 --- /dev/null +++ b/lib/artists/addSpotifyArtist.ts @@ -0,0 +1,30 @@ +import type { ArtistRecord } from "@/types/Artist"; +import type { SpotifyArtistResult } from "@/lib/spotify/parseSpotifyArtistResults"; +import { createRosterArtist } from "@/lib/artists/createRosterArtist"; +import saveArtist from "@/lib/saveArtist"; + +/** + * Adds a Spotify-searched artist to the roster with real data: creates the + * artist by name (`POST /api/artists`), then links its Spotify profile URL and + * avatar image (`PATCH /api/artists/{id}`) so the roster card and reports have + * the correct image + a Spotify social to enrich. Reuses the existing endpoints + * rather than a bespoke create path. + */ +export async function addSpotifyArtist( + accessToken: string, + artist: SpotifyArtistResult, + orgId?: string | null, +): Promise { + const created = await createRosterArtist(accessToken, artist.name, orgId); + + const { artist: updated } = await saveArtist( + accessToken, + created.account_id, + { + ...(artist.imageUrl ? { image: artist.imageUrl } : {}), + profileUrls: { SPOTIFY: artist.profileUrl }, + }, + ); + + return updated ?? created; +} diff --git a/lib/spotify/__tests__/parseSpotifyArtistResults.test.ts b/lib/spotify/__tests__/parseSpotifyArtistResults.test.ts new file mode 100644 index 000000000..c4e1f9211 --- /dev/null +++ b/lib/spotify/__tests__/parseSpotifyArtistResults.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { parseSpotifyArtistResults } from "@/lib/spotify/parseSpotifyArtistResults"; + +const item = { + id: "0xPoVNPnxIIUS1vrxAYV00", + name: "Del Water Gap", + external_urls: { + spotify: "https://open.spotify.com/artist/0xPoVNPnxIIUS1vrxAYV00", + }, + followers: { total: 317952 }, + images: [ + { url: "https://i.scdn.co/image/big", height: 640, width: 640 }, + { url: "https://i.scdn.co/image/small", height: 160, width: 160 }, + ], +}; + +describe("parseSpotifyArtistResults", () => { + it("maps the search envelope to id/name/imageUrl/profileUrl/followers", () => { + const results = parseSpotifyArtistResults({ artists: { items: [item] } }); + expect(results).toEqual([ + { + id: "0xPoVNPnxIIUS1vrxAYV00", + name: "Del Water Gap", + imageUrl: "https://i.scdn.co/image/big", + profileUrl: "https://open.spotify.com/artist/0xPoVNPnxIIUS1vrxAYV00", + followers: 317952, + }, + ]); + }); + + it("uses the first (largest) image and nulls when there are none", () => { + const noImage = { ...item, images: [] }; + const [r] = parseSpotifyArtistResults({ artists: { items: [noImage] } }); + expect(r.imageUrl).toBeNull(); + }); + + it("nulls followers when the count is missing", () => { + const noFollowers = { ...item, followers: undefined }; + const [r] = parseSpotifyArtistResults({ + artists: { items: [noFollowers] }, + }); + expect(r.followers).toBeNull(); + }); + + it("falls back to a constructed profile URL when external_urls is absent", () => { + const noUrl = { ...item, external_urls: undefined }; + const [r] = parseSpotifyArtistResults({ artists: { items: [noUrl] } }); + expect(r.profileUrl).toBe( + "https://open.spotify.com/artist/0xPoVNPnxIIUS1vrxAYV00", + ); + }); + + it("drops items missing an id or name, and returns [] for malformed input", () => { + expect( + parseSpotifyArtistResults({ + artists: { items: [{ id: "x" }, { name: "y" }, item] }, + }), + ).toHaveLength(1); + expect(parseSpotifyArtistResults(null)).toEqual([]); + expect(parseSpotifyArtistResults({})).toEqual([]); + }); +}); diff --git a/lib/spotify/parseSpotifyArtistResults.ts b/lib/spotify/parseSpotifyArtistResults.ts new file mode 100644 index 000000000..b252e763c --- /dev/null +++ b/lib/spotify/parseSpotifyArtistResults.ts @@ -0,0 +1,51 @@ +export interface SpotifyArtistResult { + id: string; + name: string; + imageUrl: string | null; + profileUrl: string; + followers: number | null; +} + +interface RawSpotifyArtist { + id?: unknown; + name?: unknown; + external_urls?: { spotify?: unknown }; + followers?: { total?: unknown }; + images?: Array<{ url?: unknown }>; +} + +/** + * Maps the `GET /api/spotify/search?type=artist` envelope + * (`{ artists: { items: [...] } }`) to the flat shape the artist-search UI + * renders. Spotify returns images largest-first, so the first is the avatar. + * Items without an id or name are dropped; malformed input yields []. + */ +export function parseSpotifyArtistResults( + json: unknown, +): SpotifyArtistResult[] { + const items = (json as { artists?: { items?: unknown } })?.artists?.items; + if (!Array.isArray(items)) return []; + + return items.flatMap((raw: RawSpotifyArtist) => { + const id = typeof raw?.id === "string" ? raw.id : null; + const name = typeof raw?.name === "string" ? raw.name : null; + if (!id || !name) return []; + + const spotifyUrl = raw.external_urls?.spotify; + const firstImage = raw.images?.[0]?.url; + const followers = raw.followers?.total; + + return [ + { + id, + name, + imageUrl: typeof firstImage === "string" ? firstImage : null, + profileUrl: + typeof spotifyUrl === "string" + ? spotifyUrl + : `https://open.spotify.com/artist/${id}`, + followers: typeof followers === "number" ? followers : null, + }, + ]; + }); +} From 5b56a8b7df39cfe0e3a4c67633e2371aacac89ed Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 21 Jul 2026 19:37:39 -0500 Subject: [PATCH 2/2] =?UTF-8?q?refactor(artists):=20reuse=20canonical=20Sp?= =?UTF-8?q?otify=20types;=20drop=20redundant=20parser=20(=E2=88=92106=20LO?= =?UTF-8?q?C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplification pass on the add-artist slice: - Delete lib/spotify/parseSpotifyArtistResults.ts (+ its 5 tests) and the custom SpotifyArtistResult type — types/spotify.ts already exports SpotifySearchResponse ({ artists?: { items: SpotifyArtistSearchResult[] } }) and SpotifyArtistSearchResult (id/name/external_urls/images/followers). useSpotifyArtistSearch now reads `data.artists?.items` directly and returns the canonical type; the component and addSpotifyArtist read images[0].url / followers.total / external_urls.spotify inline. Kept (checked, "no simpler"): addSpotifyArtist's two-step create→link, because POST /api/artists only accepts a name; and useSpotifyArtistSearch, since there is no existing debounced-search hook to reuse. Net -106 LOC; behavior unchanged; addSpotifyArtist 2/2 green, tsc 0 new. Co-Authored-By: Claude Opus 4.8 (1M context) --- components/Artists/AddArtistDialog.tsx | 4 +- components/Artists/SpotifyArtistSearch.tsx | 12 ++-- hooks/useAddSpotifyArtist.ts | 4 +- hooks/useSpotifyArtistSearch.ts | 16 ++--- .../__tests__/addSpotifyArtist.test.ts | 23 ++++--- lib/artists/addSpotifyArtist.ts | 16 ++--- .../parseSpotifyArtistResults.test.ts | 62 ------------------- lib/spotify/parseSpotifyArtistResults.ts | 51 --------------- 8 files changed, 41 insertions(+), 147 deletions(-) delete mode 100644 lib/spotify/__tests__/parseSpotifyArtistResults.test.ts delete mode 100644 lib/spotify/parseSpotifyArtistResults.ts diff --git a/components/Artists/AddArtistDialog.tsx b/components/Artists/AddArtistDialog.tsx index 2a855b0f7..8eac4e9dc 100644 --- a/components/Artists/AddArtistDialog.tsx +++ b/components/Artists/AddArtistDialog.tsx @@ -10,7 +10,7 @@ import { import { useArtistProvider } from "@/providers/ArtistProvider"; import { useAddSpotifyArtist } from "@/hooks/useAddSpotifyArtist"; import SpotifyArtistSearch from "./SpotifyArtistSearch"; -import type { SpotifyArtistResult } from "@/lib/spotify/parseSpotifyArtistResults"; +import type { SpotifyArtistSearchResult } from "@/types/spotify"; /** * Global "Add New Artist" dialog: search Spotify -> pick -> add to the roster. @@ -21,7 +21,7 @@ const AddArtistDialog = () => { const { isCreationOpen, closeCreation } = useArtistProvider(); const { add, isAdding } = useAddSpotifyArtist(); - const handleSelect = async (artist: SpotifyArtistResult) => { + const handleSelect = async (artist: SpotifyArtistSearchResult) => { const added = await add(artist); if (added) closeCreation(); }; diff --git a/components/Artists/SpotifyArtistSearch.tsx b/components/Artists/SpotifyArtistSearch.tsx index 7c875c53b..b3ab0baa3 100644 --- a/components/Artists/SpotifyArtistSearch.tsx +++ b/components/Artists/SpotifyArtistSearch.tsx @@ -5,10 +5,10 @@ import { Loader, Search } from "lucide-react"; import { Input } from "@/components/ui/input"; import { useSpotifyArtistSearch } from "@/hooks/useSpotifyArtistSearch"; import formatFollowerCount from "@/lib/utils/formatFollowerCount"; -import type { SpotifyArtistResult } from "@/lib/spotify/parseSpotifyArtistResults"; +import type { SpotifyArtistSearchResult } from "@/types/spotify"; interface SpotifyArtistSearchProps { - onSelect: (artist: SpotifyArtistResult) => void; + onSelect: (artist: SpotifyArtistSearchResult) => void; /** Disables input + results while an add is in flight. */ isBusy?: boolean; autoFocus?: boolean; @@ -69,10 +69,10 @@ const SpotifyArtistSearch = ({ onClick={() => onSelect(artist)} className="flex items-center gap-3 rounded-lg p-2 text-left hover:bg-muted disabled:opacity-50" > - {artist.imageUrl ? ( + {artist.images?.[0]?.url ? ( // eslint-disable-next-line @next/next/no-img-element @@ -83,9 +83,9 @@ const SpotifyArtistSearch = ({

{artist.name}

- {artist.followers !== null && ( + {typeof artist.followers?.total === "number" && (

- {formatFollowerCount(artist.followers)} followers + {formatFollowerCount(artist.followers.total)} followers

)}
diff --git a/hooks/useAddSpotifyArtist.ts b/hooks/useAddSpotifyArtist.ts index 6549e4528..23e270d66 100644 --- a/hooks/useAddSpotifyArtist.ts +++ b/hooks/useAddSpotifyArtist.ts @@ -6,7 +6,7 @@ import { toast } from "sonner"; import { addSpotifyArtist } from "@/lib/artists/addSpotifyArtist"; import { useArtistProvider } from "@/providers/ArtistProvider"; import { useOrganization } from "@/providers/OrganizationProvider"; -import type { SpotifyArtistResult } from "@/lib/spotify/parseSpotifyArtistResults"; +import type { SpotifyArtistSearchResult } from "@/types/spotify"; /** * Adds a Spotify-searched artist to the roster and refreshes + selects it. @@ -19,7 +19,7 @@ export function useAddSpotifyArtist() { const { selectedOrgId } = useOrganization(); const [isAdding, setIsAdding] = useState(false); - const add = async (artist: SpotifyArtistResult): Promise => { + const add = async (artist: SpotifyArtistSearchResult): Promise => { setIsAdding(true); try { const accessToken = await getAccessToken(); diff --git a/hooks/useSpotifyArtistSearch.ts b/hooks/useSpotifyArtistSearch.ts index 01c129045..225b0b3e5 100644 --- a/hooks/useSpotifyArtistSearch.ts +++ b/hooks/useSpotifyArtistSearch.ts @@ -2,13 +2,13 @@ import { useEffect, useState } from "react"; import { getClientApiBaseUrl } from "@/lib/api/getClientApiBaseUrl"; -import { - parseSpotifyArtistResults, - type SpotifyArtistResult, -} from "@/lib/spotify/parseSpotifyArtistResults"; +import type { + SpotifyArtistSearchResult, + SpotifySearchResponse, +} from "@/types/spotify"; interface UseSpotifyArtistSearchResult { - results: SpotifyArtistResult[]; + results: SpotifyArtistSearchResult[]; isSearching: boolean; } @@ -20,7 +20,7 @@ interface UseSpotifyArtistSearchResult { export function useSpotifyArtistSearch( query: string, ): UseSpotifyArtistSearchResult { - const [results, setResults] = useState([]); + const [results, setResults] = useState([]); const [isSearching, setIsSearching] = useState(false); useEffect(() => { @@ -41,8 +41,8 @@ export function useSpotifyArtistSearch( )}&type=artist`, { signal: controller.signal }, ); - const json = await res.json(); - setResults(parseSpotifyArtistResults(json)); + const data: SpotifySearchResponse = await res.json(); + setResults(data.artists?.items ?? []); } catch { if (!controller.signal.aborted) setResults([]); } finally { diff --git a/lib/artists/__tests__/addSpotifyArtist.test.ts b/lib/artists/__tests__/addSpotifyArtist.test.ts index f00e36899..e0aa8057c 100644 --- a/lib/artists/__tests__/addSpotifyArtist.test.ts +++ b/lib/artists/__tests__/addSpotifyArtist.test.ts @@ -2,18 +2,25 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { addSpotifyArtist } from "@/lib/artists/addSpotifyArtist"; import { createRosterArtist } from "@/lib/artists/createRosterArtist"; import saveArtist from "@/lib/saveArtist"; +import type { SpotifyArtistSearchResult } from "@/types/spotify"; vi.mock("@/lib/artists/createRosterArtist", () => ({ createRosterArtist: vi.fn(), })); vi.mock("@/lib/saveArtist", () => ({ default: vi.fn() })); -const spotifyArtist = { +const spotifyArtist: SpotifyArtistSearchResult = { id: "0xPoVNPnxIIUS1vrxAYV00", name: "Del Water Gap", - imageUrl: "https://i.scdn.co/image/big", - profileUrl: "https://open.spotify.com/artist/0xPoVNPnxIIUS1vrxAYV00", - followers: 317952, + type: "artist", + uri: "spotify:artist:0xPoVNPnxIIUS1vrxAYV00", + external_urls: { + spotify: "https://open.spotify.com/artist/0xPoVNPnxIIUS1vrxAYV00", + }, + images: [{ url: "https://i.scdn.co/image/big", height: 640, width: 640 }], + popularity: 60, + genres: [], + followers: { href: null, total: 317952 }, }; describe("addSpotifyArtist", () => { @@ -24,7 +31,7 @@ describe("addSpotifyArtist", () => { vi.clearAllMocks(); vi.mocked(createRosterArtist).mockResolvedValue(created as never); vi.mocked(saveArtist).mockResolvedValue({ - artist: { ...created, image: spotifyArtist.imageUrl }, + artist: { ...created, image: "https://i.scdn.co/image/big" }, } as never); }); @@ -38,15 +45,15 @@ describe("addSpotifyArtist", () => { ); expect(saveArtist).toHaveBeenCalledWith(token, "acc-1", { image: "https://i.scdn.co/image/big", - profileUrls: { SPOTIFY: spotifyArtist.profileUrl }, + profileUrls: { SPOTIFY: spotifyArtist.external_urls.spotify }, }); expect(result.account_id).toBe("acc-1"); }); it("omits the image when the Spotify result has none", async () => { - await addSpotifyArtist(token, { ...spotifyArtist, imageUrl: null }); + await addSpotifyArtist(token, { ...spotifyArtist, images: [] }); expect(saveArtist).toHaveBeenCalledWith(token, "acc-1", { - profileUrls: { SPOTIFY: spotifyArtist.profileUrl }, + profileUrls: { SPOTIFY: spotifyArtist.external_urls.spotify }, }); }); }); diff --git a/lib/artists/addSpotifyArtist.ts b/lib/artists/addSpotifyArtist.ts index 6db49a0d4..441d0995a 100644 --- a/lib/artists/addSpotifyArtist.ts +++ b/lib/artists/addSpotifyArtist.ts @@ -1,28 +1,28 @@ import type { ArtistRecord } from "@/types/Artist"; -import type { SpotifyArtistResult } from "@/lib/spotify/parseSpotifyArtistResults"; +import type { SpotifyArtistSearchResult } from "@/types/spotify"; import { createRosterArtist } from "@/lib/artists/createRosterArtist"; import saveArtist from "@/lib/saveArtist"; /** * Adds a Spotify-searched artist to the roster with real data: creates the - * artist by name (`POST /api/artists`), then links its Spotify profile URL and - * avatar image (`PATCH /api/artists/{id}`) so the roster card and reports have - * the correct image + a Spotify social to enrich. Reuses the existing endpoints - * rather than a bespoke create path. + * artist by name (`POST /api/artists`, which only accepts a name), then links + * its Spotify profile URL and avatar image (`PATCH /api/artists/{id}`) so the + * roster card and reports have the correct image + a Spotify social to enrich. */ export async function addSpotifyArtist( accessToken: string, - artist: SpotifyArtistResult, + artist: SpotifyArtistSearchResult, orgId?: string | null, ): Promise { const created = await createRosterArtist(accessToken, artist.name, orgId); + const imageUrl = artist.images?.[0]?.url; const { artist: updated } = await saveArtist( accessToken, created.account_id, { - ...(artist.imageUrl ? { image: artist.imageUrl } : {}), - profileUrls: { SPOTIFY: artist.profileUrl }, + ...(imageUrl ? { image: imageUrl } : {}), + profileUrls: { SPOTIFY: artist.external_urls.spotify }, }, ); diff --git a/lib/spotify/__tests__/parseSpotifyArtistResults.test.ts b/lib/spotify/__tests__/parseSpotifyArtistResults.test.ts deleted file mode 100644 index c4e1f9211..000000000 --- a/lib/spotify/__tests__/parseSpotifyArtistResults.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseSpotifyArtistResults } from "@/lib/spotify/parseSpotifyArtistResults"; - -const item = { - id: "0xPoVNPnxIIUS1vrxAYV00", - name: "Del Water Gap", - external_urls: { - spotify: "https://open.spotify.com/artist/0xPoVNPnxIIUS1vrxAYV00", - }, - followers: { total: 317952 }, - images: [ - { url: "https://i.scdn.co/image/big", height: 640, width: 640 }, - { url: "https://i.scdn.co/image/small", height: 160, width: 160 }, - ], -}; - -describe("parseSpotifyArtistResults", () => { - it("maps the search envelope to id/name/imageUrl/profileUrl/followers", () => { - const results = parseSpotifyArtistResults({ artists: { items: [item] } }); - expect(results).toEqual([ - { - id: "0xPoVNPnxIIUS1vrxAYV00", - name: "Del Water Gap", - imageUrl: "https://i.scdn.co/image/big", - profileUrl: "https://open.spotify.com/artist/0xPoVNPnxIIUS1vrxAYV00", - followers: 317952, - }, - ]); - }); - - it("uses the first (largest) image and nulls when there are none", () => { - const noImage = { ...item, images: [] }; - const [r] = parseSpotifyArtistResults({ artists: { items: [noImage] } }); - expect(r.imageUrl).toBeNull(); - }); - - it("nulls followers when the count is missing", () => { - const noFollowers = { ...item, followers: undefined }; - const [r] = parseSpotifyArtistResults({ - artists: { items: [noFollowers] }, - }); - expect(r.followers).toBeNull(); - }); - - it("falls back to a constructed profile URL when external_urls is absent", () => { - const noUrl = { ...item, external_urls: undefined }; - const [r] = parseSpotifyArtistResults({ artists: { items: [noUrl] } }); - expect(r.profileUrl).toBe( - "https://open.spotify.com/artist/0xPoVNPnxIIUS1vrxAYV00", - ); - }); - - it("drops items missing an id or name, and returns [] for malformed input", () => { - expect( - parseSpotifyArtistResults({ - artists: { items: [{ id: "x" }, { name: "y" }, item] }, - }), - ).toHaveLength(1); - expect(parseSpotifyArtistResults(null)).toEqual([]); - expect(parseSpotifyArtistResults({})).toEqual([]); - }); -}); diff --git a/lib/spotify/parseSpotifyArtistResults.ts b/lib/spotify/parseSpotifyArtistResults.ts deleted file mode 100644 index b252e763c..000000000 --- a/lib/spotify/parseSpotifyArtistResults.ts +++ /dev/null @@ -1,51 +0,0 @@ -export interface SpotifyArtistResult { - id: string; - name: string; - imageUrl: string | null; - profileUrl: string; - followers: number | null; -} - -interface RawSpotifyArtist { - id?: unknown; - name?: unknown; - external_urls?: { spotify?: unknown }; - followers?: { total?: unknown }; - images?: Array<{ url?: unknown }>; -} - -/** - * Maps the `GET /api/spotify/search?type=artist` envelope - * (`{ artists: { items: [...] } }`) to the flat shape the artist-search UI - * renders. Spotify returns images largest-first, so the first is the avatar. - * Items without an id or name are dropped; malformed input yields []. - */ -export function parseSpotifyArtistResults( - json: unknown, -): SpotifyArtistResult[] { - const items = (json as { artists?: { items?: unknown } })?.artists?.items; - if (!Array.isArray(items)) return []; - - return items.flatMap((raw: RawSpotifyArtist) => { - const id = typeof raw?.id === "string" ? raw.id : null; - const name = typeof raw?.name === "string" ? raw.name : null; - if (!id || !name) return []; - - const spotifyUrl = raw.external_urls?.spotify; - const firstImage = raw.images?.[0]?.url; - const followers = raw.followers?.total; - - return [ - { - id, - name, - imageUrl: typeof firstImage === "string" ? firstImage : null, - profileUrl: - typeof spotifyUrl === "string" - ? spotifyUrl - : `https://open.spotify.com/artist/${id}`, - followers: typeof followers === "number" ? followers : null, - }, - ]; - }); -}