Backend Latam Radio #2

Publicado el 23/05/2026

Stack y arquitectura: piezas y cómo encajan

En el post anterior establecí el por qué. Ahora toca el cómo: qué herramientas voy a usar y cómo se conectan entre sí. Es una decisión que parece técnica pero que en realidad es de comunicación con el "yo del año que viene": cada elección añade jerga, deuda y placer en proporciones variables.

Django + DRF: el chasis

Elijo Django como framework. La alternativa más razonable hoy sería FastAPI + SQLAlchemy + Alembic. ¿Por qué Django?

  • El ORM. Para un catálogo (CRUD con filtros, búsqueda y un pellizco de geografía) el ORM de Django es excelente, vencido y predecible.
  • El admin gratis. En cuanto registro Station en admin.py tengo un panel donde editar emisoras a mano si tengo que limpiar datos sucios. Eso, en un proyecto donde la fuente externa puede meter basura, es oro.
  • drf-yasg. Para una API REST de solo lectura, DRF + drf-yasg genera el swagger UI sin que yo tenga que tocarlo casi.
  • Las migraciones. Lo veremos en otro post, pero Django me deja escribir migraciones de datos que deduplican filas antes de aplicar un unique=True. No es trivial hacerlo en otros stacks sin escribir scripts ad-hoc.

GeoDjango con dos backends: SpatiaLite en local, PostGIS en producción

GeoDjango soporta varios backends. PostGIS es el adulto; SpatiaLite es la extensión geoespacial de SQLite. En este proyecto uso los dos, eligiéndolo por entorno:

  • SpatiaLite en desarrollo local. Es una sola librería del sistema (libsqlite3-mod-spatialite en Debian, brew install libspatialite gdal en macOS). Sin Postgres corriendo, sin configurar usuarios, sin docker-compose con dos servicios para hacer un test.
  • PostGIS en producción (Dokploy). El despliegue real va contra un cluster Postgres con CREATE EXTENSION postgis. Concurrencia real y un único storage compartido entre todas las réplicas posibles del backend.

La clave es que el código de la aplicación es el mismo. Todo se decide por env vars:

# latamradio/settings.py
DATABASES = {
    "default": {
        "ENGINE": env(
            "DATABASE_ENGINE",
            default="django.contrib.gis.db.backends.spatialite",
        ),
        "NAME": env("DATABASE_NAME", default=str(BASE_DIR / "db.sqlite3")),
        "USER": env("DATABASE_USER", default=""),
        "PASSWORD": env("DATABASE_PASSWORD", default=""),
        "HOST": env("DATABASE_HOST", default=""),
        "PORT": env("DATABASE_PORT", default=""),
    }
}

En el .env de Dokploy:

DATABASE_ENGINE=django.contrib.gis.db.backends.postgis
DATABASE_NAME=latamradio
DATABASE_USER=latamradio
DATABASE_PASSWORD=…
DATABASE_HOST=postgres-host.dokploy.internal
DATABASE_PORT=5432

Y la app no se entera de quién está detrás.

radios (SDK) en lugar de hablar a pelo con la API

Para sincronizar con radio-browser uso el paquete radios, que es un cliente async para la API. Podría haber escrito un httpx + JSON parser en 30 líneas, pero radios me da gratis:

  • Selección automática del mirror más rápido (la federación tiene varios servidores).
  • Reintentos con backoff.
  • Tipado: el cliente devuelve dataclasses con campos definidos, no diccionarios.
# streams/management/commands/update_radios.py
from radios import FilterBy, Order, RadioBrowser

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,
        )

Celery: descartado (por ahora)

Pensé inicialmente en montar Celery + Redis para correr la sincronización en background. Acabé descartándolo:

  • Para un sync diario que se ejecuta una vez (cron), Celery añade un broker y un proceso worker que viven 24h al pedo.
  • Un management command de Django invocado desde un scheduler externo hace exactamente lo mismo con cero infra extra.

En Dokploy esto se traduce en un Schedule (cron expression desde el panel) apuntando a python manage.py update_radios sobre el servicio backend. Sin worker, sin beat, sin contenedores adicionales. Lo cuento entero en el post del sync y en el DEPLOY.md del repo.

Si en el futuro necesito tareas más finas (re-validar URLs cada hora, encolar checks por estación, distribución por país), entonces sí me planteo Celery. Hasta entonces, YAGNI.

Redis sí, pero para cache ORM, no para Celery

Lo que sí terminé añadiendo es Redis, pero para algo distinto: cache automática del ORM con django-cacheops. Los endpoints /countries/, /tags/ y los filtros más comunes sobre Station se sirven en milisegundos desde Redis e invalidan automáticamente cuando el sync diario modifica algún registro. Una pieza opcional con un beneficio enorme, sin lockings ni semáforos en el código de aplicación.

El compose levanta el contenedor redis:7-alpine con maxmemory 128mb y política allkeys-lru. La aplicación se configura con DJANGO_REDIS_URL=redis://redis:6379/1 y cacheops se desactiva automáticamente bajo pytest para no acoplar los tests a un broker.

Las piezas, juntas

Arquitectura interna: el cliente entra por DRF y baja hasta el ORM; el management command alimenta el ORM consultando a radio-browser

Tres caminos de entrada al backend: la API REST (lo que ve la app), el management command (lo que alimenta la base), y el admin de Django (para mi mantenimiento manual). Tres caminos, una única base.

El requirements.txt esencial

No pongo el archivo entero; solo lo que aporta. El resto son transitivas:

Django==5.1.7
djangorestframework==3.15.2
djangorestframework-gis==1.1
django-cors-headers==4.7.0
django-environ==0.11.2
django-filter==24.3
drf-yasg==1.21.8
django-cacheops==7.2
radios==0.3.2
psycopg[binary]==3.2.3
redis==5.2.1
whitenoise==6.8.2
gunicorn==23.0.0

Trece dependencias directas para un backend completo, observable y desplegable. psycopg[binary] aparece sólo para hablar con PostGIS en producción; whitenoise sirve estáticos sin nginx (el reverse proxy de Dokploy es Traefik y delega los estáticos al propio gunicorn); django-cacheops y redis son la cache ORM mencionada arriba. Esto es lo que la decisión inicial de "ir con Django" me compra: una stack reducida, predecible y con buena documentación.

En el siguiente capítulo entramos en el dominio: cómo modelar una estación, sus tags, sus idiomas y su posición geográfica.