Problem Statement
Admins fill in missing Spotify/SoundCloud links for artists one at a time in the Link Wizard (src/pages/admin/festivals/LinkWizard/), but today that means manually finding each artist's profile on Spotify/SoundCloud and pasting the URL. This is slow and error-prone across an edition with dozens of missing links.
Solution
Auto-search SoundCloud (and later Spotify) for each artist by name, and let the admin pick from a short list of candidates instead of hunting for and pasting URLs by hand. Selecting a candidate also offers to fill in the artist's image and description if those are currently empty — turning most of the wizard into a quick "confirm the right match" flow rather than manual data entry, while always falling back to the existing manual URL input when no good match is found.
This spec covers the SoundCloud implementation end-to-end, with Spotify built on the same multi-provider contract as an immediate follow-up.
User Stories
- As an admin running the Link Wizard, I want SoundCloud match candidates to already be loaded when I open the wizard, so that I don't wait on a search for the first artists I review.
- As an admin, I want only the first 10 artists' candidates fetched up front, so that opening the wizard for a large edition isn't slow.
- As an admin, I want the next batch of candidates prefetched before I reach it, so that I never see a loading spinner mid-flow for artists near a batch boundary.
- As an admin, I want to see up to 3 candidate cards (image, name, follower count) per provider for each artist, so that I can visually confirm which one is the right match.
- As an admin, I want candidates matched by the artist's name as stored in our system, so that the search reflects the data I already trust.
- As an admin, when no candidates are found for an artist, I want to fall straight back to the manual URL input, so that I'm never blocked.
- As an admin, when none of the shown candidates are the right artist, I want to type a custom search query and re-run the search for just that artist, so that I can recover from name mismatches (typos, alternate spellings) without leaving the wizard.
- As an admin, I want selecting a candidate to populate the URL field (not save immediately), so that I can still review or edit before committing, consistent with how manual entry works today.
- As an admin, I want selecting a candidate to also stage the artist's image and description if those fields are currently empty, so that I get richer artist data without extra manual work.
- As an admin, I want existing image/description data to never be silently overwritten by a provider match, so that curated data isn't lost.
- As an admin, if I select both a SoundCloud and a Spotify candidate for the same artist, I want the first one I pick to win the shared image/description fields, so that behavior is predictable without needing to build per-field provider choice UI.
- As an admin, I want to see a provider's genre tags on a candidate card as read-only context, so that I have another signal to judge whether it's the right artist, without those tags being written anywhere.
- As an admin, I want "Save & Next" to remain the single commit action for both manually-entered and candidate-selected data, so that the save behavior I already know doesn't change.
- As an admin, if the search fails for one artist in a batch (rate limit, network error, no match), I want the rest of that batch's results to still show up normally, so that one failure doesn't block the whole page.
- As a developer, I want the search edge function built on a provider-agnostic contract (
provider?: "soundcloud" | "spotify", omitted = search all providers) from the start, so that adding the Spotify adapter later requires no client-side or contract changes.
Implementation Decisions
- New Supabase edge function
search-artist-links: accepts { artistNames: string[], provider?: "soundcloud" | "spotify" } (provider omitted = search all supported providers) and returns up to 3 candidates per provider per artist name. Reuses the existing _shared/soundcloud-api/ auth/fetch helpers (getSoundCloudAccessToken, fetchSoundCloudAPI) for the SoundCloud adapter, adding a /users?q= search call. The Spotify adapter is stubbed to return an empty result set in this PR — its implementation is a separate, immediate follow-up PR that fills in the same contract.
- Candidate shape: normalized per provider to
{ name, url, imageUrl, followers, genres } — genres is display-only, sourced from the provider (e.g. SoundCloud's tags), never mapped to the music_genres table.
- Batching: the client requests candidates for the first 10 artists (by wizard order) missing links in the edition when the wizard loads. When the admin's position nears the end of the current batch (~8 of 10), the client prefetches the next 10. This is client-driven pagination against the edge function, not a single mega-batch call.
- Partial failure handling: a search failure for one artist within a batch does not fail the batch — other artists' results in the same call still return normally; the failed artist falls back to "no matches found."
- Candidate merge logic (the core seam — see Testing Decisions): a pure function computes what to stage onto the artist's pending update when a candidate is selected. Rules:
- Always sets the provider's URL field (
spotify_url / soundcloud_url).
- Sets
image_url and description only if they are not already set (either pre-existing on the artist, or already staged by an earlier candidate selection in the same step) — never overwrites existing data.
- Never writes genres anywhere; genre tags are display-only on the candidate card.
- If both providers' candidates are selected for the same artist, the first selection made wins the shared
image_url/description fields.
LinkWizardStep UI changes: for each missing field, candidate cards (image, name, follower count, genre tags) render above the existing URL Input. Selecting a card populates the input below via the merge function (still manually editable afterward). A loading skeleton shows while that artist's batch is in flight. A "search again" affordance next to the input lets the admin type a custom query and re-invoke the search for just that artist/provider when no candidate fits. No changes to the existing "Save & Next" / "Skip" / "Previous" behavior — saving is unchanged and remains the single commit point for both manual and candidate-derived data.
- Query key / API module: follows ADR 0001 — a new feature-sliced module (e.g.
src/api/artistSearch/) with a query-key factory and a query hook (useSearchArtistLinksQuery or similar) wrapping supabase.functions.invoke("search-artist-links", ...), batched per the pagination rule above.
Testing Decisions
Good tests here assert on external behavior — inputs and outputs of pure functions — not on implementation details like fetch call counts or React internals. Two seams, chosen to concentrate coverage where the business rules actually live:
- Primary seam — candidate merge logic (Vitest unit test, client-side): the pure function that computes staged artist updates from a selected candidate (always-set URL; fill-if-empty image/description; first-provider-wins; no genre writes) is where nearly every decision from this spec lives, so it gets the bulk of test coverage. Follows the existing pattern in
src/api/artists/useArtistsMissingLinksByEdition.test.ts, which extracts a pure selector (selectArtistsMissingLinks) out of the query hook and tests it directly with hand-built fixtures, with no Supabase or React Query mocking.
- Secondary seam — provider response normalization (Deno unit test, edge function-side): a pure function per provider (e.g.
normalizeSoundCloudSearchResult(rawUser) -> Candidate) tested against hand-built fixture JSON, with no real network call and no mocking of the SoundCloud fetch/auth plumbing.
- Everything else — edge function HTTP orchestration/batching, lazy-load/prefetch timing, and
LinkWizardStep rendering — is left untested at the unit level, consistent with sync-artist-data (no fetch-level tests) and LinkWizardStep.tsx (currently untested) today.
Out of Scope
- Spotify adapter implementation (follow-up PR; only the contract/stub lands here).
- Auto-sync of other artist fields after saving.
- Bulk/CSV import.
- Cross-edition filtering or dedup/merge of artists.
- Fuzzy-matching or auto-mapping provider genre tags onto the
music_genres table.
- Per-field provider choice UI for image/description when both providers are selected (first-selection-wins is the only rule; no toggle UI).
- Confidence-score-based filtering of candidates (always show up to 3, let the admin judge).
- Query augmentation beyond artist name (e.g. genre/stage as search hints).
Further Notes
- SoundCloud search credentials are expected to be reusable from the existing
sync-artist-data edge function's client credentials (SOUNDCLOUD_CLIENT_ID/SOUNDCLOUD_CLIENT_SECRET).
- Spotify search will require a new Client Credentials OAuth app and new Supabase secrets in its follow-up PR — the user has confirmed Spotify API access is available for that work.
- This spec builds directly on the existing Link Wizard (
src/pages/admin/festivals/LinkWizard/LinkWizardStep.tsx, LinkWizardTable.tsx) and the existing useArtistsMissingLinksByEdition query — no changes to how artists enter or leave the missing-links list.
Problem Statement
Admins fill in missing Spotify/SoundCloud links for artists one at a time in the Link Wizard (
src/pages/admin/festivals/LinkWizard/), but today that means manually finding each artist's profile on Spotify/SoundCloud and pasting the URL. This is slow and error-prone across an edition with dozens of missing links.Solution
Auto-search SoundCloud (and later Spotify) for each artist by name, and let the admin pick from a short list of candidates instead of hunting for and pasting URLs by hand. Selecting a candidate also offers to fill in the artist's image and description if those are currently empty — turning most of the wizard into a quick "confirm the right match" flow rather than manual data entry, while always falling back to the existing manual URL input when no good match is found.
This spec covers the SoundCloud implementation end-to-end, with Spotify built on the same multi-provider contract as an immediate follow-up.
User Stories
provider?: "soundcloud" | "spotify", omitted = search all providers) from the start, so that adding the Spotify adapter later requires no client-side or contract changes.Implementation Decisions
search-artist-links: accepts{ artistNames: string[], provider?: "soundcloud" | "spotify" }(provider omitted = search all supported providers) and returns up to 3 candidates per provider per artist name. Reuses the existing_shared/soundcloud-api/auth/fetch helpers (getSoundCloudAccessToken,fetchSoundCloudAPI) for the SoundCloud adapter, adding a/users?q=search call. The Spotify adapter is stubbed to return an empty result set in this PR — its implementation is a separate, immediate follow-up PR that fills in the same contract.{ name, url, imageUrl, followers, genres }—genresis display-only, sourced from the provider (e.g. SoundCloud's tags), never mapped to themusic_genrestable.spotify_url/soundcloud_url).image_urlanddescriptiononly if they are not already set (either pre-existing on the artist, or already staged by an earlier candidate selection in the same step) — never overwrites existing data.image_url/descriptionfields.LinkWizardStepUI changes: for each missing field, candidate cards (image, name, follower count, genre tags) render above the existing URLInput. Selecting a card populates the input below via the merge function (still manually editable afterward). A loading skeleton shows while that artist's batch is in flight. A "search again" affordance next to the input lets the admin type a custom query and re-invoke the search for just that artist/provider when no candidate fits. No changes to the existing "Save & Next" / "Skip" / "Previous" behavior — saving is unchanged and remains the single commit point for both manual and candidate-derived data.src/api/artistSearch/) with a query-key factory and a query hook (useSearchArtistLinksQueryor similar) wrappingsupabase.functions.invoke("search-artist-links", ...), batched per the pagination rule above.Testing Decisions
Good tests here assert on external behavior — inputs and outputs of pure functions — not on implementation details like fetch call counts or React internals. Two seams, chosen to concentrate coverage where the business rules actually live:
src/api/artists/useArtistsMissingLinksByEdition.test.ts, which extracts a pure selector (selectArtistsMissingLinks) out of the query hook and tests it directly with hand-built fixtures, with no Supabase or React Query mocking.normalizeSoundCloudSearchResult(rawUser) -> Candidate) tested against hand-built fixture JSON, with no real network call and no mocking of the SoundCloud fetch/auth plumbing.LinkWizardSteprendering — is left untested at the unit level, consistent withsync-artist-data(no fetch-level tests) andLinkWizardStep.tsx(currently untested) today.Out of Scope
music_genrestable.Further Notes
sync-artist-dataedge function's client credentials (SOUNDCLOUD_CLIENT_ID/SOUNDCLOUD_CLIENT_SECRET).src/pages/admin/festivals/LinkWizard/LinkWizardStep.tsx,LinkWizardTable.tsx) and the existinguseArtistsMissingLinksByEditionquery — no changes to how artists enter or leave the missing-links list.