Backend Latam Radio #4
Publicado el 25/05/2026
Sincronizando con radio-browser
Tengo el modelo. Tengo la base. Ahora toca rellenarla. Y aquí aparece la primera tensión interesante del proyecto: ¿cómo hacer la sincronización sin que dependa de un broker?
Management command, no Celery
Ya lo conté åen el post del stack: descarté Celery por YAGNI. La alternativa es un management command de Django que se invoca desde un scheduler externo. La interfaz queda:
docker compose exec backend python manage.py update_radios AR BR CL
# o, con el listado por defecto:
docker compose exec backend python manage.py update_radios
Sin worker, sin beat. Solo Django.
El comando, por bloques
Vive en streams/management/commands/update_radios.py. Tiene tres responsabilidades: resolver qué países, consultar radio-browser y persistir cada estación.
1. Argumentos y resolución de países
def add_arguments(self, parser):
parser.add_argument(
"country_codes",
nargs="*",
help="Códigos ISO-2 (ej.: AR BR CL). Si se omiten, se usa --from-file.",
)
parser.add_argument(
"--from-file",
type=str,
default=None,
help="Fichero de texto con un código por línea.",
)
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
parser.add_argument("--hide-broken", action="store_true", default=True)
Si no paso códigos, lee un fichero por defecto incluido en el paquete (streams/management/commands/data/latam_iso_codes.txt) con los 20 países LATAM. Si paso códigos en la línea de comandos, gana ese listado. Si paso --from-file, gana el fichero apuntado.
2. Consulta async dentro de un command sync
El SDK radios es async. Los management commands de Django son sync. El puente es asyncio.run:
def handle(self, *args, **options):
country_codes = self._resolve_country_codes(options)
repo = StreamRepositoryORM()
for code in country_codes:
try:
stations = asyncio.run(
self._query_radios(
code,
limit=options["limit"],
hide_broken=options["hide_broken"],
)
)
except Exception as exc:
logger.exception("query failed for %s: %s", code, exc)
continue
self._persist_stations(repo, stations, code)
async def _query_radios(self, country_code, *, limit, hide_broken):
async with RadioBrowser(user_agent=USER_AGENT) as client:
return await client.stations(
limit=limit,
hide_broken=hide_broken,
order=Order.CLICK_COUNT,
reverse=True,
filter_by=FilterBy.COUNTRY_CODE_EXACT,
filter_term=country_code,
)
Cada país se consulta con su propio async with (sesión HTTP nueva) y se procesa de forma síncrona antes de pasar al siguiente. Si la consulta de un país revienta, los demás siguen.
3. Persistencia con tolerancia a basura
La salud del comando depende de no abortar al primer registro raro:
def _persist_stations(self, repo, stations, country_code):
ok, err = 0, 0
for station in stations:
try:
repo.create_or_update_station(station)
ok += 1
except Exception as exc:
err += 1
logger.warning(
"[%s] saltando estación inválida (uuid=%s): %s",
country_code, getattr(station, "uuid", "?"), exc,
)
return ok, err
Una emisora con country_code=None o uuid="" se loggea como warning y la importación continúa. Solo un fallo de red entero corta el bucle de ese país, no del proceso.
El repositorio: idempotencia y saneamiento
El comando delega en StreamRepositoryORM.create_or_update_station. Aquí pasan dos cosas críticas:
@transaction.atomic
def create_or_update_station(self, data):
uuid = _safe_str(getattr(data, "uuid", ""))
country_code = _safe_str(getattr(data, "country_code", "")).upper()
if not uuid or len(country_code) != 2:
raise ValueError(
f"station with invalid identity (uuid={uuid!r}, country_code={country_code!r})"
)
station_data = {
"name": _safe_str(getattr(data, "name", "")),
"country_code": country_code,
"url": _safe_url(getattr(data, "url", "")),
"homepage": _safe_url(getattr(data, "homepage", "")),
# ... resto de campos saneados
}
station, _ = Station.objects.update_or_create(uuid=uuid, defaults=station_data)
# ... seteo de M2M (tags, languages, language_codes)
return station
_safe_url rechaza esquemas que no sean http/https:
def _safe_url(value):
if not isinstance(value, str):
return ""
value = value.strip()
if value.startswith("http://") or value.startswith("https://"):
return value
return ""
Esto previene que entren cadenas tipo javascript:alert(1) que radio-browser puede tener: recordemos que la base es comunitaria, cualquiera registra emisoras. En el post de la auditoría veremos por qué este saneo es crítico, no cosmético.
Idempotencia
update_or_create(uuid=...) es la clave: si la emisora existía, se actualiza; si no, se crea. La sincronización puede ejecutarse N veces sin duplicar nada, lo cual es exactamente lo que quiero de un cron diario.
Lo que no hace este comando
- No borra estaciones que desaparecen de radio-browser. Si una emisora se da de baja allí, aquí queda con
lastcheckok=Falsey la API la filtra. Más simple que borrarla. - No reintenta automáticamente en caso de fallo. Si quiero retry, lo gestiona el scheduler externo.
- No emite métricas. Los
logger.infoconguardadas=X errores=Yson suficientes hoy.
Cómo se dispara en producción
En despliegue uso Dokploy y aprovecho su panel de Schedules, que ejecuta un comando cron contra un servicio del compose sin añadir contenedores extra:
| Campo | Valor |
|---|---|
| Name | update-radios-daily |
| Cron Expression | 0 4 * * * (04:00 UTC) |
| Service | backend |
| Command | python manage.py update_radios |
Sin worker que mantener, sin beat al que vigilar. Esto funciona porque el comando ya es idempotente (update_or_create por UUID) y tolerante (un error en un país no aborta los demás). Si mañana cambio de Dokploy a algo distinto, basta con apuntar un cron del host al mismo docker compose exec backend python manage.py update_radios y problema resuelto.
El detalle completo de despliegue (Postgres con PostGIS, env vars, configuración del Schedule) vive en DEPLOY.md del repo.
Lista la sincronización. En el siguiente post miramos el otro extremo del backend: la API que consume la app móvil.