Documentation
¶
Overview ¶
Package state implements per-user watch state and playlists over SQLite: core.State and core.Playlists.
Semantics follow Plex:
- Progress at >= 90% of duration marks the item watched (view count increments once per viewing, the resume offset clears).
- Progress below 5% is treated as "not really started": no resume point is kept. Between 5% and 90% the item is in progress.
- Scrobble marks watched unconditionally; Unscrobble clears the view count and resume point (the user's rating is kept).
Index ¶
- Constants
- Variables
- func Fraction(ws core.WatchStatus) float64
- func ImportPlaylistTx(ctx context.Context, tx *sql.Tx, id, userID int64, title string, ...) (int64, error)
- func IsInProgress(ws core.WatchStatus) bool
- func IsWatched(ws core.WatchStatus) bool
- type Event
- type EventKind
- type ExportEntry
- type ImportBatch
- type ImportRecord
- type Options
- type Rollup
- type State
- func (s *State) AckSync(ctx context.Context, seq int64, holder string, at time.Time) (bool, error)
- func (s *State) Add(ctx context.Context, userID int64, key core.RatingKey, items []core.RatingKey) error
- func (s *State) ApplyRemote(ctx context.Context, userID int64, key core.RatingKey, watched *bool, ...) error
- func (s *State) ChildrenStatus(ctx context.Context, userID int64, children []core.RatingKey) (Rollup, map[core.RatingKey]core.WatchStatus, error)
- func (s *State) ClaimSync(ctx context.Context, holder string, now time.Time, stale time.Duration) (SyncRow, bool, error)
- func (s *State) Close(ctx context.Context) error
- func (s *State) CompactSync(ctx context.Context, now time.Time) error
- func (s *State) Create(ctx context.Context, userID int64, title string, items []core.RatingKey) (*core.Playlist, error)
- func (s *State) DeadSync(ctx context.Context, seq int64, holder string, reason string) error
- func (s *State) DeferSync(ctx context.Context, seq int64, holder string, until time.Time, reason string) error
- func (s *State) Delete(ctx context.Context, userID int64, key core.RatingKey) error
- func (s *State) DropPendingSync(ctx context.Context, userID int64, reason string) error
- func (s *State) DropPendingSyncTx(ctx context.Context, tx *sql.Tx, userID int64, reason string) error
- func (s *State) ExportEntries(ctx context.Context, userID int64, ratings bool) ([]ExportEntry, error)
- func (s *State) Get(ctx context.Context, userID int64, key core.RatingKey) (core.WatchStatus, error)
- func (s *State) GetMany(ctx context.Context, userID int64, keys []core.RatingKey) (map[core.RatingKey]core.WatchStatus, error)
- func (s *State) GetPlaylist(ctx context.Context, userID int64, key core.RatingKey) (*core.Playlist, error)
- func (s *State) History(ctx context.Context, userID int64, since time.Time, limit int) ([]core.HistoryEntry, error)
- func (s *State) ImportTransaction(ctx context.Context, prepare func(*sql.Tx) (ImportBatch, error)) error
- func (s *State) InProgress(ctx context.Context, userID int64, limit int) ([]core.WatchStatus, error)
- func (s *State) InProgressAny(ctx context.Context) ([]core.RatingKey, error)
- func (s *State) List(ctx context.Context, userID int64) ([]core.Playlist, error)
- func (s *State) LockSync(ctx context.Context, userID int64, key core.RatingKey, aspect SyncAspect, ...) (bool, error)
- func (s *State) Move(ctx context.Context, userID int64, key core.RatingKey, from, to int) error
- func (s *State) NoteSyncIntents(ctx context.Context, rows []SyncRow) error
- func (s *State) OutboxStatus() (st store.OutboxStatus, ok bool)
- func (s *State) PauseSync(ctx context.Context, seq int64, holder string, until time.Time, reason string) error
- func (s *State) PendingSyncCount(ctx context.Context, userID int64) (pending, dead int, err error)
- func (s *State) Playlists() core.Playlists
- func (s *State) Progress(ctx context.Context, userID int64, key core.RatingKey, ...) error
- func (s *State) Rate(ctx context.Context, userID int64, key core.RatingKey, rating float64) error
- func (s *State) ReleaseSync(ctx context.Context, seq int64, holder string) error
- func (s *State) Remove(ctx context.Context, userID int64, key core.RatingKey, itemIndex int) error
- func (s *State) RetryDeadSync(ctx context.Context, userID int64, reasons []string) error
- func (s *State) Scrobble(ctx context.Context, userID int64, key core.RatingKey) error
- func (s *State) ScrobbleMany(ctx context.Context, userID int64, keys []core.RatingKey) error
- func (s *State) SetJournalFault(fn func() error)
- func (s *State) SetJournalGate(...)
- func (s *State) Subscribe(fn func(Event)) func()
- func (s *State) SyncCurrent(ctx context.Context, seq int64, holder string) (bool, error)
- func (s *State) UnlockSync(ctx context.Context, userID int64, key core.RatingKey, aspect SyncAspect, ...) error
- func (s *State) Unscrobble(ctx context.Context, userID int64, key core.RatingKey) error
- func (s *State) UnscrobbleMany(ctx context.Context, userID int64, keys []core.RatingKey) error
- func (s *State) WatchedEpisodes(ctx context.Context, userID int64) ([]core.RatingKey, error)
- type SyncAspect
- type SyncRow
Constants ¶
const ( SyncComplete = "complete" SyncUnplayed = "unplayed" SyncRatingKind = "rating" )
SyncKind is what a journal row asks a subscriber to deliver. Watch kinds are the Plex viewState values "complete" and "unplayed". "rating" carries the raw local rating; the subscriber decides which values it can send.
const ( WatchedThreshold = 0.90 StartedThreshold = 0.05 )
WatchedThreshold and StartedThreshold are the fractions of duration at which an item counts as watched / meaningfully started.
Variables ¶
var ErrLocalNewer = errors.New("state: local watch state is newer than the remote record")
ErrLocalNewer means a remote import is older than the stored row.
Functions ¶
func Fraction ¶
func Fraction(ws core.WatchStatus) float64
Fraction returns offset/duration, or 0 when the duration is unknown.
func ImportPlaylistTx ¶
func ImportPlaylistTx(ctx context.Context, tx *sql.Tx, id, userID int64, title string, keys []core.RatingKey, now int64) (int64, error)
ImportPlaylistTx preserves imported playlist IDs while enforcing ownership.
func IsInProgress ¶
func IsInProgress(ws core.WatchStatus) bool
IsInProgress reports whether a status has a resume point.
func IsWatched ¶
func IsWatched(ws core.WatchStatus) bool
IsWatched reports whether a status counts as watched (watched at least once).
Types ¶
type Event ¶
type Event struct {
Kind EventKind
UserID int64
Key core.RatingKey
Offset time.Duration
Duration time.Duration
Fraction float64
Rating float64 // EventRated only
State string // raw timeline state for EventProgress: playing, paused, stopped
Completed bool
At time.Time
Imported bool // reviewed history import; never an active playback timeline
// Account is a manual Plex-account import. Local clients refresh from it.
// It is not journaled and is not a Trakt or Simkl export.
Account bool
}
Event is a watch-state change. Fraction is offset/duration (0 when the duration is unknown). Completed is true for EventCompleted/EventScrobble.
type EventKind ¶
type EventKind string
EventKind identifies a state change subscribers may react to.
const ( EventProgress EventKind = "progress" // a timeline update (incl. start/stop) EventCompleted EventKind = "completed" // crossed the watched threshold EventScrobble EventKind = "scrobble" // explicitly marked watched EventUnscrobble EventKind = "unscrobble" // explicitly marked unwatched EventRated EventKind = "rated" )
type ExportEntry ¶
ExportEntry is the state-owned, source-neutral view used by CSV exports.
type ImportBatch ¶
type ImportBatch struct{ Records, History []ImportRecord }
type ImportRecord ¶
type ImportRecord struct {
UserID int64
Key core.RatingKey
Views int
Offset, Duration time.Duration
Rating float64
ViewedAt time.Time
RatingsOnly bool
}
ImportRecord uses native time units; source-specific parsing belongs to the importer. RatingsOnly preserves existing progress and completion counts.
type Options ¶
type Options struct {
Logger *slog.Logger
Now func() time.Time
// Availability, when set (a clustered node on a shared PostgreSQL
// database), makes timeline progress survive a database outage: a
// Progress the database cannot take is held in memory (the latest per
// user and title) and written when the database answers again, so
// players are never blocked or failed by the outage. Nil (standalone)
// writes progress synchronously as before.
Availability *store.Availability
}
Options tune a State. Now is injectable for tests.
type Rollup ¶
type Rollup struct {
Total int
Watched int // viewCount > 0
InProgress int // has a resume point
AllWatched bool // Total > 0 && Watched == Total
AnyWatched bool
}
Rollup summarises the watched state of a set of children (the episodes of a season/show) for Plex's viewedLeafCount-style fields.
type State ¶
type State struct {
// contains filtered or unexported fields
}
State implements core.State and core.Playlists.
func (*State) AckSync ¶ added in v0.2.57
AckSync marks a send done only while the row is still the live intent. ok is false when a newer intent superseded it during the send.
func (*State) Add ¶
func (s *State) Add(ctx context.Context, userID int64, key core.RatingKey, items []core.RatingKey) error
Add appends items to a playlist.
func (*State) ApplyRemote ¶ added in v0.2.57
func (s *State) ApplyRemote(ctx context.Context, userID int64, key core.RatingKey, watched *bool, watchAt time.Time, rating *float64, ratingAt time.Time) error
ApplyRemote writes a remote watched flag and/or rating without journaling an upload. Each aspect is compared with its own clock: a newer local rating does not block an older watch mark, and a newer watch mark does not block an older rating. ErrLocalNewer means every requested aspect was older than the local clock.
func (*State) ChildrenStatus ¶
func (s *State) ChildrenStatus(ctx context.Context, userID int64, children []core.RatingKey) (Rollup, map[core.RatingKey]core.WatchStatus, error)
ChildrenStatus fetches statuses for children and rolls them up.
func (*State) ClaimSync ¶ added in v0.2.57
func (s *State) ClaimSync(ctx context.Context, holder string, now time.Time, stale time.Duration) (SyncRow, bool, error)
ClaimSync takes the oldest live intent for holder. A row already claimed by a live holder is left alone until stale.
func (*State) Close ¶
Close writes progress still held from a database outage (bounded by ctx). It is a no-op on a standalone server.
func (*State) CompactSync ¶ added in v0.2.57
CompactSync deletes terminal journal rows and idle locks. A live intent, the newest undelivered row for a viewer and title, is kept.
func (*State) Create ¶
func (s *State) Create(ctx context.Context, userID int64, title string, items []core.RatingKey) (*core.Playlist, error)
Create makes a playlist with the given items in order.
func (*State) DeadSync ¶ added in v0.2.57
DeadSync stops delivery of one row without changing local watch state.
func (*State) DeferSync ¶ added in v0.2.57
func (s *State) DeferSync(ctx context.Context, seq int64, holder string, until time.Time, reason string) error
DeferSync puts a claimed row back, later, with an error note.
func (*State) DropPendingSync ¶ added in v0.2.57
DropPendingSync cancels every undelivered intent for a viewer.
func (*State) DropPendingSyncTx ¶ added in v0.2.57
func (s *State) DropPendingSyncTx(ctx context.Context, tx *sql.Tx, userID int64, reason string) error
DropPendingSyncTx is DropPendingSync on an open transaction, so a consent change and the cancellation commit together.
func (*State) ExportEntries ¶
func (*State) Get ¶
func (s *State) Get(ctx context.Context, userID int64, key core.RatingKey) (core.WatchStatus, error)
Get returns the status for one key. A never-touched key returns a zero status (with Key set) and no error.
func (*State) GetMany ¶
func (s *State) GetMany(ctx context.Context, userID int64, keys []core.RatingKey) (map[core.RatingKey]core.WatchStatus, error)
GetMany returns a status for every requested key (zero when untouched).
func (*State) GetPlaylist ¶
func (s *State) GetPlaylist(ctx context.Context, userID int64, key core.RatingKey) (*core.Playlist, error)
Get returns one playlist owned by the user, or core.ErrNotFound.
func (*State) History ¶
func (s *State) History(ctx context.Context, userID int64, since time.Time, limit int) ([]core.HistoryEntry, error)
History returns the viewing log, newest first. A zero since means all.
func (*State) ImportTransaction ¶
func (s *State) ImportTransaction(ctx context.Context, prepare func(*sql.Tx) (ImportBatch, error)) error
ImportTransaction commits a reviewed import with the caller's own bookkeeping and list writes. State owns every watch/history write and emits ordinary state events only after the complete transaction succeeds. Repeated history is inert.
func (*State) InProgress ¶
func (s *State) InProgress(ctx context.Context, userID int64, limit int) ([]core.WatchStatus, error)
InProgress returns items with a resume point, most recently viewed first.
func (*State) InProgressAny ¶
InProgressAny lists distinct keys in progress for any user.
func (*State) LockSync ¶ added in v0.2.57
func (s *State) LockSync(ctx context.Context, userID int64, key core.RatingKey, aspect SyncAspect, holder string, now time.Time, ttl time.Duration) (bool, error)
LockSync holds one user/title/aspect so two nodes cannot deliver it at once.
func (*State) Move ¶
Move moves the item at from so it lands at index to (both 0-based, to is the final position in the resulting list).
func (*State) NoteSyncIntents ¶ added in v0.2.57
NoteSyncIntents appends explicit intents (an owner-chosen export) in one transaction, superseding older undelivered rows of the same aspect.
func (*State) OutboxStatus ¶
func (s *State) OutboxStatus() (st store.OutboxStatus, ok bool)
OutboxStatus reports timeline writes held through a database outage; ok is false when progress is written synchronously (standalone).
func (*State) PauseSync ¶ added in v0.2.57
func (s *State) PauseSync(ctx context.Context, seq int64, holder string, until time.Time, reason string) error
PauseSync puts a claimed row back without counting an attempt. Delivery uses it when another node still holds the title lock, so this node can move on to a different row instead of claiming the same one again.
func (*State) PendingSyncCount ¶ added in v0.2.57
PendingSyncCount is undelivered, not superseded, not dead.
func (*State) Progress ¶
func (s *State) Progress(ctx context.Context, userID int64, key core.RatingKey, offset, duration time.Duration, state string) error
Progress records a timeline update. state is Plex's timeline state ("playing", "paused", "stopped", "buffering"). See the package doc for the 5%/90% rules. A duration of 0 keeps the previously known duration; with no known duration the offset is kept as a resume point and nothing is ever marked watched by progress alone.
On a clustered node a Progress the database cannot take within a short timeout is held and written later (see Options.Availability); it then returns nil.
func (*State) ReleaseSync ¶ added in v0.2.57
ReleaseSync drops a claim so another worker can take the row.
func (*State) RetryDeadSync ¶ added in v0.2.57
RetryDeadSync makes missing-GUID rows claimable again. Rejected and unsupported rows stay dead.
func (*State) ScrobbleMany ¶
ScrobbleMany marks several items watched in one transaction — the Plex front end expands a show or season scrobble into its episode keys and calls this. Each key gets a history entry and an EventScrobble.
func (*State) SetJournalFault ¶ added in v0.2.57
SetJournalFault installs a test hook that fails the journal write inside the watch transaction. Production leaves it unset.
func (*State) SetJournalGate ¶ added in v0.2.57
func (s *State) SetJournalGate(fn func(ctx context.Context, tx *sql.Tx, userID int64) (generation int64, ok bool, err error))
SetJournalGate installs the per-viewer decision for durable account-sync intents. The generation is the consent fence observed by that decision. The watch transaction still commits when the gate returns false.
func (*State) Subscribe ¶
Subscribe registers fn for state events (scrobbles, progress, ratings) so Trakt sync, recommendations, prefetch etc. can react. It returns an unsubscribe function. Handlers run synchronously after the change is committed; a panicking handler is recovered and logged.
func (*State) SyncCurrent ¶ added in v0.2.57
SyncCurrent reports whether seq is still the holder's live intent.
func (*State) UnlockSync ¶ added in v0.2.57
func (s *State) UnlockSync(ctx context.Context, userID int64, key core.RatingKey, aspect SyncAspect, holder string) error
UnlockSync releases a lock this holder owns.
func (*State) Unscrobble ¶
Unscrobble marks an item unwatched: view count and offset clear. The user's rating and history log are kept (history is a log, not status).
func (*State) UnscrobbleMany ¶
UnscrobbleMany marks several items unwatched in one transaction (show or season "mark unwatched").
type SyncAspect ¶ added in v0.2.57
type SyncAspect string
SyncAspect is the independent half of a title's account sync: watched state, or a rating. A newer rating must not erase a pending watch, and the reverse.
const ( SyncWatch SyncAspect = "watch" SyncRating SyncAspect = "rating" )
type SyncRow ¶ added in v0.2.57
type SyncRow struct {
Seq int64
UserID int64
Key core.RatingKey
Aspect SyncAspect
Kind string
Rating float64
At time.Time
Bulk bool
Dead bool
LastErr string
Attempts int
ConsentGen int64
}
SyncRow is one durable intent. Seq orders intents. Bulk is set when the watch change was one of several keys in the same transaction (a season or show mark).