package main import ( "bytes" "encoding/json" "fmt" "io" "log/slog" "net/http" "os" "strconv" "strings" "time" ) var lbToken string var plexUsername string var plexLibsStr string var listen = ":9102" func main() { lbToken = os.Getenv("PB_LISTENBRAINZ_USER_TOKEN") plexUsername = os.Getenv("PB_PLEX_USERNAME") plexLibsStr = os.Getenv("PB_PLEX_LIBRARIES") debug, _ := strconv.ParseBool(os.Getenv("PB_DEBUG")) if lbToken == "" { slog.Error("you must set PB_LISTENBRAINZ_USER_TOKEN - see https://listenbrainz.org/settings/") os.Exit(1) } if plexUsername == "" { slog.Error("you must set PB_PLEX_USERNAME to the user who's listens will be recorded") os.Exit(1) } if plexLibsStr == "" { slog.Error("you must set PB_PLEX_LIBRARIES to a comma separated list of plex libraries which will be scrobbled") os.Exit(1) } if debug { slog.SetLogLoggerLevel(slog.LevelDebug) } plexLibs := strings.Split(plexLibsStr, ",") http.HandleFunc("POST /plex", func(w http.ResponseWriter, r *http.Request) { err := r.ParseMultipartForm(1 * 1024 * 1024) if err != nil { slog.Error("error parsing multipart form", "error", err.Error()) return } pl := r.FormValue("payload") event := webhookEvent{} err = json.Unmarshal([]byte(pl), &event) if err != nil { slog.Error("error parsing JSON", "error", err.Error()) w.WriteHeader(400) w.Write([]byte("could not parse your JSON")) return } slog.Debug("received event", "event", event) // slog.Printf("account name: %s", event.Account.Title) // slog.Printf("title: %s", event.Metadata.Title) // slog.Printf("album: %s", event.Metadata.ParentTitle) // slog.Printf("artist: %s", event.Metadata.GrandparentTitle) // slog.Printf("library: %s", event.Metadata.LibrarySectionTitle) // 2025/10/19 09:21:31 event: media.play // 2025/10/19 09:21:31 account name: username // 2025/10/19 09:21:31 title: Face to Face (Cosmo Vitelli remix) // 2025/10/19 09:21:31 album: Daft Club // 2025/10/19 09:21:31 artist: Daft Punk // 2025/10/19 09:21:31 library: Music if event.Account.Title != plexUsername { slog.Error("not scrobbling for this user", "user", event.Account.Title) w.WriteHeader(200) w.Write([]byte("ok")) return } if event.Event == "media.scrobble" { found := false for i := range plexLibs { if event.Metadata.LibrarySectionTitle == plexLibs[i] { found = true break } } if !found { slog.Debug("not scrobbling for this library", "library", event.Metadata.LibrarySectionTitle) slog.Debug("does not match a configured library", "libraries", plexLibsStr) w.WriteHeader(200) w.Write([]byte("ok")) return } err = lbSubmit(event.Metadata.Title, event.Metadata.GrandparentTitle, event.Metadata.ParentTitle) if err != nil { slog.Error("error submitting to listenbrainz", "error", err.Error()) w.WriteHeader(400) w.Write([]byte("could not submit to listenbrainz")) return } else { slog.Info("scrobbled play", "grandparent", event.Metadata.GrandparentTitle, "title", event.Metadata.Title) w.WriteHeader(200) w.Write([]byte("ok")) return } } else { slog.Debug("non scrobble event", "event", event.Event) w.WriteHeader(200) w.Write([]byte("ok")) return } }) slog.Info("starting web server", "listen", listen) slog.Error(http.ListenAndServe(listen, nil).Error()) os.Exit(1) } func lbSubmit(title, artist, album string) error { c := http.Client{} c.Timeout = time.Second * 10 url := "https://api.listenbrainz.org/1/submit-listens" b := bytes.Buffer{} pl := lbListen{ ListenType: "single", Payload: []lbListenPayload{{ ListenedAt: time.Now().Unix(), TrackMetadata: lbTrackMetadata{ ArtistName: artist, TrackName: title, ReleaseName: album, }, }}, } err := json.NewEncoder(&b).Encode(pl) if err != nil { return err } req, err := http.NewRequest("POST", url, bytes.NewBuffer(b.Bytes())) if err != nil { return err } req.Header.Set("Authorization", fmt.Sprintf("Token %s", lbToken)) req.Header.Set("Content-Type", "application/json") res, err := c.Do(req) if err != nil { return err } defer res.Body.Close() if res.StatusCode != 200 { errB := bytes.Buffer{} io.Copy(&errB, res.Body) return fmt.Errorf("got non-200 response: %d - body: %s", res.StatusCode, errB.String()) } // happy path resB := bytes.Buffer{} io.Copy(&resB, res.Body) slog.Debug("listenbrainz OK", "response", resB.String()) return nil }