github.com/artpar/rclone@v1.67.3/backend/cache/plex.go (about)

     1  //go:build !plan9 && !js
     2  
     3  package cache
     4  
     5  import (
     6  	"bytes"
     7  	"crypto/tls"
     8  	"encoding/json"
     9  	"fmt"
    10  	"io"
    11  	"net/http"
    12  	"net/url"
    13  	"strings"
    14  	"sync"
    15  	"time"
    16  
    17  	"github.com/artpar/rclone/fs"
    18  	cache "github.com/patrickmn/go-cache"
    19  	"golang.org/x/net/websocket"
    20  )
    21  
    22  const (
    23  	// defPlexLoginURL is the default URL for Plex login
    24  	defPlexLoginURL        = "https://plex.tv/users/sign_in.json"
    25  	defPlexNotificationURL = "%s/:/websockets/notifications?X-Plex-Token=%s"
    26  )
    27  
    28  // PlaySessionStateNotification is part of the API response of Plex
    29  type PlaySessionStateNotification struct {
    30  	SessionKey       string `json:"sessionKey"`
    31  	GUID             string `json:"guid"`
    32  	Key              string `json:"key"`
    33  	ViewOffset       int64  `json:"viewOffset"`
    34  	State            string `json:"state"`
    35  	TranscodeSession string `json:"transcodeSession"`
    36  }
    37  
    38  // NotificationContainer is part of the API response of Plex
    39  type NotificationContainer struct {
    40  	Type             string                         `json:"type"`
    41  	Size             int                            `json:"size"`
    42  	PlaySessionState []PlaySessionStateNotification `json:"PlaySessionStateNotification"`
    43  }
    44  
    45  // PlexNotification is part of the API response of Plex
    46  type PlexNotification struct {
    47  	Container NotificationContainer `json:"NotificationContainer"`
    48  }
    49  
    50  // plexConnector is managing the cache integration with Plex
    51  type plexConnector struct {
    52  	url        *url.URL
    53  	username   string
    54  	password   string
    55  	token      string
    56  	insecure   bool
    57  	f          *Fs
    58  	mu         sync.Mutex
    59  	running    bool
    60  	runningMu  sync.Mutex
    61  	stateCache *cache.Cache
    62  	saveToken  func(string)
    63  }
    64  
    65  // newPlexConnector connects to a Plex server and generates a token
    66  func newPlexConnector(f *Fs, plexURL, username, password string, insecure bool, saveToken func(string)) (*plexConnector, error) {
    67  	u, err := url.ParseRequestURI(strings.TrimRight(plexURL, "/"))
    68  	if err != nil {
    69  		return nil, err
    70  	}
    71  
    72  	pc := &plexConnector{
    73  		f:          f,
    74  		url:        u,
    75  		username:   username,
    76  		password:   password,
    77  		token:      "",
    78  		insecure:   insecure,
    79  		stateCache: cache.New(time.Hour, time.Minute),
    80  		saveToken:  saveToken,
    81  	}
    82  
    83  	return pc, nil
    84  }
    85  
    86  // newPlexConnector connects to a Plex server and generates a token
    87  func newPlexConnectorWithToken(f *Fs, plexURL, token string, insecure bool) (*plexConnector, error) {
    88  	u, err := url.ParseRequestURI(strings.TrimRight(plexURL, "/"))
    89  	if err != nil {
    90  		return nil, err
    91  	}
    92  
    93  	pc := &plexConnector{
    94  		f:          f,
    95  		url:        u,
    96  		token:      token,
    97  		insecure:   insecure,
    98  		stateCache: cache.New(time.Hour, time.Minute),
    99  	}
   100  	pc.listenWebsocket()
   101  
   102  	return pc, nil
   103  }
   104  
   105  func (p *plexConnector) closeWebsocket() {
   106  	p.runningMu.Lock()
   107  	defer p.runningMu.Unlock()
   108  	fs.Infof("plex", "stopped Plex watcher")
   109  	p.running = false
   110  }
   111  
   112  func (p *plexConnector) websocketDial() (*websocket.Conn, error) {
   113  	u := strings.TrimRight(strings.Replace(strings.Replace(
   114  		p.url.String(), "http://", "ws://", 1), "https://", "wss://", 1), "/")
   115  	url := fmt.Sprintf(defPlexNotificationURL, u, p.token)
   116  
   117  	config, err := websocket.NewConfig(url, "http://localhost")
   118  	if err != nil {
   119  		return nil, err
   120  	}
   121  	if p.insecure {
   122  		config.TlsConfig = &tls.Config{InsecureSkipVerify: true}
   123  	}
   124  	return websocket.DialConfig(config)
   125  }
   126  
   127  func (p *plexConnector) listenWebsocket() {
   128  	p.runningMu.Lock()
   129  	defer p.runningMu.Unlock()
   130  
   131  	conn, err := p.websocketDial()
   132  	if err != nil {
   133  		fs.Errorf("plex", "%v", err)
   134  		return
   135  	}
   136  
   137  	p.running = true
   138  	go func() {
   139  		for {
   140  			if !p.isConnected() {
   141  				break
   142  			}
   143  
   144  			notif := &PlexNotification{}
   145  			err := websocket.JSON.Receive(conn, notif)
   146  			if err != nil {
   147  				fs.Debugf("plex", "%v", err)
   148  				p.closeWebsocket()
   149  				break
   150  			}
   151  			// we're only interested in play events
   152  			if notif.Container.Type == "playing" {
   153  				// we loop through each of them
   154  				for _, v := range notif.Container.PlaySessionState {
   155  					// event type of playing
   156  					if v.State == "playing" {
   157  						// if it's not cached get the details and cache them
   158  						if _, found := p.stateCache.Get(v.Key); !found {
   159  							req, err := http.NewRequest("GET", fmt.Sprintf("%s%s", p.url.String(), v.Key), nil)
   160  							if err != nil {
   161  								continue
   162  							}
   163  							p.fillDefaultHeaders(req)
   164  							resp, err := http.DefaultClient.Do(req)
   165  							if err != nil {
   166  								continue
   167  							}
   168  							var data []byte
   169  							data, err = io.ReadAll(resp.Body)
   170  							if err != nil {
   171  								continue
   172  							}
   173  							p.stateCache.Set(v.Key, data, cache.DefaultExpiration)
   174  						}
   175  					} else if v.State == "stopped" {
   176  						p.stateCache.Delete(v.Key)
   177  					}
   178  				}
   179  			}
   180  		}
   181  	}()
   182  }
   183  
   184  // fillDefaultHeaders will add common headers to requests
   185  func (p *plexConnector) fillDefaultHeaders(req *http.Request) {
   186  	req.Header.Add("X-Plex-Client-Identifier", fmt.Sprintf("rclone (%v)", p.f.String()))
   187  	req.Header.Add("X-Plex-Product", fmt.Sprintf("rclone (%v)", p.f.Name()))
   188  	req.Header.Add("X-Plex-Version", fs.Version)
   189  	req.Header.Add("Accept", "application/json")
   190  	if p.token != "" {
   191  		req.Header.Add("X-Plex-Token", p.token)
   192  	}
   193  }
   194  
   195  // authenticate will generate a token based on a username/password
   196  func (p *plexConnector) authenticate() error {
   197  	p.mu.Lock()
   198  	defer p.mu.Unlock()
   199  
   200  	form := url.Values{}
   201  	form.Set("user[login]", p.username)
   202  	form.Add("user[password]", p.password)
   203  	req, err := http.NewRequest("POST", defPlexLoginURL, strings.NewReader(form.Encode()))
   204  	if err != nil {
   205  		return err
   206  	}
   207  	p.fillDefaultHeaders(req)
   208  	resp, err := http.DefaultClient.Do(req)
   209  	if err != nil {
   210  		return err
   211  	}
   212  	var data map[string]interface{}
   213  	err = json.NewDecoder(resp.Body).Decode(&data)
   214  	if err != nil {
   215  		return fmt.Errorf("failed to obtain token: %w", err)
   216  	}
   217  	tokenGen, ok := get(data, "user", "authToken")
   218  	if !ok {
   219  		return fmt.Errorf("failed to obtain token: %v", data)
   220  	}
   221  	token, ok := tokenGen.(string)
   222  	if !ok {
   223  		return fmt.Errorf("failed to obtain token: %v", data)
   224  	}
   225  	p.token = token
   226  	if p.token != "" {
   227  		if p.saveToken != nil {
   228  			p.saveToken(p.token)
   229  		}
   230  		fs.Infof(p.f.Name(), "Connected to Plex server: %v", p.url.String())
   231  	}
   232  	p.listenWebsocket()
   233  
   234  	return nil
   235  }
   236  
   237  // isConnected checks if this rclone is authenticated to Plex
   238  func (p *plexConnector) isConnected() bool {
   239  	p.runningMu.Lock()
   240  	defer p.runningMu.Unlock()
   241  	return p.running
   242  }
   243  
   244  // isConfigured checks if this rclone is configured to use a Plex server
   245  func (p *plexConnector) isConfigured() bool {
   246  	return p.url != nil
   247  }
   248  
   249  func (p *plexConnector) isPlaying(co *Object) bool {
   250  	var err error
   251  	if !p.isConnected() {
   252  		p.listenWebsocket()
   253  	}
   254  
   255  	remote := co.Remote()
   256  	if cr, yes := p.f.isWrappedByCrypt(); yes {
   257  		remote, err = cr.DecryptFileName(co.Remote())
   258  		if err != nil {
   259  			fs.Debugf("plex", "can not decrypt wrapped file: %v", err)
   260  			return false
   261  		}
   262  	}
   263  
   264  	isPlaying := false
   265  	for _, v := range p.stateCache.Items() {
   266  		if bytes.Contains(v.Object.([]byte), []byte(remote)) {
   267  			isPlaying = true
   268  			break
   269  		}
   270  	}
   271  
   272  	return isPlaying
   273  }
   274  
   275  // adapted from: https://stackoverflow.com/a/28878037 (credit)
   276  func get(m interface{}, path ...interface{}) (interface{}, bool) {
   277  	for _, p := range path {
   278  		switch idx := p.(type) {
   279  		case string:
   280  			if mm, ok := m.(map[string]interface{}); ok {
   281  				if val, found := mm[idx]; found {
   282  					m = val
   283  					continue
   284  				}
   285  			}
   286  			return nil, false
   287  		case int:
   288  			if mm, ok := m.([]interface{}); ok {
   289  				if len(mm) > idx {
   290  					m = mm[idx]
   291  					continue
   292  				}
   293  			}
   294  			return nil, false
   295  		}
   296  	}
   297  	return m, true
   298  }