Documentation
¶
Overview ¶
Package sync holds the per-episode track-synchronization orchestrator.
Responsibilities:
- Apply a reference episode's language selections to other episodes in the same show/season (ChangeTracksForEpisode).
- Discover a reference episode for a new/updated episode and fan it out across all users (ProcessNewOrUpdatedEpisodeAllUsers).
- Fall back to learned language profiles when no reference is found (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) ChangeTracksForEpisode(ctx context.Context, userClient api.PlexReadWriter, userID string, ...)
- func (s *Syncer) FindEpisodeReference(ctx context.Context, episode *streams.Episode) *EpisodeRef
- func (s *Syncer) ProcessNewOrUpdatedEpisodeAllUsers(ctx context.Context, episode *streams.Episode, trigger 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) ChangeTracksForEpisode ¶
func (s *Syncer) ChangeTracksForEpisode( ctx context.Context, userClient api.PlexReadWriter, userID string, reference *streams.Episode, trigger string, )
ChangeTracksForEpisode applies language preferences from a reference episode to other episodes in the same show (or season, depending on UpdateLevel), using a per-user client.
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) 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 ChangeTracksForEpisode, which self-gates.)
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.