Tap to Play!

Back

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)
plaintext

Zwei 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"]}
json

Nachgerechnet: 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"]}
json

Im 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#

  1. Snapshot-Kette (montags): loved_snapshot.pyloved_tracks.jsontrack_plays_snapshot.pytrack_plays.jsoneinkaufsliste.pyEinkaufsliste.json.
  2. Zentraler Hub (täglich): interesting_artists.py bündelt Last.fm-Plays + beets + concerts.json + Einkaufsliste.json zu interesting_artists.json (★) — der gerankten Künstlerliste.
  3. Hub speist die Empfehlungen: interesting_artists.jsonrelease_radar.py, discovery_yt_playlist.py, radio.py.
  4. Generatoren teilen Bausteine: alle Playlist-Skripte nutzen match.py (beets-Matching) und yt-dlp; radio.py importiert zusätzlich aus daylist.py (Resolver) und release_radar.py (MB-Releases für den Recency-Opener).