🔍

API de Pesquisa Unificada

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

Os resultados são ordenados por uma pontuação de relevância que considera:

  1. Matching do nome/título (exact match > partial match)
  2. Popularidade (para rádios: clicks e votes; para podcasts/episódios: recência)
  3. 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

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:

4. Analytics de Pesquisa

Tracking de métricas:

5. Personalização

Adaptar resultados baseado em:


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

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.