Audissey Scripts Documentation
Documentação completa de todos os scripts disponíveis no projeto Audissey.
Referência Rápida
# Seed
npm run seed:podcasts # Seed rápido (~10-20 podcasts)
npm run seed:podcasts:full # Seed completo (centenas de podcasts)
npm run seed:radios # Seed rápido (~30 rádios)
npm run seed:radios:full # Seed completo (centenas de rádios)
./apps/api/src/scripts/test-ingestion-status.sh # Testar dashboard de monitorização
# Validação
npm run validate:schedules # Validar qualidade de EPG
# Limpeza
npm run clean:local-schedules # Limpar schedules incorretos
npm run clean:seed-radios # Limpar todas as rádios
npm run reprocess:local-radios # Reprocessar rádios específicas
# Machine Learning
npm run migrate:content-features # Migrar features para ML/IA
# Índices MongoDB (Atlas)
node scripts/ensure-atlas-indexes.js --mode audit-live --desiredFile deploy/config/indexes/indexes.json # Deriva entre indexes.json e a BD real (só leitura)
EXPLAIN_SMOKE_ENV=staging node scripts/explain-smoke.js # explain('queryPlanner') das queries de idioma de conteúdo (só leitura)
# Testes de integração (MongoDB 8.0 em memória)
npx nx run api:test-integration # Suites *.int-spec.ts da API contra um mongod 8.0 local
npx nx run worker:test-integration # Suites *.int-spec.ts do worker (arranque do AppModule)
# Testes de contrato (payloads da app pelo ValidationPipe real; testes unitários, sem BD)
npx nx test api --testPathPatterns=app/contracts/
Índice
- Scripts de Seed
- Scripts de EPG
- Scripts de Sincronização
- Scripts de Teste
- Scripts de Validação
- Scripts de Índices (Atlas)
- Testes de Integração (MongoDB em memória)
- Testes de Contrato (payloads da app)
Localização: apps/api/src/scripts/seed-podcasts.ts
Propósito: Popular base de dados com podcasts iniciais (seed rápido)
Comando:
npm run seed:podcasts
Descrição:
- Adiciona ~10-20 podcasts de exemplo para desenvolvimento
- Configura feeds RSS
- Valida estrutura de dados
- Seed rápido para testes
Quando usar:
- Setup inicial do projeto
- Desenvolvimento local
- Testes rápidos
seed-podcasts-full
Localização: apps/api/src/scripts/seed-podcasts-full.ts
Propósito: Popular base de dados com lista completa de podcasts portugueses
Comando:
npm run seed:podcasts:full
- Seed completo para produção
- Processo mais demorado (~5-10 min)
- Seed completo de staging
- Criar base de dados realista
---
### seed-radios
**Localização**: `apps/api/src/scripts/seed-radios.ts`
**Propósito**: Popular base de dados com rádios portuguesas (seed rápido)
**Comando**:
```bash
npm run seed:radios
Descrição:
- Importa ~30 rádios principais portuguesas
- Seed rápido para desenvolvimento
- Configura streams e metadados
- Valida URLs de stream
Quando usar:
- Setup inicial do projeto
- Desenvolvimento local
- Testes rápidos
Dados incluídos:
- Nome da rádio
- País (Portugal)
- URLs de stream (principal + fallbacks)
- Homepage
- Favicon
- Tags/categorias
seed-radios-full
Localização: apps/api/src/scripts/seed-radios-full.ts
Propósito: Popular base de dados com lista completa de rádios portuguesas
Comando:
npm run seed:radios:full
Descrição:
- Importa centenas de rádios portuguesas via Radio Browser API
- Seed completo para produção
- Inclui rádios nacionais, regionais e locais
- Processo mais demorado (~3-5 min)
Quando usar:
- Setup inicial de produção
- Seed completo de staging
- Criar base de dados realista com todas as rádios disponíveis
⚠️ Atenção: Processo demorado, recomendado apenas para setup inicial completo
Scripts de EPG
collect-epg
Localização: apps/api/src/scripts/collect-epg.ts
Propósito: Coletar EPG (programação) de todas as rádios portuguesas
Comando:
npm run collect:epg
Descrição:
Versão melhorada e resiliente para coleta automática de EPG via MyTuner Radio API.
Características:
- ✅ Processa apenas rádios sem EPG ou com EPG expirado
- ✅ Algoritmo Levenshtein para matching inteligente
- ✅ Threshold configurável para evitar matches incorretos
- ✅ Resiliente a erros (continua mesmo com falhas)
- ✅ Estatísticas detalhadas ao final
- ✅ Delay entre requests para evitar rate limiting
Configuração (dentro do script):
const CONFIG = {
LEVENSHTEIN_THRESHOLD: 15, // Distância máxima para match válido
REQUEST_DELAY_MS: 2000, // 2 segundos entre requests
SCHEDULE_EXPIRATION_DAYS: 7, // EPG válido por 7 dias
FORCE_REFRESH: false, // Forçar atualização
};
Quando usar:
- Setup inicial (primeira coleta)
- Atualização semanal de EPG expirado
- Após adicionar novas rádios
- Troubleshooting de dados faltantes
Output esperado:
📻 Total de rádios portuguesas: 230
📋 Rádios a processar: 190
✅ Rádios com EPG válido: 40
[1/190] 📻 Antena 1
✅ EPG salvo com sucesso!
🎯 Match: "Antena 1" → "Antena 1 (Portugal)"
📏 Distância: 11
📊 MyTuner ID: 396103
📊 Programas: 227
============================================================
📊 ESTATÍSTICAS FINAIS
============================================================
✅ Sucesso: 35 (18.4%)
⚠️ Não encontradas no MyTuner: 143 (75.3%)
❌ Matches inválidos (threshold): 10 (5.3%)
Problemas comuns:
- "Not found": Normal para ~75% das rádios locais (não têm EPG no MyTuner)
- "Invalid match": Distância Levenshtein > threshold (proteção contra matches errados)
- 403/401 errors: Cookies expirados (ver troubleshooting)
Scripts de Sincronização
sync-radios (Worker)
Localização: apps/worker/src/scripts/test-sync-radios.ts
Propósito: Sincronizar dados de rádios com fontes externas
Comando:
npm run sync:radios
Descrição:
- Atualiza metadados de rádios
- Sincroniza com RadioBrowser API
- Valida streams ativos
Quando usar:
- Manutenção periódica
- Verificar rádios offline
- Atualizar informações
Scripts de Teste
test-ingestion-status
Localização: apps/api/src/scripts/test-ingestion-status.sh
Propósito: Testar endpoints de monitorização de ingestão
Comando:
./apps/api/src/scripts/test-ingestion-status.sh
Descrição:
- Testa endpoint de status geral de ingestão (
/admin/ingestion-status) - Verifica histórico de execuções (
/admin/ingestion-status/history) - Valida health do sistema (healthy/warning/critical)
- Formata JSON automaticamente (usa python3 ou jq se disponível)
- Inclui token JWT válido pré-configurado
Requisitos:
- API rodando em localhost:3000
- JWT token válido (incluído no script)
- Python3 instalado (para formatação JSON)
Quando usar:
- Verificar status das sincronizações (radios, programs, podcasts, episodes)
- Debugging de problemas de ingestão
- Validar implementação do dashboard de monitorização
- Testar autenticação JWT nos endpoints admin
Output esperado:
🔍 Testando Dashboard de Monitorização de Ingestão
==================================================
📊 1. Obtendo status geral de ingestão...
{
"timestamp": "2025-12-24T16:18:20.377Z",
"jobs": [
{
"jobType": "sync-radios",
"lastExecution": {
"status": "completed",
"startedAt": "2025-12-24T03:00:00.000Z",
"completedAt": "2025-12-24T03:05:32.000Z",
"duration": 332000,
"stats": {
"total": 150,
"created": 5,
"updated": 145
}
},
"summary": {
"totalExecutions": 42,
"successRate": 98.5,
"averageDuration": 315000
}
}
// ... outros jobs
],
"overallHealth": "healthy"
}
📜 2. Obtendo histórico de execuções...
{
"total": 10,
"executions": [...]
}
🏥 3. Verificando health geral do sistema...
Health: healthy
✅ Testes concluídos!
Documentação relacionada:
- Ver INGESTION_MONITORING.md para detalhes do dashboard
test-rss-ingest
Localização: apps/api/src/scripts/test-rss-ingest.ts
Propósito: Testar ingestão de feeds RSS de podcasts
Comando:
npm run test:rss-ingest
Descrição:
- Valida parsing de feeds RSS
- Testa extração de episódios
- Verifica estrutura de dados
Quando usar:
- Debugging de problemas com podcasts
- Validar novo formato de feed
- Desenvolvimento de features RSS
test-mytuner-radio
Localização: apps/api/src/scripts/test-mytuner-radio.ts
Propósito: Testar integração com MyTuner Radio API
Comando:
npm run test:mytuner-radio
Descrição:
- Testa autenticação (3 passos)
- Valida resposta de EPG
- Verifica cookies e headers
Quando usar:
- Debugging de problemas com MyTuner
- Validar cookies atualizados
- Testar nova rádio específica
Output esperado:
🔍 Testando MyTuner Radio Service
✅ Passo 1: Pesquisa - OK
✅ Passo 2: Widget Registration - OK
✅ Passo 3: Schedule Retrieval - OK
📊 Programas encontrados: 227
test-stream-fallback (Worker)
Localização: apps/worker/src/scripts/test-stream-fallback.ts
Propósito: Testar sistema de fallback de streams
Comando:
npm run test:stream-fallback
Descrição:
- Valida URLs de stream
- Testa fallbacks automáticos
- Verifica qualidade de conexão
Quando usar:
- Debugging de problemas de streaming
- Validar configuração de fallbacks
- Testar nova rádio
Scripts de Validação
validate-schedules
Localização: apps/api/src/scripts/validate-schedules.ts
Propósito: Validar qualidade e integridade dos EPG armazenados
Comando:
npm run validate:schedules
Descrição:
Sistema completo de validação que verifica:
Duplicados por radio_id
- Detecta múltiplas entradas para mesma rádio
- Indica problemas no script de coleta
Schedules partilhados
- Identifica rádios diferentes com mesma programação
- Pode indicar:
- Retransmissoras legítimas (normal)
- Matches incorretos (problema)
Estatísticas gerais
- Total de schedules
- Rádios únicas
- Total de programas
- Média de programas por rádio
Output esperado:
📊 Total de schedules: 40
✅ Sem duplicados por radio_id
⚠️ Encontrados 7 schedules partilhados:
Schedule partilhado por 9 rádios:
- Antena 1
- Antena 1 (Portugal)
- Antena 1 (AAC)
...
Programas: 90 anos no ar, a fé dos homens, ...
📊 Estatísticas:
Total de schedules: 40
Rádios únicas: 40
Duplicados: 0
Schedules partilhados: 7
Total de programas: 4859
Média de programas por rádio: 121.5
Quando usar:
- Após executar
collect:epg - Antes de deploy (validação)
- Troubleshooting de dados incorretos
- Auditoria periódica
Interpretação:
- ✅ Duplicados: 0 = Normal, esperado
- ⚠️ Schedules partilhados = Pode ser normal (verificar se são variantes da mesma rádio)
- ❌ Muitos duplicados = Problema no script de coleta
listen-notes-validation
Localização: apps/api/src/scripts/listen-notes-validation.ts
Propósito: Fase 0 do país de origem dos podcasts. Valida o campo country do Listen Notes numa amostra de podcasts com origem conhecida (docs/reports/listen-notes-validation-sample.json) e gera o relatório de cobertura e acerto. Não usa a base de dados.
Comando (a partir da raiz do repo; a chave é sua e só vive na shell, nunca no repo):
LISTEN_NOTES_API_KEY='<a sua chave>' npx ts-node -r tsconfig-paths/register \
-P apps/api/tsconfig.app.json \
apps/api/src/scripts/listen-notes-validation.ts
-r tsconfig-paths/register é obrigatório (resolve @audissey/*). Sem LISTEN_NOTES_API_KEY o script recusa-se a correr.
Opções:
--mode auto|batch|search:batchusaPOST /podcasts(só plano PRO/ENTERPRISE),searchusa a pesquisa por título do plano gratuito (até 2 pedidos por podcast),auto(por omissão) tentabatche passa asearchse o plano o recusar--sample <ficheiro>: amostra (por omissãodocs/reports/listen-notes-validation-sample.json)--out <ficheiro>: relatório (por omissãodocs/reports/<data>-listen-notes-validation.md)--delay-ms <n>: pausa entre pedidos no modosearch(por omissão 600)
Output esperado:
Sample: 89 podcasts (docs/reports/listen-notes-validation-sample.json), mode: auto
Mode used: batch. Coverage <encontrados>/89 = <x>%; accuracy <certos>/<encontrados> = <y>%.
Requests used: 16
Report written to docs/reports/<data>-listen-notes-validation.md
Notas:
- Num erro 429 (quota ou ritmo) o script guarda os resultados parciais e marca o resto como
skippedno relatório - O relatório regista os pedidos usados e os cabeçalhos
X-ListenAPI-Usage/X-ListenAPI-FreeQuota, se existirem - Antes de usar o país em produção, confirmar nos termos de uso do Listen Notes que é permitido guardá-lo
Scripts de Índices (Atlas)
ensure-atlas-indexes
Localização: scripts/ensure-atlas-indexes.js (estado desejado em deploy/config/indexes/indexes.json)
Propósito: Manter os índices MongoDB iguais ao indexes.json e detetar quando a base de dados real se afasta dele. Corre mongosh, por isso precisa de mongosh no PATH. A ligação vem sempre da variável de ambiente MONGO_URI (nunca de um ficheiro, nunca impressa).
Modos (--mode):
| Modo | O que faz | Liga à BD |
|---|---|---|
ensure |
Cria os índices em falta; com --reconcile recria os que têm opções diferentes. Nunca dropa extras |
Sim |
audit |
Lista os índices desejados (offline) | Não |
audit-live |
Compara os índices reais com o indexes.json e reporta a deriva. Só leitura |
Sim |
Comandos:
# Criar / reconciliar (é o que os pipelines de release correm)
MONGO_URI='<uri>' node scripts/ensure-atlas-indexes.js \
--mode ensure --desiredFile deploy/config/indexes/indexes.json --reconcile
# Auditar a BD real (só leitura)
MONGO_URI='<uri>' node scripts/ensure-atlas-indexes.js \
--mode audit-live --desiredFile deploy/config/indexes/indexes.json
audit-live
O ensure --reconcile cria e recria os índices desejados, mas nunca vê os que existem na BD e já não estão no indexes.json (por exemplo um índice removido do ficheiro que continua a custar escritas). O audit-live lê os índices reais (listCollections + getIndexes(), nada mais) e reporta por coleção:
- MISSING: está no
indexes.jsone não existe na BD (inclui as coleções desejadas que não existem) - EXTRA: existe na BD e não está no
indexes.json - DIFFERENT: mesmas chaves, opções diferentes (
unique,sparse,name,expireAfterSeconds,partialFilterExpression, pesos e opções de texto); indica o valor desejado e o real. Usa a mesma comparação que a verificação final doensure - UNMANAGED: existe na BD, não está no
indexes.json, mas está emdeploy/config/indexes/unmanaged-indexes.json(índice de outro dono: ver Índices não geridos). Não conta como EXTRA nem para o--fail-on-extra; o resumo acrescentaN known unmanaged
Um índice é identificado pelas chaves por ordem e direção: os mesmos campos noutra ordem (ou -1 em vez de 1) são outro índice, por isso aparece um MISSING e um EXTRA.
Opções (só valem com --mode audit-live; noutros modos são recusadas):
--all-collections: inclui também as coleções que não estão noindexes.json(todos os seus índices contam como EXTRA). Por omissão só se leem as coleções doindexes.json--fail-on-drift: termina com código 1 se houver MISSING ou DIFFERENT--fail-on-extra: termina com código 1 se houver EXTRA (sozinho não falha por MISSING/DIFFERENT; combine as duas para ser estrito)--json <ficheiro>: escreve também o relatório em JSON (resumo + por coleçãomissing,extra,different), útil como artefacto de CI. Não contém a ligação--quiet: só imprime as coleções com deriva e o resumo (sem as linhas UNMANAGED)--unmanagedFile <ficheiro>: lista de índices não geridos. Por omissão lêunmanaged-indexes.jsonao lado do--desiredFile, se existir (é o caso dos pipelines, que correm do checkout do repo)
O que ignora: o índice _id_, vistas, coleções system.* e os índices Atlas Search (que não aparecem em getIndexes(); ver ensure-search-indexes.js).
Output esperado:
Database: audissey-staging
radios: OK
episodes:
MISSING episodes — podcast_id+guid (unique)
tokens:
DIFFERENT tokens — expiresAt (ttl:0s) — live 'expiresAt_1': expireAfterSeconds (desired 0, actual 60)
userevents:
EXTRA legacy_1_foo_-1 {"legacy":1,"foo":-1}
Summary: 45 collections checked, 3 with drift — 1 missing, 1 extra, 1 different options.
Códigos de saída: 0 sem falha pedida (a deriva só é reportada), 1 deriva com --fail-on-drift / --fail-on-extra, 2 erro (sem MONGO_URI, sem mongosh, mongosh falhou ou excedeu AUDIT_LIVE_TIMEOUT_MS (2 minutos), argumentos inválidos).
Pipelines: os releases do API e do Worker (azure-pipelines-api-release.yml, azure-pipelines-worker-release.yml) correm um passo Audit live Atlas indexes (<env>, report only) como último passo do job de dev, staging e prod (em staging do API segue-se-lhe só o explain-smoke), depois do Deploy, por isso vê o estado dos índices pós-deploy e não pode atrasar o Deploy. Só relata (sem flags --fail-on-*) e nunca dropa índices; tem continueOnError: true, pelo que uma falha aparece como "com problemas" e não falha o release. Limites: timeoutInMinutes: 3 no passo e AUDIT_LIVE_TIMEOUT_MS (2 minutos) no mongosh. O passo conta para o timeoutInMinutes do job, já depois do Deploy, mas um timeout do job cancela o stage (e em staging bloqueia o de prod); por isso o job de staging do API, que tem dois passos só de relatório depois do Deploy (este e o explain-smoke, até 6 minutos juntos), passou de 20 para 25 minutos.
Quando usar:
- Depois de remover um índice do
indexes.json(confirmar que sobra na BD e decidir se o dropa à mão) - Antes de um release, para ver se a BD real tem deriva
- Investigar escritas lentas (índices a mais) ou queries lentas (índices em falta)
Testes: node --test scripts/ensure-atlas-indexes.test.js (a comparação é a função pura diffIndexes(desired, actual); o mongosh é simulado, não há ligação a nenhuma BD). Correm no CI: job Scripts_Tests do azure-pipelines.yml (ver Testes dos scripts no CI).
Chaves _ do indexes.json: uma chave começada por _ é uma nota (_comment), exceto se tiver uma lista de índices: _migrations é uma coleção (o índice único version do runner de migrações, que nenhum outro código cria). Até 2026-10-29 o ensure ignorava todas as chaves _, por isso o _migrations do ficheiro nunca era aplicado; o tools/testing/mongo-memory lê as coleções com a mesma regra (desiredCollectionNames).
Índices não geridos (`unmanaged-indexes.json`)
Índices que existem na BD, que outro código cria (e pode dropar) e que o deploy não deve gerir. O audit-live reporta-os como UNMANAGED em vez de EXTRA; o ensure nunca lê o ficheiro. Cada entrada: keys (formato do indexes.json), name (o nome real), owner, reason. Um teste do ensure-atlas-indexes.test.js exige as quatro e que nenhum esteja também no indexes.json.
| Coleção | Índice | Dono |
|---|---|---|
content_embeddings |
itemKey_1 |
ml-service (embedding_store.py): índice único legado, o próprio ml-service dropa-o e cria item_generation_unique |
user_embeddings |
userId_1 |
ml-service (embedding_store.py): idem, substituído por user_generation_unique |
ml_models.chunks |
files_id_1_n_1 |
GridFS (bucket ml_models do ml-service) |
ml_models.files |
filename_1_uploadDate_1 |
GridFS (bucket ml_models do ml-service) |
Um índice legado sem dono não entra aqui: fica EXTRA e é um candidato a drop.
Guarda: índices declarados nos schemas
A API e o worker correm com autoIndex: false: um índice só existe numa BD quando o ensure do release o cria a partir do indexes.json. Um índice declarado num schema Mongoose (Schema.index(...) ou as opções index / unique / sparse / expires / text de um @Prop) que falte no indexes.json só existe onde o autoIndex correu um dia: um ambiente novo não o teria. Três specs falham quando isso acontece (chave por ordem e direção, e opções: unique, sparse, TTL, filtro parcial, nome, opções de texto):
| Spec | Schemas |
|---|---|
worker-shared/src/schemas/indexes-json.spec.ts |
worker-shared/src/**/*.schema.ts e os de @audissey/content-features |
apps/api/src/app/indexes-json.spec.ts |
apps/api/src/**/*.schema.ts e os de @audissey/radio-collector |
apps/worker/src/app/indexes-json.spec.ts |
apps/worker/src/**/*.schema.ts (cópias locais do worker incluídas) |
A comparação está em worker-shared/testing/schema-indexes.ts (@audissey/worker-shared/testing): lê cada *.schema.ts e os módulos indicados, usa schema.indexes() (o que o Mongoose criaria) e resolve a coleção pela opção collection ou pelo plural do nome do modelo. A mensagem diz o que difere: missing from indexes.json, other directions ou other options. Cada spec falha também se um *.schema.ts que lê não exportar nenhum schema (um ficheiro saltado em silêncio deixaria os seus índices fora da guarda). Para corrigir: acrescentar o índice ao indexes.json (o próximo ensure cria-o; ver o custo abaixo) ou, se o indexes.json é que está certo, alinhar a declaração do schema. Os targets test do api, worker e worker-shared têm o indexes.json como input do Nx, para a cache não saltar a guarda quando só o ficheiro muda.
Próximo `ensure` (sincronização schemas ↔ `indexes.json`, 2026-10-29)
Índices que o próximo ensure --reconcile cria, iguais em dev e staging (lidos por nome com o MCP em 2026-10-29): 24. Esta branch acrescenta mais 2 nos podcasts (content_language_1_latest_episode_publish_date_-1__id_1, latest_episode_publish_date_-1__id_1), total 26. Documentos em staging, para saber que builds pesam:
| Coleção | Índice(s) novo(s) | Docs (staging) |
|---|---|---|
episodes |
duration_seconds_1 (pré-construir, ver abaixo) |
2 000 308 |
musiclinks |
status_1_updatedAt_1 |
79 702 |
radios |
is_active_1_genres_1, alternative_radio_browser_uuids_1 (unique, sparse), is_active_1_content_language_1_clickcount_-1_votes_-1__id_1, normalized_stream_url_1 |
57 810 |
podcasts |
author_1, content_language_1_latest_episode_publish_date_-1__id_1, latest_episode_publish_date_-1__id_1 |
7 978 |
recommendation_request_logs |
contentTypes_1 |
5 138 |
artist_genres |
normalized_artist_1 (unique), taxonomy_version_1 |
1 814 |
recommendation_suppressions |
userId_1_contentType_1_contentId_1 (unique), userId_1_expiresAt_1, userId_1_completedAt_1 |
731 |
unresolved_genre_tags |
tag_1 (unique), count_1, status_1 |
566 |
schedules |
programming_id_1, programming_activation_id_1 |
145 |
data_quality_snapshots |
timestamp_1 (TTL 90 dias) |
128 |
genre_taxonomy |
key_1 (unique), axis_1, parent_1, version_1 |
77 |
home_highlight_snapshots |
key_1 (unique) |
2 |
Já existem com o mesmo nome em dev e staging (o
ensuresó os verifica):scraping_logs.radio_id_1_createdAt_-1,recommendation_request_logs.requestId_1,push_notification_logs.userId_1_notificationType_1_periodKey_1(unique; coleção vazia nos dois) e_migrations.version_1(unique; sem duplicados, agora gerido peloensure). Se algum tiver outras opções, o--reconcilerecria-o.radios.alternative_radio_browser_uuids_1(unique, sparse): campo novo, que nenhuma rádio tinha em 2026-10-29 (dev e staging: 0): o build percorre as 57 810 rádios mas o índice fica vazio. Guarda os outros uuids de uma rádio que a Radio Browser lista duas vezes; oRadioCanonicalizationServiceprocura por ele quando nenhuma rádio tem o uuid da estação comoradio_browser_uuid, e o sync de rádios (RadiosSyncService.findStationRadio,markSeen) também: sem o índice, cada estação nova, cada gémea e cada importação da pesquisa da API faria um COLLSCAN das ~58 mil rádios. O campo nunca pode ser[]: um array vazio é indexado como a chaveundefined, e duas rádios com[]colidiriam no índice único (por isso o schema não lhe dá default e o serviço descarta o campo vindo de quem chama).- Ordem do release: o índice tem de existir antes de a API e o worker novos correrem. Os dois pipelines de release correm o
ensure --reconcileantes do seu próprio deploy, em dev, staging e prod:azure-pipelines-api-release.yml(passoEnsure Atlas indexes (<env>)antes deDeploy container (<env>)) eazure-pipelines-worker-release.yml(Ensure Atlas indexes (<env>)antes deDeploy to Container Apps). Por isso o primeiro release a sair (API ou worker) cria o índice, e um release só do worker (ou só da API) também fica coberto. Um passoensureque falha (set -euo pipefail) pára o deploy. Não fazer deploy de nenhum dos dois fora do pipeline (por exemploaz containerapp updateà mão) sem correr oensureprimeiro. - A verificação de duplicados abaixo inclui-o: antes deste release não pode ter nenhum, mas serve também para os
ensureseguintes, depois de o worker o preencher.
- Ordem do release: o índice tem de existir antes de a API e o worker novos correrem. Os dois pipelines de release correm o
radios.normalized_stream_url_1: o$orde identidade doRadioCanonicalizationService(buildMatch) tem um ramonormalized_stream_urlsem índice, por isso cada fonte que nenhuma rádio tem (estação nova da Radio Browser, canal novo da Radio Garden, criação no admin, myradioonline e a importação da pesquisa da API, até 10 seguidas por pesquisa) fazia um COLLSCAN das rádios: em staging (2026-10-29) 57 810 documentos e390 ms. Logo a seguir vinha um segundo COLLSCAN (370 ms), ofindLegacyStrongMatch, que procurava porstream_url/homepageexatos as rádios semdedupe_identity_keys(53 091 em staging, todas em dev). Este release remove-o: essas rádios guardam os campos de candidatos (normalized_stream_url,dedupe_fingerprint,homepage_domain,stream_domain) que o$orjá procura, agora todos com índice (em staging, 53 069 das 53 091 com os mesmos valores que derivam). Custo estimado depois do índice, sobre as rádios de staging: mediana 2 documentos lidos, p99 10, máximo 90.Ordem do release: (1) o
ensure --reconcilecria o índice antes do deploy (como oalternative_radio_browser_uuids_1acima: o primeiro release, API ou worker, cria-o); (2) deploy da API e do worker; (3) correr à mão, em cada ambiente, a migração20261029_043(only: ['20261029_043']), que recalcula os campos de candidatos onde faltam ou estão desatualizados (staging: 245 rádios, das quais 22 semnormalized_stream_url; dev: ~54,7 mil, nunca recalculadas pelo job diário), sem nunca escreverdedupe_identity_keys. Até ela correr, as 22 rádios de staging semnormalized_stream_url(canais antigos da Radio Garden com nome só de frequência, como "93,3 FM", que o job diário salta por não terem fingerprint) não são encontradas pelo stream; a Radio Garden encontra-as peloradio_garden_id. Depois dela, o job diárioradio-deduplicationfunde automaticamente (e apaga as absorvidas) as que passam a partilhar um stream normalizado com outra rádio (17 pares em staging, nenhum com query string). Ver a lista antes de correr a migração, só com leitura:// mongosh: as rádios que a migração faz entrar nos grupos de stream exato db.radios.find({ normalized_stream_url: { $in: [null, ''] } }, { name: 1, stream_url: 1, radio_browser_uuid: 1, radio_garden_id: 1, is_active: 1 }).limit(1000); // por cada uma, o stream sem esquema, `www.`, query nem `/` final (o normalizeStreamUrl): db.radios.find({ normalized_stream_url: '<host[:porta]/caminho>' }, { name: 1, stream_url: 1 });O
normalizeStreamUrlignora a query string:.../stream.php?id=1e?id=2contam como o mesmo stream (o mesmo acontece já hoje com as chavesstream=e o merge de stream exato de todas as rádios). O merge de stream exato não tem veto por par: se a lista tiver duas rádios diferentes com o mesmo stream normalizado, não correr a migração nesse ambiente antes de corrigir isso (follow-up: manter a query nonormalizeStreamUrl).
Novos episódios: dois índices dos podcasts dão a ordem por
latest_episode_publish_date(últimos 31 dias), porcontent_languagee na completação de qualquer língua, semSORT. Custo baixo: dois índices sobre ~7 900 podcasts, atualizados na sincronização dos episódios. Antes do release em prod, confirmar que o campo está preenchido comdb.podcasts.countDocuments({latest_episode_publish_date: {$gte: <31 days ago>}})(staging: ~4 200), porque o fallback só dispara quando nenhum podcast é elegível.radios.is_active_1_content_language_1_clickcount_-1_votes_-1__id_1: substituiis_active_1_content_language_1(o seu prefixo) noindexes.jsone no schema, para onearbydas apps antigas que só enviamlanguageler as rádios dessa língua por popularidade a partir do índice (SORT_MERGE, semSORT). Oensurecria-o mas nunca dropa o antigo, que passa a EXTRA noaudit-live(ver Candidatos a drop). Até ao build, onearby-device-languagedoexplain-smokereporta problemas e a query lê o índice antigo com umSORTtop-k (no máximo 250 documentos em memória).TTL de
data_quality_snapshots: o primeiro build apaga logo os snapshots com mais de 90 dias (staging 40, dev 3, em 2026-10-29).Sem TTL em
recommendation_suppressionsneste release (expiresAt, 0): linhas antigas têmcompletedAteexpiresAtao mesmo tempo (omarkListeneddava expiração a uma sugestão concluída quando a escuta não trazia progresso, e omarkRecommendedManyliacompletedAtantes de escrever), e o TTL apagá-las-ia, voltando a recomendar episódios concluídos (14 em staging). Ordem: (1) este release corrige os dois (um pipeline de update atómico decide oexpiresAtpelocompletedAtguardado); (2) correr à mão a migração20261029_041(remove oexpiresAtdas concluídas) em cada ambiente, já com a API corrigida; (3) só num release seguinte acrescentar o TTL ao schema e aoindexes.json. Não no mesmo release da migração: o monitor de TTL corre a cada 60 s e apagaria as linhas antes de a migração correr. Em 2026-10-29 havia 608 sugestões já expiradas em staging e 302 em dev, que o TTL apagará quando chegar (o serviço já as ignora).push_notification_logs.userId_1_notification_type_1_content_key_1saiu doindexes.json: era único em campos que nenhum log tem, por isso o segundo log de cada utilizador falhava com E11000 e o push repetia-se. Oensurenão o dropa; a migração20261029_042dropa-o (só se existir com essa chave). É automática (não mexe em dados e pode correr outra vez): corre depois do arranque da API ondeDATA_MIGRATIONS_AUTO_RUN=true; nos outros ambientes, correr à mão. Até lá, oaudit-livemostra-o como EXTRA, e o serviço, que reporta o push enviado como enviado, regista um erro "rate limit NOT recorded; run migration 20261029_042" quando um E11000 vem desse índice (um E11000 na chave do rate limit é só um aviso: já registado).
Antes do release (prod e staging)
1. Pré-construir episodes.duration_seconds_1. O distinct diário do enrich-episode-durations faz hoje um COLLSCAN de 2 M episódios. Construir o índice numa janela calma, de preferência como rolling build do Atlas (Data Explorer → Indexes → Create Index, "Build index via rolling process"), em staging e prod, antes do release, com exatamente a chave { "duration_seconds": 1 }, sem opções e com o nome por omissão duration_seconds_1. O ensure passa então só a verificá-lo. Se o ensure tiver de o construir e exceder o prazo de 10 minutos do mongosh, termina com código 2, mas o build continua no servidor; voltar a correr o ensure é seguro (um createIndex igual espera pelo build em curso).
2. Procurar duplicados dos 6 índices unique novos, em prod (staging e dev: nenhum em 2026-10-29). Um build unique que falha faz falhar o ensure. Só leitura, com um utilizador de leitura; cada agregação tem de devolver [] (o $ifNull conta um campo em falta como null, como o índice):
// mongosh, na BD de prod (audissey-prod)
for (const [collection, key] of [
['artist_genres', { normalized_artist: { $ifNull: ['$normalized_artist', null] } }],
[
'recommendation_suppressions',
{
userId: { $ifNull: ['$userId', null] },
contentType: { $ifNull: ['$contentType', null] },
contentId: { $ifNull: ['$contentId', null] },
},
],
['unresolved_genre_tags', { tag: { $ifNull: ['$tag', null] } }],
['genre_taxonomy', { key: { $ifNull: ['$key', null] } }],
['home_highlight_snapshots', { key: { $ifNull: ['$key', null] } }],
]) {
const duplicates = db
.getCollection(collection)
.aggregate([{ $group: { _id: key, count: { $sum: 1 } } }, { $match: { count: { $gt: 1 } } }, { $limit: 5 }], { allowDiskUse: true })
.toArray();
print(collection, JSON.stringify(duplicates));
}
O sexto, radios.alternative_radio_browser_uuids_1, é sparse e multikey: um uuid colide quando está na lista de duas rádios (repetido na lista de uma só não colide), e só as rádios que têm o campo entram no índice. A agregação tem de devolver [], e as duas contagens 0 (nenhum escritor deixa null nem []: o schema não dá default ao campo, e duas rádios com null, ou duas com [], colidiriam):
// mongosh, na BD de prod (audissey-prod)
print('duplicados', JSON.stringify(db.radios.aggregate([{ $match: { alternative_radio_browser_uuids: { $exists: true, $ne: [] } } }, { $unwind: '$alternative_radio_browser_uuids' }, { $group: { _id: '$alternative_radio_browser_uuids', ids: { $addToSet: '$_id' } } }, { $match: { 'ids.1': { $exists: true } } }, { $limit: 5 }], { allowDiskUse: true }).toArray()));
print('null', db.radios.countDocuments({ alternative_radio_browser_uuids: { $type: 'null' } }));
print('[]', db.radios.countDocuments({ alternative_radio_browser_uuids: { $size: 0 } }));
Só relatório (não faz falhar o build: são índices diferentes): um uuid que uma rádio tem como gémeo e outra como radio_browser_uuid. O sync e o findCanonical levam a listagem à dona; o gémeo da outra fica sem uso (de uma corrida, ou de uma edição à mão) e vale a pena ver a que rádio pertence:
db.radios.aggregate([{ $match: { alternative_radio_browser_uuids: { $exists: true, $ne: [] } } }, { $unwind: '$alternative_radio_browser_uuids' }, { $lookup: { from: 'radios', localField: 'alternative_radio_browser_uuids', foreignField: 'radio_browser_uuid', pipeline: [{ $project: { _id: 1 } }], as: 'owners' } }, { $match: { 'owners.0': { $exists: true } } }, { $project: { uuid: '$alternative_radio_browser_uuids', heldAsTwinBy: '$_id', ownedBy: '$owners._id' } }, { $limit: 5 }]).toArray();
Se alguma verificação de duplicados (não a de só relatório) devolver grupos, ou uma contagem não for 0: não fazer o release com esse índice unique; limpar os duplicados primeiro (migração manual) ou tirar o unique do indexes.json e do schema.
Candidatos a drop (decisão humana)
Índices que existem na BD e que nem os schemas nem o indexes.json declaram (2026-10-29). Nada foi dropado: o ensure nunca dropa extras; um drop é uma decisão humana (migração manual). Quase todos são gémeos de direção de um índice do indexes.json (mesmos campos, -1 em vez de 1), criados por um autoIndex antigo; o índice ascendente serve as mesmas queries percorrido ao contrário, por isso os schemas passaram a declarar a direção do indexes.json.
| Coleção | Índice | Onde |
|---|---|---|
content_features |
contentType_1_popularity_-1 |
dev+staging |
content_features |
favoritesCount_1 (sem declaração) |
staging |
enrichment_logs |
createdAt_-1 (o TTL createdAt_1 cobre) |
dev+staging |
favorites |
userId_1_createdAt_-1, userId_1_type_1_createdAt_-1, createdAt_1 (sem declaração) |
staging |
feedbacks |
userId_1_createdAt_-1, type_1_createdAt_-1 |
dev |
job_execution_logs |
jobType_1_startedAt_-1, status_1_startedAt_-1 |
dev+staging |
listeninghistories |
userId_1_playedAt_-1, playedAt_1 (sem declaração) |
staging |
ml_training_runs |
trigger_type_1_started_at_-1, started_at_-1 |
dev+staging |
podcasts |
createdAt_-1 |
dev+staging |
radioprograms |
name_1, description_1 (sem declaração) |
staging |
radios |
is_active_1_content_language_1 (prefixo do novo is_active_1_content_language_1_clickcount_-1_votes_-1__id_1; ver abaixo) |
dev+staging |
recommendation_request_logs |
userId_1_timestamp_-1, endpoint_1_timestamp_-1 |
dev+staging |
schedule_change_events |
createdAt_-1 (o TTL createdAt_1 cobre) |
dev+staging |
scheduletriggerevents |
scheduleId_1_triggered_at_-1 |
staging |
scraping_log_summaries |
period_start_-1 |
dev |
scraping_logs |
status_1_createdAt_-1, error_type_1_createdAt_-1, extraction_method_1_createdAt_-1 |
dev+staging |
userevents |
userId_1_createdAt_-1, eventType_1_createdAt_-1, eventType_1_contentType_1_createdAt_-1, userId_1_eventType_1_createdAt_-1 |
staging |
Antes de dropar: confirmar com o audit-live (aparecem como EXTRA) e, se houver dúvida sobre uma query, com o $indexStats do índice. O radios.is_active_1_content_language_1 só se dropa à mão depois de o ensure ter construído is_active_1_content_language_1_clickcount_-1_votes_-1__id_1 nesse ambiente e de o explain-smoke dar nearby-device-language OK (dropado antes, a query cai no is_active_1 com um SORT bloqueante); em prod, só se lá existir. O push_notification_logs.userId_1_notification_type_1_content_key_1 (dev+staging) também aparece como EXTRA, mas tem migração própria (20261029_042, acima).
explain-smoke
Localização: scripts/explain-smoke.js (verificações em deploy/config/explain-smoke.json; o mongosh corre pelo scripts/lib/mongosh.js, o mesmo do ensure-atlas-indexes)
Propósito: confirmar numa BD real (staging) que as queries das funcionalidades de idioma de conteúdo usam os índices previstos, como pede o passo 8 do runbook de release das sugestões por idioma. Para cada verificação pede ao MongoDB o plano da query real e reporta os índices e as fases do plano vencedor, e os problemas: um índice esperado que não é usado, ou uma fase proibida (COLLSCAN, SORT bloqueante, ...).
Só planeia, nunca executa: usa explain('queryPlanner') para find e aggregate, nunca executionStats nem allPlansExecution; não itera nenhum cursor e não escreve nada. O MongoDB escolhe o plano vencedor e devolve-o: com vários planos candidatos corre o seu ensaio curto e limitado, como na primeira execução de qualquer forma de query, mas nenhuma query corre até ao fim nem devolve documentos. O config recusa $out, $merge, $where, $function e $accumulator, e também $lookup, $unionWith, $graphLookup e $facet: os planos dos seus sub-pipelines não fazem parte do plano vencedor que o script percorre, por isso um COLLSCAN dentro deles passaria sem ser visto. Por isso também não mede tempos: o "~1 s" do pool de destaques no runbook continua a ser um executionStats feito à mão.
Ambiente: recusa correr (código 2, antes de arrancar o mongosh) se EXPLAIN_SMOKE_ENV não for dev ou staging, se o host ou o nome da BD no MONGO_URI disserem prod/production (palavra inteira, como em audissey-prod, também codificado com %, como audissey%2Dprod, que o mongosh descodifica; credenciais e opções não contam; uma codificação % inválida é recusada como não verificável), ou se o MONGO_URI não for um URI mongodb:///mongodb+srv:// com host (por exemplo audissey-prod.abcde.mongodb.net/audissey, sem esquema, que o mongosh aceitaria mas que não se pode verificar), salvo com --allow-prod. A ligação vem do MONGO_URI e nunca é impressa (nem no stderr do mongosh). A BD é a do path do MONGO_URI, a mesma que o ensure e o audit-live usam; o relatório imprime-a (Database:).
Comandos:
# Relatório (é o que o pipeline de staging corre)
EXPLAIN_SMOKE_ENV=staging MONGO_URI='<uri>' node scripts/explain-smoke.js
# Estrito: termina com 1 se alguma verificação tiver problemas
EXPLAIN_SMOKE_ENV=staging MONGO_URI='<uri>' node scripts/explain-smoke.js --fail-on-problems
Opções: --config <ficheiro> (por omissão deploy/config/explain-smoke.json), --fail-on-problems, --allow-prod, --help. Um argumento desconhecido é recusado (código 2), para que um erro de escrita numa flag não passe em silêncio.
Verificações (cada uma copia o filtro/ordenação/projeção/limite ou o pipeline do código citado em source, com valores reais como origin_country: 'PT'):
| Verificação | Query (código) | Esperado |
|---|---|---|
nearby-origin-country |
RadiosService.nearbyCandidates com origin_country (radios.service.ts:550) |
IXSCAN is_active_1_origin_country_1_clickcount_-1_votes_-1__id_1, sem SORT nem COLLSCAN |
nearby-device-language |
RadiosService.nearbyCandidates das apps antigas que só enviam language, aqui pt ($or de content_language e, nas rádios ainda sem ela, do prefixo de language; radios.service.ts:550) |
SORT_MERGE de dois IXSCAN is_active_1_content_language_1_clickcount_-1_votes_-1__id_1 (pt e null), sem SORT nem COLLSCAN |
ver-mais-by-country |
RadiosService.homeListCandidates, "Ver mais" das rádios populares (GET /radios?sortBy=home) com idiomas, uma query por país das variantes do utilizador, aqui GB (radios.service.ts:1846) |
IXSCAN is_active_1_origin_country_1_clickcount_-1_votes_-1__id_1, sem SORT nem COLLSCAN |
ver-mais-worldwide |
idem, a query das rádios mais populares do mundo (radios.service.ts:1846) |
IXSCAN is_active_1_clickcount_1_votes_1 (lido de trás para a frente), sem SORT nem COLLSCAN |
weekly-discovery-language-radios |
WeeklyDiscoveryService.queryLanguageRadios, variante preferida pt-PT (worker-shared/src/services/weekly-discovery.service.ts:711) |
IXSCAN is_active_1_origin_country_1_clickcount_-1_votes_-1__id_1, sem SORT nem COLLSCAN (o locale filtra-se depois do FETCH) |
content-languages-radios |
ContentLanguagesService.listAvailable, rádios (content-languages.service.ts:59) |
IXSCAN is_active_1_locale_1, sem COLLSCAN (o locale_status filtra-se depois do FETCH) |
content-languages-podcasts |
idem, podcasts (content-languages.service.ts:69) |
sem COLLSCAN (o runbook não fixa índice; locale_1 ou locale_status_1_author_1 servem) |
new-episodes-candidates |
NewEpisodesService.getCandidatePodcasts, idioma preferido pt (new-episodes.service.ts:417) |
IXSCAN content_language_1_latest_episode_publish_date_-1__id_1, últimos 31 dias, mais recentes primeiro, sem SORT nem COLLSCAN |
new-episodes-global-completion |
NewEpisodesService.getCandidatePodcasts, completação de qualquer língua |
IXSCAN latest_episode_publish_date_-1__id_1, últimos 31 dias, mais recentes primeiro, sem SORT nem COLLSCAN |
review-queue-list |
AdminContentLocaleReviewService.list, 1.ª página (admin-content-locale-review.service.ts:140) |
IXSCAN locale_status_1_author_1, sem COLLSCAN. O SORT por {author, title, _id} é esperado: o índice só dá a ordem do author (top 50 da fila em memória) |
review-queue-top-authors |
AdminContentLocaleReviewService.topAuthors (admin-content-locale-review.service.ts:163) |
IXSCAN locale_status_1_author_1 (coberto, sem FETCH), sem COLLSCAN |
highlights-radio-pool |
RADIO_POOL_PIPELINE do worker (worker-shared/src/services/home.service.ts:170) |
corre e sem COLLSCAN (ver abaixo) |
highlights-podcast-pool |
PODCAST_POOL_PIPELINE do worker (worker-shared/src/services/home.service.ts:237) |
corre e sem COLLSCAN (ver abaixo) |
podcasts-home-list |
PodcastsService.findAll com sortBy=home, o "Ver mais" dos podcasts (podcasts.service.ts:114) |
IXSCAN health_score_-1_episode_count_-1_last_publish_date_-1__id_1 pela ordem do ranking, sem SORT nem COLLSCAN (língua, explicit e feed_dead filtram-se depois do FETCH) |
radio-canonical-identity-match |
RadioCanonicalizationService.findCanonical, o $or de identidade de uma fonte que nenhuma rádio tem (estação nova da Radio Browser, canal novo da Radio Garden, criação no admin, importação da pesquisa; radio-canonicalization.service.ts:200) |
OR de IXSCANs em radio_browser_uuid_1, dedupe_identity_keys_1 e normalized_stream_url_1, e um só IXSCAN em dedupe_fingerprint_1 (ou no índice de um domínio) para os dois ramos de fingerprint, com os domínios filtrados depois do FETCH (lê todas as rádios desse fingerprint: no máximo 85 em staging), sem COLLSCAN |
Ligação ao código: cada verificação é uma cópia em JSON, por isso os testes confirmam que a cópia é a query que o código envia (um $match editado sob a mesma linha de source falha, o que o sourceAnchor sozinho não apanha):
nearby-origin-country,nearby-device-language,content-languages-radios,content-languages-podcasts,new-episodes-candidates,new-episodes-global-completion,review-queue-listereview-queue-top-authors: os testes de integração da API correm o serviço real no MongoDB em memória com os valores literais da verificação (countryCode: 'PT',language: 'pt'sem país, idioma por omissãopt, primeira página da filareview), gravam o que a ligação do Mongoose envia (recordSentCommands, no hookdebugdo Mongoose) e comparam o primeirofind/aggregatedessa coleção com a verificação (filtro, ordenação, projeção, limite ou pipeline, incluindo a ordem das chaves):apps/api/test/integration/explain-smoke-queries.int-spec.ts,apps/api/src/app/radios/nearby-origin-country.int-spec.tseapps/api/src/app/radios/nearby-device-language.int-spec.tsver-mais-by-countryever-mais-worldwide:apps/api/src/app/radios/home-list-languages.int-spec.tspede o "Ver mais" de um utilizador só comen-GBe compara os doisfindde rádios com as verificações, da mesma formapodcasts-home-list:apps/api/src/app/podcasts/podcasts-home-list.int-spec.tspede a listasortBy=homeaoPodcastsServicereal e compara ofindenviado com a verificação, da mesma forma (e confirma o plano comexplain)radio-canonical-identity-match:apps/api/src/app/radios/radio-canonical-lookup-indexes.int-spec.tsfaz o upsert canónico de uma estação nova da Radio Browser com os valores literais da verificação e compara ofindOnecom$orenviado (ofindOneconta como umfindcomlimit: 1); o mesmo spec fazexplainde todas as leituras de rádios desse upsert e falha com umCOLLSCANweekly-discovery-language-radios:apps/api/src/app/recommendations/weekly-discovery-languages.int-spec.tsgera a descoberta semanal (sem ML) de um utilizador só compt-PTe compara ofindde rádios porlocalecom a verificação, da mesma formahighlights-radio-poolehighlights-podcast-pool: ohome.service.tsdo worker-shared exportaRADIO_POOL_PIPELINEePODCAST_POOL_PIPELINE, eworker-shared/src/services/home-pool-pipelines.explain-smoke.spec.ts(teste unitário) compara-os com as verificações; ohome.service.spec.tsconfirma que são esses os arrays passados aoaggregate, eapps/api/src/app/home/home-highlights-pool.int-spec.tscompara as agregações que o precompute envia
As regex comparam-se pelo que o servidor lê: /^pt/i, { $regex: /^pt/i } e { "$regex": "^pt", "$options": "i" } são a mesma. Ao mudar uma destas queries, estes testes falham até a verificação ser atualizada (e as fixtures recapturadas).
Campos de uma verificação: name, collection, source (ficheiro:linha, obrigatório), sourceAnchor (um excerto do código que tem de estar a 3 linhas ou menos da linha citada; os testes verificam-no, por isso uma citação desatualizada falha), note (o esperado, impresso quando há problemas), find (filter, sort, projection, limit) ou aggregate (pipeline), expectIndex (um nome, ou uma lista cujas entradas têm todas de ser usadas; uma entrada que é ela própria uma lista aceita qualquer um dos seus nomes) e forbidStages (fases do plano que são problema: COLLSCAN, SORT = ordenação bloqueante em memória; as fases de agregação depois do $cursor vão pelo nome com $, por exemplo $sort; só se aceitam nomes conhecidos, KNOWN_STAGES no script: um nome desconhecido é um erro de config, código 2, para que um erro de escrita não deixe de apanhar nada). Os testes confirmam também que cada expectIndex é um índice do indexes.json (pelo nome que o createIndex lhe dá).
Pool de destaques: as duas agregações leem por desenho quase todas as rádios ativas e todos os podcasts (o pool de todos os países e variantes), por isso não se exige um índice, só que corram sem COLLSCAN. No MongoDB 8.0 o $match nunca faz COLLSCAN legitimamente: todos os ramos do $or têm índice. O esperado é um único $group com $topN (empurrado para o SBE, fase GROUP) sobre um OR de dois IXSCAN: nas rádios, o prefixo is_active/origin_country do índice do nearby e um índice com country (is_active_1_country_1 nos dados da captura, country_1_city_1 noutros dados: a escolha entre índices equivalentes depende dos dados); nos podcasts, content_language_1 e language_1 (o explicit e o feed_dead filtram-se depois do FETCH; o $project tira um last_publish_date no futuro, por isso a fase é PROJECTION_DEFAULT; o $topN guarda 30 candidatos por grupo, por health_score, sem índice). O ramo language usa uma regex sem distinção de maiúsculas (^pt, i), que percorre o índice language_1 inteiro (limites ["", {})): não é um COLLSCAN, mas custa perto disso.
Output esperado (exemplo encurtado, com um problema):
📋 explain-smoke [staging] — 8 checks from deploy/config/explain-smoke.json
explain('queryPlanner') only: each query is planned, none runs to completion or returns a document.
Database: audissey-staging
✅ nearby-origin-country — radios find (classic)
plan: LIMIT > PROJECTION_SIMPLE > FETCH > IXSCAN[is_active_1_origin_country_1_clickcount_-1_votes_-1__id_1]
✅ review-queue-top-authors — podcasts aggregate (sbe)
plan: GROUP > PROJECTION_COVERED > IXSCAN[locale_status_1_author_1] | $sort
⚠️ content-languages-radios — radios aggregate (sbe)
plan: GROUP > FETCH > IXSCAN[is_active_1]
problem: expected index is_active_1_locale_1 not used (winning plan uses is_active_1)
expected: IXSCAN on is_active_1_locale_1 (...)
Summary: 8 checks, 7 OK, 1 with problems.
(classic) / (sbe) é o motor do plano: no 8.0 os find usam o motor clássico e as agregações com $group o SBE.
Códigos de saída: 0 relatório produzido, mesmo com problemas (esta primeira versão só relata); 1 problemas com --fail-on-problems; 2 recusado ou nada verificado (ambiente recusado, sem MONGO_URI, sem mongosh, mongosh falhou ou excedeu 90 s, argumentos ou config inválidos). Um explain que falha numa verificação (por exemplo sem permissão) é um problema dessa verificação, não um erro do script. Também é um problema, e nunca um OK, um plano que o script não consegue ler por inteiro: um explain de um cluster com shards (via mongos: sharded explain not supported), um nó sem nome de fase, um nó de plano sob uma chave que o script não percorre, ou um ramo que não termina num acesso à coleção (IXSCAN, COLLSCAN, EOF, IDHACK, COUNT_SCAN, DISTINCT_SCAN, CLUSTERED_IXSCAN, GEO_NEAR_*, EXPRESS_*): unrecognised plan shape.
Pipeline: só no stage de staging do azure-pipelines-api-release.yml, passo Explain smoke checks (staging, report only), o último passo do job (depois do Deploy e do Audit live Atlas indexes (staging, report only)), por isso vê os índices pós-deploy. EXPLAIN_SMOKE_ENV: staging e MONGO_URI: $(resolvedMongoUri), como os passos de índices ao lado; sem --fail-on-problems e com continueOnError: true, pelo que um problema, uma recusa ou uma falha do mongosh aparecem como "com problemas" e não falham o release. Limites: timeoutInMinutes: 3 no passo e 90 s no mongosh (o job de staging tem 25 minutos para caberem este passo e o audit-live).
Quando usar:
- Depois de um release em staging: ler o relatório do passo (o pipeline já o corre)
- Ao mudar uma destas queries ou os seus índices: atualizar a verificação e recapturar as fixtures (abaixo)
- Antes de passar o pipeline a
--fail-on-problems, quando o relatório estiver limpo em staging
Testes: node --test scripts/explain-smoke.test.js. O analyzePlan(explainOutput, check) (função pura) é testado sobre saídas reais do MongoDB 8.0 em scripts/__fixtures__/explain/ (uma por verificação do config, mais um COLLSCAN, um SORT bloqueante e uma coleção inexistente), que cobrem as formas clássica (stage/inputStage(s), SUBPLAN, OR) e SBE (queryPlan) e uma agregação com stages[].$cursor. Os testes confirmam também que cada fixture foi capturada da query atual de cada verificação (coleção, find/aggregate) e que todas as verificações passam no 8.0 com os índices reais; mudar só source, sourceAnchor, note, expectIndex ou forbidStages não obriga a recapturar. A CLI corre com um mongosh falso (nunca liga a uma BD) que falha se o script iterar um cursor, escrever ou pedir outro nível de explain. Correm no CI, no job Scripts_Tests (ver Testes dos scripts no CI).
As datas nos filtros são EJSON ({ "$date": "..." }); o script converte-as em Date antes do explain. Os testes de novos episódios fixam o relógio para comparar o mesmo cutoff.
Fixtures (node scripts/explain-smoke.capture-fixtures.js, só local, nunca no CI): arranca o MongoDB 8.0 em memória de tools/testing/mongo-memory, cria os índices do indexes.json das coleções usadas com o ensure-atlas-indexes --mode ensure, semeia dados determinísticos e corre as verificações pelo mesmo script gerado e o mesmo mongosh da CLI; grava cada saída sem host, porta nem dados de cluster. Precisa de mongosh no PATH. Correr de novo sempre que a query de uma verificação mudar (os testes falham até a fixture corresponder ao config) ou a versão fixada do MongoDB mudar. As fixtures não são reformatadas pelo prettier (.prettierignore).
Testes dos scripts no CI
O azure-pipelines.yml corre os testes dos scripts que as pipelines de release e de auditoria chamam, em dois sítios:
| Onde | Testes (scripts/<nome>.test.js) |
Precisa de |
|---|---|---|
Job Scripts_Tests (Node do .nvmrc) |
acr-cleanup, audit-rbac, check-renovate-worker-report, collect-phase3-operations, create-phase3-operations-snapshot, ensure-atlas-indexes, ensure-health-metric-alerts, ensure-ml-training-job, ensure-phase3-budget, ensure-podcast-sync-alerts, explain-smoke, node-version-alignment, worker-chromium-smoke |
Só o Node; git no node-version-alignment |
Job Backend_CI, logo a seguir ao npm ci |
ensure-containerapp-env, ensure-worker-keda, pre-push-script-tests |
Pacote yaml; bash e python3 com PyYAML no ensure-worker-keda; git no pre-push-script-tests |
Porque estão separados: os do Backend_CI leem as pipelines com o pacote yaml para verificar a estrutura de jobs e passos; sem ele teriam de usar expressões regulares sobre o texto, o que enfraquece essas verificações. Um job próprio com npm ci custaria cerca de 100 a 230 s por execução (restaurar a cache mais npm ci, medido nas execuções 7096 a 7137) no agente onde já corre o resto, para três ficheiros que demoram segundos.
O Scripts_Tests não tem npm ci nem depende do Backend_CI: os ficheiros correm numa cópia do repositório sem node_modules, com NODE_PATH vazio e sem az nem mongosh no PATH (são sempre os falsos dos testes, sem ligação ao Azure nem a uma BD). Limite de 10 minutos. Os ficheiros são explícitos, sem glob.
Antes do push: o .husky/pre-push corre scripts/pre-push-script-tests.js origin/dev, que lê do azure-pipelines.yml os ficheiros dos passos node --test e os filtros de caminhos (trigger e pr), e corre os testes se o diff tocar num caminho filtrado ou se o git não conseguir comparar. Não há outra lista para manter: um passo que o script não saiba ler faz falhar o hook, e o pre-push-script-tests.test.js falha se a pipeline nomear um teste fora desses passos. Corre antes dos checks do Nx e demora cerca de 25 s (Node 24, num Mac), ou 0,05 s quando não há nada a correr. Diferenças para o CI:
- Precisa de
python3com PyYAML noPATH: sem ele, oensure-worker-kedafalha comNo module named 'yaml'(python3 -m pip install pyyaml). - Há
node_modules, por isso um teste doScripts_Testsque passe a precisar de um pacote passa no hook e só falha no CI. - Os testes correm sem as variáveis locais do git (
git rev-parse --local-env-vars). Numa worktree o git exportaGIT_DIRpara o hook, e ogit initdas fixtures donode-version-alignmentescreveriacore.bare = truena config do repositório, o que parte todos os checkouts. Correr esse teste à mão comGIT_DIRdefinido tem o mesmo risco.
Fora desta verificação:
- Sem testes:
ensure-search-indexes.js,ensure-api-probes.sh,ensure-ml-http-scaler.shesetup-ml-containerapp.sh. As pipelines de release chamam-nos, por isso uma mudança nestes scripts só é exercitada no deploy. - Com testes, mas sem nenhum script chamado por uma pipeline:
api-sizing,audit-backup-evidence,audit-radio-genre-enrichment,audit-redis-connections,job-execution-schemas,phase3-configerecover-ml-training-run. Correr à mão comnode --test scripts/*.test.js(depois denpm ci).
Filtros de caminhos (trigger e pr): incluem scripts/**, deploy/config/**, Dockerfile*, **/Dockerfile*, azure-pipelines.yml, .husky/pre-push, azure-pipelines-*-release.yml, azure-pipelines-appsettings-audit.yml e azure-pipelines-acr-cleanup.yml, porque os testes leem estes ficheiros. Mudar só uma destas pipelines corre o CI, para um teste nunca ficar a falhar até ao commit seguinte de outra pessoa.
Testes de Integração (MongoDB em memória)
Comando:
npx nx run api:test-integration
npx nx run worker:test-integration
# Só uma pasta (padrão com caminho, como nos testes unitários)
npx nx run api:test-integration --testPathPatterns=app/home/
O que corre: os ficheiros *.int-spec.ts da API (apps/api/src/** e apps/api/test/integration/) e do worker (apps/worker/src/**) contra um MongoDB em memória (mongodb-memory-server), sem rede além do primeiro download do binário. O npx nx test api e o npx nx test worker ignoram-nos (testPathIgnorePatterns em apps/api/jest.config.cts e apps/worker/jest.config.cts), por isso os testes unitários nunca arrancam um mongod. Nunca se liga a uma BD real: o MONGO_URI não é lido e o setup apaga-o do ambiente.
Versão do MongoDB: fixada num só sítio, config.mongodbMemoryServer.version no package.json da raiz (8.0.34, a série 8.0 de produção). O setup passa-a explicitamente (um MONGOMS_VERSION exportado não a muda) e confirma buildInfo().version antes de qualquer suite: se o servidor não for exatamente essa versão 8.0 (por exemplo com MONGOMS_SYSTEM_BINARY), a execução falha logo. Para mudar de versão basta alterar esse campo; a cache do CI acompanha.
Infraestrutura partilhada (tools/testing/mongo-memory/, usada pela API e pelo worker):
global-setup.js/global-teardown.js: um servidor por execução, em127.0.0.1; o URI chega aos testes emAUDISSEY_MONGO_MEMORY_URIindex.ts:mongoMemoryUri()(só aceita127.0.0.1; o erro de um URI recusado mostra só o esquema, os hosts e a BD, nunca credenciais nem opções),mongoMemoryRootOptions(dbName)(autoIndex: false, como a API),createIndexesFromJson(db, ['radios', ...])(cria os índices doindexes.json, nunca uma cópia à mão, e confirma-os com odiffIndexesdoensure-atlas-indexes.js),clearCollections(db)eassertServerIsPinnedMongo(db)sent-commands.ts(exportado peloindex.ts):recordSentCommands(connection, fn)grava o que a ligação do Mongoose envia ao MongoDB enquantofncorre (pelo hookdebugdo Mongoose) eexplainSmokeQueryOfcompara umfind/findOne/aggregateenviado com uma verificação doexplain-smoke.json(umfindOneconta comofindcomlimit: 1) (ver Ligação ao código)- Cada suite usa a sua base de dados; correm em série (
maxWorkers: 1), o que compila o código partilhado uma só vez
Binário: o mongod (80 MB) descarrega-se na primeira execução para /.cache/mongodb-binariesMONGOMS_DOWNLOAD_DIR, se estiver definido (o CI aponta-o para a sua cache), ou, por omissão, para ` (os.homedir()), fora do repositório: o node_modules/.cacheapaga-se a cadanpm cie é partilhado pelas worktrees através do symlink donode_modules, enquanto a pasta da home guarda um só download para todos os checkouts. Não é preciso copiar nada: o mongodb-memory-serverainda encontra um binário que já esteja emnode_modules/.cache/mongodb-memory-server, e quando esse desaparecer a execução seguinte descarrega-o uma vez para ~/.cache/mongodb-binaries. O postinstall do pacote está desligado (disablePostinstallno mesmoconfig), por isso o npm ci` (CI e Docker) não descarrega nada.
Arranque do DI (apps/api/src/app/app.module.int-spec.ts, apps/worker/src/app/app.module.int-spec.ts): compila e inicializa (app.init()) o AppModule real de cada app contra o MongoDB em memória e resolve um provider real de cada domínio. Na API, antes do init(), corre o configureApp(app, { routeTemplateTelemetry }) de apps/api/src/configure-app.ts, o mesmo que o main.ts chama entre o NestFactory.create e o listen (middleware de telemetria por rota e de corpo JSON, prefixo global api, CORS, shutdown hooks, correlation id, o ValidationPipe global e o documento Swagger): um erro ao gerar o Swagger (já derrubou o arranque em staging) falha a suite, que confirma também o pipe global e as rotas do documento. O Key Vault, o Application Insights, o import do AppModule e o listen ficam no main.ts, porque têm de correr antes do create ou são o próprio arranque; apps/api/src/configure-app.spec.ts fixa a ordem e as opções das chamadas; no worker confirma também que os schemas Podcast, Episode e RadioProgram registados são os locais do worker (o primeiro MongooseModule.forFeature na ordem de imports ganha). Helpers em tools/testing/nest-boot/:
useHermeticEnv(vars): o arranque só vê as variáveis que a suite define (valores falsos) e corre numa pasta vazia, por isso oConfigModulenão encontra nenhum ficheiro.env; antes disso, osetupFilesdas duas configs de integração (tools/testing/mongo-memory/integration-env.js) já apagou o ambiente herdado (o.envda raiz que o nx carrega, a shell, o agente de CI), deixando só PATH, HOME, TMPDIR, as variáveis do jest e domongodb-memory-servere o URI do servidorprepareDeployedDatabase(dbName): a base de dadosdbNamedo MongoDB em memória da execução como um deploy a deixa (as coleções e os índices doindexes.json)BootLogRecorder(+captureConsole,captureLoggerService): regista cada aviso e erro do arranque (Logger do Nest,console,CustomLoggerServiceda API); cada suite tem umaBOOT_LOG_ALLOW_LISTexata nos dois sentidos (nada fora dela, e cada entrada tem de aparecer), verificada depois doinit()e depois doclose()useOfflineBullMq(): filas e workers BullMQ que nunca abrem uma ligação ao Redis (pelosBullModule.queueClass/workerClassdo próprio@nestjs/bullmq), por isso cadaregisterQueuee@Processorcontinua registado peloBullModulerealrecordOutboundConnections(): cada socket que o processo abre; as suites falham se o arranque ou o encerramento ligarem a algo além do MongoDB em memória (connectionsElsewhere(connections, ...ports): numa suite HTTP também a porta do seu próprio servidor em127.0.0.1)- Cada suite exporta
OVERRIDES(os providers substituídos,{ token, factory, inject?, reason }, a fonte única dos overrides do módulo de teste) eBOOT_DEVIATIONS(o resto que difere de produção, com a razão)
Suites: pool dos home highlights (precompute, latest, snapshotKey, readPool), migrações 20261023_013 e 20261023_014 (incluindo a escrita concorrente entre a leitura e o bulkWrite), idiomas de conteúdo no signup (UsersService), rádios perto por origin_country (RadiosService.findNearbyPublic), a descoberta semanal nas variantes do utilizador (WeeklyDiscoveryService.generateForUser sem ML, com o explain das queries por variante), as queries das verificações do explain-smoke (apps/api/test/integration/explain-smoke-queries.int-spec.ts), o signup por HTTP (abaixo) e a própria infraestrutura (apps/api/test/integration/mongo-memory-server.int-spec.ts).
Signup por HTTP (apps/api/src/app/users/users-signup-http.int-spec.ts): a app real (o AppModule com os mesmos OVERRIDES do arranque do DI, configurada pelo configureApp) a escutar em 127.0.0.1 numa porta livre (app.listen(0, '127.0.0.1')), chamada com o fetch do Node. Um utilizador com um access token no formato que o JwtStrategy aceita ({ email, sub, tv, typ: 'access' }, assinado pelo JwtService da própria app) faz GET /api/users/me (o teste de sessão, que guarda o idioma por omissão), PATCH /api/users/me com os payloads de signup das fixtures de contrato (app-client-payloads.ts: app atual, app antiga com country: 'Portugal' e o deviceLanguage: 'pt_PT') e GET outra vez; confirma as respostas (contentLanguages, preferredLanguage, country) e o documento guardado (incluindo contentLanguagesSource, que a resposta não traz), e um utilizador antigo com contentLanguages: null, que recebe o idioma do seu país no primeiro GET. Um pedido que registe um aviso ou erro não esperado falha o teste.
CI: azure-pipelines.yml, passos 🧪 Test API integration (MongoDB 8.0) e 🧪 Test Worker integration (MongoDB 8.0) logo depois dos testes unitários, com MONGOMS_DOWNLOAD_DIR=$(Pipeline.Workspace)/.cache/mongodb-binaries guardado por Cache@2 (chave: SO, arquitetura do agente, distribuição do binário e versão fixada). Ambos passam MONGOMS_DISTRO=ubuntu-22.04: a deteção automática da distribuição do agente levava o download a pedir ubuntu2304, que não existe para MongoDB 8.0.34 (HTTP 403). O override escolhe o binário LTS ubuntu2204, mantendo a arquitetura nativa e a versão fixada; só afeta downloads Linux. Verificar os dois targets de integração e, numa cache fria Linux ARM64, confirmar o download de mongodb-linux-aarch64-ubuntu2204-8.0.34.tgz.
Testes de Contrato (payloads da app)
Comando:
npx nx test api --testPathPatterns=app/contracts/
O que são: testes unitários (sem BD, correm no npx nx test api e no CI) que passam os payloads que a app Flutter envia de facto pelo mesmo ValidationPipe global que o main.ts instala (createGlobalValidationPipe(), instalado pelo configureApp). Um spec que chama só o validate() do class-validator não reproduz o pipe: este também faz whitelist, recusa propriedades desconhecidas (forbidNonWhitelisted) e transforma (transform). Foi assim que uma app que enviava um campo novo recebeu um 400 de uma API que ainda não o conhecia. Os testes do pipe (global-validation-pipe.spec.ts) confirmam que o main.ts chama o configureApp e que este instala esse pipe uma só vez.
Ficheiros: os casos em apps/api/src/app/contracts/app-client-payloads.ts (dados de teste: excluídos do tsconfig.app.json, nunca entram no build), corridos por app-client.contract.spec.ts. O teste de signup por HTTP usa os mesmos payloads.
Como acrescentar um caso: quando a app começa a enviar um campo ou pedido novo, acrescente-o primeiro ao array do endpoint em app-client-payloads.ts: name (o comportamento que fixa), client (app-current, app-legacy ou admin-tool), endpoint ('PATCH /users/me'), kind (body ou query; numa query os valores são strings, como chegam por HTTP), dto (o DTO a que o controller liga o payload), payload, expect (accept ou reject) e, opcionalmente, expectTransformed (propriedades do DTO depois do transform) ou, num reject, expectReason (excerto da mensagem do 400, para que um payload recusado por outra razão não passe). O spec falha se a API recusar o payload. Quando a app deixa de enviar um campo, mantenha o último payload como caso app-legacy: as apps instaladas continuam a enviá-lo.
Scripts de Limpeza
clean-local-schedules
Localização: apps/api/src/scripts/clean-local-schedules.ts
Propósito: Remover schedules incorretos de rádios locais
Comando:
npm run clean:local-schedules
Descrição:
Remove EPG de rádios que foram incorretamente mapeadas devido a:
- Matches com distância Levenshtein muito alta
- Rádios que não existem no MyTuner
- Dados obsoletos ou corrompidos
IDs hardcoded (ajustar conforme necessário):
const localRadioIds = [
'694830b40e1febe2e0cbfde4', // Rádio Elvas
'6948308f0e1febe2e0cbfc66', // Rádio Castelo Branco
'694830800e1febe2e0cbfbfa', // Rádio Portalegre
];
Quando usar:
- Após identificar matches incorretos com
validate:schedules - Cleanup manual de dados ruins
- Antes de re-executar
collect:epg
⚠️ Atenção: Este script remove dados permanentemente!
clean-seed-radios (Worker)
Localização: apps/worker/src/scripts/clean-seed-radios.ts
Propósito: Limpar base de dados de rádios para re-seed
Comando:
npm run clean:seed-radios
Descrição:
- Remove todas as rádios da BD
- Limpa schedules associados
- Prepara para novo seed
Quando usar:
- Reset completo do sistema
- Antes de re-executar seed com novos dados
- Desenvolvimento/testes
⚠️ Atenção: Remove TODAS as rádios!
reprocess-local-radios
Localização: apps/api/src/scripts/reprocess-local-radios.ts
Propósito: Reprocessar rádios locais específicas
Comando:
npm run reprocess:local-radios
Descrição:
- Recoleta EPG para lista específica de rádios
- Remove schedules antigos antes de reprocessar
- Útil para debugging
Rádios hardcoded:
const localRadioNames = ['Rádio Elvas', 'Rádio Castelo Branco', 'Rádio Portalegre'];
Quando usar:
- Debugging de rádio específica
- Re-tentar coleta após fix
- Validar algoritmo de matching
Scripts de Machine Learning
migrate-content-features
Localização: apps/api/src/scripts/migrate-content-features.ts
Propósito: Popular collection content_features com metadados normalizados para IA/ML
Comando:
npm run migrate:content-features
Descrição:
- Processa todos os conteúdos existentes (rádios, programas, podcasts, episódios)
- Normaliza gêneros para 25+ categorias padronizadas
- Converte idiomas/países para códigos ISO (ISO 639-1, ISO 3166-1)
- Extrai keywords automaticamente de títulos/descrições
- Remove stopwords (PT/EN) e palavras curtas
- Cria/atualiza documentos na collection
content_features - Prepara estrutura para embeddings futuros (texto/áudio)
- Processa via cursor (streaming) para evitar carregar coleções inteiras em memória
O que normaliza:
// Gêneros: 100+ variações → 25+ normalizados
"notícias", "news" → NormalizedGenre.NEWS
"música pop", "pop music" → NormalizedGenre.MUSIC_POP
"desporto", "sports" → NormalizedGenre.SPORTS
// Idiomas: ISO 639-1
"portuguese", "português" → "pt"
"english", "inglês" → "en"
// Países: ISO 3166-1 alpha-2
"Portugal" → "PT"
"Brasil", "Brazil" → "BR"
// Keywords: extraídas e filtradas
"The Latest News About..." → ["latest", "news", "about"]
Batching / Progress:
- O script percorre coleções com cursores (
lean().cursor()) - Faz logs de progresso periódicos (valores exatos dependem do script)
Saída Exemplo:
🚀 Starting ContentFeatures migration...
================================================
📻 Processing Radios...
Processed 50/100 radios...
✅ Radios completed: 100/100
📺 Processing Radio Programs...
Processed 100/500 programs...
✅ Programs completed: 500/500
🎙️ Processing Podcasts...
Processed 50/200 podcasts...
✅ Podcasts completed: 200/200
🎧 Processing Episodes...
Processed 500/5000 episodes...
✅ Episodes completed: 5000/5000
================================================
📊 MIGRATION STATISTICS
================================================
📻 Radios: 100/100 (0 errors)
📺 Programs: 500/500 (0 errors)
🎙️ Podcasts: 200/200 (0 errors)
🎧 Episodes: 5000/5000 (0 errors)
================================================
✅ Total Success: 5600
⚠️ Total Errors: 0
✨ Migration completed successfully!
Quando usar:
- Setup inicial após seed de conteúdos
- Re-popular features após mudanças no schema
- Atualizar normalizações após melhorias no algoritmo
- Preparar dados para treino de modelos ML
⚠️ Importante:
- Requer base de dados populada (radios, radioprograms, podcasts, episodes)
- Usa
findOneAndUpdatecomupsert: true(safe para re-runs) - Processo pode demorar alguns minutos para grandes volumes
Performance:
- Performance depende do tamanho das coleções e do MongoDB
- O script usa
lean()+cursor()para reduzir overhead de memória
Troubleshooting:
# Se houver erros de index
# 1. Drop da collection
db.content_features.drop()
# 2. Re-executar migration
npm run migrate:content-features
# Se precisar limitar episódios (editar script)
const episodesCursor = episodeModel.find({}).limit(100).lean().cursor();
Integração Contínua:
Para manter features atualizadas, integrar nos processors:
// Em sync-podcasts.processor.ts
for (const podcast of newPodcasts) {
await this.contentFeaturesService.upsertPodcastFeatures({
_id: podcast._id,
title: podcast.title,
categories: podcast.categories,
// ...
});
}
Ver também:
- CONTENT_FEATURES_ML.md - Documentação completa do sistema
/api/admin/content-features/stats- Endpoint de estatísticas/api/admin/content-features/search- Endpoint de busca
Workflow Recomendado
Setup Inicial (Desenvolvimento)
# 1. Popular base de dados (seed rápido)
npm run seed:radios
npm run seed:podcasts
# 2. Coletar EPG inicial
npm run collect:epg
# 3. Popular features para ML
npm run migrate:content-features
# 4. Validar dados
npm run validate:schedules
# 5. Testar monitorização
./apps/api/src/scripts/test-ingestion-status.sh
Setup Inicial (Produção/Staging)
# 1. Popular base de dados (seed completo)
npm run seed:radios:full
npm run seed:podcasts:full
# 2. Coletar EPG inicial
npm run collect:epg
# 3. Popular features para ML
npm run migrate:content-features
# 4. Validar dados
npm run validate:schedules
# 5. Verificar status de ingestão
./apps/api/src/scripts/test-ingestion-status.sh
Manutenção Semanal
# 1. Coletar EPG expirado
npm run collect:epg
# 2. Validar qualidade
npm run validate:schedules
# 3. (Opcional) Sincronizar metadados
npm run sync:radios
# 4. Monitorizar health do sistema
./apps/api/src/scripts/test-ingestion-status.sh
Troubleshooting
# 1. Testar componente específico
npm run test:mytuner-radio
npm run test:rss-ingest
npm run test:stream-fallback
# 2. Validar dados
npm run validate:schedules
# 3. Verificar status de jobs
./apps/api/src/scripts/test-ingestion-status.sh
# 4. Limpar se necessário
npm run clean:local-schedules
# 5. Re-coletar
npm run collect:epg
Reset Completo
# ⚠️ CUIDADO: Remove todos os dados!
# 1. Limpar base de dados
npm run clean:seed-radios
# 2. Re-seed (escolher rápido ou completo)
npm run seed:radios # OU npm run seed:radios:full
npm run seed:podcasts # OU npm run seed:podcasts:full
# 3. Re-coletar EPG
npm run collect:epg
# 4. Validar
npm run validate:schedules
Configuração de Webpack
Todos os scripts são compilados via Webpack com entry points configurados em:
Localização: apps/api/webpack.config.js e apps/worker/webpack.config.js
Para adicionar novo script:
additionalEntryPoints: [
{
entryName: 'scripts/novo-script',
entryPath: './src/scripts/novo-script.ts',
},
];
E adicionar em package.json:
"scripts": {
"novo-script": "npx nx run api:build && node dist/apps/api/scripts/novo-script.js"
}
Boas Práticas
Logging
Todos os scripts devem usar NestJS Logger:
const logger = new Logger('ScriptName');
logger.log('Informação');
logger.warn('Aviso');
logger.error('Erro');
logger.debug('Debug');
Error Handling
Scripts devem ser resilientes:
try {
// ... código
} catch (error) {
logger.error(`Erro ao processar: ${error.message}`);
continue; // Continuar processamento
}
Statistics
Scripts longos devem fornecer estatísticas:
const stats = { success: 0, errors: 0, ... };
// ... ao final
logger.log('📊 ESTATÍSTICAS');
logger.log(`✅ Sucesso: ${stats.success}`);
logger.log(`❌ Erros: ${stats.errors}`);
Exit Codes
Scripts devem retornar exit codes apropriados:
// Sucesso
await app.close();
process.exit(0);
// Erro
await app.close();
process.exit(1);
Recursos
- Ver também: CONTENT_FEATURES_ML.md - Sistema de features para IA/ML
- Ver também: INGESTION_MONITORING.md - Dashboard de monitorização de ingestão
- Ver também: RADIO_SCHEDULES_EPG.md - Sistema de EPG para rádios
- Ver também: ICY_METADATA_IMPLEMENTATION.md - Metadata ICY em tempo real
- Ver também: RADIOS_SYNC_IMPLEMENTATION.md - Sincronização de rádios
- Ver também: PODCASTS_IMPLEMENTATION.md - Sistema de podcasts
- Ver também: EPISODE_SYNC.md - Sincronização de episódios