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:
2026-10-17 19:16:39 +01:00
co-authored by Claude Opus 5.5
commit 3610de7bae
45 changed files with 5666 additions and 0 deletions
+110
View File
@@ -0,0 +1,110 @@
"""Audiofy – Bluetooth-Lautsprecher suchen, koppeln und verbinden (bluetoothctl, ohne sudo).
Gekoppelt wird immer MIT Agent in derselben bluetoothctl-Sitzung – sonst speichert
BlueZ den Schlüssel nicht dauerhaft ("Bonded: no"), und der Lautsprecher lässt sich
nach dem Aus- und Einschalten nicht mehr verbinden ("Permission denied").
"""
import logging
import re
import shutil
import subprocess
import time
log = logging.getLogger("audiofy.lautsprecher")
MAC_RE = re.compile(r"^([0-9A-F]{2}:){5}[0-9A-F]{2}$")
SUCHDAUER_S = 15
MAX_GERAETE = 30
def verfuegbar() -> bool:
return shutil.which("bluetoothctl") is not None
def _ctl(*args: str, timeout: int = 20) -> str:
try:
return subprocess.run(["bluetoothctl", *args], capture_output=True, text=True, timeout=timeout).stdout
except (OSError, subprocess.SubprocessError) as e:
log.warning("bluetoothctl %s fehlgeschlagen: %s", args[0] if args else "", e)
return ""
def info(mac: str) -> dict:
"""Name und Zustand eines Geräts; "audio" = kann Musik abspielen (A2DP-Sink)."""
text = _ctl("info", mac, timeout=10)
name = re.search(r"^\s*Name:\s*(.+)$", text, re.M)
ja = lambda feld: re.search(rf"^\s*{feld}:\s*yes", text, re.M) is not None # noqa: E731
return {
"mac": mac,
"name": (name.group(1).strip() if name else "")[:100],
"paired": ja("Paired"),
"bonded": ja("Bonded"),
"connected": ja("Connected"),
"audio": "Audio Sink" in text or re.search(r"^\s*Icon:\s*audio", text, re.M) is not None,
}
def oeffentlich(geraet: dict) -> dict:
"""Nur die Felder, die an den Server gemeldet werden."""
return {k: geraet[k] for k in ("mac", "name", "paired", "connected")}
def suchen(dauer: int = SUCHDAUER_S) -> list[dict]:
"""Sucht Bluetooth-Geräte und liefert nur Audiogeräte (verbundene und gekoppelte zuerst)."""
_ctl("--timeout", str(dauer), "scan", "on", timeout=dauer + 10)
macs = re.findall(r"^Device\s+(([0-9A-F]{2}:){5}[0-9A-F]{2})", _ctl("devices"), re.M)
geraete = [g for g in (info(m[0]) for m in macs) if g["audio"]]
geraete.sort(key=lambda g: (not g["connected"], not g["paired"], g["name"].casefold()))
return geraete[:MAX_GERAETE]
def trennen(mac: str) -> None:
_ctl("disconnect", mac, timeout=15)
def verbinden(mac: str) -> bool:
_ctl("--timeout", "15", "connect", mac, timeout=25)
return info(mac)["connected"]
def koppeln(mac: str) -> tuple[bool, str]:
"""Koppelt (falls nötig, mit Agent), vertraut und verbindet. Liefert (ok, Meldung)."""
if not MAC_RE.match(mac):
return False, "Ungültige Adresse"
vorher = info(mac)
if not vorher["bonded"]:
# Eine interaktive Sitzung, damit der Agent während des Koppelns registriert bleibt
befehle = []
if vorher["paired"]:
befehle.append((f"remove {mac}", 2)) # halbe Kopplung ohne Schlüssel verwerfen
befehle += [("agent NoInputNoOutput", 1), ("default-agent", 1), ("pairable on", 1),
("scan on", 8), ("scan off", 1), (f"pair {mac}", 12), (f"trust {mac}", 2),
(f"connect {mac}", 10), ("quit", 1)]
p = None
try:
p = subprocess.Popen(["bluetoothctl"], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, text=True)
for befehl, warten in befehle:
p.stdin.write(befehl + "\n")
p.stdin.flush()
time.sleep(warten)
p.stdin.close()
p.wait(timeout=10)
except (OSError, subprocess.SubprocessError, BrokenPipeError) as e:
return False, f"Kopplung fehlgeschlagen ({type(e).__name__})"
finally:
if p is not None and p.poll() is None:
p.kill()
else:
_ctl("trust", mac)
jetzt = info(mac)
if not jetzt["connected"] and jetzt["bonded"]:
verbinden(mac)
jetzt = info(mac)
if jetzt["connected"] and jetzt["bonded"]:
return True, f"Verbunden mit {jetzt['name'] or mac}"
if not jetzt["bonded"]:
return False, "Kopplung fehlgeschlagen – ist der Lautsprecher im Kopplungsmodus?"
return False, "Gekoppelt, aber keine Verbindung – Lautsprecher eingeschaltet und in Reichweite?"