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:
title: pesquisas por títulocategories: filtros por categorialanguage: filtros por idiomarss_url: único (previne duplicados)
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:
podcast_id: queries de episódios por podcastpublish_date: ordenação por datapodcast_id + publish_date: índice composto
🔗 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
- 1 Podcast pode ter N Episodes
- 1 Episode pertence a 1 Podcast
Validações:
- Ao criar um Episode, verifica se o Podcast existe
- Retorna
NotFoundExceptionse o Podcast não existir
🛠️ 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
POST /podcasts- Criar podcast (só admins)GET /podcasts- Listar podcasts (query params: language, category,page,pageSizeesortBy=home)GET /podcasts/search?q=termo- Pesquisar podcastsGET /podcasts/:id- Obter podcast por IDGET /podcasts/:id/episodes- Listar episódios de um podcast (ordenados por data desc)PUT /podcasts/:id- Atualizar podcast (só admins)DELETE /podcasts/:id- Eliminar podcast (só admins)
Episodes
POST /episodes- Criar episódio (só admins)GET /episodes- Listar episódios (query param: podcast_id)GET /episodes/podcast/:podcastId- Listar episódios de um podcast (alternativo)GET /episodes/:id- Obter episódio por IDPUT /episodes/:id- Atualizar episódio (só admins)DELETE /episodes/:id- Eliminar episódio (só admins)
Seed
POST /seed/podcasts- Criar dados de exemplo (só admins)
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 mensagemFeed 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
- 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"}'
- Execute o seed (substitua TOKEN_DE_ADMIN):
curl -X POST http://localhost:3000/seed/podcasts \
-H "Authorization: Bearer TOKEN_DE_ADMIN"
Isto criará:
- 3 podcasts (Tecnologia, História, Futebol)
- 6 episódios distribuídos pelos podcasts
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:
podcasts.service.spec.ts- Testes do service de podcastspodcasts.controller.spec.ts- Testes do controller de podcastsepisodes.service.spec.ts- Testes do service de episódiosepisodes.controller.spec.ts- Testes do controller de episódios
📝 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 índicehealth_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:
- Schema Podcast com campos: id, title, description, rss_url, language, categories
- Schema Episode com campos: id, podcast_id, title, description, duration_seconds (opcional), audio_url, publish_date
- Relação Podcast → Episodes bem definida (FK via ObjectId)
- Estrutura criada na BD com índices otimizados
- Testada com registos de exemplo (endpoint seed + script)
- Testes unitários completos
- Documentação detalhada
🤖 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
- Execução Diária: Job automático às 5h da manhã (Europe/Lisbon)
- Volume: ~300-500 podcasts únicos por execução (5 vetores de descoberta)
- Idioma: Português (PT) e Português Brasileiro (PT-BR)
- Vetores: Trending PT, Trending BR, Categorias (10), Pesquisa (5 termos), Feeds Recentes
- Deduplicação: Por Podcast Index ID (in-memory) + RSS URL (DB)
- Enriquecimento: Tenta enriquecer dados via parsing do feed RSS
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
Cron Trigger (5h AM)
- Job agendado dispara automaticamente
- Parâmetro
withDiscovery: true
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
- Conecta à Podcast Index API via 5 vetores:
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
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:
- Logs essenciais mantidos
- Debug removido
- Formato conciso e informativo
- Fácil monitorização
🚀 Próximos Passos Sugeridos
Implementar ingestão automática de RSS feeds✅ Concluído- Adicionar paginação aos endpoints de listagem
- Implementar busca/pesquisa avançada de podcasts
- Implementar webhooks para novos episódios
- Adicionar estatísticas e analytics
- Sistema de recomendações baseado em categorias
- Rate limiting para discovery API