Documentation
¶
Overview ¶
Package plexwatch delivers one viewer's watched state, and whole-star ratings, to that viewer's own Plex account.
Local watch rows stay authoritative. A watch mark and a rating each have their own timestamp, so one cannot make the other look newer. Intents are rows in state_sync_journal, written in the same transaction as the watch change. This package does not subscribe to in-memory state events.
Provenance of the upstream contract, none of it a live call from this repository:
POST https://scrobbles.plex.tv/state returned 201 for items with guid, viewedAt (unix milliseconds), viewState "complete" or "unplayed", optional isBulk, and optional ratedAt plus an integer rating. The user field is the numeric Plex account id. The GET echo is GET https://scrobbles.plex.tv/state/{accountId}?after={cursor}&count=100 with items and pageInfo.endCursor / hasNextPage. isBulk and a show-level guid in the POST were omitted from that echo. Source: PMS verbose log posted by doug316, 2024-06-07, https://forums.plex.tv/t/mark-as-unwatched-does-not-work/879522
viewState "complete" for a watched movie, a rating-only item {"guid","ratedAt","rating"}, and HTTP 422 when viewedAt is in the future (the whole POST fails). Plex employee gbooker02 identified the future timestamp. Source: Havohej, 2024-01-24, https://forums.plex.tv/t/status-sync-for-old-movies-and-series/867157
A custom-agent GUID received HTTP 400 and PMS stopped that sync. Plex employee BigWheel attributed it to the custom agent. Source: ItsThatJA, 2026-03-19, https://forums.plex.tv/t/can-these-reviews-for-my-pfofile-be-deleted-by-a-plex-dev/937027
PMS decides whether to sync with GET https://plex.tv/api/v2/server/users/{accountId}/view_state_sync (200 continue, 403 stop). It obtains per-user tokens from GET https://plex.tv/api/v2/server/users/services?auth_token=<server token>. This package does not call the services route and does not send a server, shared-server, or home-switch token. Sources: https://forums.plex.tv/t/unable-to-install-new-plex-server-on-new-w-11-computer/934457 and https://forums.plex.tv/t/403-response-from-get-https-plex-tv-api-v2-server-users-xxx-view-state-sync-reused/883818
Official scope: movies and episodes only, no in-progress offsets, no music, no movie editions. Activity and profile history depend on the account setting "Sync My Watch State and Ratings" and on privacy settings. A POST does not by itself guarantee a Discover Activity card. https://support.plex.tv/articles/sync-watch-state-and-ratings/ (modified 2025-03-24)
A Plex engineer said metadata.provider.plex.tv scrobble handlers are deprecated. This package does not call them. https://github.com/Taxel/PlexTraktSync/discussions/1842
The token stored here is the authToken from POST/GET /api/v2/pins, the same credential Ether already uses for GET /api/v2/user. Captured ViewStateSync logs do not show that a PIN token, rather than a services token, is what scrobbles.plex.tv accepts. Eligibility is therefore a read of view_state_sync with that stored token, and a refusal leaves sync off. The check is a GET. A result is stored only for the token generation that was checked, so a late response cannot restore a revoked token or change the account that replaced it. This package does not PUT consent and does not call /api/v2/server/users/services or /api/home. ProbeToken is that read-only pair of requests and does not start a PIN.
Permit admission and consent changes (enable, disable, reconnect, revoke) take the same fence-row lock. Admission inserts a permit only when that locked generation matches the intent's stored consent generation and this attempt still owns the live journal row. A database error reading that generation leaves the intent retryable. A confirmed missing or stale generation does not send.
A permit already inserted is in flight. Turning sync off does not delete it, and a request already handed to Plex cannot be recalled. An intent that has not been admitted is not sent after the fence moves, including when lookup began under the previous generation. Releasing one permit does not change another. A journal row records the fence generation observed when it was written; a row that commits after a disable keeps that older generation and is not sent when sync is turned on again.
Index ¶
- Variables
- type Client
- type GUIDMap
- type Options
- type Service
- func (s *Service) BeginPIN(ctx context.Context, userID, plexID int64) (Status, error)
- func (s *Service) Close() error
- func (s *Service) DeliverOnce(ctx context.Context) (bool, error)
- func (s *Service) Disconnect(ctx context.Context, userID, plexID int64) (Status, error)
- func (s *Service) ExportCurrent(ctx context.Context, userID, plexID int64) (Status, error)
- func (s *Service) FinishPIN(ctx context.Context, userID, plexID int64) (Status, error)
- func (s *Service) ImportCurrent(ctx context.Context, userID, plexID int64) (Status, error)
- func (s *Service) ProbeToken(ctx context.Context, token string, plexID int64) (remoteAccount, error)
- func (s *Service) Retry(ctx context.Context, userID, plexID int64) (Status, error)
- func (s *Service) Run(ctx context.Context)
- func (s *Service) SetEnabled(ctx context.Context, userID, plexID int64, on bool) (Status, error)
- func (s *Service) Status(ctx context.Context, userID, plexID int64) (Status, error)
- type Status
- type StatusError
Constants ¶
This section is empty.
Variables ¶
var ErrAccountMismatch = errors.New("plexwatch: plex account does not belong to this viewer")
ErrAccountMismatch means the Plex account that approved a PIN is not the viewer's account.
var ErrNotEligible = errors.New("plexwatch: account has not allowed watch-state sync")
ErrNotEligible means Plex refused watch-state sync for this account token.
var ErrRevoked = errors.New("plexwatch: account token rejected")
ErrRevoked means the stored account token was rejected.
Functions ¶
This section is empty.
Types ¶
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client is the allowlisted Plex HTTP API used by this package.
type GUIDMap ¶
type GUIDMap interface {
Resolve(ctx context.Context, key core.RatingKey) (string, error)
ExactKey(ctx context.Context, guid string) (core.RatingKey, error)
}
GUIDMap resolves a local title to a verified plex:// GUID and the reverse for a GUID already stored for exactly one title.
type Options ¶
type Options struct {
DB *sql.DB
State *state.State
Sealer config.FieldSealer
GUID GUIDMap
NodeID string
ClientID string
BaseURL string
HTTP *http.Client
Logger *slog.Logger
Now func() time.Time
}
Options configure a Service. BaseURL is the test origin for every Plex host. Leave it empty in production.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service is one server's Plex account watch-state integration.
func (*Service) BeginPIN ¶
BeginPIN starts a sign-in that must be approved as plexID, not as whatever account the owner happens to be signed into.
func (*Service) DeliverOnce ¶
DeliverOnce sends at most one live intent. more is true when a row was claimed, including rows skipped because a newer intent replaced them.
func (*Service) Disconnect ¶
Disconnect removes the stored account token and cancels pending delivery.
func (*Service) ExportCurrent ¶
ExportCurrent enqueues the viewer's current watched titles and whole-star ratings. It does not run unless the viewer asks, and it does not upload in-progress offsets. Delivery still waits until sync is enabled.
func (*Service) ImportCurrent ¶
ImportCurrent reads the account's current watch state and applies records that are newer than the local row. Episode GUIDs apply only when that exact GUID is already stored. Show GUIDs are not expanded into episodes.
func (*Service) ProbeToken ¶
func (s *Service) ProbeToken(ctx context.Context, token string, plexID int64) (remoteAccount, error)
ProbeToken reads identity and eligibility for a token the caller already holds. It does not create a PIN, store the token, or send watch state.
func (*Service) Run ¶
Run delivers until Close or ctx ends. It does not decide cluster leadership: row claims are safe for every node.
func (*Service) SetEnabled ¶
SetEnabled turns delivery on or off. On does not upload existing history. Off cancels undelivered intents. A later enable sends only newer changes.
type Status ¶
type Status struct {
Connected bool `json:"connected"`
PlexID int64 `json:"plexId,omitempty"`
Username string `json:"username,omitempty"`
Enabled bool `json:"enabled"`
Eligibility string `json:"eligibility"`
Revoked bool `json:"revoked"`
Pending int `json:"pending"`
Dead int `json:"dead"`
InFlight bool `json:"inFlight"`
Skipped int `json:"skipped"`
Restricted bool `json:"restricted"`
PINCode string `json:"pinCode,omitempty"`
PINURL string `json:"pinUrl,omitempty"`
PINExpires string `json:"pinExpires,omitempty"`
AccountMatch bool `json:"accountMatch"`
}
Status is the viewer-facing connection, with no token and no history.
type StatusError ¶
StatusError is an HTTP result that is not success. Body is not retained.
func (*StatusError) Error ¶
func (e *StatusError) Error() string
func (*StatusError) Is ¶
func (e *StatusError) Is(target error) bool