🎵

API "Agora a Tocar"

API "Agora a Tocar" (Now Playing)

Visão Geral

Esta API fornece duas funcionalidades complementares para informar o utilizador sobre o que está a ser transmitido numa rádio:

  1. Metadata ICY - Informação sobre a música/conteúdo atual extraída do stream de áudio
  2. Programa Atual - Informação sobre o programa de rádio que está no ar baseado no EPG (Electronic Program Guide)

Endpoint: Now Playing (Metadata ICY)

GET /radios/:id/now-playing

Descrição

Retorna informação sobre o que está a tocar numa rádio específica.

O endpoint é read-only (não faz fetch a streams ao vivo):

O rádio expõe icy_metadata_valid; o REST/SSE de now-playing expõe a mesma
política como icyMetadataValid. false invalida apenas metadata do stream e
relatórios ICY do cliente, mantendo adapters dedicados e fingerprint ativos.
O campo ausente mantém o comportamento anterior. A app aplica a política à UI,
aos relatórios e à sessão nativa, sem conhecer nomes de rádios. Na Antena 1 São
Paulo, o backend força false pelo URL e o job de capability persiste a flag,
sem alterar enable_icy_epg nem a capability full. Payloads Redis antigos sem
política são reavaliados em MongoDB e voltam a ser publicados com a flag.
ICY Valid no Data Quality continua a medir frescura, não esta política.

A atualização do now_playing pode vir do Worker (ICY collector ou fingerprint) ou de um relatório ICY do cliente autenticado. Todos os escritores persistem a mesma proveniência em Redis + MongoDB.

Na Antena 1 de São Paulo, o adapter antena1 usa o feed oficial de música atual,
preservando o timestamp fornecido pela rádio. A identificação ANTENA 1 - ANTENA 1
é ignorada no coletor e nos relatórios do cliente e não substitui uma música válida.
O reconhecimento existente cobre também esta rádio após dois probes vazios.
Ver routing, fallback, monitorização e custo.

Streams http(s)://<host>/listen/<shortcode>/<ficheiro> (AzuraCast) usam o adapter
azuracast, que lê GET <origin>/api/nowplaying/<shortcode> e preserva played_at
como timestamp. Se a API falhar ou estiver desatualizada, cai no caminho ICY
genérico; uma resposta saudável sem música (jingle, anúncio, nome de ficheiro) dá um
probe vazio. O reconhecimento existente cobre estas rádios após dois probes vazios.
Ver routing, validação, fallback e custo.

Parâmetros

Path Parameters

Parâmetro Tipo Obrigatório Descrição
id string Sim ID MongoDB da rádio (24 caracteres hexadecimais)

Resposta

Formato

{
  "radioId": "string",
  "radioName": "string",
  "currentTrack": "string",
  "isFresh": boolean,
  "source": "live-collected | fingerprint | client | lastKnown | synthetic",
  "adapter": "string",
  "artist": "string",
  "track": "string",
  "station": "string",
  "genre": "string",
  "bitrate": number,
  "updatedAt": "string (ISO 8601)",
  "spotifyUrl": "string",
  "youtubeUrl": "string",
  "albumArtUrl": "string"
}

Campos

Exemplos de Uso

Pedido Básico

curl -X GET "http://localhost:3000/api/radios/694830c00e1febe2e0cbfe38/now-playing"

Resposta de Sucesso

{
  "radioId": "694830c00e1febe2e0cbfe38",
  "radioName": "Rádio Comercial",
  "currentTrack": "The Weeknd - Blinding Lights",
  "isFresh": true,
  "source": "live-collected",
  "artist": "The Weeknd",
  "track": "Blinding Lights",
  "station": "Rádio Comercial",
  "genre": "Pop",
  "bitrate": 128,
  "updatedAt": "2025-12-23T21:30:00.000Z"
}

Resposta Stale / Último Valor Conhecido

{
  "radioId": "694830c00e1febe2e0cbfe38",
  "radioName": "Cidade_fm",
  "currentTrack": "Hugel, Topic, Arash, Daecolm - I Adore You",
  "isFresh": false,
  "source": "lastKnown",
  "artist": "Hugel, Topic, Arash, Daecolm",
  "track": "I Adore You",
  "station": "Cidade_fm",
  "genre": "Pop",
  "bitrate": 128,
  "updatedAt": "2026-03-28T21:13:12.725Z"
}

Clientes podem mostrar este valor como Última detecção, mas não devem rotulá-lo como A tocar agora nem mostrar ações de favorito de música como se fosse atual.

Resposta Quando Não Há Metadata Conhecida

{
  "radioId": "694830c00e1febe2e0cbfe38",
  "radioName": "Smooth FM",
  "currentTrack": "Sem informação",
  "isFresh": false,
  "source": "synthetic",
  "station": "Smooth FM",
  "genre": "Jazz",
  "bitrate": 128
}

Comportamento e Cache

Sistema de Cache (3 camadas)

A API nunca consulta streams ao vivo no GET. A metadata é populada pelo Worker ou pelo endpoint autenticado de relatório do cliente.

Camada Armazenamento TTL Propósito
1 In-memory Map 30 s Evitar queries MongoDB em polls consecutivos
2 Redis (NowPlayingCacheService) 120 s Cache partilhável, populado pelo ICY collector
3 MongoDB (radios.now_playing) 15 min (freshness) Fonte persistente; aceite stale sem limite de idade

Sem consulta ao stream

O endpoint GET /radios/:id/now-playing é puramente read-only. A responsabilidade de atualizar metadata está no Worker (ICY collector), que:

  1. Corre em ciclos periódicos (Container App)
  2. Liga-se aos streams de rádio e extrai metadata ICY
  3. Persiste em Redis + MongoDB
  4. Emite eventos SSE quando a faixa muda

Quando uma rádio devolve seis sondas ICY vazias consecutivas, o collector abre um circuit breaker e reduz novas sondas a um canário por hora. O reconhecimento por fingerprint continua elegível. Uma sonda ICY útil fecha imediatamente o circuito ao repor icy_empty_streak a zero.

Para observabilidade, now_playing_adapter identifica quem produziu o payload atual; now_playing_last_probe_adapter, now_playing_last_probe_at e now_playing_last_probe_status descrevem separadamente a última sonda do collector. Uma sonda ICY vazia nunca substitui a proveniência de um valor atual produzido por fingerprint.

Antes de comparar, persistir, calcular o hash de dedupe, preencher cache ou emitir SSE, os writers ICY/cliente/fingerprint canonizam entidades HTML, incluindo entidades numéricas e valores duplamente codificados. O collector ICY e o relatório do cliente comparam a tupla canónica completa (StreamTitle, artist, track), pelo que alterações ou remoções dos campos estruturados são propagadas mesmo quando o título não muda. Nessas transições, a atualização MongoDB usa $set para os campos presentes e $unset para os ausentes; assim, valores de uma faixa anterior não chegam ao contrato REST/SSE.

A continuidade das filas icy-collector e radio-recognition, incluindo heartbeat, recuperação e operação dos alertas, está documentada em ../operations/NOW_PLAYING_CONTINUITY.md.

Tecnologia: Protocolo ICY

O que é ICY Metadata?

ICY (também conhecido como Shoutcast metadata) é um protocolo usado por servidores de streaming (Shoutcast, Icecast) para enviar informações sobre o que está a tocar em tempo real junto com o stream de áudio.

Como Funciona

  1. Cliente solicita metadata: Envia header Icy-MetaData: 1
  2. Servidor responde: Inclui headers ICY (icy-name, icy-genre, etc.) e icy-metaint
  3. Stream de dados: Servidor envia blocos de áudio de tamanho icy-metaint
  4. Metadata inline: Após cada bloco de áudio:
    • 1 byte indicando tamanho do metadata (multiplicado por 16)
    • N bytes de metadata no formato StreamTitle='Artista - Música';

Headers ICY Suportados

Parsing do StreamTitle

A API suporta múltiplos formatos de separação entre artista e música:

Se nenhum separador for encontrado, todo o texto é considerado como título da música.

Códigos de Resposta

Código Descrição
200 Sucesso - Informação retornada (pode ser "Sem informação")
404 Rádio não encontrada

Limitações

Streams sem Suporte ICY

Alguns streams não suportam metadata ICY:

Nestes casos, a API retorna currentTrack: "Sem informação".

Streams com Metadata Estático

Algumas rádios enviam sempre o mesmo StreamTitle (ex: nome da estação) em vez de atualizar com a música atual. Isto é uma limitação do servidor de streaming, não da API.

Formato Inconsistente

O formato do StreamTitle varia entre rádios. A API tenta extrair artista e música automaticamente, mas pode falhar se o formato for não-standard.

Integração

Frontend

async function getNowPlaying(radioId) {
  try {
    const response = await fetch(`/api/radios/${radioId}/now-playing`);
    const data = await response.json();

    if (data.isFresh && data.currentTrack && data.currentTrack !== 'Sem informação') {
      return {
        artist: data.artist || 'Desconhecido',
        track: data.track || data.currentTrack,
        station: data.station,
      };
    }

    return null; // Sem informação disponível
  } catch (error) {
    console.error('Erro ao obter now playing:', error);
    return null;
  }
}

// Atualizar a cada 30 segundos (SSE é preferível a polling — ver SSE_NOW_PLAYING.md)
setInterval(() => {
  const radioId = getCurrentRadioId();
  getNowPlaying(radioId).then(updateUI);
}, 30000);

Polling Recomendado

Troubleshooting

currentTrack sempre "Sem informação"

Possíveis causas:

  1. Worker (ICY collector) não está a conseguir ligar-se ao stream da rádio
  2. Stream não suporta metadata ICY
  3. URL do stream está incorreta ou inativa
  4. Worker não está a correr / está parado

Verificação:

  1. Verificar logs do Worker/ICY collector
  2. Verificar se o stream responde manualmente:
curl -H "Icy-MetaData: 1" -v "http://stream-url.com/radio"

Procure por headers icy-* e icy-metaint na resposta.

Timeout frequente

Possíveis causas:

  1. Stream muito lento ou instável
  2. Servidor de streaming sobrecarregado
  3. Problemas de rede

Solução:
O sistema marca automaticamente rádios com falhas consecutivas como inativas após 3 tentativas falhadas.

Artista/Música não separados

Causa:
O StreamTitle não usa um formato padrão de separação.

Exemplo:

Nestes casos, todo o conteúdo fica em currentTrack e os campos artist/track ficam vazios.


Endpoint: Now Playing Program (EPG)

GET /radios/:id/now-playing-program

Descrição

Retorna informação sobre o programa de rádio que está atualmente no ar, baseado no EPG (Electronic Program Guide) da rádio. Calcula automaticamente qual programa está a ser transmitido com base no dia da semana e hora atual.

Parâmetros

Path Parameters

Parâmetro Tipo Obrigatório Descrição
id string Sim ID MongoDB da rádio (24 caracteres hexadecimais)

Resposta

Formato

{
  "title": "string",
  "subtitle": "string",
  "description": "string",
  "time": "string (HH:mm)",
  "end_time": "string (HH:mm)",
  "duration": number,
  "radio_name": "string",
  "radio_id": "string"
}

Ou null se não existir EPG ou nenhum programa agendado para a hora atual.

Campos

Exemplos de Uso

Pedido Básico

curl -X GET "http://localhost:3000/api/radios/694830c00e1febe2e0cbfe38/now-playing-program"

Resposta com Programa no Ar

{
  "title": "Café da Manhã",
  "subtitle": "Com João Silva",
  "description": "O melhor programa matinal com notícias e entretenimento",
  "time": "07:00",
  "end_time": "10:00",
  "duration": 180,
  "radio_name": "Antena 1",
  "radio_id": "694830c00e1febe2e0cbfe38"
}

Resposta Sem EPG

null

Comportamento

Cálculo do Programa Atual

O sistema:

  1. Obtém o dia da semana atual (0=Segunda, ..., 6=Domingo)
  2. Obtém a hora e minutos atuais no formato HH:mm
  3. Busca o EPG da rádio no banco de dados
  4. Procura no array de programas do dia atual
  5. Compara a hora atual com os horários de início e fim de cada programa
  6. Retorna o programa que está no ar neste momento

Sem Hora de Fim

Se um programa não tem end_time definido, o sistema assume que vai até o início do próximo programa ou até ao fim do dia.

Retorna `null` quando

Códigos de Resposta

Código Descrição
200 Sucesso - Retorna programa ou null
404 Rádio não encontrada

Casos de Uso

Use Case 1: Exibir "No ar agora"

async function displayCurrentProgram(radioId) {
  const response = await fetch(`/api/radios/${radioId}/now-playing-program`);
  const program = await response.json();

  if (program) {
    document.getElementById('program-title').textContent = program.title;
    document.getElementById('program-time').textContent = `${program.time} - ${program.end_time || ''}`;

    if (program.subtitle) {
      document.getElementById('program-subtitle').textContent = program.subtitle;
    }
  } else {
    document.getElementById('program-title').textContent = 'Sem programação';
  }
}

Use Case 2: Combinar com Metadata ICY

async function getCompleteNowPlaying(radioId) {
  const [program, metadata] = await Promise.all([fetch(`/api/radios/${radioId}/now-playing-program`).then((r) => r.json()), fetch(`/api/radios/${radioId}/now-playing`).then((r) => r.json())]);

  return {
    program: program ? program.title : 'Sem programação',
    currentTrack: metadata.currentTrack || 'Sem informação',
    artist: metadata.artist,
    time: program ? `${program.time} - ${program.end_time || ''}` : null,
  };
}

// UI:
// "Café da Manhã (07:00 - 10:00)"
// "A tocar: The Weeknd - Blinding Lights"

Use Case 3: Atualização Periódica

// Atualizar a cada minuto (programas mudam menos frequentemente que músicas)
setInterval(() => {
  const radioId = getCurrentRadioId();
  displayCurrentProgram(radioId);
}, 60000); // 1 minuto

Diferenças: Now Playing vs Now Playing Program

Aspecto Now Playing (ICY) Now Playing Program (EPG)
Fonte de dados Stream de áudio (ICY metadata) Base de dados (EPG)
Informação Música/faixa atual Programa de rádio
Atualização Tempo real (~cada música) Por programa (30min-3h)
Disponibilidade Depende do stream Depende do EPG
Cache 5 minutos Sem cache (cálculo instantâneo)
Use case "A tocar: Artista - Música" "No ar: Nome do Programa"

Polling Recomendado

Fontes de EPG

O EPG pode vir de várias fontes (ordem de prioridade):

  1. User-Submitted - Rádio submete a sua própria programação via API
  2. MyTuner - Obtido automaticamente da API MyTuner
  3. ICY Patterns - Gerado automaticamente com base em padrões históricos (Fase 4)
  4. Scraping - Sites oficiais das rádios (Fase 2)

Ver Radio Schedules & EPG para mais detalhes.


Endpoint: Today's Programs

GET /radios/:id/programs/today

Descrição

Retorna a lista completa de programas do dia de hoje para uma rádio específica, incluindo o programa atual (se estiver no ar) e todos os programas futuros. Permite ao utilizador planear o que vai ouvir ao longo do dia.

Parâmetros

Path Parameters

Parâmetro Tipo Obrigatório Descrição
id string Sim ID MongoDB da rádio (24 caracteres hexadecimais)

Resposta

Formato

Array de objetos com a seguinte estrutura:

[
  {
    "title": "string",
    "subtitle": "string",
    "description": "string",
    "time": "string (HH:mm)",
    "end_time": "string (HH:mm)",
    "duration": number
  }
]

Ou array vazio [] se não existir EPG ou não houver programação para hoje.

Campos

Exemplos de Uso

Pedido Básico

curl -X GET "http://localhost:3000/api/radios/6948306f0e1febe2e0cbfb3a/programs/today"

Resposta com Programação Completa

[
  {
    "title": "Café da Manhã",
    "subtitle": "Com João Silva",
    "description": "O melhor programa matinal com notícias e entretenimento",
    "time": "07:00",
    "end_time": "10:00",
    "duration": 180
  },
  {
    "title": "Música ao Meio-Dia",
    "time": "12:00",
    "end_time": "13:00",
    "duration": 60
  },
  {
    "title": "Tarde em Grande",
    "subtitle": "Com Ana Costa",
    "description": "Entretenimento e boa disposição durante a tarde",
    "time": "14:00",
    "end_time": "17:00",
    "duration": 180
  },
  {
    "title": "Notícias da Noite",
    "time": "20:00",
    "end_time": "21:00",
    "duration": 60
  }
]

Resposta Sem EPG

[]

Comportamento

Filtragem de Programas

O sistema:

  1. Obtém o dia da semana atual (0=Segunda, ..., 6=Domingo)
  2. Busca todos os programas agendados para o dia atual
  3. Remove programas que já terminaram (baseado no end_time)
  4. Mantém o programa atual (se estiver no ar)
  5. Mantém todos os programas futuros
  6. Ordena por horário de início

Programa Atual Incluído

Se às 15:00 consultar a programação, e houver um programa das 14:00-17:00, este será incluído na lista (primeiro da lista).

Ordenação

Programas sempre retornam ordenados por time (horário de início), do mais cedo para o mais tarde.

Códigos de Resposta

Código Descrição
200 Sucesso - Retorna array de programas ou array vazio
404 Rádio não encontrada

Casos de Uso

Use Case 1: Exibir Grade de Programação

async function displayTodaySchedule(radioId) {
  const response = await fetch(`/api/radios/${radioId}/programs/today`);
  const programs = await response.json();

  if (programs.length === 0) {
    document.getElementById('schedule').innerHTML = '<p>Programação não disponível</p>';
    return;
  }

  const scheduleHTML = programs
    .map(
      (program) => `
    <div class="program-item">
      <div class="program-time">${program.time} - ${program.end_time || '—'}</div>
      <div class="program-title">${program.title}</div>
      ${program.subtitle ? `<div class="program-subtitle">${program.subtitle}</div>` : ''}
      ${program.description ? `<div class="program-description">${program.description}</div>` : ''}
    </div>
  `
    )
    .join('');

  document.getElementById('schedule').innerHTML = scheduleHTML;
}

Use Case 2: Identificar Programa Atual e Próximo

async function getCurrentAndNextProgram(radioId) {
  const programs = await fetch(`/api/radios/${radioId}/programs/today`).then((r) => r.json());

  if (programs.length === 0) {
    return { current: null, next: null };
  }

  const now = new Date();
  const currentTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;

  // Encontrar programa atual
  const current = programs.find((p) => {
    if (p.end_time) {
      return currentTime >= p.time && currentTime < p.end_time;
    }
    return currentTime >= p.time;
  });

  // Encontrar próximo programa
  const next = programs.find((p) => p.time > currentTime);

  return { current, next };
}

// UI:
// "No ar: Café da Manhã (07:00 - 10:00)"
// "A seguir: Música ao Meio-Dia (12:00 - 13:00)"

Use Case 3: Calcular Tempo até Próximo Programa

async function getTimeUntilNextProgram(radioId) {
  const programs = await fetch(`/api/radios/${radioId}/programs/today`).then((r) => r.json());

  const now = new Date();
  const currentTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;

  const nextProgram = programs.find((p) => p.time > currentTime);

  if (!nextProgram) {
    return null;
  }

  const [hours, minutes] = nextProgram.time.split(':').map(Number);
  const nextStart = new Date();
  nextStart.setHours(hours, minutes, 0, 0);

  const diffMs = nextStart - now;
  const diffMinutes = Math.floor(diffMs / 60000);

  return {
    program: nextProgram.title,
    time: nextProgram.time,
    minutesUntil: diffMinutes,
  };
}

// UI: "Próximo programa: Tarde em Grande começa em 45 minutos"

Use Case 4: Notificações de Programa Favorito

async function checkFavoriteProgram(radioId, favoriteTitle) {
  const programs = await fetch(`/api/radios/${radioId}/programs/today`).then((r) => r.json());

  const favoriteProgram = programs.find((p) => p.title === favoriteTitle);

  if (favoriteProgram) {
    const now = new Date();
    const currentTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;

    // Se o programa está para começar em breve (próximos 15 minutos)
    const [hours, minutes] = favoriteProgram.time.split(':').map(Number);
    const programStart = new Date();
    programStart.setHours(hours, minutes, 0, 0);

    const diffMs = programStart - now;
    const diffMinutes = Math.floor(diffMs / 60000);

    if (diffMinutes > 0 && diffMinutes <= 15) {
      showNotification(`${favoriteProgram.title} começa em ${diffMinutes} minutos!`, radioId);
    }
  }
}

Polling Recomendado

Diferenças: Today Programs vs Now Playing Program

Aspecto Today Programs Now Playing Program
Endpoint /programs/today /now-playing-program
Retorna Array de programas Um programa ou null
Âmbito Dia completo Apenas programa atual
Filtragem Exclui programas passados Calcula programa no ar
Use case Grade de programação "No ar agora"
Atualização UI Carregar uma vez Polling 1 minuto

Complementaridade com Outros Endpoints

Combinação Completa de Informação

async function getCompleteRadioInfo(radioId) {
  const [radio, currentProgram, todayPrograms, nowPlaying] = await Promise.all([fetch(`/api/radios/${radioId}`).then((r) => r.json()), fetch(`/api/radios/${radioId}/now-playing-program`).then((r) => r.json()), fetch(`/api/radios/${radioId}/programs/today`).then((r) => r.json()), fetch(`/api/radios/${radioId}/now-playing`).then((r) => r.json())]);

  return {
    radio: radio.name,
    currentProgram: currentProgram?.title || 'Sem programação',
    currentTrack: nowPlaying.currentTrack || 'Sem informação',
    todaySchedule: todayPrograms,
  };
}

// Renderização completa:
// Rádio: "Antena 1"
// No ar: "Café da Manhã (07:00 - 10:00)"
// A tocar: "The Weeknd - Blinding Lights"
// Programação do dia: [lista de 8 programas]

Ver Também