Files
memby/server/internal/config/remote_config.go
T
2026-08-28 23:00:02 +12:00

373 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: "Matts 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
}