# CLAUDE.md — AutoDJ Tocador de música que anda sozinho, com mixagem de DJ. Go hexagonal + Next.js. ⚠️ **NÃO chamar de "rádio" nem de "automação de rádio"** (pedido do operador, registrado em `AboutAutoDJ.tsx`): o padrão do app é o modo **Tocador** — o acervo da pessoa tocando sozinho. Estação/rádio (grade, vinhetas, relógios, RDS) é um **modo opcional**, não o que o produto é. **Playout roda no browser** (3 motores: Spotify SDK / YouTube IFrame / áudio local); o servidor só orquestra fila/now-playing. Deploy Fly.io. AIOX SDD (`docs/prd.md` → `architecture.md` → `stories/`). **Broadcast por servidor (Liquidsoap/Icecast) foi descontinuado** — não transmitimos mais no servidor. > **O que NÃO está pronto?** [`docs/pendencias.md`](docs/pendencias.md) — o que foi > pedido e não foi feito, o que funciona pela metade (com o limite explícito) e o que > é impossível por plataforma. Consultar ANTES de prometer comportamento ao operador > ou de "reimplementar" algo que já foi avaliado e recusado. > > **Algo não toca / some do deck / trava em 00:00?** Ler primeiro > **[`docs/known-issues.md`](docs/known-issues.md)** — catálogo de erros conhecidos > (sintoma → causa-raiz → fix → como reconhecer), com índice por sintoma. Este arquivo > guarda o detalhe de implementação; o `known-issues.md` guarda o diagnóstico operacional. > **Todo bug com causa-raiz provada entra lá no mesmo passo do fix.** > > Também vale para **ambiente de dev** (seção `DEV-*`), que é onde mais se perde tempo: > feature nova que não aparece porque o binário Go é velho (`DEV-5`), "dou refresh e some > tudo" (`DEV-6`, era o modo `memory`), deploy invisível por cache do HTML (`DEV-7`), > `curl` e browser em tenants diferentes (`DEV-8`), `apiBase()` sem o `/api/v1` (`DEV-9`). > E a pesquisa **YouTube sem API key** (`YT-11`): o que foi medido, o que não funcionou, e > por que `playabilityStatus` medido do servidor não serve de filtro. ## Regras invioláveis - **Módulo Go é LOCAL: `autodj`** (NÃO `github.com/...`). Imports: `autodj/domain/shared`. - **Recursos locais via Nix, NUNCA Docker** (regra global 23). Docker só p/ deploy remoto (Fly.io). Local = `task serve` (server + web/out; playout no browser); Postgres opcional via `nix run .#services`; shell = `nix develop`. - **Cobertura**: domain 100%, adapters/app ≥90%. CI bloqueia. `postgres` e `cmd/*` são integration-tier (fora do gate unitário). Gate: `task cover`. - **`GOWORK=off`** — este módulo builda standalone (o `go.work` da raiz é opcional). O Taskfile já exporta `GOWORK=off`. - **Frontend SEMPRE em componentes** (INVIOLÁVEL). Nada de JSX solto grande no `page.tsx` / rotas. Toda peça de UI = 1 componente em `web/app/components/*.tsx` (um por arquivo, `export function`). Dados/mocks em `web/app/lib/`, hooks em `web/app/hooks/`. `page.tsx` só orquestra. Import via alias `@/*` (→ `web/app/*`). - **Persistência local = SQLite por browser** (1 arquivo por fingerprint FingerprintJS em `~/.autodj/sessions/.db`, `modernc.org/sqlite` pure-Go, sem daemon; isolamento por arquivo — cada browser = estação própria, ver seção "Estação por browser"). Postgres é só remoto/multiusuário e entra **quando escalar**. Uso caseiro não sobe servidor de DB. - **CÓDIGO EM INGLÊS, TEXTO EM PT-BR (INVIOLÁVEL — regra GLOBAL, vale em todos os projetos: `~/.claude/rules/24-code-english.md`)**: **todo objeto de programação em inglês** — função, variável, campo, tipo, prop, ref, estado, chave de objeto, constante, nome de teste, arquivo e **classe CSS**. **Comentários (`//`, `/* */`) e textos de UI ficam em pt-BR**, que é a língua do operador. Vale inclusive para código descartável (script de diagnóstico, snippet de console): se aparece numa tela ou num log, segue a regra. Exceção única e explícita: **valores de contrato com o servidor** (chaves de período `dia`/`semana`/`mes` do playlog, `Kind` de mídia como `hora`/`vinheta`, o kind de evento `hora_certa`) — renomear ali quebra o acordo com o backend; o identificador acompanha o valor de propósito. ⚠️ **Ao renomear em lote, script com regex NÃO basta** (aprendido no sweep da 0.2.26): ele traduz texto de UI dentro de `${...}` de template literal, e um valor de estado que vira **classe CSS** (`"up-" + status`) exige mexer no `globals.css` na mesma passada, senão a tela perde o estilo em silêncio. Máscara de comentário/string ajuda, mas o que fecha a conta é `task web:build` + `go test ./...` e olhar a tela. - **DOC SEMPRE ATUALIZADA (INVIOLÁVEL)**: qualquer mexida em feature — criar, alterar, evoluir, remover ou reverter — exige atualizar a documentação no MESMO passo. Nunca deixar `CLAUDE.md`, `docs/prd.md`, `docs/architecture.md`, `docs/stories/` ou README do módulo desatualizados em relação ao código. Feature mudou → doc muda junto, sempre. - **CHANGELOG A CADA VERSÃO (INVIOLÁVEL)**: todo incremento de versão (nova `git tag vX.Y.Z`) exige uma entrada correspondente no TOPO de `web/app/lib/changelog.ts` (mais recente primeiro: `version/date/title/highlights[]`, pt-BR, voltado ao usuário). O gate `scripts/changelog-check.sh` **aborta o build** (`task web:build`, `task desktop:build`, `task deploy`) se a git tag mais recente não tiver entrada no changelog. A versão exibida no titlebar vem da git tag (`NEXT_PUBLIC_APP_VERSION`, injetada por `scripts/web-build.sh`/Dockerfile `ARG APP_VERSION`); clicar nela abre o `ChangelogModal`. Bumpou versão → adicione a entrada no MESMO passo. ⚠️ **O RÓTULO SAI DE `scripts/app-version.sh`, NÃO do `changelog-check.sh` (0.2.17)**: o gate segue devolvendo a versão da tag, mas quem decide o que aparece na tela é o `app-version.sh` — HEAD **exatamente** na tag **e** árvore limpa → `v` (release); qualquer outra coisa → `v-dev.` (+ `.dirty` se houver alteração não commitada). Motivo: antes o número vinha da última tag independentemente do que o HEAD continha, então build local pós-release exibia o número idêntico ao de prod e — pior — um `web/out` velho exibia versão MENOR que a de prod sem sinal de staleness. Consequência prática: **`-dev` no número = não é produção**, e deploy fora da tag rotula a imagem como `-dev` de propósito. Ao mexer em versão, mexa no `app-version.sh` (ponto único), nunca reintroduza `git describe` solto no `web-build.sh`/Taskfile. - **BRANCH DE TRABALHO = `develop` (INVIOLÁVEL, OBRIGATÓRIO)**: todo commit do dia a dia vai na `develop`. **`main` = produção** — só recebe o que vai pro ar, via **merge de `develop` → `main`** no momento do deploy (nunca commit direto na `main`). Nada de branches `feature/*` avulsas por padrão; se precisar isolar algo grande, a branch nasce de `develop` e volta pra `develop`. Sequência de release: commit na `develop` → doc + changelog + `git tag vX.Y.Z` → `git checkout main && git merge develop` → `task deploy` → voltar pra `develop`. Tags de versão ficam no commit que foi pra prod. Remote = `ab:bassi-projects/autodj.git` (`origin`). - **DEPLOY EM PROD SÓ COM AUTORIZAÇÃO EXPLÍCITA DO USUÁRIO (INVIOLÁVEL, OBRIGATÓRIO)**: `task deploy` (Fly.io, app `autodj`) **nunca** roda por iniciativa própria — nem depois de "está tudo pronto", nem como último passo natural de uma tarefa, nem porque os testes passaram. É preciso um "pode subir / põe em prod / deploy" **explícito, daquela vez**. Autorização não é retroativa nem se estende ao próximo deploy. O mesmo vale pra tudo que altera produção: `fly secrets set`, `fly scale`, restart de máquina, mudança de DNS. Merge `develop` → `main` acontece no ato do deploy autorizado. Sem autorização: deixar pronto na `develop`, avisar que está pronto, e **parar aí**. - **⚠️ O CI SÓ COMEÇOU A RODAR NA PUBLICAÇÃO (2026-07-28)** — o `.github/workflows/ci.yml` existia há muitas versões, mas o repositório vivia no GitLab, então **nenhuma execução tinha acontecido**. A primeira encontrou dois bugs só-de-Linux com ~45 versões de idade: `mktemp -t` (BSD-only) no `coverage-gate.sh`, e cobertura do `adapters/driving/ws` dependente de corrida de tempo (92% no Mac, 80% no runner). Ver `DEV-11` no known-issues. **Consequência prática**: portão que nunca executou é decoração, e cobertura que muda de máquina não mede nada — teste que depende de `time.Sleep`/tique para ALCANÇAR um caminho deve chamar o caminho direto. - Timeout 30s em comandos; logging `2>&1 | tee /tmp/log.txt`; Taskfile primeiro. ## Princípio central — três motores de áudio Uso **pessoal/caseiro** (não transmissão pública): saída é playout local. Spotify = DRM (sem PCM). YouTube = embed IFrame (sem PCM). Local = PCM próprio. Bifurca o domínio via porta `PlaybackEngine` + value object `Playable{SourceKind}` + matriz de capacidade. **Fila (agenda) ≠ bus de mix (PCM).** A `PlayQueue` é a **agenda** (ordem dos itens): Spotify é **cidadão de 1ª classe** — entra na fila, vira now-playing e avança como qualquer mídia. Só a **transição** difere: PCM entra com `crossfade`; Spotify com `hard_cut`/`fade_tts` (`shared.DefaultTransition`) e toca **out-of-band** pelo Web Playback SDK do browser, nunca no bus PCM. Invariante **`spotify ⇒ mixable=false`** = áudio DRM **nunca entra no bus PCM/mix local**: sem crossfade/beatmatch/waveform. Imposto em **2 camadas**: domínio (`Track.Validate`) e banco (CHECK `spotify_never_mixable`). Marker/Beatgrid/waveform só p/ fontes PCM. **Nunca** relaxar o invariante — mas **não** confundi-lo com a fila: enfileirar/tocar Spotify (hard_cut) é permitido e esperado. A fila e o stub engine (mirror do playout no browser) **aceitam** Spotify. (O antigo bus de broadcast Liquidsoap, que era a 3ª camada de imposição, foi descontinuado.) **Invariante gêmeo `youtube ⇒ mixable=false`** (v0.2.0) — mesma regra p/ o embed YouTube (sem PCM, sem crossfade/beatmatch/waveform), imposto nas mesmas 2 camadas: domínio (`Track.Validate` via `ErrYouTubeNeedsURI` + matriz de capacidade `SourceYouTube`) e banco (CHECK `youtube_never_mixable`). YouTube também é cidadão de 1ª classe na fila (entra, vira now-playing, avança) e transiciona só por `hard_cut`/`fade_tts`. `GET /library/tracks/{id}/audio` → **403** p/ youtube (não-PCM). ## YouTube (v0.2.0) — nova fonte, espelho fiel do Spotify Fonte `source=youtube` replica a integração Spotify inteira (OAuth por estação, busca/import, playout no browser) como terceiro motor lado a lado. **Divergência estrutural única**: o IFrame Player API exige um elemento DOM **visível** no deck (ver "iframe persistente" abaixo); Spotify SDK é device remoto sem elemento, local é `