plexwatch

package
v0.3.3 Latest Latest
Warning

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

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

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:

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

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

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 ErrCredentialForbidden = errors.New("plexwatch: credential is not allowed to access watch state")

ErrCredentialForbidden means the credential lacks access to watch state.

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

ErrNotEligible means the viewer has not consented to watch-state sync.

View Source
var ErrRevoked = errors.New("plexwatch: account token rejected")

ErrRevoked means the stored account token was rejected.

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

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) RunImports added in v0.3.3

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

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.

func (*Service) Status

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

func (*Service) Supported added in v0.3.3

func (s *Service) Supported() bool

Supported reports whether watch state can reach a Plex account at all.

func (*Service) Unmatched added in v0.3.3

func (s *Service) Unmatched(ctx context.Context, userID, plexID int64, after string) ([]string, error)

Unmatched returns one bounded page of unmatched Plex GUIDs.

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

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

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