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:
Executable
+179
@@ -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()
|
||||
@@ -0,0 +1,716 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Audiofy – Controller (Player).
|
||||
|
||||
Spielt zufällige Titel aus der Grund-Playlist auf dem Spotify-Connect-Gerät
|
||||
"Audiofy" und schiebt nach jeweils N-1 Playlist-Titeln einen Wunsch ein.
|
||||
Spotify bekommt immer nur den jeweils nächsten Titel (POST /me/player/queue,
|
||||
ca. 15 s vor Titelende); die Reihenfolge verwaltet ausschließlich der Controller.
|
||||
|
||||
Wünsche, Einstellungen (N, Playlist, Neu mischen, Not-Aus) und Befehle des
|
||||
Besitzers (Play, Pause, Weiter, Lautstärke) kommen vom Server (Player-API, ca. alle 3 s).
|
||||
"Als Nächstes" des Besitzers wird sofort eingeplant und zählt nicht im Wunsch-Takt.
|
||||
Ist der Server nicht erreichbar, läuft einfach die Grund-Playlist weiter.
|
||||
|
||||
Bedienung über die Spotify-App bleibt möglich: Fremde Titel werden erkannt und
|
||||
geduldet, eine Pause durch den Besitzer (App oder Admin-Bereich) wird respektiert.
|
||||
|
||||
Aufruf: .venv/bin/python controller.py
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
import lautsprecher
|
||||
from auth import env_lesen, env_setzen
|
||||
from spotify_client import SpotifyClient, SpotifyFehler
|
||||
from server_client import ServerVerbindung, Wunsch
|
||||
|
||||
log = logging.getLogger("audiofy.controller")
|
||||
|
||||
ORDNER = Path(__file__).resolve().parent
|
||||
POLL_S = 3 # Abfrageintervall /me/player und Server
|
||||
VORLAUF_MS = 15_000 # so lange vor Titelende den Nächsten einreihen
|
||||
ENDE_MS = 10_000 # Restzeit, ab der ein Stopp als "Titel zu Ende" gilt
|
||||
START_TIMEOUT_S = 15 # spielt es nach einem Start so lange nicht, erneut versuchen
|
||||
BT_PRUEF_S = 30 # Bluetooth-Verbindung so oft prüfen
|
||||
BT_NEU_S = 10 # bei getrenntem Lautsprecher so oft neu verbinden
|
||||
MAX_BACKOFF_S = 15 # nach Netzausfall schnell wieder dran sein
|
||||
PAUSE_MIN_MS = 5_000 # pausierter Titel ab dieser Position gilt beim Start als Besitzer-Pause
|
||||
BT_MELDEN_S = 30 # Lautsprecher-Zustand so oft an den Server melden
|
||||
BT_BEREIT_S = 3 # nach dem Verbinden so lange warten, bis BlueALSA das Audiogerät anbietet
|
||||
|
||||
|
||||
def kennung(item: dict) -> str:
|
||||
"""Vergleichsschlüssel aus Titel + Hauptinterpret, unempfindlich für Zusätze wie "- 2022 Remaster"."""
|
||||
name = re.sub(r"\s+-\s+.*$|\s*[(\[].*?[)\]]", "", item.get("name") or "").casefold().strip()
|
||||
interpret = ((item.get("artists") or [{}])[0].get("name") or "").casefold().strip()
|
||||
return f"{name}|{interpret}"
|
||||
|
||||
|
||||
def titel_info(item: dict) -> dict | None:
|
||||
"""Spotify-Track → Titelinfo für den Server (None für lokale Dateien, Podcasts usw.)."""
|
||||
if not (item.get("uri") or "").startswith("spotify:track:"):
|
||||
return None
|
||||
bilder = sorted((item.get("album") or {}).get("images") or [], key=lambda b: b.get("width") or 0)
|
||||
return {
|
||||
"track_uri": item["uri"],
|
||||
"title": (item.get("name") or "")[:300],
|
||||
"artist": ", ".join(a.get("name", "") for a in item.get("artists") or [])[:300],
|
||||
"album": ((item.get("album") or {}).get("name") or "")[:300] or None,
|
||||
"cover_url": next((b["url"] for b in bilder if (b.get("width") or 0) >= 200),
|
||||
bilder[-1]["url"] if bilder else None),
|
||||
"duration_ms": item.get("duration_ms"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Eintrag:
|
||||
"""Ein vom Controller geplanter Titel."""
|
||||
uri: str
|
||||
quelle: str = "playlist" # playlist, wish oder owner ("Als Nächstes" des Besitzers)
|
||||
wunsch: Wunsch | None = None
|
||||
direkt: bool = False # wurde schon einmal per PUT /me/player/play gestartet
|
||||
kennung: str | None = None # Titel|Interpret, um Ersatzversionen zu erkennen
|
||||
titel: dict | None = None # Titelinfo für die Anzeige "Als Nächstes"
|
||||
|
||||
|
||||
class Playlist:
|
||||
"""Grund-Playlist gemischt; ohne Wiederholung, bis alle Titel durch sind."""
|
||||
|
||||
def __init__(self, sp: SpotifyClient, playlist_id: str):
|
||||
self.sp = sp
|
||||
self.id = playlist_id
|
||||
self._stapel: list[str] = []
|
||||
self._letzter: str | None = None
|
||||
|
||||
def wechseln(self, playlist_id: str) -> None:
|
||||
self.id = playlist_id
|
||||
self._stapel = []
|
||||
|
||||
def neu_mischen(self) -> None:
|
||||
self._stapel = [] # beim nächsten Titel neu laden und mischen
|
||||
|
||||
def naechster(self) -> str:
|
||||
if not self._stapel:
|
||||
titel = self.sp.playlist_titel(self.id)
|
||||
if not titel:
|
||||
raise RuntimeError("Grund-Playlist ist leer oder nicht lesbar")
|
||||
random.shuffle(titel)
|
||||
# Nicht denselben Titel direkt zweimal hintereinander (an der Mischgrenze)
|
||||
if len(titel) > 1 and titel[-1] == self._letzter:
|
||||
titel[0], titel[-1] = titel[-1], titel[0]
|
||||
self._stapel = titel
|
||||
log.info("Grund-Playlist geladen und gemischt: %d Titel", len(titel))
|
||||
self._letzter = self._stapel.pop()
|
||||
return self._letzter
|
||||
|
||||
|
||||
class Controller:
|
||||
def __init__(self, sp: SpotifyClient, playlist: Playlist, server: ServerVerbindung | None,
|
||||
n: int, geraetename: str, bt_mac: str | None, zustand_pfad: Path | None = None,
|
||||
env_pfad: Path | None = None):
|
||||
self.sp = sp
|
||||
self.env_pfad = env_pfad # hier wird ein neu gewählter Lautsprecher gespeichert
|
||||
self.zustand_pfad = zustand_pfad # merkt sich Besitzer-Pause/Not-Aus über Neustarts hinweg
|
||||
self.playlist = playlist
|
||||
self.server = server
|
||||
self.n = max(1, n)
|
||||
self.geraetename = geraetename
|
||||
self.bt_mac = bt_mac
|
||||
|
||||
self.seit_wunsch = 0 # Playlist-Titel seit dem letzten Wunsch
|
||||
self.eingereiht: Eintrag | None = None # an Spotify übergeben, aber noch nicht gestartet
|
||||
self.fremdwechsel = 0 # Titelwechsel, seit eingereiht wurde, ohne dass er kam
|
||||
self.aktuell_uri: str | None = None
|
||||
self.aktuell_quelle: str | None = None # Quelle des laufenden Titels (für den Server)
|
||||
self.aktuell_wunsch_id: int | None = None
|
||||
self.neuer_titel = False # Titelwechsel noch nicht an den Server gemeldet
|
||||
self.vorher: dict | None = None # Zustand der letzten Abfrage
|
||||
self.st: dict | None = None # letzte Antwort von /me/player
|
||||
self.besitzer_pause = False
|
||||
self.bt_pause = False # Wiedergabe stockt, weil der Lautsprecher weg ist
|
||||
self.stopp = False # Not-Aus vom Admin-Bereich
|
||||
self.neustart = False
|
||||
self.direktstart = False # nächster Titel stammt aus starten()
|
||||
self.gestartet_um: float | None = None
|
||||
self.geraet_ok = True
|
||||
self.fehler: str | None = None # z. B. Playlist nicht lesbar (wird gemeldet)
|
||||
self._reshuffle: int | None = None
|
||||
self._erledigt: deque[int] = deque(maxlen=200) # ausgeführte Befehls-IDs (nie doppelt)
|
||||
self._bt_geprueft = 0.0
|
||||
self._bt_verbunden: bool | None = None
|
||||
self._bt_verbinden: subprocess.Popen | None = None # laufender Verbindungsversuch
|
||||
self._bt_versuch = 0.0
|
||||
self._geraet_fehlt_log = 0.0
|
||||
self._bt_verbunden_seit = 0.0
|
||||
self._lautsprecher_log = 0.0
|
||||
self._gespeichert: dict | None = None
|
||||
self._bt_aufgabe: threading.Thread | None = None # Suche/Kopplung im Hintergrund
|
||||
self._bt_geraete: list[dict] = [] # Ergebnis der letzten Suche
|
||||
self._bt_gesucht_um: str | None = None
|
||||
self._bt_gemeldet = 0.0
|
||||
self._zustand_laden()
|
||||
|
||||
# --- dauerhafter Zustand ---------------------------------------------
|
||||
|
||||
def _zustand_laden(self) -> None:
|
||||
"""Pause/Not-Aus von vor dem Neustart übernehmen (sonst würde nach einem Reboot einfach losgespielt)."""
|
||||
if not self.zustand_pfad or not self.zustand_pfad.is_file():
|
||||
return
|
||||
try:
|
||||
daten = json.loads(self.zustand_pfad.read_text())
|
||||
except (OSError, ValueError) as e:
|
||||
log.warning("Zustandsdatei nicht lesbar (%s) – wird ignoriert", e)
|
||||
return
|
||||
self.besitzer_pause = bool(daten.get("besitzer_pause"))
|
||||
self.stopp = bool(daten.get("stopp"))
|
||||
self._gespeichert = {"besitzer_pause": self.besitzer_pause, "stopp": self.stopp}
|
||||
if self.stopp:
|
||||
log.warning("Vor dem Neustart war Not-Aus aktiv – bleibt aktiv, bis er im Admin-Bereich aufgehoben wird")
|
||||
elif self.besitzer_pause:
|
||||
log.info("Vor dem Neustart war pausiert – bleibt pausiert, bis Play gedrückt wird")
|
||||
|
||||
def zustand_sichern(self) -> None:
|
||||
"""Schreibt nur bei Änderungen (schont die SD-Karte)."""
|
||||
if not self.zustand_pfad:
|
||||
return
|
||||
aktuell = {"besitzer_pause": self.besitzer_pause, "stopp": self.stopp}
|
||||
if aktuell == self._gespeichert:
|
||||
return
|
||||
try:
|
||||
tmp = self.zustand_pfad.with_name(self.zustand_pfad.name + ".tmp")
|
||||
tmp.write_text(json.dumps(aktuell))
|
||||
os.replace(tmp, self.zustand_pfad)
|
||||
self._gespeichert = aktuell
|
||||
except OSError as e:
|
||||
log.error("Zustand konnte nicht gespeichert werden: %s", e)
|
||||
|
||||
# --- Planung ----------------------------------------------------------
|
||||
|
||||
def naechster_eintrag(self) -> Eintrag:
|
||||
wunsch = self.server.naechster_wunsch() if self.server else None
|
||||
if wunsch and (wunsch.priority or self.seit_wunsch >= self.n - 1):
|
||||
eintrag = Eintrag(wunsch.uri, "owner" if wunsch.priority else "wish", wunsch)
|
||||
else:
|
||||
# kein (fälliger) Wunsch → Playlist weiter, Zähler bleibt "fällig"
|
||||
try:
|
||||
eintrag = Eintrag(self.playlist.naechster())
|
||||
self.fehler = None
|
||||
except (RuntimeError, SpotifyFehler) as e:
|
||||
self.fehler = "Grund-Playlist nicht lesbar – Link im Admin-Bereich prüfen"
|
||||
raise RuntimeError(f"{self.fehler} ({e})") from e
|
||||
try:
|
||||
t = self.sp.titel(eintrag.uri)
|
||||
eintrag.kennung = kennung(t)
|
||||
eintrag.titel = titel_info(t)
|
||||
except SpotifyFehler as e:
|
||||
log.warning("Metadaten für %s nicht abrufbar (%s) – Vergleich nur per URI", eintrag.uri, e)
|
||||
return eintrag
|
||||
|
||||
def geraet_id(self) -> str | None:
|
||||
for geraet in self.sp.geraete():
|
||||
if geraet.get("name") == self.geraetename:
|
||||
self.geraet_ok = True
|
||||
return geraet["id"]
|
||||
self.geraet_ok = False
|
||||
return None
|
||||
|
||||
def starten(self) -> None:
|
||||
"""Wiedergabe auf dem Gerät "Audiofy" (neu) starten."""
|
||||
gid = self.geraet_id()
|
||||
if not gid:
|
||||
# Warnung beim ersten Mal, danach höchstens einmal pro Minute
|
||||
jetzt = time.monotonic()
|
||||
if jetzt - self._geraet_fehlt_log >= 60:
|
||||
log.warning("Gerät '%s' nicht gefunden – versuche es weiter alle %d s", self.geraetename, POLL_S)
|
||||
self._geraet_fehlt_log = jetzt
|
||||
return
|
||||
self._geraet_fehlt_log = 0.0
|
||||
# Ein bereits eingereihter Titel wird direkt gestartet. Liegt er noch in der
|
||||
# Spotify-Queue, kann er später ein zweites Mal kommen (Queue nicht leerbar).
|
||||
eintrag = self.eingereiht
|
||||
if eintrag and eintrag.direkt:
|
||||
# Schon einmal direkt gestartet – nicht in einer Schleife wiederholen
|
||||
log.warning("Eintrag %s wurde bereits gestartet – wird übersprungen", eintrag.uri)
|
||||
eintrag = None
|
||||
eintrag = eintrag or self.naechster_eintrag()
|
||||
self.sp.abspielen([eintrag.uri], gid)
|
||||
eintrag.direkt = True
|
||||
self.eingereiht = eintrag
|
||||
self.fremdwechsel = 0
|
||||
self.neustart = False
|
||||
self.direktstart = True
|
||||
self.aktuell_uri = None
|
||||
self.gestartet_um = time.monotonic()
|
||||
log.info("Wiedergabe auf '%s' gestartet: %s", self.geraetename, eintrag.uri)
|
||||
|
||||
# --- Abgleich mit dem Server ----------------------------------------------
|
||||
|
||||
def server_abgleich(self) -> list[dict]:
|
||||
"""Konfiguration vom Server übernehmen; liefert die offenen Befehle."""
|
||||
if not self.server:
|
||||
return []
|
||||
k = self.server.konfiguration()
|
||||
if not k:
|
||||
return [] # offline → mit den letzten bekannten Werten weiter
|
||||
n = max(1, int(k.get("wish_every_n") or 3))
|
||||
if n != self.n:
|
||||
log.info("N geändert: jeder %d. Titel ist ein Wunsch", n)
|
||||
self.n = n
|
||||
if k.get("playlist_id") and k["playlist_id"] != self.playlist.id:
|
||||
log.info("Grund-Playlist gewechselt: %s", k["playlist_id"])
|
||||
self.playlist.wechseln(k["playlist_id"])
|
||||
token = k.get("reshuffle_token")
|
||||
if self._reshuffle is not None and token != self._reshuffle:
|
||||
log.info("Grund-Playlist wird neu gemischt")
|
||||
self.playlist.neu_mischen()
|
||||
self._reshuffle = token
|
||||
stopp = bool(k.get("stopped"))
|
||||
if stopp != self.stopp:
|
||||
log.warning("Not-Aus %s", "AKTIV – Controller angehalten" if stopp else "aufgehoben")
|
||||
self.stopp = stopp
|
||||
return k.get("commands") or []
|
||||
|
||||
def befehle_ausfuehren(self, befehle: list[dict]) -> None:
|
||||
for b in befehle:
|
||||
if b["id"] in self._erledigt:
|
||||
continue
|
||||
if b["type"] in ("bt_scan", "bt_connect"):
|
||||
self._erledigt.append(b["id"])
|
||||
self._bt_befehl(b["id"], b["type"], b.get("payload") or {})
|
||||
continue
|
||||
try:
|
||||
ok, meldung = self._befehl(b["type"], b.get("payload") or {})
|
||||
except SpotifyFehler as e:
|
||||
ok, meldung = False, str(e)[:200]
|
||||
self._erledigt.append(b["id"])
|
||||
log.info("Befehl %s: %s%s", b["type"], "ausgeführt" if ok else "fehlgeschlagen",
|
||||
f" ({meldung})" if meldung else "")
|
||||
if self.server:
|
||||
self.server.quittieren(b["id"], ok, meldung)
|
||||
|
||||
# --- Lautsprecher wechseln ------------------------------------------------
|
||||
|
||||
def _bt_befehl(self, befehl_id: int, typ: str, payload: dict) -> None:
|
||||
"""Suche/Kopplung dauert bis zu 45 s → eigener Thread, Quittung kommt danach."""
|
||||
fehler = None
|
||||
if not lautsprecher.verfuegbar():
|
||||
fehler = "Kein Bluetooth auf diesem Player"
|
||||
elif self._bt_aufgabe and self._bt_aufgabe.is_alive():
|
||||
fehler = "Es läuft schon eine Suche oder Kopplung"
|
||||
elif typ == "bt_connect" and not lautsprecher.MAC_RE.match(payload.get("mac") or ""):
|
||||
fehler = "Ungültige Lautsprecher-Adresse"
|
||||
if fehler:
|
||||
log.info("Befehl %s: fehlgeschlagen (%s)", typ, fehler)
|
||||
if self.server:
|
||||
self.server.quittieren(befehl_id, False, fehler)
|
||||
return
|
||||
log.info("Befehl %s: läuft im Hintergrund", typ)
|
||||
self._bt_aufgabe = threading.Thread(target=self._bt_arbeit, args=(befehl_id, typ, payload.get("mac")),
|
||||
daemon=True)
|
||||
self._bt_aufgabe.start()
|
||||
|
||||
def _bt_arbeit(self, befehl_id: int, typ: str, mac: str | None) -> None:
|
||||
ok, meldung = False, "Unbekannter Fehler"
|
||||
try:
|
||||
self.bluetooth_melden(laeuft=True)
|
||||
if typ == "bt_scan":
|
||||
self._bt_geraete = lautsprecher.suchen()
|
||||
self._bt_gesucht_um = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
ok, meldung = True, f"{len(self._bt_geraete)} Audiogerät(e) gefunden"
|
||||
else:
|
||||
alt = self.bt_mac
|
||||
if alt and alt != mac:
|
||||
lautsprecher.trennen(alt) # sonst bleibt BlueALSA beim alten Lautsprecher
|
||||
ok, meldung = lautsprecher.koppeln(mac)
|
||||
if ok:
|
||||
self.bt_mac = mac
|
||||
self._bt_verbunden = None # beim nächsten Durchlauf frisch prüfen
|
||||
if self.env_pfad:
|
||||
env_setzen(self.env_pfad, "BT_SPEAKER_MAC", mac)
|
||||
self._bt_geraete = [lautsprecher.info(g["mac"]) for g in self._bt_geraete]
|
||||
elif alt and alt != mac:
|
||||
meldung += " – alter Lautsprecher wird wieder verbunden"
|
||||
lautsprecher.verbinden(alt)
|
||||
except Exception as e: # Hintergrund-Thread darf nie still sterben
|
||||
log.exception("Lautsprecher-Vorgang fehlgeschlagen")
|
||||
ok, meldung = False, f"Fehler: {type(e).__name__}"
|
||||
finally:
|
||||
log.info("Befehl %s: %s (%s)", typ, "ausgeführt" if ok else "fehlgeschlagen", meldung)
|
||||
if self.server:
|
||||
self.server.quittieren(befehl_id, ok, meldung[:500])
|
||||
self.bluetooth_melden(laeuft=False)
|
||||
|
||||
def bluetooth_melden(self, laeuft: bool | None = None) -> None:
|
||||
"""Lautsprecher und Suchergebnis an den Server (Admin → Einstellungen → Lautsprecher)."""
|
||||
if not self.server or not lautsprecher.verfuegbar():
|
||||
return
|
||||
if laeuft is None: # Aufruf aus der Hauptschleife: nur alle BT_MELDEN_S Sekunden
|
||||
if time.monotonic() - self._bt_gemeldet < BT_MELDEN_S:
|
||||
return
|
||||
laeuft = bool(self._bt_aufgabe and self._bt_aufgabe.is_alive())
|
||||
self._bt_gemeldet = time.monotonic()
|
||||
aktuell = lautsprecher.oeffentlich(lautsprecher.info(self.bt_mac)) if self.bt_mac else None
|
||||
self.server.bluetooth_melden({
|
||||
"current": aktuell,
|
||||
"devices": [lautsprecher.oeffentlich(g) for g in self._bt_geraete],
|
||||
"scanning": laeuft,
|
||||
"scanned_at": self._bt_gesucht_um,
|
||||
})
|
||||
|
||||
def _befehl(self, typ: str, payload: dict) -> tuple[bool, str | None]:
|
||||
gid = self.geraet_id()
|
||||
if not gid:
|
||||
return False, f"Gerät '{self.geraetename}' nicht gefunden"
|
||||
if typ == "pause":
|
||||
try:
|
||||
self.sp.anfrage("PUT", "/me/player/pause", params={"device_id": gid})
|
||||
except SpotifyFehler as e:
|
||||
if e.status != 403: # 403 = war schon pausiert
|
||||
raise
|
||||
self.besitzer_pause = True
|
||||
return True, None
|
||||
if typ == "volume":
|
||||
prozent = max(0, min(100, int(payload.get("percent", 50))))
|
||||
self.sp.anfrage("PUT", "/me/player/volume", params={"volume_percent": prozent, "device_id": gid})
|
||||
if self.st and self.st.get("device"):
|
||||
self.st["device"]["volume_percent"] = prozent # gleich so melden, nicht erst nach der nächsten Abfrage
|
||||
return True, f"{prozent} %"
|
||||
if self.stopp:
|
||||
return False, "Not-Aus ist aktiv"
|
||||
if typ in ("play", "skip") and not self.lautsprecher_bereit():
|
||||
return False, "Lautsprecher nicht verbunden"
|
||||
if typ == "play":
|
||||
st = self.sp.wiedergabe()
|
||||
self.besitzer_pause = False
|
||||
if st and (st.get("device") or {}).get("name") == self.geraetename and st.get("item") \
|
||||
and (st.get("progress_ms") or 0) >= 2000:
|
||||
self.sp.fortsetzen(gid) # pausierten Titel an derselben Stelle fortsetzen
|
||||
else:
|
||||
self.starten() # nichts Sinnvolles zum Fortsetzen → nächster Titel
|
||||
return True, None
|
||||
if typ == "skip":
|
||||
if self.eingereiht:
|
||||
self.sp.anfrage("POST", "/me/player/next", params={"device_id": gid})
|
||||
else:
|
||||
self.starten() # Nächster noch nicht eingereiht → direkt starten
|
||||
self.besitzer_pause = False
|
||||
return True, None
|
||||
return False, f"Unbekannter Befehl: {typ}"
|
||||
|
||||
def melden(self) -> None:
|
||||
"""Aktuellen Zustand an den Server ("Läuft gerade", Status für den Admin-Bereich)."""
|
||||
if not self.server:
|
||||
return
|
||||
st = self.st or {}
|
||||
geraet = st.get("device") or {}
|
||||
unser = geraet.get("name") == self.geraetename
|
||||
track = titel_info(st.get("item") or {}) if unser else None
|
||||
naechster = self.eingereiht.titel if self.eingereiht else None
|
||||
zustand = {
|
||||
"track": track,
|
||||
"progress_ms": (st.get("progress_ms") or 0) if track else 0,
|
||||
"is_playing": bool(track and st.get("is_playing")),
|
||||
"new_track": self.neuer_titel,
|
||||
"source": self.aktuell_quelle if track else None,
|
||||
"wish_id": self.aktuell_wunsch_id if track else None,
|
||||
"next": naechster,
|
||||
"device_ok": self.geraet_ok or unser,
|
||||
"speaker_ok": self._bt_verbunden is not False,
|
||||
"stopped": self.stopp,
|
||||
"volume_percent": geraet.get("volume_percent") if unser else None,
|
||||
"error": self.fehler,
|
||||
}
|
||||
if self.server.melden(zustand):
|
||||
self.neuer_titel = False
|
||||
|
||||
# --- Hauptschritt -----------------------------------------------------
|
||||
|
||||
def schritt(self) -> None:
|
||||
st = self.sp.wiedergabe()
|
||||
self.st = st
|
||||
geraet = (st or {}).get("device") or {}
|
||||
item = (st or {}).get("item") or {}
|
||||
unser = geraet.get("name") == self.geraetename
|
||||
spielt = bool(st and st.get("is_playing") and item)
|
||||
uri = item.get("uri")
|
||||
fortschritt = (st or {}).get("progress_ms") or 0
|
||||
rest = (item.get("duration_ms") or 0) - fortschritt
|
||||
if unser:
|
||||
self.geraet_ok = True
|
||||
|
||||
vorher = self.vorher
|
||||
self.vorher = {"spielt": spielt, "unser": unser, "uri": uri, "rest": rest, "fortschritt": fortschritt,
|
||||
"zeit": time.monotonic()}
|
||||
|
||||
if self.stopp:
|
||||
# Not-Aus: anhalten und nichts selbst starten oder einreihen
|
||||
if spielt and unser:
|
||||
self.sp.anfrage("PUT", "/me/player/pause", params={"device_id": geraet["id"]})
|
||||
log.warning("Not-Aus: Wiedergabe angehalten")
|
||||
self.neustart = False
|
||||
self.besitzer_pause = True # nach dem Aufheben nicht eigenmächtig starten
|
||||
return
|
||||
|
||||
if spielt and not unser:
|
||||
if not vorher or vorher["unser"] or not vorher["spielt"]:
|
||||
log.info("Wiedergabe läuft auf '%s' – Controller wartet", geraet.get("name"))
|
||||
return
|
||||
|
||||
if spielt:
|
||||
self.besitzer_pause = False
|
||||
self.bt_pause = False
|
||||
self.gestartet_um = None
|
||||
self._titelwechsel_pruefen(uri, fortschritt, item, vorher)
|
||||
if self.eingereiht is None and rest < VORLAUF_MS:
|
||||
eintrag = self.naechster_eintrag()
|
||||
self.sp.einreihen(eintrag.uri, geraet["id"])
|
||||
self.eingereiht = eintrag
|
||||
self.fremdwechsel = 0
|
||||
log.info("Als Nächstes eingereiht: %s [%s]", eintrag.uri, eintrag.quelle)
|
||||
return
|
||||
|
||||
# --- es läuft nichts (auf Audiofy) ---
|
||||
if self.bt_pause:
|
||||
# librespot pausiert bei Audiofehlern selbst – nach Neuverbindung fortsetzen
|
||||
if self.lautsprecher_bereit():
|
||||
gid = self.geraet_id()
|
||||
if gid:
|
||||
self.sp.fortsetzen(gid)
|
||||
self.bt_pause = False
|
||||
log.info("Lautsprecher wieder da – Wiedergabe fortgesetzt")
|
||||
return
|
||||
if vorher is None:
|
||||
# Controller-Start: pausierter Titel auf Audiofy = Besitzer-Pause, sonst starten
|
||||
if st and unser and item and fortschritt >= PAUSE_MIN_MS:
|
||||
if not self._bt_pruefen_jetzt():
|
||||
log.warning("Audiofy pausiert und Lautsprecher getrennt – geht nach Neuverbindung weiter")
|
||||
self.bt_pause = True
|
||||
else:
|
||||
log.info("Audiofy ist pausiert – warte, bis der Besitzer fortsetzt")
|
||||
self.besitzer_pause = True
|
||||
else:
|
||||
self.neustart = True
|
||||
elif vorher["spielt"] and vorher["unser"]:
|
||||
# Titelende: rechnerisch erreicht (auch über einen Netzausfall hinweg) oder
|
||||
# Spotify hat die Position nach dem Ende auf ~0 zurückgesetzt
|
||||
vergangen_ms = (time.monotonic() - vorher["zeit"]) * 1000
|
||||
titel_zu_ende = (vorher["rest"] - vergangen_ms <= ENDE_MS
|
||||
or (uri == vorher["uri"] and fortschritt < 2000 and vorher["fortschritt"] > fortschritt + 2000))
|
||||
if titel_zu_ende:
|
||||
log.warning("Wiedergabe nach Titelende gestoppt – starte nächsten Titel")
|
||||
self.neustart = True
|
||||
elif st and unser and not self._bt_pruefen_jetzt():
|
||||
log.warning("Lautsprecher getrennt – Wiedergabe geht nach Neuverbindung weiter")
|
||||
self.bt_pause = True
|
||||
elif st and unser:
|
||||
log.info("Pausiert (Besitzer) – wird respektiert")
|
||||
self.besitzer_pause = True
|
||||
else:
|
||||
log.warning("Gerät '%s' nicht mehr aktiv – starte Wiedergabe neu", self.geraetename)
|
||||
self.neustart = True
|
||||
elif self.gestartet_um and time.monotonic() - self.gestartet_um > START_TIMEOUT_S:
|
||||
log.warning("Start hat nicht gegriffen – neuer Versuch")
|
||||
self.neustart = True
|
||||
|
||||
if self.neustart and not self.besitzer_pause and self.lautsprecher_bereit():
|
||||
self.starten()
|
||||
|
||||
def lautsprecher_bereit(self) -> bool:
|
||||
"""Ohne verbundenen Lautsprecher nichts starten: librespot stürzt sonst ab (kein Audiogerät)."""
|
||||
if not self.bt_mac:
|
||||
return True
|
||||
if self._bt_verbunden is None:
|
||||
self._bt_verbunden = self._bt_pruefen_jetzt()
|
||||
if self._bt_verbunden:
|
||||
self._bt_verbunden_seit = time.monotonic() - BT_BEREIT_S
|
||||
bereit = self._bt_verbunden and time.monotonic() - self._bt_verbunden_seit >= BT_BEREIT_S
|
||||
if not bereit and time.monotonic() - self._lautsprecher_log >= 60:
|
||||
log.warning("Lautsprecher nicht verbunden – Wiedergabe startet, sobald er wieder da ist")
|
||||
self._lautsprecher_log = time.monotonic()
|
||||
if bereit:
|
||||
self._lautsprecher_log = 0.0
|
||||
return bool(bereit)
|
||||
|
||||
def _titelwechsel_pruefen(self, uri: str, fortschritt: int, item: dict, vorher: dict | None) -> None:
|
||||
gleicher_titel_neu = (vorher is not None and vorher["uri"] == uri and vorher["rest"] <= ENDE_MS
|
||||
and fortschritt + 5000 < vorher["fortschritt"])
|
||||
if uri == self.aktuell_uri and not gleicher_titel_neu:
|
||||
return
|
||||
self.aktuell_uri = uri
|
||||
self.neuer_titel = True
|
||||
|
||||
# Spotify spielt manchmal eine Ersatzversion mit anderer ID (z. B. Remaster),
|
||||
# ohne linked_from. Deshalb zusätzlich über Titel+Interpret vergleichen.
|
||||
# Alte Reste in der Spotify-Queue gelten so als "fremd"; unser Eintrag bleibt
|
||||
# offen und kommt direkt danach.
|
||||
eintrag = self.eingereiht
|
||||
passt = eintrag is not None and (eintrag.uri == uri or self.direktstart
|
||||
or (eintrag.kennung is not None and eintrag.kennung == kennung(item)))
|
||||
self.direktstart = False
|
||||
self.aktuell_wunsch_id = None
|
||||
if passt:
|
||||
if eintrag.uri != uri:
|
||||
log.info("Spotify spielt %s statt %s (Ersatzversion)", uri, eintrag.uri)
|
||||
self.eingereiht = None
|
||||
self.aktuell_quelle = eintrag.quelle
|
||||
if eintrag.wunsch:
|
||||
self.aktuell_wunsch_id = eintrag.wunsch.id
|
||||
if self.server:
|
||||
self.server.wunsch_gespielt(eintrag.wunsch)
|
||||
if eintrag.quelle == "wish":
|
||||
self.seit_wunsch = 0
|
||||
elif eintrag.quelle == "playlist":
|
||||
self.seit_wunsch += 1
|
||||
# "owner" (Als Nächstes des Besitzers) zählt nicht im Wunsch-Takt
|
||||
else:
|
||||
self.aktuell_quelle = "fremd"
|
||||
if eintrag:
|
||||
self.fremdwechsel += 1
|
||||
if self.fremdwechsel >= 3:
|
||||
# Ein Wunsch bleibt auf dem Server offen und wird später erneut angeboten
|
||||
log.warning("Eingereihter Titel %s kam nicht – wird verworfen", eintrag.uri)
|
||||
self.eingereiht = None
|
||||
self.fremdwechsel = 0
|
||||
|
||||
interpreten = ", ".join(a["name"] for a in item.get("artists", []))
|
||||
bis_wunsch = max(0, self.n - 1 - self.seit_wunsch)
|
||||
log.info("▶ %s – %s [%s] (Wunsch fällig in %d)", item.get("name"), interpreten, self.aktuell_quelle, bis_wunsch)
|
||||
|
||||
# --- Bluetooth --------------------------------------------------------
|
||||
|
||||
def _bt_pruefen_jetzt(self) -> bool:
|
||||
"""Sofortige Prüfung, ob der Lautsprecher verbunden ist (ohne Lautsprecher: immer True)."""
|
||||
if not self.bt_mac:
|
||||
return True
|
||||
try:
|
||||
info = subprocess.run(["bluetoothctl", "info", self.bt_mac], capture_output=True, text=True, timeout=10)
|
||||
verbunden = "Connected: yes" in info.stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return True # im Zweifel als Besitzer-Pause behandeln
|
||||
if not verbunden and self._bt_verbunden is not False:
|
||||
log.warning("Bluetooth-Lautsprecher %s nicht verbunden – verbinde neu", self.bt_mac)
|
||||
self._bt_verbunden = False
|
||||
return verbunden
|
||||
|
||||
def bluetooth_pruefen(self) -> None:
|
||||
"""Lautsprecher bei Verbindungsverlust neu verbinden (bluetoothctl, ohne sudo).
|
||||
|
||||
Der Verbindungsversuch läuft als Hintergrundprozess, damit die Hauptschleife
|
||||
nicht blockiert. Bei getrenntem Lautsprecher wird in jedem Durchlauf geprüft.
|
||||
"""
|
||||
if not self.bt_mac or (self._bt_aufgabe and self._bt_aufgabe.is_alive()):
|
||||
return
|
||||
if self._bt_verbinden and self._bt_verbinden.poll() is not None:
|
||||
self._bt_verbinden = None # Versuch beendet (Erfolg oder nicht)
|
||||
jetzt = time.monotonic()
|
||||
if self._bt_verbunden is not False and jetzt - self._bt_geprueft < BT_PRUEF_S:
|
||||
return
|
||||
self._bt_geprueft = jetzt
|
||||
|
||||
vorher = self._bt_verbunden
|
||||
verbunden = self._bt_pruefen_jetzt()
|
||||
self._bt_verbunden = verbunden
|
||||
if vorher is not None and verbunden != vorher:
|
||||
self._bt_gemeldet = 0.0 # Änderung gleich an den Server melden
|
||||
if verbunden:
|
||||
if vorher is not True:
|
||||
self._bt_verbunden_seit = jetzt
|
||||
if vorher is False:
|
||||
log.info("Bluetooth-Lautsprecher wieder verbunden")
|
||||
return
|
||||
if self._bt_verbinden is None and jetzt - self._bt_versuch >= BT_NEU_S:
|
||||
self._bt_versuch = jetzt
|
||||
try:
|
||||
self._bt_verbinden = subprocess.Popen(
|
||||
["bluetoothctl", "--timeout", "15", "connect", self.bt_mac],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except OSError as e:
|
||||
log.error("bluetoothctl nicht startbar: %s", e)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Audiofy-Controller")
|
||||
parser.add_argument("--env", type=Path, default=ORDNER / ".env")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Unter systemd setzt journald die Zeitstempel selbst
|
||||
unter_systemd = "JOURNAL_STREAM" in os.environ
|
||||
logging.basicConfig(level=logging.INFO, datefmt="%H:%M:%S",
|
||||
format="%(levelname)-7s %(message)s" if unter_systemd else "%(asctime)s %(levelname)-7s %(message)s")
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING) # keine Request-URLs im Log
|
||||
|
||||
env = env_lesen(args.env)
|
||||
sp = SpotifyClient(env["SPOTIFY_CLIENT_ID"], env["SPOTIFY_CLIENT_SECRET"], env["SPOTIFY_REFRESH_TOKEN"],
|
||||
neuer_refresh_token=lambda t: env_setzen(args.env, "SPOTIFY_REFRESH_TOKEN", t))
|
||||
# alte Namen (VPS_URL, PI_API_TOKEN) weiter akzeptieren
|
||||
server_url = env.get("SERVER_URL") or env.get("VPS_URL")
|
||||
token = env.get("PLAYER_API_TOKEN") or env.get("PI_API_TOKEN")
|
||||
server = ServerVerbindung(server_url, token) if server_url and token else None
|
||||
if not server:
|
||||
log.warning("SERVER_URL/PLAYER_API_TOKEN fehlen – nur Grund-Playlist, keine Wünsche")
|
||||
controller = Controller(
|
||||
sp,
|
||||
Playlist(sp, env["SPOTIFY_PLAYLIST_ID"]),
|
||||
server,
|
||||
n=int(env.get("WISH_EVERY_N") or 3),
|
||||
geraetename=env.get("DEVICE_NAME") or "Audiofy",
|
||||
bt_mac=env.get("BT_SPEAKER_MAC") or None,
|
||||
zustand_pfad=args.env.parent / "zustand.json",
|
||||
env_pfad=args.env,
|
||||
)
|
||||
log.info("Controller gestartet (Gerät '%s', Server: %s)", controller.geraetename, server_url or "–")
|
||||
|
||||
backoff = POLL_S
|
||||
while True:
|
||||
try:
|
||||
controller.bluetooth_pruefen()
|
||||
befehle = controller.server_abgleich()
|
||||
controller.schritt()
|
||||
controller.befehle_ausfuehren(befehle)
|
||||
controller.melden()
|
||||
controller.bluetooth_melden()
|
||||
controller.zustand_sichern()
|
||||
backoff = POLL_S
|
||||
time.sleep(POLL_S)
|
||||
except KeyboardInterrupt:
|
||||
log.info("Beendet")
|
||||
return
|
||||
except SpotifyFehler as e:
|
||||
if e.retry_after:
|
||||
log.warning("Spotify-Rate-Limit – warte %d s", e.retry_after)
|
||||
time.sleep(e.retry_after)
|
||||
continue
|
||||
log.error("Spotify-Fehler %s – neuer Versuch in %d s", e, backoff)
|
||||
time.sleep(backoff)
|
||||
backoff = min(backoff * 2, MAX_BACKOFF_S)
|
||||
except httpx.HTTPError as e:
|
||||
log.error("Netzwerkfehler (%s) – neuer Versuch in %d s", type(e).__name__, backoff)
|
||||
time.sleep(backoff)
|
||||
backoff = min(backoff * 2, MAX_BACKOFF_S)
|
||||
except RuntimeError as e:
|
||||
log.error("%s – neuer Versuch in %d s", e, backoff)
|
||||
controller.melden() # Fehler im Admin-Bereich sichtbar machen
|
||||
time.sleep(backoff)
|
||||
backoff = min(backoff * 2, MAX_BACKOFF_S)
|
||||
except Exception:
|
||||
log.exception("Unerwarteter Fehler – neuer Versuch in %d s", backoff)
|
||||
time.sleep(backoff)
|
||||
backoff = min(backoff * 2, MAX_BACKOFF_S)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Audiofy – Bluetooth-Lautsprecher suchen, koppeln und verbinden (bluetoothctl, ohne sudo).
|
||||
|
||||
Gekoppelt wird immer MIT Agent in derselben bluetoothctl-Sitzung – sonst speichert
|
||||
BlueZ den Schlüssel nicht dauerhaft ("Bonded: no"), und der Lautsprecher lässt sich
|
||||
nach dem Aus- und Einschalten nicht mehr verbinden ("Permission denied").
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
log = logging.getLogger("audiofy.lautsprecher")
|
||||
|
||||
MAC_RE = re.compile(r"^([0-9A-F]{2}:){5}[0-9A-F]{2}$")
|
||||
SUCHDAUER_S = 15
|
||||
MAX_GERAETE = 30
|
||||
|
||||
|
||||
def verfuegbar() -> bool:
|
||||
return shutil.which("bluetoothctl") is not None
|
||||
|
||||
|
||||
def _ctl(*args: str, timeout: int = 20) -> str:
|
||||
try:
|
||||
return subprocess.run(["bluetoothctl", *args], capture_output=True, text=True, timeout=timeout).stdout
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
log.warning("bluetoothctl %s fehlgeschlagen: %s", args[0] if args else "", e)
|
||||
return ""
|
||||
|
||||
|
||||
def info(mac: str) -> dict:
|
||||
"""Name und Zustand eines Geräts; "audio" = kann Musik abspielen (A2DP-Sink)."""
|
||||
text = _ctl("info", mac, timeout=10)
|
||||
name = re.search(r"^\s*Name:\s*(.+)$", text, re.M)
|
||||
ja = lambda feld: re.search(rf"^\s*{feld}:\s*yes", text, re.M) is not None # noqa: E731
|
||||
return {
|
||||
"mac": mac,
|
||||
"name": (name.group(1).strip() if name else "")[:100],
|
||||
"paired": ja("Paired"),
|
||||
"bonded": ja("Bonded"),
|
||||
"connected": ja("Connected"),
|
||||
"audio": "Audio Sink" in text or re.search(r"^\s*Icon:\s*audio", text, re.M) is not None,
|
||||
}
|
||||
|
||||
|
||||
def oeffentlich(geraet: dict) -> dict:
|
||||
"""Nur die Felder, die an den Server gemeldet werden."""
|
||||
return {k: geraet[k] for k in ("mac", "name", "paired", "connected")}
|
||||
|
||||
|
||||
def suchen(dauer: int = SUCHDAUER_S) -> list[dict]:
|
||||
"""Sucht Bluetooth-Geräte und liefert nur Audiogeräte (verbundene und gekoppelte zuerst)."""
|
||||
_ctl("--timeout", str(dauer), "scan", "on", timeout=dauer + 10)
|
||||
macs = re.findall(r"^Device\s+(([0-9A-F]{2}:){5}[0-9A-F]{2})", _ctl("devices"), re.M)
|
||||
geraete = [g for g in (info(m[0]) for m in macs) if g["audio"]]
|
||||
geraete.sort(key=lambda g: (not g["connected"], not g["paired"], g["name"].casefold()))
|
||||
return geraete[:MAX_GERAETE]
|
||||
|
||||
|
||||
def trennen(mac: str) -> None:
|
||||
_ctl("disconnect", mac, timeout=15)
|
||||
|
||||
|
||||
def verbinden(mac: str) -> bool:
|
||||
_ctl("--timeout", "15", "connect", mac, timeout=25)
|
||||
return info(mac)["connected"]
|
||||
|
||||
|
||||
def koppeln(mac: str) -> tuple[bool, str]:
|
||||
"""Koppelt (falls nötig, mit Agent), vertraut und verbindet. Liefert (ok, Meldung)."""
|
||||
if not MAC_RE.match(mac):
|
||||
return False, "Ungültige Adresse"
|
||||
vorher = info(mac)
|
||||
if not vorher["bonded"]:
|
||||
# Eine interaktive Sitzung, damit der Agent während des Koppelns registriert bleibt
|
||||
befehle = []
|
||||
if vorher["paired"]:
|
||||
befehle.append((f"remove {mac}", 2)) # halbe Kopplung ohne Schlüssel verwerfen
|
||||
befehle += [("agent NoInputNoOutput", 1), ("default-agent", 1), ("pairable on", 1),
|
||||
("scan on", 8), ("scan off", 1), (f"pair {mac}", 12), (f"trust {mac}", 2),
|
||||
(f"connect {mac}", 10), ("quit", 1)]
|
||||
p = None
|
||||
try:
|
||||
p = subprocess.Popen(["bluetoothctl"], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, text=True)
|
||||
for befehl, warten in befehle:
|
||||
p.stdin.write(befehl + "\n")
|
||||
p.stdin.flush()
|
||||
time.sleep(warten)
|
||||
p.stdin.close()
|
||||
p.wait(timeout=10)
|
||||
except (OSError, subprocess.SubprocessError, BrokenPipeError) as e:
|
||||
return False, f"Kopplung fehlgeschlagen ({type(e).__name__})"
|
||||
finally:
|
||||
if p is not None and p.poll() is None:
|
||||
p.kill()
|
||||
else:
|
||||
_ctl("trust", mac)
|
||||
|
||||
jetzt = info(mac)
|
||||
if not jetzt["connected"] and jetzt["bonded"]:
|
||||
verbinden(mac)
|
||||
jetzt = info(mac)
|
||||
if jetzt["connected"] and jetzt["bonded"]:
|
||||
return True, f"Verbunden mit {jetzt['name'] or mac}"
|
||||
if not jetzt["bonded"]:
|
||||
return False, "Kopplung fehlgeschlagen – ist der Lautsprecher im Kopplungsmodus?"
|
||||
return False, "Gekoppelt, aber keine Verbindung – Lautsprecher eingeschaltet und in Reichweite?"
|
||||
@@ -0,0 +1 @@
|
||||
httpx==0.28.1
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Audiofy – Verbindung Player → Server (Player-API mit Bearer-Token, nur ausgehend).
|
||||
|
||||
Alle Aufrufe fangen Fehler selbst ab: Ist der Server nicht erreichbar, liefern sie
|
||||
None/False, und der Controller spielt einfach die Grund-Playlist weiter. Im
|
||||
Offline-Fall wird nur alle OFFLINE_RETRY_S Sekunden ein neuer Versuch gemacht.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
log = logging.getLogger("audiofy.server")
|
||||
|
||||
OFFLINE_RETRY_S = 15
|
||||
TIMEOUT_S = 5
|
||||
|
||||
|
||||
@dataclass
|
||||
class Wunsch:
|
||||
id: int
|
||||
uri: str
|
||||
title: str = ""
|
||||
artist: str = ""
|
||||
priority: bool = False # "Als Nächstes" des Besitzers
|
||||
|
||||
|
||||
class ServerVerbindung:
|
||||
def __init__(self, basis_url: str, token: str):
|
||||
self._http = httpx.Client(base_url=basis_url.rstrip("/") + "/api/player", timeout=TIMEOUT_S,
|
||||
headers={"Authorization": f"Bearer {token}"})
|
||||
self.online: bool | None = None
|
||||
self._naechster_versuch = 0.0
|
||||
self._unbestaetigt: set[int] = set() # gespielte Wünsche, deren Meldung noch fehlt
|
||||
|
||||
# --- intern ----------------------------------------------------------------
|
||||
|
||||
def _anfrage(self, methode: str, pfad: str, **kwargs):
|
||||
"""JSON der Antwort oder None bei jedem Fehler (Netz, Timeout, HTTP-Fehler)."""
|
||||
if self.online is False and time.monotonic() < self._naechster_versuch:
|
||||
return None
|
||||
try:
|
||||
r = self._http.request(methode, pfad, **kwargs)
|
||||
if r.status_code == 401:
|
||||
self._offline("Token abgelehnt (401) – PLAYER_API_TOKEN prüfen")
|
||||
return None
|
||||
r.raise_for_status()
|
||||
if self.online is not True:
|
||||
log.info("Server erreichbar")
|
||||
self.online = True
|
||||
return r.json() if r.content else {}
|
||||
except (httpx.HTTPError, ValueError) as e:
|
||||
self._offline(f"{type(e).__name__}")
|
||||
return None
|
||||
|
||||
def _offline(self, grund: str) -> None:
|
||||
if self.online is not False:
|
||||
log.warning("Server nicht erreichbar (%s) – spiele Grund-Playlist weiter, neuer Versuch alle %d s",
|
||||
grund, OFFLINE_RETRY_S)
|
||||
self.online = False
|
||||
self._naechster_versuch = time.monotonic() + OFFLINE_RETRY_S
|
||||
|
||||
# --- Player-API ----------------------------------------------------------------
|
||||
|
||||
def konfiguration(self) -> dict | None:
|
||||
return self._anfrage("GET", "/config")
|
||||
|
||||
def naechster_wunsch(self) -> Wunsch | None:
|
||||
self._bestaetigungen_nachholen()
|
||||
daten = self._anfrage("GET", "/next-wish")
|
||||
w = (daten or {}).get("wish")
|
||||
if not w or w["id"] in self._unbestaetigt:
|
||||
return None # noch nicht als gespielt bestätigt → nicht doppelt spielen
|
||||
return Wunsch(id=w["id"], uri=w["track_uri"], title=w.get("title", ""), artist=w.get("artist", ""),
|
||||
priority=bool(w.get("priority")))
|
||||
|
||||
def wunsch_gespielt(self, wunsch: Wunsch) -> None:
|
||||
self._unbestaetigt.add(wunsch.id)
|
||||
self._bestaetigungen_nachholen()
|
||||
|
||||
def _bestaetigungen_nachholen(self) -> None:
|
||||
for wid in list(self._unbestaetigt):
|
||||
if self._anfrage("POST", f"/wish/{wid}/played") is not None:
|
||||
self._unbestaetigt.discard(wid)
|
||||
|
||||
def melden(self, zustand: dict) -> bool:
|
||||
return self._anfrage("POST", "/now-playing", json=zustand) is not None
|
||||
|
||||
def bluetooth_melden(self, zustand: dict) -> bool:
|
||||
return self._anfrage("POST", "/bluetooth", json=zustand) is not None
|
||||
|
||||
def quittieren(self, befehl_id: int, ok: bool, meldung: str | None = None) -> None:
|
||||
self._anfrage("POST", f"/commands/{befehl_id}/ack", json={"ok": ok, "message": meldung})
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Audiofy – schlanker Spotify-Web-API-Client für den Controller (httpx).
|
||||
|
||||
Erneuert den Access-Token selbst per Refresh-Token. Fehler werden als
|
||||
SpotifyFehler geworfen (bei 429 mit retry_after); Netzwerkfehler kommen als
|
||||
httpx.HTTPError durch – die Wiederholung übernimmt der Controller.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
import httpx
|
||||
|
||||
log = logging.getLogger("audiofy.spotify")
|
||||
|
||||
API = "https://api.spotify.com/v1"
|
||||
TOKEN_URL = "https://accounts.spotify.com/api/token"
|
||||
|
||||
|
||||
class SpotifyFehler(Exception):
|
||||
def __init__(self, status: int, nachricht: str, retry_after: int | None = None):
|
||||
super().__init__(f"{status}: {nachricht}")
|
||||
self.status = status
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
def _fehlertext(r: httpx.Response) -> str:
|
||||
try:
|
||||
fehler = r.json().get("error")
|
||||
if isinstance(fehler, dict):
|
||||
return fehler.get("message", "") + (f" ({fehler['reason']})" if fehler.get("reason") else "")
|
||||
return str(r.json().get("error_description") or fehler)
|
||||
except ValueError:
|
||||
return r.text[:200]
|
||||
|
||||
|
||||
class SpotifyClient:
|
||||
def __init__(self, client_id: str, client_secret: str, refresh_token: str,
|
||||
neuer_refresh_token: Callable[[str], None] | None = None):
|
||||
self._basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
||||
self._refresh_token = refresh_token
|
||||
self._neuer_refresh_token = neuer_refresh_token
|
||||
self._token: str | None = None
|
||||
self._ablauf = 0.0
|
||||
self._http = httpx.Client(timeout=10)
|
||||
|
||||
def _access_token(self) -> str:
|
||||
if self._token and time.monotonic() < self._ablauf - 60:
|
||||
return self._token
|
||||
r = self._http.post(TOKEN_URL, headers={"Authorization": f"Basic {self._basic}"},
|
||||
data={"grant_type": "refresh_token", "refresh_token": self._refresh_token})
|
||||
if r.status_code != 200:
|
||||
raise SpotifyFehler(r.status_code, "Token-Erneuerung fehlgeschlagen: " + _fehlertext(r))
|
||||
daten = r.json()
|
||||
self._token = daten["access_token"]
|
||||
self._ablauf = time.monotonic() + daten.get("expires_in", 3600)
|
||||
# Spotify kann den Refresh-Token rotieren – dann den neuen dauerhaft speichern
|
||||
neu = daten.get("refresh_token")
|
||||
if neu and neu != self._refresh_token:
|
||||
self._refresh_token = neu
|
||||
if self._neuer_refresh_token:
|
||||
self._neuer_refresh_token(neu)
|
||||
log.info("Neuer Refresh-Token von Spotify erhalten und gespeichert")
|
||||
return self._token
|
||||
|
||||
def anfrage(self, methode: str, pfad: str, **kwargs):
|
||||
"""Liefert das JSON der Antwort oder None (204, leerer oder Nicht-JSON-Body)."""
|
||||
url = pfad if pfad.startswith("http") else API + pfad
|
||||
for versuch in range(2):
|
||||
r = self._http.request(methode, url, headers={"Authorization": f"Bearer {self._access_token()}"},
|
||||
**kwargs)
|
||||
if r.status_code == 401 and versuch == 0:
|
||||
self._token = None # Token abgelaufen/ungültig → einmal erneuern
|
||||
continue
|
||||
if r.status_code == 429:
|
||||
raise SpotifyFehler(429, "Rate-Limit", int(r.headers.get("Retry-After", "5")))
|
||||
if r.status_code >= 400:
|
||||
raise SpotifyFehler(r.status_code, _fehlertext(r))
|
||||
if not r.content:
|
||||
return None
|
||||
try:
|
||||
return r.json()
|
||||
except ValueError:
|
||||
# z. B. PUT /me/player/pause liefert teils 200 mit Nicht-JSON-Body
|
||||
return None
|
||||
raise SpotifyFehler(401, "Nicht autorisiert")
|
||||
|
||||
# --- Wiedergabe -------------------------------------------------------
|
||||
|
||||
def wiedergabe(self) -> dict | None:
|
||||
"""GET /me/player – None, wenn gerade nichts aktiv ist (204)."""
|
||||
return self.anfrage("GET", "/me/player", params={"market": "from_token"})
|
||||
|
||||
def geraete(self) -> list[dict]:
|
||||
return (self.anfrage("GET", "/me/player/devices") or {}).get("devices", [])
|
||||
|
||||
def einreihen(self, uri: str, device_id: str) -> None:
|
||||
self.anfrage("POST", "/me/player/queue", params={"uri": uri, "device_id": device_id})
|
||||
|
||||
def titel(self, uri: str) -> dict:
|
||||
"""Metadaten eines Titels (GET /tracks/{id})."""
|
||||
return self.anfrage("GET", f"/tracks/{uri.rsplit(':', 1)[-1]}", params={"market": "from_token"}) or {}
|
||||
|
||||
def fortsetzen(self, device_id: str) -> None:
|
||||
"""Pausierte Wiedergabe auf dem Gerät fortsetzen (ohne Titelwechsel)."""
|
||||
self.anfrage("PUT", "/me/player/play", params={"device_id": device_id})
|
||||
|
||||
def abspielen(self, uris: list[str], device_id: str) -> None:
|
||||
self.anfrage("PUT", "/me/player/play", params={"device_id": device_id}, json={"uris": uris})
|
||||
|
||||
# --- Playlist ---------------------------------------------------------
|
||||
|
||||
def playlist_titel(self, playlist_id: str) -> list[str]:
|
||||
"""Alle abspielbaren Track-URIs der Playlist (seit 2026 nur über /items, Feld "item")."""
|
||||
uris: list[str] = []
|
||||
pfad: str | None = f"/playlists/{playlist_id}/items"
|
||||
params: dict | None = {"limit": 50, "market": "from_token"}
|
||||
while pfad:
|
||||
daten = self.anfrage("GET", pfad, params=params) or {}
|
||||
for eintrag in daten.get("items", []):
|
||||
titel = eintrag.get("item") or eintrag.get("track") or {}
|
||||
uri = titel.get("uri", "")
|
||||
if titel.get("type") == "track" and uri.startswith("spotify:track:") \
|
||||
and titel.get("is_playable", True):
|
||||
uris.append(uri)
|
||||
pfad, params = daten.get("next"), None # "next" enthält die Query schon
|
||||
return uris
|
||||
Reference in New Issue
Block a user