Documentation
¶
Overview ¶
Package sync holds the per-episode track-synchronization orchestrator.
The package is organized around two planes:
- EVENT PLANE (ObserveAndPropagate): a resolved play session is the only moment a selection read is attributable to a user. It learns profiles, records per-show intents, and propagates the observed selection. This is the only plane that creates knowledge.
- RECONCILE PLANE (ReconcileWithIntent): the scheduler's history replay re-applies RECORDED intents. It never derives a user's choice from a delayed metadata read (whose per-user attribution is unreliable after the fact), never learns, never records.
Additional responsibilities:
- Seed a new/updated episode for all users (ProcessNewOrUpdatedEpisodeAllUsers): per-user intent first, then a lazily-searched shared reference episode, then the learned language profile (ApplyLanguageProfile).
Inviolate contracts preserved (see refactor-agent-guide.md):
- Plex HTTP URL paths and query parameters — the sync package never constructs URLs directly; it calls through api.PlexReader / api.PlexWriter, so the concrete plex.Client's verbatim path strings remain the single source of truth (inviolate item 1/9).
- WARN / ERROR slog keys ("failed to set audio stream", "failed to set subtitle stream", "failed to disable subtitles", "language update complete", "new/updated episode language set", "failed to fetch episodes for update", "failed to fetch show episodes for reference") are byte-for-byte identical to the pre-extraction log lines (inviolate item 5).
Consumer note: sync depends on api.PlexReader, api.PlexWriter, api.Cache, and api.UserLookup (not on the concrete internal/plex, internal/cache, or internal/users types). This keeps the package trivially testable with in-memory fakes.
Index ¶
- Constants
- type Config
- type EpisodeRef
- type Syncer
- func (s *Syncer) ApplyLanguageProfile(ctx context.Context, userClient api.PlexWriter, userID string, ...) bool
- func (s *Syncer) FindEpisodeReference(ctx context.Context, episode *streams.Episode) *EpisodeRef
- func (s *Syncer) ObserveAndPropagate(ctx context.Context, userClient api.PlexReadWriter, userID string, ...)
- func (s *Syncer) ProcessNewOrUpdatedEpisodeAllUsers(ctx context.Context, episode *streams.Episode, trigger string)
- func (s *Syncer) ReconcileWithIntent(ctx context.Context, userClient api.PlexReadWriter, userID string, ...)
- func (s *Syncer) UpdateEpisodeStreams(ctx context.Context, userClient api.PlexReadWriter, username, ratingKey string, ...) bool
Constants ¶
const ( LevelShow = "show" LevelSeason = "season" )
UPDATE_LEVEL accepted values. Shared with the main/config package which parses the env var into one of these.
const ( StrategyAll = "all" StrategyNext = "next" )
UPDATE_STRATEGY accepted values.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Config ¶
type Config struct {
Ignore api.IgnoreChecker // library/label skip rules; nil means "never skip"
UpdateLevel string // "show" (default) or "season"
UpdateStrategy string // "all" (default) or "next"
LanguageProfiles bool // enable learn/apply language profiles
}
Config captures the subset of application configuration the Syncer actually reads. Decoupling from the full main.config keeps the package boundary clean and lets tests construct a Syncer without mimicking the app's full env-var surface.
type EpisodeRef ¶
EpisodeRef bundles a shared reference episode and its selected streams. A nil *EpisodeRef means "no reference found, fall back to the learned language profile" for the caller.
type Syncer ¶
type Syncer struct {
// contains filtered or unexported fields
}
Syncer owns the per-episode orchestration. Construct via NewSyncer in the composition root; *Syncer is safe for concurrent use because all mutation goes through api.Cache (which is itself safe for concurrent use) and the Plex clients handled below are concurrency-safe (net/http transport + method-local state).
func NewSyncer ¶
func NewSyncer(cfg Config, reader api.PlexReader, c api.Cache, lookup api.UserLookup, userClient api.UserClientFunc) *Syncer
NewSyncer constructs a Syncer with the given collaborators. Callers must supply a non-nil PlexReader, Cache, UserLookup, and UserClientFunc; fields are intentionally unexported so composition only happens here.
func (*Syncer) ApplyLanguageProfile ¶
func (s *Syncer) ApplyLanguageProfile( ctx context.Context, userClient api.PlexWriter, userID string, episode *streams.Episode, trigger string, ) bool
ApplyLanguageProfile applies a learned language profile to a new episode when no reference episode exists in the show. This handles the case where a brand-new show is added and the user has established preferences (e.g., Japanese audio → English subtitles for anime).
Historical note: this function used to Episode() via the per-user client on the theory that the user-scoped view might show different Stream.selected values than the caller's (possibly admin-fetched) episode. Verified 2026-04-26 against live data: Plex does NOT differentiate Stream.selected per user token — the same episode returns identical Stream[*].selected values whether fetched via admin token or a Plex Home user's token. The re-fetch was a per-user round-trip that added no information, just latency.
The caller (applyEpisodeForUser) passes `episode` freshly fetched from either admin or user client upstream. The data we read from it here — selected streams and the first Part ID — is metadata-level and consistent across clients. When applyProfileSubtitle mutates state, it does so via `userClient`: per-user writes go through the user's own client when one is available. When the per-user client cannot be constructed, callers pass nothing through to here — the operation is SKIPPED upstream rather than written under the admin token, preserving per-user isolation (writing a shared user's selection under the admin token would corrupt the admin's per-user state and not apply the intended user's).
func (*Syncer) FindEpisodeReference ¶
FindEpisodeReference locates a reference episode for a new/updated episode: the most recent previously-seen episode in the show with a selected audio stream. Returns nil when the show has no reference yet (no prior episode with an active selection), which signals callers to fall back to the learned language profile.
Uses the admin reader because stream-selection state is server-wide, not per-user (see ProcessNewOrUpdatedEpisodeAllUsers for full rationale). The caller shares the result across every user being processed for the same episode, so this runs ONCE per episode regardless of user count.
When no reference is found, the branch emits a log line with a stable `reason` label so Loki can pinpoint why an episode fell through to the language-profile path. DEBUG reasons are benign ("no_grandparent_key", "no_candidate", "no_selected_audio"); WARN reasons signal a degraded fetch ("get_show_episodes_error", "candidate_fetch_errors") that may have masked an otherwise-usable reference.
func (*Syncer) ObserveAndPropagate ¶ added in v1.6.3
func (s *Syncer) ObserveAndPropagate( ctx context.Context, userClient api.PlexReadWriter, userID string, reference *streams.Episode, trigger string, )
ObserveAndPropagate is the EVENT-PLANE entry point: it handles a deliberately observed selection (a resolved play session, sampled within seconds of the user's action — the only moment a server read is attributable to a user). It learns the user's language profile, records the per-show intent, and propagates the observed selection across the show (or season).
This is the ONLY path that creates knowledge (profiles, intents). The reconcile plane (ReconcileWithIntent) and the new-episode seeding path only re-apply what was recorded here.
func (*Syncer) ProcessNewOrUpdatedEpisodeAllUsers ¶
func (s *Syncer) ProcessNewOrUpdatedEpisodeAllUsers( ctx context.Context, episode *streams.Episode, trigger string, )
ProcessNewOrUpdatedEpisodeAllUsers processes a new/updated episode for every known user (admin + shared).
The reference episode is searched ONCE per episode via the admin reader and shared across all users. Plex returns identical Stream.selected and metadata fields regardless of which user's token is used for reads (verified 2026-04-26 against live API + Tautulli playback history). Writes via UpdateEpisodeStreams / ApplyLanguageProfile still use per-user clients because PUTs set per-user playback state.
Cost collapse: for an N-user household, this path previously ran ShowEpisodes + up to maxRefSearchDepth Episode calls × N times per episode (e.g. 15 users × ~10 calls = 150 admin-equivalent calls). Now ~10 calls total for the reference search plus N writes — the only per-user work required.
Ignore contract: this entry point does NOT apply the ignore policy itself. Callers MUST gate on api.IgnoreChecker.ShouldSkipEpisode before invoking it (main.go handleTimeline and scheduler processRecentlyAddedEpisode both do). The gate is kept upstream deliberately to avoid a redundant ShowMetadata fetch here; a caller that skips it will silently apply language to an ignored show, breaking the documented "ignored show excluded from BOTH propagation AND learning" policy. (Contrast ObserveAndPropagate and ReconcileWithIntent, which self-gate.)
func (*Syncer) ReconcileWithIntent ¶ added in v1.6.3
func (s *Syncer) ReconcileWithIntent( ctx context.Context, userClient api.PlexReadWriter, userID string, episode *streams.Episode, viewedAt int64, trigger string, )
ReconcileWithIntent is the RECONCILE-PLANE entry point (scheduler history replay): it re-applies the user's RECORDED intent for the episode's show, and deliberately never derives the user's choice from the episode's current selection state. A delayed metadata read joins a historical identity to an ambient current selection whose per-user attribution is unreliable — the fabrication this design retires.
viewedAt is the replayed play's unix timestamp (0 when unknown). A play NEWER than the recorded intent means the app provably missed an interaction it never observed; applying the older intent could revert a manual change the user made during that unobserved window, so the item is skipped. The user's next play re-establishes the intent via the event plane.
No intent recorded for the show → skip: the safety net only replays knowledge, it never invents it.
func (*Syncer) UpdateEpisodeStreams ¶
func (s *Syncer) UpdateEpisodeStreams( ctx context.Context, userClient api.PlexReadWriter, username, ratingKey string, refAudio, refSub *streams.Stream, ) bool
UpdateEpisodeStreams applies reference audio/subtitle streams to a single episode using the provided per-user client. Returns true when any change was written.