DEV Community

Cover image for Construí um gerador de playlists no Spotify com Claude
Leo Garcez
Leo Garcez

Posted on

Construí um gerador de playlists no Spotify com Claude

Eu queria digitar “noite chuvosa, meio melancólica” e receber uma playlist perfeita. Então eu construí isso.

TL;DR

  • Eu construí um gerador de playlists com IA usando Claude + Spotify API
  • Você descreve um humor → ele gera 50 músicas → salva direto no Spotify
  • O maior problema foi OAuth local com NextAuth (sim, foi um inferno)
  • Claude funciona bem, mas precisa de bastante controle pra não inventar músicas
  • Streaming com SSE melhorou muito a UX

Links:

Índice

A Ideia

Fazer um gerador de playlists que realmente entendesse vibes, não só tags de gênero. Algo tipo: você digita "tarde fria num apartamento vazio" e recebe uma playlist boa de verdade, já salva no seu Spotify.

O conceito:

  1. Usuário descreve um humor
  2. Claude retorna 50 músicas em JSON
  3. App busca cada faixa no Spotify
  4. Cria e salva a playlist na conta do usuário

Três APIs, um app.

A Stack

Next.js 14 (App Router)
NextAuth v5 beta
Anthropic Claude API (claude-sonnet-4-6)
Spotify Web API
Supabase (PostgreSQL)
TypeScript + Tailwind CSS + Framer Motion
Enter fullscreen mode Exit fullscreen mode

Escolhi o Claude porque ele tá bem em alta agora e, na prática, é confiável, alucina menos do que eu esperava pra esse tipo de tarefa. Ele é um pouco menos criativo que o GPT nas recomendações, mas compensa sendo mais previsível no formato das respostas, o que importa bastante quando você tá parseando JSON.

O Problema de Trabalhar com OAuth Localmente

Isso me custou algumas horas e sessões de debug. Vou detalhar porque tem várias camadas de problema e você provavelmente vai bater na mesma parede se estiver usando NextAuth v5 com Spotify.

O Spotify não aceita localhost

O Spotify proíbe localhost como redirect URI pra URIs de loopback. A solução é usar 127.0.0.1. Cadastrei http://127.0.0.1:3000/api/auth/callback/spotify no dashboard e setei AUTH_URL=http://127.0.0.1:3000 no .env.local.

Não foi suficiente.

O NextRequest normaliza URLs pra localhost

O Next.js, independente do host que você passa pra next dev -H, normaliza req.url e req.nextUrl.href de volta pra localhost em desenvolvimento. Isso não é bug documentado — é comportamento interno do framework.

O NextAuth v5 tem um utilitário chamado reqWithEnvURL que tenta corrigir exatamente isso, mas falha silenciosamente:

// Dentro do next-auth — simplificado
function reqWithEnvURL(req: NextRequest): NextRequest {
  const url = process.env.AUTH_URL ?? req.url;
  return new NextRequest(url, req); // ← o construtor normaliza de volta pra localhost
}
Enter fullscreen mode Exit fullscreen mode

Mesmo passando 127.0.0.1 explicitamente, o construtor do NextRequest sobrescreve. A "correção" não funciona.

Dois momentos onde o redirect URI importa

O OAuth tem dois momentos distintos onde o redirect URI aparece:

  1. Requisição de autorização — a URL do Spotify onde o usuário loga. O redirect_uri aqui vem dos seus params de configuração, então você pode hardcodar 127.0.0.1 na config do provider. Isso funcionou.

  2. Troca de token — quando o Spotify manda o código de volta, o @auth/core envia um POST pra trocar o código por tokens. O redirect_uri nessa requisição vem de provider.callbackUrl, que é derivado de params.url.origin — ou seja, da URL da requisição de callback. Se essa URL ainda diz localhost, a troca falha com invalid_grant: Invalid redirect URI.

O sintoma era desconcertante: a URL de autorização mostrava 127.0.0.1 corretamente, mas a troca de token continuava falhando. Fui atrás do código do @auth/core pra entender o que tava acontecendo.

A solução: Auth() direto com Request nativo

Objetos Request nativos do browser/Node não normalizam URLs. A correção é contornar os route handlers do NextAuth e chamar Auth() do @auth/core diretamente, passando um Request nativo com a URL já corrigida:

// src/app/api/auth/[...nextauth]/route.ts
import { Auth } from "@auth/core";
import { authConfig } from "../../../../../auth";
import { NextRequest } from "next/server";

function buildRequest(req: NextRequest): Request {
  const authOrigin = process.env.AUTH_URL ?? `http://${req.headers.get("host")}`;
  const fixedUrl = req.url.replace(/^https?:\/\/[^/]+/, authOrigin);

  const hasBody = req.method !== "GET" && req.method !== "HEAD";
  return new Request(fixedUrl, {
    method: req.method,
    headers: req.headers,
    body: hasBody ? req.body : undefined,
    // duplex necessário para streaming de body no Node.js
    ...(hasBody && ({ duplex: "half" } as object)),
  });
}

const handler = (req: NextRequest) =>
  Auth(buildRequest(req), authConfig as Parameters<typeof Auth>[1]);

export { handler as GET, handler as POST };
Enter fullscreen mode Exit fullscreen mode

Versão do @auth/core: ao usar Auth() diretamente, instale a versão exata que o next-auth usa internamente:

npm ls @auth/core  # veja qual versão o next-auth requer
npm install @auth/core@0.41.0 --save-exact
Enter fullscreen mode Exit fullscreen mode

Versões diferentes criam conflitos de tipo que explodem em runtime.

Configuração explícita no auth.ts: ao contornar os handlers do NextAuth, setEnvDefaults não roda mais. Configure basePath, secret e redirect_uri explicitamente:

// auth.ts
export const authConfig: NextAuthConfig = {
  trustHost: true,
  basePath: "/api/auth",
  secret: process.env.AUTH_SECRET,
  providers: [
    Spotify({
      clientId: process.env.AUTH_SPOTIFY_ID,
      clientSecret: process.env.AUTH_SPOTIFY_SECRET,
      authorization: {
        url: "https://accounts.spotify.com/authorize",
        params: {
          scope: SPOTIFY_SCOPES,
          show_dialog: true,
          redirect_uri: `${process.env.AUTH_URL}/api/auth/callback/spotify`,
        },
      },
    }),
  ],
  // ... callbacks e pages como antes
};
Enter fullscreen mode Exit fullscreen mode

Bônus: problema de domínio do cookie PKCE

Mesmo com tudo acima, pode acontecer mais uma falha: se o usuário acessa http://localhost:3000, o navegador seta o cookie PKCE pro domínio localhost. Quando o Spotify redireciona de volta pra http://127.0.0.1:3000/..., o navegador não envia o cookie — domínios diferentes — e o code_verifier some. A troca falha de novo.

A correção é garantir que localhost:3000 nunca apareça pro usuário, redirecionando via next.config.mjs:

async redirects() {
  return [
    {
      source: "/:path*",
      has: [{ type: "host", value: "localhost:3000" }],
      destination: "http://127.0.0.1:3000/:path*",
      permanent: false,
    },
    {
      source: "/",
      has: [{ type: "host", value: "localhost:3000" }],
      destination: "http://127.0.0.1:3000/",
      permanent: false,
    },
  ];
},
Enter fullscreen mode Exit fullscreen mode

Com isso, todo o fluxo de auth fica em 127.0.0.1 e os cookies PKCE chegam onde precisam chegar.

Endpoints Deprecated do Spotify

Com o auth funcionando, bati num muro de 403s.

O Spotify deprecated alguns endpoints sem muito alarde:

Antigo (deprecated) Novo
POST /playlists/{id}/tracks POST /playlists/{id}/items
GET /playlists/{id}/tracks GET /playlists/{id}/items
GET /audio-features, GET /recommendations Deprecated, sem substituto

A migração /tracks/items está documentada, mas é fácil de perder se você seguiu um tutorial de 2022. Audio features e recomendações sumindo foi mais chato — tive que construir contexto de energia de outra forma, mais sobre isso abaixo.

Spotify em Produção

Tem uma limitação que eu não vi muito discutida: no modo de desenvolvimento, o Spotify permite apenas 5 usuários autenticados E eles precisam ser adicionados manualmente via allowlist no dashboard.

Pra ir além disso, você precisa solicitar Extended Quota Mode. E o processo atual é bem pesado:

  • Entidade jurídica registrada (pessoa física não é aceita desde maio de 2025)
  • Serviço já lançado e ativo
  • Mínimo de 250.000 usuários ativos mensais
  • Viabilidade comercial

A análise pode levar até seis semanas, e sem garantia de aprovação.

Na prática, isso significa que se você tá construindo algo novo como indie dev, vai ficar travado em modo de desenvolvimento. Você consegue testar e mostrar pra até 4 pessoas além de você — e só. Vale saber disso antes de planejar algum lançamento público.

Fazendo o Claude Obedecer

O system prompt instrui o Claude a retornar apenas um array JSON, sem markdown, sem explicações. Essa parte é direta. O problema mais difícil é conseguir 50 faixas que realmente existam.

One-shot prompting

Incluir uma conversa de exemplo completa (usuário + assistente) antes do request real reduziu bastante os erros de formato, especialmente com artistas não-ingleses:

messages: [
  {
    role: "user",
    content: "Create a playlist for this mood/vibe: late night drive, nostalgic",
  },
  {
    role: "assistant",
    content: JSON.stringify([
      { title: "Drive", artist: "The Cars", reason: "Synth-pop clássico com energia perfeita de madrugada" },
      { title: "Running Up That Hill", artist: "Kate Bush", reason: "Art-pop etéreo, emocionalmente assombroso" },
      // ...
    ]),
  },
  { role: "user", content: actualUserMessage },
],
Enter fullscreen mode Exit fullscreen mode

Extração de JSON como fallback

Mesmo com prompting cuidadoso, modelos eventualmente jogam texto de introdução. Sempre extraia o array:

const match = raw.match(/\[[\s\S]*\]/);
if (!match) throw new Error("Nenhum array JSON encontrado");
const suggestions = JSON.parse(match[0]);
Enter fullscreen mode Exit fullscreen mode

Prompt Engineering Anti-Alucinação

Descobri que quanto mais músicas você pede, mais o modelo inventa títulos.

Pedindo 70 músicas, o Claude começa a criar faixas com nomes plausíveis que não existem. Com 50 e restrições explícitas, fica bem melhor.

O que adicionei ao system prompt:

EXISTÊNCIA NO SPOTIFY — CRÍTICO:
Cada música DEVE existir no Spotify. Antes de incluir uma faixa, pergunte:
"Tenho certeza que esta música existe no Spotify com este título e artista exatos?"
Se houver qualquer dúvida, escolha outra música que você tem certeza.

REGRAS DE FORMATO DO TÍTULO:
- Use apenas o título canônico limpo do lançamento
- SEM sufixos: sem "- Remastered", "- Live at...", "- Radio Edit"
- Use o nome do lançamento mais conhecido, não compilações

ARMADILHAS COMUNS DE ALUCINAÇÃO:
- Não invente títulos de músicas que parecem plausíveis mas podem não existir
- Não confunda dois artistas com nomes similares
- Não sugira deep cuts que você não tem certeza
Enter fullscreen mode Exit fullscreen mode

Também removi a abordagem de duas etapas "gerar 70, refinar pra 50". Era cara em tempo e custo, e uma única geração de 50 com boas instruções performa melhor.

O system prompt atual completo

Esse é o prompt que tá rodando em produção hoje:

You are a world-class Spotify playlist curator.

GOAL:
Generate EXACTLY 50 high-quality songs for a playlist.

OUTPUT:
Return ONLY a raw JSON array. No markdown, no explanations.

Each item:
- "title": string — the canonical Spotify title, nothing else
- "artist": string — the primary artist exactly as listed on Spotify
- "reason": string — max 10 words

SPOTIFY EXISTENCE — CRITICAL:
Every song MUST exist on Spotify. Before including a track, ask yourself:
"Am I certain this song exists on Spotify under this exact title and artist?"
If there is any doubt, pick a different song you are certain about.

TITLE FORMAT RULES:
- Use the clean, canonical release title only
- NO suffixes: no "- Remastered", "- Live at...", "- Radio Edit", "- feat. X"
- NO parentheticals unless part of the official title
- Use the most well-known release name, not compilations or bonus versions

COMMON HALLUCINATION TRAPS TO AVOID:
- Do not invent song titles that sound plausible but may not exist
- Do not confuse two artists with similar names
- Do not suggest deep cuts you are uncertain about
- Do not suggest songs only released in specific regions unavailable globally

DISTRIBUTION:
- 50% recognizable hits (high confidence they exist)
- 40% lesser-known but confirmed tracks
- 10% deep cuts you are fully certain about

DIVERSITY:
- At least 2 genres
- At least 3 decades
- Non-English tracks welcome if you are certain they are on Spotify

CURATION:
- Cohesive flow, playlist-worthy, non-random
- No duplicates

Return ONLY the JSON array. Exactly 50 items.
Enter fullscreen mode Exit fullscreen mode

Curiosidade: o prompt tá em inglês mesmo que o usuário escreva em português. O Claude entende o humor no idioma que vier e retorna os dados no formato esperado sem problema.

Melhor resolução de busca no Spotify

Mesmo com um bom prompt, algumas faixas voltam com títulos ou artistas levemente errados. Três ajustes no lado da busca:

1. Buscar 10 candidatos em vez de 1
Em vez de limit=1, buscar limit=10 e escolher o melhor match.

2. Filtro de popularidade
Pular resultados com popularity < 30 — evita gravar versões ao vivo obscuras quando a faixa correta não é encontrada:

const POPULARITY_FLOOR = 30;
const aboveFloor = candidates.filter(t => t.popularity >= POPULARITY_FLOOR);
const pool = aboveFloor.length > 0 ? aboveFloor : candidates;
Enter fullscreen mode Exit fullscreen mode

3. Fuzzy matching + seleção por popularidade
Normalizar strings e verificar correspondência bidirecional de substring, depois escolher o match com maior popularidade:

function fuzzyMatch(candidate: SpotifyTrack, suggestion: ClaudeTrackSuggestion): boolean {
  const trackName = normalizeStr(candidate.name);
  const artistName = normalizeStr(candidate.artists[0]?.name ?? "");
  const sugTitle = normalizeStr(suggestion.title);
  const sugArtist = normalizeStr(suggestion.artist);
  const titleMatch = trackName.includes(sugTitle) || sugTitle.includes(trackName);
  const artistMatch = artistName.includes(sugArtist) || sugArtist.includes(artistName);
  return titleMatch && artistMatch;
}
Enter fullscreen mode Exit fullscreen mode

Modo Related Artists

Playlists geradas por IA alucinam mais quando o humor é específico de artista — tipo "algo como Radiohead". Nesses casos, o grafo de artistas do próprio Spotify é mais confiável que o Claude.

Adicionei detecção automática: uma chamada rápida ao Claude Haiku (~0,5s) classifica o prompt antes da geração principal:

// Retorna { mode: "ai" | "related", artists: ["Radiohead", "Nick Cave"] }
const detected = await detectPlaylistMode(mood);
Enter fullscreen mode Exit fullscreen mode

Se artistas são detectados, o app:

  1. Os resolve no Spotify
  2. Busca artistas relacionados (GET /artists/{id}/related-artists)
  3. Pega top tracks de cada artista relacionado
  4. Monta uma playlist com dados reais do Spotify, sem depender do Claude pra nada

Se não detectar artistas, cai pro fluxo normal com Claude.

if (detected.mode === "related" && detected.artists.length > 0) {
  const { suggestions, resolvedSeeds } = await buildRelatedArtistsPlaylist(
    detected.artists,
    accessToken,
    energy
  );
  if (suggestions.length > 0) {
    return NextResponse.json({ suggestions, mode: "related", seedArtists: resolvedSeeds });
  }
  // fallthrough para modo AI se não encontrou nada
}
Enter fullscreen mode Exit fullscreen mode

Construindo o Perfil Musical

O app deixa usuários escolher uma playlist de referência ou seus top artistas do Spotify (último mês / 6 meses / histórico completo). Esse contexto é passado pro Claude como uma impressão digital musical.

Mandar nomes de faixas brutos confunde o modelo. Em vez disso, agregue num perfil:

// Contar frequência de artistas em todas as faixas da playlist
const artistFreq = new Map<string, { name: string; count: number }>();
for (const track of tracks) {
  const a = track.artists[0];
  const prev = artistFreq.get(a.id);
  artistFreq.set(a.id, { name: a.name, count: (prev?.count ?? 0) + 1 });
}
Enter fullscreen mode Exit fullscreen mode

Pra playlists, também busco dados de gênero dos artistas principais via GET /artists/{id} (5 chamadas paralelas), já que os itens de playlist não retornam gêneros nativamente.

Mesmo sem seleção de referência explícita, o app passa os top artistas e gêneros baseline do usuário como contexto suave pra cada geração.

A instrução no prompt mudou de "não recomende esses artistas" pra algo mais útil:

→ Biase as recomendações em direção a este DNA musical: tempo, humor e estilo de produção similar.
→ Descubra artistas com som SIMILAR — não necessariamente os mesmos artistas.
→ NÃO repita faixas já listadas acima.
Enter fullscreen mode Exit fullscreen mode

Streaming com SSE

O fluxo original: buscar todas as 50 faixas em paralelo, retornar tudo de uma vez, mostrar um spinner.

O problema é que o usuário ficava olhando "Salvando no Spotify..." por 5-10 segundos sem nenhum feedback. Server-Sent Events resolve isso — cada faixa é emitida conforme resolve:

// Na rota da API
const stream = new ReadableStream({
  async start(controller) {
    const send = (data: object) =>
      controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));

    await Promise.all(
      suggestions.map(async (suggestion) => {
        const track = await searchTrack(suggestion, accessToken);
        if (track) {
          foundTracks.push({ track, suggestion });
          send({ type: "track", track, suggestion }); // emitido imediatamente
        }
      })
    );

    // Criar playlist depois que todas as buscas terminam
    const playlist = await createPlaylist(...);
    send({ type: "done", playlist, found: foundTracks.length });
    controller.close();
  },
});

return new Response(stream, {
  headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" },
});
Enter fullscreen mode Exit fullscreen mode

A busca paralela continua na velocidade máxima. Mas agora o cliente vê cada faixa aparecer com album art conforme resolve, com uma barra de progresso ao vivo — em vez de um spinner em branco por 10 segundos.

O Que Aprendi

Sobre prompt engineering:

  • Menos é mais (50 > 70)
  • Exemplos one-shot (par de mensagens usuário + assistente) são mais confiáveis que instruções de formato detalhadas.
  • Dizer o que não fazer funciona muito bem
  • Contexto de perfil funciona melhor como guia de DNA musical, não como lista de restrições.

Sobre a API do Spotify:

  • Sempre verifique se o endpoint ainda é atual. /tracks/items, audio features sumiu, recomendações sumiram.
  • GET /artists/{id}/related-artists funciona bem pra descoberta e quase ninguém usa.
  • O score de popularidade nas faixas é um bom proxy pra "essa faixa existe como esperado."

Sobre streaming no Next.js:

  • ReadableStream + text/event-stream funciona limpo no App Router.
  • Promise.all + emit-on-resolve te dá paralelismo real com UI progressiva de graça.

Sobre Next.js + OAuth:

  • NextRequest normaliza URLs pra localhost em desenvolvimento, mesmo que você passe 127.0.0.1 explicitamente. Use Request nativo quando a URL importa.
  • O NextAuth v5 tem um utilitário reqWithEnvURL que tenta corrigir isso mas usa new NextRequest() internamente — que normaliza de novo. A correção do framework não funciona.
  • O OAuth tem dois momentos onde o redirect_uri é verificado: na requisição de autorização e na troca de token. Você precisa garantir que os dois mostrem 127.0.0.1.
  • provider.callbackUrl é derivado da URL da requisição de callback — não da sua config. Se a URL da requisição ainda diz localhost, a troca falha com invalid_grant.
  • Ao usar Auth() do @auth/core diretamente, pin a versão exata que o next-auth requer. Versões diferentes causam conflitos de tipo em runtime.
  • Cookies PKCE são scopados por domínio. Se o usuário começa em localhost e o callback chega em 127.0.0.1, o code_verifier some. Redirecione todo o tráfego de localhost pra 127.0.0.1 no next.config.mjs.
  • NextAuth v5 é poderoso mas a documentação beta é bem escassa. Ler o código-fonte do @auth/core foi necessário pra entender o que estava acontecendo.

Teste Você Mesmo

O app se chama Moodify. Está OpenSource no GitHub. Se você tá fazendo algo parecido, espero que ajude um pouco.


Como vocês melhorariam esse prompt? Se alguém já passou por algo parecido ou tiver ideias, comenta aí

Construído com Next.js, Claude API, Spotify Web API e Supabase. Deploy no Vercel.

Top comments (4)

Collapse
 
surocham profile image
Suami Medeiros

Muito bom!!

Collapse
 
stherzada profile image
Sther

ficou insano de bommmm

Collapse
 
natholea profile image
Nathália Acordi

que massa!! adorei

Collapse
 
spyrusz profile image
Ronaldo

incrível demais! curti muito