🎙️

Implementação de Podcasts

Modelo de Dados de Podcasts e Episódios

📋 Resumo da Implementação

Implementação completa do modelo de dados para podcasts e episódios, incluindo:

✅ Schemas Podcast e Episode com todos os campos solicitados
✅ Relação Podcast → Episodes bem definida (Foreign Key via ObjectId)
✅ Services completos com validações
✅ Controllers com endpoints REST
✅ Testes unitários (71/71 passando)
✅ Endpoint de seed para criar dados de exemplo
✅ Documentação completa
✅ Descoberta automática de podcasts via Podcast Index API
✅ Sincronização diária com 50 novos podcasts
✅ Worker com jobs agendados para automação

🗄️ Schemas Criados

Podcast Schema

apps/api/src/app/podcasts/schemas/podcast.schema.ts

{
  id: ObjectId,              // Gerado automaticamente
  title: string,             // Obrigatório
  description?: string,      // Opcional
  rss_url: string,          // Obrigatório, único
  language?: string,         // Opcional
  categories: string[],      // Array
  createdAt: Date,          // Automático
  updatedAt: Date           // Automático
}

Índices:

Episode Schema

apps/api/src/app/podcasts/schemas/episode.schema.ts

{
  id: ObjectId,                    // Gerado automaticamente
  podcast_id: ObjectId,            // FK para Podcast (obrigatório)
  title: string,                   // Obrigatório
  description?: string,            // Opcional
  duration_seconds?: number,       // Opcional
  audio_url: string,              // Obrigatório
  publish_date: Date,             // Obrigatório
  createdAt: Date,                // Automático
  updatedAt: Date                 // Automático
}

Índices:

🔗 Relação Podcast → Episodes

A relação é implementada através de:

@Prop({ type: Types.ObjectId, ref: 'Podcast', required: true })
podcast_id: Types.ObjectId;

Tipo: One-to-Many

Validações:

🛠️ Endpoints Criados

As escritas (POST, PUT, DELETE) são só para admins com escrita (adminRole: admin: JwtAuthGuard, AdminGuard e AdminWriteGuard); um utilizador ou um viewer recebe 403. As leituras são públicas.

Podcasts

Episodes

Seed

Ingestão de feeds RSS

A ingestão por admins (POST /podcasts/ingest e POST /podcasts/ingest/bulk) só
busca endereços http(s) públicos. O URL é verificado antes do pedido, em cada
redirecionamento e no momento da ligação, incluindo todos os endereços devolvidos
pelo DNS. Um endereço privado devolve 400 com a mensagem
Feed URL must be a public http(s) address. Na ingestão em lote, a resposta mantém
o estado 200 e a entrada desse feed leva a mesma mensagem em errors.

O limite de tamanho do feed é 30 MiB, para limitar o uso de memória sem excluir
feeds com históricos longos. SSRF_ALLOW_PRIVATE_URLS=true desliga a verificação
de endereços apenas para desenvolvimento local; nunca é definido em deploy/config.

🌱 Testar com Dados de Exemplo

Opção 1: Usar o endpoint de seed

  1. Primeiro, faça login com uma conta de admin (adminRole: admin) para obter o token JWT:
curl -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com","password":"password"}'
  1. Execute o seed (substitua TOKEN_DE_ADMIN):
curl -X POST http://localhost:3000/seed/podcasts \
  -H "Authorization: Bearer TOKEN_DE_ADMIN"

Isto criará:

Opção 2: Script standalone

Também existe um script em apps/api/src/scripts/seed-podcasts.ts que pode ser usado para seed mais complexo.

🧪 Testes

Todos os testes passam (71/71):

npx nx test api

Testes incluem:

📝 Exemplos de Uso

Criar um Podcast

# Token de um admin com escrita (adminRole: admin); outro recebe 403.
curl -X POST http://localhost:3000/podcasts \
  -H "Authorization: Bearer TOKEN_DE_ADMIN" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Meu Podcast",
    "description": "Um podcast sobre...",
    "rss_url": "https://example.com/feed.xml",
    "language": "pt",
    "categories": ["Technology", "Science"]
  }'

Listar Podcasts

# Todos os podcasts
curl http://localhost:3000/podcasts

# Filtrar por idioma
curl http://localhost:3000/podcasts?language=pt

# Filtrar por categoria
curl http://localhost:3000/podcasts?category=Technology

# Continuar os podcasts populares da Home
curl 'http://localhost:3000/podcasts?sortBy=home&page=1&pageSize=50'
curl 'http://localhost:3000/podcasts?sortBy=home&page=2&pageSize=50'

sortBy=home é a continuação dos podcasts populares da Home e segue a mesma
ordem: só feeds em português (language /^pt/i, sempre: ainda não segue os
idiomas do utilizador), sem explicit: true nem feed_dead: true, ordenados
por HOME_PODCAST_POOL_SORT (health_score, episode_count,
last_publish_date desc e _id; um último episódio no futuro conta como
nenhum), lidos pela ordem do índice
health_score_-1_episode_count_-1_last_publish_date_-1__id_1 (no máximo 300).
Depois rankHomePodcastList tira as cópias de um programa (mesmo título e
autor) e aplica no máximo 2 do mesmo autor e 3 da mesma primeira categoria em
cada janela de 10. É uma lista fixa de até 200 e cada página é uma fatia dela,
por isso as páginas nunca repetem um podcast. Uma category continua a
filtrar. Limpar pesquisa/filtros no cliente deve
voltar a esta ordenação.

Criar um Episódio

# Token de um admin com escrita (adminRole: admin); outro recebe 403.
curl -X POST http://localhost:3000/episodes \
  -H "Authorization: Bearer TOKEN_DE_ADMIN" \
  -H "Content-Type: application/json" \
  -d '{
    "podcast_id": "PODCAST_ID_AQUI",
    "title": "Episódio 1: Introdução",
    "description": "Primeiro episódio do podcast",
    "duration_seconds": 3600,
    "audio_url": "https://example.com/audio/ep1.mp3",
    "publish_date": "2025-01-01T10:00:00Z"
  }'

Listar Episódios de um Podcast

# Usando query param
curl http://localhost:3000/episodes?podcast_id=PODCAST_ID

# Usando rota específica
curl http://localhost:3000/episodes/podcast/PODCAST_ID

📂 Estrutura de Ficheiros

apps/api/src/app/podcasts/
├── schemas/
│   ├── podcast.schema.ts       # Schema Podcast
│   └── episode.schema.ts       # Schema Episode
├── dto/
│   ├── create-podcast.dto.ts   # DTO para criar podcast
│   ├── podcast-response.dto.ts # DTO de resposta podcast
│   ├── create-episode.dto.ts   # DTO para criar episódio
│   └── episode-response.dto.ts # DTO de resposta episódio
├── podcasts.service.ts         # Service de podcasts
├── podcasts.controller.ts      # Controller de podcasts
├── episodes.service.ts         # Service de episódios
├── episodes.controller.ts      # Controller de episódios
├── seed.controller.ts          # Controller para seed
├── podcasts.module.ts          # Módulo
├── *.spec.ts                   # Testes
└── README.md                   # Documentação detalhada

✅ Critérios de Aceitação

Todos os critérios foram cumpridos:

🤖 Descoberta Automática de Podcasts

Visão Geral

O sistema inclui descoberta automática de novos podcasts através da Podcast Index API, executando diariamente para expandir o catálogo.

Características

Configuração

Variáveis de Ambiente (.env):

# IMPORTANTE: Use \ antes do $ para escapar caracteres especiais
PODCAST_INDEX_API_KEY=sua_api_key
PODCAST_INDEX_API_SECRET=sua_api_secret
PODCAST_INDEX_API_URL=https://api.podcastindex.org/api/1.0

⚠️ Nota Importante: Se o API_SECRET contiver caracteres especiais como $, ^, ou !, escape-os com \ no ficheiro .env.

Arquitetura

Worker (apps/worker/src/app/):

jobs/
├── services/
│   └── job-scheduler.service.ts    # Agendamento (cron diário)
└── processors/
    └── sync-podcasts.processor.ts   # Processamento do job

podcasts-sync/
└── services/
    └── podcasts-sync.service.ts     # Lógica de descoberta

Fluxo de Descoberta

  1. Cron Trigger (5h AM)

    • Job agendado dispara automaticamente
    • Parâmetro withDiscovery: true
  2. Descoberta Multi-Vetor

    • Conecta à Podcast Index API via 5 vetores:
      • Trending PT (100) + Trending BR (100)
      • 10 categorias via trending?cat=X&lang=pt,pt-br (50 cada)
      • 5 termos de pesquisa (50 cada)
      • Feeds recentes lang=pt,pt-br (100)
    • Autenticação via SHA1 hash
  3. Processamento

    • Deduplica por Podcast Index ID (in-memory)
    • Para cada podcast único:
      • Verifica se já existe (por RSS URL)
      • Se novo: adiciona à base de dados
      • Se existe: atualiza metadados se necessário
    • Tenta enriquecer dados via parsing RSS
  4. Logging

    • Progresso a cada 10 podcasts
    • Resumo final: novos, existentes, erros

Monitorização

Logs do Worker:

🔄 Iniciando sincronização de podcasts...
✨ Discovery mode enabled
🔍 Discovering new podcasts...
📡 API returned 50 podcasts
Processing 50 podcasts...
Progress: 10/50 - New: 8, Existing: 2
Progress: 20/50 - New: 16, Existing: 4
...
📊 Discovery summary: 45 new, 5 existing, 0 errors
✅ Discovery phase completed

API de Status:

GET http://localhost:3001/api/jobs/stats

Retorna estatísticas das filas de jobs, incluindo podcasts sync.

Execução Manual

Para testar ou forçar descoberta:

# Via API do Worker
curl -X POST http://localhost:3001/api/jobs/trigger/sync-podcasts-with-discovery \
  -H "Authorization: Bearer YOUR_TOKEN"

Schema Atualizado

Campos adicionais no Podcast Schema:

{
  // ... campos anteriores ...
  author?: string,           // Autor do podcast
  logo?: string,            // URL da imagem/logo
  podcast_index_id?: number // ID no Podcast Index
}

Logs de Produção

Logs otimizados para produção:

🚀 Próximos Passos Sugeridos

  1. Implementar ingestão automática de RSS feeds ✅ Concluído
  2. Adicionar paginação aos endpoints de listagem
  3. Implementar busca/pesquisa avançada de podcasts
  4. Implementar webhooks para novos episódios
  5. Adicionar estatísticas e analytics
  6. Sistema de recomendações baseado em categorias
  7. Rate limiting para discovery API