Tune by Mozaiklabs

Build your first Tune plugin in 5 minutes

Tune Plugin SDK

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 types
  • ctx.api_app — mount custom FastAPI routes
  • ctx.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:

  1. Discovery — Tune scans entry_points(group="tune_server.plugins")
  2. setup(ctx) — the plugin registers its routes, hooks and output types
  3. Normal operation — the server runs, the plugin is active
  4. 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-icecast
  • tune-plugin-lastfm
  • tune-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/except in your business logic.
  • Logging — Use logging.getLogger("tune.plugin.<name>") so your logs stay consistent with the server's.
  • Async — The setup and teardown methods, along with event callbacks, must be async. Tune is fully asynchronous (asyncio).
  • Tests — Test your plugin independently with pytest and pytest-asyncio. Mock the PluginContext for 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:

We can't wait to see what you'll build.