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,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()