Backend Latam Radio #3

Publicado el 24/05/2026

Modelando el dominio

Antes de tocar la API, el comando de sincronización o el admin, hay una pregunta más fundamental: ¿qué es una estación, exactamente?

radio-browser devuelve un objeto con unos 30 campos. No todos me interesan, y algunos quiero almacenarlos con una forma distinta a la que vienen. Esa es la primera decisión de dominio: qué entra y cómo.

Entidades

Identifico cuatro entidades:

  • Station: la emisora. Es el ciudadano de primera clase.
  • Tag: una etiqueta arbitraria asociada a estaciones (rock, noticias, cumbia...). Relación M2M.
  • Language: un idioma en lenguaje humano (Spanish, Português). Lo conserva radio-browser y lo respeto, aunque es bastante sucio.
  • LanguageCode: el código ISO del idioma (spa, por, qbp para guaraní paraguayo). Mucho más estable que el Language.

Separar Language y LanguageCode no es elegancia gratuita: radio-browser devuelve ambos, no siempre coinciden, y el código es lo que la app móvil debería usar para filtrar (es estable y la fuente es ISO 639-3).

El modelo, después de varias iteraciones

Lo enseño ya en la versión "buena" después de la auditoría de seguridad y de un dedupe que tuve que hacer. En posts posteriores reconstruyo cómo se llegó hasta aquí:

# streams/models.py
from django.contrib.gis.db import models


class Language(models.Model):
    lang = models.CharField(max_length=255, unique=True)

    class Meta:
        ordering = ["lang"]

    def __str__(self):
        return self.lang


class LanguageCode(models.Model):
    lang_codes = models.CharField(max_length=255, unique=True)
    name = models.CharField(max_length=255, blank=True, default="")

    class Meta:
        ordering = ["lang_codes"]

    def __str__(self):
        return self.lang_codes


class Tag(models.Model):
    name = models.CharField(max_length=255, unique=True)

    class Meta:
        ordering = ["name"]

    def __str__(self):
        return self.name


class Station(models.Model):
    uuid = models.CharField(max_length=255, unique=True, db_index=True)
    change_uuid = models.CharField(max_length=255, blank=True, default="")
    name = models.CharField(max_length=255, db_index=True)
    country_code = models.CharField(max_length=4, db_index=True)
    state = models.CharField(max_length=255, blank=True, default="")
    codec = models.CharField(max_length=255, blank=True, default="")
    bitrate = models.IntegerField(default=0)
    # URLField por defecto pone max_length=200 que se queda corto para algunas
    # URLs de radio-browser (homepages con querystrings largos, CDNs de favicons,
    # streams con tokens firmados). Postgres devuelve "value too long for type
    # character varying(200)" y descartaríamos la estación. Subimos a 500.
    url = models.URLField(max_length=500, blank=True, default="")
    url_resolved = models.URLField(max_length=500, blank=True, default="")
    favicon = models.URLField(max_length=500, blank=True, default="")
    homepage = models.URLField(max_length=500, blank=True, default="")
    languages = models.ManyToManyField(Language, blank=True)
    language_codes = models.ManyToManyField(LanguageCode, blank=True)
    tags = models.ManyToManyField(Tag, blank=True)
    location = models.PointField(null=True, blank=True)
    lastcheckok = models.BooleanField(default=False, db_index=True)
    last_check_ok_time = models.DateTimeField(blank=True, null=True)
    last_check_time = models.DateTimeField(blank=True, null=True)
    last_local_check_time = models.DateTimeField(blank=True, null=True)
    lastchange_time = models.DateTimeField(blank=True, null=True)

    class Meta:
        ordering = ["country_code", "name"]
        indexes = [
            models.Index(
                fields=["country_code", "lastcheckok"],
                name="station_country_check_idx",
            ),
        ]

    def __str__(self):
        return self.name

Las decisiones que merece la pena explicar

uuid como CharField, no UUIDField

radio-browser usa UUIDs, sí, pero los expone como cadenas y mi cliente (la app móvil) los va a manejar como strings. Convertir a UUIDField añade validación en el ORM pero también restringe el ingreso de datos desde la API externa, donde puede haber registros con UUIDs malformados.

Compromiso: lo dejo como CharField con unique=True. La validación de formato se hace en el serializer de entrada cuando el cliente pregunta por UUIDs.

country_code como CharField(max_length=4)

Aunque ISO 3166-1 alpha-2 son dos letras, dejo cuatro de margen por si en el futuro acepto sub-divisiones (US-CA, AR-BA). No me cuesta nada en disco.

URLs a 500 caracteres

URLField de Django por defecto traduce a VARCHAR(200). Lo suficiente para una URL "buena". Lo escaso para los datos reales: radio-browser tiene homepages con querystrings de tracking de cien caracteres y CDNs de favicon con paths firmados largos. SQLite traga (no aplica el max_length), pero PostGIS rechaza el insert con value too long for type character varying(200) y la estación se descarta del sync. Subir a 500 elimina ese ruido sin que el almacenamiento real sufra (Postgres usa text por debajo cuando hay max_length, no reserva 500 bytes).

location puede ser null

Muchas emisoras no traen coordenadas en radio-browser. En lugar de inventarlas o filtrarlas, las acepto sin localización. La búsqueda por proximidad las excluirá naturalmente.

Meta.indexes compuesto

indexes = [
    models.Index(
        fields=["country_code", "lastcheckok"],
        name="station_country_check_idx",
    ),
]

El 99% de los queries que sirve la API hacen WHERE country_code = 'AR' AND lastcheckok = True. Un índice compuesto sobre esos dos campos evita scans secuenciales en una tabla que va a crecer.

unique=True en Tag.name, Language.lang, LanguageCode.lang_codes

Estos atributos son la identidad natural: dos tags con name='rock' son el mismo tag. Sin unique=True, get_or_create puede crear duplicados ante concurrencia, y acabas con Tag(name='rock', id=12) y Tag(name='rock', id=44).

Esta es una lección dolorosa: el código pre-auditoría tenía un subquery con Min('id') en el TagViewSet para colapsar duplicados que ya estaban en la base. El parche estaba parcheando un síntoma, no la causa. Aplicar unique=True cura el origen.

Modelos M2M sin tabla through propia

Station.tags, Station.languages y Station.language_codes son ManyToManyField puros: Django genera tablas intermedias automáticamente. No necesito una tabla through propia porque no hay metadatos en la relación (no me importa cuándo se asoció un tag a una estación).

blank=True en los M2M permite crear Station sin tags asignados todavía, lo cual es necesario porque al sincronizar primero creo la Station y después le adjunto los tags.

Migraciones: el momento más subestimado

Lo dejo apuntado para un post próximo: la migración que añade los unique=True no puede aplicarse en una base con duplicados, porque la ALTER TABLE revienta. Hay que deduplicar primero con un RunPython que reubica las relaciones M2M antes de imponer la constraint.

Es uno de esos casos donde el ORM de Django te paga el haberlo elegido: la migración es declarativa, versionable y deja huella en el repo.

En el próximo capítulo, el camino contrario al de este post: ya tengo dónde guardar los datos, ahora toca rellenarlos desde radio-browser sin que la sincronización se rompa al primer registro raro.