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
+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]]