Arquitectura del Editor Local de Playlists IPTV: Cómo FastAPI y React 19 Resuelven los Desafíos de Sincronización y Reproducción
Editar playlists IPTV con miles de canales requiere una sincronización compleja del estado y soluciones alternativas para limitaciones técnicas. Examinemos las soluciones arquitectónicas en el proyecto de código abierto m3u Studio, construido con FastAPI y React 19.
Gestión de Playlists Grandes
Los editores estándar de archivos m3u no pueden manejar cambios dinámicos de los proveedores. Al actualizar la lista de canales, se pierde la curación: orden, agrupación, favoritos. Desafíos clave:
- Las URL de streams cambian al cambiar de proveedores, rompiendo los identificadores
- La falta de CORS en fuentes IPTV bloquea la reproducción directa en el navegador
- Incompatibilidad de códecs de audio (AC-3/E-AC-3) en Chrome/Safari
- Sincronizar dos paneles sin botón de guardar
m3u Studio soluciona estos problemas mediante una interfaz web local con arrastrar y soltar, un reproductor HLS integrado y EPG. La pila incluye FastAPI + Pydantic v2 en el backend, React 19 + TypeScript + Tailwind CSS en el frontend.
Almacenamiento del Estado por Nombres, No por IDs
El enfoque tradicional hashea las URL para generar identificadores estables:
def _stable_id(url: str) -> str:
digest = hashlib.sha1(url.encode("utf-8"), usedforsecurity=False).hexdigest()
return digest[:12]
Este método falla al cambiar de proveedores: las URL cambian por completo, los identificadores se recalculan y se pierde la curación. La solución almacena el orden por nombres de canales, que permanecen estables entre proveedores. Un mecanismo interno convierte nombre ↔ ID mediante un diccionario dinámico:
@dataclass(frozen=True, slots=True)
class MainState:
main_names: tuple[str, ...]
class StateStore:
def current_ids(self) -> list[str]:
"""Stored names → current playlist ids."""
with self._lock:
name_to_id = self._name_to_id_map()
return [name_to_id[n] for n in self._state.main_names if n in name_to_id]
La API sigue trabajando con IDs, pero al cargar una nueva playlist, la curación se aplica automáticamente a los canales con nombres coincidentes. Esto asegura la continuidad de la experiencia del usuario al cambiar de fuentes de datos.
Sincronización de Main y Source Sin Botón de Guardar
La lista curada "Main" y el grupo "main" de la fuente deben actualizarse de forma atómica. Al arrastrar un canal a Main, requiere:
- Actualizar el estado de curación
- Reescribir el archivo m3u8 con el nuevo orden
- Guardar la lista de nombres para futuras importaciones
Implementado mediante un ayudante centralizado _sync_main_to_source, llamado tras cada mutación:
def _sync_main_to_source() -> None:
main_ids = _state.store.current_ids()
text = build_with_main_group(
header=_state.playlist.header,
all_channels=_state.playlist.channels,
main_ids=main_ids,
group_name=MAIN_GROUP_NAME,
)
PLAYLIST_PATH.write_text(text, encoding="utf-8")
_state.playlist = parse_playlist(PLAYLIST_PATH)
_state.store.bind_playlist(_state.playlist)
current_names = _state.store.state.main_names
if current_names:
DEFAULT_NAMES_PATH.write_text("\n".join(current_names), encoding="utf-8")
_state.store.set_default_names(current_names)
Punto crítico: el rebindings directo vía bind_playlist() en lugar de releer state.json previene condiciones de carrera. En el frontend, React Query invalida la caché de la fuente tras la mutación:
onSettled: (server) => {
if (server) client.setQueryData(KEY_MAIN, server)
client.invalidateQueries({ queryKey: KEY_SOURCE })
}
Resultado: sincronización instantánea de paneles sin guardar explícitamente.
Proxy HLS para Evitar CORS
Los proveedores IPTV rara vez envían cabeceras CORS, haciendo imposible la reproducción directa en el navegador. El servidor proxy reescribe no solo el manifiesto maestro, sino todos los enlaces de segmentos anidados:
async def proxy_stream(upstream_url: str) -> Response:
async with httpx.AsyncClient() as client:
resp = await client.get(upstream_url, follow_redirects=True)
content_type = resp.headers.get("content-type", "")
if "mpegurl" in content_type.lower() or upstream_url.endswith(".m3u8"):
base = urljoin(upstream_url, ".")
rewritten = []
for line in resp.text.splitlines():
if line.startswith("#") or not line.strip():
rewritten.append(line)
else:
absolute = urljoin(base, line)
rewritten.append(f"/api/proxy?u={quote(absolute)}")
return Response("\n".join(rewritten), media_type=content_type)
return Response(resp.content, media_type=content_type)
Este manejador de 40 líneas reemplaza recursivamente todas las rutas relativas en archivos m3u8 con URL de proxy, asegurando la carga correcta de segmentos a través del servidor de la app.
Transcodificación de Audio en Tiempo Real
Para canales con audio AC-3/E-AC-3 no soportado por navegadores, se implementa un mecanismo de fallback. Cuando se activa el botón "Arreglar audio", entra en acción la transcodificación con ffmpeg:
proc = await asyncio.create_subprocess_exec(
FFMPEG_BIN,
"-i", upstream_url,
"-c:v", "copy",
"-c:a", "aac",
"-f", "hls",
"-hls_time", "4",
"-hls_list_size", "6",
"-hls_flags", "delete_segments",
str(output_dir / "index.m3u8"),
)
El reproductor cambia a /api/transcode/{channel_id}/index.m3u8, restaurando el audio en 3-4 segundos (latencia de segmento HLS). Los procesos ffmpeg se gestionan mediante una tarea de limpieza en segundo plano para evitar fugas de recursos.
Implementación de Tema Claro en una Base Solo Oscura
Reescribir miles de líneas de código para soporte de tema claro resultó impráctico. En su lugar, index.css añade overrides para clases utilitarias:
[data-theme="light"] .text-white { color: var(--color-fog-300); }
[data-theme="light"] .bg-white\/5 { background-color: var(--tint-bg-sm); }
[data-theme="light"] .bg-white\/10 { background-color: var(--tint-bg-md); }
[data-theme="light"] .border-white\/10 { border-color: var(--tint-border-sm); }
[data-theme="light"] .hover\:bg-white\/5:hover { background-color: var(--tint-bg-sm); }
Variables semánticas como --tint-bg-sm y --tint-border-sm crean una jerarquía de elevación. La especificidad del selector [data-theme="light"] .class (0,2,0) sobreescribe las clases estándar de Tailwind (0,1,0), evitando cambios en componentes.
Lecciones Clave
- Estabilidad de Curación: Almacenar el orden por nombres de canales, no por IDs, preserva la configuración al cambiar de proveedores
- Sincronización Atómica: Combinar actualizaciones de estado, reescritura del archivo m3u8 y guardado de la lista de nombres en una sola operación
- Transformación Proxy: Reescritura recursiva de todas las URL en manifiestos HLS para evitar CORS
- Fallback de Audio: Transcodificación bajo demanda de AC-3 a AAC mediante ffmpeg con gestión de procesos
- Theming Sin Refactorización: Sobrescribir clases utilitarias vía atributos data con control de especificidad
Componentes adicionales del proyecto incluyen un parser m3u con soporte para EXTGRP y atributos tvg, resolvedor de logos (overrides locales → iptv-org/database → CDN), detector de duplicados mediante normalización de nombres, cargador de EPG XMLTV con caché y salto a archivo. El arrastrar y soltar usa @dnd-kit con detección de colisiones personalizada.
Lanzar el proyecto es sencillo:
git clone https://github.com/stepanovandrey89/m3ustudio.git
cd m3ustudio
docker compose up -d
Tras abrir http://127.0.0.1:8000, el usuario sube su archivo m3u8 e inicia la edición. El proyecto está abierto a PRs y discusiones arquitectónicas.
— Editorial Team
Aún no hay comentarios.