package config import ( "encoding/json" "fmt" "io" "strconv" "strings" ) // RemoteConfig is the versioned, app-scoped control-plane document offered to TVs. It // contains behaviour switches and ordering, but never credentials or viewer state. An // unavailable document is safe because the APK carries equivalent bundled defaults. type RemoteConfig struct { SchemaVersion int `json:"schemaVersion"` ConfigVersion int64 `json:"configVersion"` MinimumAppVersion string `json:"minimumAppVersion,omitempty"` MaximumAppVersion string `json:"maximumAppVersion,omitempty"` Copy RemoteConfigCopy `json:"copy"` Features RemoteConfigFeatures `json:"features"` Presentation RemoteConfigPresentation `json:"presentation"` Home RemoteHomeConfig `json:"home"` Movies RemotePageConfig `json:"movies"` TV RemotePageConfig `json:"tv"` ContinueWatching RemoteContinueWatching `json:"continueWatching"` ForYou RemoteForYouConfig `json:"forYou"` Recommendations RemoteRecommendations `json:"recommendations"` Search RemoteSearchConfig `json:"search"` UI RemoteUIConfig `json:"ui"` Experimental map[string]bool `json:"experimental,omitempty"` Integrations RemoteIntegrations `json:"integrations"` Branding RemoteBranding `json:"branding"` } type RemoteConfigCopy struct { Navigation RemoteConfigNavigationCopy `json:"navigation"` Tagline string `json:"tagline"` } type RemoteConfigNavigationCopy struct { Home string `json:"home"` ForYou string `json:"forYou"` Search string `json:"search"` Movies string `json:"movies"` TVShows string `json:"tvShows"` Genres string `json:"genres"` TVCalendar string `json:"tvCalendar"` Favourites string `json:"favourites"` User string `json:"user"` Settings string `json:"settings"` } type RemoteConfigFeatures struct { ShowNavigationVersion bool `json:"showNavigationVersion"` Flags map[string]bool `json:"flags,omitempty"` } type RemoteConfigPresentation struct { NavigationRailExpandedWidthDp int `json:"navigationRailExpandedWidthDp"` NavigationContentShiftDp int `json:"navigationContentShiftDp"` FontFamily string `json:"fontFamily"` } // The remainder of the document is deliberately declarative. A new server-side row or // option can be added to these lists without making the client understand it: older clients // filter unknown ids and retain their bundled ordering for anything they do not know. type RemoteHomeConfig struct { Sections []string `json:"sections"` SectionDefinitions []RemoteSectionDefinition `json:"sectionDefinitions,omitempty"` ShowForYou bool `json:"showForYou"` ShowSeasonal bool `json:"showSeasonal"` HeroRefreshSeconds int `json:"heroRefreshSeconds"` MaxItemsPerRow int `json:"maxItemsPerRow"` } type RemotePageConfig struct { Sections []string `json:"sections"` SectionDefinitions []RemoteSectionDefinition `json:"sectionDefinitions,omitempty"` ShowGenres bool `json:"showGenres"` } // RemoteSectionDefinition is the stable composition contract. Clients render only // known component types and ignore definitions introduced by newer gateways. type RemoteSectionDefinition struct { ID string `json:"id"` Type string `json:"type"` Title string `json:"title"` Enabled bool `json:"enabled"` Position int `json:"position"` DataSource string `json:"dataSource"` Component string `json:"component"` MaxItems int `json:"maxItems,omitempty"` Destination string `json:"destination,omitempty"` // Layout overrides the card shape a mediaRow draws: "poster" forces upright poster // cards, "thumb" forces the wide landscape cards Continue Watching uses. Empty leaves // the client's automatic choice (episodes landscape, everything else poster) alone. Layout string `json:"layout,omitempty"` Settings map[string]any `json:"settings,omitempty"` } // SectionLayoutPoster and SectionLayoutThumb are the two explicit card shapes an operator // can pin a row to; anything else means "let the client decide". const ( SectionLayoutPoster = "poster" SectionLayoutThumb = "thumb" ) type RemoteContinueWatching struct { Enabled bool `json:"enabled"` IncludeNextUp bool `json:"includeNextUp"` MaxItems int `json:"maxItems"` ProgressColour string `json:"progressColour"` } type RemoteForYouConfig struct { Enabled bool `json:"enabled"` MaxRows int `json:"maxRows"` RefreshHours int `json:"refreshHours"` } type RemoteRecommendations struct { Enabled bool `json:"enabled"` Sections []string `json:"sections"` } type RemoteSearchConfig struct { Enabled bool `json:"enabled"` GenresEnabled bool `json:"genresEnabled"` MaxResults int `json:"maxResults"` } type RemoteUIConfig struct { ArtworkStyle string `json:"artworkStyle"` CardDensity string `json:"cardDensity"` ShowWatchedBadges bool `json:"showWatchedBadges"` ShowMediaTypeIcons bool `json:"showMediaTypeIcons"` } type RemoteIntegrations struct { Tracearr bool `json:"tracearr"` Sonarr bool `json:"sonarr"` Radarr bool `json:"radarr"` } type RemoteBranding struct { MarkURL string `json:"markUrl,omitempty"` MarkVersion string `json:"markVersion,omitempty"` } // DefaultRemoteConfig mirrors the APK's bundled values. Serving it is still useful: it // establishes the schema and ETag contract before an operator chooses an override. func DefaultRemoteConfig() RemoteConfig { return RemoteConfig{ SchemaVersion: 1, ConfigVersion: 1, Copy: RemoteConfigCopy{ Tagline: "Matt’s Android TV client", Navigation: RemoteConfigNavigationCopy{ Home: "Home", ForYou: "For You", Search: "Search", Movies: "Movies", TVShows: "TV Shows", Genres: "Genres", TVCalendar: "TV Calendar", Favourites: "Favourites", User: "User", Settings: "Settings", }, }, Features: RemoteConfigFeatures{ ShowNavigationVersion: true, Flags: map[string]bool{ "continue_watching": true, "for_you": true, "recommendations": true, "genre_browser": false, "tv_calendar": true, "tracearr": false, "sonarr": false, "radarr": false, }, }, Presentation: RemoteConfigPresentation{ NavigationRailExpandedWidthDp: 184, NavigationContentShiftDp: 112, FontFamily: "system", }, Home: RemoteHomeConfig{ Sections: []string{"continue", "for-you", "favorites", "latest-movies"}, SectionDefinitions: defaultHomeSections(), ShowForYou: true, ShowSeasonal: true, HeroRefreshSeconds: 60, MaxItemsPerRow: 20, }, Movies: RemotePageConfig{Sections: []string{"genres", "library"}, SectionDefinitions: defaultPageSections("movies"), ShowGenres: false}, TV: RemotePageConfig{Sections: []string{"genres", "library"}, SectionDefinitions: defaultPageSections("tv"), ShowGenres: false}, ContinueWatching: RemoteContinueWatching{Enabled: true, IncludeNextUp: true, MaxItems: 20, ProgressColour: "emby"}, ForYou: RemoteForYouConfig{Enabled: true, MaxRows: 3, RefreshHours: 24}, Recommendations: RemoteRecommendations{Enabled: true, Sections: []string{"for-you", "because-you-watched"}}, Search: RemoteSearchConfig{Enabled: true, GenresEnabled: false, MaxResults: 50}, UI: RemoteUIConfig{ArtworkStyle: "automatic", CardDensity: "standard", ShowWatchedBadges: true, ShowMediaTypeIcons: false}, Experimental: map[string]bool{}, Integrations: RemoteIntegrations{}, Branding: RemoteBranding{}, } } func defaultHomeSections() []RemoteSectionDefinition { return []RemoteSectionDefinition{ {ID: "continue", Type: "continueWatching", Title: "Continue Watching", Enabled: true, Position: 10, DataSource: "emby.resume", Component: "mediaRow", MaxItems: 20, Destination: "home"}, {ID: "for-you", Type: "forYou", Title: "For You", Enabled: true, Position: 20, DataSource: "gateway.recommendations", Component: "mediaRow", MaxItems: 20, Destination: "for-you"}, {ID: "favorites", Type: "favorites", Title: "Favourites", Enabled: true, Position: 30, DataSource: "emby.favourites", Component: "mediaRow", MaxItems: 20, Destination: "home"}, {ID: "latest-movies", Type: "latest", Title: "Recently Added", Enabled: true, Position: 40, DataSource: "emby.latest", Component: "mediaRow", MaxItems: 20, Destination: "movies"}, } } func defaultPageSections(destination string) []RemoteSectionDefinition { return []RemoteSectionDefinition{ {ID: "genres", Type: "genres", Title: "Genres", Enabled: true, Position: 10, DataSource: "emby.genres", Component: "genreBrowser", MaxItems: 20, Destination: destination}, {ID: "library", Type: "library", Title: "Library", Enabled: true, Position: 20, DataSource: "emby.library", Component: "mediaGrid", MaxItems: 20, Destination: destination}, } } func loadRemoteConfig(raw string) (RemoteConfig, error) { if strings.TrimSpace(raw) == "" { return DefaultRemoteConfig(), nil } // Start from defaults so a document published before a newly added section remains // valid and receives the same safe behaviour as a bundled client. document := DefaultRemoteConfig() decoder := json.NewDecoder(strings.NewReader(raw)) decoder.DisallowUnknownFields() if err := decoder.Decode(&document); err != nil { return RemoteConfig{}, fmt.Errorf("MEMBY_REMOTE_CONFIG_JSON: %w", err) } var trailing any if err := decoder.Decode(&trailing); err != io.EOF { return RemoteConfig{}, fmt.Errorf("MEMBY_REMOTE_CONFIG_JSON must contain exactly one document") } if err := validateRemoteConfig(document); err != nil { return RemoteConfig{}, fmt.Errorf("MEMBY_REMOTE_CONFIG_JSON: %w", err) } return document, nil } func validateRemoteConfig(document RemoteConfig) error { if document.SchemaVersion != 1 { return fmt.Errorf("schemaVersion must be 1") } if document.ConfigVersion < 1 { return fmt.Errorf("configVersion must be positive") } minimum, minimumSet, err := parseRemoteConfigVersion(document.MinimumAppVersion) if err != nil { return fmt.Errorf("minimumAppVersion must be a three-part version") } maximum, maximumSet, err := parseRemoteConfigVersion(document.MaximumAppVersion) if err != nil { return fmt.Errorf("maximumAppVersion must be a three-part version") } if minimumSet && maximumSet && compareRemoteConfigVersions(minimum, maximum) > 0 { return fmt.Errorf("minimumAppVersion must not be newer than maximumAppVersion") } labels := []string{ document.Copy.Tagline, document.Copy.Navigation.Home, document.Copy.Navigation.ForYou, document.Copy.Navigation.Search, document.Copy.Navigation.Movies, document.Copy.Navigation.TVShows, document.Copy.Navigation.Genres, document.Copy.Navigation.TVCalendar, document.Copy.Navigation.Favourites, document.Copy.Navigation.User, document.Copy.Navigation.Settings, } for _, label := range labels { trimmed := strings.TrimSpace(label) if trimmed == "" || len([]rune(trimmed)) > 64 || strings.ContainsAny(trimmed, "\r\n\t") { return fmt.Errorf("copy must be between 1 and 64 characters and contain no control whitespace") } } width := document.Presentation.NavigationRailExpandedWidthDp shift := document.Presentation.NavigationContentShiftDp if width < 160 || width > 240 { return fmt.Errorf("navigationRailExpandedWidthDp must be between 160 and 240") } if shift < 80 || shift > 160 || shift >= width { return fmt.Errorf("navigationContentShiftDp must be between 80 and 160 and less than the rail width") } if document.Presentation.FontFamily != "system" && document.Presentation.FontFamily != "inter" { return fmt.Errorf("fontFamily must be system or inter") } if err := validateRemoteConfigSections(document); err != nil { return err } return nil } func validateRemoteConfigSections(document RemoteConfig) error { if len(document.Home.Sections) > 32 || len(document.Movies.Sections) > 32 || len(document.TV.Sections) > 32 || len(document.Recommendations.Sections) > 32 { return fmt.Errorf("section ordering contains too many entries") } for _, sections := range [][]string{document.Home.Sections, document.Movies.Sections, document.TV.Sections, document.Recommendations.Sections} { for _, section := range sections { section = strings.TrimSpace(section) if section == "" || len(section) > 64 || strings.ContainsAny(section, "\r\n\t") { return fmt.Errorf("section ids must be between 1 and 64 characters") } } } for key := range document.Features.Flags { if strings.TrimSpace(key) == "" || len(key) > 64 { return fmt.Errorf("feature flag ids must be between 1 and 64 characters") } } for key := range document.Experimental { if strings.TrimSpace(key) == "" || len(key) > 64 { return fmt.Errorf("experimental flag ids must be between 1 and 64 characters") } } if document.ContinueWatching.MaxItems < 1 || document.ContinueWatching.MaxItems > 100 { return fmt.Errorf("continueWatching.maxItems must be between 1 and 100") } if document.ForYou.MaxRows < 0 || document.ForYou.MaxRows > 20 || document.ForYou.RefreshHours < 1 || document.ForYou.RefreshHours > 168 { return fmt.Errorf("forYou limits are unsafe") } if document.Search.MaxResults < 1 || document.Search.MaxResults > 200 { return fmt.Errorf("search.maxResults must be between 1 and 200") } if document.UI.ArtworkStyle != "automatic" && document.UI.ArtworkStyle != "poster" && document.UI.ArtworkStyle != "backdrop" { return fmt.Errorf("ui.artworkStyle is invalid") } if document.UI.CardDensity != "standard" && document.UI.CardDensity != "compact" && document.UI.CardDensity != "large" { return fmt.Errorf("ui.cardDensity is invalid") } for _, definitions := range [][]RemoteSectionDefinition{document.Home.SectionDefinitions, document.Movies.SectionDefinitions, document.TV.SectionDefinitions} { if len(definitions) > 64 { return fmt.Errorf("remote configuration has too many section definitions") } for _, section := range definitions { if strings.TrimSpace(section.ID) == "" || strings.TrimSpace(section.Type) == "" || strings.TrimSpace(section.Component) == "" { return fmt.Errorf("remote configuration section definitions require id, type and component") } if section.Position < 0 || section.MaxItems < 0 || section.MaxItems > 500 { return fmt.Errorf("remote configuration section has an invalid position or item limit") } if section.Layout != "" && section.Layout != SectionLayoutPoster && section.Layout != SectionLayoutThumb { return fmt.Errorf("remote configuration section %q has an invalid layout", section.ID) } } } return nil } func parseRemoteConfigVersion(raw string) ([3]int, bool, error) { var version [3]int if strings.TrimSpace(raw) == "" { return version, false, nil } parts := strings.Split(raw, ".") if len(parts) != len(version) { return version, false, fmt.Errorf("invalid version") } for index, part := range parts { value, err := strconv.Atoi(part) if err != nil || value < 0 { return version, false, fmt.Errorf("invalid version") } version[index] = value } return version, true, nil } func compareRemoteConfigVersions(left, right [3]int) int { for index := range left { if left[index] < right[index] { return -1 } if left[index] > right[index] { return 1 } } return 0 }