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 PMSAccount
- type PMSCatalog
- type PMSMapping
- type PMSMappingInput
- type PMSServer
- type PMSUnmatched
- type Servers
- func (s *Servers) Accounts(ctx context.Context, id int64) ([]PMSAccount, error)
- func (s *Servers) Add(ctx context.Context, name, baseURL, token string) (PMSServer, error)
- func (s *Servers) Close() error
- func (s *Servers) List(ctx context.Context) ([]PMSServer, error)
- func (s *Servers) MapAccount(ctx context.Context, id, accountID int64, in PMSMappingInput) (PMSServer, error)
- func (s *Servers) Remove(ctx context.Context, id int64) error
- func (s *Servers) Run(ctx context.Context)
- func (s *Servers) RunOnce(ctx context.Context)
- func (s *Servers) Server(ctx context.Context, id int64) (PMSServer, error)
- func (s *Servers) SyncNow(ctx context.Context, id int64) (PMSServer, error)
- func (s *Servers) Unmatched(ctx context.Context, id, userID int64, after string) ([]PMSUnmatched, error)
- func (s *Servers) Update(ctx context.Context, id int64, name, baseURL, token string) (PMSServer, error)
- type ServersOptions
- 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 ( // ErrPMSDuplicate means this server is already configured. ErrPMSDuplicate = errors.New("plexwatch: this Plex Media Server is already added") // ErrPMSDifferentServer means an edited address answers as another server. ErrPMSDifferentServer = errors.New("plexwatch: that address is a different Plex Media Server") // ErrPMSLimit means the server or account limit is reached. ErrPMSLimit = errors.New("plexwatch: limit reached") // ErrPMSNeedsToken means sending to Plex for this account needs its own token. ErrPMSNeedsToken = errors.New("plexwatch: sending to Plex for this person needs their token for this server") // ErrPMSInvalid means the request is malformed. ErrPMSInvalid = errors.New("plexwatch: invalid server settings") )
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 ErrPMSAddress = errors.New("plexwatch: enter the server address, like http://192.168.1.10:32400")
ErrPMSAddress means the server address is not an http(s) origin.
var ErrPMSNotOwner = errors.New("plexwatch: this token is not the server owner's")
ErrPMSNotOwner means the token is valid but is not the server owner's.
var ErrPMSToken = errors.New("plexwatch: the Plex Media Server refused this token")
ErrPMSToken means the Plex Media Server refused the token.
var ErrPMSUnreachable = errors.New("plexwatch: the Plex Media Server could not be reached")
ErrPMSUnreachable means the server could not be reached or did not answer as a PMS.
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 PMSAccount ¶ added in v0.3.4
type PMSAccount struct {
ID int64 `json:"id"`
Name string `json:"name"`
Owner bool `json:"owner"`
UserID int64 `json:"userId"`
}
PMSAccount is a PMS account and, when mapped, its viewer.
type PMSCatalog ¶ added in v0.3.4
type PMSCatalog interface {
Get(ctx context.Context, key core.RatingKey) (*core.Title, error)
ByExternalIDForType(ctx context.Context, source, id string, typ core.MediaType) (*core.Title, error)
}
PMSCatalog verifies matched titles and resolves IMDb/TVDB identifiers.
type PMSMapping ¶ added in v0.3.4
type PMSMapping struct {
AccountID int64 `json:"accountId"`
AccountName string `json:"accountName"`
Owner bool `json:"owner"`
UserID int64 `json:"userId"`
Direction int `json:"direction"`
Interval int `json:"interval"`
CanSend bool `json:"canSend"`
LastImport int64 `json:"lastImport"`
LastSend int64 `json:"lastSend"`
NextSync int64 `json:"nextSync"`
Problem string `json:"problem"`
Unmatched int `json:"unmatched"`
}
PMSMapping is one PMS account mapped to one Ether viewer.
type PMSMappingInput ¶ added in v0.3.4
PMSMappingInput changes one account's mapping. UserID 0 removes it. Token nil keeps the stored per-account token; "" clears it.
type PMSServer ¶ added in v0.3.4
type PMSServer struct {
ID int64 `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
MachineID string `json:"machineId"`
Version string `json:"version"`
Owner string `json:"owner"`
CheckedAt int64 `json:"checkedAt"`
Problem string `json:"problem"`
Viewers []PMSMapping `json:"viewers"`
}
PMSServer is one configured server as the owner console shows it. It never carries a token.
type PMSUnmatched ¶ added in v0.3.4
type PMSUnmatched struct {
Direction string `json:"direction"`
Ref string `json:"ref"`
Title string `json:"title"`
Reason string `json:"reason"`
SeenAt int64 `json:"seenAt"`
}
PMSUnmatched is a title one direction could not match by identifier.
type Servers ¶ added in v0.3.4
type Servers struct {
// contains filtered or unexported fields
}
Servers syncs watched state with Plex Media Servers the owner runs, over each server's own HTTP API and with the owner's token for it.
The owner adds servers and maps each PMS account to one Ether viewer. Nothing is guessed from names. Reads are incremental: PMS watch history after a stored cursor, and Ether's watch rows after a stored (updated_at, rating_key) cursor on the state_watch_updated index. No request path lists a whole PMS library or Ether's catalogue.
Echo safety rests on plexwatch_pms_sent, one row per server, viewer and title: the watched state the server is known to hold, the local state it was last reconciled with, and when Ether itself last wrote it. A write is made only when the local watch state changed since that reconciliation and the server does not already hold it, so a change imported from server A is never sent back to A, while server B (which has no such row) gets it once. History rows that are the echo of Ether's own scrobble are ignored.
This path does not use, and is not gated by, AccountSyncSupported.
func NewServers ¶ added in v0.3.4
func NewServers(o ServersOptions) (*Servers, error)
NewServers starts nothing; call Run under cluster leadership.
func (*Servers) Accounts ¶ added in v0.3.4
Accounts lists the server's accounts live, with their current viewer.
func (*Servers) Add ¶ added in v0.3.4
Add validates and stores a server. The token is sealed and never returned.
func (*Servers) MapAccount ¶ added in v0.3.4
func (s *Servers) MapAccount(ctx context.Context, id, accountID int64, in PMSMappingInput) (PMSServer, error)
MapAccount maps a PMS account to a viewer and sets direction and interval. Turning a direction on starts it from now: existing history is not copied either way. Sending for an account other than the owner's needs that account's own token for this server.
func (*Servers) Remove ¶ added in v0.3.4
Remove forgets a server, its mappings, cursors, matches and echo records.
func (*Servers) Run ¶ added in v0.3.4
Run syncs due mappings until ctx ends or Close. Start it under cluster leadership; the per-mapping lease also serializes overlapping leaders and the owner's Sync now.
func (*Servers) SyncNow ¶ added in v0.3.4
SyncNow runs every mapping of one server now. A server that asked Ether to wait (HTTP 429) is still left alone until it said.
func (*Servers) Unmatched ¶ added in v0.3.4
func (s *Servers) Unmatched(ctx context.Context, id, userID int64, after string) ([]PMSUnmatched, error)
Unmatched returns one bounded page of a viewer's unmatched titles.
func (*Servers) Update ¶ added in v0.3.4
func (s *Servers) Update(ctx context.Context, id int64, name, baseURL, token string) (PMSServer, error)
Update renames a server, moves it to a new address or replaces its token. An empty token keeps the stored one. The address must still answer as the same server, so cursors and matches stay valid.
type ServersOptions ¶ added in v0.3.4
type ServersOptions struct {
DB *sql.DB
State *state.State
Sealer config.FieldSealer
GUID GUIDMap
Catalog PMSCatalog
ClientID string
HTTP *http.Client
Logger *slog.Logger
Now func() time.Time
// ViewerExists reports whether an Ether viewer id is real. Nil skips the check.
ViewerExists func(context.Context, int64) (bool, error)
}
ServersOptions configure Servers. HTTP defaults to the direct egress route.
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