Aus Linux-Magazin 01/2023

Spotify-Dossier anfordern und mit Go und R auswerten.

© stokkete / 123RF.com

Der Netzmusikdienst Spotify sammelt Daten über die Musikgewohnheiten seiner User. Mike Schilli hat eine Kopie seiner Akten angefordert und stöbert nun mit Go darin herum.

Streaming-Dienste wie Spotify oder Apple Music dominieren die Musikindustrie. Ihre umfangreichen Kataloge decken mittlerweile das gesamte Spektrum konsumierbarer Musik ab. Über künstliche Intelligenz führen sie User an immer neue Songs heran, die diese mit hoher Wahrscheinlichkeit begeistert abspielen. Dagegen haben traditionelle Tonträger keine Chance mehr und verstauben in den Regalen. Vorbei ist durch diese Entwicklung aber auch der anonyme Musikkonsum, denn Streaming-Dienste führen genau darüber Buch, wer wann und wie lange welche Musik laufen ließ.

Abbildung 1: Spotify gestattet es seinen Usern, die über sie gesammelten Daten einzusehen.

Abbildung 1: Spotify gestattet es seinen Usern, die über sie gesammelten Daten einzusehen.

Diese gesammelten Daten rückt Spotify auf Anfrage sogar heraus. Wer geduldig herumstöbert, findet auf Spotifys Website unter Account | Privacy Settings die richtigen Knöpfe, um eine Kopie dieser Akten anzufordern. Mit der Antwort lässt sich Spotify Zeit. Vom Zeitpunkt der Anfrage an dauert es etwa eine Woche, bis anscheinend ein ärmelschonertragender Archivar die Daten in Leitz-Ordnern aus dem Spotify-Keller holt, komprimiert und als ZIP-Archiv auf der Webseite zur Abholung bereitstellt. Der mittels einer E-Mail benachrichtigte User darf die Daten dann während eines Zeitraums von zwei Wochen herunterladen und darin herumstöbern.

Fingerübung

In der ZIP-Datei mit den heruntergeladenen Daten findet sich eine JSON-Datei namens »StreamingHistory0.json«, die die Metadaten aller abgespielten Streams in ihrer historischen Reihenfolge enthält (Abbildung 2). Neben Song und Künstler listen die Einträge auch noch das Startdatum mit Uhrzeit sowie die Spieldauer auf. Letztere ist besonders interessant, denn wenn der User den Stream nach wenigen Sekunden abbricht und auf den nächsten Song vorspult, hat sich das Stück wahrscheinlich irrtümlich auf die Playlist verirrt und den User genervt. Es wird sich also beim Zusammenstellen von Musikvorschlägen höchstwahrscheinlich als Ente entpuppen.

Abbildung 2: JSON-Daten der Streaming-History.

Abbildung 2: JSON-Daten der Streaming-History.

Als leichte Fingerübung zeigt Listing 1 ein Go-Programm, das sich durch die JSON-Daten schlängelt und eine Hitparade der meistgespielten Songs erstellt. Die Ausgabe der Top Drei in Listing 2 enthüllt meine Lieblingssongs – abzüglich derer, die wegen Peinlichkeit von der Veröffentlichung ausgenommen wurden.

Listing 1

stats.go

package main
import (
  "encoding/json"
  "fmt"
  "io/ioutil"
  "sort"
)
type stream struct {
  EndTime    string `json:endTime`
  ArtistName string `json:artistName`
  MsPlayed   int64  `json:msPlayed`
  TrackName  string `json:trackName`
}
const jsonFile = "MyData/StreamingHistory0.json"
func main() {
  bySong := map[string]int64{}
  content, err := ioutil.ReadFile(jsonFile)
  if err != nil {
    panic(err)
  }
  data := []stream{}
  err = json.Unmarshal(content, &data)
  if err != nil {
    panic(err)
  }
  for _, song := range data {
    title := fmt.Sprintf("%s/%s", song.ArtistName, song.TrackName)
    bySong[title] += 1
  }
  type kv struct {
    Key   string
    Value int64
  }
  kvs := []kv{}
  for k, v := range bySong {
    kvs = append(kvs, kv{k, v})
  }
  sort.Slice(kvs, func(i, j int) bool {
    return kvs[i].Value > kvs[j].Value
  })
  for i := 0; i < 3; i++ {
    fmt.Printf("%s (%dx)\n", kvs[i].Key, kvs[i].Value)
  }
}

Listing 2

Top 3

Sparks/When Do I Get to Sing "My Way" - 2019 - Remaster (19x)
Falco/The Sound of Musik (16x)
Linkin Park/With You (14x)

Dazu öffnet Listing 1 in Zeile 17 die JSON-Datei und erhält in der Variablen »content« ein Objekt zurück, das das »Reader«-Interface beherrscht. Das gibt Zeile 22 an die Funktion »Unmarshal« aus dem Paket json in Gos Standard-Fundus weiter, mitsamt einem Pointer auf eine Struktur vom Typ »stream«, die vorher ab Zeile 8 definiert wurde. Go insistiert ja bekanntlich auf strenger Typprüfung, und damit der JSON-Parser aus den Spotify-Daten eine Go-interne Datenstruktur machen kann, muss deren Format bekannt sein und auch mit dem der tatsächlichen JSON-Daten übereinstimmen.

Das von Spotify gelieferte JSON besteht, wie aus Abbildung 2 ersichtlich, aus einem Array, dessen Elemente jeweils einem gestreamten Titel entsprechen. In den Feldern »ArtistName« und »TrackName« enthalten sie Interpret und Titel als Strings. In »MsPlayed« findet sich die Spieldauer in Millisekunden und in »EndTime« das Datum und die Uhrzeit beim Ende des Abspielens.

Die Felder der Struktur »stream« im Listing beginnen jeweils mit einem Großbuchstaben, damit andere Pakete später auch auf sie zugreifen dürfen. Dadurch sind die Namen jedoch nicht identisch mit den Variablennamen im JSON-Format, die jeweils mit einem Kleinbuchstaben beginnen. Das ist aber kein Beinbruch, denn Go erlaubt es, einer Struktur mit der Markierung »’json:’« einen eventuell vom Feldnamen abweichenden Namen zu geben.

So legt zum Beispiel »ArtistName string ‘json:artistName’« in Zeile 10 fest, dass der Interpret im Feld »ArtistName« vom Typ String in der Go-Struktur zu liegen kommt und der im hereinkommenden JSON dafür verwendete Name »artistName« ist. Das genügt, damit sich »json.Unmarshal()« in Zeile 22 durch alle Einträge der JSON-Datei wühlt, denn der Funktion wurde ein noch leeres Array dieser »stream«-Einträge in »data« als Pointer übergeben. Durch Reflexion findet die Funktion heraus, durch welche JSON-Strukturen sie sich durcharbeiten muss.

Listing 1 zählt in der Map »bySong«, die in Zeile 16 definiert ist, wie oft ein Song in der Streaming-Historie vorkommt. Dazu verwendet sie den String des Titels als Schlüssel und zählt den dahinter liegenden 64-Bit-Integer bei jedem gefundenen Abspiel-Event um eins hoch. Am Ende muss die Funktion dann die Map nach dem höchsten Integer-Wert absteigend sortieren, um die Top Drei auszugeben.

Sortieren ist kein Klacks

In einer Skriptsprache wäre das ein Klacks, aber Go bietet Typsicherheit, und deswegen wandelt Listing 1 die Map-Einträge in ein Array-Slice von »kv«-Strukturen (für Key/Value) um, deren Typ es ab Zeile 30 definiert. Die For-Schleife ab Zeile 35 muss dann durch die Einträge der Map rattern und jeden gefundenen Eintrag als »kv«-Struct an den Array-Slice »kvs« anhängen. Den kann dann Gos Standardfunktion »sort.Slice()« sortieren, weil der Callback in Zeile 39 ihr mitteilt, dass sie die gewünschte Reihenfolge zweier Einträge im Slice an den Positionen »i« und »j« durch einen Integer-Größenvergleich der beiden Werte an diesen Positionen ermitteln kann.

Puh, ganz schön umständlich! Am Ende geht die For-Schleife ab Zeile 41 durch das sortierte Array, gibt die Top-Positionen aus und bricht nach dem dritten Wert ab.

Schneller fertig mit R

Go-Programme für das Parsen von JSON-Daten und deren statistische Auswertung gehen also nur mühsam von der Hand. Gos Typsicherheit erfordert hier unverhältnismäßig viel Boilerplate-Code, den Skriptsprachen elegant vermeiden. Der Klassiker unter den Datenwurstlern, die Sprache R, geht da unbeschwerter vor und spart so manchen Arbeitsschritt. Falls Sie R noch nicht auf dem Rechner haben, installieren Sie es zum Beispiel unter Ubuntu einfach mit »sudo apt install r-base« nach.

Listing 3 zeigt eine einfache Anwendung, die die Streaming-History von Spotify durchforstet, ein Histogramm über die tatsächliche Abspieldauer der gehörten Songs produziert und es auch noch formschön grafisch darstellt (Abbildung 5). Das Diagramm illustriert, dass viele Songs nach weniger als 15 Sekunden (15 000 Millisekunden) einfach abgebrochen werden. In dem Fall hat sich Spotifys Vorschlagsalgorithmus also geirrt und der Hörer genervt auf den nächsten Song geschaltet. Ab etwa einer Minute Abspielzeit (also nach 60 000 Millisekunden) zeigt sich eine fast Gauss-artige Glockenkurve, deren Scheitel bei 220 Sekunden liegt. Die meisten Songs sind heutzutage also etwa dreieinhalb Minuten lang, die Mehrzahl liegt zwischen zwei und fünf Minuten.

Listing 3

hist.r

#!/usr/bin/env Rscript
library("jsonlite")
jdata <- fromJSON("MyData/StreamingHistory0.json", simplifyDataFrame = TRUE)
jdata <- jdata[jdata$msPlayed < 300000, ]
attach(jdata)
png(filename="hist.png")
hist(msPlayed, main="Milliseconds Played") 10 detach(jdata)

Damit Listing 3 sich von der Kommandozeile aufrufen lässt, sucht die Shebang-Anweisung in der ersten Zeile das Programm Rscript in den Suchpfaden der Shell und ruft den dahinter steckenden R-Interpreter mit dem Programmcode aus dem Listing auf.

Zum eleganten Einlesen der JSON-Daten nutzt Listing 3 das Paket »jsonlite«, das Sie vorab installieren müssen. Nach dem Öffnen einer R-Session (einfach auf der Kommandozeile »R« eingeben) lädt der Befehl »install.packages(“jsonlite”)« die C++-Quellen des Pakets aus dem CRAN-Netzwerk, kompiliert sie lokal und bindet die Library ins R-Universum ein. Anschließend darf jedes R-Skript mit »library(“jsonlite”)« die neue Bibliothek einbinden und Funktionen daraus aufrufen.

Zeile 3 liest mit der aus »jsonlite« exportierten Funktion »fromJSON« die JSON-Daten aus der Streaming-History und legt sie als sogenannten »Dataframe« der Variablen »jdata« ab. Dieser R-Standardtyp ist eine Art Datenbanktabelle mit reihenweisen Vektorwerten, die sich jeweils über mehrere Spaltenwerte erstrecken. Dabei dürfen die Spalten neben numerischen Werten und Zeichenketten auch sogenannte Factors enthalten. In R sind das Variablen mit einer bestimmten Zahl möglicher Werte, zum Beispiel »small«, »medium« und »large«.

Spiellänge als Statistik

Listing 3 muss die maximal erfasste Abspiellänge auf 5 Minuten begrenzen, da meine Streaming-History auch Hörspiele mit anderthalb Stunden Länge enthielt, die die Statistik bis zur Unkenntlichkeit verzerrten. Das Filtern erledigt die sogenannte Recoding-Anweisung in Zeile 4, die mit der Bedingung »jdata$msPlayed < 300000« alle Stücke über 300 Sekunden Spieldauer aus dem Dataframe »jdata« ausfiltert und das Ergebnis wiederum der Variablen »jdata« zuweist.

Das Recoding erfolgt sowohl auf Reihen- als auch auf Spaltenniveau: In den eckigen Klammern in Zeile 4 stehen die Bedingungen, jeweils durch ein Komma getrennt. Die erste wendet der Filter auf jede Reihe an, die zweite auf jede Spalte. So kommt hinten ein Dataframe heraus, der unter Umständen sowohl weniger Zeilen als auch weniger Spalten führt. Im vorliegenden Fall gilt es aber nur, Reihen zu entfernen, keine Spalten, weswegen der zweite Teil der Bedingung in eckigen Klammern nach dem Komma leer bleibt.

Das sehr kompakte Listing muss anschließend nur noch ein Histogramm über die »msPlayed«-Einträge im Dataframe »jdata« erstellen und die Zählerwerte für Spieldauern in einer Balkengrafik aufbereiten. Das macht die eingebaute R-Funktion »hist()« in Zeile 7, nachdem Zeile 5 den Dataframe »jdata« als Bezugspunkt eingestellt und Zeile 6 etwaige PNG-Ausgabedateien auf »hist.png« gesetzt hat. Das sorgt dafür, dass R am Ende des Skripts eine PNG-Datei mit der Balkengrafik unter diesem Namen anlegt.

Gruppe der Stunde

Erlauben die Daten in der Streaming-Historie auch Rückschlüsse auf eine Vorliebe für bestimmte Musik, abhängig von der Tageszeit? Listing 3 liest wieder die JSON-Daten ein, extrahiert aus dem »endTime«-Datumsstempel jedes Streaming-Ereignisses die Stunde der Abspielendzeit als numerischen Wert und bestimmt anschließend, welcher Interpret innerhalb dieses Zeitfensters über alle Tage gemittelt am häufigsten gespielt wurde.

Abbildung 3: Original-Dataframe aus den JSON-Daten.

Abbildung 3: Original-Dataframe aus den JSON-Daten.

Abbildung 3 zeigt die Original-JSON-Daten im Dataframe mit allen in der JSON-Datei definierten Feldern. Zeile 5 in Listing 4 verwirft alle Songs, die nicht mindestens eine Minute lang gelaufen sind, um keine Irrtümer in die Statistik einfließen zu lassen. Nun gilt es, aus dem Spotify-Zeitstempel die Tagesstunde herauszufieseln, und zwar nach dem Anpassen der Zeitzone: Spotify notiert die Zeiten als UTC (also GMT), ich lausche der Musik aber in der Zeitzone Pacific Time an der Westküste der USA. Daher liest die Funktion »as.POSIXct()« den Wert als UTC aus dem JSON, und der Formatierer »format« in Zeile 7 gibt ihn für die Zone »America/Los_Angeles« aus. Anschließend liegt der mit »%H« ermittelte Stundenwert als String vor. Um die Einträge später zu sortieren, braucht R jedoch numerische Werte, weswegen »as.numeric()« eine Zahl daraus macht.

Nun liegt der Dataframe wie in Abbildung 4 gezeigt in der Variablen »jdata«. Zeile 8 macht mit »subset()« aus den vollständigen Daten anschließend einen Dataframe mit nur zwei Spalten: dem Interpreten und der Abspielstunde. Die in R eingebaute Funktion »aggregate()« fasst nun in Zeile 9 alle Zeilen für einen Interpreten mit demselben Stundenwert zusammen. »FUN=length« legt dabei fest, dass in der zusätzlichen Aggregationsspalte die Länge, also die Anzahl der Interpret-Stunden-Tupel zu liegen kommt.

Abbildung 4: Gefilterter Dataframe mit Stundenspalte.

Abbildung 4: Gefilterter Dataframe mit Stundenspalte.

Abbildung 5 zeigt einen Ausschnitt dieses Zwischenergebnisses. Danach wurde die Gruppe ZZ Top zur Stunde 19 genau einmal gespielt, während sich ganze elf Einträge zur Stunde 20 mit der Gruppe Linkin Park finden. Um aus dieser Darstellung jetzt nur die Spitzenreiter herauszufiltern (also zum Beispiel zur Stunde 20 die Gruppe Linkin Park) bieten sich mehrere Verfahren an. Eines, das mit Rs Standardfunktionen auskommt, geht so: Der Dataframe wird nach Stunde (aufsteigend) und Anzahl der Ereignisse (absteigend) sortiert. Dann behält der Algorithmus mittels Deduplikation jeweils nur den ersten Eintrag pro Stundenwert bei und verwirft den Rest.

Abbildung 5: Aggregierte Z&auml;hler pro Stunde.

Abbildung 5: Aggregierte Zähler pro Stunde.

Zeile 10 sortiert den Dataframe »agg« entsprechend der in den eckigen Klammern angegebenen Funktion »order()«. Ihr erster Parameter ist der (positive) Feldname für den Stundenwert, der zweite der (negative) für den Zähler der »length«-Funktion. In R heißt er »x« und enthält die Anzahl der Ergebnisse.

Zeile 11 führt eine Recode-Anweisung auf den nun »winners« genannten Dataframe aus und gibt mit »!duplicated(winners[2])« an, dass das zweite Feld (also der Stundenwert; R-Arrays fangen immer mit dem Index 1 an, nicht mit 0) immer nur einmal im Ergebnis vorliegen darf. Folglich behält die Funktion immer nur den vorher nach vorne sortierten höchsten Ergebniswert für einen Stundenwert mit dem zugehörigen Interpreten und verwirft alle anderen.

Listing 4

hourly.r

#!/usr/bin/env Rscript
library("jsonlite")
jdata <- fromJSON("MyData/StreamingHistory0.json", simplifyDataFrame = TRUE)
# only enjoyed songs
jdata <- jdata[jdata$msPlayed > 60000, ]
d <- as.POSIXct(jdata$endTime, tz = "UTC")
jdata$hour <- as.numeric(format(d, tz="America/Los_Angeles", "%H"))
songs <- subset(jdata, , select=c(hour, artistName))
agg <- aggregate(songs$hour, by=list(artistName=songs$artistName, hour=songs$hour), FUN=length)
winners <- agg[order(agg$hour, -agg$x),]
winners <- winners[!duplicated(winners[2]),]
winners

Fertig ist die Liste mit den beliebtesten Gruppen, abhängig von der Tageszeit im Büro der Perlmeister-Studios! Abbildung 6 zeigt die Ausgabe des R-Programms »hourly.r«. Nach Mitternacht laufen in den heiligen Hallen der illustren Softwarebude nachweislich entweder peinliche Oldies aus den Achtzigern (Rainbow) oder, wie ich mich vage zu erinnern glaube, einmal von 1 bis 4 Uhr nachts alle Titel der Gruppe Sparks, von denen ich eine Netflix-Doku verschlungen hatte und daraufhin vier Stunden lang alle Songs abspielen ließ. Der nächste Arbeitstag war naturgemäß furchtbar, aber man lebt nur einmal.

Abbildung 6: Welche Interpreten laufen am h&auml;ufigsten zu welcher Stunde?

Abbildung 6: Welche Interpreten laufen am häufigsten zu welcher Stunde?

Erfahrungsgemäß kann es in R Tage dauern, bis der User die richtige Datenstruktur mitsamt den Methoden findet, die das Gewünschte dann in nur drei Zeilen implementieren. Zu den Gründen dafür gehören wohl das Alter der Sprache, die eine Art Anti-Python-Einstellung mitbringt (“Von wegen, nur ein Weg führt zum Ziel!”) und die vielen Pakete, die seit dem ursprünglichen Release über Jahrzehnte hinweg unkoordiniert veröffentlicht wurden. Eine Google-Suche nach einem bestimmen Problem enthüllt deshalb oft drei, vier verschiedene Wege zum Ziel. Ein Schulungswerk von Robert I. Kabacoff [1] erklärt ganz gut einige grundsätzliche Verfahren.

Noch mehr Geheimnisse

Wer weiter in der ZIP-Datei des Dossiers stöbert, findet noch das ein oder andere Datenjuwel. So enthält zum Beispiel die Datei »Inferences.json« von Spotify ermittelte Fakten über den User – wohl, um entsprechende Werbung zu schalten, auf die der Hörer auch anspricht.

Abbildung 7: Spotify h&auml;lt den Autor &ndash; f&auml;lschlicherweise &ndash; f&uuml;r einen Leichtbiertrinker.

Abbildung 7: Spotify hält den Autor – fälschlicherweise – für einen Leichtbiertrinker.

In meinem Fall nahm Spotify an, ich hätte eine Vorliebe für “Light Beer” (Abbildung 7), was absurd und falsch ist – wie jedermann, der mich kennt, weiß und notfalls beeiden könnte! Bei eventuell aufpoppender Bud-Light-Werbung läge hier die Erklärung.

Der Autor

Michael Schilli arbeitet als Software Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mailto:mschilli@perlmeister.com beantwortet er gern Ihre Fragen.

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 5 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Nach oben