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

personalize

package
v0.2.11 Latest Latest
Warning

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

Go to latest
Published: Oct 26, 2026 License: GPL-3.0 Imports: 27 Imported by: 0

Documentation

Overview

Package personalize is Ether's Netflix-style personalization engine.

Viewers only ever use Plex. The engine makes each viewer's Plex Home and library sections feel hand-curated for them, invisibly, by choosing which rows they get, in which order, with which titles, for the device and time they are watching:

  • a taste model per viewer (taste.go): affinities for genres, keywords, people, decades and languages, with time decay, learned from completions, rewatches, abandons, ratings and impressions that never led to a play, plus habits (session length, peak hours, device, binge and rewatch tendencies) and owner overrides;
  • candidate rows from generators (generators.go): Continue Watching, Up Next, Top 10, Because you watched, More from <person>, Trending in your taste, Fans of what you watch also loved and Trending with fans of what you watch (crowd.go, buzz.go), Hidden gems, New this week, Comfort rewatches, Something different, Short on time, Finish the franchise, owner collections, LLM themes, Trakt, and popularity rows for new viewers;
  • assembly per request context (assemble.go): context-aware item scoring, MMR diversity, cross-row dedupe, bandit row ranking with daily-seeded Thompson exploration (bandit.go), the owner's Home layout document (homelayout.go) and daily rotation;
  • a feedback loop: every served row is recorded as an impression (impressions.go) and every play is attributed to the row it came from (plays.go), which feeds both the taste model and the bandit.

Models are built in the background (on a schedule, after watch-state changes and on demand) and cached per viewer; a /hubs request only runs assembly over the cache and never waits on TMDB or the LLM.

The Engine implements core.Recommender, core.ContextualRecommender, core.ImpressionSink and core.PersonalScorer, plus the owner-facing methods the admin API needs (owner.go).

Index

Constants

View Source
const (
	KindContinue      = "continue-watching"
	KindUpNext        = "up-next"
	KindTop10         = "top-10"
	KindTop10Today    = "top-10-today"
	KindShowcase      = "genre-showcase"
	KindBecause       = "because-you-watched"
	KindPerson        = "more-from-person"
	KindTrendingTaste = "trending-in-your-taste"
	KindHiddenGems    = "hidden-gems"
	KindNewThisWeek   = "new-this-week"
	KindComfort       = "comfort-rewatches"
	KindDifferent     = "something-different"
	KindShortOnTime   = "short-on-time"
	KindFranchise     = "finish-the-franchise"
	KindLLM           = "llm"
	KindTrending      = "trending"
	// Crowd rows (crowd.go): public co-engagement over the viewer's
	// favourites, and its intersection with what is trending now.
	KindCrowd         = "fans-also-loved"
	KindCrowdTrending = "trending-with-fans"
	KindPopularServer = "popular-on-server"
	KindNewReleases   = "new-releases"
	KindTopRated      = "top-rated"
	KindPopular       = "popular"
	KindAiringToday   = "airing-today"
	KindTraktRecs     = "trakt-recommendations"
	KindTraktWatch    = "trakt-watchlist"
	KindRecentlyAdded = "recently-added" // built by the Plex front end in sections
	KindPlaylists     = "playlists"      // built by the Plex front end on Home

	// Movies → Recommended lenses (lenses.go): type-native rows that
	// explore the viewer's film taste from one angle each.
	KindTasteCluster  = "taste-cluster"      // "Heist Thrillers", "Sci-Fi Thrillers"
	KindEra           = "era-favourites"     // "Best of the '90s for You"
	KindWorld         = "world-cinema"       // "Korean Cinema for You"
	KindOccasion      = "occasion"           // "Date Night", "Family Night", "Under 100 Minutes"
	KindAcclaimed     = "acclaimed-unseen"   // acclaimed titles by fit
	KindTopGenre      = "top-rated-in-genre" // "Top-Rated Thrillers" in the viewer's genres
	KindBridge        = "taste-bridge"       // "Where <Seed A> Meets <Seed B>"
	KindStudio        = "more-from-studio"   // "More from A24"
	KindNewSeasons    = "new-seasons"        // TV Shows → Recommended (lenses_tv.go)
	KindFinishShow    = "finish-what-you-started"
	KindStartNew      = "start-something-new"
	KindBinge         = "complete-series"
	KindLimited       = "limited-series"
	KindAiringTaste   = "airing-for-you"
	KindNetwork       = "more-from-network"
	KindSticksLanding = "sticks-the-landing"
	KindStandalone    = "standalone-episodes"
	// KindCollectionPrefix + collection id is an owner collection row.
	KindCollectionPrefix = "collection:"
)

Row kinds. A kind is the stable, owner-facing identity of a row family: layout rules, insights and the bandit all work per kind. Hub ids are per row ("home.because.<key>"), kinds are not.

View Source
const (
	EvidenceFinished  = "finished"
	EvidenceWatching  = "watching"
	EvidenceAbandoned = "abandoned"
	EvidenceRated     = "rated"
	EvidenceIgnored   = "ignored" // shown repeatedly, never played
)

Evidence kinds.

Variables

This section is empty.

Functions

This section is empty.

Types

type Discovery

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

Discovery adds viewer-owned utility rows without changing Designer layout.

func NewDiscovery

func NewDiscovery(db *sql.DB, cat core.Catalog, st core.State, allowed func(*core.User, *core.Title) bool) *Discovery

func (*Discovery) ContinueAllowed

func (d *Discovery) ContinueAllowed(ctx context.Context, uid int64, t *core.Title) bool

func (*Discovery) FilterContinue

func (d *Discovery) FilterContinue(ctx context.Context, u *core.User, titles []core.Title) []core.Title

FilterContinue deduplicates episodes by their parent show's TMDB identity.

func (*Discovery) Hubs

func (d *Discovery) Hubs(ctx context.Context, u *core.User, limit int) ([]core.Hub, error)

func (*Discovery) Rediscover

func (d *Discovery) Rediscover(ctx context.Context, u *core.User, limit int) (core.Hub, error)

func (*Discovery) RemoveContinue

func (d *Discovery) RemoveContinue(ctx context.Context, uid int64, key core.RatingKey) error

RemoveContinue stores a sticky show-level dismissal, so the next episode or a repeated timeline cannot immediately put a dismissed show back on Home.

func (*Discovery) RestoreContinue

func (d *Discovery) RestoreContinue(ctx context.Context, uid int64, key core.RatingKey) error

func (*Discovery) ReviewCollections

func (d *Discovery) ReviewCollections(base core.Collections) core.Collections

ReviewCollections retains the collection engine as the authority for owner collections and adds virtual collections assembled from the viewer's history.

func (*Discovery) YearInReview

func (d *Discovery) YearInReview(ctx context.Context, u *core.User, year, limit int) (core.Hub, error)

type Engine

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

Engine is the personalization engine.

func New

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

New builds the engine and starts its background work (impression writer, event handling, model builders, scheduler). Call Close on shutdown.

func (*Engine) Activity

func (e *Engine) Activity(ctx context.Context, days int) ([]core.ActivityDay, error)

Activity returns server-wide viewing per local day for the last `days` days, oldest first, including empty days.

func (*Engine) AvailableRows

func (e *Engine) AvailableRows(ctx context.Context) ([]core.RowDescriptor, error)

AvailableRows lists the row kinds layout policies can target, plus owner collection rows recently served, each with the pages it can appear on (Sections) and its designer group.

func (*Engine) Close

func (e *Engine) Close()

Close stops background work, writing any queued impressions.

func (*Engine) FinalizeHome

func (e *Engine) FinalizeHome(ctx context.Context, user *core.User, hc core.HubContext, section string, hubs []core.Hub) ([]core.Hub, error)

FinalizeHome arranges the complete Plex row list after all producers ran. Unknown/custom hubs with no catalogue items are deliberately preserved.

func (*Engine) Flush

func (e *Engine) Flush(ctx context.Context)

Flush writes queued impressions now (tests, shutdown hooks).

func (*Engine) HomeCandidatesFor

func (e *Engine) HomeCandidatesFor(ctx context.Context, u *core.User, hc core.HubContext, limit int) ([]core.Hub, error)

func (*Engine) HomeHubs

func (e *Engine) HomeHubs(ctx context.Context, user *core.User, limit int) ([]core.Hub, error)

HomeHubs implements core.Recommender with a context derived from the clock only (device unknown).

func (*Engine) HomeHubsFor

func (e *Engine) HomeHubsFor(ctx context.Context, user *core.User, hc core.HubContext, limit int) ([]core.Hub, error)

HomeHubsFor implements core.ContextualRecommender: the viewer's Home rows for this context, served from the precomputed model. The Continue Watching row is returned as a placeholder ("home.continue", no items) marking where the Plex front end should put its own.

func (*Engine) HomeLayout

func (e *Engine) HomeLayout(ctx context.Context) (core.HomeLayout, error)

func (*Engine) LayoutDefaults

func (e *Engine) LayoutDefaults() map[string]int

func (*Engine) MicroReason

func (e *Engine) MicroReason(ctx context.Context, user *core.User, t *core.Title, now time.Time) core.MicroReason

MicroReason implements core.MicroReasoner. It only reads t.

func (*Engine) NotePlayback

func (e *Engine) NotePlayback(user *core.User, key core.RatingKey)

NotePlayback implements core.IntentTracker: playback starting within intentConvertWindow of an open for the same title removes that open — it converted, and the play itself lands in the watch history.

func (*Engine) PostPlay

func (e *Engine) PostPlay(ctx context.Context, user *core.User, justWatched core.RatingKey, hc core.HubContext, limit int) ([]core.PostPlayPick, error)

PostPlay implements core.PostPlayer.

func (*Engine) PreviewHome

func (e *Engine) PreviewHome(ctx context.Context, user *core.User, hc core.HubContext, sectionArg string, limit int) (*core.HomePreview, error)

PreviewHome returns exactly what the Plex front end would serve the viewer in this context (the same assembly as HomeHubsFor and SectionHubsFor), plus the rows the front end adds itself (Continue Watching and Recently Added in sections, Recent Playlists on Home), marked source "plex". Rows hidden by a layout policy are included last with Hidden set; Plex never receives them.

section is "" or "home", "movies", "shows", or a library section id. The daypart and weekend flag are derived from hc.Now in server local time (hc.Device is kept as given).

func (*Engine) PreviewLayout

func (e *Engine) PreviewLayout(ctx context.Context, user *core.User, hc core.HubContext, section string, limit int, d core.HomeLayout) (*core.HomePreview, error)

PreviewLayout assembles an unsaved document without changing persisted state, memoized production layouts, impressions, or viewer overrides.

func (*Engine) RankerInsight

func (e *Engine) RankerInsight(ctx context.Context) (core.RankerInsight, error)

RankerInsight reports the learned ranker for the owner UI: when it last trained, on how much data, its held-out AUC against the hand-tuned baseline, whether it is active, and its weights by magnitude.

func (*Engine) Rebuild

func (e *Engine) Rebuild(ctx context.Context, user *core.User) error

Rebuild builds a viewer's model now and waits for it (owner refresh, tests).

func (*Engine) RecentActivity

func (e *Engine) RecentActivity(ctx context.Context, userID int64, limit int) ([]core.ViewerActivity, error)

RecentActivity lists a viewer's latest viewings, newest first.

func (*Engine) RecordDetailOpen

func (e *Engine) RecordDetailOpen(ctx context.Context, user *core.User, t *core.Title)

RecordDetailOpen implements core.IntentTracker: one single-item detail open. Re-opening the same title refreshes its vote instead of stacking.

func (*Engine) RecordImpressions

func (e *Engine) RecordImpressions(user *core.User, hc core.HubContext, hubs []core.Hub)

RecordImpressions implements core.ImpressionSink: it records what a user was shown, asynchronously. It never blocks.

func (*Engine) Refresh

func (e *Engine) Refresh(ctx context.Context, user *core.User) error

Refresh rebuilds a viewer's model now (and asks the LLM for fresh themed rows when it supports invalidation).

func (*Engine) Related

func (e *Engine) Related(ctx context.Context, user *core.User, key core.RatingKey, limit int) ([]core.Hub, error)

Related implements core.Recommender by combining the base recommender's detail-page rows with personalized context-aware shelves (Closer to Your Taste, Same Vibe Under 105 Minutes, and high-affinity craft/auteur matches) when the viewer's model is warm.

func (*Engine) RowInsights

func (e *Engine) RowInsights(ctx context.Context, days int) ([]core.RowInsight, error)

RowInsights reports, per row kind, how often it was shown and played over the last `days` days (row-days shown; plays attributed).

func (*Engine) ScoreTitles

func (e *Engine) ScoreTitles(ctx context.Context, user *core.User, titles []core.Title) (map[core.RatingKey]float64, error)

ScoreTitles implements core.PersonalScorer: taste fit blended with quality, roughly -0.7..1 (higher = better fit for this viewer).

func (*Engine) SectionCandidatesFor

func (e *Engine) SectionCandidatesFor(ctx context.Context, u *core.User, t core.MediaType, hc core.HubContext, limit int) ([]core.Hub, error)

func (*Engine) SectionHubs

func (e *Engine) SectionHubs(ctx context.Context, user *core.User, t core.MediaType, limit int) ([]core.Hub, error)

SectionHubs implements core.Recommender.

func (*Engine) SectionHubsFor

func (e *Engine) SectionHubsFor(ctx context.Context, user *core.User, t core.MediaType, hc core.HubContext, limit int) ([]core.Hub, error)

SectionHubsFor implements core.ContextualRecommender for a library section's rows (below the front end's Continue Watching and Recently Added).

func (*Engine) SetBus

func (e *Engine) SetBus(bus clusterbus.Bus)

SetBus connects the cluster bus (nil disconnects). A standalone server never calls it.

func (*Engine) SetExtraHomeRows

func (e *Engine) SetExtraHomeRows(fn func(context.Context, *core.User, core.HubContext, string, int) []core.Hub)

SetExtraHomeRows supplies the additional production rows for owner previews. Wire once during startup, before serving requests.

func (*Engine) SetHomeLayout

func (e *Engine) SetHomeLayout(ctx context.Context, d core.HomeLayout) (core.HomeLayout, error)

func (*Engine) SetTasteOverrides

func (e *Engine) SetTasteOverrides(ctx context.Context, user *core.User, os []core.TasteOverride) (*core.TasteProfile, error)

SetTasteOverrides replaces the owner's corrections for a viewer and rebuilds their model so Plex reflects them on the next Home load.

func (*Engine) Taste

func (e *Engine) Taste(ctx context.Context, user *core.User) (*core.TasteProfile, error)

Taste returns the viewer's taste profile (from the cached model when there is one).

func (*Engine) TrainRanker

func (e *Engine) TrainRanker(ctx context.Context) (core.RankerInsight, error)

TrainRanker labels closed serves, retrains the learned ranker on the serve log now and installs the result (active only when it passed the gate). It returns the new insight.

func (*Engine) ViewerStats

func (e *Engine) ViewerStats(ctx context.Context, userIDs []int64) (map[int64]core.ViewerStats, error)

ViewerStats summarises each viewer's last 30 days.

type Evidence

type Evidence struct {
	Key         core.RatingKey
	Title       core.Title
	Weight      float64 // signed, decayed, clamped to ±2
	Raw         float64 // before decay
	At          time.Time
	Kind        string
	Completions int // finished viewings (movies) or finished episodes (shows)
	Rewatched   bool
	Rating      float64
	Progress    float64
	IgnoredDays int
}

Evidence is what one title says about a viewer's taste. Episodes fold onto their show.

type Options

type Options struct {
	FilterAvailable func(context.Context, []core.Title) []core.Title
	// Owner previews use the same effective viewer and title policy as Plex.
	UserPolicy func(*core.User) *core.User
	Allowed    func(*core.User, *core.Title) bool
	DB         *sql.DB
	Catalog    core.Catalog
	State      core.State
	// Events delivers watch-state changes (internal/state.State).
	Events recommend.EventSource
	// Users lists viewers for background precompute (core.Auth).
	Users UserSource
	// Collections supplies owner-designed collection rows.
	Collections core.CollectionRows
	LLM         recommend.LLMSource
	Trakt       recommend.TraktSource
	Playlists   core.Playlists
	// Base answers detail-page Related rows (internal/recommend).
	Base core.Recommender
	// Dependencies constructs consumers of this engine's scorer before any
	// background work starts. This resolves the scorer/row-source construction
	// cycle without publishing a partially wired engine through setters.
	Dependencies func(core.PersonalScorer) (core.Recommender, core.CollectionRows, error)
	// ExtraHomeRows supplies optional integration rows to owner previews.
	ExtraHomeRows func(context.Context, *core.User, core.HubContext, string, int) []core.Hub
	// Sections maps library section ids to their type, for per-user
	// allowed sections (default 1 = movies, 2 = shows).
	Sections map[int]core.MediaType
	// ServerName is the friendly name shown in "Popular on <name>".
	ServerName func() string
	// ImageURL builds browser-loadable artwork URLs for the owner UI
	// (default /ether/api/image?path=…&w=…).
	ImageURL func(path string, width int) string
	Logger   *slog.Logger
	Now      func() time.Time
	Location *time.Location // server local time (default time.Local)
	// Busy reports whether anyone is streaming right now (nil = never).
	// Background work that can wait, like retraining the ranker, waits.
	Busy func() bool
	// IntentEnabled reports whether live session intent (#32) is on
	// (nil = on); consulted per signal so owner edits apply live.
	IntentEnabled func() bool
	// ClockFormat reports the micro-reason finish-time style (#31):
	// "12h", "24h", a locale like "en-GB", or "" for 12h.
	ClockFormat func() string

	ModelTTL        time.Duration // rebuild an active viewer's model after this (default 3h)
	RefreshEvery    time.Duration // background scheduler period (default 15m; <0 disables)
	RebuildDebounce time.Duration // after a watch-state change (default 20s)
	Retention       time.Duration // item impressions (default 60 days)
	Workers         int           // concurrent model builds (default 2)
	ColdWait        time.Duration // how long a request may wait for the first-ever model (default 1.5s)
	OwnerRowsWait   time.Duration // how long a request may wait for owner rows it has never fetched (default 150ms)
	SourceTimeout   time.Duration // Trakt and owner collection calls (default 16s)
	LLMTimeout      time.Duration // LLM themed rows during a build (default 60s)
	BuildTimeout    time.Duration // one model build (default 3m)
	MaxHomeRows     int           // default 20 across the complete composed page
	MaxSectionRows  int           // default 16
	// MinRowItems is the fewest titles an engine row may show (default 12;
	// negative disables the floor). Continue Watching, Up Next and owner
	// collections are exempt; Top 10 and smaller requested pages use their cap.
	MinRowItems int
	// Exploration scales Thompson sampling (0 = pure exploitation, 1 =
	// full sampling; default 0.5; negative = 0).
	Exploration      float64
	HalfLife         time.Duration // taste decay half-life (default 120 days)
	ImpressionBuffer int           // queued impressions before dropping (default 4096)
	FlushEvery       time.Duration // impression write interval (default 2s)
	Ranker           RankerOptions // learned ranker (ranker.go)
}

Options build an Engine. DB and Catalog are required; every other dependency is optional and its rows simply drop out when absent.

type Problem

type Problem struct {
	Field   string
	Message string
}

Problem is one validation failure.

type RankerOptions

type RankerOptions struct {
	// LogRows × LogItems is the part of a served page logged (default 8×12).
	LogRows, LogItems int
	// MaxServesPerDay caps logged pages per viewer per day (default 48).
	MaxServesPerDay int
	// MaxItems caps logged items overall (default 1,000,000).
	MaxItems int
	// Retention of the serve log (default 60 days).
	Retention time.Duration
	// TrainEvery is the retraining period (default 6h). Negative disables
	// the background loop; it is also off when Options.RefreshEvery is
	// negative, unless set explicitly.
	TrainEvery time.Duration
	// RetrainLabels retrains early once this many new labels arrived
	// (default 1000).
	RetrainLabels int
	// TrainHour is the local hour training prefers (default 4, the quiet
	// small hours; negative = any hour). A model older than two days
	// trains at the next quiet moment regardless. Training never starts
	// while Options.Busy reports viewers streaming.
	TrainHour int
	// MinExamples / MinPositives gate activation (default 2000 / 30).
	MinExamples, MinPositives int
	// MinLift is the held-out AUC margin over the baseline required to
	// activate (default 0.02).
	MinLift float64
	// MaxBlend bounds the model's share of an item score (default 0.35).
	MaxBlend float64
	// Disabled turns logging, training and inference off.
	Disabled bool
}

RankerOptions tune the learned ranker. Zero values take the defaults.

type Taste

type Taste struct {
	UserID     int64
	BuiltAt    time.Time
	Confidence float64 // 0..1

	Genre     map[string]float64 // -1..1
	Keyword   map[string]float64
	Person    map[int64]float64
	People    map[int64]personInfo
	Decade    map[int]float64
	Language  map[string]float64
	Franchise map[int64]float64
	Studio    map[string]float64

	// Evidence is the per-title evidence the model was built from, by
	// descending |weight|.
	Evidence []Evidence
	Habits   core.TasteHabits

	// DaypartGenre is a learned genre lift per daypart (-0.5..0.5): what
	// this viewer watches more of in the morning than at night.
	DaypartGenre map[string]map[string]float64
	// ContextGenre and DeviceGenre disentangle sub-personas across
	// (weekday|weekend × daypart) and device class (TV vs mobile).
	ContextGenre map[string]map[string]float64
	DeviceGenre  map[string]map[string]float64
	// MovieShare is the smoothed share of films (vs episodes) the viewer
	// watches per "weekday|weekend/daypart" bucket.
	MovieShare map[string]float64

	Overrides []core.TasteOverride
	// contains filtered or unexported fields
}

Taste is one viewer's taste model: signed affinities for genres, keywords, people, decades, languages and franchises, learned from their watch history, plays, ratings and impressions, with owner overrides on top. It is deterministic for a given input and clock, and explains itself (Fit returns a reason; Profile lists the weights).

func (*Taste) ContextGenreLift added in v0.2.7

func (ts *Taste) ContextGenreLift(hc core.HubContext, gs []string) float64

ContextGenreLift returns the viewer's contextual persona lift (-0.5..0.5) for a title's normalized genres in hc, blending the specific (weekend × daypart) mode, daypart lift, and device mode.

func (*Taste) Fit

func (ts *Taste) Fit(t *core.Title) (float64, string)

Fit scores how well a title matches the taste, roughly -1..1, and explains the strongest positive reason.

fit = 0.40·genre + 0.15·keywords + 0.25·people + 0.08·decade
    + language (±0.07, see languageFit) + 0.05·franchise

A genre weighted -0.6 or below (usually an owner override) vetoes the title: its fit is capped at 0 and pushed down by 0.4×that weight.

func (*Taste) Profile

func (ts *Taste) Profile(imageURL func(path string, width int) string) *core.TasteProfile

Profile is the owner-facing view of the model. imageURL turns a TMDB image path into a browser-loadable URL.

func (*Taste) TopGenres

func (ts *Taste) TopGenres(n int) []string

TopGenres returns the n strongest positive genres.

type TrendSource

type TrendSource interface {
	PublicList(ctx context.Context, list string, t core.MediaType, limit int) ([]core.Title, error)
}

TrendSource is optionally implemented by the Trakt dependency (internal/trakt.Client): Trakt's public, crowd-wide lists. It returns (nil, nil) when Trakt is not configured.

type UserSource

type UserSource interface {
	Users(ctx context.Context) ([]core.User, error)
}

UserSource lists the server's users (core.Auth satisfies it).

type ValidationError

type ValidationError struct {
	Problems []Problem
}

ValidationError lists problems with an owner-supplied document (Home layout, taste overrides), addressed by dotted field path.

func (*ValidationError) Error

func (e *ValidationError) Error() string

func (*ValidationError) InvalidFields

func (e *ValidationError) InvalidFields() [][2]string

InvalidFields returns (field, message) pairs; the admin API maps them to its validation error shape.

Jump to

Keyboard shortcuts

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