Documentation
¶
Overview ¶
Package plexwatch delivers one viewer's watched state, and whole-star ratings, to that viewer's own Plex account.
In this release the integration is dormant: AccountSyncSupported is false because scrobbles.plex.tv accepts no credential a non-Plex server can hold. Nothing below runs against Plex until that constant is turned on.
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 except the November 2026 interoperability probe:
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>. The services route is authenticated with Ether's claimed server token. Service credentials are selected by the connected viewer's account ID. 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 and the account consent endpoint. It is not sent to scrobbles.plex.tv. The claimed Ether server token obtains the matching account's scrobbling service envelope; the decrypted JWT is sent as Authorization: Bearer. A read-only probe using Ether's own claim returned HTTP 200 with items/pageInfo on 2026-11-04. PMS 1.43.4/1.43.5 loader inspection established the base64/AES-CBC envelope and length-byte padding. Service tokens remain in memory for at most five minutes, bounded by expiry, and are discarded when the claim changes or the service refuses a token. Consent checks never modify Plex settings. Their result is stored only for the generation checked, so late responses cannot restore a revoked account.
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.
Admission also commits an immutable plexwatch_attempt containing the exact account id, consent generation, resolved GUID, aspect, original timestamp and wire payload. It contains no plaintext credential. Failure or uncertainty committing admission means no network send. Once admission is durable, even a crash before transmission leaves an unknown-effect hold. Expiring worker claims or permits never expires that hold. The remote account/GUID/aspect conflict index and a local user/key/aspect check prevent aliases and remaps from bypassing it. Unrelated known targets and other aspects remain usable.
A successful response commits the attempt's receipt, import echo and original journal acknowledgement together. A failed or uncertain local commit cannot reopen the network path. Transport/read errors, server failures, ambiguous statuses and Retry-After do not prove no effect; delivery stops until the hold is reconciled against GET /state/{accountId}. Definitive refusals (HTTP 400, 401, 403, 422) clear the attempt fence so later changes to that title are not blocked.
Reconciliation reads GET /state/{accountId} on a backoff schedule, on reconnect/consent/remap, and on explicit viewer Resolve. If Plex already reflects the admitted payload, the attempt is receipted without re-POSTing. If Plex does not reflect it and no permit for that attempt is still in flight, the hold is released and one fresh admission of the current intent is allowed.
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 ¶
- Constants
- 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) ReconcileOnce(ctx context.Context) (bool, error)
- func (s *Service) Resolve(ctx context.Context, userID, plexID int64) (Status, error)
- func (s *Service) Retry(ctx context.Context, userID, plexID int64) (Status, error)
- func (s *Service) Run(ctx context.Context)
- func (s *Service) RunImports(ctx context.Context)
- func (s *Service) SetDirection(ctx context.Context, userID, plexID int64, direction, interval int) (Status, error)
- func (s *Service) Status(ctx context.Context, userID, plexID int64) (Status, error)
- func (s *Service) Supported() bool
- func (s *Service) Unmatched(ctx context.Context, userID, plexID int64, after string) ([]string, error)
- type Status
- type StatusError
Constants ¶
const AccountSyncSupported = false
AccountSyncSupported gates every watch-state request to scrobbles.plex.tv: sends, scheduled and manual imports, exports and hold reconciliation.
Plex has no working path for a server other than its own. PMS reads GET plex.tv/api/v2/server/users/services with its server token and receives, per user, a scrobbling entry carrying an opaque 620-character token (464 bytes of binary once decoded). Presented to scrobbles.plex.tv/state/{id} as Authorization: Bearer or as X-Plex-Token it answers 401 Invalid Token, including for users whose consent is on; PMS derives the real credential from it, and Ether does not reverse engineer that. A viewer PIN token is refused too.
While false, nothing is sent or imported, queued intents and held attempts stay parked untouched, and Status reports eligibility and problem "unsupported". Direction, interval, fence and journal plumbing stay intact, so turning a future working credential path on is this constant.
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 ErrCredentialForbidden = errors.New("plexwatch: credential is not allowed to access watch state")
ErrCredentialForbidden means the credential lacks access to watch state.
var ErrNotEligible = errors.New("plexwatch: account has not allowed watch-state sync")
ErrNotEligible means the viewer has not consented to watch-state sync.
var ErrRevoked = errors.New("plexwatch: account token rejected")
ErrRevoked means the stored account token was rejected.
var ErrUnsupported = errors.New("plexwatch: Plex accepts watch-state sync only from its own servers")
ErrUnsupported means Plex does not accept watch-state sync from Ether.
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 {
// ServerIdentity returns the current claimed server token and client identifier.
ServerIdentity func() (string, string)
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 uses the same cursor and persisted status for manual and scheduled imports.
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) ReconcileOnce ¶ added in v0.3.0
ReconcileOnce runs one pass of scheduled hold reconciliation across viewers whose unreceipted attempts are due and have no live in-flight permit.
func (*Service) Resolve ¶ added in v0.3.0
Resolve reads Plex's current watch state and reconciles held sends for this viewer without blindly re-POSTing.
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) RunImports ¶ added in v0.3.3
RunImports is started under cluster leadership. Each pass is bounded and the persisted lease also serializes manual imports and overlapping leaders.
func (*Service) SetDirection ¶ added in v0.3.3
func (s *Service) SetDirection(ctx context.Context, userID, plexID int64, direction, interval int) (Status, error)
SetDirection selects outbound (bit 0) and inbound (bit 1) sync. Enabling outbound does not upload existing history.
type Status ¶
type Status struct {
Connected bool `json:"connected"`
PlexID int64 `json:"plexId,omitempty"`
Username string `json:"username,omitempty"`
Direction int `json:"direction"`
ImportInterval int `json:"importInterval"`
LastImport int64 `json:"lastImport"`
LastSend int64 `json:"lastSend"`
NextImport int64 `json:"nextImport"`
Problem string `json:"problem"`
ProblemCode int `json:"problemCode"`
Eligibility string `json:"eligibility"`
Revoked bool `json:"revoked"`
Pending int `json:"pending"`
Held int `json:"held"`
HeldLegacy int `json:"heldLegacy"`
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