Empfehlungssystem für lokale Musik (2/2): Relevante Künstler ermitteln
Relevanz-Score aus lokaler Musik, Hörhistorie, Kaufabsicht und besuchten Konzerten berechnen
Das Geschmacks-Modell: interesting_artists.json#
Eine lokale Musiksammlung weiß nicht, welche Künstler mir wichtig sind. Sie kennt Dateien, keine Vorlieben. Damit Funktionen wie der Release Radar, die Discovery-Playlist oder das Künstler-Radio überhaupt sinnvoll arbeiten können, brauchen sie mehr: Welche Künstler zählen für mich, und wie sehr? Statt dass jede Funktion das für sich selbst herausfindet, berechne ich es einmal zentral — täglich neu, in interesting_artists.json.
Diese Datei ist der wichtigste abgeleitete Baustein des Systems: Eine nach einem Gesamt-Score gerankte Liste aller relevanten Künstler. Wer hier oben steht, taucht überall bevorzugt auf.
Warum eine zentrale Rangliste#
Mehrere Funktionen stellen im Grunde dieselbe Frage — „welche Künstler interessieren mich?” — nur aus unterschiedlichen Blickwinkeln. Ohne eine gemeinsame Antwort müsste jede Funktion ihr eigenes Maß erfinden, mit eigenen Schwellen und eigenen Macken. Drei Probleme entstünden: Inkonsistenz (das Radio hielte jemanden für wichtig, den der Radar ignoriert), Doppelarbeit (jede Funktion fragt Last.fm erneut ab) und Unwartbarkeit (eine Gewichtung ändern hieße, sie an fünf Stellen zu ändern).
Die Rangliste löst das, indem sie die Frage einmal beantwortet. Sie ist das gemeinsame Geschmacks-Modell, auf das sich alles andere stützt — und der eine Ort, an dem ich die Gewichtung justiere.
Die vier Signale#
Der Score speist sich aus vier Quellen, die jeweils einen anderen Aspekt von „wichtig” erfassen:
- Besitz (
beets/beets_all.tsv): Die Zahl lokal vorhandener Alben. Wer Geld für ein Album ausgegeben hat, hat eine bewusste Entscheidung getroffen — ein starkes, träges Signal. - Hörhistorie (Last.fm): Die persönlichen Spielzahlen in vier Zeitfenstern — gesamt, 12 Monate, 3 Monate, 1 Monat. Die kurzen Fenster fangen ab, wofür ich mich gerade begeistere, das lange die Künstler, die mich über Jahre begleiten.
- Kaufabsicht (
Einkaufsliste.json): Die Zahl offener Kaufwünsche. Was auf der Einkaufsliste steht, will ich besitzen — also interessiert es mich, auch wenn ich es noch nicht oft gehört habe. - Konzerte (
concerts.json): Die decay- und reisegewichteten Konzertbesuche. Das stärkste persönliche Signal überhaupt.
Welche Signale einen Künstler überhaupt berührt haben, hält das Feld sources fest — hilfreich, um zu sehen, warum jemand in der Liste steht.
Der Score#
Der Gesamt-Score ist eine gewichtete Summe dieser Signale. Die Gewichte sind so gewählt, dass jedes Signal in einer vergleichbaren Größenordnung beiträgt:
score = local_albums × 5 (Besitz)
+ plays_overall ÷ 100 (Hörhistorie gesamt)
+ plays_12month ÷ 30 (letztes Jahr)
+ plays_3month ÷ 10 (letzte 3 Monate)
+ plays_1month ÷ 3 (letzter Monat — akuter Push)
+ acquire_pending × 2 (offene Kaufwünsche)
+ concerts × 12 (Konzertbesuche, decay-gewichtet)plaintextZwei Dinge sind Absicht. Erstens die abnehmenden Teiler bei den Hörfenstern: Ein Play im letzten Monat (÷ 3) wiegt rund 33-mal so viel wie einer in der Gesamthistorie (÷ 100). So bekommt eine aktuelle Obsession einen kräftigen Schub — der von selbst wieder abklingt, sobald ich den Künstler weniger höre. Zweitens der hohe Konzert-Faktor (× 12): Ein einziger gewichteter Konzertbesuch zählt mehr als 300 Streams aus der Gesamthistorie.
Wie das zusammenspielt, zeigen zwei echte Einträge.
Taylor Swift, Platz 1 mit Score 620 — hier feuern alle Signale gleichzeitig:
{"name": "Taylor Swift", "mbid": null, "score": 620.0, "local_albums": 33,
"plays_overall": 21954, "plays_12month": 3088, "plays_3month": 485,
"plays_1month": 179, "acquire_pending": 5, "concerts": 1.2,
"sources": ["lastfm", "beets", "acquire", "concerts"]}jsonNachgerechnet: 33 × 5 = 165 (Besitz), 21.954 ÷ 100 ≈ 219,5 (Historie), 3.088 ÷ 30 ≈ 102,9, 485 ÷ 10 = 48,5, 179 ÷ 3 ≈ 59,7, 5 × 2 = 10, 1,2 × 12 = 14,4 — Summe ≈ 620.
Interessanter ist Metallica mit Score 133,6 — ein Fall, in dem das aktuelle Hören fast versiegt ist:
{"name": "Metallica", "mbid": null, "score": 133.6, "local_albums": 6,
"plays_overall": 5059, "plays_12month": 18, "plays_3month": 11,
"plays_1month": 11, "acquire_pending": 13, "concerts": 1.8,
"sources": ["lastfm", "beets", "acquire", "concerts"]}jsonIm letzten Jahr nur 18 Plays — über das Hörverhalten allein wäre Metallica fast unsichtbar. Trotzdem steht die Band stabil im Mittelfeld, getragen von der Historie (50,6), dem Besitz (30), 13 offenen Kaufwünschen (26) und den Konzerten (21,6). Genau so soll es sein: Eine alte Liebe, die ich live gesehen habe und von der ich noch Alben kaufen will, verschwindet nicht, nur weil sie gerade nicht in der Rotation läuft.
Wie es aktualisiert wird#
Die Liste wird täglich um 09:00 komplett neu gebaut — kein inkrementelles Update, sondern ein voller Durchlauf: Die vier Last.fm-Zeitfenster abfragen (overall und 12 Monate je bis 1.000 Künstler, 3 und 1 Monat je bis 500), den beets-Bestand, die Einkaufsliste und die Konzerte einlesen, pro Künstler den Score rechnen und absteigend sortiert schreiben. Künstler unter einem Mindest-Score (im täglichen Lauf 5) fallen raus.
Aktuell stehen so 151 Künstler in der Datei, mit Scores von 5 bis 620. Dass der Lauf täglich passiert, ist wichtig: Nur so klingen die kurzlebigen Push-Signale (1- und 3-Monats-Plays) zeitnah wieder ab, statt eine alte Momentaufnahme festzuhalten.
Relevanz in der Gesamt-Infrastruktur#
Die Rangliste ist der Knotenpunkt zwischen den Eingabekanälen und den eigentlichen Funktionen. Drei davon stützen sich direkt darauf, jede auf ihre Weise:
- Der Release Radar prüft genau diese Künstler auf neue Veröffentlichungen — die Rangliste definiert, wessen Releases mich überhaupt erreichen.
- Die Discovery-Playlist nutzt sie als Negativ-Filter: Wer hier steht, ist mir schon bekannt und wird aus den Empfehlungen ausgeschlossen, damit wirklich Neues übrig bleibt.
- Das Künstler-Radio zieht aus ihr die
plays_12month, um ähnliche Künstler nach meinem tatsächlichen Hörverhalten zu gewichten.
So wirkt eine Änderung an einem einzigen Eintrag an mehreren Stellen zugleich. Wer in der Rangliste steigt, dessen neue Alben werden gemeldet, der verschwindet aus der Discovery und rückt im Radio nach vorn — alles aus einer Zahl, die einmal am Tag neu entsteht.
Abhängigkeiten#
Dependency-Graph der lokalen Musik-Automatisierung. Pfeile = „liefert Daten an”. Erzeugt aus dem tatsächlichen Code (Inputs/Outputs je Skript verifiziert).
flowchart TD
%% ===================== Externe Dienste & Cache =====================
subgraph SVC["Externe Dienste"]
LFM["Last.fm API"]
MB["MusicBrainz API"]
YTM["YouTube Music<br/>(ytmusicapi)"]
YTDLP["yt-dlp<br/>(URL-Auflösung, live)"]
end
CACHE[("~/.cache/lastfm-mb<br/>API-Cache · ~150 MB<br/>(Do 16:00 TTL-Cleanup)")]
LFM --> CACHE
MB --> CACHE
YTM --> CACHE
%% ===================== Manuelle / Bibliotheks-Eingaben =====================
subgraph IN["Eingaben (manuell / Bibliothek)"]
BEETS[("beets_all.tsv<br/>FLAC-Bibliothek")]
CONC["concerts.json<br/>(manuell)"]
PLDATA["playlist_data/*.m3u<br/>(kuratiert, manuell)"]
SPOTCSV["src/*.csv<br/>(Spotify-Export)"]
end
MATCH["match.py<br/>(beets-Matching)"]
BEETS --> MATCH
%% ===================== Profil-/Snapshot-Pipeline =====================
LOVEDSNAP["loved_snapshot.py"]
TRACKSNAP["track_plays_snapshot.py"]
EINKAUF["einkaufsliste.py"]
IA["interesting_artists.py"]
LOVED["loved_tracks.json"]
PLAYS["track_plays.json"]
EINKJSON["Einkaufsliste.json"]
IAJSON(["interesting_artists.json<br/>★ zentrale Künstler-Rangliste"])
LFM --> LOVEDSNAP --> LOVED
LOVED --> TRACKSNAP
SPOTCSV --> TRACKSNAP
LFM --> TRACKSNAP --> PLAYS
LOVED --> EINKAUF
PLAYS --> EINKAUF
SPOTCSV --> EINKAUF
BEETS --> EINKAUF --> EINKJSON
LFM --> IA
BEETS --> IA
CONC --> IA
EINKJSON --> IA --> IAJSON
%% ===================== Playlist-Generatoren =====================
DAYLIST["daylist.py"]
RADIO["radio.py"]
DISC["discovery_yt_playlist.py"]
ONREP["on_repeat.py"]
REDISC["rediscovery.py"]
LFM --> DAYLIST
MATCH --> DAYLIST
YTDLP --> DAYLIST
DAYLIST --> O1["m3u_playback/daylist.m3u"]
IAJSON --> RADIO
LOVED --> RADIO
PLDATA --> RADIO
MATCH --> RADIO
MB --> RADIO
YTM --> RADIO
YTDLP --> RADIO
RADIO -.->|"importiert MB-Release-Lookup"| RR
RADIO --> O2["m3u_playback/Radio_*.m3u"]
IAJSON --> DISC
LFM --> DISC
YTDLP --> DISC
DISC --> O3["m3u_playback/discovery-yt.m3u"]
LFM --> ONREP
MATCH --> ONREP
ONREP --> O4["m3u_playback/On Repeat.m3u<br/>Repeat Rewind.m3u"]
LFM --> REDISC
MATCH --> REDISC
REDISC --> O5["m3u (Re-Discovery)"]
%% ===================== Release Radar / Rückblicke / Migration =====================
RR["release_radar.py"]
RENDER["render_release_radar_html.py"]
SEND["send_release_radar.py"]
WRAP["wrapped_month.py"]
TOPSONGS["generate_topsongs_csv.py"]
IAJSON --> RR
LFM --> RR
MB --> RR
YTM --> RR
EINKJSON --> RR
BEETS --> RR
RR --> RADARMD["release-radar-*.md"] --> RENDER --> RADARHTML["HTML"] --> SEND --> MAIL["E-Mail"]
LFM --> WRAP
MB --> WRAP
BEETS --> WRAP
WRAP --> O6["dist/wrapped/*.png"]
LFM --> TOPSONGS
BEETS --> TOPSONGS
TOPSONGS --> SPOTCSV
SPOTCSV --> MATCH --> O7["m3u/*.m3u (Migration)"]
%% ===================== Stile =====================
classDef svc fill:#fde2e2,stroke:#c0392b,color:#000;
classDef cache fill:#fff3cd,stroke:#b8860b,color:#000;
classDef data fill:#e8f0fe,stroke:#3367d6,color:#000;
classDef script fill:#e6f4ea,stroke:#1e8e3e,color:#000;
classDef hub fill:#d7f5dd,stroke:#0b8043,color:#000,stroke-width:3px;
classDef out fill:#f3e8fd,stroke:#7b2ff7,color:#000;
class LFM,MB,YTM,YTDLP svc;
class CACHE cache;
class BEETS,CONC,PLDATA,SPOTCSV,LOVED,PLAYS,EINKJSON data;
class IAJSON hub;
class LOVEDSNAP,TRACKSNAP,EINKAUF,IA,DAYLIST,RADIO,DISC,ONREP,REDISC,RR,RENDER,SEND,WRAP,TOPSONGS,MATCH script;
class O1,O2,O3,O4,O5,O6,O7,RADARMD,RADARHTML,MAIL out;
Legende#
- 🟥 Externe Dienste — Last.fm, MusicBrainz, YouTube Music (
ytmusicapi),yt-dlp. Ihre API-Antworten landen im Cache (außer den live aufgelösten yt-dlp-URLs). - 🟨 Cache
~/.cache/lastfm-mb/— pro Dienst/Abfrage eine JSON-Datei; donnerstags werden bewegliche Bereiche (Similar, Top-Tracks, Tags) gelöscht. - 🟦 Daten — Eingaben (beets-Bibliothek,
concerts.json,playlist_data/, Spotify-CSVs) und abgeleitete Artefakte (loved_tracks.json,track_plays.json,Einkaufsliste.json). - 🟩 Skripte (Python).
- 🟪 Outputs —
.m3u-Playlists, Release-Radar-Mail, Wrapped-PNGs.
Die vier Hauptketten#
- Snapshot-Kette (montags):
loved_snapshot.py→loved_tracks.json→track_plays_snapshot.py→track_plays.json→einkaufsliste.py→Einkaufsliste.json. - Zentraler Hub (täglich):
interesting_artists.pybündelt Last.fm-Plays + beets +concerts.json+Einkaufsliste.jsonzuinteresting_artists.json(★) — der gerankten Künstlerliste. - Hub speist die Empfehlungen:
interesting_artists.json→release_radar.py,discovery_yt_playlist.py,radio.py. - Generatoren teilen Bausteine: alle Playlist-Skripte nutzen
match.py(beets-Matching) undyt-dlp;radio.pyimportiert zusätzlich ausdaylist.py(Resolver) undrelease_radar.py(MB-Releases für den Recency-Opener).