Files
playwish/player/auth.py
T
99admandClaude Opus 5.5 b83da952ee Durchsicht: Robustheit bei Neuinstallationen
- auth.py: .env auch in schreibgeschütztem Ordner aktualisieren (Server-Login)
- Controller: ohne Spotify-Login warten statt abstürzen; Playlist darf fehlen;
  nicht abspielbare Wünsche überspringen statt hängen zu bleiben
- install-server.sh: alle SSH-Ports freigeben (kein Aussperren), curl und
  python3-systemd (fail2ban) installieren
- install-player.sh: Bluetooth per rfkill entsperren und Dienst starten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:55:00 +01:00

187 lines
7.3 KiB
Python
Executable File
Raw 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
"""Audiofy – einmaliger Spotify-Login (Authorization Code Flow).
Holt einen Refresh-Token für das eigene Spotify-Konto und schreibt ihn in die
.env-Datei. Funktioniert ohne Browser auf dem Gerät selbst:
1. Die ausgegebene Login-Adresse in einem beliebigen Browser öffnen.
2. Bei Spotify anmelden und zustimmen.
3. Der Browser landet auf http://127.0.0.1:8888/callback?... – die Seite lädt
nicht, das ist normal. Die komplette Adresse aus der Adresszeile kopieren
und hier einfügen.
Aufruf:
python3 auth.py --env /opt/audiofy/config/.env # Player (Wiedergabe steuern)
python3 auth.py --profil server --env /opt/audiofy/.env # Server (nur Suche)
Benötigte Einträge in der .env: SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET,
optional SPOTIFY_REDIRECT_URI. Nur Python-Standardbibliothek, kein venv nötig.
"""
import argparse
import base64
import getpass
import json
import os
import secrets
import stat
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
AUTHORIZE_URL = "https://accounts.spotify.com/authorize"
TOKEN_URL = "https://accounts.spotify.com/api/token"
ME_URL = "https://api.spotify.com/v1/me"
STANDARD_REDIRECT = "http://127.0.0.1:8888/callback"
# Player: Wiedergabe steuern + Playlist lesen; user-read-private nur, damit /me das
# Feld "product" (Premium-Check) liefert.
# Server: Suche braucht keinen Scope. ("pi"/"vps" = alte Namen)
SCOPES = {
"player": "user-read-playback-state user-modify-playback-state "
"user-read-currently-playing playlist-read-private user-read-private",
"server": "",
"pi": None, # alter Name für "player"
"vps": None, # alter Name für "server"
}
def env_lesen(pfad: Path) -> dict:
"""Einfacher KEY=VALUE-Parser (Kommentare und Anführungszeichen erlaubt)."""
werte = {}
for zeile in pfad.read_text().splitlines():
zeile = zeile.strip()
if not zeile or zeile.startswith("#") or "=" not in zeile:
continue
key, _, wert = zeile.partition("=")
werte[key.strip()] = wert.strip().strip('"').strip("'")
return werte
def env_setzen(pfad: Path, key: str, wert: str) -> None:
"""Setzt KEY=wert in der .env (ersetzt oder hängt an), atomar und mit Rechten 600."""
zeilen = pfad.read_text().splitlines()
neu, gefunden = [], False
for zeile in zeilen:
if zeile.strip().startswith(f"{key}="):
neu.append(f"{key}={wert}")
gefunden = True
else:
neu.append(zeile)
if not gefunden:
neu.append(f"{key}={wert}")
inhalt = "\n".join(neu) + "\n"
if not os.access(pfad.parent, os.W_OK):
# z. B. Server: /opt/audiofy gehört root, nur die .env selbst dem Dienst-Benutzer
fd = os.open(pfad, os.O_WRONLY | os.O_TRUNC)
with os.fdopen(fd, "w") as f:
f.write(inhalt)
return
tmp = pfad.with_name(pfad.name + ".tmp")
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
f.write(inhalt)
os.replace(tmp, pfad)
def token_anfragen(client_id: str, client_secret: str, daten: dict) -> dict:
body = urllib.parse.urlencode(daten).encode()
basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
req = urllib.request.Request(TOKEN_URL, data=body, headers={
"Authorization": f"Basic {basic}",
"Content-Type": "application/x-www-form-urlencoded",
})
try:
with urllib.request.urlopen(req, timeout=15) as antwort:
return json.load(antwort)
except urllib.error.HTTPError as e:
# Spotify-Fehlertexte enthalten keine Geheimnisse
sys.exit(f"Token-Anfrage fehlgeschlagen ({e.code}): {e.read().decode(errors='replace')[:300]}")
def me_abfragen(access_token: str) -> dict:
req = urllib.request.Request(ME_URL, headers={"Authorization": f"Bearer {access_token}"})
try:
with urllib.request.urlopen(req, timeout=15) as antwort:
return json.load(antwort)
except urllib.error.HTTPError as e:
sys.exit(f"/me fehlgeschlagen ({e.code}): {e.read().decode(errors='replace')[:300]}")
def main() -> None:
parser = argparse.ArgumentParser(description="Audiofy – Spotify-Login, schreibt den Refresh-Token in die .env")
parser.add_argument("--profil", choices=SCOPES, default="player", help="Scope-Satz (Standard: player)")
parser.add_argument("--env", type=Path, default=Path(__file__).resolve().parent / ".env",
help="Pfad zur .env (Standard: neben dem Skript)")
args = parser.parse_args()
args.profil = {"pi": "player", "vps": "server"}.get(args.profil, args.profil) # alte Namen
if not args.env.is_file():
sys.exit(f"{args.env} fehlt. Bitte anlegen (Rechte 600) mit SPOTIFY_CLIENT_ID und SPOTIFY_CLIENT_SECRET.")
if stat.S_IMODE(args.env.stat().st_mode) & 0o077:
print(f"Warnung: {args.env} ist für andere lesbar – bitte 'chmod 600 {args.env}'.", file=sys.stderr)
env = env_lesen(args.env)
client_id = env.get("SPOTIFY_CLIENT_ID", "")
client_secret = env.get("SPOTIFY_CLIENT_SECRET", "")
redirect_uri = env.get("SPOTIFY_REDIRECT_URI") or STANDARD_REDIRECT
if not client_id or not client_secret:
sys.exit(f"SPOTIFY_CLIENT_ID und SPOTIFY_CLIENT_SECRET müssen in {args.env} gesetzt sein.")
state = secrets.token_urlsafe(16)
parameter = {
"client_id": client_id,
"response_type": "code",
"redirect_uri": redirect_uri,
"state": state,
"show_dialog": "true",
}
if SCOPES[args.profil]:
parameter["scope"] = SCOPES[args.profil]
print(f"Profil: {args.profil}")
print("\n1) Diese Adresse im Browser öffnen und bei Spotify anmelden:\n")
print(f" {AUTHORIZE_URL}?{urllib.parse.urlencode(parameter)}\n")
print(f"2) Danach die komplette Adresse aus der Adresszeile ({redirect_uri}?...) einfügen.")
print(" (Eingabe wird nicht angezeigt.)\n")
eingabe = getpass.getpass("Adresse: ").strip()
url = urllib.parse.urlparse(eingabe)
query = urllib.parse.parse_qs(url.query)
if "error" in query:
sys.exit(f"Spotify meldet: {query['error'][0]}")
if query.get("state", [""])[0] != state:
sys.exit("State passt nicht – bitte die Adresse aus genau diesem Durchlauf einfügen.")
code = query.get("code", [""])[0]
if not code:
sys.exit("Kein 'code' in der Adresse gefunden.")
tokens = token_anfragen(client_id, client_secret, {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
})
refresh_token = tokens.get("refresh_token")
if not refresh_token:
sys.exit("Antwort enthält keinen Refresh-Token.")
env_setzen(args.env, "SPOTIFY_REFRESH_TOKEN", refresh_token)
print(f"\nRefresh-Token in {args.env} gespeichert.")
erhalten = set(tokens.get("scope", "").split())
fehlend = set(SCOPES[args.profil].split()) - erhalten
if fehlend:
print(f"Warnung: Diese Scopes wurden nicht gewährt: {' '.join(sorted(fehlend))}")
me = me_abfragen(tokens["access_token"])
print(f"Angemeldet als: {me.get('display_name') or me.get('id')}")
if "product" in me:
print(f"Abo: {me['product']}" + (" ✅" if me["product"] == "premium" else " ❌ (Premium nötig!)"))
if __name__ == "__main__":
main()