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:
@@ -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()
|
||||
Reference in New Issue
Block a user