Audiofy – Musikwünsche für Partys

Server (Website, Admin-Bereich, Player-API) und Player (raspotify +
Controller) mit Install-Skripten für Debian-Systeme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 19:16:39 +01:00
co-authored by Claude Opus 5.5
commit 3610de7bae
45 changed files with 5666 additions and 0 deletions
Executable
+179
View File
@@ -0,0 +1,179 @@
#!/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}")
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("\n".join(neu) + "\n")
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()