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>
717 lines
34 KiB
Python
717 lines
34 KiB
Python
#!/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()
|