Build your first Tune plugin in 5 minutes
Tune Server is designed to be open. Since version 0.7.90, a Python plugin system makes it possible to extend the server without touching its source code: adding new audio outputs, integrating third-party services, creating custom API endpoints, or reacting to playback events. In this article, we'll build a first working plugin in just a few minutes.
Prerequisites
- Python 3.11+ installed on your machine
- pip (included with Python)
- A working Tune server (version 0.7.90 or later)
- A text editor (VS Code, PyCharm, vim...)
Check your Python version:
python3 --version
# Python 3.11.x or later
The concept
Tune's plugin system rests on three elements:
1. The TunePlugin Protocol
Every plugin is a Python class that implements the TunePlugin protocol. This protocol defines two attributes (name and version) and two lifecycle methods (setup and teardown).
from typing import Protocol, runtime_checkable
@runtime_checkable
class TunePlugin(Protocol):
name: str # unique identifier, e.g. "hello"
version: str # semver version, e.g. "0.1.0"
async def setup(self, ctx: PluginContext) -> None:
"""Called when the server starts."""
...
async def teardown(self) -> None:
"""Called when the server shuts down."""
...
2. The PluginContext
During setup, your plugin receives a PluginContext object that provides safe access to Tune's internal services:
ctx.event_bus— subscribe to playback events (track started, zone created, etc.)ctx.zone_manager— register new audio output typesctx.api_app— mount custom FastAPI routesctx.settings— read the server configuration (read-only)ctx.db— database access
3. The lifecycle
At startup, Tune automatically discovers installed plugins via Python entry points, then initialises them in order:
- Discovery — Tune scans
entry_points(group="tune_server.plugins") setup(ctx)— the plugin registers its routes, hooks and output types- Normal operation — the server runs, the plugin is active
teardown()— on shutdown, the plugin releases its resources
There is no hot reloading: after any change, a server restart is required. This is a deliberate choice to guarantee stability (no state corruption mid-playback).
Step-by-step quick start
Let's create a "Hello" plugin that adds an /api/v1/hello API endpoint and logs a message on every track change.
Step 1 — Create the project structure
mkdir tune-hello-plugin
cd tune-hello-plugin
mkdir -p src/tune_hello
The final structure will be:
tune-hello-plugin/
├── pyproject.toml
└── src/
└── tune_hello/
├── __init__.py
└── plugin.py
Step 2 — Write the pyproject.toml
The pyproject.toml file declares your plugin to Python and to Tune. The [project.entry-points] section is the key: it lets Tune discover your plugin at startup.
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"
[project]
name = "tune-hello-plugin"
version = "0.1.0"
description = "My first Tune plugin"
requires-python = ">=3.11"
dependencies = []
[project.entry-points."tune_server.plugins"]
hello = "tune_hello:HelloPlugin"
[tool.setuptools.packages.find]
where = ["src"]
The line hello = "tune_hello:HelloPlugin" means: "register the plugin named 'hello', whose class is HelloPlugin in the tune_hello package".
Step 3 — Write the plugin
Create src/tune_hello/__init__.py:
from tune_hello.plugin import HelloPlugin
__all__ = ["HelloPlugin"]
Then src/tune_hello/plugin.py:
import logging
from fastapi import APIRouter
logger = logging.getLogger("tune.plugin.hello")
class HelloPlugin:
"""A minimal plugin that adds an endpoint and logs tracks."""
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": "Hello from the Hello plugin!"}
@self.router.get("/hello/status")
async def status():
return {
"plugin": self.name,
"version": self.version,
"status": "running",
}
async def setup(self, ctx) -> None:
"""Called when Tune starts."""
# Register our API routes
ctx.register_router(self.router, prefix="/api/v1")
# Subscribe to playback events
ctx.event_bus.on("track.started", self._on_track_started)
logger.info("Hello plugin started (v%s)", self.version)
async def teardown(self) -> None:
"""Called when Tune shuts down."""
logger.info("Hello plugin stopped")
async def _on_track_started(self, event) -> None:
"""Called on every new track."""
track = event.track
logger.info(
"New track: %s - %s",
track.artist,
track.title,
)
Step 4 — Install the plugin
From your plugin folder, install it in development mode inside the same Python environment as Tune:
# Activate Tune's virtualenv if needed
source /path/to/tune-server/venv/bin/activate
# Install the plugin in editable mode
pip install -e .
The -e (editable) mode lets you modify the code without reinstalling every time.
Step 5 — Restart Tune
Restart the Tune server. In the logs, you should see:
INFO plugin_loaded name=hello version=0.1.0
INFO Hello plugin started (v0.1.0)
If you use systemd:
sudo systemctl restart tune-server
Or via the API (if the server is already running):
curl -X POST http://localhost:8888/api/v1/system/restart
Step 6 — Verify
Confirm the plugin is properly loaded:
# List of loaded plugins
curl http://localhost:8888/api/v1/system/plugins
Expected response:
{
"plugins": [
{
"name": "hello",
"version": "0.1.0",
"status": "running"
}
]
}
Test your endpoint:
curl http://localhost:8888/api/v1/hello
{"message": "Hello from the Hello plugin!"}
Then start a track in Tune — you'll see the New track: Artist - Title log appear in the server output.
Going further
The Hello plugin is deliberately simple. Here are the main capabilities of the plugin system.
Adding a custom audio output type
You can create new output types to stream to devices or services that aren't natively supported. For example, a plugin to broadcast to an Icecast server:
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="Broadcast to an Icecast server",
))
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
Once the plugin is installed, the icecast output type appears in the list of available outputs for each zone.
Hooking into playback events
Tune's EventBus emits events on every action. A plugin can subscribe to them to implement scrobbling (Last.fm, ListenBrainz), listening statistics, or notifications.
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:
# Report "now playing" to 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 if the track was played at least 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()
Adding API routes
Every plugin can contribute a complete FastAPI APIRouter, with as many routes as needed. The routes are automatically documented in Tune's Swagger UI (/docs).
router = APIRouter(tags=["stats"])
@router.get("/stats/top-artists")
async def top_artists(limit: int = 10):
"""Returns the most-played artists."""
return await get_top_artists(limit=limit)
@router.get("/stats/listening-time")
async def listening_time(period: str = "week"):
"""Total listening time over the period."""
return await get_listening_time(period=period)
Configuration via environment variables
Plugins can declare a configuration schema based on pydantic-settings. Environment variables follow the TUNE_<PLUGIN>_<KEY> convention:
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 is $TUNE_ICECAST_HOST or "localhost"
...
Variables are read from the environment or from a .env file at the server root.
Hooking into Player events
For use cases that need finer control than the EventBus, plugins can register hooks directly on each zone's Player:
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)
The difference from the EventBus: Player hooks are called synchronously within the playback pipeline, which allows you to intervene before an action occurs (for example, preparing a buffer before a track starts).
Publishing on PyPI
Your plugin is ready? Share it with the community by publishing it on PyPI.
Naming convention
Tune plugins follow the tune-plugin-<name> convention for the PyPI package name:
tune-plugin-icecasttune-plugin-lastfmtune-plugin-prometheus
The plugin's internal name (the name attribute) stays short: icecast, lastfm, prometheus.
Packaging
Make sure your pyproject.toml is complete:
[project]
name = "tune-plugin-icecast"
version = "0.1.0"
description = "Tune plugin for broadcasting to Icecast"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.11"
dependencies = [
"tune-server>=0.7.90",
]
[project.urls]
Homepage = "https://github.com/your-name/tune-plugin-icecast"
[project.entry-points."tune_server.plugins"]
icecast = "tune_icecast:IcecastPlugin"
Publish:
pip install build twine
python -m build
twine upload dist/*
Users will then be able to install your plugin with a single command:
pip install tune-plugin-icecast
A server restart is all it takes for the plugin to be discovered and enabled automatically.
Version compatibility
Always declare the compatible range of Tune versions in your dependencies. The server exposes a PROTOCOL_VERSION that your plugin can check during setup:
from tune_server.plugins.api import PROTOCOL_VERSION
async def setup(self, ctx) -> None:
if PROTOCOL_VERSION != "1":
raise RuntimeError(
f"Plugin {self.name} requires PROTOCOL_VERSION 1, "
f"found {PROTOCOL_VERSION}"
)
Best practices
- Error handling — Exceptions in a plugin must never crash the server. Tune catches errors at load time and inside hooks, but handle your own
try/exceptin your business logic. - Logging — Use
logging.getLogger("tune.plugin.<name>")so your logs stay consistent with the server's. - Async — The
setupandteardownmethods, along with event callbacks, must beasync. Tune is fully asynchronous (asyncio). - Tests — Test your plugin independently with
pytestandpytest-asyncio. Mock thePluginContextfor unit tests. - Prefixing — If your plugin creates database tables, prefix them with the plugin name (e.g.
icecast_sessions) to avoid collisions. - Resources — Always release your resources (network connections, open files, asyncio tasks) in
teardown.
Conclusion
With a few lines of Python and a pyproject.toml, you can extend Tune Server to meet your specific needs: home-automation integrations, scrobbling, exotic audio outputs, custom dashboards... The possibilities are wide open.
To go further:
- Read the complete plugin SDK documentation
- Explore the examples on GitHub
- Share your creations and ask your questions on the Mozaik Labs forum
We can't wait to see what you'll build.