🔍 API de Pesquisa Unificada
Endpoint de pesquisa que busca simultaneamente em rádios, podcasts e episódios, retornando resultados ordenados por relevância.
Endpoint
GET /search
Descrição
Este endpoint realiza uma pesquisa unificada em todos os tipos de conteúdo da plataforma:
- Rádios: Pesquisa em nome, descrição e géneros
- Podcasts: Pesquisa em título, descrição, autor e categorias
- Episódios: Pesquisa em título e descrição
Os resultados são ordenados por uma pontuação de relevância que considera:
- Matching do nome/título (exact match > partial match)
- Popularidade (para rádios: clicks e votes; para podcasts/episódios: recência)
- Idioma (prioriza conteúdo no idioma do utilizador)
Autenticação
❌ Não requer autenticação (endpoint público)
Request
Query Parameters
| Parâmetro | Tipo | Obrigatório | Descrição | Exemplo |
|---|---|---|---|---|
q |
string | ✅ Sim | Termo de pesquisa | rock |
language |
string | ❌ Não | Código do idioma (ISO) para priorizar resultados | pt |
limit |
number | ❌ Não | Número máximo de resultados (padrão: 50) | 20 |
Exemplos
# Pesquisa simples
curl "http://localhost:3000/search?q=rock"
# Pesquisa com preferência de idioma
curl "http://localhost:3000/search?q=notícias&language=pt"
# Pesquisa limitada
curl "http://localhost:3000/search?q=música&limit=10"
# Pesquisa completa
curl "http://localhost:3000/search?q=tecnologia&language=pt&limit=20"
Response
Success (200 OK)
{
"query": "rock",
"total_results": 15,
"results": [
{
"id": "507f1f77bcf86cd799439011",
"name": "Rock FM",
"stream_url": "https://stream.rockfm.pt/stream",
"genre": ["Rock", "Classic Rock"],
"language": "pt",
"country": "Portugal",
"relevance_score": 150.5,
"type": "radio"
},
{
"id": "507f1f77bcf86cd799439012",
"title": "Rock Podcast",
"description": "Discussões sobre rock clássico",
"author": "Rock Expert",
"logo": "https://example.com/logo.png",
"language": "pt",
"categories": ["Music", "Rock"],
"relevance_score": 145.2,
"type": "podcast"
},
{
"id": "507f1f77bcf86cd799439013",
"title": "História do Rock",
"description": "Episódio sobre a história do rock",
"podcast_id": "507f1f77bcf86cd799439012",
"podcast_title": "Rock Podcast",
"podcast_logo": "https://example.com/logo.png",
"audio_url": "https://example.com/episode.mp3",
"duration_seconds": 3600,
"publish_date": "2024-01-15T10:00:00.000Z",
"relevance_score": 130.0,
"type": "episode"
}
],
"radios_count": 5,
"podcasts_count": 7,
"episodes_count": 3
}
Estrutura da Resposta
Resposta Principal
| Campo | Tipo | Descrição |
|---|---|---|
query |
string | Termo de pesquisa usado |
total_results |
number | Número total de resultados retornados |
results |
array | Array de resultados ordenados por relevância |
radios_count |
number | Número de rádios encontradas |
podcasts_count |
number | Número de podcasts encontrados |
episodes_count |
number | Número de episódios encontrados |
Resultado Tipo: Radio
| Campo | Tipo | Descrição |
|---|---|---|
id |
string | ID único da rádio |
name |
string | Nome da rádio |
stream_url |
string | URL do stream de áudio |
genre |
string[] | Lista de géneros musicais |
language |
string | Código do idioma (ISO) |
country |
string | País de origem |
relevance_score |
number | Pontuação de relevância |
type |
"radio" |
Identificador do tipo |
Resultado Tipo: Podcast
| Campo | Tipo | Descrição |
|---|---|---|
id |
string | ID único do podcast |
title |
string | Título do podcast |
description |
string | Descrição do podcast |
author |
string | Autor/Criador do podcast |
logo |
string | URL da imagem de capa |
language |
string | Código do idioma (ISO) |
categories |
string[] | Categorias do podcast |
relevance_score |
number | Pontuação de relevância |
type |
"podcast" |
Identificador do tipo |
Resultado Tipo: Episode
| Campo | Tipo | Descrição |
|---|---|---|
id |
string | ID único do episódio |
title |
string | Título do episódio |
description |
string | Descrição do episódio |
podcast_id |
string | ID do podcast pai |
podcast_title |
string | Título do podcast pai |
podcast_logo |
string | URL do logo do podcast |
audio_url |
string | URL do ficheiro de áudio |
duration_seconds |
number | Duração em segundos |
publish_date |
Date | Data de publicação |
relevance_score |
number | Pontuação de relevância |
type |
"episode" |
Identificador do tipo |
Algoritmo de Relevância
A pontuação de relevância é calculada para cada resultado baseando-se em múltiplos fatores:
Para Rádios (até ~200 pontos)
| Fator | Pontos | Descrição |
|---|---|---|
| Exact Match | 100 | Nome é exatamente igual ao termo |
| Starts With | 90 | Nome começa com o termo |
| Word Match | 80 | Termo é uma palavra completa no nome |
| Contains | 60 | Nome contém o termo |
| Popularidade | 0-50 | Baseado em clicks e votes |
| Idioma Match | +30 | Idioma corresponde ao utilizador |
| Description Match | +10 | Termo aparece na descrição |
| Genre Match | +15 | Termo aparece nos géneros |
Para Podcasts (até ~200 pontos)
| Fator | Pontos | Descrição |
|---|---|---|
| Name Match | 0-100 | Mesma lógica das rádios |
| Idioma Match | +30 | Idioma corresponde ao utilizador |
| Recência | 0-20 | Podcasts mais recentes |
| Description Match | +10 | Termo na descrição |
| Author Match | +15 | Termo no autor |
| Category Match | +15 | Termo nas categorias |
Para Episódios (até ~200 pontos)
| Fator | Pontos | Descrição |
|---|---|---|
| Name Match | 0-100 | Mesma lógica das rádios |
| Idioma do Podcast | +30 | Idioma do podcast corresponde |
| Recência | 0-20 | Episódios mais recentes |
| Description Match | +10 | Termo na descrição |
| Podcast Match | +20 | Termo no nome do podcast |
Casos de Uso
1. Interface de Pesquisa Global
async function globalSearch(query) {
const userLang = navigator.language.split('-')[0];
const response = await fetch(
`/search?q=${encodeURIComponent(query)}&language=${userLang}`
);
const data = await response.json();
// Processar resultados
displayResults(data.results);
// Mostrar contadores
showStats({
radios: data.radios_count,
podcasts: data.podcasts_count,
episodes: data.episodes_count
});
}
2. Filtrar por Tipo de Conteúdo
const response = await fetch('/search?q=música');
const data = await response.json();
// Separar por tipo
const radios = data.results.filter(r => r.type === 'radio');
const podcasts = data.results.filter(r => r.type === 'podcast');
const episodes = data.results.filter(r => r.type === 'episode');
// Exibir em tabs separadas
displayInTab('radios', radios);
displayInTab('podcasts', podcasts);
displayInTab('episodes', episodes);
3. Pesquisa com Autocomplete
let searchTimeout;
function handleSearchInput(value) {
clearTimeout(searchTimeout);
if (value.length < 3) return; // Mínimo 3 caracteres
searchTimeout = setTimeout(async () => {
const response = await fetch(`/search?q=${value}&limit=5`);
const data = await response.json();
showAutocomplete(data.results);
}, 300); // Debounce de 300ms
}
4. Pesquisa por Voz
async function voiceSearch() {
const recognition = new webkitSpeechRecognition();
recognition.lang = 'pt-PT';
recognition.onresult = async (event) => {
const query = event.results[0][0].transcript;
const response = await fetch(`/search?q=${query}&language=pt`);
const data = await response.json();
displayResults(data.results);
};
recognition.start();
}
Performance e Cache
Estratégia de Cache Recomendada
class SearchCache {
constructor(ttl = 5 * 60 * 1000) { // 5 minutos
this.ttl = ttl;
this.cache = new Map();
}
async search(query, language, limit = 50) {
const key = `${query}_${language}_${limit}`;
const cached = this.cache.get(key);
if (cached && Date.now() - cached.timestamp < this.ttl) {
return cached.data;
}
const data = await fetch(
`/search?q=${query}&language=${language}&limit=${limit}`
).then(r => r.json());
this.cache.set(key, {
data,
timestamp: Date.now()
});
return data;
}
clear() {
this.cache.clear();
}
}
const searchCache = new SearchCache();
Otimizações
- ✅ Pesquisas executadas em paralelo (rádios, podcasts, episódios)
- ✅ Limite de 20 resultados por tipo antes da agregação
- ✅ Utiliza índices MongoDB existentes
- ✅ Regex case-insensitive para matching
Melhorias Futuras
1. Filtros Avançados
Adicionar query parameters para filtrar:
GET /search?q=rock&type=radio&country=PT&genre=Rock
GET /search?q=tech&type=podcast&category=Technology
2. Pesquisa Fuzzy
Implementar tolerância a erros de digitação:
"rockk" → "rock"
"tecnologia" → "tecnología"
3. Elasticsearch Integration
Migrar para Elasticsearch para:
- Pesquisa full-text mais avançada
- Suporte a sinónimos
- Highlighting de termos
- Agregações e facets
4. Analytics de Pesquisa
Tracking de métricas:
- Termos mais pesquisados
- Taxa de clique por posição
- Pesquisas sem resultados
- Tempo de resposta
5. Personalização
Adaptar resultados baseado em:
- Histórico de pesquisa
- Conteúdo favorito
- Localização geográfica
- Horário de acesso
Links Relacionados
Última Atualização: 25 de dezembro de 2025
Correção #374 — 22–23/10/2026
Orçamento por tipo. unifiedSearch corre rádios, podcasts, episódios e programas em paralelo e cada tipo tem 2,5 s (TYPE_SEARCH_DEADLINE_MS). Um tipo que excede o orçamento ou falha fica identificado em failed_types e regista Search for <tipo> exceeded 2500 ms (warn) ou Search for <tipo> failed (error); os outros tipos são devolvidos com HTTP 200 e partial: true. Se todos os tipos pedidos falharem (incluindo pedidos com um único types), a API devolve HTTP 503 recuperável, em vez de uma pesquisa vazia. Uma pesquisa concluída sem correspondências continua a devolver HTTP 200 e results: []; respostas completas omitem partial e failed_types. Contagens, paginação e has_more de uma resposta parcial referem-se apenas aos tipos concluídos. Todas as consultas da pesquisa (Atlas e regex, nos quatro tipos) levam maxTimeMS de 2,5 s, para o servidor parar a consulta atrasada em vez de a acumular. Depois de um MaxTimeMSExpired no Atlas, o serviço não repete a pesquisa em regex. Pior caso da resposta: ~2,5 s mais rede, em vez do tipo mais lento (até 14,7 s medidos em staging).
Fornecedores externos. Cada pedido a Radio Browser ou Podcast Index tem um prazo de 3 s (EXTERNAL_SEARCH_TIMEOUT_MS). Como o orçamento do tipo é 2,5 s, uma descoberta externa lenta pode terminar e gravar em segundo plano depois da resposta; os novos registos aparecem na pesquisa seguinte. A importação de até 20 podcasts usa lotes de quatro RSS distintos, preserva IDs e metadados existentes e mantém a normalização de categorias/idioma (upsert pelo índice único rss_url).
Atlas Search. Com ATLAS_SEARCH_ENABLED=true (ver docs/setup/ENVIRONMENTS.md):
- Rádios:
$searchcomfilteris_active: trueeminimumShouldMatch: 1→$limitN →$addFields(highlights) →$project. - Podcasts:
$search→$limitN →$addFields→$project. - Episódios:
$search→$addFields→$lookuppodcasts (projeção_id title logo language) →$unwind→$limitN →$project. - Programas:
$search→$addFields→$lookuprádios (projeção comis_active,clickcount,votes) →$unwind→$match_radio.is_active→$limitN →$project. - O
$searchjá devolve os hits por score: não há$sortbloqueante antes do$limit. Episódios e programas percorrem os hits até obter N resultados com pai válido ou esgotar as correspondências; uma sequência de podcasts apagados ou rádios inativas não corta os resultados válidos seguintes. O resultado fica limitado a N (máximo 250 candidatos por tipo) e o trabalho da consulta a 2,5 s pormaxTimeMS; atingir o prazo é uma falha explícita desse tipo, não prova de ausência de resultados. - Episódios e programas: fuzzy (
maxEdits: 1) só no título/nome; a descrição usa termos exatos (com stemminglucene.portuguese). Rádios e podcasts mantêm o fuzzy anterior. - Relevância: o Atlas só recolhe candidatos e highlights. A ordenação usa as mesmas funções de relevância do caminho regex (
calculate*Relevance), porque os scores$searchde coleções diferentes não são comparáveis: comsearchScore × 30, em "Radio Comercial" a primeira rádio só aparecia na posição 17 (posição 1 no caminho regex). - Rádios e podcasts sem resultados Atlas seguem para o caminho regex, que mantém o fallback Radio Browser / Podcast Index.
As consultas regex usam projeção e lean. Não há novos recursos Azure nem novos índices. Medições antes/depois: docs/reports/BUG_374_SEARCH_PERFORMANCE.md.