commit 49bf9370b0a35f390d34acfdb70cf6645225ff18 Author: Justin Hawkins Date: Sun Oct 19 15:47:39 2025 +1030 Initial commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bc6dbe8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM --platform=$BUILDPLATFORM golang:1.25 AS build-stage +ARG TARGETPLATFORM +ARG BUILDPLATFORM +RUN echo "I am running on $BUILDPLATFORM, building for $TARGETPLATFORM" + +WORKDIR /app + +COPY go.mod ./ +RUN go mod download + +COPY . ./ + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /plexbrainz + +# Run the tests in the container +FROM build-stage AS run-test-stage +RUN go test -v ./... + +# Deploy the application binary into a lean image +FROM gcr.io/distroless/base-debian11 AS build-release-stage + +WORKDIR / + +COPY --from=build-stage /plexbrainz /plexbrainz + +EXPOSE 1973 + +USER nonroot:nonroot + +ENTRYPOINT ["/plexbrainz"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..63c521c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + plexbrainz: + platform: linux/amd64 + build: + context: . + image: tardisx/plexbrainz + container_name: plexbrainz + environment: + - PB_LISTENBRAINZ_USER_TOKEN=token + - PB_PLEX_USERNAME=someusernamehere + - PB_PLEX_LIBRARIES=Music + ports: + - 9102:9102 + restart: unless-stopped diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..86e904e --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module plexbrainz + +go 1.24.1 diff --git a/main.go b/main.go new file mode 100644 index 0000000..40d43f7 --- /dev/null +++ b/main.go @@ -0,0 +1,163 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "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") + + if lbToken == "" { + log.Fatal("you must set PB_LISTENBRAINZ_USER_TOKEN - see https://listenbrainz.org/settings/") + } + + if plexUsername == "" { + log.Fatal("you must set PB_PLEX_USERNAME to the user who's listens will be recorded") + } + + if plexLibsStr == "" { + log.Fatal("you must set PB_PLEX_LIBRARIES to a comma separated list of plex libraries which will be scrobbled") + } + + plexLibs := strings.Split(plexLibsStr, ",") + + http.HandleFunc("POST /plex", func(w http.ResponseWriter, r *http.Request) { + err := r.ParseMultipartForm(1 * 1024 * 1024) + if err != nil { + log.Printf("error parsing multipart form: %s", err.Error()) + return + } + + pl := r.FormValue("payload") + event := webhookEvent{} + err = json.Unmarshal([]byte(pl), &event) + + if err != nil { + log.Printf("error parsing JSON: %s", err.Error()) + w.WriteHeader(400) + w.Write([]byte("could not parse your JSON")) + return + } + + // log.Printf("event: %s", event.Event) + // log.Printf("account name: %s", event.Account.Title) + // log.Printf("title: %s", event.Metadata.Title) + // log.Printf("album: %s", event.Metadata.ParentTitle) + // log.Printf("artist: %s", event.Metadata.GrandparentTitle) + // log.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 { + log.Printf("not scrobbling for user '%s'", plexUsername) + 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 { + log.Printf("not scrobbling for library '%s'", event.Metadata.LibrarySectionTitle) + log.Printf("does not match one of: '%s'", plexLibsStr) + w.WriteHeader(200) + w.Write([]byte("ok")) + return + } + + err = lbSubmit(event.Metadata.Title, event.Metadata.GrandparentTitle, event.Metadata.ParentTitle) + if err != nil { + log.Printf("error submitting: %s", err.Error()) + w.WriteHeader(400) + w.Write([]byte("could not parse your JSON")) + return + } else { + log.Printf("scrobbled play of %s - %s", event.Metadata.GrandparentTitle, event.Metadata.Title) + w.WriteHeader(200) + w.Write([]byte("ok")) + return + } + } else { + log.Printf("non scrobble event: %s", event.Event) + w.WriteHeader(200) + w.Write([]byte("ok")) + return + } + + }) + log.Printf("starting web server on %s", listen) + log.Fatal(http.ListenAndServe(listen, nil)) +} + +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 + return nil +} diff --git a/structs.go b/structs.go new file mode 100644 index 0000000..5dad06d --- /dev/null +++ b/structs.go @@ -0,0 +1,95 @@ +package main + +// webhookEvent is an incoming plex event +// this struct was autogenerated so may not be suitable for all +// events +type webhookEvent struct { + Event string `json:"event"` + User bool `json:"user"` + Owner bool `json:"owner"` + Account struct { + ID int `json:"id"` + Thumb string `json:"thumb"` + Title string `json:"title"` + } `json:"Account"` + Server struct { + Title string `json:"title"` + UUID string `json:"uuid"` + } `json:"Server"` + Player struct { + Local bool `json:"local"` + PublicAddress string `json:"publicAddress"` + Title string `json:"title"` + UUID string `json:"uuid"` + } `json:"Player"` + Metadata struct { + LibrarySectionType string `json:"librarySectionType"` + RatingKey string `json:"ratingKey"` + Key string `json:"key"` + ParentRatingKey string `json:"parentRatingKey"` + GrandparentRatingKey string `json:"grandparentRatingKey"` + GUID string `json:"guid"` + ParentGUID string `json:"parentGuid"` + GrandparentGUID string `json:"grandparentGuid"` + ParentStudio string `json:"parentStudio"` + Type string `json:"type"` + Title string `json:"title"` + GrandparentKey string `json:"grandparentKey"` + ParentKey string `json:"parentKey"` + LibrarySectionTitle string `json:"librarySectionTitle"` + LibrarySectionID int `json:"librarySectionID"` + LibrarySectionKey string `json:"librarySectionKey"` + GrandparentTitle string `json:"grandparentTitle"` + ParentTitle string `json:"parentTitle"` + Summary string `json:"summary"` + Index int `json:"index"` + ParentIndex int `json:"parentIndex"` + RatingCount int `json:"ratingCount"` + ViewCount int `json:"viewCount"` + LastViewedAt int `json:"lastViewedAt"` + ParentYear int `json:"parentYear"` + Thumb string `json:"thumb"` + Art string `json:"art"` + ParentThumb string `json:"parentThumb"` + GrandparentThumb string `json:"grandparentThumb"` + GrandparentArt string `json:"grandparentArt"` + AddedAt int `json:"addedAt"` + UpdatedAt int `json:"updatedAt"` + MusicAnalysisVersion string `json:"musicAnalysisVersion"` + Image []struct { + Alt string `json:"alt"` + Type string `json:"type"` + URL string `json:"url"` + } `json:"Image"` + Genre []struct { + ID int `json:"id"` + Filter string `json:"filter"` + Tag string `json:"tag"` + } `json:"Genre"` + GUID0 []struct { + ID string `json:"id"` + } `json:"Guid"` + Mood []struct { + ID int `json:"id"` + Filter string `json:"filter"` + Tag string `json:"tag"` + } `json:"Mood"` + } `json:"Metadata"` +} + +// lbListen is the payload for a listenbrainz listen submission +type lbListen struct { + ListenType string `json:"listen_type"` + Payload []lbListenPayload `json:"payload"` +} + +type lbListenPayload struct { + ListenedAt int64 `json:"listened_at"` + TrackMetadata lbTrackMetadata `json:"track_metadata"` +} + +type lbTrackMetadata struct { + ArtistName string `json:"artist_name"` + TrackName string `json:"track_name"` + ReleaseName string `json:"release_name"` +}