⚙️

Scripts & Automação

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


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:

Quando usar:


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:

Quando usar:

Dados incluídos:


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:

Quando usar:

⚠️ 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:

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:

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:


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:

Quando usar:


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:

Requisitos:

Quando usar:

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:


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:

Quando usar:


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:

Quando usar:

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:

Quando usar:


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:

  1. Duplicados por radio_id

    • Detecta múltiplas entradas para mesma rádio
    • Indica problemas no script de coleta
  2. Schedules partilhados

    • Identifica rádios diferentes com mesma programação
    • Pode indicar:
      • Retransmissoras legítimas (normal)
      • Matches incorretos (problema)
  3. 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:

Interpretação:

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:

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:


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:

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):

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:

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

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):

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:

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:

Fora desta verificação:

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):

Binário: o mongod (80 MB) descarrega-se na primeira execução para MONGOMS_DOWNLOAD_DIR, se estiver definido (o CI aponta-o para a sua cache), ou, por omissão, para `/.cache/mongodb-binaries (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/:

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:

IDs hardcoded (ajustar conforme necessário):

const localRadioIds = [
  '694830b40e1febe2e0cbfde4', // Rádio Elvas
  '6948308f0e1febe2e0cbfc66', // Rádio Castelo Branco
  '694830800e1febe2e0cbfbfa', // Rádio Portalegre
];

Quando usar:

⚠️ 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:

Quando usar:

⚠️ 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:

Rádios hardcoded:

const localRadioNames = ['Rádio Elvas', 'Rádio Castelo Branco', 'Rádio Portalegre'];

Quando usar:


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:

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:

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:

⚠️ Importante:

Performance:

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:


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