0.3.01
This commit is contained in:
+214
-14
@@ -8,6 +8,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/config"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
@@ -42,6 +43,41 @@ type featureDefinition struct {
|
||||
Recovery string `json:"recovery"`
|
||||
}
|
||||
|
||||
// configurationDefinition is the shared catalogue for booleans and behavioural
|
||||
// values. Scope is part of the contract so new features do not grow bespoke settings.
|
||||
type configurationDefinition struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"type"`
|
||||
Scopes []string `json:"scopes"`
|
||||
Default any `json:"default"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
Min *int `json:"min,omitempty"`
|
||||
Max *int `json:"max,omitempty"`
|
||||
}
|
||||
|
||||
var configurationCatalogue = []configurationDefinition{
|
||||
{Key: "forYou.enabled", Name: "For You", Description: "Show personalised recommendations on Home.", Type: "boolean", Scopes: []string{"global", "user", "device", "experimental"}, Default: true},
|
||||
{Key: "continueWatching.enabled", Name: "Continue Watching", Description: "Show the Continue Watching row.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
|
||||
{Key: "continueWatching.showNextUp", Name: "Continue Watching: Next Up", Description: "Include an unstarted next episode in Continue Watching.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
|
||||
{Key: "continueWatching.progressColour", Name: "Progress bar colour", Description: "Choose the progress bar treatment.", Type: "enum", Scopes: []string{"global", "user", "device"}, Default: "emby", Options: []string{"emby", "white"}},
|
||||
{Key: "ratings.enabled", Name: "Ratings", Description: "Show ratings throughout the catalogue.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
|
||||
{Key: "genres.enabled", Name: "Genres", Description: "Show genre browsing controls.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
|
||||
{Key: "trailers.enabled", Name: "Trailers", Description: "Offer trailers where available.", Type: "boolean", Scopes: []string{"global", "device", "experimental"}, Default: true},
|
||||
{Key: "requests.enabled", Name: "Requests", Description: "Allow title requests from viewers.", Type: "boolean", Scopes: []string{"global", "user"}, Default: true},
|
||||
{Key: "hero.enabled", Name: "Hero", Description: "Show the Home hero presentation.", Type: "boolean", Scopes: []string{"global", "device", "experimental"}, Default: true},
|
||||
{Key: "home.heroRefreshSeconds", Name: "Hero refresh interval", Description: "Seconds between hero refreshes.", Type: "integer", Scopes: []string{"global", "device"}, Default: 60, Min: intPtr(15), Max: intPtr(3600)},
|
||||
{Key: "home.maxItemsPerRow", Name: "Maximum items per row", Description: "Maximum number of cards shown in a row.", Type: "integer", Scopes: []string{"global", "device"}, Default: 20, Min: intPtr(1), Max: intPtr(100)},
|
||||
{Key: "home.sectionDefinitions", Name: "Home page sections", Description: "JSON section definitions controlling Home composition and order.", Type: "json", Scopes: []string{"global", "device"}, Default: config.DefaultRemoteConfig().Home.SectionDefinitions},
|
||||
{Key: "movies.sectionDefinitions", Name: "Movies page sections", Description: "JSON section definitions controlling Movies composition and order.", Type: "json", Scopes: []string{"global", "device"}, Default: config.DefaultRemoteConfig().Movies.SectionDefinitions},
|
||||
{Key: "tv.sectionDefinitions", Name: "TV page sections", Description: "JSON section definitions controlling TV composition and order.", Type: "json", Scopes: []string{"global", "device"}, Default: config.DefaultRemoteConfig().TV.SectionDefinitions},
|
||||
{Key: "branding.markUrl", Name: "Memby mark URL", Description: "HTTPS image used for the TV rail mark; the bundled mark remains the fallback.", Type: "string", Scopes: []string{"global", "device"}, Default: ""},
|
||||
{Key: "branding.markVersion", Name: "Memby mark version", Description: "Cache-busting version for the configured mark.", Type: "string", Scopes: []string{"global", "device"}, Default: ""},
|
||||
}
|
||||
|
||||
func intPtr(v int) *int { return &v }
|
||||
|
||||
var featureCatalogue = []featureDefinition{
|
||||
{
|
||||
Key: featureSonarrPreroll, Name: "Sonarr upcoming preroll", Area: "Playback",
|
||||
@@ -191,12 +227,66 @@ type evaluatedFeature struct {
|
||||
}
|
||||
|
||||
type featureResponse struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Revision int64 `json:"revision"`
|
||||
SafeMode bool `json:"safeMode"`
|
||||
UpdatedAt any `json:"updatedAt,omitempty"`
|
||||
CanRollback bool `json:"canRollback"`
|
||||
Features []evaluatedFeature `json:"features"`
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Revision int64 `json:"revision"`
|
||||
SafeMode bool `json:"safeMode"`
|
||||
UpdatedAt any `json:"updatedAt,omitempty"`
|
||||
CanRollback bool `json:"canRollback"`
|
||||
Features []evaluatedFeature `json:"features"`
|
||||
Configuration []evaluatedConfiguration `json:"configuration"`
|
||||
}
|
||||
|
||||
type evaluatedConfiguration struct {
|
||||
configurationDefinition
|
||||
Value any `json:"value"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
func configurationDefinitionFor(key string) (configurationDefinition, bool) {
|
||||
for _, definition := range configurationCatalogue {
|
||||
if definition.Key == key {
|
||||
return definition, true
|
||||
}
|
||||
}
|
||||
return configurationDefinition{}, false
|
||||
}
|
||||
|
||||
func configurationValue(policy store.FeaturePolicy, definition configurationDefinition, sessions ...store.Session) (any, string) {
|
||||
if len(sessions) > 0 {
|
||||
session := sessions[0]
|
||||
if values, ok := policy.DeviceValues[session.DeviceID]; ok {
|
||||
if raw, ok := values[definition.Key]; ok {
|
||||
var value any
|
||||
if json.Unmarshal(raw, &value) == nil {
|
||||
return value, "device"
|
||||
}
|
||||
}
|
||||
}
|
||||
if values, ok := policy.UserValues[session.EmbyUserID]; ok {
|
||||
if raw, ok := values[definition.Key]; ok {
|
||||
var value any
|
||||
if json.Unmarshal(raw, &value) == nil {
|
||||
return value, "user"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if raw, ok := policy.Values[definition.Key]; ok {
|
||||
var value any
|
||||
if json.Unmarshal(raw, &value) == nil {
|
||||
return value, "global"
|
||||
}
|
||||
}
|
||||
return definition.Default, "default"
|
||||
}
|
||||
|
||||
func configurationPayload(policy store.FeaturePolicy, sessions ...store.Session) []evaluatedConfiguration {
|
||||
result := make([]evaluatedConfiguration, 0, len(configurationCatalogue))
|
||||
for _, definition := range configurationCatalogue {
|
||||
value, source := configurationValue(policy, definition, sessions...)
|
||||
result = append(result, evaluatedConfiguration{configurationDefinition: definition, Value: value, Source: source})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func knownFeature(key string) (featureDefinition, bool) {
|
||||
@@ -213,6 +303,20 @@ func evaluateFeature(policy store.FeaturePolicy, definition featureDefinition, p
|
||||
if override, ok := policy.Overrides[definition.Key]; ok {
|
||||
enabled, source = override, "override"
|
||||
}
|
||||
// Legacy server call-sites continue to use their stable snake_case keys while
|
||||
// operators edit the canonical typed catalogue.
|
||||
canonical := map[string]string{
|
||||
featureContinueWatching: "continueWatching.enabled",
|
||||
featureGenreBrowser: "genres.enabled",
|
||||
}[definition.Key]
|
||||
if canonical != "" {
|
||||
if raw, ok := policy.Values[canonical]; ok {
|
||||
var value bool
|
||||
if json.Unmarshal(raw, &value) == nil {
|
||||
enabled, source = value, "configuration"
|
||||
}
|
||||
}
|
||||
}
|
||||
if policy.SafeMode {
|
||||
enabled, source = false, "safe_mode"
|
||||
}
|
||||
@@ -249,6 +353,10 @@ func (s *Server) featureEnabled(ctx context.Context, key string) bool {
|
||||
}
|
||||
|
||||
func featurePayload(policy store.FeaturePolicy, protocol int, capabilities ...[]string) featureResponse {
|
||||
return featurePayloadForSession(policy, protocol, nil, capabilities...)
|
||||
}
|
||||
|
||||
func featurePayloadForSession(policy store.FeaturePolicy, protocol int, session *store.Session, capabilities ...[]string) featureResponse {
|
||||
features := make([]evaluatedFeature, 0, len(featureCatalogue))
|
||||
for _, definition := range featureCatalogue {
|
||||
evaluated := evaluateFeature(policy, definition, protocol)
|
||||
@@ -260,11 +368,17 @@ func featurePayload(policy store.FeaturePolicy, protocol int, capabilities ...[]
|
||||
}
|
||||
features = append(features, evaluated)
|
||||
}
|
||||
return featureResponse{
|
||||
response := featureResponse{
|
||||
SchemaVersion: featureSchemaVersion, Revision: policy.Revision,
|
||||
SafeMode: policy.SafeMode, UpdatedAt: policy.UpdatedAt,
|
||||
CanRollback: policy.Previous != nil, Features: features,
|
||||
}
|
||||
if session != nil {
|
||||
response.Configuration = configurationPayload(policy, *session)
|
||||
} else {
|
||||
response.Configuration = configurationPayload(policy)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func featureMap(policy store.FeaturePolicy, protocol int, capabilities []string) map[string]bool {
|
||||
@@ -275,16 +389,20 @@ func featureMap(policy store.FeaturePolicy, protocol int, capabilities []string)
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Server) handleFeatures(w http.ResponseWriter, r *http.Request, _ store.Session) {
|
||||
writeJSON(w, http.StatusOK, featurePayload(
|
||||
s.currentFeaturePolicy(r.Context()), clientProtocolNumber(r), clientCapabilities(r),
|
||||
func (s *Server) handleFeatures(w http.ResponseWriter, r *http.Request, session store.Session) {
|
||||
writeJSON(w, http.StatusOK, featurePayloadForSession(
|
||||
s.currentFeaturePolicy(r.Context()), clientProtocolNumber(r), &session, clientCapabilities(r),
|
||||
))
|
||||
}
|
||||
|
||||
type featurePolicyRequest struct {
|
||||
Action string `json:"action"`
|
||||
ExpectedRevision int64 `json:"expectedRevision"`
|
||||
Overrides map[string]bool `json:"overrides"`
|
||||
Action string `json:"action"`
|
||||
ExpectedRevision int64 `json:"expectedRevision"`
|
||||
Overrides map[string]bool `json:"overrides"`
|
||||
Values map[string]json.RawMessage `json:"values"`
|
||||
UserValues map[string]map[string]json.RawMessage `json:"userValues"`
|
||||
DeviceValues map[string]map[string]json.RawMessage `json:"deviceValues"`
|
||||
Experimental map[string]json.RawMessage `json:"experimental"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminFeaturePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -300,7 +418,7 @@ func (s *Server) handleAdminFeaturePolicy(w http.ResponseWriter, r *http.Request
|
||||
writeError(w, http.StatusConflict, "feature flags changed in another admin session; refresh before saving")
|
||||
return
|
||||
}
|
||||
next := store.FeaturePolicy{Overrides: map[string]bool{}, SafeMode: current.SafeMode}
|
||||
next := store.FeaturePolicy{Overrides: map[string]bool{}, Values: req.Values, UserValues: req.UserValues, DeviceValues: req.DeviceValues, Experimental: req.Experimental, SafeMode: current.SafeMode}
|
||||
switch strings.TrimSpace(req.Action) {
|
||||
case "save":
|
||||
for key, enabled := range req.Overrides {
|
||||
@@ -310,11 +428,17 @@ func (s *Server) handleAdminFeaturePolicy(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
next.Overrides[key] = enabled
|
||||
}
|
||||
if err := validateConfigurationValues(next); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
case "safe-mode":
|
||||
next.Overrides = current.Overrides
|
||||
next.Values, next.UserValues, next.DeviceValues, next.Experimental = current.Values, current.UserValues, current.DeviceValues, current.Experimental
|
||||
next.SafeMode = true
|
||||
case "leave-safe-mode":
|
||||
next.Overrides = current.Overrides
|
||||
next.Values, next.UserValues, next.DeviceValues, next.Experimental = current.Values, current.UserValues, current.DeviceValues, current.Experimental
|
||||
next.SafeMode = false
|
||||
case "reset":
|
||||
next.SafeMode = false
|
||||
@@ -324,6 +448,7 @@ func (s *Server) handleAdminFeaturePolicy(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
next.Overrides = current.Previous.Overrides
|
||||
next.Values, next.UserValues, next.DeviceValues, next.Experimental = current.Previous.Values, current.Previous.UserValues, current.Previous.DeviceValues, current.Previous.Experimental
|
||||
next.SafeMode = current.Previous.SafeMode
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "unknown feature policy action")
|
||||
@@ -345,6 +470,81 @@ func (s *Server) handleAdminFeaturePolicy(w http.ResponseWriter, r *http.Request
|
||||
writeJSON(w, http.StatusOK, featurePayload(stored, ProtocolVersion))
|
||||
}
|
||||
|
||||
func validateConfigurationValues(policy store.FeaturePolicy) error {
|
||||
for key, raw := range policy.Values {
|
||||
definition, ok := configurationDefinitionFor(key)
|
||||
if !ok {
|
||||
return errors.New("unknown configuration value: " + key)
|
||||
}
|
||||
if err := validateConfigurationValue(definition, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for scope, values := range map[string]map[string]json.RawMessage{"experimental": policy.Experimental} {
|
||||
for key, raw := range values {
|
||||
definition, ok := configurationDefinitionFor(key)
|
||||
if !ok {
|
||||
return errors.New("unknown configuration value: " + key)
|
||||
}
|
||||
if !slices.Contains(definition.Scopes, scope) {
|
||||
return errors.New("configuration value does not support scope " + scope + ": " + key)
|
||||
}
|
||||
if err := validateConfigurationValue(definition, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for scope, groups := range map[string]map[string]map[string]json.RawMessage{"user": policy.UserValues, "device": policy.DeviceValues} {
|
||||
for _, values := range groups {
|
||||
for key, raw := range values {
|
||||
definition, ok := configurationDefinitionFor(key)
|
||||
if !ok {
|
||||
return errors.New("unknown configuration value: " + key)
|
||||
}
|
||||
if !slices.Contains(definition.Scopes, scope) {
|
||||
return errors.New("configuration value does not support scope " + scope + ": " + key)
|
||||
}
|
||||
if err := validateConfigurationValue(definition, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateConfigurationValue(definition configurationDefinition, raw json.RawMessage) error {
|
||||
var value any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return errors.New("invalid configuration value: " + definition.Key)
|
||||
}
|
||||
switch definition.Type {
|
||||
case "boolean":
|
||||
if _, ok := value.(bool); !ok {
|
||||
return errors.New("configuration value must be boolean: " + definition.Key)
|
||||
}
|
||||
case "integer":
|
||||
n, ok := value.(float64)
|
||||
if !ok || n != float64(int(n)) || (definition.Min != nil && int(n) < *definition.Min) || (definition.Max != nil && int(n) > *definition.Max) {
|
||||
return errors.New("configuration value is outside its allowed range: " + definition.Key)
|
||||
}
|
||||
case "enum":
|
||||
text, ok := value.(string)
|
||||
if !ok || !slices.Contains(definition.Options, text) {
|
||||
return errors.New("configuration value is not an allowed option: " + definition.Key)
|
||||
}
|
||||
case "json":
|
||||
if _, ok := value.([]any); !ok {
|
||||
return errors.New("configuration value must be a JSON array: " + definition.Key)
|
||||
}
|
||||
case "string":
|
||||
if _, ok := value.(string); !ok {
|
||||
return errors.New("configuration value must be text: " + definition.Key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseCapabilities(raw string) []string {
|
||||
seen := map[string]bool{}
|
||||
values := []string{}
|
||||
|
||||
@@ -159,6 +159,7 @@ type heroCandidate struct {
|
||||
// when none did, which is a different thing from a score of zero.
|
||||
Rating float64
|
||||
Rated bool
|
||||
Source string
|
||||
}
|
||||
|
||||
// heroRecency decays linearly across the window.
|
||||
@@ -189,6 +190,16 @@ func heroScore(candidate heroCandidate, now time.Time) float64 {
|
||||
return score
|
||||
}
|
||||
|
||||
func heroSource(candidate heroCandidate) string {
|
||||
if candidate.Source != "" {
|
||||
return candidate.Source
|
||||
}
|
||||
if candidate.Kind == heroSeriesPremiere || candidate.Kind == heroSeasonPremiere {
|
||||
return "continue_world"
|
||||
}
|
||||
return "recommended_for_user"
|
||||
}
|
||||
|
||||
// rankHeroCandidates orders the hero and is the whole of the feature that can be reasoned
|
||||
// about without a network.
|
||||
//
|
||||
@@ -340,6 +351,22 @@ func heroLabel(candidate heroCandidate, now time.Time) string {
|
||||
// be empty, and is empty precisely when there is nothing true to say — a card with no
|
||||
// evidence behind it says nothing rather than inventing a reason.
|
||||
func heroReason(candidate heroCandidate, now time.Time, location *time.Location) string {
|
||||
if location == nil {
|
||||
location = time.UTC
|
||||
}
|
||||
local := now.In(location)
|
||||
if candidate.Source == "time_sensitive" {
|
||||
return "Tonight's pick"
|
||||
}
|
||||
if candidate.Source == "continue_world" && local.Hour() >= 20 {
|
||||
return "You normally watch an episode around now"
|
||||
}
|
||||
if candidate.Source == "favourite_genre" && local.Weekday() == time.Sunday && local.Hour() < 18 {
|
||||
return "Something easy for Sunday"
|
||||
}
|
||||
if candidate.Source == "trending" {
|
||||
return "Trending amongst Memby viewers"
|
||||
}
|
||||
acclaimed := candidate.Rated && candidate.Rating >= heroAcclaimedRating
|
||||
fresh := heroRecency(candidate.ReleasedAt, now) > 0
|
||||
switch {
|
||||
@@ -857,7 +884,7 @@ func (s *Server) pinnedHeroCandidates(ctx context.Context, ids []string) []heroC
|
||||
}
|
||||
byID[fact.ID] = heroCandidate{
|
||||
ID: fact.ID, Name: fact.Name, Kind: kind, Item: raw,
|
||||
ReleasedAt: fact.Premiere, Rating: rating, Rated: rated,
|
||||
ReleasedAt: fact.Premiere, Rating: rating, Rated: rated, Source: "admin_pinned",
|
||||
}
|
||||
}
|
||||
out := make([]heroCandidate, 0, len(ids))
|
||||
@@ -949,6 +976,20 @@ func heroMovieCandidates(rows []recommend.Row) ([]heroCandidate, map[string]hero
|
||||
seen[fact.ID] = true
|
||||
facts[fact.ID] = fact
|
||||
rating, rated := heroRatingOf(raw)
|
||||
source := "recommended_for_user"
|
||||
rowText := strings.ToLower(row.Kind + " " + row.ID)
|
||||
if strings.Contains(rowText, "favorite") {
|
||||
source = "favourite_genre"
|
||||
}
|
||||
if strings.Contains(rowText, "trending") {
|
||||
source = "trending"
|
||||
}
|
||||
if strings.Contains(rowText, "season") {
|
||||
source = "seasonal"
|
||||
}
|
||||
if strings.Contains(rowText, "latest") {
|
||||
source = "new_release"
|
||||
}
|
||||
candidates = append(candidates, heroCandidate{
|
||||
ID: fact.ID,
|
||||
Name: fact.Name,
|
||||
@@ -956,7 +997,7 @@ func heroMovieCandidates(rows []recommend.Row) ([]heroCandidate, map[string]hero
|
||||
Item: raw,
|
||||
ReleasedAt: fact.Premiere,
|
||||
Rating: rating,
|
||||
Rated: rated,
|
||||
Rated: rated, Source: source,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1139,7 +1180,7 @@ func (s *Server) heroPremiereCandidates(ctx context.Context, now time.Time) []he
|
||||
Item: raw,
|
||||
ReleasedAt: premiere.AiredAt,
|
||||
Rating: rating,
|
||||
Rated: rated,
|
||||
Rated: rated, Source: "continue_world",
|
||||
})
|
||||
}
|
||||
return candidates
|
||||
|
||||
@@ -15,9 +15,18 @@ import (
|
||||
)
|
||||
|
||||
type activeHeroResponse struct {
|
||||
Placement string `json:"placement"`
|
||||
Source string `json:"source"`
|
||||
Rows []recommend.Row `json:"rows"`
|
||||
Placement string `json:"placement"`
|
||||
Source string `json:"source"`
|
||||
Candidates []activeHeroCandidate `json:"candidates,omitempty"`
|
||||
Rows []recommend.Row `json:"rows"`
|
||||
}
|
||||
|
||||
type activeHeroCandidate struct {
|
||||
ItemID string `json:"itemId"`
|
||||
Source string `json:"source"`
|
||||
Score float64 `json:"score"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
|
||||
// handleActiveHero gives every section the same server-owned resolver as Home. The client
|
||||
@@ -83,6 +92,9 @@ func (s *Server) resolveActiveHero(ctx context.Context, sess store.Session, plac
|
||||
location := s.heroLocation()
|
||||
scheduledIDs := activeHeroScheduleIDs(policy.Schedules, placement, sess.EmbyUserID, now, location)
|
||||
scheduled := filterHeroPlacement(s.pinnedHeroCandidates(ctx, scheduledIDs), placement)
|
||||
for index := range scheduled {
|
||||
scheduled[index].Source = "time_sensitive"
|
||||
}
|
||||
|
||||
var candidates []heroCandidate
|
||||
if placement == store.HeroPlacementTVShows {
|
||||
@@ -109,9 +121,13 @@ func (s *Server) resolveActiveHero(ctx context.Context, sess store.Session, plac
|
||||
return response, nil
|
||||
}
|
||||
items := make([]json.RawMessage, 0, len(ranked))
|
||||
metadata := make([]activeHeroCandidate, 0, len(ranked))
|
||||
for index, candidate := range ranked {
|
||||
items = append(items, injectHeroFields(candidate.Item, heroLabel(candidate, now), heroReasonForPosition(candidate, index, placementPolicy.PrimeSubtitle, now, location)))
|
||||
reason := heroReasonForPosition(candidate, index, placementPolicy.PrimeSubtitle, now, location)
|
||||
items = append(items, injectHeroFields(candidate.Item, heroLabel(candidate, now), reason))
|
||||
metadata = append(metadata, activeHeroCandidate{ItemID: candidate.ID, Source: heroSource(candidate), Score: heroScore(candidate, now), Reason: reason, Pinned: candidate.Source == "admin_pinned"})
|
||||
}
|
||||
response.Candidates = metadata
|
||||
response.Rows = append(response.Rows, recommend.Row{ID: "hero-" + placement, Title: "Featured", Kind: heroRowKind, Items: items})
|
||||
s.loggerFor(ctx).Debug("section hero resolved", "placement", placement, "source", source, "items", len(items))
|
||||
return response, nil
|
||||
|
||||
@@ -49,7 +49,8 @@ type homeResponse struct {
|
||||
// Rows is the home screen as the server wants it drawn: order, titles and kinds all
|
||||
// decided here, so a new row (a recommendation strip, a seasonal collection) ships
|
||||
// without touching the TV app. The client renders whatever arrives.
|
||||
Rows []recommend.Row `json:"rows"`
|
||||
Rows []recommend.Row `json:"rows"`
|
||||
RowRelevance []homeRowRelevance `json:"rowRelevance,omitempty"`
|
||||
|
||||
// The fixed rows are also sent flat. They are what the client caches for an
|
||||
// instant cold start, and what the direct-to-Emby path still produces.
|
||||
@@ -67,6 +68,12 @@ type homeResponse struct {
|
||||
// answer to another running a different build. The client asks /v1/update instead.
|
||||
}
|
||||
|
||||
type homeRowRelevance struct {
|
||||
RowID string `json:"rowId"`
|
||||
Score float64 `json:"score"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// handleHome answers the entire launcher in one round trip.
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
ctx := r.Context()
|
||||
@@ -369,7 +376,9 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
out.Rows = append(rows, recommendations...)
|
||||
out.Rows = s.filterRecommendationPermissions(ctx, sess, out.Rows)
|
||||
if rowStatsOK {
|
||||
out.Rows = personalizeHomeRows(out.Rows, rowStats)
|
||||
out.Rows, out.RowRelevance = rankHomeRows(out.Rows, rowStats, now)
|
||||
} else {
|
||||
out.Rows, out.RowRelevance = rankHomeRows(out.Rows, nil, now)
|
||||
}
|
||||
assemble()
|
||||
rank := timing.Start(ctx, timing.StageRank)
|
||||
@@ -528,6 +537,75 @@ func personalizeHomeRows(rows []recommend.Row, stats []store.RowStat) []recommen
|
||||
return out
|
||||
}
|
||||
|
||||
// rankHomeRows is the server-side contextual row engine. It deliberately keeps
|
||||
// Continue Watching as the household's reliable first landmark, then scores discovery
|
||||
// shelves using engagement, time context and the row's own data source. New row types can
|
||||
// participate without a client release because only the row metadata is interpreted here.
|
||||
func rankHomeRows(rows []recommend.Row, stats []store.RowStat, now time.Time) ([]recommend.Row, []homeRowRelevance) {
|
||||
byID := make(map[string]store.RowStat, len(stats))
|
||||
for _, stat := range stats {
|
||||
byID[stat.RowID] = stat
|
||||
}
|
||||
type scored struct {
|
||||
row recommend.Row
|
||||
score float64
|
||||
reason string
|
||||
position int
|
||||
}
|
||||
ranked := make([]scored, 0, len(rows))
|
||||
for position, row := range rows {
|
||||
score := 1.0
|
||||
reason := ""
|
||||
id := strings.ToLower(row.ID + " " + row.Kind + " " + row.Title)
|
||||
if stat, ok := byID[row.ID]; ok && stat.Impressions >= 3 {
|
||||
engagement := float64(stat.Selects)*6 + float64(stat.Focuses) + float64(stat.DwellMs)/30_000
|
||||
score += (engagement + 2) / (float64(stat.Impressions) + 2)
|
||||
}
|
||||
if strings.Contains(id, "continue") {
|
||||
score += 1000
|
||||
reason = "Continue Watching"
|
||||
}
|
||||
if now.Weekday() == time.Friday && now.Hour() >= 18 && (strings.Contains(id, "movie") || strings.Contains(id, "film")) {
|
||||
score += 8
|
||||
reason = "Friday night films"
|
||||
}
|
||||
if now.Weekday() == time.Sunday && now.Hour() < 18 && (strings.Contains(id, "easy") || strings.Contains(id, "comfort")) {
|
||||
score += 7
|
||||
reason = "Something easy for Sunday"
|
||||
}
|
||||
if now.Hour() >= 20 && strings.Contains(id, "episode") {
|
||||
score += 6
|
||||
reason = "One episode before bed"
|
||||
}
|
||||
if strings.Contains(id, "for-you") || strings.Contains(id, "recommend") {
|
||||
score += 3
|
||||
if reason == "" {
|
||||
reason = "New for you"
|
||||
}
|
||||
}
|
||||
if strings.Contains(id, "latest") || strings.Contains(id, "recent") {
|
||||
score += 2
|
||||
if reason == "" {
|
||||
reason = "Recently added"
|
||||
}
|
||||
}
|
||||
ranked = append(ranked, scored{row: row, score: score, reason: reason, position: position})
|
||||
}
|
||||
sort.SliceStable(ranked, func(i, j int) bool {
|
||||
if ranked[i].score != ranked[j].score {
|
||||
return ranked[i].score > ranked[j].score
|
||||
}
|
||||
return ranked[i].position < ranked[j].position
|
||||
})
|
||||
out := make([]recommend.Row, 0, len(ranked))
|
||||
relevance := make([]homeRowRelevance, 0, len(ranked))
|
||||
for _, item := range ranked {
|
||||
out = append(out, item.row)
|
||||
relevance = append(relevance, homeRowRelevance{RowID: item.row.ID, Score: item.score, Reason: item.reason})
|
||||
}
|
||||
return out, relevance
|
||||
}
|
||||
|
||||
// preparedHomeForYouRows promotes the specific abandoned-show shelf as well as the
|
||||
// time-aware general picks. Other For You shelves remain in the dedicated destination.
|
||||
func preparedHomeForYouRows(
|
||||
|
||||
@@ -6,13 +6,29 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/config"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// handleRemoteConfig serves one app-scoped, immutable-at-runtime document. It is public
|
||||
// handleRemoteConfig serves one app-scoped, versioned document. It is public
|
||||
// for the same reason the update verdict is public: a fresh install and a signed-out TV
|
||||
// must be able to warm the next launch. No viewer or session data belongs in this answer.
|
||||
func (s *Server) handleRemoteConfig(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := json.Marshal(s.cfg.RemoteConfig)
|
||||
document := s.cfg.RemoteConfig
|
||||
clientSchema := parseClientSchema(r)
|
||||
components := parseCapabilities(r.Header.Get("X-Memby-Components"))
|
||||
if clientSchema > 0 && clientSchema < document.SchemaVersion {
|
||||
document.SchemaVersion = clientSchema
|
||||
}
|
||||
policy := s.currentFeaturePolicy(r.Context())
|
||||
applyGlobalConfiguration(&document, policy)
|
||||
negotiateRemoteSections(&document, components)
|
||||
if s.log != nil {
|
||||
s.loggerFor(r.Context()).Info("remote configuration delivered", "client_version", r.Header.Get("X-Memby-Version"), "schema", clientSchema, "delivered_schema", document.SchemaVersion, "components", components, "config_version", document.ConfigVersion)
|
||||
}
|
||||
body, err := json.Marshal(document)
|
||||
if err != nil {
|
||||
// Config is validated during start-up, so this is defensive rather than an expected
|
||||
// operational failure.
|
||||
@@ -23,7 +39,9 @@ func (s *Server) handleRemoteConfig(w http.ResponseWriter, r *http.Request) {
|
||||
etag := `"rc-` + hex.EncodeToString(digest[:12]) + `"`
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("ETag", etag)
|
||||
w.Header().Set("X-Memby-Config-Version", configVersionHeader(s.cfg.RemoteConfig.ConfigVersion))
|
||||
w.Header().Set("X-Memby-Config-Version", configVersionHeader(document.ConfigVersion))
|
||||
w.Header().Set("X-Memby-Delivered-Schema", strconv.Itoa(document.SchemaVersion))
|
||||
w.Header().Set("X-Memby-Delivered-Components", strings.Join(components, ","))
|
||||
if r.Header.Get("If-None-Match") == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
@@ -31,6 +49,113 @@ func (s *Server) handleRemoteConfig(w http.ResponseWriter, r *http.Request) {
|
||||
writeRaw(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
func parseClientSchema(r *http.Request) int {
|
||||
value, _ := strconv.Atoi(strings.TrimSpace(r.Header.Get("X-Memby-Config-Schema")))
|
||||
return value
|
||||
}
|
||||
|
||||
func negotiateRemoteSections(document *config.RemoteConfig, components []string) {
|
||||
if len(components) == 0 {
|
||||
return
|
||||
}
|
||||
supported := make(map[string]bool, len(components))
|
||||
for _, component := range components {
|
||||
supported[component] = true
|
||||
}
|
||||
for _, sections := range []*[]config.RemoteSectionDefinition{&document.Home.SectionDefinitions, &document.Movies.SectionDefinitions, &document.TV.SectionDefinitions} {
|
||||
for index := range *sections {
|
||||
section := &(*sections)[index]
|
||||
if supported[section.Component] {
|
||||
continue
|
||||
}
|
||||
if section.Component == "landscapeMediaCardV2" && supported["mediaRow"] {
|
||||
section.Component = "mediaRow"
|
||||
continue
|
||||
}
|
||||
section.Enabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyGlobalConfiguration is intentionally small and typed. The public startup
|
||||
// document has no viewer identity, so user/device assignments are resolved by the
|
||||
// authenticated feature endpoint rather than leaking into this cacheable response.
|
||||
func applyGlobalConfiguration(document *config.RemoteConfig, policy store.FeaturePolicy) {
|
||||
for key, raw := range policy.Values {
|
||||
var value any
|
||||
if json.Unmarshal(raw, &value) != nil {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "forYou.enabled":
|
||||
if v, ok := value.(bool); ok {
|
||||
document.ForYou.Enabled = v
|
||||
document.Features.Flags["forYou.enabled"] = v
|
||||
document.Features.Flags["for_you"] = v
|
||||
}
|
||||
case "continueWatching.enabled":
|
||||
if v, ok := value.(bool); ok {
|
||||
document.ContinueWatching.Enabled = v
|
||||
document.Features.Flags["continueWatching.enabled"] = v
|
||||
document.Features.Flags["continue_watching"] = v
|
||||
}
|
||||
case "continueWatching.showNextUp":
|
||||
if v, ok := value.(bool); ok {
|
||||
document.ContinueWatching.IncludeNextUp = v
|
||||
}
|
||||
case "continueWatching.progressColour":
|
||||
if v, ok := value.(string); ok {
|
||||
document.ContinueWatching.ProgressColour = v
|
||||
}
|
||||
case "home.heroRefreshSeconds":
|
||||
if v, ok := value.(float64); ok {
|
||||
document.Home.HeroRefreshSeconds = int(v)
|
||||
}
|
||||
case "home.maxItemsPerRow":
|
||||
if v, ok := value.(float64); ok {
|
||||
document.Home.MaxItemsPerRow = int(v)
|
||||
}
|
||||
case "home.sectionDefinitions":
|
||||
if err := json.Unmarshal(raw, &document.Home.SectionDefinitions); err == nil {
|
||||
document.Home.Sections = sectionIDs(document.Home.SectionDefinitions)
|
||||
}
|
||||
case "movies.sectionDefinitions":
|
||||
if err := json.Unmarshal(raw, &document.Movies.SectionDefinitions); err == nil {
|
||||
document.Movies.Sections = sectionIDs(document.Movies.SectionDefinitions)
|
||||
}
|
||||
case "tv.sectionDefinitions":
|
||||
if err := json.Unmarshal(raw, &document.TV.SectionDefinitions); err == nil {
|
||||
document.TV.Sections = sectionIDs(document.TV.SectionDefinitions)
|
||||
}
|
||||
case "hero.enabled":
|
||||
if v, ok := value.(bool); ok {
|
||||
document.Features.Flags["hero.enabled"] = v
|
||||
}
|
||||
case "branding.markUrl":
|
||||
if v, ok := value.(string); ok {
|
||||
document.Branding.MarkURL = v
|
||||
}
|
||||
case "branding.markVersion":
|
||||
if v, ok := value.(string); ok {
|
||||
document.Branding.MarkVersion = v
|
||||
}
|
||||
}
|
||||
if v, ok := value.(bool); ok {
|
||||
document.Features.Flags[key] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sectionIDs(definitions []config.RemoteSectionDefinition) []string {
|
||||
ids := make([]string, 0, len(definitions))
|
||||
for _, definition := range definitions {
|
||||
if definition.Enabled {
|
||||
ids = append(ids, definition.ID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func configVersionHeader(version int64) string {
|
||||
return strconv.FormatInt(version, 10)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: "Matt’s 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":"Matt’s 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) {
|
||||
|
||||
@@ -400,30 +400,52 @@ func (s *Store) SetMDBListSettings(ctx context.Context, settings MDBListSettings
|
||||
const FeaturePolicyKey = "feature_policy"
|
||||
|
||||
type FeaturePolicySnapshot struct {
|
||||
Overrides map[string]bool `json:"overrides"`
|
||||
SafeMode bool `json:"safeMode"`
|
||||
Revision int64 `json:"revision"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Overrides map[string]bool `json:"overrides"`
|
||||
Values map[string]json.RawMessage `json:"values,omitempty"`
|
||||
UserValues map[string]map[string]json.RawMessage `json:"userValues,omitempty"`
|
||||
DeviceValues map[string]map[string]json.RawMessage `json:"deviceValues,omitempty"`
|
||||
Experimental map[string]json.RawMessage `json:"experimental,omitempty"`
|
||||
SafeMode bool `json:"safeMode"`
|
||||
Revision int64 `json:"revision"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type FeaturePolicy struct {
|
||||
Overrides map[string]bool `json:"overrides"`
|
||||
SafeMode bool `json:"safeMode"`
|
||||
Revision int64 `json:"revision"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Previous *FeaturePolicySnapshot `json:"previous,omitempty"`
|
||||
Overrides map[string]bool `json:"overrides"`
|
||||
// Values is the central typed configuration store. Raw JSON keeps the store
|
||||
// forward-compatible while the API validates each catalogue entry's type.
|
||||
Values map[string]json.RawMessage `json:"values,omitempty"`
|
||||
UserValues map[string]map[string]json.RawMessage `json:"userValues,omitempty"`
|
||||
DeviceValues map[string]map[string]json.RawMessage `json:"deviceValues,omitempty"`
|
||||
Experimental map[string]json.RawMessage `json:"experimental,omitempty"`
|
||||
SafeMode bool `json:"safeMode"`
|
||||
Revision int64 `json:"revision"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Previous *FeaturePolicySnapshot `json:"previous,omitempty"`
|
||||
}
|
||||
|
||||
var ErrFeaturePolicyConflict = errors.New("store: feature policy revision conflict")
|
||||
|
||||
func DefaultFeaturePolicy() FeaturePolicy {
|
||||
return FeaturePolicy{Overrides: map[string]bool{}}
|
||||
return FeaturePolicy{Overrides: map[string]bool{}, Values: map[string]json.RawMessage{}, UserValues: map[string]map[string]json.RawMessage{}, DeviceValues: map[string]map[string]json.RawMessage{}, Experimental: map[string]json.RawMessage{}}
|
||||
}
|
||||
|
||||
func normalizeFeaturePolicy(policy FeaturePolicy) FeaturePolicy {
|
||||
if policy.Overrides == nil {
|
||||
policy.Overrides = map[string]bool{}
|
||||
}
|
||||
if policy.Values == nil {
|
||||
policy.Values = map[string]json.RawMessage{}
|
||||
}
|
||||
if policy.UserValues == nil {
|
||||
policy.UserValues = map[string]map[string]json.RawMessage{}
|
||||
}
|
||||
if policy.DeviceValues == nil {
|
||||
policy.DeviceValues = map[string]map[string]json.RawMessage{}
|
||||
}
|
||||
if policy.Experimental == nil {
|
||||
policy.Experimental = map[string]json.RawMessage{}
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
@@ -475,6 +497,7 @@ func (s *Store) SetFeaturePolicy(
|
||||
next.UpdatedAt = time.Now().UTC()
|
||||
next.Previous = &FeaturePolicySnapshot{
|
||||
Overrides: current.Overrides, SafeMode: current.SafeMode,
|
||||
Values: current.Values, UserValues: current.UserValues, DeviceValues: current.DeviceValues, Experimental: current.Experimental,
|
||||
Revision: current.Revision, UpdatedAt: current.UpdatedAt,
|
||||
}
|
||||
raw, err := json.Marshal(next)
|
||||
|
||||
Reference in New Issue
Block a user