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:
202
custom_components/busch_radio_spotify/media_player.py
Normal file
202
custom_components/busch_radio_spotify/media_player.py
Normal 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
|
||||
Reference in New Issue
Block a user