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