diff --git a/README.md b/README.md index f6960e8..6eb7d9a 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Admin-Bereich, auch vom Handy. - **Gäste:** Spotify-Suche, Wünschen (optional mit Namen), „Läuft gerade“, Warteschlange – ohne Spotify-Konto - **Schutz:** Zugang nur per Einladungslink/QR-Code, Limit pro Gast, keine Duplikate, Sperrzeit für Wiederholungen, Sperrliste, optional Moderation und Cloudflare Turnstile - **Admin:** Play/Pause/Weiter/Lautstärke, „Als Nächstes spielen“, Not-Aus, Wünsche freigeben/umsortieren/löschen, Gäste sperren, Playlist wechseln, Bluetooth-Lautsprecher suchen und wechseln, Verlauf +- **Abwechslung:** Ein Playlist-Titel kommt erst wieder, wenn seit seinem letzten Einsatz mindestens X % der Playlist gelaufen sind (einstellbar, auch über Neustarts); Wünsche haben eine eigene Sperrzeit in Minuten - **Robust:** Ohne Server spielt der Player die Playlist weiter; Bedienung über die normale Spotify-App bleibt möglich; Pause des Gastgebers wird auch über Neustarts respektiert ## Aufbau @@ -155,7 +156,9 @@ eigenen Refresh-Token desselben Kontos. ``` cd server && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt pytest -.venv/bin/python -m pytest -q +.venv/bin/python -m pytest -q # Server +cd ../player && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt pytest +.venv/bin/python -m pytest -q tests # Player (Wiederholungsregel) ``` ## Lizenz diff --git a/player/controller.py b/player/controller.py index 3b7381c..3f0636e 100644 --- a/player/controller.py +++ b/player/controller.py @@ -50,6 +50,7 @@ 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 @@ -88,34 +89,72 @@ class Eintrag: class Playlist: - """Grund-Playlist gemischt; ohne Wiederholung, bis alle Titel durch sind.""" + """Grund-Playlist gemischt, ohne Wiederholung, bis alle Titel durch sind. - def __init__(self, sp: SpotifyClient, playlist_id: str): + 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] = [] - self._letzter: str | None = None def wechseln(self, playlist_id: str) -> None: self.id = playlist_id - self._stapel = [] + self._alle, self._stapel = [], [] def neu_mischen(self) -> None: - self._stapel = [] # beim nächsten Titel neu laden und mischen + 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._stapel: - titel = self.sp.playlist_titel(self.id) - if not titel: + 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") - 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 + 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: @@ -177,17 +216,23 @@ class Controller: 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.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 = {"besitzer_pause": self.besitzer_pause, "stopp": self.stopp} + aktuell = self._zustand() if aktuell == self._gespeichert: return try: @@ -273,6 +318,10 @@ class Controller: 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") @@ -575,6 +624,7 @@ class Controller: 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" @@ -664,7 +714,7 @@ 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"]), + 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", diff --git a/player/tests/test_playlist.py b/player/tests/test_playlist.py new file mode 100644 index 0000000..b14ae80 --- /dev/null +++ b/player/tests/test_playlist.py @@ -0,0 +1,101 @@ +"""Tests für die Wiederholungsregel der Grund-Playlist (ohne Spotify).""" + +import random +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from controller import Controller, Playlist # noqa: E402 + + +class FakeSpotify: + def __init__(self, titel): + self.titel = list(titel) + + def playlist_titel(self, playlist_id): + return list(self.titel) + + +def spielen(pl: Playlist, anzahl: int) -> list[str]: + folge = [] + for _ in range(anzahl): + uri = pl.naechster() + pl.gespielt(uri) + folge.append(uri) + return folge + + +def kleinster_abstand(folge: list[str]) -> int: + """Wie viele verschiedene andere Titel liefen mindestens zwischen zwei Einsätzen desselben Titels?""" + minimum = 10**9 + letzte: dict[str, int] = {} + for i, uri in enumerate(folge): + if uri in letzte: + minimum = min(minimum, len(set(folge[letzte[uri] + 1:i]))) + letzte[uri] = i + return minimum + + +TITEL = [f"spotify:track:{i:022d}" for i in range(10)] + + +@pytest.mark.parametrize("prozent, mindestens", [(50, 5), (80, 8), (100, 9)]) +def test_regel_haelt_abstand_ein(prozent, mindestens): + random.seed(prozent) + pl = Playlist(FakeSpotify(TITEL), "liste", prozent) + assert kleinster_abstand(spielen(pl, 300)) >= mindestens + + +def test_jeder_titel_kommt_dran(): + pl = Playlist(FakeSpotify(TITEL), "liste", 100) + assert set(spielen(pl, 10)) == set(TITEL) + + +def test_regel_gilt_ueber_neustart(): + random.seed(1) + alt = Playlist(FakeSpotify(TITEL), "liste", 50) + vorher = spielen(alt, 7) + neu = Playlist(FakeSpotify(TITEL), "liste", 50) # "Neustart" + neu.zuletzt.extend(alt.zuletzt) + gesperrt = set(vorher[-5:]) + assert not gesperrt & set(spielen(neu, 5)) + + +def test_neu_mischen_bricht_regel_nicht(): + random.seed(2) + pl = Playlist(FakeSpotify(TITEL), "liste", 80) + folge = spielen(pl, 4) + pl.neu_mischen() + folge += spielen(pl, 30) + assert kleinster_abstand(folge) >= 8 + + +def test_null_prozent_wie_bisher(): + pl = Playlist(FakeSpotify(TITEL), "liste", 0) + assert pl.gesperrt() == set() + assert len(spielen(pl, 25)) == 25 + + +def test_titel_anderer_playlist_zaehlen_nicht(): + pl = Playlist(FakeSpotify(TITEL), "liste", 100) + pl.zuletzt.extend(f"spotify:track:x{i:021d}" for i in range(50)) # alte Playlist + pl.naechster() + assert pl.gesperrt() == set() + + +def test_ein_titel_bleibt_immer_frei(): + pl = Playlist(FakeSpotify(TITEL[:2]), "liste", 100) + assert len(spielen(pl, 10)) == 10 + + +def test_zustand_speichert_zuletzt(tmp_path): + datei = tmp_path / "zustand.json" + neu = lambda: Controller(sp=None, playlist=Playlist(FakeSpotify(TITEL), "liste", 50), server=None, # noqa: E731 + n=3, geraetename="Audiofy", bt_mac=None, zustand_pfad=datei) + c = neu() + c.playlist.gespielt(TITEL[3]) + c.playlist.gespielt(TITEL[4]) + c.zustand_sichern() + assert list(neu().playlist.zuletzt) == [TITEL[3], TITEL[4]] diff --git a/server/app/admin.py b/server/app/admin.py index 5b98c54..7456bce 100644 --- a/server/app/admin.py +++ b/server/app/admin.py @@ -49,6 +49,7 @@ AENDERBAR = { "wish_limit_count": (int, 1, 50), "wish_limit_minutes": (int, 1, 1440), "repeat_block_minutes": (int, 0, 1440), + "playlist_repeat_percent": (int, 0, 100), "moderation": (bool, None, None), "wishes_paused": (bool, None, None), "block_explicit": (bool, None, None), diff --git a/server/app/db.py b/server/app/db.py index 1bce307..b018802 100644 --- a/server/app/db.py +++ b/server/app/db.py @@ -105,7 +105,8 @@ STANDARD_EINSTELLUNGEN = { "wishes_paused": False, # Wunschannahme pausiert "wish_limit_count": 2, # max. Wünsche pro Gast … "wish_limit_minutes": 30, # … pro Zeitraum - "repeat_block_minutes": 60, # Sperrzeit für Wiederholungen + "repeat_block_minutes": 60, # Sperrzeit für Wiederholungen (Wünsche) + "playlist_repeat_percent": 50, # Playlist-Titel erst wieder nach X % der Playlist "block_explicit": False, # explizite Titel nicht erlauben "access_enabled": False, # Gastzugang per Einladungslink/QR-Code (anfangs aus) "access_token": "", # geheimer Teil des Einladungslinks /z/ diff --git a/server/app/player_api.py b/server/app/player_api.py index 23eadab..b05c7c2 100644 --- a/server/app/player_api.py +++ b/server/app/player_api.py @@ -90,6 +90,7 @@ def pi_config(db: sqlite3.Connection = Depends(get_db)) -> dict: "wish_every_n": e["wish_every_n"], "playlist_id": e["playlist_id"], "reshuffle_token": e["reshuffle_token"], + "playlist_repeat_percent": e["playlist_repeat_percent"], "stopped": e["emergency_stop"], "commands": offene_befehle(db), } diff --git a/server/app/static/admin.html b/server/app/static/admin.html index dada080..2e11d15 100644 --- a/server/app/static/admin.html +++ b/server/app/static/admin.html @@ -138,8 +138,10 @@

Ablauf

Jeder wievielte Titel ist ein Wunsch (N)
-
Sperrzeit für Wiederholungen (Min.)
- +
Wunsch erst wieder erlaubt nach (Min.)
+
Playlist-Titel erst wieder nach (% der Playlist)
+

Ein Titel der Grund-Playlist kommt erst wieder, wenn seit seinem letzten Einsatz mindestens so viel Prozent der Playlist gelaufen sind. 100 % = erst wenn alle anderen dran waren. Für Wünsche gilt nur die Minuten-Sperre.

+

Lautsprecher

diff --git a/server/app/static/admin.js b/server/app/static/admin.js index 4ebfc8f..2cb5bbf 100644 --- a/server/app/static/admin.js +++ b/server/app/static/admin.js @@ -265,6 +265,7 @@ function zeigeEinstellungen(z) { const e = z.settings; setzeWert("e-n", e.wish_every_n); setzeWert("e-sperrzeit", e.repeat_block_minutes); + setzeWert("e-playlist-prozent", e.playlist_repeat_percent); setzeWert("e-limit-anzahl", e.wish_limit_count); setzeWert("e-limit-minuten", e.wish_limit_minutes); setzeSchalter("s-explizit", e.block_explicit); diff --git a/server/tests/test_admin.py b/server/tests/test_admin.py index e109b4e..e87ae2e 100644 --- a/server/tests/test_admin.py +++ b/server/tests/test_admin.py @@ -176,8 +176,10 @@ def test_einstellungen_validiert(client): h = anmelden(client) assert client.post("/admin/api/settings", json={"wish_every_n": 4, "moderation": True}, headers=h).status_code == 200 assert (einstellung("wish_every_n"), einstellung("moderation")) == (4, True) + assert client.post("/admin/api/settings", json={"playlist_repeat_percent": 80}, headers=h).status_code == 200 + assert einstellung("playlist_repeat_percent") == 80 for falsch in ({"wish_every_n": 0}, {"wish_every_n": "4"}, {"moderation": 1}, {"access_token": "x"}, - {"emergency_stop": True}): + {"emergency_stop": True}, {"playlist_repeat_percent": 101}, {"playlist_repeat_percent": -1}): assert client.post("/admin/api/settings", json=falsch, headers=h).status_code == 422, falsch diff --git a/server/tests/test_player_api.py b/server/tests/test_player_api.py index afc4969..9cfa866 100644 --- a/server/tests/test_player_api.py +++ b/server/tests/test_player_api.py @@ -42,7 +42,7 @@ def test_token_pflicht(client): def test_config_standardwerte(client): daten = client.get("/api/player/config", headers=AUTH).json() assert daten == {"wish_every_n": 3, "playlist_id": "startliste", "reshuffle_token": 0, - "stopped": False, "commands": []} + "playlist_repeat_percent": 50, "stopped": False, "commands": []} def test_next_wish_leer_und_reihenfolge(client):