Cree su primer plugin de Tune en 5 minutos
Tune Server está diseñado para ser abierto. Desde la versión 0.7.90, un sistema de plugins en Python permite extender el servidor sin modificar su código fuente: añadir nuevas salidas de audio, integrar servicios de terceros, crear endpoints de API personalizados o reaccionar a los eventos de reproducción. En este artículo vamos a crear un primer plugin funcional en unos minutos.
Requisitos previos
- Python 3.11+ instalado en su máquina
- pip (incluido con Python)
- Un servidor Tune funcional (versión 0.7.90 o superior)
- Un editor de texto (VS Code, PyCharm, vim...)
Compruebe su versión de Python:
python3 --version
# Python 3.11.x o superior
El concepto
El sistema de plugins de Tune se basa en tres elementos:
1. El Protocol TunePlugin
Cada plugin es una clase Python que implementa el protocol TunePlugin. Este protocol define dos atributos (name y version) y dos métodos de ciclo de vida (setup y teardown).
from typing import Protocol, runtime_checkable
@runtime_checkable
class TunePlugin(Protocol):
name: str # identificador único, ej.: "hello"
version: str # versión semver, ej.: "0.1.0"
async def setup(self, ctx: PluginContext) -> None:
"""Llamado al arrancar el servidor."""
...
async def teardown(self) -> None:
"""Llamado al detener el servidor."""
...
2. El PluginContext
Durante el setup, su plugin recibe un objeto PluginContext que da acceso a los servicios internos de Tune de forma segura:
ctx.event_bus— suscribirse a los eventos de reproducción (pista iniciada, zona creada, etc.)ctx.zone_manager— registrar nuevos tipos de salida de audioctx.api_app— montar rutas API FastAPI personalizadasctx.settings— leer la configuración del servidor (solo lectura)ctx.db— acceso a la base de datos
3. El ciclo de vida
Al arrancar, Tune descubre automáticamente los plugins instalados a través de los entry points de Python, y luego los inicializa en orden:
- Descubrimiento — Tune escanea los
entry_points(group="tune_server.plugins") setup(ctx)— el plugin registra sus rutas, hooks y tipos de salida- Funcionamiento normal — el servidor funciona, el plugin está activo
teardown()— al detenerse, el plugin libera sus recursos
Sin recarga en caliente: tras cualquier modificación es necesario reiniciar el servidor. Es una decisión deliberada para garantizar la estabilidad (sin corrupción de estado durante la reproducción).
Quick start paso a paso
Creemos un plugin "Hello" que añada un endpoint API /api/v1/hello y registre un mensaje en cada cambio de pista.
Paso 1 — Crear la estructura del proyecto
mkdir tune-hello-plugin
cd tune-hello-plugin
mkdir -p src/tune_hello
La estructura final será:
tune-hello-plugin/
├── pyproject.toml
└── src/
└── tune_hello/
├── __init__.py
└── plugin.py
Paso 2 — Escribir el pyproject.toml
El archivo pyproject.toml declara su plugin ante Python y ante Tune. La sección [project.entry-points] es la clave: permite que Tune descubra su plugin al arrancar.
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"
[project]
name = "tune-hello-plugin"
version = "0.1.0"
description = "Mi primer plugin de Tune"
requires-python = ">=3.11"
dependencies = []
[project.entry-points."tune_server.plugins"]
hello = "tune_hello:HelloPlugin"
[tool.setuptools.packages.find]
where = ["src"]
La línea hello = "tune_hello:HelloPlugin" significa: "registra el plugin llamado 'hello', cuya clase es HelloPlugin en el paquete tune_hello".
Paso 3 — Escribir el plugin
Cree src/tune_hello/__init__.py:
from tune_hello.plugin import HelloPlugin
__all__ = ["HelloPlugin"]
Luego src/tune_hello/plugin.py:
import logging
from fastapi import APIRouter
logger = logging.getLogger("tune.plugin.hello")
class HelloPlugin:
"""Un plugin mínimo que añade un endpoint y registra las pistas."""
name = "hello"
version = "0.1.0"
def __init__(self):
self.router = APIRouter(tags=["hello"])
self._setup_routes()
def _setup_routes(self):
@self.router.get("/hello")
async def hello():
return {"message": "¡Hola desde el plugin Hello!"}
@self.router.get("/hello/status")
async def status():
return {
"plugin": self.name,
"version": self.version,
"status": "running",
}
async def setup(self, ctx) -> None:
"""Llamado al arrancar Tune."""
# Registrar nuestras rutas API
ctx.register_router(self.router, prefix="/api/v1")
# Suscribirse a los eventos de reproducción
ctx.event_bus.on("track.started", self._on_track_started)
logger.info("Plugin Hello iniciado (v%s)", self.version)
async def teardown(self) -> None:
"""Llamado al detener Tune."""
logger.info("Plugin Hello detenido")
async def _on_track_started(self, event) -> None:
"""Llamado en cada nueva pista."""
track = event.track
logger.info(
"Nueva pista: %s - %s",
track.artist,
track.title,
)
Paso 4 — Instalar el plugin
Desde la carpeta de su plugin, instálelo en modo desarrollo en el mismo entorno Python que Tune:
# Active el virtualenv de Tune si es necesario
source /ruta/hacia/tune-server/venv/bin/activate
# Instale el plugin en modo editable
pip install -e .
El modo -e (editable) permite modificar el código sin reinstalar cada vez.
Paso 5 — Reiniciar Tune
Reinicie el servidor Tune. En los logs debería ver:
INFO plugin_loaded name=hello version=0.1.0
INFO Plugin Hello iniciado (v0.1.0)
Si utiliza systemd:
sudo systemctl restart tune-server
O a través de la API (si el servidor ya está en ejecución):
curl -X POST http://localhost:8888/api/v1/system/restart
Paso 6 — Verificar
Confirme que el plugin se ha cargado correctamente:
# Lista de plugins cargados
curl http://localhost:8888/api/v1/system/plugins
Respuesta esperada:
{
"plugins": [
{
"name": "hello",
"version": "0.1.0",
"status": "running"
}
]
}
Pruebe su endpoint:
curl http://localhost:8888/api/v1/hello
{"message": "¡Hola desde el plugin Hello!"}
Y lance una pista en Tune — verá aparecer el log Nueva pista: Artista - Título en la salida del servidor.
Ir más lejos
El plugin Hello es deliberadamente simple. Estas son las principales capacidades del sistema de plugins.
Añadir un tipo de salida de audio personalizado
Puede crear nuevos tipos de salida para transmitir hacia dispositivos o servicios no soportados de forma nativa. Por ejemplo, un plugin para emitir hacia un servidor Icecast:
from tune_server.plugins.api import OutputTypeSpec
from tune_server.audio.formats import AudioCapabilities
class IcecastPlugin:
name = "icecast"
version = "0.1.0"
async def setup(self, ctx) -> None:
ctx.register_output_type(OutputTypeSpec(
name="icecast",
factory=self._create_output,
capabilities=AudioCapabilities(
max_sample_rate=48000,
max_bit_depth=16,
formats=["flac", "mp3"],
),
description="Emisión hacia un servidor Icecast",
))
def _create_output(self, zone_id: str | None):
return IcecastOutput(
host=os.environ.get("TUNE_ICECAST_HOST", "localhost"),
port=int(os.environ.get("TUNE_ICECAST_PORT", "8000")),
mount=os.environ.get("TUNE_ICECAST_MOUNT", "/stream"),
password=os.environ.get("TUNE_ICECAST_PASSWORD", "hackme"),
)
async def teardown(self) -> None:
pass
Una vez instalado el plugin, el tipo de salida icecast aparece en la lista de salidas disponibles para cada zona.
Enganchar los eventos de reproducción
El EventBus de Tune emite eventos en cada acción. Un plugin puede suscribirse para implementar scrobbling (Last.fm, ListenBrainz), estadísticas de escucha o notificaciones.
class ScrobblerPlugin:
name = "lastfm-scrobbler"
version = "0.1.0"
async def setup(self, ctx) -> None:
ctx.event_bus.on("track.started", self._on_track_started)
ctx.event_bus.on("track.finished", self._on_track_finished)
async def _on_track_started(self, event) -> None:
# Señalar "now playing" a Last.fm
await self.lastfm_client.update_now_playing(
artist=event.track.artist,
title=event.track.title,
album=event.track.album,
)
async def _on_track_finished(self, event) -> None:
# Scrobble si la pista se ha escuchado al menos al 50%
if event.progress >= 0.5:
await self.lastfm_client.scrobble(
artist=event.track.artist,
title=event.track.title,
album=event.track.album,
timestamp=event.started_at,
)
async def teardown(self) -> None:
await self.lastfm_client.close()
Añadir rutas API
Cada plugin puede aportar un APIRouter FastAPI completo, con tantas rutas como sea necesario. Las rutas se documentan automáticamente en el Swagger UI de Tune (/docs).
router = APIRouter(tags=["stats"])
@router.get("/stats/top-artists")
async def top_artists(limit: int = 10):
"""Devuelve los artistas más escuchados."""
return await get_top_artists(limit=limit)
@router.get("/stats/listening-time")
async def listening_time(period: str = "week"):
"""Tiempo de escucha total en el periodo."""
return await get_listening_time(period=period)
Configuración mediante variables de entorno
Los plugins pueden declarar un esquema de configuración basado en pydantic-settings. Las variables de entorno siguen la convención TUNE_<PLUGIN>_<CLAVE>:
from pydantic_settings import BaseSettings
class IcecastSettings(BaseSettings):
host: str = "localhost"
port: int = 8000
mount: str = "/stream"
password: str = "hackme"
class Config:
env_prefix = "TUNE_ICECAST_"
class IcecastPlugin:
name = "icecast"
version = "0.1.0"
def config_schema(self) -> type[BaseSettings]:
return IcecastSettings
async def setup(self, ctx) -> None:
self.config = IcecastSettings()
# self.config.host vale $TUNE_ICECAST_HOST o "localhost"
...
Las variables se leen desde el entorno o desde un archivo .env en la raíz del servidor.
Enganchar los eventos del Player
Para casos de uso que requieren un control más fino que el EventBus, los plugins pueden registrar hooks directamente en el Player de cada zona:
async def setup(self, ctx) -> None:
ctx.register_player_hook("before_track", self._before_track)
ctx.register_player_hook("after_track", self._after_track)
ctx.register_player_hook("play", self._on_play)
ctx.register_player_hook("pause", self._on_pause)
La diferencia con el EventBus: los hooks del Player se llaman de forma síncrona dentro del pipeline de reproducción, lo que permite intervenir antes de que se produzca una acción (por ejemplo, preparar un búfer antes del inicio de una pista).
Publicar en PyPI
¿Su plugin está listo? Compártalo con la comunidad publicándolo en PyPI.
Convención de nomenclatura
Los plugins de Tune siguen la convención tune-plugin-<nombre> para el nombre del paquete PyPI:
tune-plugin-icecasttune-plugin-lastfmtune-plugin-prometheus
El nombre interno del plugin (atributo name) sigue siendo corto: icecast, lastfm, prometheus.
Packaging
Asegúrese de que su pyproject.toml esté completo:
[project]
name = "tune-plugin-icecast"
version = "0.1.0"
description = "Plugin de Tune para emitir hacia Icecast"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.11"
dependencies = [
"tune-server>=0.7.90",
]
[project.urls]
Homepage = "https://github.com/su-nombre/tune-plugin-icecast"
[project.entry-points."tune_server.plugins"]
icecast = "tune_icecast:IcecastPlugin"
Publique:
pip install build twine
python -m build
twine upload dist/*
Los usuarios podrán después instalar su plugin con un solo comando:
pip install tune-plugin-icecast
Basta con reiniciar el servidor para que el plugin sea descubierto y activado automáticamente.
Compatibilidad de versiones
Declare siempre el rango de versiones de Tune compatibles en sus dependencias. El servidor expone un PROTOCOL_VERSION que su plugin puede verificar en el setup:
from tune_server.plugins.api import PROTOCOL_VERSION
async def setup(self, ctx) -> None:
if PROTOCOL_VERSION != "1":
raise RuntimeError(
f"El plugin {self.name} requiere PROTOCOL_VERSION 1, "
f"encontrado {PROTOCOL_VERSION}"
)
Buenas prácticas
- Gestión de errores — Las excepciones de un plugin nunca deben hacer caer el servidor. Tune captura los errores en la carga y en los hooks, pero gestione sus propios
try/excepten la lógica de negocio. - Logging — Utilice
logging.getLogger("tune.plugin.<nombre>")para que sus logs sean coherentes con los del servidor. - Async — Los métodos
setup,teardowny los callbacks de eventos deben serasync. Tune es totalmente asíncrono (asyncio). - Tests — Pruebe su plugin de forma independiente con
pytestypytest-asyncio. Simule (mock) elPluginContextpara las pruebas unitarias. - Prefijado — Si su plugin crea tablas en la base de datos, prefíjelas con el nombre del plugin (ej.:
icecast_sessions) para evitar colisiones. - Recursos — Libere siempre sus recursos (conexiones de red, archivos abiertos, tareas asyncio) en
teardown.
Conclusión
Con unas pocas líneas de Python y un pyproject.toml, puede extender Tune Server para responder a sus necesidades específicas: integraciones domóticas, scrobbling, salidas de audio exóticas, paneles personalizados... Las posibilidades están abiertas.
Para ir más lejos:
- Consulte la documentación completa del SDK de plugins
- Explore los ejemplos en GitHub
- Comparta sus creaciones y plantee sus preguntas en el foro de Mozaik Labs
Estamos deseando ver lo que va a construir.