Help us shape the future of Go! Take the 2026 Go Developer Survey

plexwatch

package
v0.3.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 1, 2026 License: GPL-3.0 Imports: 23 Imported by: 0

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:

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.

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

This section is empty.

Variables

View Source
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.

View Source
var ErrNotEligible = errors.New("plexwatch: account has not allowed watch-state sync")

ErrNotEligible means Plex refused watch-state sync for this account token.

View Source
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 New

func New(opts Options) (*Service, error)

New starts nothing. Call Run to deliver, or DeliverOnce from tests.

func (*Service) BeginPIN

func (s *Service) BeginPIN(ctx context.Context, userID, plexID int64) (Status, error)

BeginPIN starts a sign-in that must be approved as plexID, not as whatever account the owner happens to be signed into.

func (*Service) Close

func (s *Service) Close() error

Close stops Run.

func (*Service) DeliverOnce

func (s *Service) DeliverOnce(ctx context.Context) (bool, error)

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

func (s *Service) Disconnect(ctx context.Context, userID, plexID int64) (Status, error)

Disconnect removes the stored account token and cancels pending delivery.

func (*Service) ExportCurrent

func (s *Service) ExportCurrent(ctx context.Context, userID, plexID int64) (Status, error)

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) FinishPIN

func (s *Service) FinishPIN(ctx context.Context, userID, plexID int64) (Status, error)

FinishPIN stores the account only when its Plex id equals plexID.

func (*Service) ImportCurrent

func (s *Service) ImportCurrent(ctx context.Context, userID, plexID int64) (Status, error)

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) ReconcileOnce added in v0.3.0

func (s *Service) ReconcileOnce(ctx context.Context) (bool, error)

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

func (s *Service) Resolve(ctx context.Context, userID, plexID int64) (Status, error)

Resolve reads Plex's current watch state and reconciles held sends for this viewer without blindly re-POSTing.

func (*Service) Retry

func (s *Service) Retry(ctx context.Context, userID, plexID int64) (Status, error)

Retry rechecks eligibility, revives retryable rows, and reconciles holds.

func (*Service) Run

func (s *Service) Run(ctx context.Context)

Run delivers until Close or ctx ends. It does not decide cluster leadership: row claims are safe for every node.

func (*Service) SetEnabled

func (s *Service) SetEnabled(ctx context.Context, userID, plexID int64, on bool) (Status, error)

SetEnabled turns delivery on or off. On does not upload existing history. Off cancels undelivered intents. A later enable sends only newer changes.

func (*Service) Status

func (s *Service) Status(ctx context.Context, userID, plexID int64) (Status, error)

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"`
	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

type StatusError struct {
	Status     int
	RetryAfter time.Duration
	Op         string
}

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

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL