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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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]]
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user