From 7b56fdc2bb009e66881521ed6e603ec48e511432 Mon Sep 17 00:00:00 2001 From: retr0 Date: Mon, 6 Apr 2026 11:52:15 +0200 Subject: [PATCH] Initiales Release: Busch-Radio Spotify Bridge v1.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 22 ++ README.md | 359 ++++++++++++++++++ .../busch_radio_spotify/__init__.py | 47 +++ .../busch_radio_spotify/config_flow.py | 109 ++++++ .../busch_radio_spotify/manifest.json | 12 + .../busch_radio_spotify/media_player.py | 202 ++++++++++ .../busch_radio_spotify/strings.json | 24 ++ .../busch_radio_spotify/translations/de.json | 24 ++ .../busch_radio_spotify/translations/en.json | 24 ++ example-config.yaml | 43 +++ ha-addon/Dockerfile | 49 +++ ha-addon/build.yaml | 11 + ha-addon/config.yaml | 51 +++ ha-addon/run.sh | 209 ++++++++++ 14 files changed, 1186 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 custom_components/busch_radio_spotify/__init__.py create mode 100644 custom_components/busch_radio_spotify/config_flow.py create mode 100644 custom_components/busch_radio_spotify/manifest.json create mode 100644 custom_components/busch_radio_spotify/media_player.py create mode 100644 custom_components/busch_radio_spotify/strings.json create mode 100644 custom_components/busch_radio_spotify/translations/de.json create mode 100644 custom_components/busch_radio_spotify/translations/en.json create mode 100644 example-config.yaml create mode 100644 ha-addon/Dockerfile create mode 100644 ha-addon/build.yaml create mode 100644 ha-addon/config.yaml create mode 100644 ha-addon/run.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..92536cf --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..81e2084 --- /dev/null +++ b/README.md @@ -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://: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://:8000/stream.mp3 +``` + +Ersetze `` 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) diff --git a/custom_components/busch_radio_spotify/__init__.py b/custom_components/busch_radio_spotify/__init__.py new file mode 100644 index 0000000..3620265 --- /dev/null +++ b/custom_components/busch_radio_spotify/__init__.py @@ -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 diff --git a/custom_components/busch_radio_spotify/config_flow.py b/custom_components/busch_radio_spotify/config_flow.py new file mode 100644 index 0000000..d88b9d5 --- /dev/null +++ b/custom_components/busch_radio_spotify/config_flow.py @@ -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, + ) diff --git a/custom_components/busch_radio_spotify/manifest.json b/custom_components/busch_radio_spotify/manifest.json new file mode 100644 index 0000000..f4fa39f --- /dev/null +++ b/custom_components/busch_radio_spotify/manifest.json @@ -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 +} diff --git a/custom_components/busch_radio_spotify/media_player.py b/custom_components/busch_radio_spotify/media_player.py new file mode 100644 index 0000000..5add579 --- /dev/null +++ b/custom_components/busch_radio_spotify/media_player.py @@ -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 diff --git a/custom_components/busch_radio_spotify/strings.json b/custom_components/busch_radio_spotify/strings.json new file mode 100644 index 0000000..0be249d --- /dev/null +++ b/custom_components/busch_radio_spotify/strings.json @@ -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." + } + } +} diff --git a/custom_components/busch_radio_spotify/translations/de.json b/custom_components/busch_radio_spotify/translations/de.json new file mode 100644 index 0000000..0be249d --- /dev/null +++ b/custom_components/busch_radio_spotify/translations/de.json @@ -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." + } + } +} diff --git a/custom_components/busch_radio_spotify/translations/en.json b/custom_components/busch_radio_spotify/translations/en.json new file mode 100644 index 0000000..a91ed47 --- /dev/null +++ b/custom_components/busch_radio_spotify/translations/en.json @@ -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." + } + } +} diff --git a/example-config.yaml b/example-config.yaml new file mode 100644 index 0000000..552a200 --- /dev/null +++ b/example-config.yaml @@ -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://:8000/stream.mp3 +# +# Beispiel: +# http://192.168.1.100:8000/stream.mp3 +# ================================================================ diff --git a/ha-addon/Dockerfile b/ha-addon/Dockerfile new file mode 100644 index 0000000..aa82a6b --- /dev/null +++ b/ha-addon/Dockerfile @@ -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"] diff --git a/ha-addon/build.yaml b/ha-addon/build.yaml new file mode 100644 index 0000000..f635210 --- /dev/null +++ b/ha-addon/build.yaml @@ -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: "" diff --git a/ha-addon/config.yaml b/ha-addon/config.yaml new file mode 100644 index 0000000..2d65a1c --- /dev/null +++ b/ha-addon/config.yaml @@ -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 diff --git a/ha-addon/run.sh b/ha-addon/run.sh new file mode 100644 index 0000000..44bad32 --- /dev/null +++ b/ha-addon/run.sh @@ -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 < + Home Assistant + admin@localhost + + + 10 + 2 + 524288 + 30 + 15 + 10 + 0 + 65536 + + + + ${ICECAST_PASSWORD} + ${ICECAST_PASSWORD} + admin + ${ICECAST_PASSWORD} + + + 0.0.0.0 + + + ${STREAM_PORT} + + + + ${STREAM_MOUNT} + 0 + Busch-Radio Spotify Bridge + Spotify via Home Assistant + ${BITRATE} + audio/mpeg + + + 1 + + + ${ICECAST_WEBROOT} + /var/log/icecast + ${ICECAST_WEBROOT}/web + ${ICECAST_WEBROOT}/admin + /run/icecast/icecast.pid + + + + + - + - + 2 + 10000 + + + + 0 + + +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://:${STREAM_PORT}${STREAM_MOUNT}" +bashio::log.info "" +bashio::log.info "Icecast-Statusseite:" +bashio::log.info " http://:${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