sync

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: 8 Imported by: 0

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

View Source
const (
	LevelShow   = "show"
	LevelSeason = "season"
)

UPDATE_LEVEL accepted values. Shared with the main/config package which parses the env var into one of these.

View Source
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

type EpisodeRef struct {
	Episode  *streams.Episode
	Audio    *streams.Stream
	Subtitle *streams.Stream
}

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

func (s *Syncer) FindEpisodeReference(
	ctx context.Context,
	episode *streams.Episode,
) *EpisodeRef

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.

Jump to

Keyboard shortcuts

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