initial prototype complete, starting to package for docker
This commit is contained in:
16
go.mod
Normal file
16
go.mod
Normal file
@@ -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
|
||||
)
|
28
go.sum
Normal file
28
go.sum
Normal file
@@ -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=
|
91
main.go
Normal file
91
main.go
Normal file
@@ -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)
|
||||
|
||||
}
|
105
plex_client.go
Normal file
105
plex_client.go
Normal file
@@ -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
|
||||
}
|
188
plex_models.go
Normal file
188
plex_models.go
Normal file
@@ -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"`
|
||||
}
|
14
prom_metrics.go
Normal file
14
prom_metrics.go
Normal file
@@ -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",
|
||||
})
|
Reference in New Issue
Block a user