🔧

Go Collector — Alternativa ao Radio Collector

Go Collector — Alternativa ao Radio Collector Node.js

Status: ⏸️ Suspenso (dev) — pode ser reactivado em produção
Data: 2026-04-08
Localização: tools/go-collector/ > Referência: IDEAL_ARCHITECTURE.md (visão norte-estrela)

Nota (2026-04-08): O Go Collector está suspenso devido a custos e limitações de Cosmos DB RU/s.
A recolha de ICY history é agora feita no dispositivo do utilizador via just_audio na app Flutter.
O Go Collector poderá ser reactivado em produção no futuro quando o custo de Cosmos DB for optimizado.

Contexto

O pipeline de recolha de metadados agora-a-tocar (now-playing) era executado pelo radio-collector (Node.js/NestJS), integrado no Worker via BullMQ. Esta abordagem apresentava problemas estruturais:

Problema Causa raiz
Cosmos 429 (RequestRateTooLarge) Burst de escritas do collector saturava o pool de RU partilhado
API 500s em /favorites, /icy-history Read queries bloqueadas por write contention
Now-playing stale Ciclo de recolha bloqueado por DB writes lentos
Controlo de concorrência frágil Semáforos em código com caminhos de leak
Alto consumo de memória Hydratação de documentos Mongoose + overhead V8 (~180 MB RSS)

O Go Collector é uma alternativa standalone em Go que substitui todo o pipeline Node.js com 7.5× menos memória e sem dependência do BullMQ.

Comparação

Aspecto Radio Collector (Node.js) Go Collector
Linguagem TypeScript / NestJS Go 1.26
LOC ~2 500 (TS) ~2 250 (Go)
Dependências NestJS, Mongoose, BullMQ, p-limit, ioredis mongo-driver v2, go-redis v9
Memória (RSS) ~180 MB ~12–15 MB
Binário N/A (Node.js runtime) 9.8 MB (static, multi-stage)
Comunicação BullMQ queue (poll) Standalone loop (goroutines)
Orquestração Worker NestJS + ActiveListenerService Ciclo interno com sync.WaitGroup
MusicLink PENDING → enrich job → ENRICHED Directo como ENRICHED com youtube_search_url
Adapters 11 classes TypeScript 10 ficheiros Go (package main)
Escalonamento Por container (Worker) Por binário (Container App / Docker)

Arquitectura

graph TB subgraph GO_COLLECTOR["🔧 Go Collector (standalone)"] direction TB MAIN["main.go
Loop + graceful shutdown"] LOADER["loader.go
MongoDB radio query"] ROUTER["router.go
Domain adapter routing"] subgraph ADAPTERS["HTTP Adapters"] direction LR A_RK["radioking"] A_TR["triton"] A_RTP["rtp"] A_RJ["radiojar"] A_RF["radiofrance"] A_SN["securenet"] A_RC["rcom"] A_ZF["zenofm"] A_AW["amperwave"] A_BR["bauer"] end ICY["icy.go
Binary ICY protocol"] TRACK["track.go
Normalize + classify"] DB_GO["db.go
4 write operations"] REDIS_GO["redis.go
NP cache + pub/sub"] CONFIG["config.go
Env var parsing"] end subgraph DATA["💾 Data Layer"] MONGO[(MongoDB / Cosmos DB)] REDIS[(Redis)] end subgraph COLLECTIONS["📦 Collections (escritas)"] C_RAD["radios
now_playing update"] C_ICY["icyhistories
insert (dedup)"] C_ML["musiclinks
upsert (enriched)"] C_TRS["trackradiostats
play_count++"] end MAIN --> LOADER LOADER --> MONGO MAIN --> ROUTER ROUTER --> ADAPTERS ROUTER --> ICY ADAPTERS --> ICY MAIN --> TRACK MAIN --> DB_GO MAIN --> REDIS_GO DB_GO --> C_RAD DB_GO --> C_ICY DB_GO --> C_ML DB_GO --> C_TRS C_RAD --> MONGO C_ICY --> MONGO C_ML --> MONGO C_TRS --> MONGO REDIS_GO -->|"SET radio:{id}:np"| REDIS REDIS_GO -->|"PUBLISH np:events"| REDIS REDIS_GO -->|"GET/SET radio:{id}:icy-hash"| REDIS

Estrutura de Ficheiros

tools/go-collector/
├── go.mod                   # Module + deps
├── go.sum                   # Checksums
├── Dockerfile               # Multi-stage: golang:1.24-alpine → scratch
├── main.go                  # 254 LOC — loop, collectOne, health endpoint
├── config.go                # 192 LOC — env vars, Azure Redis parser
├── db.go                    # 168 LOC — MongoDB: 4 write operations
├── redis.go                 #  96 LOC — NP cache, meta-hash, pub/sub
├── loader.go                #  68 LOC — radio query (eligible)
├── router.go                #  78 LOC — domain-based adapter routing
├── track.go                 # 166 LOC — normalize, classify, confidence
├── icy.go                   # 235 LOC — binary ICY protocol prober
├── adapters_radioking.go    # 106 LOC — RadioKing widget API
├── adapters_triton.go       # 102 LOC — Triton Digital NowPlaying XML
├── adapters_rtp.go          #  93 LOC — RTP live channel JSON
├── adapters_radiojar.go     #  64 LOC — RadioJar station API
├── adapters_radiofrance.go  # 125 LOC — Radio France livemeta + time match
├── adapters_securenet.go    #  74 LOC — SecureNetSystems XSPF XML
├── adapters_rcom.go         # 101 LOC — Grupo Renascença UTF-16LE XML
├── adapters_zenofm.go       #  17 LOC — strip .m3u → ICY
├── adapters_amperwave.go    #  62 LOC — HEAD redirect → ICY
└── adapters_bauer.go        # 249 LOC — ICY + XML metadata + ad skip
                              ─────────
                              2 250 LOC total

Variáveis de Ambiente

Variável Default (dev) Descrição
MONGO_URI mongodb://admin:password@localhost:27017/audissey?authSource=admin Connection string MongoDB
MONGODB_DB_NAME audissey Nome da base de dados (fallback: COSMOS_DB_NAME)
REDIS_CONNECTION_STRING — Formato Azure: host:port,password=xxx,ssl=True
REDIS_URL redis://localhost:6379 Formato standard (alternativa a REDIS_CONNECTION_STRING)
COLLECTOR_CONCURRENCY 50 Máximo de goroutines de probing simultâneas
COLLECTOR_INTERVAL 120s Intervalo entre ciclos de recolha
ICY_TIMEOUT 8s Timeout da primeira tentativa ICY
HTTP_TIMEOUT 8s Timeout para adapters HTTP
DB_WRITE_CONCURRENCY 20 Semáforo de escrita para Cosmos DB
COLLECTION_LIMIT 5000 Máximo de rádios por ciclo
LOG_LEVEL info Nível de log (debug, info, warn)

Ciclo de Recolha

┌─────────────────────────────────────────────────────────┐
│ 1. LoadEligibleRadios()                                 │
│    filter: is_active=true, enable_icy_epg=true,         │
│            stream_url exists, failures < 5              │
│    sort: now_playing_updated_at ASC (stalest first)     │
│    limit: COLLECTION_LIMIT                              │
├─────────────────────────────────────────────────────────┤
│ 2. Para cada rádio (goroutine pool, sem=CONCURRENCY):   │
│    a) ResolveAdapter(stream_url) → adapter match        │
│    b) FetchWithAdapter() → ProbeResult                  │
│    c) normalizeTitle + classifyContent + confidence      │
│    d) CheckAndSetMetaHash (Redis dedup 300s)            │
│    e) Se erro → IncrementFailures, skip                 │
├─────────────────────────────────────────────────────────┤
│ 3. DB writes (sem=DB_WRITE_CONCURRENCY):                │
│    a) UpdateRadioNowPlaying (always)                    │
│    b) InsertIcyHistory (dedup vs last entry)            │
│    c) UpsertMusicLink + youtube_search_url (if music)   │
│    d) RecordPlay — play_count++ (if music)              │
├─────────────────────────────────────────────────────────┤
│ 4. Redis (se track mudou):                              │
│    a) SET radio:{id}:np (120s TTL)                      │
│    b) PUBLISH np:events (track change event)            │
├─────────────────────────────────────────────────────────┤
│ 5. Sleep(COLLECTOR_INTERVAL), goto 1                    │
└─────────────────────────────────────────────────────────┘

Adapter Routing

O router.go mapeia domínios de stream_url para o adapter correcto:

Domínio Adapter Timeout Protocolo
bauermedia.pt bauer 45s ICY + XML metadata + ad skip
rtp.pt rtp 8s HTTPS JSON
streamtheworld.com + rfm/mega/renascenca rcom 8s Azure Blob UTF-16LE XML
streamtheworld.com (outros) triton 8s Triton NowPlaying XML
amperwave.net amperwave 10s HEAD redirect → ICY
radioking.com radioking 8s Widget API JSON
radiojar.com radiojar 8s Station API JSON
zeno.fm / zenomedia.com zenofm 12s Strip .m3u → ICY
ihrhls.com triton 8s Triton NowPlaying XML
radiofrance.fr radiofrance 8s Livemeta API JSON
securenetsystems.net securenet 8s XSPF XML
(qualquer outro) icy 8s+20s ICY two-tier fallback

Para tracks classificadas como music com artist+track:

youtube_search_url = https://music.youtube.com/search?q={urlEncode(artist + " " + track)}

Criado via $setOnInsert com status: "enriched" — sem estado PENDING intermediário. Isto elimina a necessidade do job de enriquecimento separado que existia no pipeline Node.js.

Operações MongoDB

Operação Collection Tipo Condição
UpdateRadioNowPlaying radios updateOne Sempre
InsertIcyHistory icyhistories findOne + insertOne Track diferente do último registo
UpsertMusicLink musiclinks updateOne (upsert) is_music=true AND artist+track presentes
RecordPlay trackradiostats updateOne (upsert) is_music=true AND artist+track presentes
IncrementFailures radios updateOne Erro de probe

Redis Keys

Key Pattern TTL Uso
radio:{id}:np 120s Cache de now-playing (JSON payload)
radio:{id}:icy-hash 300s Hash do título normalizado (dedup)
np:events (channel) — Pub/sub de track changes

Decisões de Design

  1. Sem ActiveListenerService — Recolhe todas as rádios elegíveis, não apenas as com listeners activos. Simplifica a arquitectura e garante dados frescos para todos.

  2. MusicLink ENRICHED directo — Elimina o estado PENDING e o job de enriquecimento. O youtube_search_url é gerado no momento da inserção.

  3. Standalone loop, sem BullMQ — Go gere o seu próprio schedule com time.After + context.Context para graceful shutdown.

  4. DB write semaphore — Limita escritas concorrentes a 20, matching o padrão Node.js para evitar saturar Cosmos DB RUs.

  5. Two-tier ICY fallback — Primeira tentativa a 8s, retry a 20s se falhar. Cobre streams lentos sem penalizar o ciclo inteiro.

  6. Preroll ad skip (Bauer) — Até 50 blocos ICY de preroll ignorados antes de extrair metadata real.

Como correr em dev

Via Docker Compose

cd Audissey
docker compose up go-collector

O serviço liga-se automaticamente ao MongoDB e Redis do compose (porta 8081 → 8080 interno).

Build manual

cd tools/go-collector
go build -o collector .
MONGO_URI="mongodb://admin:password@localhost:27017/audissey?authSource=admin" \
REDIS_URL="redis://localhost:6379" \
LOG_LEVEL=debug \
./collector

Health check

curl http://localhost:8081/health
# {"service":"go-collector","status":"ok"}

Verificação

Check Comando
Compila cd tools/go-collector && go build -o /dev/null .
Análise estática go vet ./...
Health endpoint curl localhost:8081/health
Radios updated Verificar campo now_playing_updated_at recente em MongoDB
IcyHistory populated Query db.icyhistories.find().sort({captured_at:-1}).limit(5)
MusicLinks enriched db.musiclinks.find({status:"enriched",youtube_search_url:{$exists:true}}).limit(5)
Redis NP cache redis-cli GET radio:{id}:np
Memória RSS < 15 MB para ciclo completo

Relação com a Arquitectura Ideal

Este Go Collector implementa o componente A (ICY Probe Service) da Ideal Architecture, mas de forma pragmática e com custo zero de infraestrutura adicional:

Se a escala exigir no futuro, o collector pode ser refactored para emitir eventos em vez de escrever directamente, alinhando com a arquitectura norte-estrela.

Ficheiros Relacionados

Ficheiro Descrição
tools/go-collector/ Código-fonte do Go Collector
docker-compose.yml Serviço go-collector adicionado
radio-collector/ Pipeline Node.js original (não removido)
tools/icy-prober/ PoC Go original (função probeICY reutilizada)
docs/specs/IDEAL_ARCHITECTURE.md Arquitectura norte-estrela