diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..dad45f9 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module gitea.derajnet.duckdns.org/deranjer/plex-prometheus-exporter + +go 1.19 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/prometheus/client_golang v1.16.0 // indirect + github.com/prometheus/client_model v0.3.0 // indirect + github.com/prometheus/common v0.42.0 // indirect + github.com/prometheus/procfs v0.10.1 // indirect + golang.org/x/sys v0.8.0 // indirect + google.golang.org/protobuf v1.30.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2240a67 --- /dev/null +++ b/go.sum @@ -0,0 +1,28 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= +github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= +github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= +github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= +github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= +github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= +github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= +github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= diff --git a/main.go b/main.go new file mode 100644 index 0000000..9f55cc8 --- /dev/null +++ b/main.go @@ -0,0 +1,91 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "os" + "strconv" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +type serverSettings struct { + port int + plexAddr string + plexToken string + plexTimeout int + metricsPrefix string + metricsMediaCollectingIntervalSeconds int +} + +func envGetter(key, fallback string) string { + if value, ok := os.LookupEnv(key); ok { + return value + } + return fallback +} + +func setupServerSettings() serverSettings { + port, err := strconv.Atoi(envGetter("PORT", "9545")) + if err != nil { + log.Fatal("Unable to parse port string to int, failing....") + } + plexToken, value := os.LookupEnv("PLEX_TOKEN") + if !value { + log.Fatal("No plex token provided, failing....") + } + plexTimeout, err := strconv.Atoi(envGetter("PLEX_TIMEOUT", "10")) + if err != nil { + log.Fatal("Unable to parse plextimeout string to int, failing....") + } + metricsMediaCollectingIntervalSeconds, err := strconv.Atoi(envGetter("METRICS_MEDIA_COLLECTING_INTERVAL_SECONDS", "300")) + if err != nil { + log.Fatal("Unable to parse METRICS_MEDIA_COLLECTING_INTERVAL_SECONDS string to int, failing....") + } + + return serverSettings{ + port: port, + plexAddr: envGetter("PLEX_ADDR", "http://localhost:32400"), + plexToken: plexToken, + plexTimeout: plexTimeout, + metricsPrefix: envGetter("METRICS_PREFIX", "PLEX"), + metricsMediaCollectingIntervalSeconds: metricsMediaCollectingIntervalSeconds, + } +} + +func main() { + + os.Setenv("PORT", "9545") + os.Setenv("PLEX_ADDR", "https://plex.derajnet.duckdns.org:32400") + os.Setenv("PLEX_TOKEN", "5ezJu5cjnhoAbPJRKngs") + os.Setenv("PLEX_TIMEOUT", "10") + os.Setenv("METRICS_PREFIX", "PLEX") + os.Setenv("METRICS_MEDIA_COLLECTING_INTERVAL_SECONDS", "300") + + settings := setupServerSettings() + // Setup plex client + pc := setupClient(&settings) + + prometheus.MustRegister(plexActiveSessions) + prometheus.MustRegister(plexNumMovies) + prometheus.MustRegister(plexNumTVShows) + + go func() { + for { + time.Sleep(time.Second * 5) + fmt.Println("Collecting info...") + stats := pc.gatherAllStats() + plexActiveSessions.Set(stats.currentSessions) + plexNumMovies.Set(stats.numMovies) + plexNumTVShows.Set(stats.numTV) + } + }() + + // m := NewMetrics(reg) + http.Handle("/metrics", promhttp.Handler()) + http.ListenAndServe(fmt.Sprintf(":%d", settings.port), nil) + +} diff --git a/plex_client.go b/plex_client.go new file mode 100644 index 0000000..c16dcfc --- /dev/null +++ b/plex_client.go @@ -0,0 +1,105 @@ +package main + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" +) + +type plexStats struct { + currentSessions float64 + numMovies float64 + numTV float64 +} + +type PlexClient struct { + baseUrl string + client *http.Client + plexToken string +} + +func setupClient(settings *serverSettings) PlexClient { + transCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + + return PlexClient{ + baseUrl: settings.plexAddr, + client: &http.Client{Transport: transCfg}, + plexToken: settings.plexToken, + } + +} + +// sendRequest sends a request and resturns response (always assuming a get request) +func (pc *PlexClient) sendRequest(url string, body []byte) (result []byte) { + fullUrl := pc.baseUrl + url + req, err := http.NewRequest("GET", fullUrl, bytes.NewBuffer(body)) + if err != nil { + fmt.Println("Error creating new request", err) + return + } + req.Header.Add("Accept", "application/json") + q := req.URL.Query() + q.Add("X-Plex-Token", pc.plexToken) + req.URL.RawQuery = q.Encode() + res, err := pc.client.Do(req) + if err != nil { + fmt.Println("Error sending request", err) + return + } + defer res.Body.Close() + resBody, err := io.ReadAll(res.Body) + if err != nil { + fmt.Println("Unable to read response body: ", err) + return + } + return resBody + +} + +func (pc *PlexClient) getActiveSessions() *ActiveSessions { + result := pc.sendRequest("/status/sessions", nil) + currentActiveSessions := ActiveSessions{} + err := json.Unmarshal(result, ¤tActiveSessions) + if err != nil { + fmt.Println("Error unmarshalling current active sessions: ", err) + } + return ¤tActiveSessions +} + +func (pc *PlexClient) getNumMovies() *ActiveSessions { + result := pc.sendRequest("/library/sections/3/all", nil) //Hard code library ID for now TODO: Grab from library/sections call + libraryAll := ActiveSessions{} + err := json.Unmarshal(result, &libraryAll) + if err != nil { + fmt.Println("Error unmarshalling current active sessions: ", err) + } + return &libraryAll +} + +func (pc *PlexClient) getNumTV() *ActiveSessions { + result := pc.sendRequest("/library/sections/2/all", nil) //Hard code library ID for now TODO: Grab from library/sections call + libraryAll := ActiveSessions{} + err := json.Unmarshal(result, &libraryAll) + if err != nil { + fmt.Println("Error unmarshalling current active sessions: ", err) + } + return &libraryAll +} + +func (pc *PlexClient) gatherAllStats() *plexStats { + allStats := plexStats{} + + sessionsData := pc.getActiveSessions() + libraryDetails := pc.getNumMovies() + tvshowDetails := pc.getNumTV() + + allStats.currentSessions = float64(sessionsData.MediaContainer.Size) + allStats.numMovies = float64(libraryDetails.MediaContainer.Size) + allStats.numTV = float64(tvshowDetails.MediaContainer.Size) + return &allStats +} diff --git a/plex_models.go b/plex_models.go new file mode 100644 index 0000000..ea8deda --- /dev/null +++ b/plex_models.go @@ -0,0 +1,188 @@ +package main + +type ActiveSessions struct { + MediaContainer struct { + Size int `json:"size"` + Metadata []struct { + AddedAt int `json:"addedAt"` + Art string `json:"art"` + Duration int `json:"duration"` + GrandparentArt string `json:"grandparentArt"` + GrandparentGUID string `json:"grandparentGuid"` + GrandparentKey string `json:"grandparentKey"` + GrandparentRatingKey string `json:"grandparentRatingKey"` + GrandparentThumb string `json:"grandparentThumb"` + GrandparentTitle string `json:"grandparentTitle"` + GUID string `json:"guid"` + Key string `json:"key"` + LastViewedAt int `json:"lastViewedAt"` + LibrarySectionID string `json:"librarySectionID"` + LibrarySectionKey string `json:"librarySectionKey"` + LibrarySectionTitle string `json:"librarySectionTitle"` + MusicAnalysisVersion string `json:"musicAnalysisVersion"` + OriginalTitle string `json:"originalTitle"` + ParentGUID string `json:"parentGuid"` + ParentIndex int `json:"parentIndex"` + ParentKey string `json:"parentKey"` + ParentRatingKey string `json:"parentRatingKey"` + ParentTitle string `json:"parentTitle"` + RatingKey string `json:"ratingKey"` + SessionKey string `json:"sessionKey"` + Title string `json:"title"` + Type string `json:"type"` + ViewOffset int `json:"viewOffset"` + Media []struct { + AudioChannels int `json:"audioChannels"` + AudioCodec string `json:"audioCodec"` + Bitrate int `json:"bitrate"` + Container string `json:"container"` + Duration int `json:"duration"` + ID string `json:"id"` + Part []struct { + Container string `json:"container"` + Duration int `json:"duration"` + File string `json:"file"` + ID string `json:"id"` + Key string `json:"key"` + Size int `json:"size"` + Stream []struct { + AlbumGain string `json:"albumGain"` + AlbumPeak string `json:"albumPeak"` + AlbumRange string `json:"albumRange"` + AudioChannelLayout string `json:"audioChannelLayout"` + Bitrate int `json:"bitrate"` + Channels int `json:"channels"` + Codec string `json:"codec"` + DisplayTitle string `json:"displayTitle"` + ExtendedDisplayTitle string `json:"extendedDisplayTitle"` + Gain string `json:"gain"` + ID string `json:"id"` + Index int `json:"index"` + Loudness string `json:"loudness"` + Lra string `json:"lra"` + Peak string `json:"peak"` + SamplingRate int `json:"samplingRate"` + Selected bool `json:"selected"` + StreamType int `json:"streamType"` + } `json:"Stream"` + } `json:"Part"` + } `json:"Media"` + User struct { + ID string `json:"id"` + Thumb string `json:"thumb"` + Title string `json:"title"` + } `json:"User"` + Player struct { + Address string `json:"address"` + Device string `json:"device"` + MachineIdentifier string `json:"machineIdentifier"` + Platform string `json:"platform"` + PlatformVersion string `json:"platformVersion"` + Product string `json:"product"` + Profile string `json:"profile"` + RemotePublicAddress string `json:"remotePublicAddress"` + State string `json:"state"` + Title string `json:"title"` + Version string `json:"version"` + Local bool `json:"local"` + Relayed bool `json:"relayed"` + Secure bool `json:"secure"` + UserID int `json:"userID"` + } `json:"Player"` + } `json:"Metadata"` + } `json:"MediaContainer"` +} + +type LibraryAll struct { + MediaContainer struct { + Size int `json:"size"` + AllowSync bool `json:"allowSync"` + Art string `json:"art"` + Identifier string `json:"identifier"` + LibrarySectionID int `json:"librarySectionID"` + LibrarySectionTitle string `json:"librarySectionTitle"` + LibrarySectionUUID string `json:"librarySectionUUID"` + MediaTagPrefix string `json:"mediaTagPrefix"` + MediaTagVersion int `json:"mediaTagVersion"` + Thumb string `json:"thumb"` + Title1 string `json:"title1"` + Title2 string `json:"title2"` + ViewGroup string `json:"viewGroup"` + ViewMode int `json:"viewMode"` + Metadata []struct { + RatingKey string `json:"ratingKey"` + Key string `json:"key"` + GUID string `json:"guid"` + Studio string `json:"studio,omitempty"` + Type string `json:"type"` + Title string `json:"title"` + ContentRating string `json:"contentRating,omitempty"` + Summary string `json:"summary"` + Rating float64 `json:"rating,omitempty"` + AudienceRating float64 `json:"audienceRating,omitempty"` + Year int `json:"year,omitempty"` + Tagline string `json:"tagline,omitempty"` + Thumb string `json:"thumb"` + Art string `json:"art,omitempty"` + Duration int `json:"duration"` + OriginallyAvailableAt string `json:"originallyAvailableAt,omitempty"` + AddedAt int `json:"addedAt"` + UpdatedAt int `json:"updatedAt"` + AudienceRatingImage string `json:"audienceRatingImage,omitempty"` + ChapterSource string `json:"chapterSource,omitempty"` + PrimaryExtraKey string `json:"primaryExtraKey,omitempty"` + RatingImage string `json:"ratingImage,omitempty"` + Media []struct { + ID int `json:"id"` + Duration int `json:"duration"` + Bitrate int `json:"bitrate"` + Width int `json:"width"` + Height int `json:"height"` + AspectRatio float64 `json:"aspectRatio"` + AudioChannels int `json:"audioChannels"` + AudioCodec string `json:"audioCodec"` + VideoCodec string `json:"videoCodec"` + VideoResolution string `json:"videoResolution"` + Container string `json:"container"` + VideoFrameRate string `json:"videoFrameRate"` + OptimizedForStreaming int `json:"optimizedForStreaming"` + Has64BitOffsets bool `json:"has64bitOffsets"` + VideoProfile string `json:"videoProfile"` + Part []struct { + ID int `json:"id"` + Key string `json:"key"` + Duration int `json:"duration"` + File string `json:"file"` + Size int `json:"size"` + Container string `json:"container"` + Has64BitOffsets bool `json:"has64bitOffsets"` + OptimizedForStreaming bool `json:"optimizedForStreaming"` + VideoProfile string `json:"videoProfile"` + } `json:"Part"` + } `json:"Media"` + Genre []struct { + Tag string `json:"tag"` + } `json:"Genre,omitempty"` + Country []struct { + Tag string `json:"tag"` + } `json:"Country,omitempty"` + Director []struct { + Tag string `json:"tag"` + } `json:"Director,omitempty"` + Writer []struct { + Tag string `json:"tag"` + } `json:"Writer,omitempty"` + Role []struct { + Tag string `json:"tag"` + } `json:"Role,omitempty"` + TitleSort string `json:"titleSort,omitempty"` + ViewCount int `json:"viewCount,omitempty"` + LastViewedAt int `json:"lastViewedAt,omitempty"` + SkipCount int `json:"skipCount,omitempty"` + OriginalTitle string `json:"originalTitle,omitempty"` + ViewOffset int `json:"viewOffset,omitempty"` + CreatedAtAccuracy string `json:"createdAtAccuracy,omitempty"` + CreatedAtTZOffset string `json:"createdAtTZOffset,omitempty"` + } `json:"Metadata"` + } `json:"MediaContainer"` +} diff --git a/prom_metrics.go b/prom_metrics.go new file mode 100644 index 0000000..0e890d4 --- /dev/null +++ b/prom_metrics.go @@ -0,0 +1,14 @@ +package main + +import "github.com/prometheus/client_golang/prometheus" + +var plexActiveSessions = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "plex_active_sessions", Help: "Shows number of active sessions in plex"}) + +var plexNumMovies = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "plex_num_movies", Help: "Number of movies in the plex database", +}) + +var plexNumTVShows = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "plex_num_tv_shows", Help: "Number of TV Shows in the plex database", +})