Initial commit

This commit is contained in:
2025-10-19 15:47:39 +10:30
commit 49bf9370b0
5 changed files with 305 additions and 0 deletions

30
Dockerfile Normal file
View File

@@ -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"]

14
docker-compose.yml Normal file
View File

@@ -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

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module plexbrainz
go 1.24.1

163
main.go Normal file
View File

@@ -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
}

95
structs.go Normal file
View File

@@ -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"`
}