Initial commit
This commit is contained in:
30
Dockerfile
Normal file
30
Dockerfile
Normal 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
14
docker-compose.yml
Normal 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
|
||||||
163
main.go
Normal file
163
main.go
Normal 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
95
structs.go
Normal 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"`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user