notify

package
v1.5.8 Latest Latest
Warning

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

Go to latest
Published: Jul 14, 2026 License: GPL-2.0, GPL-3.0 Imports: 13 Imported by: 0

Documentation

Overview

Package notify implements the Plex WebSocket notification listener.

The Listener dials Plex's /:/websockets/notifications endpoint, decodes the NotificationContainer JSON envelope into typed events, and delivers them to a caller-supplied Handler. Reconnect/backoff is test-injectable via Config (no global vars). Disconnect reasons are classified with typed sentinel errors so Loki alert rules can segment by cause without substring matching on error text.

Stable contracts preserved by this package:

  • Plex WebSocket JSON wire format (struct tags on Notification / PlayEvent / TimelineEntry).
  • WARN/ERROR slog keys and the ReasonXxx string values, which Loki alert rules match on.
  • /:/websockets/notifications URL path, 1 MB read limit, the X-Plex-Token header. The read-idle backstop is Config-driven (ReadIdleTimeout, default 1 hour), not a fixed 5-minute deadline.

Index

Constants

View Source
const (
	ReasonUnknown     = "unknown"
	ReasonServerClose = "server_close"
	ReasonReadLimit   = "read_limit"
	ReasonDialFailed  = "dial_failed"
	ReasonReadError   = "read_error"
)

Reason codes surfaced to logs (and thus to Loki alert rules). These string values are byte-for-byte frozen because Loki alert rules match on them.

Variables

View Source
var (
	// ErrDialFailed marks a failure to establish the WebSocket
	// connection (DNS, TCP, handshake, TLS).
	ErrDialFailed = errors.New("websocket dial")

	// ErrReadLimit marks a read that exceeded the 1 MB read limit.
	ErrReadLimit = errors.New("read limited")

	// ErrReadError marks a generic read failure (EOF on a truncated
	// frame, i/o timeout, transport error).
	ErrReadError = errors.New("websocket read")

	// ErrServerClose marks a clean or expected close from the server
	// side (close frames 1000/1001/1006, plain EOF).
	ErrServerClose = errors.New("server close")
)

Sentinel errors used by the Listener to label disconnect causes. The Listener wraps the underlying error with %w so ClassifyError can use errors.Is / errors.As for typed matching instead of substring search on err.Error(). Code outside this package rarely needs these directly; ClassifyError is the consumer API.

Functions

func BuildStreamCacheKey

func BuildStreamCacheKey(userID, ratingKey string, audioID, subID int) string

BuildStreamCacheKey builds a deduplication key from user, episode, and current stream IDs so we only process when the selection actually changes. The "streams:" prefix and colon-separated layout are part of the on-disk cache.json schema.

func BuildTimelineCacheKey added in v1.4.0

func BuildTimelineCacheKey(itemID string) string

BuildTimelineCacheKey builds the per-episode timeline (library-scan) dedup key. The "timeline:" prefix is part of the on-disk cache.json schema.

func ClassifyError

func ClassifyError(err error) string

ClassifyError maps a WebSocket disconnect error to a stable reason code so alerts can segment by cause without matching on error text.

Classification order (first match wins):

  1. nil → ReasonUnknown
  2. context.DeadlineExceeded → ReasonReadError (read-idle backstop fired)
  3. typed sentinel wraps via errors.Is (ErrReadLimit, ErrServerClose, ErrDialFailed, ErrReadError)
  4. *websocket.CloseError via errors.As for codes 1000 / 1001 / 1006 → ReasonServerClose
  5. default → ReasonUnknown

func IsRelevantPlayEvent

func IsRelevantPlayEvent(ev PlayEvent) bool

IsRelevantPlayEvent returns true if a play event should be processed (state is playing/paused and has a rating key).

func IsRelevantTimelineEntry

func IsRelevantTimelineEntry(entry *TimelineEntry) bool

IsRelevantTimelineEntry returns true if a timeline entry should be processed (episode type, metadata/media created or updated, non-empty item ID).

func TimelineAction

func TimelineAction(entry *TimelineEntry) string

TimelineAction returns "scan_new" if the entry represents a newly created item, or "scan_updated" otherwise. The returned strings are byte-for-byte frozen — they are emitted as log/metric values consumed by dashboards.

Types

type Config

type Config struct {
	// MinBackoff is the initial delay between reconnect attempts and the
	// floor nextBackoff clamps to. Default: 1s.
	MinBackoff time.Duration

	// MaxBackoff is the ceiling for exponential growth of the reconnect
	// delay. Default: 30s.
	MaxBackoff time.Duration

	// StableThreshold is how long a connection must stay open before the
	// backoff is reset on the next reconnect (so a long-lived session
	// pays back accumulated backoff). Default: 1 minute.
	StableThreshold time.Duration

	// ReadIdleTimeout is the application-level backstop for a stuck
	// websocket read. The primary dead-connection detection is TCP
	// keepalive (configured on the dialer at 30s probe interval); the
	// read timeout exists only as a safety net for pathological cases
	// where the OS reports the connection alive but the server has
	// silently stopped sending. Default: 1 hour. Plex doesn't send
	// heartbeats and can legitimately be quiet for tens of minutes
	// during off-peak windows; a short timeout here only churns the
	// connection without improving correctness.
	ReadIdleTimeout time.Duration
}

Config holds the tunables for the reconnect loop. Production code uses DefaultConfig(); tests construct a Config with shrunk durations so the reconnect-loop assertions run fast without mutating package globals.

func DefaultConfig

func DefaultConfig() Config

DefaultConfig returns the production values. Preserves behaviour of the original wsMinBackoff / wsMaxBackoff / wsStableThreshold package vars from main.go.

type Handler

type Handler interface {
	OnPlay(ctx context.Context, ev PlayEvent)
	OnTimeline(ctx context.Context, entries []TimelineEntry)
}

Handler receives decoded events from the Listener. Implementations live in the composition root (main package) and typically fan out per-event work to the sync subsystem.

OnPlay is called for each PlaySessionStateNotification entry in a "playing" Notification, unfiltered; the caller applies its own relevance policy (see IsRelevantPlayEvent). OnTimeline is called once per Notification with the full TimelineEntry slice so the caller can apply its own per-entry relevance, dedup, or batching policy.

The Handler is invoked synchronously from the read loop; a slow handler delays the next Read and can trigger the keepalive timeout. Handlers that need to perform long work should hand it off to a goroutine or worker pool.

type Listener

type Listener struct {
	// contains filtered or unexported fields
}

Listener dials Plex's /:/websockets/notifications endpoint, decodes the JSON envelope, and delivers events to a Handler with an outer reconnect loop and stable-connection backoff reset.

func NewListener

func NewListener(client PlexClient, cfg Config) *Listener

NewListener builds a Listener from a Plex client and a Config. Production callers pass DefaultConfig(); tests pass a shrunk Config so the reconnect-loop assertions run quickly.

func (*Listener) Listen

func (l *Listener) Listen(ctx context.Context, h Handler)

Listen runs the reconnect loop: dial the WebSocket, decode and dispatch messages until a read error or the context is cancelled, then wait for a bounded backoff and reconnect. Returns when the supplied context is cancelled.

type Notification

type Notification struct {
	NotificationContainer struct {
		Type                         string          `json:"type"`
		PlaySessionStateNotification []PlayEvent     `json:"PlaySessionStateNotification"`
		TimelineEntry                []TimelineEntry `json:"TimelineEntry"`
	} `json:"NotificationContainer"`
}

Notification is the top-level envelope Plex sends over the WebSocket. Field names and JSON tags mirror the Plex NotificationContainer wire format byte-for-byte.

type PlayEvent

type PlayEvent struct {
	SessionKey       string `json:"sessionKey"`
	ClientIdentifier string `json:"clientIdentifier"`
	RatingKey        string `json:"ratingKey"`
	State            string `json:"state"`
	ViewOffset       int64  `json:"viewOffset"`
}

PlayEvent represents a single play-session state notification from Plex.

type PlexClient

type PlexClient interface {
	BaseURL() *url.URL
	Token() string
	HTTPClient() *http.Client
}

PlexClient is the subset of an HTTP-based Plex client the Listener needs. Satisfied by *plex.Client; declared here to keep notify decoupled from the plex package (no import cycle, easy to fake in tests).

type TimelineEntry

type TimelineEntry struct {
	ItemID        string `json:"itemID"`
	Identifier    string `json:"identifier"`
	SectionID     string `json:"sectionID"`
	MetadataState string `json:"metadataState"`
	MediaState    string `json:"mediaState"`
	Type          int    `json:"type"`
	State         int    `json:"state"`
	UpdatedAt     int64  `json:"updatedAt"`
}

TimelineEntry represents a library scan timeline event from Plex.

Jump to

Keyboard shortcuts

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