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:
283
busch_radio_spotify/oauth_helper.py
Normal file
283
busch_radio_spotify/oauth_helper.py
Normal 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>🎵 Busch-Radio – Spotify-Anmeldung</h1>
|
||||
<p class="hint">Einmaliger Einrichtungs-Assistent • Credentials werden dauerhaft gespeichert.</p>
|
||||
<div class="authed-banner" id="authed-banner">
|
||||
<span class="ok">✓ Bereits autorisiert – keine erneute Anmeldung nötig.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Schritt 1 – 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 öffnen.</p>
|
||||
<div id="auth-waiting">
|
||||
<p class="warn">Warte auf librespot…<span class="spinner"></span></p>
|
||||
</div>
|
||||
<div id="auth-ready" class="hidden">
|
||||
<a id="auth-link" href="#" target="_blank" class="btn">Mit Spotify anmelden →</a>
|
||||
</div>
|
||||
<p class="hint">Du benö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ätige die Berechtigungsanfrage.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Schritt 2 – Weiterleitung abschließ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=…</code></p>
|
||||
<p>Kopiere diese URL vollständig aus der Adressleiste.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="num">4</div>
|
||||
<div class="body">
|
||||
<p>Füge die kopierte URL hier ein und klicke auf „Weiter“:</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ültige URL einfügen (beginnt mit <code>http://127.0.0.1:5588</code>).</p>
|
||||
<button class="btn" onclick="fixUrl()">Weiter →</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="num">5</div>
|
||||
<div class="body">
|
||||
<p>Im neu geö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()
|
||||
Reference in New Issue
Block a user