plexwatch

package
v0.3.4 Latest Latest
Warning

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

Go to latest
Published: Nov 5, 2026 License: GPL-3.0 Imports: 29 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 (
	// 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")
)
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 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.

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

View Source
var ErrPMSToken = errors.New("plexwatch: the Plex Media Server refused this token")

ErrPMSToken means the Plex Media Server refused the token.

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

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

type PMSMappingInput struct {
	UserID    int64
	Direction int
	Interval  int
	Token     *string
}

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

func (s *Servers) Accounts(ctx context.Context, id int64) ([]PMSAccount, error)

Accounts lists the server's accounts live, with their current viewer.

func (*Servers) Add added in v0.3.4

func (s *Servers) Add(ctx context.Context, name, baseURL, token string) (PMSServer, error)

Add validates and stores a server. The token is sealed and never returned.

func (*Servers) Close added in v0.3.4

func (s *Servers) Close() error

Close stops Run.

func (*Servers) List added in v0.3.4

func (s *Servers) List(ctx context.Context) ([]PMSServer, error)

List returns every server with its mappings.

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

func (s *Servers) Remove(ctx context.Context, id int64) error

Remove forgets a server, its mappings, cursors, matches and echo records.

func (*Servers) Run added in v0.3.4

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

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) RunOnce added in v0.3.4

func (s *Servers) RunOnce(ctx context.Context)

RunOnce syncs up to pmsDuePerTick due mappings.

func (*Servers) Server added in v0.3.4

func (s *Servers) Server(ctx context.Context, id int64) (PMSServer, error)

Server returns one server and its mappings.

func (*Servers) SyncNow added in v0.3.4

func (s *Servers) SyncNow(ctx context.Context, id int64) (PMSServer, error)

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