package historyimport import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) const ( plexClientIdentifier = "silo-history-import" plexProduct = "Silo" plexVersion = "1.0.0" plexTVBaseURL = "https://plex.tv" plexPageSize = 500 ) type PlexClient struct { httpClient *http.Client limiter *upstreamRateLimiter } type PlexAccount struct { ID int `json:"id"` Title string `json:"title"` } func NewPlexClient() *PlexClient { return &PlexClient{ httpClient: &http.Client{Timeout: 30 * time.Second}, limiter: sharedHistoryImportUpstreamLimiter, } } func (c *PlexClient) CreatePin(ctx context.Context) (pinID int, pinCode string, err error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, plexTVBaseURL+"/api/v2/pins", strings.NewReader("strong=true")) if err != nil { return 0, "", err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") c.setPlexHeaders(req, "") var resp struct { ID int `json:"id"` Code string `json:"code"` } if err := c.doJSON(req, &resp); err != nil { return 0, "", fmt.Errorf("creating Plex pin: %w", err) } if resp.ID == 0 || resp.Code == "" { return 0, "", fmt.Errorf("creating Plex pin: empty pin response") } return resp.ID, resp.Code, nil } func (c *PlexClient) CheckPin(ctx context.Context, pinID int) (authToken string, err error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api/v2/pins/%d", plexTVBaseURL, pinID), nil) if err != nil { return "", err } c.setPlexHeaders(req, "") var resp struct { AuthToken string `json:"authToken"` } if err := c.doJSON(req, &resp); err != nil { return "", fmt.Errorf("checking Plex pin: %w", err) } return resp.AuthToken, nil } type plexResourceEntry struct { Name string `json:"name"` Product string `json:"product"` ClientIdentifier string `json:"clientIdentifier"` Provides string `json:"provides"` OwnerID *int `json:"ownerId"` Owned bool `json:"owned"` AccessToken string `json:"accessToken"` Connections []struct { Protocol string `json:"protocol"` Address string `json:"address"` Port int `json:"port"` URI string `json:"uri"` Local bool `json:"local"` } `json:"connections"` } func (c *PlexClient) GetResources(ctx context.Context, token string) ([]PlexServer, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, plexTVBaseURL+"/api/v2/resources?includeHttps=1&includeRelay=1", nil) if err != nil { return nil, err } c.setPlexHeaders(req, token) var entries []plexResourceEntry if err := c.doJSON(req, &entries); err != nil { return nil, fmt.Errorf("listing Plex resources: %w", err) } var servers []PlexServer for _, entry := range entries { if !strings.Contains(entry.Provides, "server") { continue } server := PlexServer{ Name: entry.Name, ClientIdentifier: entry.ClientIdentifier, AccessToken: entry.AccessToken, Owned: entry.Owned, } for _, conn := range entry.Connections { if conn.Local { server.LocalURL = conn.URI server.HasLocalURL = true } else { server.RemoteURL = conn.URI server.HasRemoteURL = true } } servers = append(servers, server) } return servers, nil } func (c *PlexClient) GetCurrentUser(ctx context.Context, token string) (*PlexAccount, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, plexTVBaseURL+"/api/v2/user", nil) if err != nil { return nil, err } c.setPlexHeaders(req, token) var account PlexAccount if err := c.doJSON(req, &account); err != nil { return nil, fmt.Errorf("getting current Plex user: %w", err) } if account.ID == 0 { return nil, fmt.Errorf("getting current Plex user: empty user response") } return &account, nil } type plexMediaContainer struct { MediaContainer struct { Size int `json:"size"` TotalSize int `json:"totalSize"` Offset int `json:"offset"` Metadata []PlexItem `json:"Metadata"` Directory []struct { Key string `json:"key"` Type string `json:"type"` Title string `json:"title"` } `json:"Directory"` } `json:"MediaContainer"` } type PlexItem struct { RatingKey string `json:"ratingKey"` Key string `json:"key"` Type string `json:"type"` Title string `json:"title"` GrandparentTitle string `json:"grandparentTitle"` GrandparentRatingKey string `json:"grandparentRatingKey"` ParentIndex int `json:"parentIndex"` Index int `json:"index"` Year int `json:"year"` Duration int64 `json:"duration"` ViewOffset int64 `json:"viewOffset"` ViewCount int `json:"viewCount"` LastViewedAt int64 `json:"lastViewedAt"` Guid PlexGuids `json:"Guid"` } type PlexGuid struct { ID string `json:"id"` } type PlexGuids []PlexGuid func (g *PlexGuids) UnmarshalJSON(data []byte) error { if string(data) == "null" { *g = nil return nil } var single string if err := json.Unmarshal(data, &single); err == nil { single = strings.TrimSpace(single) if single == "" { *g = nil return nil } *g = PlexGuids{{ID: single}} return nil } var many []PlexGuid if err := json.Unmarshal(data, &many); err == nil { *g = PlexGuids(many) return nil } return fmt.Errorf("unsupported Plex Guid payload: %s", string(data)) } func (c *PlexClient) FetchLibrarySections(ctx context.Context, baseURL, token string) ([]struct{ Key, Type, Title string }, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/library/sections", nil) if err != nil { return nil, err } c.setPlexHeaders(req, token) var container plexMediaContainer if err := c.doJSON(req, &container); err != nil { return nil, fmt.Errorf("fetching Plex library sections: %w", err) } var sections []struct{ Key, Type, Title string } for _, dir := range container.MediaContainer.Directory { sections = append(sections, struct{ Key, Type, Title string }{dir.Key, dir.Type, dir.Title}) } return sections, nil } func (c *PlexClient) FetchWatchedItems(ctx context.Context, baseURL, token, sectionKey string, mediaType int) ([]PlexItem, error) { return c.fetchSectionItems(ctx, baseURL, token, sectionKey, mediaType, true) } func (c *PlexClient) FetchSectionItems(ctx context.Context, baseURL, token, sectionKey string, mediaType int) ([]PlexItem, error) { return c.fetchSectionItems(ctx, baseURL, token, sectionKey, mediaType, false) } func (c *PlexClient) fetchSectionItems(ctx context.Context, baseURL, token, sectionKey string, mediaType int, watchedOnly bool) ([]PlexItem, error) { var allItems []PlexItem offset := 0 for { query := url.Values{} query.Set("type", strconv.Itoa(mediaType)) if watchedOnly { query.Set("unwatched", "0") } query.Set("includeGuids", "1") query.Set("X-Plex-Container-Start", strconv.Itoa(offset)) query.Set("X-Plex-Container-Size", strconv.Itoa(plexPageSize)) reqURL := fmt.Sprintf("%s/library/sections/%s/all?%s", baseURL, url.PathEscape(sectionKey), query.Encode()) req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) if err != nil { return nil, err } c.setPlexHeaders(req, token) var container plexMediaContainer if err := c.doJSON(req, &container); err != nil { return nil, fmt.Errorf("fetching Plex section items (section %s, type %d, offset %d): %w", sectionKey, mediaType, offset, err) } allItems = append(allItems, container.MediaContainer.Metadata...) offset += len(container.MediaContainer.Metadata) if offset >= container.MediaContainer.TotalSize || len(container.MediaContainer.Metadata) == 0 { break } } return allItems, nil } func (c *PlexClient) FetchOnDeck(ctx context.Context, baseURL, token string) ([]PlexItem, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/library/onDeck?includeGuids=1", nil) if err != nil { return nil, err } c.setPlexHeaders(req, token) var container plexMediaContainer if err := c.doJSON(req, &container); err != nil { return nil, fmt.Errorf("fetching Plex on-deck items: %w", err) } return container.MediaContainer.Metadata, nil } func (c *PlexClient) FetchMetadata(ctx context.Context, baseURL, token, ratingKey string) (*PlexItem, error) { reqURL := fmt.Sprintf("%s/library/metadata/%s?includeGuids=1", baseURL, url.PathEscape(ratingKey)) req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) if err != nil { return nil, err } c.setPlexHeaders(req, token) var container plexMediaContainer if err := c.doJSON(req, &container); err != nil { return nil, fmt.Errorf("fetching Plex metadata for %s: %w", ratingKey, err) } if len(container.MediaContainer.Metadata) == 0 { return nil, nil } return &container.MediaContainer.Metadata[0], nil } // Authenticate exchanges Plex account credentials for an auth token via plex.tv. func (c *PlexClient) Authenticate(ctx context.Context, username, password string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, plexTVBaseURL+"/users/sign_in.json", nil) if err != nil { return "", err } req.SetBasicAuth(username, password) c.setPlexHeaders(req, "") var resp struct { User struct { AuthToken string `json:"authToken"` } `json:"user"` } if err := c.doJSON(req, &resp); err != nil { return "", fmt.Errorf("authenticating with Plex: %w", err) } if resp.User.AuthToken == "" { return "", fmt.Errorf("authenticating with Plex: no auth token in response") } return resp.User.AuthToken, nil } func (c *PlexClient) setPlexHeaders(req *http.Request, token string) { req.Header.Set("Accept", "application/json") req.Header.Set("X-Plex-Client-Identifier", plexClientIdentifier) req.Header.Set("X-Plex-Product", plexProduct) req.Header.Set("X-Plex-Version", plexVersion) if token != "" { req.Header.Set("X-Plex-Token", token) } } func (c *PlexClient) doJSON(req *http.Request, out any) error { if err := c.limiter.Wait(req.Context(), req.URL); err != nil { return err } resp, err := c.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return &plexHTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))} } if out == nil { io.Copy(io.Discard, resp.Body) //nolint:errcheck return nil } return json.NewDecoder(resp.Body).Decode(out) } type plexHTTPError struct { StatusCode int Body string } func (e *plexHTTPError) Error() string { if e.Body == "" { return fmt.Sprintf("plex http %d", e.StatusCode) } return fmt.Sprintf("plex http %d: %s", e.StatusCode, e.Body) } // ListAccounts returns all user accounts that have access to the Plex Media Server. // Requires an admin token for the server. func (c *PlexClient) ListAccounts(ctx context.Context, baseURL, token string) ([]ExternalUser, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(baseURL, "/")+"/accounts", nil) if err != nil { return nil, err } c.setPlexHeaders(req, token) var container struct { MediaContainer struct { Account []struct { ID int `json:"id"` Name string `json:"name"` Home bool `json:"home"` Guest bool `json:"guest"` Restricted bool `json:"restricted"` } `json:"Account"` } `json:"MediaContainer"` } if err := c.doJSON(req, &container); err != nil { return nil, fmt.Errorf("listing Plex accounts: %w", err) } result := make([]ExternalUser, 0, len(container.MediaContainer.Account)) for _, a := range container.MediaContainer.Account { if a.ID == 0 { continue } result = append(result, ExternalUser{ ID: strconv.Itoa(a.ID), Name: a.Name, Home: a.Home, Guest: a.Guest, Restricted: a.Restricted, }) } return result, nil } // PlexHistoryItem is a single entry from the PMS session history endpoint. // It shares fields with plexItem but comes from the history API rather than the library API. type PlexHistoryItem struct { RatingKey string `json:"ratingKey"` Key string `json:"key"` Type string `json:"type"` Title string `json:"title"` GrandparentTitle string `json:"grandparentTitle"` GrandparentRatingKey string `json:"grandparentRatingKey"` ParentIndex int `json:"parentIndex"` Index int `json:"index"` Year int `json:"year"` Duration int64 `json:"duration"` ViewedAt int64 `json:"viewedAt"` AccountID int `json:"accountID"` Guid PlexGuids `json:"Guid"` } // FetchUserHistory returns the complete watch history for a specific account on the // Plex Media Server. Requires an admin token. func (c *PlexClient) FetchUserHistory(ctx context.Context, baseURL, token, accountID string) ([]PlexHistoryItem, error) { var allItems []PlexHistoryItem offset := 0 for { query := url.Values{} query.Set("accountID", accountID) query.Set("sort", "viewedAt:desc") query.Set("includeGuids", "1") query.Set("X-Plex-Container-Start", strconv.Itoa(offset)) query.Set("X-Plex-Container-Size", strconv.Itoa(plexPageSize)) reqURL := fmt.Sprintf("%s/status/sessions/history/all?%s", strings.TrimRight(baseURL, "/"), query.Encode()) req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) if err != nil { return nil, err } c.setPlexHeaders(req, token) var container struct { MediaContainer struct { Size int `json:"size"` TotalSize int `json:"totalSize"` Metadata []PlexHistoryItem `json:"Metadata"` } `json:"MediaContainer"` } if err := c.doJSON(req, &container); err != nil { return nil, fmt.Errorf("fetching Plex user history (account %s, offset %d): %w", accountID, offset, err) } allItems = append(allItems, container.MediaContainer.Metadata...) offset += len(container.MediaContainer.Metadata) if offset >= container.MediaContainer.TotalSize || len(container.MediaContainer.Metadata) == 0 { break } } return allItems, nil } func (c *PlexClient) Scrobble(ctx context.Context, baseURL, token, ratingKey string) error { query := url.Values{} query.Set("identifier", "com.plexapp.plugins.library") query.Set("key", ratingKey) reqURL := fmt.Sprintf("%s/:/scrobble?%s", strings.TrimRight(baseURL, "/"), query.Encode()) req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) if err != nil { return err } c.setPlexHeaders(req, token) if err := c.doJSON(req, nil); err != nil { return fmt.Errorf("scrobbling Plex item %s: %w", ratingKey, err) } return nil } func (c *PlexClient) Unscrobble(ctx context.Context, baseURL, token, ratingKey string) error { query := url.Values{} query.Set("identifier", "com.plexapp.plugins.library") query.Set("key", ratingKey) reqURL := fmt.Sprintf("%s/:/unscrobble?%s", strings.TrimRight(baseURL, "/"), query.Encode()) req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) if err != nil { return err } c.setPlexHeaders(req, token) if err := c.doJSON(req, nil); err != nil { return fmt.Errorf("unscrobbling Plex item %s: %w", ratingKey, err) } return nil } type PlexTimelineInput struct { RatingKey string Key string State string TimeMS int64 Duration int64 UpdatedMS int64 } func (c *PlexClient) Timeline(ctx context.Context, baseURL, token string, input PlexTimelineInput) error { query := url.Values{} query.Set("ratingKey", input.RatingKey) if input.Key != "" { query.Set("key", input.Key) } if input.State == "" { input.State = "stopped" } query.Set("state", input.State) query.Set("time", strconv.FormatInt(input.TimeMS, 10)) if input.Duration > 0 { query.Set("duration", strconv.FormatInt(input.Duration, 10)) } if input.UpdatedMS > 0 { query.Set("updated", strconv.FormatInt(input.UpdatedMS, 10)) } reqURL := fmt.Sprintf("%s/:/timeline?%s", strings.TrimRight(baseURL, "/"), query.Encode()) req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, nil) if err != nil { return err } c.setPlexHeaders(req, token) if err := c.doJSON(req, nil); err != nil { return fmt.Errorf("sending Plex timeline for item %s: %w", input.RatingKey, err) } return nil }