2026-08-12 09:57:56 +12:00
|
|
|
|
package config
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"io"
|
|
|
|
|
|
"strconv"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-22 12:38:26 +12:00
|
|
|
|
// 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.
|
2026-08-12 09:57:56 +12:00
|
|
|
|
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"`
|
2026-08-22 12:38:26 +12:00
|
|
|
|
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"`
|
2026-08-12 09:57:56 +12:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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"`
|
2026-08-22 12:38:26 +12:00
|
|
|
|
Genres string `json:"genres"`
|
2026-08-12 09:57:56 +12:00
|
|
|
|
TVCalendar string `json:"tvCalendar"`
|
|
|
|
|
|
Favourites string `json:"favourites"`
|
|
|
|
|
|
User string `json:"user"`
|
|
|
|
|
|
Settings string `json:"settings"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type RemoteConfigFeatures struct {
|
2026-08-22 12:38:26 +12:00
|
|
|
|
ShowNavigationVersion bool `json:"showNavigationVersion"`
|
|
|
|
|
|
Flags map[string]bool `json:"flags,omitempty"`
|
2026-08-12 09:57:56 +12:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type RemoteConfigPresentation struct {
|
2026-08-22 18:40:50 +12:00
|
|
|
|
NavigationRailExpandedWidthDp int `json:"navigationRailExpandedWidthDp"`
|
|
|
|
|
|
NavigationContentShiftDp int `json:"navigationContentShiftDp"`
|
|
|
|
|
|
FontFamily string `json:"fontFamily"`
|
2026-08-12 09:57:56 +12:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 12:38:26 +12:00
|
|
|
|
// 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 {
|
2026-08-28 23:00:02 +12:00
|
|
|
|
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"`
|
2026-08-22 12:38:26 +12:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 23:00:02 +12:00
|
|
|
|
// 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"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-22 12:38:26 +12:00
|
|
|
|
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"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 09:57:56 +12:00
|
|
|
|
// 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",
|
2026-08-22 12:38:26 +12:00
|
|
|
|
TVShows: "TV Shows", Genres: "Genres", TVCalendar: "TV Calendar", Favourites: "Favourites",
|
2026-08-12 09:57:56 +12:00
|
|
|
|
User: "User", Settings: "Settings",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-08-22 12:38:26 +12:00
|
|
|
|
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,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-08-12 09:57:56 +12:00
|
|
|
|
Presentation: RemoteConfigPresentation{
|
|
|
|
|
|
NavigationRailExpandedWidthDp: 184,
|
|
|
|
|
|
NavigationContentShiftDp: 112,
|
2026-08-22 18:40:50 +12:00
|
|
|
|
FontFamily: "system",
|
2026-08-12 09:57:56 +12:00
|
|
|
|
},
|
2026-08-22 12:38:26 +12:00
|
|
|
|
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},
|
2026-08-12 09:57:56 +12:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func loadRemoteConfig(raw string) (RemoteConfig, error) {
|
|
|
|
|
|
if strings.TrimSpace(raw) == "" {
|
|
|
|
|
|
return DefaultRemoteConfig(), nil
|
|
|
|
|
|
}
|
2026-08-22 12:38:26 +12:00
|
|
|
|
// 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()
|
2026-08-12 09:57:56 +12:00
|
|
|
|
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,
|
2026-08-22 12:38:26 +12:00
|
|
|
|
document.Copy.Navigation.Genres,
|
2026-08-12 09:57:56 +12:00
|
|
|
|
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")
|
|
|
|
|
|
}
|
2026-08-22 18:40:50 +12:00
|
|
|
|
if document.Presentation.FontFamily != "system" && document.Presentation.FontFamily != "inter" {
|
|
|
|
|
|
return fmt.Errorf("fontFamily must be system or inter")
|
|
|
|
|
|
}
|
2026-08-22 12:38:26 +12:00
|
|
|
|
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")
|
|
|
|
|
|
}
|
2026-08-28 23:00:02 +12:00
|
|
|
|
if section.Layout != "" && section.Layout != SectionLayoutPoster && section.Layout != SectionLayoutThumb {
|
|
|
|
|
|
return fmt.Errorf("remote configuration section %q has an invalid layout", section.ID)
|
|
|
|
|
|
}
|
2026-08-22 12:38:26 +12:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-12 09:57:56 +12:00
|
|
|
|
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
|
|
|
|
|
|
}
|