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:
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal 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
359
README.md
Normal 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)
|
||||||
47
custom_components/busch_radio_spotify/__init__.py
Normal file
47
custom_components/busch_radio_spotify/__init__.py
Normal 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
|
||||||
109
custom_components/busch_radio_spotify/config_flow.py
Normal file
109
custom_components/busch_radio_spotify/config_flow.py
Normal 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,
|
||||||
|
)
|
||||||
12
custom_components/busch_radio_spotify/manifest.json
Normal file
12
custom_components/busch_radio_spotify/manifest.json
Normal 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
|
||||||
|
}
|
||||||
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
|
||||||
24
custom_components/busch_radio_spotify/strings.json
Normal file
24
custom_components/busch_radio_spotify/strings.json
Normal 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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
custom_components/busch_radio_spotify/translations/de.json
Normal file
24
custom_components/busch_radio_spotify/translations/de.json
Normal 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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
custom_components/busch_radio_spotify/translations/en.json
Normal file
24
custom_components/busch_radio_spotify/translations/en.json
Normal 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
43
example-config.yaml
Normal 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
49
ha-addon/Dockerfile
Normal 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
11
ha-addon/build.yaml
Normal 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
51
ha-addon/config.yaml
Normal 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
209
ha-addon/run.sh
Normal 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
|
||||||
Reference in New Issue
Block a user