Release v1.0.11: Pause-Fix, OAuth-Assistent, Docs, Cleanup

Pause-Fix (Silence-Injektor):
- Python-Skript zwischen librespot und ffmpeg
- Bei Pause füllt der Injektor die Stille mit Null-Bytes
- Icecast bleibt verbunden, Stream reißt nicht ab

OAuth-Einrichtungs-Assistent (Port 5589):
- oauth_helper.py: HTTP-Server mit geführtem Anmelde-Prozess
- Zeigt Spotify-Auth-Link automatisch sobald librespot ihn ausgibt
- URL-Fixer: Nutzer fügt 127.0.0.1-URL ein, Assistent korrigiert auf HA-IP
- Erkennt bereits vorhandene Credentials und zeigt Hinweis

Dokumentation:
- DOCS.md für die HA Add-on Detailseite erstellt
- README.md vollständig überarbeitet und aktualisiert

Cleanup:
- Leere Testdatei entfernt
- Veraltete example-config.yaml entfernt
- manifest.json der Custom Component auf v1.0.11 gebracht

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Niklas Gühne
2026-04-07 12:14:56 +02:00
parent fbebf84b76
commit a463403c43
9 changed files with 505 additions and 138 deletions

View File

@@ -0,0 +1,89 @@
# Busch-Radio Spotify Bridge
Streamt Spotify-Musik als Internetradio-Stream für das Busch-Jäger Unterputz-Internetradio.
Ein virtuelles Spotify Connect-Gerät (librespot) nimmt Audio entgegen und stellt es per Icecast als HTTP-Stream bereit.
```
Spotify-App → librespot → ffmpeg → Icecast → Busch-Jäger Radio
```
---
## Erste Einrichtung
### 1. Add-on konfigurieren und starten
Passe die Optionen an (siehe unten) und starte das Add-on.
### 2. Spotify autorisieren (einmalig)
Öffne den Einrichtungs-Assistenten im Browser:
```
http://<homeassistant-ip>:5589
```
Der Assistent führt dich Schritt für Schritt durch die Anmeldung. Die Credentials werden dauerhaft gespeichert dieser Schritt ist nur beim ersten Start nötig.
### 3. Stream-URL im Radio eintragen
```
http://<homeassistant-ip>:8000/stream
```
### 4. Musik abspielen
Wähle in der Spotify-App **„Busch-Radio"** (oder deinen konfigurierten Gerätenamen) als Abspielgerät aus.
---
## Konfigurationsoptionen
| Option | Standard | Beschreibung |
|---|---|---|
| `device_name` | `Busch-Radio` | Name des Geräts in der Spotify-App |
| `bitrate` | `160` | Audioqualität in kbps (`96` / `128` / `160` / `192`) |
| `stream_format` | `mp3` | Audioformat (`mp3` oder `aac`) |
| `stream_port` | `8000` | HTTP-Port des Icecast-Streams |
| `stream_mount` | `/stream` | URL-Pfad des Streams |
| `icecast_password` | | Internes Icecast-Passwort (bitte ändern) |
---
## Nützliche URLs
| URL | Funktion |
|---|---|
| `http://<ha-ip>:5589` | Einrichtungs-Assistent (OAuth) |
| `http://<ha-ip>:8000/stream` | Stream-URL für das Radio |
| `http://<ha-ip>:8000` | Icecast-Statusseite |
---
## Fehlerbehebung
**„Busch-Radio" erscheint nicht in der Spotify-App**
→ Spotify-Autorisierung noch nicht abgeschlossen. Einrichtungs-Seite öffnen: `http://<ha-ip>:5589`
**Stream nicht erreichbar**
→ Icecast-Statusseite prüfen: `http://<ha-ip>:8000`
→ Port 8000 muss im Netzwerk erreichbar sein (`host_network: true` ist aktiviert)
**Stream bricht bei Pause ab**
→ Add-on auf Version 1.0.11 oder neuer aktualisieren
**Songs wechseln zu schnell / keine Steuerung vom Handy**
→ Add-on auf Version 1.0.10 oder neuer aktualisieren
**Neu autorisieren**
→ Add-on stoppen → Datei `/data/librespot-cache/credentials.json` löschen → Add-on starten → Einrichtungs-Seite öffnen
---
## Technische Hinweise
- librespot wird beim Build aus dem Quellcode kompiliert (Rust) der erste Build dauert ca. 1015 Minuten
- Der OAuth-Callback-Server bindet an `0.0.0.0`, damit er aus dem Heimnetz erreichbar ist
- Audio wird nicht gespeichert ausschließlich live gestreamt
- Spotify Premium ist für Spotify Connect erforderlich

View File

@@ -70,6 +70,7 @@ RUN chmod +x /usr/local/bin/librespot
# Add-on Dateien kopieren
COPY run.sh /run.sh
RUN chmod a+x /run.sh
COPY oauth_helper.py /oauth_helper.py
RUN chmod a+x /run.sh /oauth_helper.py
CMD ["/run.sh"]

View File

@@ -1,6 +1,6 @@
---
name: "Busch-Radio Spotify Bridge"
version: "1.0.10"
version: "1.0.11"
slug: busch_radio_spotify
description: >
Streamt Spotify-Musik als Internet-Radio-Stream für das Busch-Jäger Unterputz-Internetradio.

View File

@@ -0,0 +1,283 @@
#!/usr/bin/env python3
"""OAuth-Hilfsserver für die Busch-Radio Spotify Bridge.
Stellt unter http://<ha-ip>:5589 eine Hilfsseite bereit,
die den einmaligen Spotify-Autorisierungsprozess vereinfacht.
Endpoints:
GET / -> HTML-Hilfsseite
GET /auth-url -> JSON: {"url": "https://accounts.spotify.com/..."}
GET /status -> JSON: {"authed": true/false}
"""
import http.server
import json
import os
import sys
import urllib.parse
HA_IP = sys.argv[1] if len(sys.argv) > 1 else "homeassistant.local"
AUTH_URL_FILE = "/tmp/busch-radio/auth_url.txt"
CRED_FILE = "/data/librespot-cache/credentials.json"
HTML = f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Busch-Radio Spotify-Anmeldung</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #1a1a2e;
color: #e0e0e0;
min-height: 100vh;
padding: 16px;
display: flex;
flex-direction: column;
align-items: center;
}}
.card {{
background: #16213e;
border-radius: 12px;
padding: 20px 24px;
max-width: 620px;
width: 100%;
margin: 10px 0;
border: 1px solid #0f3460;
}}
h1 {{ color: #1db954; font-size: 1.4em; margin-bottom: 6px; }}
h2 {{
color: #8a9bb0;
font-size: 0.8em;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 14px;
}}
.step {{
display: flex;
gap: 14px;
align-items: flex-start;
margin: 10px 0;
padding: 14px;
background: #0f3460;
border-radius: 8px;
}}
.num {{
background: #1db954;
color: #000;
border-radius: 50%;
width: 26px; height: 26px;
min-width: 26px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.85em;
}}
.body {{ flex: 1; }}
p {{ margin: 5px 0; line-height: 1.55; font-size: 0.93em; }}
code {{
background: #1a1a2e;
padding: 2px 6px;
border-radius: 4px;
color: #f0a500;
font-size: 0.88em;
word-break: break-all;
}}
.btn {{
display: inline-block;
background: #1db954;
color: #000 !important;
padding: 9px 20px;
border-radius: 20px;
font-weight: 700;
font-size: 0.9em;
cursor: pointer;
border: none;
text-decoration: none;
margin-top: 10px;
}}
.btn:hover {{ background: #17a349; }}
input[type=text] {{
width: 100%;
padding: 9px 12px;
border-radius: 6px;
border: 1px solid #264070;
background: #1a1a2e;
color: #e0e0e0;
font-size: 0.88em;
margin-top: 8px;
}}
.spinner {{
display: inline-block;
width: 14px; height: 14px;
border: 2px solid #333;
border-top-color: #1db954;
border-radius: 50%;
animation: spin 0.8s linear infinite;
vertical-align: middle;
margin-left: 6px;
}}
@keyframes spin {{ to {{ transform: rotate(360deg); }} }}
.ok {{ color: #1db954; }}
.warn {{ color: #f0a500; }}
.err {{ color: #e05050; font-size: 0.85em; margin-top: 6px; display: none; }}
.hint {{ color: #8a9bb0; font-size: 0.83em; margin-top: 5px; }}
.hidden {{ display: none !important; }}
.authed-banner {{
background: #0b3d1f;
border: 1px solid #1db954;
border-radius: 8px;
padding: 14px;
text-align: center;
display: none;
}}
</style>
</head>
<body>
<div class="card">
<h1>&#127925; Busch-Radio &ndash; Spotify-Anmeldung</h1>
<p class="hint">Einmaliger Einrichtungs-Assistent &bull; Credentials werden dauerhaft gespeichert.</p>
<div class="authed-banner" id="authed-banner">
<span class="ok">&#10003; Bereits autorisiert &ndash; keine erneute Anmeldung n&ouml;tig.</span>
</div>
</div>
<div class="card">
<h2>Schritt 1 &ndash; Spotify-Anmeldung starten</h2>
<div class="step">
<div class="num">1</div>
<div class="body">
<p>Klicke auf den Link, um die Spotify-Autorisierung zu &ouml;ffnen.</p>
<div id="auth-waiting">
<p class="warn">Warte auf librespot&hellip;<span class="spinner"></span></p>
</div>
<div id="auth-ready" class="hidden">
<a id="auth-link" href="#" target="_blank" class="btn">Mit Spotify anmelden &rarr;</a>
</div>
<p class="hint">Du ben&ouml;tigst ein Spotify-Premium-Konto.</p>
</div>
</div>
<div class="step">
<div class="num">2</div>
<div class="body">
<p>Melde dich mit deinem Spotify-Konto an und best&auml;tige die Berechtigungsanfrage.</p>
</div>
</div>
</div>
<div class="card">
<h2>Schritt 2 &ndash; Weiterleitung abschlie&szlig;en</h2>
<div class="step">
<div class="num">3</div>
<div class="body">
<p>Nach der Anmeldung leitet Spotify auf eine Seite weiter, die nicht erreichbar ist:</p>
<p><code>http://127.0.0.1:5588/login?code=&hellip;</code></p>
<p>Kopiere diese URL vollst&auml;ndig aus der Adressleiste.</p>
</div>
</div>
<div class="step">
<div class="num">4</div>
<div class="body">
<p>F&uuml;ge die kopierte URL hier ein und klicke auf &bdquo;Weiter&ldquo;:</p>
<input type="text" id="url-input" placeholder="http://127.0.0.1:5588/login?code=...">
<p id="url-err" class="err">Bitte eine g&uuml;ltige URL einf&uuml;gen (beginnt mit <code>http://127.0.0.1:5588</code>).</p>
<button class="btn" onclick="fixUrl()">Weiter &rarr;</button>
</div>
</div>
<div class="step">
<div class="num">5</div>
<div class="body">
<p>Im neu ge&ouml;ffneten Tab erscheint: <span class="ok">Autorisierung erfolgreich!</span></p>
<p>Das Add-on startet danach automatisch durch. Diese Seite kann geschlossen werden.</p>
</div>
</div>
</div>
<script>
const HA_IP = "{HA_IP}";
async function checkAuthUrl() {{
try {{
const r = await fetch('/auth-url');
const d = await r.json();
if (d.url) {{
document.getElementById('auth-waiting').classList.add('hidden');
document.getElementById('auth-link').href = d.url;
document.getElementById('auth-ready').classList.remove('hidden');
}} else {{
setTimeout(checkAuthUrl, 2000);
}}
}} catch(e) {{
setTimeout(checkAuthUrl, 2000);
}}
}}
async function checkAuthed() {{
try {{
const r = await fetch('/status');
const d = await r.json();
if (d.authed) {{
document.getElementById('authed-banner').style.display = 'block';
}}
}} catch(e) {{}}
}}
function fixUrl() {{
const raw = document.getElementById('url-input').value.trim();
const err = document.getElementById('url-err');
if (!raw.startsWith('http://127.0.0.1:5588')) {{
err.style.display = 'block';
return;
}}
err.style.display = 'none';
window.open(raw.replace('127.0.0.1', HA_IP), '_blank');
}}
document.getElementById('url-input').addEventListener('keydown', function(e) {{
if (e.key === 'Enter') fixUrl();
}});
checkAuthUrl();
checkAuthed();
</script>
</body>
</html>
"""
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
path = urllib.parse.urlparse(self.path).path
if path == "/auth-url":
url = None
if os.path.exists(AUTH_URL_FILE):
with open(AUTH_URL_FILE) as f:
url = f.read().strip() or None
body = json.dumps({"url": url}).encode()
self._respond(200, "application/json", body)
elif path == "/status":
body = json.dumps({"authed": os.path.exists(CRED_FILE)}).encode()
self._respond(200, "application/json", body)
else:
body = HTML.encode()
self._respond(200, "text/html; charset=utf-8", body)
def _respond(self, code, content_type, body):
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
if __name__ == "__main__":
server = http.server.HTTPServer(("0.0.0.0", 5589), Handler)
server.serve_forever()

View File

@@ -3,9 +3,11 @@
# 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
# 1. Icecast2 HTTP-Stream-Server
# 2. OAuth-Hilfe Einrichtungs-Assistent auf Port 5589
# 3. librespot Virtueller Spotify Connect-Empfänger (PCM auf stdout)
# 4. Silence-Injektor Fügt Stille ein wenn Spotify pausiert (hält Stream aufrecht)
# 5. ffmpeg Kodiert PCM → MP3/AAC und schickt es als Icecast-Quelle
#
# Bei Absturz wird die komplette Pipeline nach 5 Sekunden neu gestartet.
# ==============================================================================
@@ -32,7 +34,7 @@ else
fi
bashio::log.info "╔══════════════════════════════════════════════╗"
bashio::log.info "║ Busch-Radio Spotify Bridge v1.0.10 ║"
bashio::log.info "║ Busch-Radio Spotify Bridge v1.0.11 ║"
bashio::log.info "╚══════════════════════════════════════════════╝"
bashio::log.info "Gerätename : ${DEVICE_NAME}"
bashio::log.info "Bitrate : ${BITRATE} kbps"
@@ -152,7 +154,7 @@ bashio::log.info "Icecast läuft (PID: ${ICECAST_PID})"
CREDENTIAL_CACHE="/data/librespot-cache"
mkdir -p "${CREDENTIAL_CACHE}"
# ── IP-Adresse des Hosts ermitteln (für OAuth-Anleitung) ─────────────────────
# ── IP-Adresse des Hosts ermitteln ───────────────────────────────────────────
HA_IP=$(ip route get 1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1)}' | head -1)
if [ -z "${HA_IP}" ]; then
HA_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
@@ -161,11 +163,14 @@ if [ -z "${HA_IP}" ]; then
HA_IP="<homeassistant-ip>"
fi
# ── OAuth-Hilfsserver starten ─────────────────────────────────────────────────
# Stellt unter http://<ha-ip>:5589 einen Einrichtungs-Assistenten bereit,
# der den Spotify-OAuth-Prozess Schritt für Schritt erklärt.
python3 /oauth_helper.py "${HA_IP}" &
OAUTH_HELPER_PID=$!
bashio::log.info "OAuth-Hilfsseite: http://${HA_IP}:5589"
# ── Librespot-Argumente zusammenstellen ───────────────────────────────────────
# Zeroconf/mDNS funktioniert nicht zuverlässig in Containern, da Port 5353
# vom Host (systemd-resolved/avahi) bereits belegt ist.
# Stattdessen: OAuth-Authentifizierung. Das Gerät registriert sich bei
# Spotifys Servern und erscheint in der App ohne lokales mDNS.
LIBRESPOT_ARGS=(
"--name" "${DEVICE_NAME}"
"--bitrate" "${BITRATE}"
@@ -185,24 +190,10 @@ else
bashio::log.info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
bashio::log.info "ERSTER START Spotify-Autorisierung erforderlich!"
bashio::log.info ""
bashio::log.info "1) Gleich erscheint im Log eine Zeile:"
bashio::log.info " Browse to: https://accounts.spotify.com/authorize?..."
bashio::log.info " Kopiere diese URL vollständig und öffne sie im Browser."
bashio::log.info "Öffne die Einrichtungs-Seite im Browser:"
bashio::log.info " http://${HA_IP}:5589"
bashio::log.info ""
bashio::log.info "2) Melde dich mit deinem Spotify-Konto an"
bashio::log.info " und erteile die Berechtigung."
bashio::log.info ""
bashio::log.info "3) Dein Browser wird danach weitergeleitet auf:"
bashio::log.info " http://127.0.0.1:5588/login?code=..."
bashio::log.info " Diese Seite schlägt fehl das ist normal."
bashio::log.info ""
bashio::log.info "4) WICHTIG: Ersetze in der Adressleiste"
bashio::log.info " '127.0.0.1' durch '${HA_IP}'"
bashio::log.info " → http://${HA_IP}:5588/login?code=..."
bashio::log.info " und drücke Enter."
bashio::log.info ""
bashio::log.info "5) Bei Erfolg siehst du: 'Autorisierung erfolgreich!'"
bashio::log.info " Das Add-on startet danach automatisch."
bashio::log.info "Der Assistent führt dich durch die Anmeldung."
bashio::log.info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
fi
@@ -215,7 +206,7 @@ bashio::log.info "Stream-URL für das Busch-Jäger Radio:"
bashio::log.info " http://${HA_IP}:${STREAM_PORT}${STREAM_MOUNT}"
bashio::log.info ""
bashio::log.info "Icecast-Statusseite:"
bashio::log.info " http://<homeassistant-ip>:${STREAM_PORT}"
bashio::log.info " http://${HA_IP}:${STREAM_PORT}"
bashio::log.info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
bashio::log.info "Wähle '${DEVICE_NAME}' in der Spotify-App als Abspielgerät."
bashio::log.info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@@ -223,19 +214,46 @@ bashio::log.info "━━━━━━━━━━━━━━━━━━━━
# ── Aufräumen bei SIGTERM ─────────────────────────────────────────────────────
cleanup() {
bashio::log.info "Add-on wird beendet..."
kill "${ICECAST_PID}" 2>/dev/null || true
kill "${ICECAST_PID}" "${OAUTH_HELPER_PID}" 2>/dev/null || true
exit 0
}
trap cleanup SIGTERM SIGINT
# ── Silence-Injektor ─────────────────────────────────────────────────────────
# Sitzt zwischen librespot und ffmpeg.
# Wenn Spotify pausiert, hört librespot auf zu schreiben. Ohne Injektor würde
# ffmpeg/Icecast den Stream nach einigen Sekunden Stille trennen.
# Der Injektor füllt die Pause mit Stille-Bytes, damit der Stream bestehen bleibt.
SILENCE_INJECTOR='
import sys, select, os
CHUNK = 8820 # 50 ms @ 44100 Hz, S16LE, Stereo (44100*2*2/20)
silence = bytes(CHUNK)
fd = sys.stdin.buffer.fileno()
while True:
r, _, _ = select.select([fd], [], [], 0.05)
if r:
data = os.read(fd, CHUNK)
if not data:
break
sys.stdout.buffer.write(data)
else:
sys.stdout.buffer.write(silence)
sys.stdout.buffer.flush()
'
# ── 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)
# silence-injector (Python) leitet durch; bei Pause: sendet Stille
# ↓
# ffmpeg -re (Echtzeit) → PCM → MP3/AAC Encoding → Icecast
# ↓
# Busch-Jäger Radio (HTTP-Client)
#
# -re: Liest den Input in Echtzeit verhindert zu schnelles Vorspulen
# und gibt librespot korrekte Positions-Rückmeldung (Pause/Skip).
#
# Wenn entweder librespot oder ffmpeg abstürzt, wird die komplette Pipeline
# nach RESTART_DELAY Sekunden neu gestartet.
@@ -246,7 +264,20 @@ while true; do
ATTEMPT=$((ATTEMPT + 1))
bashio::log.info "Pipeline-Start (Versuch ${ATTEMPT})..."
# Librespot stderr: Zeilen werden an HA-Log weitergeleitet UND auf
# "Browse to:"-URL geprüft → wird in Datei für OAuth-Hilfsserver gespeichert.
librespot "${LIBRESPOT_ARGS[@]}" \
2> >(while IFS= read -r line; do
echo "$line" >&2
case "$line" in
*"Browse to:"*)
echo "$line" | sed 's/.*Browse to: //' | tr -d '\r\n ' \
> /tmp/busch-radio/auth_url.txt
bashio::log.info "Spotify-Anmeldung: http://${HA_IP}:5589 öffnen"
;;
esac
done) \
| python3 -c "${SILENCE_INJECTOR}" \
| ffmpeg \
-re \
-hide_banner \
@@ -259,6 +290,9 @@ while true; do
"${ICECAST_SOURCE_URL}" \
|| true
# Auth-URL-Datei leeren damit die Hilfsseite beim Neustart wieder wartet
rm -f /tmp/busch-radio/auth_url.txt
bashio::log.warning "Pipeline beendet. Neustart in ${RESTART_DELAY}s..."
sleep "${RESTART_DELAY}"
done