Files
retr0 7b56fdc2bb 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>
2026-04-06 11:52:15 +02:00

203 lines
7.0 KiB
Python

"""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