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>
299 lines
13 KiB
Bash
299 lines
13 KiB
Bash
#!/usr/bin/with-contenv bashio
|
||
# ==============================================================================
|
||
# Busch-Radio Spotify Bridge – Startup-Script
|
||
#
|
||
# Startet:
|
||
# 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.
|
||
# ==============================================================================
|
||
|
||
set -u
|
||
|
||
# ── Konfiguration lesen ───────────────────────────────────────────────────────
|
||
DEVICE_NAME=$(bashio::config 'device_name')
|
||
BITRATE=$(bashio::config 'bitrate')
|
||
STREAM_FORMAT=$(bashio::config 'stream_format')
|
||
STREAM_PORT=$(bashio::config 'stream_port')
|
||
STREAM_MOUNT=$(bashio::config 'stream_mount')
|
||
ICECAST_PASSWORD=$(bashio::config 'icecast_password')
|
||
|
||
# ── Format-spezifische Einstellungen ──────────────────────────────────────────
|
||
if [ "${STREAM_FORMAT}" = "aac" ]; then
|
||
FFMPEG_CODEC="aac"
|
||
FFMPEG_FORMAT="adts"
|
||
ICECAST_MIME="audio/aac"
|
||
else
|
||
FFMPEG_CODEC="libmp3lame"
|
||
FFMPEG_FORMAT="mp3"
|
||
ICECAST_MIME="audio/mpeg"
|
||
fi
|
||
|
||
bashio::log.info "╔══════════════════════════════════════════════╗"
|
||
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"
|
||
bashio::log.info "Format : ${STREAM_FORMAT}"
|
||
bashio::log.info "Stream-Port : ${STREAM_PORT}"
|
||
bashio::log.info "Stream-Pfad : ${STREAM_MOUNT}"
|
||
|
||
# ── Verzeichnisse anlegen + Berechtigungen für nobody-User setzen ────────────
|
||
# Icecast läuft im Container als root, wechselt aber via changeowner zu nobody.
|
||
mkdir -p /var/log/icecast /run/icecast /tmp/busch-radio
|
||
chown -R nobody:nobody /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>30</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>${ICECAST_MIME}</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>
|
||
<!-- Icecast wechselt nach dem Start zu nobody – verhindert den root-Fehler -->
|
||
<changeowner>
|
||
<user>nobody</user>
|
||
<group>nobody</group>
|
||
</changeowner>
|
||
</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})"
|
||
|
||
# ── Credential-Cache-Verzeichnis ─────────────────────────────────────────────
|
||
# Credentials werden in /data gespeichert (persistentes Add-on Verzeichnis).
|
||
CREDENTIAL_CACHE="/data/librespot-cache"
|
||
mkdir -p "${CREDENTIAL_CACHE}"
|
||
|
||
# ── 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}')
|
||
fi
|
||
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 ───────────────────────────────────────
|
||
LIBRESPOT_ARGS=(
|
||
"--name" "${DEVICE_NAME}"
|
||
"--bitrate" "${BITRATE}"
|
||
"--backend" "pipe"
|
||
"--device-type" "computer"
|
||
"--disable-audio-cache"
|
||
"--initial-volume" "100"
|
||
"--disable-discovery"
|
||
"--enable-oauth"
|
||
"--system-cache" "${CREDENTIAL_CACHE}"
|
||
)
|
||
|
||
# Prüfen ob bereits Credentials gecacht sind
|
||
if [ -f "${CREDENTIAL_CACHE}/credentials.json" ]; then
|
||
bashio::log.info "Gespeicherte Credentials gefunden – Direktstart."
|
||
else
|
||
bashio::log.info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
bashio::log.info "ERSTER START – Spotify-Autorisierung erforderlich!"
|
||
bashio::log.info ""
|
||
bashio::log.info "Öffne die Einrichtungs-Seite im Browser:"
|
||
bashio::log.info " http://${HA_IP}:5589"
|
||
bashio::log.info ""
|
||
bashio::log.info "Der Assistent führt dich durch die Anmeldung."
|
||
bashio::log.info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
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://${HA_IP}:${STREAM_PORT}${STREAM_MOUNT}"
|
||
bashio::log.info ""
|
||
bashio::log.info "Icecast-Statusseite:"
|
||
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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
|
||
# ── Aufräumen bei SIGTERM ─────────────────────────────────────────────────────
|
||
cleanup() {
|
||
bashio::log.info "Add-on wird beendet..."
|
||
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)
|
||
# ↓
|
||
# 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.
|
||
RESTART_DELAY=5
|
||
ATTEMPT=0
|
||
|
||
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 \
|
||
-loglevel warning \
|
||
-f s16le -ar 44100 -ac 2 \
|
||
-i pipe:0 \
|
||
-codec:a "${FFMPEG_CODEC}" \
|
||
-b:a "${BITRATE}k" \
|
||
-f "${FFMPEG_FORMAT}" \
|
||
"${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
|