#!/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()