Lokale Musik: Einkaufslisten
Automatisch generierte Einkaufsliste für noch fehlende Musik
Welche Musik fehlt mir?#
Bei einer lokalen Musik-Bibliothek muss die Musik irgendwoher kommen. Diese Frage wurde in vergangenen Posts bereits erläutert (QOBUZ, CDs kaufen, Büchereien), Aber welche Musik sollte ich kaufen? Im letzten Jahr habe ich mir von last.fm die Top-Alben der letzten Jahre ausgeben lassen, bin diese durchgegangen und habe CDs gekauft. Mittlerweile wird diese Einkaufsliste generiert.
Was auf die Liste gehört#
Die Liste arbeitet auf Album-Ebene: Sie sammelt erst alle Songs, die ich mag und oft höre, wirft alles raus, was lokal schon vorliegt, und gruppiert den Rest zu Alben. Ein Album landet oben, wenn ich viele verschiedene Songs daraus kenne und diese auch häufig laufen, nicht wenn ein einzelner Hit darauf liegt.
Das Rohmaterial kommt aus vier Quellen, jede mit einer eigenen Schwelle, wie ernst ein Song genommen wird:
| Quelle | Was sie ist | Filter |
|---|---|---|
| Loved-Tracks | In Last.fm von Hand mit Herz markierte Songs (loved_tracks.json) | alle (von Hand kuratiert) |
deine_top-songs_YYYY.csv | Jahres-Top-100 je Kalenderjahr, erstellt aus last.fm | nur ab 5 Plays |
deine_all-time-top-songs.csv | meine meistgehörten Songs überhaupt, erstellt aus last.fm | alle |
Spotify-Besternt.csv | von Hand in Spotify mit Stern markiert (Altbestand) | alle |
Die Play-Zahlen stammen nicht aus den CSV-Spalten, sondern aus einem eigenen Snapshot: track_plays_snapshot.py fragt pro Song über track.getInfo meinen aktuellen userplaycount bei Last.fm ab und legt ihn samt zugehörigem Album in track_plays.json ab. Jeder Eintrag hat eine TTL von 25 Tagen, ein Wiederholungslauf zieht also nur veraltete Songs neu und bleibt schnell. Bei der Jahres-Top-100 zählt darum der heutige Play-Stand, nicht der von damals: Ein Song, der 2015 kurz heiß war und nie wieder lief, fällt unter die Schwelle, ein damals wie heute oft gespielter bleibt.
Vom Song zum Album#
Jeder Song wird über match.py gegen die lokale beets-Bibliothek aufgelöst. Was als Datei vorliegt, fällt raus, nur die fehlenden Songs bleiben übrig und werden pro (Künstler, Album)-Paar zusammengefasst. Aus welchem Album ein Song stammt, sagt der Last.fm-Snapshot.
flowchart TD
LOVED["loved_tracks.json"]
CSV["Top-Songs-CSVs<br/>+ Spotify-Besternt"]
PLAYS["track_plays.json<br/>userplaycount + Album"]
BEETS["beets-Bibliothek<br/>lokale FLACs"]
SRC["Quellen sammeln"]
FILT["Quell-Filter<br/>Play-Schwelle je Quelle"]
MATCH["Lokaler Abgleich<br/>vorhandene Songs entfernen"]
AGG["Album-Aggregation<br/>distinct · plays · ≥4-Plays"]
AFILT["Album-Filter"]
OUT["Einkaufsliste.json"]
LOVED --> SRC
CSV --> SRC
PLAYS --> FILT
SRC --> FILT
FILT --> MATCH
BEETS --> MATCH
MATCH --> AGG
AGG --> AFILT
AFILT --> OUT
classDef src fill:#e8f0fe,stroke:#3367d6,color:#000;
classDef phase fill:#e6f4ea,stroke:#1e8e3e,color:#000,stroke-width:2px;
classDef io fill:#f3e8fd,stroke:#7b2ff7,color:#000;
class LOVED,CSV,PLAYS,BEETS src;
class SRC,FILT,MATCH,AGG,AFILT phase;
class OUT io;
Pro Album hält die Aggregation vier Kennzahlen, aus denen sich Relevanz und Reihenfolge ergeben:
distinct_tracks— wie viele verschiedene Songs des Albums in meinen Quellen auftauchen.plays_total— die Summe der Last.fm-Plays über diese Songs.tracks_ge4— wie viele davon auf mindestens 4 Plays kommen.sources— welche Quellen das Album überhaupt nennen.
Der Album-Filter#
Ein Album mit zwölf bekannten Songs ist ein klarer Kauf, ein Album, von dem ich einen einzigen Song zweimal gehört habe, nicht. Drei Regeln sieben das Schwache aus:
1) plays_total < 8 UND distinct_tracks < 2 → raus
2) distinct_tracks == 1 UND plays_total < 20 → raus
3) distinct_tracks >= 2 UND tracks_ge4/distinct < 0.34 → rausplaintextRegel 1 entfernt kaum gehörte Einzeltreffer, Regel 2 verlangt für ein Album, von dem nur ein einziger Song bekannt ist, mindestens 20 Plays auf diesem Song, bevor sich das ganze Album lohnt. Regel 3 ist die wichtigste: Hat ein Album fünf bekannte Songs, aber nur einer davon kommt auf 4 oder mehr Plays, ist das Verhältnis 1/5 = 0,2 und liegt unter 0,34, das Album fliegt raus. Ein einzelner Lieblingssong rechtfertigt kein Komplett-Album, eine breite Streuung mit echten Play-Zahlen schon.
Sortiert wird danach nach distinct_tracks, dann tracks_ge4, dann plays_total, jeweils absteigend. Oben steht also, wovon ich am meisten kenne und am meisten höre. Der aktuelle Stand führt 283 Alben mit 683 Songs; die Spitze sieht so aus:
| # | Künstler | Album | Distinct | ≥4 Plays | Σ Plays | Quellen |
|---|---|---|---|---|---|---|
| 1 | Nightwish | Imaginaerum (Deluxe Version) | 21 | 13 | 128 | Spotify-Besternt, deine_top-songs_2019, loved |
| 2 | OK Go | 180/365 | 12 | 12 | 524 | deine_top-songs_2015, loved |
| 3 | Parkway Drive | Horizons | 11 | 11 | 455 | deine_top-songs_2008, deine_top-songs_2009, deine_top-songs_2012, loved |
| 4 | L’Âme Immortelle | Wenn Der Letzte Schatten Fällt | 9 | 9 | 120 | deine_top-songs_2006, deine_top-songs_2007, loved |
| 5 | Oceans of Slumber | Oceans of Slumber | 9 | 9 | 102 | Spotify-Besternt, deine_top-songs_2021, loved |
| 6 | Paramore | RIOT! | 9 | 8 | 94 | Spotify-Besternt, deine_top-songs_2009, loved |
| 7 | All Shall Perish | The Price of Existence | 8 | 8 | 130 | deine_top-songs_2014, loved |
| 8 | Paradise Lost | Medusa | 8 | 8 | 65 | Spotify-Besternt, deine_top-songs_2017, loved |
| 9 | My Dying Bride | Songs of Darkness, Words of Light | 7 | 7 | 179 | deine_top-songs_2009, deine_top-songs_2010, loved |
| 10 | Lene Marlin | Another Day | 6 | 6 | 166 | deine_top-songs_2014 |
Pflege und Betrieb#
Drei Skripte halten die Kette aktuell: loved_snapshot.py zieht alle Loved-Tracks, track_plays_snapshot.py die aktuellen Play-Zahlen, einkaufsliste.py baut daraus die Liste neu.
python3 lastfm/loved_snapshot.py
python3 lastfm/track_plays_snapshot.py
python3 lastfm/einkaufsliste.pybashPer Cron läuft das wöchentlich montags. Beim Neuaufbau überleben von Hand ergänzte Einträge ("sources": ["manual"]) für brandneue Releases, die noch in keiner Quell-CSV stehen, und fallen erst weg, sobald ihr Album aus den Quellen auftaucht oder lokal vorliegt.
Zwei Sync-Modi verbinden die Liste mit dem Rest:
- Nach der Aktualisierung der
beets-Bibliothek meldeteinkaufsliste_sync.py --check-refresh, welche Alben jetzt neu lokal vorhanden sind und entfernt werden können - Nach einer Playlist-Migration aus einer CSV-Datei trägt
--check-migrationneu erkannte Lücken nach.
Beide laufen standardmäßig als Dry-Run und schreiben erst mit --apply.
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.