Tune by Mozaiklabs

Erstellen Sie Ihr erstes Tune-Plugin in 5 Minuten

Tune Plugin SDK

Tune Server ist offen konzipiert. Seit Version 0.7.90 erlaubt ein Python-Plugin-System, den Server zu erweitern, ohne seinen Quellcode zu verändern: neue Audioausgänge hinzufügen, Drittanbieterdienste integrieren, eigene API-Endpoints erstellen oder auf Wiedergabeereignisse reagieren. In diesem Artikel erstellen wir in wenigen Minuten ein erstes funktionsfähiges Plugin.

Voraussetzungen

  • Python 3.11+ auf Ihrem Rechner installiert
  • pip (in Python enthalten)
  • Ein funktionierender Tune-Server (Version 0.7.90 oder höher)
  • Ein Texteditor (VS Code, PyCharm, vim...)

Prüfen Sie Ihre Python-Version:

python3 --version
# Python 3.11.x oder höher

Das Konzept

Das Plugin-System von Tune beruht auf drei Elementen:

1. Das Protocol TunePlugin

Jedes Plugin ist eine Python-Klasse, die das Protocol TunePlugin implementiert. Dieses Protocol definiert zwei Attribute (name und version) sowie zwei Lebenszyklus-Methoden (setup und teardown).

from typing import Protocol, runtime_checkable

@runtime_checkable
class TunePlugin(Protocol):
    name: str       # eindeutiger Bezeichner, z. B. "hello"
    version: str    # semver-Version, z. B. "0.1.0"

    async def setup(self, ctx: PluginContext) -> None:
        """Wird beim Serverstart aufgerufen."""
        ...

    async def teardown(self) -> None:
        """Wird beim Herunterfahren des Servers aufgerufen."""
        ...

2. Der PluginContext

Beim setup erhält Ihr Plugin ein PluginContext-Objekt, das auf sichere Weise Zugriff auf die internen Dienste von Tune gewährt:

  • ctx.event_bus — Wiedergabeereignisse abonnieren (Titel gestartet, Zone erstellt usw.)
  • ctx.zone_manager — neue Audioausgabetypen registrieren
  • ctx.api_app — eigene FastAPI-Routen einhängen
  • ctx.settings — Serverkonfiguration lesen (nur Lesezugriff)
  • ctx.db — Zugriff auf die Datenbank

3. Der Lebenszyklus

Beim Start erkennt Tune installierte Plugins automatisch über die Python-Entry Points und initialisiert sie dann der Reihe nach:

  1. Erkennung — Tune scannt die entry_points(group="tune_server.plugins")
  2. setup(ctx) — das Plugin registriert seine Routen, Hooks und Ausgabetypen
  3. Normalbetrieb — der Server läuft, das Plugin ist aktiv
  4. teardown() — beim Herunterfahren gibt das Plugin seine Ressourcen frei

Kein Hot Reloading: Nach jeder Änderung ist ein Neustart des Servers erforderlich. Das ist eine bewusste Entscheidung zugunsten der Stabilität (keine Zustandskorruption während der Wiedergabe).

Quick Start Schritt für Schritt

Erstellen wir ein Plugin "Hello", das einen API-Endpoint /api/v1/hello hinzufügt und bei jedem Titelwechsel eine Meldung loggt.

Schritt 1 — Projektstruktur anlegen

mkdir tune-hello-plugin
cd tune-hello-plugin
mkdir -p src/tune_hello

Die endgültige Struktur sieht so aus:

tune-hello-plugin/
├── pyproject.toml
└── src/
    └── tune_hello/
        ├── __init__.py
        └── plugin.py

Schritt 2 — Die pyproject.toml schreiben

Die Datei pyproject.toml meldet Ihr Plugin bei Python und bei Tune an. Der Abschnitt [project.entry-points] ist entscheidend: Er ermöglicht Tune, Ihr Plugin beim Start zu erkennen.

[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"

[project]
name = "tune-hello-plugin"
version = "0.1.0"
description = "Mein erstes Tune-Plugin"
requires-python = ">=3.11"
dependencies = []

[project.entry-points."tune_server.plugins"]
hello = "tune_hello:HelloPlugin"

[tool.setuptools.packages.find]
where = ["src"]

Die Zeile hello = "tune_hello:HelloPlugin" bedeutet: "registriere das Plugin mit dem Namen 'hello', dessen Klasse HelloPlugin im Package tune_hello liegt".

Schritt 3 — Das Plugin schreiben

Erstellen Sie src/tune_hello/__init__.py:

from tune_hello.plugin import HelloPlugin

__all__ = ["HelloPlugin"]

Und dann src/tune_hello/plugin.py:

import logging
from fastapi import APIRouter

logger = logging.getLogger("tune.plugin.hello")


class HelloPlugin:
    """Ein minimales Plugin, das einen Endpoint hinzufügt und Titel loggt."""

    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": "Hallo vom 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:
        """Wird beim Start von Tune aufgerufen."""
        # Unsere API-Routen registrieren
        ctx.register_router(self.router, prefix="/api/v1")

        # Wiedergabeereignisse abonnieren
        ctx.event_bus.on("track.started", self._on_track_started)

        logger.info("Hello-Plugin gestartet (v%s)", self.version)

    async def teardown(self) -> None:
        """Wird beim Herunterfahren von Tune aufgerufen."""
        logger.info("Hello-Plugin gestoppt")

    async def _on_track_started(self, event) -> None:
        """Wird bei jedem neuen Titel aufgerufen."""
        track = event.track
        logger.info(
            "Neuer Titel: %s - %s",
            track.artist,
            track.title,
        )

Schritt 4 — Das Plugin installieren

Installieren Sie es aus dem Ordner Ihres Plugins heraus im Entwicklungsmodus in derselben Python-Umgebung wie Tune:

# Aktivieren Sie ggf. das virtualenv von Tune
source /pfad/zu/tune-server/venv/bin/activate

# Plugin im editable-Modus installieren
pip install -e .

Der Modus -e (editable) erlaubt es, den Code zu ändern, ohne jedes Mal neu installieren zu müssen.

Schritt 5 — Tune neu starten

Starten Sie den Tune-Server neu. In den Logs sollten Sie sehen:

INFO  plugin_loaded  name=hello  version=0.1.0
INFO  Hello-Plugin gestartet (v0.1.0)

Wenn Sie systemd verwenden:

sudo systemctl restart tune-server

Oder über die API (wenn der Server bereits läuft):

curl -X POST http://localhost:8888/api/v1/system/restart

Schritt 6 — Überprüfen

Bestätigen Sie, dass das Plugin korrekt geladen wurde:

# Liste der geladenen Plugins
curl http://localhost:8888/api/v1/system/plugins

Erwartete Antwort:

{
  "plugins": [
    {
      "name": "hello",
      "version": "0.1.0",
      "status": "running"
    }
  ]
}

Testen Sie Ihren Endpoint:

curl http://localhost:8888/api/v1/hello
{"message": "Hallo vom Hello-Plugin!"}

Und starten Sie einen Titel in Tune — Sie sehen das Log Neuer Titel: Künstler - Titel in der Serverausgabe erscheinen.

Weitergehende Möglichkeiten

Das Hello-Plugin ist bewusst einfach gehalten. Hier die wichtigsten Fähigkeiten des Plugin-Systems.

Einen eigenen Audioausgabetyp hinzufügen

Sie können neue Ausgabetypen erstellen, um zu Geräten oder Diensten zu streamen, die nicht nativ unterstützt werden. Zum Beispiel ein Plugin zum Streamen an einen 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="Streaming zu einem 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

Sobald das Plugin installiert ist, erscheint der Ausgabetyp icecast in der Liste der verfügbaren Ausgänge für jede Zone.

Wiedergabeereignisse hooken

Der EventBus von Tune sendet bei jeder Aktion Ereignisse aus. Ein Plugin kann diese abonnieren, um Scrobbling (Last.fm, ListenBrainz), Hörstatistiken oder Benachrichtigungen umzusetzen.

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:
        # "now playing" an Last.fm melden
        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, wenn der Titel mindestens zu 50 % gehört wurde
        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()

API-Routen hinzufügen

Jedes Plugin kann einen vollständigen FastAPI-APIRouter beisteuern, mit beliebig vielen Routen. Die Routen werden automatisch im Swagger UI von Tune (/docs) dokumentiert.

router = APIRouter(tags=["stats"])

@router.get("/stats/top-artists")
async def top_artists(limit: int = 10):
    """Gibt die meistgehörten Künstler zurück."""
    return await get_top_artists(limit=limit)

@router.get("/stats/listening-time")
async def listening_time(period: str = "week"):
    """Gesamte Hörzeit im Zeitraum."""
    return await get_listening_time(period=period)

Konfiguration über Umgebungsvariablen

Plugins können ein Konfigurationsschema auf Basis von pydantic-settings deklarieren. Die Umgebungsvariablen folgen der Konvention TUNE_<PLUGIN>_<SCHLÜSSEL>:

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 entspricht $TUNE_ICECAST_HOST oder "localhost"
        ...

Die Variablen werden aus der Umgebung oder aus einer .env-Datei im Serververzeichnis gelesen.

Player-Ereignisse hooken

Für Anwendungsfälle, die eine feinere Kontrolle als der EventBus erfordern, können Plugins Hooks direkt am Player jeder Zone registrieren:

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)

Der Unterschied zum EventBus: Die Player-Hooks werden synchron innerhalb der Wiedergabe-Pipeline aufgerufen, was ein Eingreifen vor dem Eintreten einer Aktion erlaubt (etwa das Vorbereiten eines Buffers vor dem Start eines Titels).

Auf PyPI veröffentlichen

Ihr Plugin ist fertig? Teilen Sie es mit der Community, indem Sie es auf PyPI veröffentlichen.

Namenskonvention

Tune-Plugins folgen für den Namen des PyPI-Packages der Konvention tune-plugin-<name>:

  • tune-plugin-icecast
  • tune-plugin-lastfm
  • tune-plugin-prometheus

Der interne Name des Plugins (Attribut name) bleibt kurz: icecast, lastfm, prometheus.

Packaging

Stellen Sie sicher, dass Ihre pyproject.toml vollständig ist:

[project]
name = "tune-plugin-icecast"
version = "0.1.0"
description = "Tune-Plugin zum Streamen nach Icecast"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.11"
dependencies = [
    "tune-server>=0.7.90",
]

[project.urls]
Homepage = "https://github.com/ihr-name/tune-plugin-icecast"

[project.entry-points."tune_server.plugins"]
icecast = "tune_icecast:IcecastPlugin"

Veröffentlichen:

pip install build twine
python -m build
twine upload dist/*

Anwender können Ihr Plugin anschließend mit einem einzigen Befehl installieren:

pip install tune-plugin-icecast

Ein Neustart des Servers genügt, damit das Plugin automatisch erkannt und aktiviert wird.

Versionskompatibilität

Deklarieren Sie in Ihren Abhängigkeiten immer den kompatiblen Versionsbereich von Tune. Der Server stellt eine PROTOCOL_VERSION bereit, die Ihr Plugin beim setup prüfen kann:

from tune_server.plugins.api import PROTOCOL_VERSION

async def setup(self, ctx) -> None:
    if PROTOCOL_VERSION != "1":
        raise RuntimeError(
            f"Plugin {self.name} benötigt PROTOCOL_VERSION 1, "
            f"gefunden {PROTOCOL_VERSION}"
        )

Best Practices

  • Fehlerbehandlung — Exceptions in einem Plugin dürfen niemals den Server zum Absturz bringen. Tune fängt Fehler beim Laden und in den Hooks ab, aber kümmern Sie sich in Ihrer Fachlogik um eigene try/except.
  • Logging — Verwenden Sie logging.getLogger("tune.plugin.<name>"), damit Ihre Logs konsistent mit denen des Servers sind.
  • Async — Die Methoden setup, teardown sowie die Event-Callbacks müssen async sein. Tune ist vollständig asynchron (asyncio).
  • Tests — Testen Sie Ihr Plugin unabhängig mit pytest und pytest-asyncio. Mocken Sie den PluginContext für Unit-Tests.
  • Präfixe — Wenn Ihr Plugin Tabellen in der Datenbank anlegt, versehen Sie sie mit dem Namen des Plugins als Präfix (z. B. icecast_sessions), um Kollisionen zu vermeiden.
  • Ressourcen — Geben Sie Ihre Ressourcen (Netzwerkverbindungen, offene Dateien, asyncio-Tasks) immer in teardown frei.

Fazit

Mit wenigen Zeilen Python und einer pyproject.toml können Sie Tune Server für Ihre speziellen Bedürfnisse erweitern: Smart-Home-Integrationen, Scrobbling, exotische Audioausgänge, individuelle Dashboards ... Die Möglichkeiten sind offen.

Um weiterzugehen:

Wir sind gespannt, was Sie bauen werden.