This commit is contained in:
ponzischeme89
2026-08-22 12:38:26 +12:00
parent f982d391b9
commit b3e3f62c54
26 changed files with 1279 additions and 108 deletions
+8
View File
@@ -288,6 +288,14 @@ func Load() (Config, error) {
ForYouRefreshInterval: duration("MEMBY_FOR_YOU_REFRESH_INTERVAL", 24*time.Hour),
ForYouRebuildHour: integer("MEMBY_FOR_YOU_REBUILD_HOUR", 4),
}
// Integration capabilities are server facts, not secrets. Publish only whether each
// service is configured; API keys and URLs remain gateway-only. An operator can still
// disable the corresponding feature flag in the document without exposing credentials.
c.RemoteConfig.Integrations = RemoteIntegrations{
Tracearr: c.TracearrURL != "" && c.TracearrAPIKey != "",
Sonarr: c.SonarrURL != "" && c.SonarrAPIKey != "",
Radarr: c.RadarrURL != "" && c.RadarrAPIKey != "",
}
if c.AnalyticsRetention < 30*24*time.Hour {
c.AnalyticsRetention = 30 * 24 * time.Hour
}
+192 -8
View File
@@ -8,10 +8,9 @@ import (
"strings"
)
// RemoteConfig is the deliberately small, presentation-only document offered to TVs.
// It must never contain authentication, playback or navigation-routing decisions: an
// unavailable document is required to be indistinguishable from an ordinary offline
// launch apart from its wording and safe presentation choices.
// 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"`
@@ -20,6 +19,17 @@ type RemoteConfig struct {
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 {
@@ -33,6 +43,7 @@ type RemoteConfigNavigationCopy struct {
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"`
@@ -40,7 +51,8 @@ type RemoteConfigNavigationCopy struct {
}
type RemoteConfigFeatures struct {
ShowNavigationVersion bool `json:"showNavigationVersion"`
ShowNavigationVersion bool `json:"showNavigationVersion"`
Flags map[string]bool `json:"flags,omitempty"`
}
type RemoteConfigPresentation struct {
@@ -48,6 +60,81 @@ type RemoteConfigPresentation struct {
NavigationContentShiftDp int `json:"navigationContentShiftDp"`
}
// 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"`
Settings map[string]any `json:"settings,omitempty"`
}
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 {
@@ -58,15 +145,53 @@ func DefaultRemoteConfig() RemoteConfig {
Tagline: "Matts Android TV client",
Navigation: RemoteConfigNavigationCopy{
Home: "Home", ForYou: "For You", Search: "Search", Movies: "Movies",
TVShows: "TV Shows", TVCalendar: "TV Calendar", Favourites: "Favourites",
TVShows: "TV Shows", Genres: "Genres", TVCalendar: "TV Calendar", Favourites: "Favourites",
User: "User", Settings: "Settings",
},
},
Features: RemoteConfigFeatures{ShowNavigationVersion: true},
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,
},
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},
}
}
@@ -74,7 +199,9 @@ func loadRemoteConfig(raw string) (RemoteConfig, error) {
if strings.TrimSpace(raw) == "" {
return DefaultRemoteConfig(), nil
}
var document RemoteConfig
// 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 {
@@ -115,6 +242,7 @@ func validateRemoteConfig(document RemoteConfig) error {
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,
@@ -134,6 +262,62 @@ func validateRemoteConfig(document RemoteConfig) error {
if shift < 80 || shift > 160 || shift >= width {
return fmt.Errorf("navigationContentShiftDp must be between 80 and 160 and less than the rail width")
}
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")
}
}
}
return nil
}
@@ -16,6 +16,20 @@ func TestRemoteConfigDefaultsAreComplete(t *testing.T) {
if document.Copy.Navigation.Favourites != "Favourites" {
t.Fatalf("favourites label = %q", document.Copy.Navigation.Favourites)
}
if !document.ContinueWatching.IncludeNextUp || len(document.Home.Sections) == 0 {
t.Fatalf("central defaults are incomplete: %+v", document)
}
}
func TestRemoteConfigDefaultsMissingNewSectionsForOlderDocuments(t *testing.T) {
raw := `{"schemaVersion":1,"configVersion":3,"copy":{"navigation":{"home":"Home","forYou":"For You","search":"Search","movies":"Movies","tvShows":"TV Shows","tvCalendar":"TV Calendar","favourites":"Favourites","user":"User","settings":"Settings"},"tagline":"Matts Android TV client"},"features":{"showNavigationVersion":true},"presentation":{"navigationRailExpandedWidthDp":184,"navigationContentShiftDp":112}}`
document, err := loadRemoteConfig(raw)
if err != nil {
t.Fatal(err)
}
if !document.ContinueWatching.Enabled || document.Search.MaxResults != 50 {
t.Fatalf("new fields did not receive defaults: %+v", document)
}
}
func TestRemoteConfigRejectsMalformedAndUnsafeDocuments(t *testing.T) {