Files
playwish/player/controller.py
T
99admandClaude Opus 5.5 df5567e6a3 Wiederholungsregel für die Grund-Playlist
Ein Playlist-Titel kommt erst wieder, wenn seit seinem letzten Einsatz
mindestens X % der Playlist gelaufen sind (Admin → Einstellungen → Ablauf,
Standard 50 %). Der Player merkt sich die gespielten Titel über Neustarts.
Wünsche sind ausgenommen (dort gilt weiter die Sperrzeit in Minuten).

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

767 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Audiofy – 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
ZULETZT_MAX = 1000 # so viele zuletzt gespielte Playlist-Titel merken (für die Wiederholungsregel)
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.
Zusätzlich gilt die Wiederholungsregel: Ein Titel kommt erst wieder, wenn seit
seinem letzten Einsatz mindestens wiederholung_prozent % der Playlist (verschiedene
Titel) gelaufen sind – auch über Neustarts und neues Mischen hinweg (zuletzt wird
in der Zustandsdatei gespeichert). Wünsche zählen hier nicht mit.
"""
def __init__(self, sp: SpotifyClient, playlist_id: str, wiederholung_prozent: int = 50):
self.sp = sp
self.id = playlist_id
self.wiederholung_prozent = wiederholung_prozent
self.zuletzt: deque[str] = deque(maxlen=ZULETZT_MAX) # gespielte Playlist-Titel, neueste hinten
self._alle: list[str] = []
self._stapel: list[str] = []
def wechseln(self, playlist_id: str) -> None:
self.id = playlist_id
self._alle, self._stapel = [], []
def neu_mischen(self) -> None:
self._alle, self._stapel = [], [] # beim nächsten Titel neu laden und mischen
def gespielt(self, uri: str) -> None:
"""Ein Titel aus der Playlist ist tatsächlich gelaufen."""
self.zuletzt.append(uri)
def gesperrt(self) -> set[str]:
"""Titel, die wegen der Wiederholungsregel gerade nicht dran sein dürfen."""
anzahl = len(self._alle)
# mindestens ein Titel muss immer frei bleiben
grenze = min(anzahl - 1, anzahl * max(0, min(100, self.wiederholung_prozent)) // 100)
sperre: set[str] = set()
if grenze <= 0:
return sperre
vorhanden = set(self._alle)
for uri in reversed(self.zuletzt):
if uri in vorhanden:
sperre.add(uri)
if len(sperre) >= grenze:
break
return sperre
def naechster(self) -> str:
if not self._alle:
self._alle = self.sp.playlist_titel(self.id)
self._stapel = []
if not self._alle:
raise RuntimeError("Grund-Playlist ist leer oder nicht lesbar")
sperre = self.gesperrt()
for runde in range(2):
while self._stapel:
uri = self._stapel.pop()
if uri not in sperre:
return uri
if runde == 0:
# Durchlauf zu Ende → neu laden (Änderungen an der Playlist übernehmen) und mischen
self._alle = self.sp.playlist_titel(self.id) or self._alle
sperre = self.gesperrt()
self._stapel = [u for u in self._alle if u not in sperre]
random.shuffle(self._stapel)
log.info("Grund-Playlist gemischt: %d Titel, davon %d gerade gesperrt (Wiederholungsregel %d %%)",
len(self._alle), len(sperre), self.wiederholung_prozent)
# Sollte nicht vorkommen (es bleibt immer ein Titel frei) – zur Sicherheit der am längsten nicht gespielte
letzte = {u: i for i, u in enumerate(self.zuletzt)}
return min(self._alle, key=lambda u: letzte.get(u, -1))
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"))
if self.playlist is not None:
self.playlist.zuletzt.extend(u for u in daten.get("zuletzt", []) if isinstance(u, str))
self._gespeichert = self._zustand()
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(self) -> dict:
return {"besitzer_pause": self.besitzer_pause, "stopp": self.stopp,
"zuletzt": list(self.playlist.zuletzt) if self.playlist is not None else []}
def zustand_sichern(self) -> None:
"""Schreibt nur bei Änderungen (schont die SD-Karte)."""
if not self.zustand_pfad:
return
aktuell = self._zustand()
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"])
prozent = k.get("playlist_repeat_percent")
if isinstance(prozent, int) and prozent != self.playlist.wiederholung_prozent:
log.info("Wiederholungsregel: Playlist-Titel erst wieder nach %d %% der Playlist", prozent)
self.playlist.wiederholung_prozent = prozent
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
self.playlist.gespielt(eintrag.uri)
# "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"], int(env.get("PLAYLIST_REPEAT_PERCENT") or 50)),
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()