Durchsicht: Robustheit bei Neuinstallationen

- auth.py: .env auch in schreibgeschütztem Ordner aktualisieren (Server-Login)
- Controller: ohne Spotify-Login warten statt abstürzen; Playlist darf fehlen;
  nicht abspielbare Wünsche überspringen statt hängen zu bleiben
- install-server.sh: alle SSH-Ports freigeben (kein Aussperren), curl und
  python3-systemd (fail2ban) installieren
- install-player.sh: Bluetooth per rfkill entsperren und Dienst starten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 19:55:00 +01:00
co-authored by Claude Opus 5.5
parent 1c31ac2b6d
commit b83da952ee
6 changed files with 129 additions and 13 deletions
+8 -1
View File
@@ -73,10 +73,17 @@ def env_setzen(pfad: Path, key: str, wert: str) -> None:
if not gefunden:
neu.append(f"{key}={wert}")
inhalt = "\n".join(neu) + "\n"
if not os.access(pfad.parent, os.W_OK):
# z. B. Server: /opt/audiofy gehört root, nur die .env selbst dem Dienst-Benutzer
fd = os.open(pfad, os.O_WRONLY | os.O_TRUNC)
with os.fdopen(fd, "w") as f:
f.write(inhalt)
return
tmp = pfad.with_name(pfad.name + ".tmp")
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
f.write("\n".join(neu) + "\n")
f.write(inhalt)
os.replace(tmp, pfad)
+28 -4
View File
@@ -14,7 +14,7 @@ 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
Aufruf: .venv/bin/python controller.py --env /opt/audiofy/config/.env
"""
import argparse
@@ -247,7 +247,7 @@ class Controller:
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):
if wunsch and (wunsch.priority or self.seit_wunsch >= self.n - 1) and self._abspielbar(wunsch):
eintrag = Eintrag(wunsch.uri, "owner" if wunsch.priority else "wish", wunsch)
else:
# kein (fälliger) Wunsch → Playlist weiter, Zähler bleibt "fällig"
@@ -265,6 +265,21 @@ class Controller:
log.warning("Metadaten für %s nicht abrufbar (%s) – Vergleich nur per URI", eintrag.uri, e)
return eintrag
def _abspielbar(self, wunsch: Wunsch) -> bool:
"""Ein bei Spotify nicht (mehr) abspielbarer Wunsch würde den Controller blockieren → überspringen."""
try:
titel = self.sp.titel(wunsch.uri)
except SpotifyFehler as e:
if e.status not in (400, 404):
return True # vorübergehender Fehler – Wunsch nicht verwerfen
titel = {"is_playable": False}
if titel.get("is_playable", True):
return True
log.warning("Wunsch %s ist bei Spotify nicht abspielbar – wird übersprungen", wunsch.uri)
if self.server:
self.server.wunsch_gespielt(wunsch) # aus der Warteschlange nehmen
return False
def geraet_id(self) -> str | None:
for geraet in self.sp.geraete():
if geraet.get("name") == self.geraetename:
@@ -703,7 +718,15 @@ def main() -> None:
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)
# Ohne Spotify-Zugang nicht abstürzen (systemd würde endlos neu starten), sondern warten
while True:
env = env_lesen(args.env)
fehlend = [k for k in ("SPOTIFY_CLIENT_ID", "SPOTIFY_CLIENT_SECRET", "SPOTIFY_REFRESH_TOKEN") if not env.get(k)]
if not fehlend:
break
log.error("In %s fehlt %s – Spotify-Login nachholen: auth.py --profil player --env %s "
"(siehe README). Neuer Versuch in 60 s.", args.env, ", ".join(fehlend), args.env)
time.sleep(60)
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
@@ -714,7 +737,8 @@ def main() -> None:
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)),
# Playlist darf fehlen, wenn sie vom Server kommt
Playlist(sp, env.get("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",
+80
View File
@@ -0,0 +1,80 @@
"""Tests für Schutzmaßnahmen: nicht abspielbare Wünsche, .env in schreibgeschütztem Ordner."""
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from auth import env_lesen, env_setzen # noqa: E402
from controller import Controller, Playlist # noqa: E402
from server_client import Wunsch # noqa: E402
from spotify_client import SpotifyFehler # noqa: E402
TITEL = [f"spotify:track:{i:022d}" for i in range(5)]
WUNSCH = "spotify:track:" + "W" * 22
class FakeSpotify:
def __init__(self, wunsch_status="ok"):
self.wunsch_status = wunsch_status
def playlist_titel(self, playlist_id):
return list(TITEL)
def titel(self, uri):
if uri == WUNSCH and self.wunsch_status == "404":
raise SpotifyFehler(404, "nicht gefunden")
if uri == WUNSCH and self.wunsch_status == "503":
raise SpotifyFehler(503, "kurz weg")
return {"uri": uri, "name": "x", "artists": [{"name": "y"}],
"is_playable": not (uri == WUNSCH and self.wunsch_status == "gesperrt")}
class FakeServer:
def __init__(self):
self.gespielt = []
def naechster_wunsch(self):
return Wunsch(id=7, uri=WUNSCH)
def wunsch_gespielt(self, wunsch):
self.gespielt.append(wunsch.id)
def controller(sp, server):
c = Controller(sp=sp, playlist=Playlist(sp, "liste"), server=server, n=1, geraetename="Audiofy", bt_mac=None)
return c
def test_abspielbarer_wunsch_wird_gespielt():
server = FakeServer()
assert controller(FakeSpotify("ok"), server).naechster_eintrag().quelle == "wish"
assert server.gespielt == []
def test_gesperrter_wunsch_wird_uebersprungen():
for status in ("gesperrt", "404"):
server = FakeServer()
eintrag = controller(FakeSpotify(status), server).naechster_eintrag()
assert eintrag.quelle == "playlist" and eintrag.uri in TITEL
assert server.gespielt == [7], status # aus der Warteschlange genommen
def test_voruebergehender_fehler_verwirft_wunsch_nicht():
server = FakeServer()
controller(FakeSpotify("503"), server).naechster_eintrag()
assert server.gespielt == []
def test_env_in_schreibgeschuetztem_ordner(tmp_path):
"""Wie auf dem Server: Ordner gehört root, nur die .env dem Dienst-Benutzer."""
env = tmp_path / ".env"
env.write_text("SPOTIFY_CLIENT_ID=abc\n")
os.chmod(env, 0o600)
os.chmod(tmp_path, 0o500)
try:
env_setzen(env, "SPOTIFY_REFRESH_TOKEN", "neu")
assert env_lesen(env) == {"SPOTIFY_CLIENT_ID": "abc", "SPOTIFY_REFRESH_TOKEN": "neu"}
assert oct(env.stat().st_mode & 0o777) == "0o600"
finally:
os.chmod(tmp_path, 0o700)