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 viajust_audiona 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
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 |
MusicLink — YouTube URL
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
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.
MusicLink ENRICHED directo — Elimina o estado PENDING e o job de enriquecimento. O
youtube_search_urlé gerado no momento da inserção.Standalone loop, sem BullMQ — Go gere o seu próprio schedule com
time.After+context.Contextpara graceful shutdown.DB write semaphore — Limita escritas concorrentes a 20, matching o padrão Node.js para evitar saturar Cosmos DB RUs.
Two-tier ICY fallback — Primeira tentativa a 8s, retry a 20s se falhar. Cobre streams lentos sem penalizar o ciclo inteiro.
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:
- Sem Azure Service Bus (escrita directa ao DB)
- Sem separação writer/enricher (tudo inline no mesmo binário)
- Sem SignalR (pub/sub Redis existente)
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 |