Initiales Release: Busch-Radio Spotify Bridge v1.0.0

Stellt Spotify-Musik via Spotify Connect als HTTP-MP3-Stream bereit,
den das Busch-Jäger Unterputz-Internetradio direkt abspielen kann.

Komponenten:
- ha-addon/: Home Assistant Add-on (librespot + ffmpeg + Icecast2)
- custom_components/: Optionale HA Integration mit Media-Player-Entity
- README.md: Vollständige Installations- und Konfigurationsanleitung
- example-config.yaml: Kommentierte Beispielkonfiguration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 11:52:15 +02:00
parent 5f3ebe9e9f
commit 7b56fdc2bb
14 changed files with 1186 additions and 0 deletions

22
.gitignore vendored Normal file
View File

@@ -0,0 +1,22 @@
# Python
__pycache__/
*.py[cod]
*.pyo
.pytest_cache/
# Docker
.docker/
# Editor
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Secrets (niemals committen!)
secrets.yaml
.env

359
README.md Normal file
View File

@@ -0,0 +1,359 @@
# Busch-Radio Spotify Bridge
Spotify-Musik auf dem Busch-Jäger Unterputz-Internetradio ohne Bluetooth.
## Problem
Das **Busch-Jäger Unterputz-Internetradio** (z.B. 8217 U) kann nur vordefinierte oder eigene Internetradio-Streams abspielen. Es hat kein Bluetooth und keine Spotify-Integration. Spotify-Musik lässt sich damit direkt nicht wiedergeben.
## Lösung
Dieses Projekt enthält:
1. **Home Assistant Add-on** erzeugt ein virtuelles **Spotify Connect-Gerät** im Netzwerk und stellt das Audio als HTTP-MP3-Stream bereit
2. **Custom Component** (optional) zeigt den Stream-Status als Media-Player-Entity in HA an
```
Spotify-App
│ Spotify Connect (WiFi)
┌─────────────────────────────────────────────┐
│ Home Assistant Add-on │
│ │
│ librespot ──(PCM)──▶ ffmpeg ──(MP3)──▶ Icecast │
│ (Spotify Connect) (Kodierung) (HTTP-Server) │
└─────────────────────────────────────────────┘
│ HTTP-Stream: http://ha-ip:8000/stream.mp3
Busch-Jäger Radio
```
---
## Voraussetzungen
| Anforderung | Details |
|---|---|
| Home Assistant | OS, Supervised oder Container (Add-on benötigt Supervisor) |
| Spotify Premium | Pflicht für Spotify Connect |
| Busch-Jäger Radio | Muss benutzerdefinierte Stream-URLs unterstützen |
| Netzwerk | HA und Smartphone im gleichen WLAN/LAN |
> **Wichtig:** Die kostenlose Version von Spotify unterstützt **kein** Spotify Connect. Ein Premium-Abo ist erforderlich.
---
## Installation
### 1. Add-on installieren
#### Option A: Repository in HA hinzufügen
1. Navigiere zu **Einstellungen → Add-ons → Add-on Store**
2. Klicke oben rechts auf **⋮ → Repositories**
3. Trage die URL dieses Repositories ein
4. Das Add-on **Busch-Radio Spotify Bridge** erscheint im Store
5. Installiere es (Kompilierung von librespot dauert ~10-15 Minuten)
#### Option B: Manuell (für Entwickler)
```bash
# Repository klonen
git clone https://gitea.example.com/retr0/busch-radio-spotify.git
# ha-addon-Verzeichnis in HA Add-on-Pfad kopieren
# (unter Home Assistant OS: /addons/busch_radio_spotify)
cp -r busch-radio-spotify/ha-addon /addons/busch_radio_spotify
# In HA: Einstellungen → Add-ons → Add-on Store → ⋮ → Lokale Add-ons prüfen
```
### 2. Add-on konfigurieren
Navigiere zu **Einstellungen → Add-ons → Busch-Radio Spotify Bridge → Konfiguration**:
```yaml
device_name: "Busch-Radio" # Name in der Spotify-App
bitrate: 320 # 96 / 160 / 320 kbps
stream_port: 8000 # HTTP-Port des Streams
stream_mount: "/stream.mp3" # URL-Pfad
icecast_password: "geheim" # Sicherheitspasswort (bitte ändern!)
username: "" # Spotify-Konto (optional, s.u.)
password: "" # Spotify-Passwort (optional, s.u.)
```
> **Hinweis zu `username`/`password`:** Diese Felder sind optional. Ohne Zugangsdaten
> wird das Gerät über **Zeroconf/mDNS** automatisch im Netzwerk gefunden das ist
> die empfohlene Methode. Zugangsdaten werden nur benötigt, wenn mDNS im Netzwerk
> blockiert ist.
### 3. Netzwerk-Port freigeben
Im Tab **Netzwerk** des Add-ons den Port `8000` auf `8000` mappen (oder einen anderen freien Port wählen).
### 4. Add-on starten
Im Tab **Info** auf **Starten** klicken. Die Logs zeigen:
```
[INFO] Busch-Radio Spotify Bridge v1.0.0
[INFO] Stream-URL für das Busch-Jäger Radio:
[INFO] http://<homeassistant-ip>:8000/stream.mp3
[INFO] Wähle 'Busch-Radio' in der Spotify-App als Abspielgerät.
```
### 5. Stream im Busch-Jäger Radio eintragen
Im Radio-Menü (je nach Modell verschieden) eine neue Internetradio-Station anlegen:
```
URL: http://<homeassistant-ip>:8000/stream.mp3
```
Ersetze `<homeassistant-ip>` durch die tatsächliche IP-Adresse deines Home Assistant, z.B. `192.168.1.100`.
> **Tipp:** Die IP-Adresse findest du in HA unter **Einstellungen → System → Netzwerk**.
### 6. Optional: Custom Component installieren
Für die Status-Anzeige in HA:
```bash
# custom_components-Verzeichnis in HA config kopieren
cp -r custom_components/busch_radio_spotify \
/config/custom_components/busch_radio_spotify
# HA neu starten
```
Dann unter **Einstellungen → Integrationen → Integration hinzufügen → Busch-Radio Spotify Bridge** einrichten.
---
## Bedienung
1. Öffne die **Spotify-App** auf Smartphone oder Computer
2. Spiele ein Lied ab
3. Tippe auf das **Gerät-Symbol** (unten in der App)
4. Wähle **"Busch-Radio"** (oder deinen konfigurierten Gerätenamen)
5. Die Musik wird automatisch über den Stream an das Radio übertragen
> Das Radio muss die Stream-URL bereits abgespielt haben oder neu eingestellt werden,
> sobald Spotify mit dem Abspielen beginnt.
---
## Einrichtung der Stream-URL im Radio (Modellabhängig)
### Busch-Jäger 8217 U / ähnliche Modelle
1. Ins Radio-Menü navigieren (meist über die Benutzeroberfläche oder App)
2. **Meine Sender → Sender hinzufügen** oder **Internetradio → Eigene URL**
3. URL eingeben: `http://192.168.1.100:8000/stream.mp3`
4. Namen vergeben: z.B. "Spotify"
5. Speichern und Stream starten
### Alternativ: Über vTuner/Frontier Silicon Portal
Einige Busch-Jäger Modelle nutzen vTuner als Radio-Backend:
1. Auf `http://www.wifiradio-frontier.com` registrieren
2. Unter "Meine Sender" → "Sender hinzufügen" die Stream-URL eintragen
3. Im Radio synchronisieren
---
## Custom Component Media Player Entity
Nach der Installation der Custom Component erscheint ein neuer Entity in HA:
**Entity-ID:** `media_player.busch_radio_192_168_1_100`
| State | Bedeutung |
|---|---|
| `playing` | Mindestens ein Hörer verbunden |
| `idle` | Quelle aktiv, aber kein Hörer |
| `off` | Icecast nicht erreichbar |
**Attribute:**
| Attribut | Beschreibung |
|---|---|
| `stream_url` | Die URL für das Radio |
| `listeners` | Anzahl aktiver Hörer |
| `source_connected` | Ob librespot an Icecast sendet |
| `bitrate_kbps` | Aktuelle Bitrate |
**Dashboard-Karte (Beispiel):**
```yaml
type: media-control
entity: media_player.busch_radio_192_168_1_100
```
**Automatisierung Radio starten wenn Spotify beginnt:**
```yaml
automation:
alias: "Busch-Radio wenn Spotify spielt"
trigger:
platform: state
entity_id: media_player.busch_radio_192_168_1_100
to: "playing"
action:
service: notify.mobile_app
data:
message: "Spotify spielt auf dem Busch-Radio"
```
---
## Technische Details
### Komponenten
| Komponente | Funktion | Version |
|---|---|---|
| [librespot](https://github.com/librespot-org/librespot) | Spotify Connect Client | Aktuell via cargo |
| [ffmpeg](https://ffmpeg.org) | PCM → MP3 Transkodierung | Via Alpine apk |
| [Icecast](https://icecast.org) | HTTP-Stream-Server | Via Alpine apk |
### Signalfluss im Detail
```
Spotify CDN → (verschlüsselt, OGG Vorbis)
→ librespot (dekodiert, gibt PCM aus)
→ stdout (44100 Hz, S16LE, Stereo = 176.4 KB/s)
→ ffmpeg stdin
→ MP3-Kodierung (libmp3lame, 320 kbps ≈ 40 KB/s)
→ Icecast (HTTP-Streaming-Server)
→ Busch-Jäger Radio (HTTP-Client)
```
### PCM-Format von librespot
- Sample Rate: 44100 Hz
- Bit-Tiefe: 16-bit signed, Little-Endian
- Kanäle: 2 (Stereo)
- Rohe Datenrate: ~172 KB/s
### Restart-Verhalten
Die Pipeline `librespot | ffmpeg` wird bei Absturz automatisch nach 5 Sekunden neu gestartet. Icecast bleibt davon unberührt.
---
## Fehlerbehebung
### Das Gerät erscheint nicht in der Spotify-App
**Ursachen & Lösungen:**
- **Add-on läuft nicht** → Add-on-Status in HA prüfen
- **Anderes WLAN/Subnetz** → Smartphone und HA müssen im selben Netzwerk sein
- **mDNS blockiert** → Spotify-Zugangsdaten in der Add-on-Konfig eintragen
- **Gerätename bereits vergeben** → `device_name` in der Konfig ändern
- **Firewall** → Port 5353 (mDNS/UDP) und Port 8000 (TCP) freigeben
### Kein Audio / Stream leer
```bash
# Im Browser öffnen sollte die Icecast-Statusseite zeigen:
http://homeassistant-ip:8000
# Wenn das funktioniert, aber kein Audio:
# → In Spotify das Gerät "Busch-Radio" auswählen und abspielen
```
### Stream bricht ab beim Pausieren
Das ist das erwartete Verhalten: Wenn Spotify pausiert, sendet librespot nichts mehr an Icecast. Das Radio verliert die Verbindung.
**Lösung:** Nach dem Fortsetzen der Wiedergabe in Spotify den Stream im Radio kurz stoppen und wieder starten.
*Zukünftige Verbesserung: Stille-Generierung bei Pause geplant.*
### ffmpeg startet nicht / Kodierungsfehler
```bash
# Add-on Logs prüfen auf Zeilen wie:
[WARNING] [ffmpeg] ...
# Häufige Ursachen:
# - libmp3lame nicht installiert (ffmpeg ohne MP3-Support)
# - Icecast-Passwort stimmt nicht überein
# - Port 8000 bereits belegt
```
### Icecast antwortet mit 401 (in der Custom Component)
Das Passwort in der Custom Component-Konfiguration stimmt nicht mit `icecast_password` im Add-on überein. Integration neu einrichten.
---
## Rechtliche Hinweise
### librespot und Spotify ToS
- Dieses Projekt nutzt **librespot**, eine quelloffene Reimplementierung des Spotify-Clients
- Librespot ist ein **Spotify Connect-Client** und kein Downloader/Recorder
- Audio wird **nicht gespeichert** nur live gestreamt
- Die Nutzung im privaten Heimbereich ist technisch weitgehend akzeptiert, kann aber gegen **Abschnitt 2.5** von Spotifys Nutzungsbedingungen verstoßen
- **Spotify Premium ist erforderlich** kostenlose Konten funktionieren nicht
- Dieses Projekt ist ausschließlich für den **persönlichen Privatgebrauch** gedacht
### Verwendete Open-Source-Lizenzen
- **librespot** MIT License
- **ffmpeg** LGPL 2.1+ / GPL 2+
- **Icecast** GPL 2
---
## Projektstruktur
```
busch-radio-spotify/
├── ha-addon/ # Home Assistant Add-on
│ ├── Dockerfile # Multi-Stage Build (Rust + Alpine)
│ ├── config.yaml # Add-on Manifest & Optionen
│ ├── build.yaml # Multi-Arch Build-Konfiguration
│ └── run.sh # Startup-Script (Icecast + librespot + ffmpeg)
├── custom_components/ # Optionale HA Integration
│ └── busch_radio_spotify/
│ ├── __init__.py # Integration Setup
│ ├── manifest.json # Integration Manifest
│ ├── media_player.py # Media Player Entity (Icecast-Polling)
│ ├── config_flow.py # Grafische Einrichtung
│ ├── strings.json # UI-Texte
│ └── translations/
│ ├── de.json # Deutsch
│ └── en.json # Englisch
├── example-config.yaml # Kommentierte Beispielkonfiguration
└── README.md # Diese Datei
```
---
## Geplante Verbesserungen
- [ ] Stille-Generierung bei Spotify-Pause (verhindert Stream-Abbruch im Radio)
- [ ] Lautstärke-Steuerung über HA (librespot unterstützt DBUS)
- [ ] Track-Metadaten (Titel, Künstler) via librespot-Event-System
- [ ] OGG-Stream Option für bessere Qualität bei gleicher Bitrate
- [ ] Automatischer Start des Radios beim Verbinden (via HA-Automation)
---
## Beitragen
Issues und Pull Requests sind willkommen auf:
`https://gitea.example.com/retr0/busch-radio-spotify`
---
## Lizenz
MIT License siehe [LICENSE](LICENSE)

View File

@@ -0,0 +1,47 @@
"""Busch-Radio Spotify Bridge Home Assistant Custom Component.
Diese Integration stellt einen Media-Player-Entity bereit, der den Status
des Icecast-Streams (vom Add-on) in der HA-Oberfläche anzeigt.
Voraussetzung: Das 'Busch-Radio Spotify Bridge' Add-on muss laufen.
"""
from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
_LOGGER = logging.getLogger(__name__)
DOMAIN = "busch_radio_spotify"
PLATFORMS = ["media_player"]
CONF_HOST = "host"
CONF_PORT = "port"
CONF_MOUNT = "mount"
CONF_PASSWORD = "password"
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Integration aus einem Config-Entry aufsetzen."""
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = entry.data
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
_LOGGER.info(
"Busch-Radio Spotify Bridge verbunden: %s:%s%s",
entry.data[CONF_HOST],
entry.data[CONF_PORT],
entry.data[CONF_MOUNT],
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Config-Entry entladen."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id, None)
return unload_ok

View File

@@ -0,0 +1,109 @@
"""Config Flow für die Busch-Radio Spotify Bridge Integration.
Führt den Nutzer durch die Einrichtung in der HA-Oberfläche.
Fragt nach:
- Hostname/IP des HA-Hosts (normalerweise 'localhost' wenn Add-on läuft)
- Port (Standard: 8000)
- Stream-Mount-Pfad (Standard: /stream.mp3)
- Icecast Admin-Passwort (identisch mit 'icecast_password' in der Add-on Konfig)
"""
from __future__ import annotations
import logging
import aiohttp
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from . import CONF_HOST, CONF_MOUNT, CONF_PASSWORD, CONF_PORT, DOMAIN
_LOGGER = logging.getLogger(__name__)
DEFAULT_HOST = "localhost"
DEFAULT_PORT = 8000
DEFAULT_MOUNT = "/stream.mp3"
DEFAULT_PASSWORD = "busch-radio-geheim"
async def _test_icecast_connection(
hass: HomeAssistant, host: str, port: int, password: str
) -> str | None:
"""Verbindung zu Icecast testen.
Gibt None zurück wenn alles ok, sonst einen Fehlercode.
"""
session = async_get_clientsession(hass)
url = f"http://{host}:{port}/admin/stats"
auth = aiohttp.BasicAuth("admin", password)
try:
async with session.get(
url,
auth=auth,
timeout=aiohttp.ClientTimeout(total=5),
) as resp:
if resp.status == 200:
return None
if resp.status == 401:
return "invalid_auth"
return "cannot_connect"
except aiohttp.ClientConnectorError:
return "cannot_connect"
except Exception:
return "unknown"
class BuschRadioSpotifyConfigFlow(
config_entries.ConfigFlow, domain=DOMAIN
):
"""Config Flow für Busch-Radio Spotify Bridge."""
VERSION = 1
async def async_step_user(
self, user_input: dict | None = None
) -> config_entries.FlowResult:
"""Ersten Einrichtungsschritt anzeigen."""
errors: dict[str, str] = {}
if user_input is not None:
error = await _test_icecast_connection(
self.hass,
user_input[CONF_HOST],
user_input[CONF_PORT],
user_input[CONF_PASSWORD],
)
if error is None:
# Doppelte Einrichtung verhindern
await self.async_set_unique_id(
f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}"
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"Busch-Radio ({user_input[CONF_HOST]}:{user_input[CONF_PORT]})",
data=user_input,
)
else:
errors["base"] = error
schema = vol.Schema(
{
vol.Required(CONF_HOST, default=DEFAULT_HOST): str,
vol.Required(CONF_PORT, default=DEFAULT_PORT): vol.All(
vol.Coerce(int), vol.Range(min=1, max=65535)
),
vol.Required(CONF_MOUNT, default=DEFAULT_MOUNT): str,
vol.Required(CONF_PASSWORD, default=DEFAULT_PASSWORD): str,
}
)
return self.async_show_form(
step_id="user",
data_schema=schema,
errors=errors,
)

View File

@@ -0,0 +1,12 @@
{
"domain": "busch_radio_spotify",
"name": "Busch-Radio Spotify Bridge",
"version": "1.0.0",
"documentation": "https://gitea.example.com/retr0/busch-radio-spotify",
"issue_tracker": "https://gitea.example.com/retr0/busch-radio-spotify/issues",
"requirements": [],
"dependencies": [],
"codeowners": ["@retr0"],
"iot_class": "local_polling",
"config_flow": true
}

View File

@@ -0,0 +1,202 @@
"""Media-Player-Entity für den Busch-Radio Spotify Bridge Stream.
Pollt den Icecast-Admin-Endpoint alle 30 Sekunden und zeigt:
- Ob der Stream aktiv ist (Quelle verbunden)
- Anzahl der aktuellen Hörer
- Die Stream-URL zum Eintragen ins Radio
Der Entity-Status entspricht dem HA MediaPlayerState:
- PLAYING → mindestens ein Hörer verbunden
- IDLE → Quelle aktiv, aber kein Hörer
- OFF → Icecast nicht erreichbar / Add-on gestoppt
"""
from __future__ import annotations
import logging
import xml.etree.ElementTree as ET
from datetime import timedelta
import aiohttp
from homeassistant.components.media_player import (
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import CONF_HOST, CONF_MOUNT, CONF_PASSWORD, CONF_PORT, DOMAIN
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=30)
REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=5)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Media-Player-Entity aus Config-Entry anlegen."""
async_add_entities([BuschRadioMediaPlayer(hass, entry)], update_before_add=True)
class BuschRadioMediaPlayer(MediaPlayerEntity):
"""Repräsentiert den Busch-Radio Spotify Bridge Stream.
Zeigt den Icecast-Stream-Status in der HA-Oberfläche an.
Die eigentliche Wiedergabe wird über die Spotify-App gesteuert.
"""
_attr_icon = "mdi:radio"
_attr_has_entity_name = False
_attr_should_poll = True
# Dieser Player hat keine eigenen Steuer-Features (Spotify-App übernimmt das),
# aber wir exportieren PLAY/PAUSE als Dummy damit HA ihn als Media-Player erkennt.
_attr_supported_features = MediaPlayerEntityFeature(0)
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self._hass = hass
self._entry = entry
self._host: str = entry.data[CONF_HOST]
self._port: int = entry.data[CONF_PORT]
self._mount: str = entry.data[CONF_MOUNT]
self._password: str = entry.data[CONF_PASSWORD]
self._listeners: int = 0
self._source_connected: bool = False
self._available: bool = False
self._bitrate: str | None = None
self._audio_codec: str | None = None
self._attr_name = f"Busch-Radio ({self._host})"
self._attr_unique_id = f"busch_radio_{self._host}_{self._port}"
# ── Properties ───────────────────────────────────────────────────────────
@property
def stream_url(self) -> str:
"""Öffentliche Stream-URL."""
return f"http://{self._host}:{self._port}{self._mount}"
@property
def state(self) -> MediaPlayerState:
if not self._available:
return MediaPlayerState.OFF
if not self._source_connected:
return MediaPlayerState.IDLE
if self._listeners > 0:
return MediaPlayerState.PLAYING
return MediaPlayerState.IDLE
@property
def available(self) -> bool:
return True # Entity immer verfügbar; Status spiegelt Icecast-Zustand
@property
def extra_state_attributes(self) -> dict:
return {
"stream_url": self.stream_url,
"listeners": self._listeners,
"source_connected": self._source_connected,
"bitrate_kbps": self._bitrate,
"codec": self._audio_codec,
"icecast_status_url": f"http://{self._host}:{self._port}",
}
@property
def media_title(self) -> str | None:
if self._source_connected:
return "Spotify"
return None
@property
def media_artist(self) -> str | None:
if self._listeners > 0:
return f"{self._listeners} Hörer"
return None
# ── Update ───────────────────────────────────────────────────────────────
async def async_update(self) -> None:
"""Icecast-Stats abfragen und Entity-Status aktualisieren."""
session = async_get_clientsession(self._hass)
stats_url = f"http://{self._host}:{self._port}/admin/stats"
auth = aiohttp.BasicAuth("admin", self._password)
try:
async with session.get(
stats_url, auth=auth, timeout=REQUEST_TIMEOUT
) as resp:
if resp.status == 200:
text = await resp.text()
self._parse_icecast_stats(text)
self._available = True
elif resp.status == 401:
_LOGGER.warning(
"Icecast: Authentifizierung fehlgeschlagen. "
"Passwort in der Integration prüfen."
)
self._available = False
else:
_LOGGER.debug(
"Icecast-Stats: HTTP %d von %s", resp.status, stats_url
)
self._available = False
except aiohttp.ClientConnectorError:
_LOGGER.debug(
"Icecast nicht erreichbar unter %s:%d (Add-on läuft?)",
self._host,
self._port,
)
self._available = False
self._source_connected = False
self._listeners = 0
except Exception as exc:
_LOGGER.warning("Fehler beim Abrufen der Icecast-Stats: %s", exc)
self._available = False
def _parse_icecast_stats(self, xml_text: str) -> None:
"""Icecast-XML-Statistiken parsen."""
try:
root = ET.fromstring(xml_text)
except ET.ParseError as exc:
_LOGGER.warning("Icecast-XML konnte nicht geparst werden: %s", exc)
return
# Suche nach unserem Mount-Point
self._source_connected = False
self._listeners = 0
self._bitrate = None
self._audio_codec = None
for source in root.findall("source"):
mount = source.get("mount", "")
if mount != self._mount:
continue
self._source_connected = True
listeners_el = source.find("listeners")
if listeners_el is not None and listeners_el.text:
try:
self._listeners = int(listeners_el.text)
except ValueError:
pass
bitrate_el = source.find("bitrate")
if bitrate_el is not None:
self._bitrate = bitrate_el.text
codec_el = source.find("audio_codecname")
if codec_el is not None:
self._audio_codec = codec_el.text
break

View File

@@ -0,0 +1,24 @@
{
"config": {
"step": {
"user": {
"title": "Busch-Radio Spotify Bridge",
"description": "Verbinde die Integration mit dem laufenden Busch-Radio Add-on.\nDas Passwort muss mit dem 'icecast_password' in der Add-on Konfiguration übereinstimmen.",
"data": {
"host": "Hostname oder IP-Adresse",
"port": "Icecast-Port",
"mount": "Stream-Mount-Pfad",
"password": "Icecast Admin-Passwort"
}
}
},
"error": {
"cannot_connect": "Verbindung zu Icecast fehlgeschlagen. Läuft das Add-on?",
"invalid_auth": "Falsches Passwort. Mit 'icecast_password' in der Add-on Konfig vergleichen.",
"unknown": "Unbekannter Fehler. Prüfe die Logs."
},
"abort": {
"already_configured": "Diese Icecast-Instanz ist bereits konfiguriert."
}
}
}

View File

@@ -0,0 +1,24 @@
{
"config": {
"step": {
"user": {
"title": "Busch-Radio Spotify Bridge",
"description": "Verbinde die Integration mit dem laufenden Busch-Radio Add-on.\nDas Passwort muss mit dem 'icecast_password' in der Add-on Konfiguration übereinstimmen.",
"data": {
"host": "Hostname oder IP-Adresse",
"port": "Icecast-Port",
"mount": "Stream-Mount-Pfad",
"password": "Icecast Admin-Passwort"
}
}
},
"error": {
"cannot_connect": "Verbindung zu Icecast fehlgeschlagen. Läuft das Add-on?",
"invalid_auth": "Falsches Passwort. Mit 'icecast_password' in der Add-on Konfig vergleichen.",
"unknown": "Unbekannter Fehler. Prüfe die Logs."
},
"abort": {
"already_configured": "Diese Icecast-Instanz ist bereits konfiguriert."
}
}
}

View File

@@ -0,0 +1,24 @@
{
"config": {
"step": {
"user": {
"title": "Busch-Radio Spotify Bridge",
"description": "Connect the integration to the running Busch-Radio add-on.\nThe password must match 'icecast_password' in the add-on configuration.",
"data": {
"host": "Hostname or IP address",
"port": "Icecast port",
"mount": "Stream mount path",
"password": "Icecast admin password"
}
}
},
"error": {
"cannot_connect": "Failed to connect to Icecast. Is the add-on running?",
"invalid_auth": "Wrong password. Compare with 'icecast_password' in the add-on config.",
"unknown": "Unknown error. Check the logs."
},
"abort": {
"already_configured": "This Icecast instance is already configured."
}
}
}

43
example-config.yaml Normal file
View File

@@ -0,0 +1,43 @@
# ================================================================
# Beispielkonfiguration: Busch-Radio Spotify Bridge Add-on
# ================================================================
#
# Diese Optionen werden in der HA Add-on Oberfläche eingetragen
# (Einstellungen → Add-ons → Busch-Radio Spotify Bridge → Konfiguration).
#
# Alternativ direkt in der YAML-Ansicht.
# ================================================================
# Name des virtuellen Spotify-Connect-Geräts.
# Erscheint in der Spotify-App unter "Geräte".
device_name: "Busch-Radio"
# Audio-Bitrate in kbps. Höhere Werte = bessere Qualität, mehr Bandbreite.
# Erlaubte Werte: 96, 160, 320
bitrate: 320
# Port auf dem der Icecast-Stream lauscht.
# Dieser Port muss im Add-on unter "Netzwerk" freigegeben sein.
stream_port: 8000
# Pfad des Streams. Wird an die URL angehängt.
# Empfehlung: mit .mp3 enden für maximale Kompatibilität.
stream_mount: "/stream.mp3"
# Passwort für Icecast (intern verwendet, nicht für Hörer sichtbar).
# Ändere diesen Wert aus Sicherheitsgründen!
icecast_password: "mein-sicheres-passwort"
# Optional: Spotify Premium Zugangsdaten.
# Wenn leer, wird Zeroconf/mDNS-Erkennung verwendet (empfohlen).
# Das Gerät wird dann automatisch in der Spotify-App im lokalen Netz gefunden.
username: ""
password: ""
# ================================================================
# Stream-URL für das Busch-Jäger Radio:
# http://<homeassistant-ip>:8000/stream.mp3
#
# Beispiel:
# http://192.168.1.100:8000/stream.mp3
# ================================================================

49
ha-addon/Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
ARG BUILD_FROM
# ============================================================
# Build stage: Librespot aus Rust-Quellcode kompilieren
# ============================================================
FROM rust:alpine AS librespot-builder
RUN apk add --no-cache \
musl-dev \
pkgconfig \
openssl-dev \
openssl-libs-static
# Librespot ohne Hardware-Audio-Backends kompilieren.
# --no-default-features entfernt ALSA/PulseAudio; der Pipe-Backend
# ist immer verfügbar und reicht für diesen Anwendungsfall.
RUN cargo install librespot \
--no-default-features \
--root /install
# ============================================================
# Runtime stage: HA-Basis-Image + ffmpeg + icecast
# ============================================================
FROM ${BUILD_FROM}
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# Abhängigkeiten installieren
# icecast liegt in Alpine's Community-Repository
RUN apk add --no-cache \
bash \
jq \
curl \
python3 \
ffmpeg \
&& apk add --no-cache \
--repository=https://dl-cdn.alpinelinux.org/alpine/edge/community \
icecast \
|| apk add --no-cache icecast
# Librespot-Binary aus dem Build-Stage übernehmen
COPY --from=librespot-builder /install/bin/librespot /usr/local/bin/librespot
RUN chmod +x /usr/local/bin/librespot
# Add-on Dateien kopieren
COPY run.sh /run.sh
RUN chmod a+x /run.sh
CMD ["/run.sh"]

11
ha-addon/build.yaml Normal file
View File

@@ -0,0 +1,11 @@
---
# Multi-Architektur Build-Konfiguration für das HA Add-on.
# Jede Architektur bekommt das passende Alpine-basierte HA-Basisimage.
build_from:
aarch64: ghcr.io/home-assistant/aarch64-base:latest
amd64: ghcr.io/home-assistant/amd64-base:latest
armhf: ghcr.io/home-assistant/armhf-base:latest
armv7: ghcr.io/home-assistant/armv7-base:latest
args:
BUILD_FROM: ""

51
ha-addon/config.yaml Normal file
View File

@@ -0,0 +1,51 @@
---
name: "Busch-Radio Spotify Bridge"
version: "1.0.0"
slug: busch_radio_spotify
description: >
Streamt Spotify-Musik als Internet-Radio-Stream für das Busch-Jäger Unterputz-Internetradio.
Das Add-on erzeugt ein virtuelles Spotify Connect-Gerät (via librespot) und stellt das Audio
als MP3-Stream über Icecast bereit.
url: "https://gitea.example.com/retr0/busch-radio-spotify"
arch:
- aarch64
- amd64
- armhf
- armv7
init: false
homeassistant: "2023.1.0"
# ---- Konfigurationsoptionen ----
options:
device_name: "Busch-Radio"
bitrate: 320
stream_port: 8000
stream_mount: "/stream.mp3"
icecast_password: "busch-radio-geheim"
username: ""
password: ""
schema:
device_name: str
bitrate: "list(96|160|320)"
stream_port: port
stream_mount: str
icecast_password: str
username: str?
password: password?
# ---- Netzwerk ----
ports:
"8000/tcp": 8000
ports_description:
"8000/tcp": "Icecast HTTP-Stream (für das Radio)"
# ---- UI ----
panel_icon: mdi:radio
panel_title: "Busch-Radio"
# ---- Sonstiges ----
map:
- config:rw
- share:rw

209
ha-addon/run.sh Normal file
View File

@@ -0,0 +1,209 @@
#!/usr/bin/with-contenv bashio
# ==============================================================================
# Busch-Radio Spotify Bridge Startup-Script
#
# Startet:
# 1. Icecast2 HTTP-Stream-Server
# 2. librespot Virtueller Spotify Connect-Empfänger (gibt PCM auf stdout aus)
# 3. ffmpeg Kodiert PCM → MP3 und schickt es als Icecast-Quelle
#
# Bei Absturz wird die komplette Pipeline nach 5 Sekunden neu gestartet.
# ==============================================================================
set -u
# ── Konfiguration lesen ───────────────────────────────────────────────────────
DEVICE_NAME=$(bashio::config 'device_name')
BITRATE=$(bashio::config 'bitrate')
STREAM_PORT=$(bashio::config 'stream_port')
STREAM_MOUNT=$(bashio::config 'stream_mount')
ICECAST_PASSWORD=$(bashio::config 'icecast_password')
bashio::log.info "╔══════════════════════════════════════════════╗"
bashio::log.info "║ Busch-Radio Spotify Bridge v1.0.0 ║"
bashio::log.info "╚══════════════════════════════════════════════╝"
bashio::log.info "Gerätename : ${DEVICE_NAME}"
bashio::log.info "Bitrate : ${BITRATE} kbps"
bashio::log.info "Stream-Port : ${STREAM_PORT}"
bashio::log.info "Stream-Pfad : ${STREAM_MOUNT}"
# ── Verzeichnisse anlegen ─────────────────────────────────────────────────────
mkdir -p /var/log/icecast /run/icecast /tmp/busch-radio
# ── Icecast-Webroot ermitteln ─────────────────────────────────────────────────
# Alpine und Debian legen Icecast in unterschiedliche Pfade
ICECAST_WEBROOT=""
for candidate in /usr/share/icecast /usr/share/icecast2; do
if [ -d "$candidate" ]; then
ICECAST_WEBROOT="$candidate"
break
fi
done
if [ -z "$ICECAST_WEBROOT" ]; then
bashio::log.warning "Icecast-Webroot nicht gefunden, verwende /usr/share/icecast"
ICECAST_WEBROOT="/usr/share/icecast"
mkdir -p "${ICECAST_WEBROOT}/web" "${ICECAST_WEBROOT}/admin"
fi
bashio::log.debug "Icecast-Webroot: ${ICECAST_WEBROOT}"
# ── Icecast-Konfiguration generieren ─────────────────────────────────────────
bashio::log.info "Icecast-Konfiguration wird erstellt..."
cat > /tmp/busch-radio/icecast.xml <<ICECAST_EOF
<icecast>
<location>Home Assistant</location>
<admin>admin@localhost</admin>
<limits>
<clients>10</clients>
<sources>2</sources>
<queue-size>524288</queue-size>
<client-timeout>30</client-timeout>
<header-timeout>15</header-timeout>
<source-timeout>10</source-timeout>
<burst-on-connect>0</burst-on-connect>
<burst-size>65536</burst-size>
</limits>
<authentication>
<source-password>${ICECAST_PASSWORD}</source-password>
<relay-password>${ICECAST_PASSWORD}</relay-password>
<admin-user>admin</admin-user>
<admin-password>${ICECAST_PASSWORD}</admin-password>
</authentication>
<hostname>0.0.0.0</hostname>
<listen-socket>
<port>${STREAM_PORT}</port>
</listen-socket>
<mount>
<mount-name>${STREAM_MOUNT}</mount-name>
<public>0</public>
<stream-name>Busch-Radio Spotify Bridge</stream-name>
<stream-description>Spotify via Home Assistant</stream-description>
<bitrate>${BITRATE}</bitrate>
<type>audio/mpeg</type>
</mount>
<fileserve>1</fileserve>
<paths>
<basedir>${ICECAST_WEBROOT}</basedir>
<logdir>/var/log/icecast</logdir>
<webroot>${ICECAST_WEBROOT}/web</webroot>
<adminroot>${ICECAST_WEBROOT}/admin</adminroot>
<pidfile>/run/icecast/icecast.pid</pidfile>
</paths>
<logging>
<!-- "-" loggt auf stderr → wird von HA erfasst -->
<accesslog>-</accesslog>
<errorlog>-</errorlog>
<loglevel>2</loglevel>
<logsize>10000</logsize>
</logging>
<security>
<chroot>0</chroot>
</security>
</icecast>
ICECAST_EOF
# ── Icecast starten ───────────────────────────────────────────────────────────
bashio::log.info "Icecast wird gestartet (Port: ${STREAM_PORT})..."
icecast -c /tmp/busch-radio/icecast.xml &
ICECAST_PID=$!
# Kurz warten bis Icecast hochgefahren ist
sleep 3
if ! kill -0 "${ICECAST_PID}" 2>/dev/null; then
bashio::log.fatal "Icecast konnte nicht gestartet werden! Prüfe die Konfiguration."
exit 1
fi
bashio::log.info "Icecast läuft (PID: ${ICECAST_PID})"
# ── Librespot-Argumente zusammenstellen ───────────────────────────────────────
LIBRESPOT_ARGS=(
"--name" "${DEVICE_NAME}"
"--bitrate" "${BITRATE}"
"--backend" "pipe"
"--disable-audio-cache"
"--initial-volume" "100"
)
# Optionale Spotify-Zugangsdaten
SP_USER=$(bashio::config 'username')
SP_PASS=$(bashio::config 'password')
if [ -n "${SP_USER}" ]; then
LIBRESPOT_ARGS+=("--username" "${SP_USER}")
bashio::log.info "Spotify-Konto: ${SP_USER}"
else
bashio::log.info "Kein Spotify-Konto konfiguriert (Zeroconf/mDNS-Erkennung aktiv)"
fi
if [ -n "${SP_PASS}" ]; then
LIBRESPOT_ARGS+=("--password" "${SP_PASS}")
fi
# ── Icecast-Source-URL ────────────────────────────────────────────────────────
ICECAST_SOURCE_URL="icecast://source:${ICECAST_PASSWORD}@localhost:${STREAM_PORT}${STREAM_MOUNT}"
# ── Stream-URL ausgeben ───────────────────────────────────────────────────────
bashio::log.info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
bashio::log.info "Stream-URL für das Busch-Jäger Radio:"
bashio::log.info " http://<homeassistant-ip>:${STREAM_PORT}${STREAM_MOUNT}"
bashio::log.info ""
bashio::log.info "Icecast-Statusseite:"
bashio::log.info " http://<homeassistant-ip>:${STREAM_PORT}"
bashio::log.info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
bashio::log.info "Wähle '${DEVICE_NAME}' in der Spotify-App als Abspielgerät."
bashio::log.info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# ── Aufräumen bei SIGTERM ─────────────────────────────────────────────────────
cleanup() {
bashio::log.info "Add-on wird beendet..."
kill "${ICECAST_PID}" 2>/dev/null || true
exit 0
}
trap cleanup SIGTERM SIGINT
# ── Haupt-Schleife: Pipeline starten und bei Absturz neu starten ──────────────
#
# Signalfluss:
# librespot (Spotify Connect) → stdout (raw PCM 44100Hz/S16LE/Stereo)
# ↓
# ffmpeg (PCM → MP3 Encoding) → Icecast (HTTP-Stream)
# ↓
# Busch-Jäger Radio (HTTP-Client)
#
# Wenn entweder librespot oder ffmpeg abstürzt, wird die komplette Pipeline
# nach RESTART_DELAY Sekunden neu gestartet.
RESTART_DELAY=5
ATTEMPT=0
while true; do
ATTEMPT=$((ATTEMPT + 1))
bashio::log.info "Pipeline-Start (Versuch ${ATTEMPT})..."
librespot "${LIBRESPOT_ARGS[@]}" \
| ffmpeg \
-hide_banner \
-loglevel warning \
-f s16le -ar 44100 -ac 2 \
-i pipe:0 \
-codec:a libmp3lame \
-b:a "${BITRATE}k" \
-q:a 2 \
-f mp3 \
"${ICECAST_SOURCE_URL}" \
|| true
bashio::log.warning "Pipeline beendet. Neustart in ${RESTART_DELAY}s..."
sleep "${RESTART_DELAY}"
done