Todo empezó con un problema simple: quería escuchar una radio de Mendoza desde la terminal. No encontré ninguna herramienta en castellano que funcionara bien, así que armé un script de bash. Lo que empezó como 20 líneas se fue convirtiendo, mes a mes, en un sistema de más de 1.200 emisoras con player web, PWA, streaming proxy y crawlers automáticos.

Esta nota cuenta cómo está construido mammoli.ar/radio: las decisiones técnicas, los problemas que aparecieron y cómo los resolví.

La versión 1: un script de bash

La primera versión fue radio.sh: un script de 30 líneas que leía un archivo de texto plano (emisoras.txt), hacía un grep sobre el nombre y lanzaba mplayer con la URL del stream.

# emisoras.txt — una emisora por línea
Cadena 3 | Córdoba | http://stream.cadena3.com.ar:9900/
Radio Nacional | Buenos Aires | http://streamer.educ.ar:8080/Nacional
LU5 Radio Mendoza | Mendoza | http://38.133.132.50:9876/
# uso básico
./radio.sh mendoza          # busca "mendoza"
./radio.sh "cadena 3" v     # fuerza VLC como reproductor

Funcionaba. El archivo de texto plano tiene una ventaja subestimada: es fácil de editar, commitear y diffear. Lo sigo usando como fuente de verdad del catálogo.

El salto a la web

El script solo funcionaba en la terminal. La idea de una versión web surgió de una pregunta obvia: ¿por qué no puede cualquiera escuchar estas radios sin instalar nada?

Elegí el stack más aburrido posible: PHP sin framework, SQLite, JavaScript vanilla. Sin npm, sin build steps, sin dependencias externas. Esto no es una limitación — es una decisión. Un archivo PHP y una base de datos SQLite se despliegan en cualquier hosting compartido con curl y un script FTP. Se mantienen solos. No tienen dependencias que rompan.

La base de datos

El catálogo vive en radio_v2.sqlite. La tabla principal tiene estos campos clave:

CREATE TABLE stations (
    id            INTEGER PRIMARY KEY,
    n             INTEGER UNIQUE,      -- número estable de la emisora
    slug          TEXT UNIQUE,         -- /radio/cadena-3/
    nombre        TEXT,
    url           TEXT,                -- URL del stream
    provincia     TEXT,
    tags          TEXT,                -- JSON: ["noticias","am"]
    codec         TEXT,                -- MP3, AAC, OGG
    bitrate       INTEGER,
    estado        TEXT,                -- 'ok' | 'timeout' | 'error'
    icy_supported INTEGER DEFAULT 0,
    total_plays   INTEGER DEFAULT 0,
    rb_votes      INTEGER DEFAULT 0    -- votos en radio-browser.info
);

La vista v_stations une esta tabla con información calculada y es lo que consume el frontend. El ordenamiento por defecto prioriza emisoras activas, luego por reproducciones, luego por votos en radio-browser.

El crawler: de dónde vienen las emisoras

Mantener 1.200+ emisoras a mano es imposible. La mayor parte del catálogo viene de radio-browser.info, una base de datos colaborativa y abierta de emisoras de todo el mundo.

crawler_radio_browser.py consume su API REST, filtra por países (AR y UY), verifica que cada URL responda con un Content-Type de audio, y agrega las nuevas a emisoras.txt. El primer paso es elegir uno de los servidores de la red:

def pick_server():
    candidates = [
        "https://de1.api.radio-browser.info",
        "https://nl1.api.radio-browser.info",
        "https://at1.api.radio-browser.info",
    ]
    for base in candidates:
        try:
            urllib.request.urlopen(base + "/json/stats", timeout=5)
            return base
        except:
            continue

El crawler no confía ciegamente en los datos de radio-browser. Verifica cada URL abriendo una conexión HTTP real y chequeando el Content-Type de la respuesta:

AUDIO_TYPES = ("audio/", "video/", "application/ogg",
               "application/octet-stream", "mpegurl", "x-mpegurl")

def verify_url(url, timeout=7):
    try:
        req = urllib.request.Request(url, headers={"User-Agent": UA})
        with urllib.request.urlopen(req, timeout=timeout) as r:
            ct = r.headers.get("Content-Type", "").lower()
            return any(t in ct for t in AUDIO_TYPES)
    except:
        return False

También hay recuperar_caidas.py, que re-verifica periódicamente las emisoras caídas y las reactiva si vuelven a responder, y dedup_urls.py que elimina duplicados. El cron recomendado corre el crawler cada dos semanas:

0 9 1,15 * * /home/usuario/Scripts/radio/crawler_radio_browser.py \
  --apply --commit --push --quiet 2>&1

El problema del streaming web: proxy.php

Este es el problema técnico más interesante. Hay dos razones por las que el tag <audio> del navegador no puede reproducir directamente la mayoría de las radios:

  1. Mixed content: mammoli.ar sirve HTTPS. La mayoría de las radios argentinas sirven sus streams por HTTP. Los browsers modernos bloquean requests HTTP desde páginas HTTPS por política de seguridad.
  2. Playlists .pls y .m3u: muchas radios no publican la URL directa del stream, sino una playlist que apunta a él. El tag <audio> no sabe parsear estos formatos.

La solución es proxy.php, un proxy de streaming del lado del servidor. Si la URL termina en .pls, lo descarga y extrae el campo File1:

if ($isPls) {
    if (preg_match('/File1=([^\r\n]+)/i', $content, $m)) {
        $resolved = trim($m[1]);
    }
}

Para .m3u busca la primera línea que no sea comentario con una URL válida. Si el stream resuelto ya es HTTPS, emite un redirect directo al navegador — no tiene sentido proxiar HTTPS a través de HTTPS.

Para streams HTTP, usa cURL con timeout infinito y escritura en streaming:

set_time_limit(0);    // el stream es continuo
ignore_user_abort(false);

curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $data) {
    echo $data;
    if (ob_get_level()) ob_flush();
    flush();
    // si el usuario cerró el tab, cortar
    return connection_aborted() ? -1 : strlen($data);
});

Devolver -1 desde CURLOPT_WRITEFUNCTION cancela la transferencia de cURL, lo que cierra la conexión al servidor de radio cuando el usuario deja de escuchar. Sin esto, el proxy seguiría consumiendo ancho de banda indefinidamente.

Protección SSRF

Un proxy de URL arbitraria puede usarse para atacar servicios internos (Server-Side Request Forgery). El proxy bloquea IPs privadas y de loopback antes de cualquier conexión:

$host = parse_url($url, PHP_URL_HOST);
if (preg_match(
    '#^(localhost|127\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|::1$|0\.0\.0\.0)#i',
    $host
)) {
    http_response_code(403);
    exit('Acceso denegado');
}

ICY Metadata: el "ahora suena"

Shoutcast inventó en los años 90 un mecanismo para insertar metadatos dentro del stream de audio. El protocolo se llama ICY: el cliente envía el header Icy-MetaData: 1 en el request, y el servidor intercala bloques de texto cada N bytes del stream (N viene en el header de respuesta icy-metaint).

El tag <audio> del navegador no soporta ICY. Para implementarlo, hay un endpoint nowplaying.php que conecta al stream, lee solo los primeros bytes hasta encontrar un bloque de metadata, extrae el título y cierra la conexión. El JS hace polling cada 30 segundos a este endpoint para las emisoras que soportan ICY.

Oyentes en tiempo real

El contador de "N escuchando ahora" usa un mecanismo simple basado en archivos JSON. Cuando el usuario empieza a escuchar, el JS emite un ping a listeners.php cada 30 segundos. El servidor guarda la sesión con un timestamp. Al contar oyentes, descarta sesiones con más de 90 segundos de antigüedad:

// TTL: si no llegó ping en 90 seg, ya no está escuchando
$listeners = array_filter($listeners, fn($ts) => (time() - $ts) < 90);

No es un contador exacto — hay latencia de polling, usuarios que duermen el celular. Pero da una idea real de actividad sin WebSockets ni estado de servidor.

El frontend: filtrado sin servidor

El buscador en tiempo real no hace ninguna request al servidor. Todas las emisoras se serializan en un array JavaScript en el HTML inicial (unos 200KB). El filtrado es puro JS sobre ese array, con debounce de 150ms.

Esto tiene una consecuencia importante: el tiempo hasta interactividad es el tiempo de carga del HTML, sin round-trip de API. En 3G razonable, el directorio es usable en menos de 2 segundos.

PWA y deploy

El player es instalable como PWA: el manifest.json declara nombre, colores e íconos, y el service worker cachea el shell de la app. El deploy usa GitHub Actions con FTP — el mecanismo que soporta hosting compartido sin acceso SSH. Un push a main sincroniza el directorio web/ al servidor automáticamente.

Números actuales

El código es público en github.com/camammoli/radio. Si querés agregar una emisora que no está, el formulario de sugerencia está en mammoli.ar/radio/sugerir.php.