Files
Niklas Gühne a463403c43 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>
2026-04-07 12:14:56 +02:00

284 lines
8.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()