
Lokale Musik: Ein Monthly Wrapped implementieren
Monthly Wrapped auf der Grundlage von last.fm
Wrapped, aber für die eigenen Dateien#
Einmal im Jahr wirft Spotify seine Wrapped-Karten in alle Timelines, und für einen Tag reden alle über ihre Hörzahlen. Last.fm ↗ und ein paar Drittanbieter wie stats.fm machen dasselbe im Kleinen jeden Monat. Ich benötige allerdings eine Lösung auf Basis meiner Hörgewohnheiten: Lokale FLAC-Dateien, abgespielt per IINA und Poweramp, gescrobbelt zu last.fm.
Einmal im Monat entstehen vier Bilder, jedes 1080×1350 groß im Story-Hochformat: Eine Übersicht und je eine Karte für Top-Artists, Top-Alben und Top-Songs. Sie landen als PNG in dist/wrapped/. Die Übersicht für April 2026 zeigt oben ein großes Künstlerbild und darunter zwei Spalten — Top-Artists und Top-Songs — plus die Gesamt-Scrobbles des Monats und den Zeitraum im Fuß. In dem Monat waren das 954 Scrobbles, angeführt von Taylor Swift mit 281 Plays, dahinter Darcie Haven, Gracie Abrams, Halsey und girl in red.
Was das eine Skript an Quellen zusammenzieht und was am Ende herauskommt:
flowchart LR
LFM["Last.fm getRecentTracks<br/>Scrobbles im Kalendermonat"]
FLAC["Lokale FLAC-Sammlung<br/>Künstler- · Albumcover"]
MB["MusicBrainz · Cover Art<br/>Künstlerbilder (Fallback)"]
WM["wrapped_month.py<br/>Filter · Top-5 · Farbverlauf"]
CARDS["dist/wrapped/<br/>Übersicht · Top-Artists<br/>Top-Alben · Top-Songs"]
LFM --> WM
FLAC --> WM
MB --> WM
WM --> CARDS
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,FLAC,MB src;
class WM script;
class CARDS out;
Der echte Monat, nicht die letzten 30 Tage#
Naheliegend wäre, Last.fm einfach nach period=1month zu fragen. Das liefert aber ein rollendes 30-Tage-Fenster, und eine Karte mit der Überschrift „April” soll den April zeigen, nicht den 2. April bis zum 1. Mai. Also setzt das Skript die Grenzen auf den Kalendermonat und holt über user.getRecentTracks alle Scrobbles im Fenster [Monatsanfang, Monatsende), paginiert zu je 200 Stück, bis Last.fm keine Seiten mehr hat.
Aus dem rohen Scrobble-Set fallen vorher Ambient- und Podcast-Künstler raus — Meeresrauschen, Brown-Noise-Schleifen und Co. zählen für ein „Wrapped” nicht als Lieblingskünstler. Was übrig bleibt, wird simpel ausgezählt: Die fünf häufigsten Künstler, die fünf häufigsten Songs, die fünf häufigsten Alben.
Woher die Bilder kommen#
Last.fm liefert für Künstler längst kaum noch echte Pressefotos, sondern fast immer denselben grauen Stern-Platzhalter. Also wird das Künstlerbild in einer festen Reihenfolge gesucht und das erste nutzbare verwendet:
- Lokales Cover aus meiner FLAC-Sammlung (
<Artist>/folder.jpg) — wenn ich den Künstler besitze, habe ich meist schon ein gutes Bild, da Jellyfin meine lokale Musik überwacht und derartige Grafiken im Dateisystem automatisch ablegt. - MusicBrainz ↗ bzw. das Cover Art Archive ↗, in der Reihenfolge Album → EP → Single. Gerade kleine oder neue Acts haben oft nur Singles, deshalb gibt das Skript nicht beim ersten leeren Album auf.
- Last.fm
artist.getInfo, wobei die bekannten Stern-Platzhalter aktiv übersprungen werden. - Als letzte Rettung ein Albumcover aus meinen eigenen Scrobbles dieses Monats.
Diese letzte Stufe ist ein Kompromiss: Bei einem kleinen Act ohne Pressefoto wird dann ein Plattencover statt eines Gesichts angezeigt. Darcie Haven, im April auf Platz 2, ist genau so ein Fall, bei dem die Kette weiter unten greift. Albumcover für die Alben- und Song-Karten werden genauso ermittelt — erst lokal <Artist>/<Album>/cover.jpg, dann die Cover-URL aus dem Scrobble.
In der Entwicklung gab es einige interessante Stolpersteine: Das macOS-Dateisystem speichert Diakritika zerlegt (NFD, E plus eigenes Akzentzeichen), Last.fm schickt sie zusammengesetzt (NFC, É). Ein stures == lässt „ROSÉ” aus dem Dateisystem und „Rosé” von Last.fm aneinander vorbeilaufen, obwohl beide gleich aussehen. Erst eine Unicode-Normalisierung vor jedem Vergleich macht die Ordnersuche zuverlässig. Beim Album-Match kommen noch typografische Anführungszeichen dazu und der Umstand, dass ein : im Albumtitel auf der Platte zum _ wird.
Die Farbe muss lesbar bleiben#
Jede Karte hat einen vertikalen Farbverlauf, und seine Farbe wird aus dem Bild des Top-1-Künstlers ermittelt: Die dominante, etwas gesättigte Farbe, damit kein blasses Grau herauskommt. Gleichzeitig muss bei hellen Hintergrundfarben der weiße Text noch lesbar bleiben.
Die Lösung ist eine kleine Kontrast-Schleife. Ermittelt wird nach WCAG ↗ der Kontrast zwischen Verlaufsfarbe und Weiß. Das Skript dunkelt den ganzen Verlauf schrittweise ab, bis er an der Stelle, wo der Text beginnt, mindestens 4,5:1 erreicht — die AA-Schwelle für normalen Text. Bei den Listenkarten ist das direkt oben. Bei der Übersicht beginnt der Text aber erst unter dem zentrierten Hero-Bild, also auf etwa 56 % der Höhe, und genau dort muss die Schwelle greifen, nicht am oberen Rand. So bleibt das Künstlerbild oben leuchtend und die Texte unten trotzdem lesbar.

Vier Karten, ein Cron#
Statt einer langweiligen Systemschrift nutze ich Minecart LCD ↗, einen Pixel-/LCD-Font, dazu weiche Schlagschatten unter den Covern und abgerundete Ecken. Über --font lässt sich das jederzeit auf eine normalere Schrift ändern.
Gebaut wird automatisch, per Cron täglich um 11:00 Uhr. Täglich klingt übertrieben für eine Monatskarte, hat aber einen Grund: Liefe der Job nur am Monatsersten, müsste mein Rechner an genau diesem Tag laufen. Stattdessen prüft das Skript bei jedem Lauf, ob die vier PNGs des Vormonats schon jünger als 24 Stunden sind, und bricht in dem Fall nach wenigen Millisekunden ohne einen einzigen API-Call ab. So entsteht die Karte irgendwann in den ersten Tagen des Monats, egal wann der Rechner zufällig an ist. Einen vergangenen Monat rendere ich bei Bedarf von Hand mit --month 2026-04 nach, und --force erzwingt einen Neubau am selben Tag.
python3 lastfm/wrapped_month.py # letzter abgeschlossener Monat
python3 lastfm/wrapped_month.py --month 2026-04 # bestimmter Monat
python3 lastfm/wrapped_month.py --card artists # nur eine KartebashDas 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.