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>
This commit is contained in:
2026-10-17 19:27:15 +01:00
co-authored by Claude Opus 5.5
parent 3610de7bae
commit df5567e6a3
10 changed files with 187 additions and 25 deletions
+4 -1
View File
@@ -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
+69 -19
View File
@@ -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",
+101
View File
@@ -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]]
+1
View File
@@ -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),
+2 -1
View File
@@ -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/<token>
+1
View File
@@ -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),
}
+4 -2
View File
@@ -138,8 +138,10 @@
<div class="karte">
<h2>Ablauf</h2>
<div class="feld-zeile"><span>Jeder wievielte Titel ist ein Wunsch (N)</span><input id="e-n" type="number" min="1" max="20"></div>
<div class="feld-zeile"><span>Sperrzeit für Wiederholungen (Min.)</span><input id="e-sperrzeit" type="number" min="0" max="1440"></div>
<button class="gross akzent voll" type="button" data-speichern="e-n:wish_every_n,e-sperrzeit:repeat_block_minutes">Speichern</button>
<div class="feld-zeile"><span>Wunsch erst wieder erlaubt nach (Min.)</span><input id="e-sperrzeit" type="number" min="0" max="1440"></div>
<div class="feld-zeile"><span>Playlist-Titel erst wieder nach (% der Playlist)</span><input id="e-playlist-prozent" type="number" min="0" max="100"></div>
<p class="hinweis klein">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.</p>
<button class="gross akzent voll" type="button" data-speichern="e-n:wish_every_n,e-sperrzeit:repeat_block_minutes,e-playlist-prozent:playlist_repeat_percent">Speichern</button>
</div>
<div class="karte">
<h2>Lautsprecher</h2>
+1
View File
@@ -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);
+3 -1
View File
@@ -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
+1 -1
View File
@@ -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):