Lokale Musik: Spotifys On Repeat und Repeat Rewind reimplementieren
Täglich neu berechnete Playlisten auf der Basis von last.fm
On Repeat und Repeat Rewind: Was höre ich häufig?#
Von Spotifys Personalisierungs-Playlists sind zwei fast deckungsgleich und doch das Gegenteil voneinander: On Repeat zeigt, was ich gerade in Dauerschleife höre, Repeat Rewind, was ich vor Kurzem in Dauerschleife hörte und jetzt langsam links liegen lasse. Beide müssen für die lokale Bibliothek nachgebaut werden.
Wie Spotify die beiden Playlists baut#
Beide speisen sich aus demselben Signal, meinen wiederholten Plays, und unterscheiden sich nur im Zeitfenster. On Repeat sammelt die Songs, die ich in den letzten rund 30 Tagen oft gehört habe. Repeat Rewind greift weiter zurück und zielt auf Songs, die ich „vor einer Weile” rauf und runter gehört habe, jetzt aber seltener spiele.
Im Wort „vor einer Weile” steckt die Schwierigkeit: Repeat Rewind ist nicht einfach „meine meistgehörten älteren Songs”, sondern beschreibt einen Übergang, einen Track, der eine echte Hochphase hatte und gerade abkühlt. Ein Song, den ich vor sechs Monaten exzessiv gehört und seitdem nie wieder angefasst habe, ist eher ein altes Lieblingsstück als ein frisch abgekühlter Favorit. Diese Unterscheidung macht die Playlist aus. Die beiden Listen überschneiden sich dabei bewusst nicht, und keine von beiden enthält Entdeckungen: Repeat Rewind spielt ausschließlich Songs, die ich schon kenne und gehört habe, es ist reines Replay, kein Discovery.
Die Datengrundlage#
Ohne Spotify-Zugriff kommt das Signal aus Last.fm. Über user.getRecentTracks holt das Skript die gesamte Scrobble-Historie der letzten 180 Tage, paginiert und mit Disk-Cache, wobei ein Scrobble ein protokollierter Abspielvorgang ist. Daraus aggregiert es pro Song dessen Plays, die Zahl der unterschiedlichen Tage, an denen er lief, und eine Tages-Verteilung der Plays. Jeder Treffer wird anschließend über match.py gegen die lokale beets-Bibliothek aufgelöst, also gegen die vorhandenen FLAC-Dateien; was lokal liegt, landet in der Playlist, der Rest wird im Report als Kauf-Kandidat ausgewiesen.
flowchart LR
LFM["Last.fm<br/>getRecentTracks (180 Tage)"]
BEETS["beets-Bibliothek<br/>lokale FLACs (Matching)"]
OR["on_repeat.py"]
M3U["On Repeat.m3u · Repeat Rewind.m3u<br/>relativ (mobil) + absolut (IINA)"]
REP["on_repeat.md · repeat_rewind.md<br/>Reports + Kauf-Kandidaten"]
PD["playlist_data/<br/>Kopie für den DAP"]
LFM --> OR
BEETS --> OR
OR --> M3U
OR --> REP
OR --> PD
classDef src fill:#e8f0fe,stroke:#3367d6,color:#000;
classDef script fill:#e6f4ea,stroke:#1e8e3e,color:#000,stroke-width:2px;
classDef out fill:#f3e8fd,stroke:#7b2ff7,color:#000;
class LFM,BEETS src;
class OR script;
class M3U,REP,PD out;
Ein einziger Durchlauf über die volle Zeitachse versorgt beide Playlists, On Repeat aus dem jüngsten Ausschnitt, Repeat Rewind aus dem älteren. Bis auf die Aufteilung danach teilen sie sich denselben Weg, aggregieren, je eigene Auswahl, gemeinsames Matching:
flowchart TD
S["Scrobbles 180 Tage"] --> AGG["Aggregation<br/>Tages-Buckets · Varianten je Tag"]
AGG --> ON["On Repeat<br/>letzte 30 Tage"]
AGG --> RW["Repeat Rewind<br/>Tag 30–180"]
ON --> ONE["Eligibility<br/>≥3 Tage oder ≥5 Plays"]
ONE --> ONS["Sortierung<br/>Plays · Hörtage"]
RW --> PK["Peak-Erkennung<br/>stärkstes 30-Tage-Fenster"]
PK --> COOL["Cool-off-Filter<br/>Recent30 < Peak30 × 0.5"]
COOL --> SC["Abkling-Score<br/>Peak × Recency × Abfall"]
SC --> SPR["Artist-Spreading<br/>Round-Robin"]
ONS --> MATCH["Lokales Matching<br/>fenster-dominante Variante → beets"]
SPR --> MATCH
MATCH --> OUT["m3u → mobiler Player / IINA"]
classDef phase fill:#e8f0fe,stroke:#3367d6,color:#000;
classDef io fill:#f3e8fd,stroke:#7b2ff7,color:#000;
class AGG,ON,RW,ONE,ONS,PK,COOL,SC,SPR,MATCH phase;
class S,OUT io;
On Repeat#
On Repeat ist eine reine Schwellenwert-Frage. Ein Track ist qualifiziert, wenn er in den letzten 30 Tagen an mindestens 3 unterschiedlichen Tagen oder insgesamt mindestens 5 Mal lief. Die Trefferliste wird nach Plays und, bei Gleichstand, nach Zahl der Hörtage sortiert, oben gekappt auf 30 Songs. Mehr Logik braucht es nicht, weil „was höre ich gerade viel” sich direkt in Zählwerten ausdrückt.
Repeat Rewind#
Der naheliegende erste Ansatz wäre, dieselbe Schwelle auf das Fenster 30 bis 180 Tage zurück anzuwenden und alles zu entfernen, was schon in On Repeat steht. Das trifft Spotifys Idee aber nicht: Es zählt nur rohe Plays im Altfenster und kann nicht zwischen „vor sechs Monaten oft gehört und seitdem tot” und „letzten Monat noch heiß, jetzt abkühlend” unterscheiden. Beide sehen in einer reinen Summe gleich aus.
Stattdessen modelliert das Skript das Abkühlen direkt. Aus den Tages-Buckets bestimmt es je Song über ein gleitendes 30-Tage-Fenster im Altbereich die stärkste zurückliegende Hochphase, den Peak, samt dem Datum, an dem dieser Peak endete. Kandidat ist ein Song nur, wenn dieser Peak mindestens 5 Plays hatte (eine echte Hochphase) und seine Plays der letzten 30 Tage unter der Hälfte des Peaks liegen (er ist also messbar abgekühlt). Die Reihenfolge bestimmt dann ein Abkling-Score statt der rohen Play-Summe:
recency_weight = exp(-Alter / tau) # Alter = Tage seit Ende des Peaks, tau = 60
drop_factor = 1 - Recent30 / Peak30 # größerer Absturz → höherer Wert
Score = Peak30 × recency_weight × drop_factorplaintextDer recency_weight sorgt dafür, dass frisch abgekühlte Hochphasen oben stehen und uralte nach hinten rutschen, der drop_factor belohnt die Größe des Absturzes. Konkret an einem Track aus meinem eigenen Rewind: Halsey, Lessons hatte in seinem stärksten 30-Tage-Fenster 8 Plays (Peak30 = 8), in den letzten 30 Tagen keinen einzigen mehr (Recent30 = 0), und dieser Peak endete vor 31 Tagen. Daraus folgt recency_weight = exp(-31/60) ≈ 0,60, drop_factor = 1 - 0/8 = 1,0 und Score = 8 × 0,60 × 1,0 ≈ 4,77. Ein Song mit demselben Peak, dessen Hochphase aber ein halbes Jahr zurückliegt, käme über exp(-180/60) ≈ 0,05 nur auf einen Bruchteil davon und landet weit hinten. Die Schwellen sind über Flags justierbar:
| Flag | Default | Wirkung |
|---|---|---|
--peak-window N | 30 | Breite des gleitenden Peak-Fensters in Tagen |
--cool-ratio R | 0.5 | Track gilt als abgekühlt, wenn Recent30 < Peak30 × R |
--decay-tau T | 60 | Decay-Konstante: kleineres T bevorzugt frischer abgekühlte Songs |
--rewind-back-days N | 180 | Wie weit das Altfenster zurückreicht |
Das Varianten-Problem#
Last.fm-Scrobbles tragen denselben Song unter wechselnden Schreibweisen. Halseys „Colors” taucht in meiner Historie als Colors, als Colors - Live From Webster Hall und als Colors (Live At The Fonda) auf. Für das Matching gegen die lokale Bibliothek werden solche Live- und Studio-Varianten bewusst auf einen gemeinsamen Schlüssel kollabiert, damit die Plays nicht über drei Pseudo-Songs zersplittern. On Repeat nimmt die im Recent-Fenster dominante Schreibweise, Repeat Rewind die im Peak-Fenster dominante.
Künstler verteilen statt clustern#
Eine reine Score-Sortierung gruppiert die Playlist nach Künstler, weil ein Interpret, den ich gerade intensiv höre, gleich mit mehreren Tracks dicht beieinander im Ranking steht. Sechs Halsey-Songs am Stück, dann ein Block Frozen Soul, dann ein Block Taylor Swift, das hört sich nicht an wie eine Playlist, sondern wie aneinandergehängte Alben. Spotify verteilt die Künstler im Round-Robin: Im echten Repeat Rewind taucht jeder der Haupt-Interpreten in festem Abstand auf, etwa jeder vierte Slot.
Das Skript bildet das nach. Es gruppiert die fertige Trefferliste nach Künstler, jede Gruppe in Score-Reihenfolge, und füllt die Playlist dann Slot für Slot: In jeder Runde kommt der Künstler mit den meisten verbleibenden Tracks an die Reihe, nie zweimal hintereinander derselbe, solange es Alternativen gibt. So erscheint der Interpret mit den meisten Songs in gleichmäßigem Abstand statt als Block, und innerhalb eines Künstlers bleibt die Score-Reihenfolge erhalten.
Ausgabe und Betrieb#
Ein Durchlauf schreibt vier .m3u-Dateien, je On Repeat und Repeat Rewind einmal mit relativen Pfaden für mobile Player und einmal mit absoluten Pfaden für IINA, dazu zwei Markdown-Reports mit der vollen Trefferliste und den nicht-lokalen Kauf-Kandidaten. Zusätzlich kopiert das Skript die beiden mobilen Playlists in den Sync-Ordner der lokalen Bibliothek, damit der DAP ohne manuelles Kopieren aktuell bleibt.
python3 lastfm/on_repeat.pybashPer Cron läuft das täglich um 10:00, sodass beide Playlists jeden Vormittag den Stand der Vortags-Scrobbles haben.
Stand und Grenzen#
Beide Playlists treffen das Spotify-Gefühl gut, aber das Signal ist schmaler als bei den Vorbildern. Es kommt ausschließlich aus Last.fm-Scrobbles, ungescrobbeltes Hören ist unsichtbar, und wo Spotify ein geräteübergreifendes Personalisierungs-Modell fährt, rechnet das Skript mit Play-Zählwerten und einer Abkling-Kurve. Deren Konstanten, cool-ratio und tau, sind justierbare Heuristiken, keine Rekonstruktion von Spotifys Verfahren: Wirkt der Rewind zu altbacken, senkt ein kleineres tau die Schwelle Richtung frischer Abkühlung. Das Kollabieren von Varianten hebt die Matching-Quote, führt aber dazu, dass sich Studio- und Live-Plays eines Songs zu einem Eintrag addieren. Und gespielt werden kann nur, was als FLAC vorliegt; alles andere bleibt im Report als Kauf-Kandidat stehen.
Das Setup hinter diesen Beiträgen#
Die Idee#
Ich baue Spotify-Funktionen wie Wrapped, Daylist, Radio, Release Radar und Discovery zu Hause nach, auf Basis meiner eigenen, in hoher Qualität gespeicherten Musiksammlung (FLAC-Dateien) und kostenloser Datenquellen. Ohne Abhängigkeit von Spotify, voll automatisiert, und Songs, die ich (noch) nicht besitze, kommen über YouTube dazu.
Jede dieser Funktionen erzeugt am Ende eine .m3u-Datei — eine simple Playlist-Textdatei, die entweder lokale Dateien oder YouTube-Links auflistet. Abgespielt wird sie im Mac-Player IINA.
KI als Kommandozeile#
Die Funktionen sind als Python-Skripte implementiert und können manuell oder automatisiert (cron) gestartet werden. bequemer ist allerdings ein KI-Tool, welches natürlichsprachige Anfragen interpretieren, ausführen und die erzeugte Playlist direkt abspielen lassen kann:
Erstelle und spiele ein Radio für “Against the Current”
Spiele Death Metal
Spiele 90er Metal/Crossover
Spiele die ersten beiden Alben von Gracie Abrams
Spiele das aktuelle Album von Taylor Swift
Die Datenquellen#
Vier Quellen liefern das Rohmaterial:
| Quelle | Was sie ist | Was ich daraus hole |
|---|---|---|
| Last.fm | Ein Dienst, der jeden abgespielten Song automatisch mitschreibt („Scrobbeln”) | Komplette Hörhistorie, Lieblingssongs („Loved”), wie oft ich was höre, „ähnliche Künstler/Songs”, Genre-Schlagworte, globale Popularität |
| MusicBrainz | Eine offene Musik-Enzyklopädie (wie Wikipedia für Musik) | Erscheinungsdaten neuer Releases, Band-Mitglieder, Genres |
| YouTube Music | Der Streaming-Dienst | (1) Abspielquelle für Songs, die ich nicht lokal habe — das Werkzeug yt-dlp findet die passende YouTube-URL; (2) ein „ähnliche Künstler”-Graph über die Bibliothek ytmusicapi |
| Lokale FLAC-Bibliothek | Meine tatsächlich besessene Musik, verwaltet mit beets (einem Musik-Bibliotheks-Tool) | Was „lokal verfügbar” ist, inkl. Künstler/Album/Jahr |
Dazu kommt eigene Handarbeit: kuratierte Lieblings-Playlists je Künstler oder Album (playlist_data/), eine Liste besuchter und geplanter Konzerte (concerts.json) und eine Einkaufsliste fehlender Musik.
Die Bausteine (Skripte)#
Kleine Python-Programme, jeweils für eine Aufgabe. Grob nach Zweck:
- Profile bilden — welche Künstler/Songs sind mir wichtig?
interesting_artists.py(Künstler-Rangliste),track_plays_snapshot.py,loved_snapshot.py. - Playlists erzeugen (die Spotify-Pendants) —
daylist.py(Mix nach Tageszeit),radio.py(Künstler-Radio),discovery_yt_playlist.py(neue, unbekannte Künstler),on_repeat.py,rediscovery.py. - Entdecken & pflegen —
release_radar.py(neue Veröffentlichungen meiner Künstler, als E-Mail),einkaufsliste.py(was mir noch fehlt). - Rückblicke —
wrapped_month.py(monatliche „Wrapped”-Grafiken),generate_topsongs_csv.py(Jahres-Top-100). - Migration —
match.pyordnet einen Spotify-Playlist-Export den lokalen Dateien zu.
Wann was läuft (Automatik per Cron)#
Cron ist ein Zeitplaner des Betriebssystems: er startet Programme automatisch zu festen Zeiten. Mein Zeitplan:
| Wann | Was | Wozu |
|---|---|---|
| täglich 09:00 | interesting_artists.py | Rangliste „wichtige Künstler” aktualisieren (Basis für Radio, Discovery, Radar) |
| täglich 09:30 | discovery_yt_playlist.py | Playlist mit neuen, noch unbekannten Künstlern |
| täglich 10:00 | on_repeat.py | „On Repeat” & „Repeat Rewind” |
| täglich 11:00 | wrapped_month.py | laufende Monatsrückblick-Grafiken |
| 6× täglich (0,5,8,12,17,21 Uhr) | daylist.py | Playlist passend zur Tageszeit |
| freitags 09:45 | release_radar.py (+ rendern + senden) | E-Mail mit neuen Releases |
| wöchentlich (montags 09:15) | Snapshots + einkaufsliste.py | Spielzahlen festhalten, Einkaufsliste aktualisieren |
| donnerstags 16:00 | Cache-Aufräumen | veraltete Daten löschen, damit sie frisch nachgeladen werden |
Das Radio (radio.py) läuft bewusst nicht automatisch, sondern auf Zuruf, wenn ich ein Radio für einen bestimmten Künstler will.
Caches (warum es schnell und höflich bleibt)#
Abfragen an Last.fm, MusicBrainz und YouTube sind langsam und haben Limits — MusicBrainz erlaubt z.B. nur eine Anfrage pro Sekunde. Deshalb wird jede Antwort lokal zwischengespeichert (ein Cache unter ~/.cache/lastfm-mb/,
aktuell ~150 MB). Die zweite Abfrage derselben Sache ist dann sofort da, und die Dienste werden nicht unnötig belastet.
Grob drei Gruppen: Last.fm (Hörhistorie, ähnliche Künstler, Tags), MusicBrainz (Künstler-Steckbriefe, Releases, Band-Mitglieder) und YouTube Music (ähnliche Künstler, Alben).
Wichtig: Manche Caches dürfen nicht ewig gelten — „ähnliche Künstler” und „neue Releases” sollen aktuell bleiben. Darum werden ausgewählte Bereiche wöchentlich gelöscht (donnerstags) bzw. beim Release Radar bewusst frisch geladen.