diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..50e65f3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: '/' + schedule: + schedule: + interval: daily + time: '01:00' + open-pull-requests-limit: 10 + labels: + - dependencies + + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: daily + time: '01:00' + open-pull-requests-limit: 10 + labels: + - dependencies diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml new file mode 100644 index 0000000..01641ea --- /dev/null +++ b/.github/workflows/goreleaser.yml @@ -0,0 +1,28 @@ +name: goreleaser + +on: + push: + tags: + - '*' + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - + name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - + name: Set up Go + uses: actions/setup-go@v2 + - + name: Run GoReleaser + uses: goreleaser/goreleaser-action@v2 + with: + distribution: goreleaser + version: latest + args: release --rm-dist + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 5c8c9de..7bd7851 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,16 @@ env CGO_ENABLED=0 go install -trimpath -ldflags="-s -w" github.com/RoyXiang/plex 1. Configure environment variables in your preferred way - `PLEX_BASEURL` (Required, e.g. `http://127.0.0.1:32400`) - - `REDIS_URL` (Optional, e.g. `redis://127.0.0.1:6379`) - * If you need a cache layer, set a value for it - `PLEX_TOKEN` (Optional, if you need it, see [here](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/)) * It is used to receive notifications from Plex Media Server * Notifications are used to flush the cache of metadata + - `REDIS_URL` (Optional, e.g. `redis://127.0.0.1:6379`) + * If you need a cache layer, set a value for it + * `PLEX_TOKEN` is required - `PLAXT_URL` (Optional, e.g. `https://plaxt.astandke.com/api?id=generate-your-own-silly`) * `PLEX_TOKEN` is required * Set it if you run an instance of [Plaxt](https://github.com/XanderStrike/goplaxt) * Or, you can set it to [the official one](https://plaxt.astandke.com/) + - `REDIRECT_WEB_APP` (Optional, default: `true`) + - `DISABLE_TRANSCODE` (Optional, default: `true`) 2. Run the program diff --git a/go.mod b/go.mod index 3dc8551..7060656 100644 --- a/go.mod +++ b/go.mod @@ -17,4 +17,4 @@ require ( github.com/google/uuid v1.3.0 // indirect ) -replace github.com/jrudio/go-plex-client v0.0.0-20220106065909-9e1d590b99aa => github.com/RoyXiang/go-plex-client v0.0.0-20220223140842-7433de7e9b77 +replace github.com/jrudio/go-plex-client v0.0.0-20220106065909-9e1d590b99aa => github.com/RoyXiang/go-plex-client v0.0.0-20220303081538-bac4a5c2593f diff --git a/go.sum b/go.sum index ad6fbd7..d21d524 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,8 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/RoyXiang/go-plex-client v0.0.0-20220223140842-7433de7e9b77 h1:GVtLzX8S4rd6u9OflS/huI2ilbXVsxSpjipLtx/Jm2c= -github.com/RoyXiang/go-plex-client v0.0.0-20220223140842-7433de7e9b77/go.mod h1:NICqgLUxSYsDHh3n+m6xomGmRbqLxBcN4D7Jb9Z6LJ0= +github.com/RoyXiang/go-plex-client v0.0.0-20220303081538-bac4a5c2593f h1:yQl+hCc6I/k8iQh73TyzG8ArLkcXeiyeAkjOc2jBAX4= +github.com/RoyXiang/go-plex-client v0.0.0-20220303081538-bac4a5c2593f/go.mod h1:NICqgLUxSYsDHh3n+m6xomGmRbqLxBcN4D7Jb9Z6LJ0= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= diff --git a/handler/const.go b/handler/const.go index 059d0de..81d391b 100644 --- a/handler/const.go +++ b/handler/const.go @@ -35,6 +35,12 @@ const ( contentTypeXml = "xml" watchedThreshold = 90 + + webhookEventPlay = "media.play" + webhookEventResume = "media.resume" + webhookEventPause = "media.pause" + webhookEventStop = "media.stop" + webhookEventScrobble = "media.scrobble" ) const ( diff --git a/handler/main.go b/handler/main.go index 4c7a65d..7f4a5ae 100644 --- a/handler/main.go +++ b/handler/main.go @@ -24,16 +24,18 @@ var ( func init() { plexClient = NewPlexClient(PlexConfig{ - BaseUrl: os.Getenv("PLEX_BASEURL"), - Token: os.Getenv("PLEX_TOKEN"), - PlaxtUrl: os.Getenv("PLAXT_URL"), + BaseUrl: os.Getenv("PLEX_BASEURL"), + Token: os.Getenv("PLEX_TOKEN"), + PlaxtUrl: os.Getenv("PLAXT_URL"), + RedirectWebApp: os.Getenv("REDIRECT_WEB_APP"), + DisableTranscode: os.Getenv("DISABLE_TRANSCODE"), }) if plexClient == nil { log.Fatalln("Please configure PLEX_BASEURL as a valid URL at first") } redisUrl := os.Getenv("REDIS_URL") - if redisUrl != "" { + if redisUrl != "" && plexClient.IsTokenSet() { options, err := redis.ParseURL(redisUrl) if err == nil { redisClient = redis.NewClient(options) diff --git a/handler/middleware.go b/handler/middleware.go index 3f37b4c..9bd42f0 100644 --- a/handler/middleware.go +++ b/handler/middleware.go @@ -185,9 +185,9 @@ func cacheMiddleware(next http.Handler) http.Handler { if token == "" { return } - userId := plexClient.GetUserId(token) - if userId > 0 { - params.Set(headerUserId, strconv.Itoa(userId)) + user := plexClient.GetUser(token) + if user != nil { + params.Set(headerUserId, strconv.Itoa(user.Id)) params.Set(headerAccept, getAcceptContentType(r)) } else { params.Set(headerToken, token) diff --git a/handler/plex.go b/handler/plex.go index eea12dc..5a55d9c 100644 --- a/handler/plex.go +++ b/handler/plex.go @@ -24,21 +24,25 @@ import ( ) type PlexConfig struct { - BaseUrl string - Token string - PlaxtUrl string + BaseUrl string + Token string + PlaxtUrl string + RedirectWebApp string + DisableTranscode string } type PlexClient struct { proxy *httputil.ReverseProxy client *plex.Plex - plaxtBaseUrl string - plaxtUrl string + plaxtUrl string + redirectWebApp bool + disableTranscode bool serverIdentifier *string sections map[string]plex.Directory sessions map[string]sessionData + friends map[string]plexUser mu sync.RWMutex } @@ -66,23 +70,48 @@ func NewPlexClient(config PlexConfig) *PlexClient { plaxtUrl = u.String() } + var redirectWebApp, disableTranscode bool + if b, err := strconv.ParseBool(config.RedirectWebApp); err == nil { + redirectWebApp = b + } else { + redirectWebApp = true + } + if b, err := strconv.ParseBool(config.DisableTranscode); err == nil { + disableTranscode = b + } else { + disableTranscode = true + } + return &PlexClient{ - proxy: proxy, - client: client, - plaxtUrl: plaxtUrl, - sections: make(map[string]plex.Directory, 0), - sessions: make(map[string]sessionData), + proxy: proxy, + client: client, + plaxtUrl: plaxtUrl, + redirectWebApp: redirectWebApp, + disableTranscode: disableTranscode, + sections: make(map[string]plex.Directory, 0), + sessions: make(map[string]sessionData), + friends: make(map[string]plexUser), } } +func (u *plexUser) MarshalBinary() ([]byte, error) { + return json.Marshal(u) +} + +func (u *plexUser) UnmarshalBinary(data []byte) error { + return json.Unmarshal(data, u) +} + func (c *PlexClient) ServeHTTP(w http.ResponseWriter, r *http.Request) { path := r.URL.EscapedPath() switch { case path == "/video/:/transcode/universal/decision": - r = c.disableTranscoding(r) + if c.disableTranscode { + r = c.disableTranscoding(r) + } case strings.HasPrefix(path, "/web/"): - if r.Method == http.MethodGet { - http.Redirect(w, r, "https://app.plex.tv/desktop", http.StatusMovedPermanently) + if c.redirectWebApp && r.Method == http.MethodGet { + http.Redirect(w, r, "https://app.plex.tv/desktop", http.StatusFound) return } } @@ -90,9 +119,10 @@ func (c *PlexClient) ServeHTTP(w http.ResponseWriter, r *http.Request) { c.proxy.ServeHTTP(w, r) if w.(middleware.WrapResponseWriter).Status() == http.StatusOK { + query := r.URL.Query() switch path { case "/:/scrobble", "/:/unscrobble": - go clearCachedMetadata(r.Header.Get(headerToken)) + go clearCachedMetadata(query.Get("key"), r.Header.Get(headerToken)) case "/:/timeline": go c.syncTimelineWithPlaxt(r) } @@ -121,75 +151,109 @@ func (c *PlexClient) SubscribeToNotifications(events *plex.NotificationEvents, i c.client.SubscribeToNotifications(events, interrupt, fn) } -func (c *PlexClient) GetUserId(token string) (id int) { +func (c *PlexClient) GetUser(token string) (user *plexUser) { + if realUser, ok := c.friends[token]; ok { + user = &realUser + return + } + var err error ctx := context.Background() cacheKey := fmt.Sprintf("%s:token:%s", cachePrefixPlex, token) - id, err = redisClient.Get(ctx, cacheKey).Int() - if err == nil { - return id + + isCacheEnabled := redisClient != nil + if isCacheEnabled { + err = redisClient.Get(ctx, cacheKey).Scan(user) + if err == nil { + c.friends[token] = *user + return + } } response := c.GetSharedServers() + if response == nil { + return + } for _, friend := range response.Friends { - key := fmt.Sprintf("%s:token:%s", cachePrefixPlex, friend.AccessToken) - redisClient.Set(ctx, key, friend.UserId, 0) + realUser := plexUser{ + Id: friend.UserId, + Username: friend.Username, + } if friend.AccessToken == token { - id = friend.UserId + user = &realUser + } + c.friends[friend.AccessToken] = realUser + if isCacheEnabled { + key := fmt.Sprintf("%s:token:%s", cachePrefixPlex, friend.AccessToken) + redisClient.Set(ctx, key, &realUser, 0) } } - if id > 0 { + if user != nil { return } - user := c.GetAccountInfo(token) - if user.ID > 0 { - redisClient.Set(ctx, cacheKey, user.ID, 0) - id = user.ID + info := c.GetAccountInfo(token) + if info.ID > 0 { + realUser := c.friends[token] + user = &realUser + if isCacheEnabled { + redisClient.Set(ctx, cacheKey, user, 0) + } } return } -func (c *PlexClient) GetSharedServers() (response plex.SharedServersResponse) { +func (c *PlexClient) GetSharedServers() *plex.SharedServersResponse { c.mu.RLock() defer c.mu.RUnlock() identifier := c.getServerIdentifier() if identifier == "" { - return + return nil } - response, _ = c.client.GetSharedServers(identifier) - return + response, err := c.client.GetSharedServers(identifier) + if err != nil { + return nil + } + return &response } func (c *PlexClient) GetAccountInfo(token string) (user plex.UserPlexTV) { - if c.client.Token != token { - c.mu.Lock() - originalToken := token - defer func() { - c.client.Token = originalToken - c.mu.Unlock() - }() - } else { - c.mu.RLock() - defer c.mu.RUnlock() - } + c.mu.Lock() + originalToken := token + defer func() { + c.client.Token = originalToken + c.mu.Unlock() + }() + var err error c.client.Token = token - user, _ = c.client.MyAccount() + user, err = c.client.MyAccount() + if err == nil { + c.friends[token] = plexUser{ + Id: user.ID, + Username: user.Username, + } + } return } func (c *PlexClient) syncTimelineWithPlaxt(r *http.Request) { - if c.plaxtUrl == "" || c.client.Token == "" { + if c.plaxtUrl == "" || !c.IsTokenSet() { return } + token := r.Header.Get(headerToken) clientUuid := r.Header.Get(headerClientIdentity) ratingKey := r.URL.Query().Get("ratingKey") playbackTime := r.URL.Query().Get("time") state := r.URL.Query().Get("state") - if clientUuid == "" || ratingKey == "" || playbackTime == "" || state == "" { + if token == "" || clientUuid == "" || ratingKey == "" || playbackTime == "" || state == "" { + return + } + + user := c.GetUser(token) + if user == nil { return } @@ -220,6 +284,9 @@ func (c *PlexClient) syncTimelineWithPlaxt(r *http.Request) { sectionId := session.metadata.LibrarySectionID.String() if c.getLibrarySection(sectionId) { section = c.sections[sectionId] + if section.Type != "show" && section.Type != "movie" { + return + } } else { return } @@ -229,6 +296,8 @@ func (c *PlexClient) syncTimelineWithPlaxt(r *http.Request) { metadata := c.getMetadata(ratingKey) if metadata == nil { return + } else if metadata.MediaContainer.Metadata[0].OriginalTitle != "" { + session.metadata.Title = metadata.MediaContainer.Metadata[0].OriginalTitle } for _, guid := range metadata.MediaContainer.Metadata[0].AltGUIDs { externalGuids = append(externalGuids, plexhooks.ExternalGuid{ @@ -247,32 +316,34 @@ func (c *PlexClient) syncTimelineWithPlaxt(r *http.Request) { case "playing": if session.status == sessionPlaying { if progress >= 100 { - event = "media.scrobble" + event = webhookEventScrobble } else { - event = "media.resume" + event = webhookEventResume } } else { - event = "media.play" + event = webhookEventPlay } case "paused": if progress >= watchedThreshold && session.status == sessionPlaying { - event = "media.scrobble" + event = webhookEventScrobble } else { - event = "media.pause" + event = webhookEventPause } case "stopped": if progress >= watchedThreshold && session.status == sessionPlaying { - event = "media.scrobble" + event = webhookEventScrobble } else { - event = "media.stop" + event = webhookEventStop } } if event == "" || session.status == sessionWatched { return - } else if event == "media.scrobble" { + } else if event == webhookEventScrobble { session.status = sessionWatched sessionChanged = true - go clearCachedMetadata(r.Header.Get(headerToken)) + go clearCachedMetadata(ratingKey, r.Header.Get(headerToken)) + } else if event == webhookEventStop { + go clearCachedMetadata(ratingKey, r.Header.Get(headerToken)) } else if session.status == sessionUnplayed { session.status = sessionPlaying sessionChanged = true @@ -289,7 +360,9 @@ func (c *PlexClient) syncTimelineWithPlaxt(r *http.Request) { Owner: true, User: false, Account: plexhooks.Account{ - Title: session.metadata.User.Title, + Id: user.Id, + Title: user.Username, + Thumb: session.metadata.User.Thumb, }, Server: plexhooks.Server{ Uuid: serverIdentifier, @@ -391,8 +464,8 @@ func (c *PlexClient) getMetadata(ratingKey string) *plex.MediaMetadata { c.mu.RLock() defer c.mu.RUnlock() - userId := c.GetUserId(c.client.Token) - if userId <= 0 { + user := c.GetUser(c.client.Token) + if user == nil { return nil } @@ -405,7 +478,7 @@ func (c *PlexClient) getMetadata(ratingKey string) *plex.MediaMetadata { req.Header.Set(headerAccept, "application/json") var resp *http.Response - cacheKey := fmt.Sprintf("%s:%s?%s=%s&%s=%d", cachePrefixMetadata, path, headerAccept, "json", headerUserId, userId) + cacheKey := fmt.Sprintf("%s:%s?%s=%s&%s=%d", cachePrefixMetadata, path, headerAccept, "json", headerUserId, user.Id) isFromCache := false if redisClient != nil { b, err := redisClient.Get(context.Background(), cacheKey).Bytes() diff --git a/handler/structs.go b/handler/structs.go index 82944cd..18af95a 100644 --- a/handler/structs.go +++ b/handler/structs.go @@ -24,3 +24,8 @@ type sessionData struct { lastEvent string status sessionStatus } + +type plexUser struct { + Id int `json:"id"` + Username string `json:"username"` +} diff --git a/handler/utils.go b/handler/utils.go index f711a5f..7faf476 100644 --- a/handler/utils.go +++ b/handler/utils.go @@ -118,7 +118,7 @@ func writeToCache(key string, resp *http.Response, ttl time.Duration) { redisClient.Set(context.Background(), key, b, ttl) } -func clearCachedMetadata(token string) { +func clearCachedMetadata(ratingKey, token string) { if redisClient == nil { return } @@ -126,16 +126,20 @@ func clearCachedMetadata(token string) { mu.Lock() defer mu.Unlock() - pattern := fmt.Sprintf("%s:*", cachePrefixMetadata) + pattern := cachePrefixMetadata + ":" + if ratingKey != "" { + pattern += fmt.Sprintf("/library/metadata/%s", ratingKey) + } if token != "" { - userId := plexClient.GetUserId(token) - if userId > 0 { - pattern = fmt.Sprintf("%s%s=%d*", pattern, headerUserId, userId) + user := plexClient.GetUser(token) + if user != nil { + pattern += fmt.Sprintf("*%s=%d", headerUserId, user.Id) } } + pattern += "*" ctx := context.Background() - keys := redisClient.Keys(ctx, fmt.Sprintf("%s:*", cachePrefixMetadata)).Val() + keys := redisClient.Keys(ctx, pattern).Val() if len(keys) > 0 { redisClient.Del(ctx, keys...).Val() } diff --git a/handler/websocket.go b/handler/websocket.go index b6cd1c6..181ffbf 100644 --- a/handler/websocket.go +++ b/handler/websocket.go @@ -69,6 +69,6 @@ func wsOnActivity(n plex.NotificationContainer) { } } if isMetadataChanged { - clearCachedMetadata("") + clearCachedMetadata("", "") } }