Zuschauern zusehen (Linux-Magazin, Dezember 2024)

Woher weiß Netflix eigentlich so genau, welchen Film ich als nächstes sehen möchte? Als Streaming-Dienst hat der Movie-Moloch natürlich Zugang zu meinen Sehgewohnheiten, und über seine "Profiles" weiß er, was die die Personen in meinem Haushalt so alles weggeguckt haben.

Nicht geizig, lässt Netflix seine User in ihre eigene Historie hineinspitzeln. Unter dem Hauptmenüpunkt "Profiles" und dem jeweiligen User-Profile steht unter "Viewing Activity" eine Liste mit allen je geguckten Filmen samt Datumsstempel, die sich sogar bequem als .csv-Datei herunterladen lässt (Abbildungen 1 und 2). Dort stehen zeilenweise jeweils der Titel des Films oder der Serie sowie das Datum der Vorführung im Heimkino (Abbildung 3).

Wortkarg wegen Datenschutz

Früher waren dort mal detailreichere Daten zu finden, die unter anderem verrieten, welche Videos die User angeklickt aber kurz darauf wieder gestoppt hatten. Leider fiel dieses Feature (angeblich) dem Datenschutz zum Opfer, aber zumindest ist eine Gesamtliste aller je geguckten Filme mit Datumsstempel schon mal eine gute Basis für allerlei statistische Auswertungen.

Abbildung 1: Hier geht's zur .csv-Datei mit der Historie

Abbildung 2: Ein paar Sekunden später liegt die .csv-Datei vor

Abbildung 3: Fast 3.000 geguckte Videos in 17 Jahren

Eine .csv-Datei auszulesen geht in Skriptsprachen with Python ratz-fatz, oft mit einem Einzeiler, aber Go ist da etwas umständlicher und Listing 1 zeigt wie's geht. Die einlesende Funktion readHistory() ab Zeile 12 gibt als Ergebnis einen Array-Slice mit Film-Events vom Typ viewing zurück, die neben dem Filmtitel als String auch noch den Datumsstempel des Ergeignisses im Go-eigenen Zeittyp time.Time enthalten.

Listing 1: csv.go

    01 package main
    02 import (
    03   "encoding/csv"
    04   "fmt"
    05   "os"
    06   "time"
    07 )
    08 type Viewing struct {
    09   Title string
    10   Date  time.Time
    11 }
    12 func readHistory() ([]Viewing, error) {
    13   csvFile := "history.csv"
    14   viewings := []Viewing{}
    15   r, err := os.Open(csvFile)
    16   if err != nil {
    17     fmt.Printf("Can't open %s (%v)\n", csvFile, err)
    18     return viewings, err
    19   }
    20   defer r.Close()
    21   csvReader := csv.NewReader(r)
    22   records, err := csvReader.ReadAll()
    23   if err != nil {
    24     fmt.Printf("CSV reader error in %s: %v\n", csvFile, err)
    25     return viewings, err
    26   }
    27   // skip header
    28   records = records[1:]
    29   for _, line := range records {
    30     t, err := time.Parse("1/2/06", line[1])
    31     if err != nil {
    32       fmt.Errorf("Can't parse %v in %s: %v\n", line, csvFile, err)
    33       return viewings, err
    34     }
    35     v := Viewing{Title: line[0], Date: t}
    36     viewings = append(viewings, v)
    37   }
    38   return viewings, nil
    39 }

Komplexer als mit Skript

Ein neuer Reader des Pakets encoding/csv aus der Standard-Library liest die kommaseparierten Einträge der CSV-Datei Zeile für Zeile aus. Das ist keineswegs trivial, denn das Format akzeptiert ja zum Beispiel auch Strings, die Kommas enthalten, die dann mit Anführungsstrichen geschützt werden müssen, aber das Go-Paket erledigt all das klaglos. Die erste Zeile der Netflix-Datei enthält, wie im CSV-Format üblich, einen Header, der beschreibt, was denn die Felder in den nachfolgenden Records bedeuten, Zeile 28 in Listing 1 entfernt ihn manuell, denn die echten Daten folgen erst hinterher.

Da das Datum im zweiten Feld der nachfolgenden Datenzeilen im Format "mm/dd/yy" vorliegt, schnappt sich time.Parse() aus Gos Standardfundus den String in Zeile 30, um ihn zu interpretieren. Das Layout-Format "1/2/06" beschreibt mit 1 den Monat, 2 den Tag und 06 das zweistellige Jahr. Herauspurzelt ein Zeitstempel vom Typ time.Time, den das Programm später für einfache Datumsberechnungen weiterverwenden kann.

Jedes gefundene Guckereignis schiebt Zeile 36 jeweils ans Ende des Ergebnis-Arrays viewings, dessen Elemente aus Strukturen vom Typ viewing bestehen, der ab Zeile 8 definiert ist und einen Titel sowie den Zeitstempel als time.Time enthält.

Abbildung 4: Shows pro Monat über die Jahre

Grafisch aufgemotzt

Nun liegen die geguckten Videos in internen Datenstrukturen vor, die statistische Auswertung kann beginnen! Meine persönliche Netflix-Historie reicht bis ins Jahr 2006 zurück, als ich als internethungriger Heißsporn auf den damaligen Streaming-Zug aufsprang und seitdem nicht mehr ausgestiegen bin. Also wäre es vielleicht ganz interessant, herauszufinden, wieviele Videos über die Jahre pro Monat aufgerufen wurden. Abbildung 4 zeigt den Verlauf. Nach zaghaftem Beginn im Jahr 2006 findet sich zwischen 2010 und 2014 eine euphorische Binge-Watching-Episode mit bis zu 100 Videos pro Monat, die dann langsam abebbt und sich aktuell bei etwa 10 pro Monat einpendelt.

Listing 2: chart.go

    01 package main
    02 import (
    03   "fmt"
    04   "github.com/wcharczuk/go-chart/v2"
    05   "os"
    06   "time"
    07 )
    08 func main() {
    09   viewings, err := readHistory()
    10   if err != nil {
    11     panic(err)
    12   }
    13   xVals := []time.Time{}
    14   yVals := []float64{}
    15   prevMonth := time.Time{}
    16   for i := len(viewings) - 1; i >= 0; i-- {
    17     v := viewings[i]
    18     month := time.Date(v.Date.Year(), v.Date.Month(), 1, 0, 0, 0, 0, v.Date.Location())
    19     if prevMonth.IsZero() || prevMonth != month {
    20       xVals = append(xVals, month)
    21       yVals = append(yVals, 0)
    22       prevMonth = month
    23     }
    24     yVals[len(yVals)-1] += 1
    25   }
    26   chartData := chart.TimeSeries{
    27     XValues: xVals,
    28     YValues: yVals,
    29     Style: chart.Style{
    30       StrokeWidth: 2,
    31       StrokeColor: chart.ColorBlack,
    32       FillColor:   chart.ColorGreen,
    33     },
    34   }
    35   graph := chart.Chart{
    36     Series: []chart.Series{
    37       chartData,
    38     },
    39   }
    40   f, err := os.Create("netflix.png")
    41   if err != nil {
    42     fmt.Println("Error creating file:", err)
    43     return
    44   }
    45   defer f.Close()
    46   graph.Render(chart.PNG, f)
    47 }

Wie lässt sich so eine Zeitreihe in Go grafisch aufmotzen so wie in Abbildung 4? Listing 2 nutzt das Paket go-chart von Github, um die Anzahl der pro Monat weggeguckten Videos über die Zeitachse aufzutragen. Referenziert ein Eintrag aus der Netflix-Historie einen bislang unbekannten Monat, erzeugt Zeile 18 einen Zeitstempel, der auf 0 Uhr am Monatsanfang zeigt. Die Variable prevMonth speichert diesen Wert auch über die aktuelle Schleifenrunde hinaus und akkumuliert jeden weiteren Eintrag in diesem Monat unter dem Zeitstempel zum Monatsanfang.

Befindet Zeile 19, dass die (rückwärts) sortierten Ereignisse nun in einem anderen Monat stehen, schiebt Zeile 20 einen neuen Monat auf die Zeitachse in xVals und setzt den dazugehörigen Zähler in yVals auf Null. Anschließend erhöht Zeile 24 den Zähler für geschaute Videos im aktuell verarbeiteten Monat um Eins.

So stehen am Ende der For-Schleife in xVals ein Array von Monats-Zeitstempeln und in yVals ein gleich langes Array von Zählern von geschauten Videos, die der Typ TimeSeries aufnimmt und in einen Graphen verwandelt. Zeile 35 schiebt diesen in ein Koordinatensystem Chart und der Aufruf von Render() in Zeile 46 macht eine Png-Datei daraus.

Tiefer mit Meta

Ob ein gegucktes Video ein Kinofilm oder eine Folge einer Serie war, ist für menschliche Beobachter ziemlich klar, denn "Breaking Bad, Episode 1" ist klar eine Serie während "3:10 to Yuma" ohne Zweifel ein Film ist. Eine Maschine braucht hierzu aber Hintergrundwissen oder, computertechnisch ausgedrückt, Meta-Informationen. Ganz wie die Internet-Movie-Database IMDB Informationen zu Filmen sammelt, hat omdb.com diese Infos in einer Datenbank und lässt Suchabfragen per API zu. Hierzu muss lediglich ein API-Key beantragt werden, den es bei Registrierung kostenlos gibt und der bis zu 1.000 Abfragen pro Tag zulässt. Wer mehr braucht, kann per Patreon-Spende mehr haben.

Abbildung 5: Kostenlosen API-Key auf omdb.com abholen

Abbildung 6: Metadaten zum Film von OMDB, roter Pfeil zum IMDB-Rating

Listing 3 schaut die Meta-Informationen zu einem Netflix-Video mit der Funktion omdbFetch() in der Online-Datenbank anhand des Videotitels nach. Zurück kommt eine Struktur vom Type MovieMeta, die außer dem Videotitel auch noch die IMDB-Bewertung des Titels von 0 bis zu 10 Punkten enthält. Weitere Felder wären natürlich möglich und sinnvoll, und wurden nur der Kürze halber weggelassen. Die von der omdb-API zurückkommenden Json-Daten (Abbildung 6) enthalten extrem nützliche Informationen, wie die mitwirkenden Schauspieler ("Actors") den Regisseur ("Director"), oder sogar wieviel Geld der Film im Kino eingespielt hat ("BoxOffice").

Listing 3: omdb.go

    01 package main
    02 import (
    03   "fmt"
    04   "github.com/mschilli/go-murmur"
    05   "github.com/tidwall/gjson"
    06   "io/ioutil"
    07   "net/http"
    08   "net/url"
    09 )
    10 type MovieMeta struct {
    11   Title string
    12   Rating string
    13 }
    14 func omdbFetch(title string) (MovieMeta, error) {
    15   mmeta := MovieMeta{}
    16   baseURL := "http://www.omdbapi.com"
    17   apiURL, _ := url.Parse(baseURL)
    18   apiKey, err := murmur.NewMurmur().Lookup("omdb-api-key")
    19   if err != nil {
    20     return mmeta, err
    21   }
    22   q := url.Values{}
    23   q.Add("t", title)
    24   q.Add("apikey", apiKey)
    25   apiURL.RawQuery = q.Encode()
    26   resp, err := http.Get(apiURL.String())
    27   if err != nil {
    28     return mmeta, err
    29   }
    30   defer resp.Body.Close()
    31   if resp.StatusCode != http.StatusOK {
    32     return mmeta, fmt.Errorf("HTTP status %d", resp.StatusCode)
    33   }
    34   body, err := ioutil.ReadAll(resp.Body)
    35   if err != nil {
    36     return mmeta, err
    37   }
    38   jsonErr := gjson.Get(string(body), "Error")
    39   if jsonErr.Exists() {
    40     return mmeta, fmt.Errorf("%s not found in omdb", title)
    41   }
    42   mmeta.Title = title
    43   mmeta.Rating = gjson.Get(string(body),
    44     `Ratings.#(Source=="Internet Movie Database").Value`).String()
    45   return mmeta, nil
    46 }

Listing 3 steckt den für den API-Call den erforderlichen URL in der Variablen apiURL zusammen, nachdem es vorher in Zeile 18 den erforderlichen API-Key aus der Geheimnisdatei ~/.murmur geholt hat. Dort ist er unter dem Schlüssel omdb-api-key zu finden, nachdem der Admin sich bei omdb.com registriert, den API-Key von dort abgeholt und ihn in der Murmeldatei als Yaml-Eintrag im Format omdb-api-key: "xxx" abgelegt hat.

Json-Zugriff per Query

Findet omdb.com den Titel des gesuchten Films nicht in seiner Datenbank, legt es im zurückgeschickten Json-Salat unter dem Schlüssel "Error" eine Fehlermeldung ab. Die Funktion Get() aus dem gjson-Paket von Github schnappt sich den Eintrag in Zeile 38, prüft ihn, und falls dort wirklich eine Fehlermeldung steht, meldet return in Zeile 40 diese an den Aufrufer der Funktion omdbFetch(), also das Hauptprogramm, das ihn nach Gusto interpretiert.

Hat omdb.com den Titel hingegen gefunden, bohrt sich der gjson-Query in Zeile 44 über den Json-Eintrag Ratings in die Hierarchie der Bewertungen der IMDB und fischt mit Value den String-Eintrag im Format "x.xx/10" heraus. Der landet im Feld Rating der MovieMeta-Struktur, die Zeile 45 im Erfolgsfall an den Aufrufer zurückreicht.

Film oder Serie?

Um nun herauszufinden, bei wievielen der geguckten Videos es sich um Serien beziehungsweise Filme handelte, hangelt sich Listing 4 ab Zeile 13 durch alle Streaming-Events, holt sich mit omdbFetch() über den Titel die Meta-Informationen zum Video und schließt aus der Tatsache, dass nichts gefunden wurde, dass es sich um eine Serie handelt, denn omdb ausschließlich Filme, keine Serien. In den Variablen series und movies zählt der Code mit und summiert das Ergebnis auf.

Listing 4: genre.go

    01 package main
    02 import (
    03   "github.com/wcharczuk/go-chart/v2"
    04   "os"
    05 )
    06 func main() {
    07   series := 0
    08   movies := 0
    09   viewings, err := readHistory()
    10   if err != nil {
    11     panic(err)
    12   }
    13   for _, v := range viewings {
    14     _, err := omdbFetch(v.Title)
    15     if err == nil {
    16       movies += 1
    17     } else {
    18       series += 1
    19     }
    20   }
    21   pie := chart.PieChart{
    22     Width:  512,
    23     Height: 512,
    24     Values: []chart.Value{
    25       {Value: float64(series), Label: "Series"},
    26       {Value: float64(movies), Label: "Movies"},
    27     },
    28   }
    29   f, err := os.Create("genre.png")
    30   if err != nil {
    31     panic(err)
    32   }
    33   defer f.Close()
    34   err = pie.Render(chart.PNG, f)
    35   if err != nil {
    36     panic(err)
    37   }
    38 }

Das Verhältnis von Serien zu Filmen illustriert die mit dem chart-Paket gezeichnete Kuchengrafik (Abbildung 7). Im meinem persönlichen Profil (das unter Umständen verfälscht wurde durch weitere Personen in meinem Haushalt, die zu faul waren, die App auf ihr eigenes Profile umzustellen) ist das Verhältnis etwa 10:1, also kommen auf einen Film etwa 10 Serienfolgen.

Abbildung 7: Auf Netflix guckt Mike Schilli meist Serien

Abbildung 8: Metadaten mit Hilfe von omdb.com

Applaus oder Faule Tomaten

Die Güte einer Filmproduktion ist oft Geschmackssache, aber Bewertungs-Aggregatoren wie "Rotten Tomatoes" oder auch IMDB spannen eine Community von Filmenthusiasten ein, um eine representative Wertung in Form einer Fließkommazahl abzugeben. Die Meta-Daten in der Json-Antwort von omdb.com enthalten für viele Filme mehrere Wertungen und es wäre doch ganz interessant, eine objektive statistische Werteverteilung meiner geguckten Filme zu sehen.

Dazu malt Listing 5 das Histogramm in Abbildung 9, das illustriert, dass mein Geschmack in etwa den des Massenpublikums trifft. Die meisten Filme fallen in den Bereich von 7.0 - 7.5 von maximal 10 Bewertungspunkten, mit einer Gauß-artigen Glockenkurve als Verteilung.

Abbildung 9: Histogramm der IMDB-Bewertungen aller geschauten Filme

Listing 5 iteriert dazu wieder durch alle Streaming-Events und verwirft diesmal alle geguckten Serien, da auf IMDB nur Filme Ratings bekommen. Da die Json-Daten das Rating als String im Format "x.xx/10" angeben (roter Pfeil in Abbildung 6), fieseln die Zeilen 24 bis 25 die Fließkommazahl durch einen Split-Befehl aus der strings-Library und ParseFloat() aus dem Standardpaket strconv heraus.

Die einzelnen Klassen des Histogramms (auf Englisch "Bins", also Eimer) bestehen aus 20 gleich breiten Ratings-Spannen. Zeile 34 in Listing 5 gibt die Klassenbreite als 0.5 an, gefolgt vom Minimalwert 0 und dem Maximalwert 10 für die Ratings. Die gefundenen Fließkommazahlen steckt das Hauptprogramm in den Array data, und übergibt ihn der Funktion drawHisto() zum Zeichnen des Histogramms.

In der gilt es nun, die Werte auf gleich große Eimer zu verteilen, um sie später mit dem Typ BarChart aus dem Paket chart als ansprechende Grafik zu malen.

Listing 5: ratings.go

    01 package main
    02 import (
    03   "fmt"
    04   "github.com/wcharczuk/go-chart/v2"
    05   "os"
    06   "strconv"
    07   "strings"
    08 )
    09 func main() {
    10   viewings, err := readHistory()
    11   if err != nil {
    12     panic(err)
    13   }
    14   data := []float64{}
    15   for _, v := range viewings {
    16     md, err := omdbFetch(v.Title)
    17     if err != nil {
    18       if strings.Contains(err.Error(), "not found in omdb") {
    19         continue // series
    20       } else {
    21         panic(err)
    22       }
    23     }
    24     parts := strings.Split(md.Rating, "/")
    25     value, err := strconv.ParseFloat(parts[0], 64)
    26     if err != nil {
    27       continue
    28     }
    29     data = append(data, value)
    30   }
    31   drawHisto(data)
    32 }
    33 func drawHisto(data []float64) {
    34   binWidth := 0.5
    35   minValue := 0.0
    36   maxValue := 10.0
    37   numberOfBins := int((maxValue - minValue) / binWidth)
    38   bins := make([]int, numberOfBins)
    39   for _, value := range data {
    40     if value >= minValue && value < maxValue {
    41       i := int((value - minValue) / binWidth)
    42       bins[i]++
    43     }
    44   }
    45   bars := []chart.Value{}
    46   for i, count := range bins {
    47     binStart := minValue + float64(i)*binWidth
    48     binEnd := binStart + binWidth
    49     bars = append(bars, chart.Value{
    50       Value: float64(count),
    51       Label: fmt.Sprintf("%.1f-%.1f", binStart, binEnd),
    52     })
    53   }
    54   barChart := chart.BarChart{
    55     Height: 512,
    56     Width:  1024,
    57     Bars:   bars,
    58   }
    59   f, _ := os.Create("ratings.png")
    60   defer f.Close()
    61   barChart.Render(chart.PNG, f)
    62 }

Dazu iteriert die for-Schleife ab Zeile 39 über alle Elemente des Arrays, bestimmt durch Integer-Division den für den aktuellen Wert zuständigen Eimer im Array bins, und Zeile 42 zählt die im Eimer enthaltenen Einträge um Eins hoch.

Zum Zeichnen der Grafik benötigt das chart-Paket die Daten für die darzustellenden Balken als Werte vom Typ chart.Value, die jeweils einen Fließkommawert führen, sowie einen String Label, der den zugehörigen Eintrag auf der X-Achse der Grafik repräsentiert. Zeile 57 übergibt den fertigen Säulen-Array dem chart-Paket und Render() in Zeile 61 malt ihn samt Achsen und deren Beschriftungen formschön in eine Png-Datei.

Listing 6: build.sh

    1 $ go mod init netflix
    2 $ go mod tidy
    3 $ go build chart.go csv.go
    4 $ go build genre.go omdb.go csv.go
    5 $ go build ratings.go omdb.go csv.go

Um die vorgestellten Listings zu Binaries zusammenzubauen, compilieren und binden die drei go build-Befehle die für die drei Hauptprogramme jeweils erforderlichen Source-Dateien, nachdem go mod tidy vorher alle abhängigen Libraries von Github eingeholt hat. Damit wäre der Grundstein gelegt für's Erforschen der Netflix-Gewohnheiten. Hier noch einige weitere Ideen: Es ließe sich auch leicht herausfinden und grafisch illustrieren, welche Wochentage der Netflix-Gucker am aktivsten auf dem Sofa verbringt. Vielleicht sogar als Matrix-Anzeige im Format von Github-Beiträgen? Oder vielleicht ließe sich eine künstliche Intelligenz dazu einspannen, aus den Plot-Beschreibungen geguckter Filme den Plot eines neuen Superfilms zu generieren? Wie immer sind der Kreativität keine Grenzen gesetzt.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2024/12/snapshot/

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 mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc