2026-08-02 22:10:19 +12:00
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"slices"
"strings"
2026-08-22 12:38:26 +12:00
"github.com/ponzischeme89/memby/server/internal/config"
2026-08-02 22:10:19 +12:00
"github.com/ponzischeme89/memby/server/internal/store"
)
const (
featureSchemaVersion = 1
featureSonarrPreroll = "sonarr_preroll"
featureAutomaticMyShows = "automatic_my_shows"
featureMyShowsNotification = "my_shows_notifications"
featureHEVCDirectPlay = "hevc_direct_play"
2026-08-06 22:33:56 +12:00
featureInstallPermission = "install_permission_prompt"
featureSubtitleDownload = "subtitle_download"
2026-08-07 10:44:17 +12:00
featureTrickplay = "trickplay"
featureSkipIntro = "skip_intro"
2026-08-09 08:25:50 +12:00
featureEndCredits = "end_credits"
2026-08-26 21:31:05 +12:00
featureNextEpisodeAiring = "next_episode_airing"
2026-08-09 08:25:50 +12:00
featureSeasonalThemes = "seasonal_themes"
featureSeasonalDecorations = "seasonal_decorations"
2026-08-09 16:04:40 +12:00
featureGenreBrowser = "genre_browser"
2026-08-11 12:08:51 +12:00
featureTVCalendar = "tv_calendar"
2026-08-21 09:54:44 +12:00
featureContinueWatching = "continue_watching"
2026-08-18 08:41:48 +12:00
featureWatchTimeDigest = "watch_time_digest"
2026-08-20 15:06:00 +12:00
featureViewers = "viewers"
2026-08-02 22:10:19 +12:00
)
type featureDefinition struct {
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description"`
Area string `json:"area"`
DefaultEnabled bool `json:"defaultEnabled"`
MinimumProtocol int `json:"minimumProtocol"`
Capability string `json:"capability"`
Recovery string `json:"recovery"`
}
2026-08-22 12:38:26 +12:00
// 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" }},
2026-08-24 22:56:46 +12:00
{ Key : "detailExperience" , Name : "Detail page experience" , Description : "Which detail-page layout a viewer sees." , Type : "enum" , Scopes : [] string { "global" , "user" , "device" }, Default : "v1" , Options : [] string { "v1" , "v2" }},
2026-08-22 18:40:50 +12:00
{ Key : "presentation.fontFamily" , Name : "App font family" , Description : "Choose the bundled font used in Memby’ s typography trial areas." , Type : "enum" , Scopes : [] string { "global" }, Default : "system" , Options : [] string { "system" , "inter" }},
2026-08-22 12:38:26 +12:00
{ 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 }
2026-08-25 11:39:55 +12:00
// rowTypeDefinition is the vocabulary behind the "row type" picker in the admin console's
// visual row editor. An operator configuring Home, Movies or TV composition should never
// need to know that a Continue Watching row is a "mediaRow" reading "emby.resume" — they
// pick the type, and component/dataSource follow from it. It exists so the client's fixed,
// small vocabulary of renderable components (mediaRow, mediaGrid, genreBrowser) is described
// once, here, rather than the admin console guessing at it independently and drifting from
// what a television actually understands.
type rowTypeDefinition struct {
2026-08-28 23:00:02 +12:00
Type string `json:"type"`
Label string `json:"label"`
Description string `json:"description"`
Component string `json:"component"`
DataSource string `json:"dataSource"`
2026-08-25 11:39:55 +12:00
// Pages lists which of Home/Movies/TV this type may be placed on. A row whose type
// is not valid for the page it is being saved to is exactly the "unsupported row/page
// combination" the visual editor exists to make impossible.
Pages [] string `json:"pages"`
// RequiresMatchingDestination is true for a type whose row only makes sense pointing at
// the page it lives on (Genres/Library on Movies must browse Movies, never TV).
RequiresMatchingDestination bool `json:"requiresMatchingDestination"`
// Custom withholds component/dataSource entirely: the operator states them directly,
// which is the escape hatch for a row this catalogue has no opinion about yet.
Custom bool `json:"custom"`
}
var rowTypeCatalogue = [] rowTypeDefinition {
{ Type : "continueWatching" , Label : "Continue Watching" , Description : "Resumable films and unwatched next episodes, merged and ordered by recency." , Component : "mediaRow" , DataSource : "emby.resume" , Pages : [] string { "home" }},
{ Type : "forYou" , Label : "For You" , Description : "Personalised recommendations built from this household's viewing history." , Component : "mediaRow" , DataSource : "gateway.recommendations" , Pages : [] string { "home" }},
{ Type : "favorites" , Label : "Favourites" , Description : "Titles marked as favourites." , Component : "mediaRow" , DataSource : "emby.favourites" , Pages : [] string { "home" }},
{ Type : "latest" , Label : "Recently Added" , Description : "The newest titles added to the library." , Component : "mediaRow" , DataSource : "emby.latest" , Pages : [] string { "home" }},
{ Type : "genres" , Label : "Genre Browser" , Description : "A full-width row of genre categories for browsing by taste." , Component : "genreBrowser" , DataSource : "emby.genres" , Pages : [] string { "movies" , "tv" }, RequiresMatchingDestination : true },
{ Type : "library" , Label : "Library Grid" , Description : "A paged grid of the whole catalogue for this page." , Component : "mediaGrid" , DataSource : "emby.library" , Pages : [] string { "movies" , "tv" }, RequiresMatchingDestination : true },
{ Type : "custom" , Label : "Custom" , Description : "Set the data source and component directly. For development and configurations this catalogue does not yet describe." , Pages : [] string { "home" , "movies" , "tv" }, Custom : true },
}
func rowTypeDefinitionFor ( rowType string ) ( rowTypeDefinition , bool ) {
for _ , definition := range rowTypeCatalogue {
if definition . Type == rowType {
return definition , true
}
}
return rowTypeDefinition {}, false
}
// sectionDefinitionPageKeys maps a configuration key to the page it composes, which is what
// lets validateSectionDefinitions refuse a row type placed on a page it does not support.
var sectionDefinitionPageKeys = map [ string ] string {
"home.sectionDefinitions" : "home" ,
"movies.sectionDefinitions" : "movies" ,
"tv.sectionDefinitions" : "tv" ,
}
// validateSectionDefinitions is the structural half of what the visual row editor promises:
// invalid configurations are impossible to save, not merely discouraged. It runs beside the
// generic "json" type check in validateConfigurationValue, which only confirms the value is
// an array at all.
func validateSectionDefinitions ( key string , raw json . RawMessage ) error {
page , ok := sectionDefinitionPageKeys [ key ]
if ! ok {
return nil
}
var definitions [] config . RemoteSectionDefinition
if err := json . Unmarshal ( raw , & definitions ); err != nil {
return errors . New ( key + ": invalid section definitions" )
}
if len ( definitions ) > 32 {
return errors . New ( key + ": too many rows" )
}
seenIDs := make ( map [ string ] bool , len ( definitions ))
seenPositions := make ( map [ int ] bool , len ( definitions ))
for _ , definition := range definitions {
id := strings . TrimSpace ( definition . ID )
if id == "" {
return errors . New ( key + ": every row needs an id" )
}
if seenIDs [ id ] {
return errors . New ( key + ": duplicate row id " + id )
}
seenIDs [ id ] = true
if strings . TrimSpace ( definition . Title ) == "" {
return errors . New ( key + ": row " + id + " needs a title" )
}
if definition . Position < 0 {
return errors . New ( key + ": row " + id + " has an invalid position" )
}
if seenPositions [ definition . Position ] {
return errors . New ( key + ": duplicate row position for " + id )
}
seenPositions [ definition . Position ] = true
if definition . MaxItems < 0 || definition . MaxItems > 100 {
return errors . New ( key + ": row " + id + " has an invalid maximum item count" )
}
2026-08-28 23:00:02 +12:00
if definition . Layout != "" &&
definition . Layout != config . SectionLayoutPoster && definition . Layout != config . SectionLayoutThumb {
return errors . New ( key + ": row " + id + " has an invalid card layout" )
}
2026-08-25 11:39:55 +12:00
if strings . TrimSpace ( definition . Component ) == "" {
return errors . New ( key + ": row " + id + " needs a component" )
}
rowType , known := rowTypeDefinitionFor ( definition . Type )
if ! known || rowType . Custom {
continue
}
if ! slices . Contains ( rowType . Pages , page ) {
return errors . New ( key + ": " + rowType . Label + " rows are not supported on this page" )
}
if definition . Component != rowType . Component {
return errors . New ( key + ": row " + id + " has a component that does not match its row type" )
}
if definition . DataSource != rowType . DataSource {
return errors . New ( key + ": row " + id + " has a data source that does not match its row type" )
}
if rowType . RequiresMatchingDestination && definition . Destination != page {
return errors . New ( key + ": row " + id + " must target the " + page + " page" )
}
}
return nil
}
2026-08-02 22:10:19 +12:00
var featureCatalogue = [] featureDefinition {
{
Key : featureSonarrPreroll , Name : "Sonarr upcoming preroll" , Area : "Playback" ,
Description : "Show the fan-art calendar before a fresh episode starts." ,
DefaultEnabled : true , MinimumProtocol : 1 , Capability : "sonarr_preroll_v1" ,
Recovery : "Takes effect the next time a title is opened." ,
},
{
Key : featureAutomaticMyShows , Name : "Automatic My Shows" , Area : "My Shows" ,
Description : "Follow a continuing Sonarr show after half an episode is watched." ,
DefaultEnabled : true , MinimumProtocol : 1 , Capability : "auto_my_shows_v1" ,
Recovery : "Server-enforced; takes effect on the next playback report." ,
},
{
Key : featureMyShowsNotification , Name : "Automatic follow notification" , Area : "Notifications" ,
Description : "Notify a viewer when a continuing show is automatically followed." ,
DefaultEnabled : true , MinimumProtocol : 1 , Capability : "auto_my_shows_v1" ,
Recovery : "Server-enforced; disabling it never removes a saved show." ,
},
{
Key : featureHEVCDirectPlay , Name : "HEVC direct play" , Area : "Playback" ,
Description : "Allow capable TVs to direct-play H.265/HEVC instead of requesting H.264." ,
DefaultEnabled : true , MinimumProtocol : 1 , Capability : "video_hevc_decode" ,
Recovery : "Server-enforced; takes effect the next time playback starts or refreshes." ,
},
2026-08-06 22:33:56 +12:00
{
Key : featureSubtitleDownload , Name : "Download missing subtitles" , Area : "Playback" ,
Description : "Let a viewer fetch a subtitle through Bazarr from the player, for a " +
"title the library has none for." ,
DefaultEnabled : true , MinimumProtocol : 1 , Capability : "subtitle_download_v1" ,
Recovery : "Takes effect the next time playback starts; the option simply stops being offered." ,
},
2026-08-07 10:44:17 +12:00
{
Key : featureTrickplay , Name : "Seek preview thumbnails" , Area : "Playback" ,
Description : "Show the frame a skip will land on, from the preview images Emby " +
"generates. Turn it off to stop the gateway reading them." ,
DefaultEnabled : true , MinimumProtocol : 1 , Capability : "trickplay_v1" ,
Recovery : "Takes effect the next time playback starts; the preview simply stops appearing." ,
},
{
Key : featureSkipIntro , Name : "Skip the title sequence" , Area : "Playback" ,
Description : "Offer to jump past an episode's opening titles, from the intro " +
"markers Emby writes. Turn it off to stop the gateway reading them." ,
DefaultEnabled : true , MinimumProtocol : 1 , Capability : "skip_intro_v1" ,
Recovery : "Takes effect the next time playback starts; the button simply stops appearing." ,
},
2026-08-09 08:25:50 +12:00
{
Key : featureEndCredits , Name : "Speed through the credits" , Area : "Playback" ,
Description : "Shrink the picture and run the closing credits at double speed with " +
2026-08-15 21:11:43 +12:00
"the next episode beside them. Emby's own marker is preferred where it has one, " +
2026-08-17 07:34:23 +12:00
"and where it has none the position discovered from episodes viewers are about to watch " +
"in your library is used instead. It is read from the same chapter list as the " +
2026-08-15 21:11:43 +12:00
"title sequence, so turning this off saves no request unless that is off too." ,
2026-08-09 08:25:50 +12:00
DefaultEnabled : true , MinimumProtocol : 1 , Capability : "end_credits_v1" ,
Recovery : "Takes effect the next time playback starts; the credits simply play out full size." ,
},
2026-08-26 21:31:05 +12:00
{
Key : featureNextEpisodeAiring , Name : "Next episode airing notice" , Area : "Playback" ,
Description : "Show a quiet notice ahead of the Next Up banner naming when a " +
"continuing show's next episode airs, from Sonarr's schedule." ,
DefaultEnabled : true , MinimumProtocol : 1 , Capability : "next_episode_airing_v1" ,
Recovery : "Takes effect the next time playback starts; the notice simply stops appearing." ,
},
2026-08-09 08:25:50 +12:00
{
// The only switch there is for seasonal themes, and it is deliberately the
// operator's rather than the viewer's: a per-person opt-out is a thing somebody
// turns off in October and never reconsiders, which is the same as the feature not
// existing. Off here means every television falls back to its viewer's own choice
// on the next status poll.
Key : featureSeasonalThemes , Name : "Seasonal themes" , Area : "Presentation" ,
Description : "Put every television into the Halloween, Christmas or Easter palette " +
"for its dates. Viewers cannot decline one; turning this off is the only way to " +
"stop them." ,
DefaultEnabled : true , MinimumProtocol : 1 , Capability : "themes_v1" ,
Recovery : "Takes effect on the next status poll, within ten seconds on an open TV." ,
},
{
// A second switch rather than a consequence of the one above, because the palette
// and the animation have quite different costs. Snow drifting over the launcher is
// the only thing in the app that animates continuously while somebody is browsing,
// and these are weak boxes; an operator who finds it costs frames should be able to
// keep December looking like December without it.
Key : featureSeasonalDecorations , Name : "Seasonal decorations" , Area : "Presentation" ,
Description : "Drift snow, bats or blossom over the launcher while a seasonal theme " +
"is on. Turning it off keeps the seasonal colours and stops the animation." ,
DefaultEnabled : true , MinimumProtocol : 1 , Capability : "seasonal_decorations_v1" ,
Recovery : "Takes effect on the next status poll; the launcher simply stops drawing them." ,
},
2026-08-09 16:04:40 +12:00
{
Key : featureGenreBrowser , Name : "Genre browser" , Area : "Presentation" ,
Description : "Show the full genre browser at the top of Movies and TV Shows." ,
DefaultEnabled : false , MinimumProtocol : 1 , Capability : "genre_browser_v1" ,
Recovery : "Takes effect on the next status poll; the browser is hidden when off." ,
},
2026-08-11 12:08:51 +12:00
{
Key : featureTVCalendar , Name : "TV calendar" , Area : "Presentation" ,
Description : "Show the month-by-month Sonarr calendar on the navigation rail. " +
"Turning it off hides the destination and stops the gateway reading months." ,
DefaultEnabled : true , MinimumProtocol : 1 , Capability : "tv_calendar_v1" ,
Recovery : "Takes effect on the next status poll; the rail entry simply disappears." ,
},
2026-08-21 09:54:44 +12:00
{
Key : featureContinueWatching , Name : "Continue Watching" , Area : "Home" ,
Description : "Show resumable films and episodes on Home. Turning it off hides the " +
"row and stops the gateway loading its resume and Next Up feeds." ,
DefaultEnabled : true , MinimumProtocol : 1 ,
Recovery : "Takes effect on the next status poll, within ten seconds on an open TV." ,
},
2026-08-18 08:41:48 +12:00
{
// No capability, because nothing on the television has to understand this: the
// summary is an ordinary entry in My Alerts, which every build that has that page
// already renders. This switch is the household's — a viewer's own is the
// watch-time toggle on their account.
Key : featureWatchTimeDigest , Name : "Weekly watch-time summary" , Area : "Notifications" ,
Description : "Tell each viewer how long they watched this week and this month, on " +
"Sunday evening, with a summary of the month just gone once it ends. Read from " +
2026-08-19 06:57:59 +12:00
"Tracearr; a server running none never sends one." ,
2026-08-18 08:41:48 +12:00
DefaultEnabled : true , MinimumProtocol : 1 ,
Recovery : "Server-enforced; takes effect before the next summary is due." ,
},
2026-08-20 15:06:00 +12:00
{
// Default **off**, the stance the genre browser takes. This is the switch that
// decides where a household's watched state is written, and a feature that
// arrives already on is one every server running this build starts using before
// anybody has decided to — so it is opted into rather than out of.
//
// Switching it on or off never deletes a viewer or their history: the rows stay
// in Postgres and come back intact. Off, the gateway routes nobody's state
// anywhere but Emby, which is the state a household was in before the feature
// existed; on, a shadow viewer's watching goes to Memby and is picked up exactly
// where they left it.
Key : featureViewers , Name : "Viewers" , Area : "Accounts" ,
Description : "Let one Emby account hold several people, each with their own " +
"Continue Watching, watched history and favourites. Off by default; turning " +
"it off again returns every television to watching as the account itself, " +
"without losing what anybody has watched." ,
DefaultEnabled : false , MinimumProtocol : 1 , Capability : "viewers_v1" ,
Recovery : "Takes effect on the next request; nothing a viewer has watched is lost." ,
},
2026-08-06 22:33:56 +12:00
{
Key : featureInstallPermission , Name : "Ask TVs for install permission" , Area : "Setup" ,
Description : "Ask a signed-in TV that cannot install its own updates to grant the " +
"permission, so a mandatory update is not the first time it comes up." ,
DefaultEnabled : true , MinimumProtocol : 1 , Capability : "install_permission_v1" ,
Recovery : "Appears on the next status poll, and only on a TV missing the permission." ,
},
2026-08-02 22:10:19 +12:00
}
type evaluatedFeature struct {
featureDefinition
Enabled bool `json:"enabled"`
Source string `json:"source"`
Compatible bool `json:"compatible"`
}
type featureResponse struct {
2026-08-22 12:38:26 +12:00
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"`
2026-08-25 11:39:55 +12:00
// RowTypes is the static catalogue behind the console's visual row editor. It travels
// with every feature-policy response rather than a dedicated endpoint, because it is
// read alongside Configuration and never changes independently of a server release.
RowTypes [] rowTypeDefinition `json:"rowTypes"`
2026-08-22 12:38:26 +12:00
}
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"
}
2026-08-24 22:56:46 +12:00
// detailExperienceFor resolves the "detailExperience" configuration value for a session,
// validating the stored value rather than trusting its type: Values/UserValues/DeviceValues
// are opaque JSON, and a value that is not exactly "v1" or "v2" must never reach the client
// as something it has to guess how to handle.
func detailExperienceFor ( policy store . FeaturePolicy , sess store . Session ) string {
definition , ok := configurationDefinitionFor ( "detailExperience" )
if ! ok {
return "v1"
}
value , _ := configurationValue ( policy , definition , sess )
if text , ok := value .( string ); ok && ( text == "v1" || text == "v2" ) {
return text
}
return "v1"
}
2026-08-22 12:38:26 +12:00
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
2026-08-02 22:10:19 +12:00
}
func knownFeature ( key string ) ( featureDefinition , bool ) {
for _ , definition := range featureCatalogue {
if definition . Key == key {
return definition , true
}
}
return featureDefinition {}, false
}
func evaluateFeature ( policy store . FeaturePolicy , definition featureDefinition , protocol int ) evaluatedFeature {
enabled , source := definition . DefaultEnabled , "default"
if override , ok := policy . Overrides [ definition . Key ]; ok {
enabled , source = override , "override"
}
2026-08-22 12:38:26 +12:00
// 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"
}
}
}
2026-08-02 22:10:19 +12:00
if policy . SafeMode {
enabled , source = false , "safe_mode"
}
compatible := protocol >= definition . MinimumProtocol
if ! compatible {
enabled , source = false , "incompatible_client"
}
return evaluatedFeature { featureDefinition : definition , Enabled : enabled , Source : source , Compatible : compatible }
}
2026-08-19 18:08:00 +12:00
// currentFeaturePolicy is read on the request path from sixteen places and by the status
// poll every open television makes, so it is cached rather than queried — see
// featurePolicyCache for how long and why that is safe.
2026-08-02 22:10:19 +12:00
func ( s * Server ) currentFeaturePolicy ( ctx context . Context ) store . FeaturePolicy {
if s . store == nil {
return store . DefaultFeaturePolicy ()
}
2026-08-19 18:08:00 +12:00
return s . featurePolicy . read ( ctx , func ( ctx context . Context ) store . FeaturePolicy {
policy , err := s . store . FeaturePolicy ( ctx )
if err != nil {
s . loggerFor ( ctx ). Warn ( "feature policy unavailable; using safe defaults" , "error" , err )
return store . DefaultFeaturePolicy ()
}
return policy
})
2026-08-02 22:10:19 +12:00
}
func ( s * Server ) featureEnabled ( ctx context . Context , key string ) bool {
definition , ok := knownFeature ( key )
if ! ok {
return false
}
2026-08-06 22:33:56 +12:00
return evaluateFeature ( s . currentFeaturePolicy ( ctx ), definition , ProtocolVersion ). Enabled
2026-08-02 22:10:19 +12:00
}
func featurePayload ( policy store . FeaturePolicy , protocol int , capabilities ... [] string ) featureResponse {
2026-08-22 12:38:26 +12:00
return featurePayloadForSession ( policy , protocol , nil , capabilities ... )
}
func featurePayloadForSession ( policy store . FeaturePolicy , protocol int , session * store . Session , capabilities ... [] string ) featureResponse {
2026-08-02 22:10:19 +12:00
features := make ([] evaluatedFeature , 0 , len ( featureCatalogue ))
for _ , definition := range featureCatalogue {
evaluated := evaluateFeature ( policy , definition , protocol )
if len ( capabilities ) > 0 && definition . Capability != "" &&
! slices . Contains ( capabilities [ 0 ], definition . Capability ) {
evaluated . Enabled = false
evaluated . Compatible = false
evaluated . Source = "missing_capability"
}
features = append ( features , evaluated )
}
2026-08-22 12:38:26 +12:00
response := featureResponse {
2026-08-02 22:10:19 +12:00
SchemaVersion : featureSchemaVersion , Revision : policy . Revision ,
SafeMode : policy . SafeMode , UpdatedAt : policy . UpdatedAt ,
CanRollback : policy . Previous != nil , Features : features ,
2026-08-25 11:39:55 +12:00
RowTypes : rowTypeCatalogue ,
2026-08-02 22:10:19 +12:00
}
2026-08-22 12:38:26 +12:00
if session != nil {
response . Configuration = configurationPayload ( policy , * session )
} else {
response . Configuration = configurationPayload ( policy )
}
return response
2026-08-02 22:10:19 +12:00
}
func featureMap ( policy store . FeaturePolicy , protocol int , capabilities [] string ) map [ string ] bool {
result := make ( map [ string ] bool , len ( featureCatalogue ))
for _ , feature := range featurePayload ( policy , protocol , capabilities ). Features {
result [ feature . Key ] = feature . Enabled
}
return result
}
2026-08-22 12:38:26 +12:00
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 ),
2026-08-02 22:10:19 +12:00
))
}
type featurePolicyRequest struct {
2026-08-22 12:38:26 +12:00
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"`
2026-08-02 22:10:19 +12:00
}
func ( s * Server ) handleAdminFeaturePolicy ( w http . ResponseWriter , r * http . Request ) {
var req featurePolicyRequest
decoder := json . NewDecoder ( http . MaxBytesReader ( w , r . Body , 16 << 10 ))
decoder . DisallowUnknownFields ()
if err := decoder . Decode ( & req ); err != nil {
writeError ( w , http . StatusBadRequest , "malformed request body" )
return
}
current := s . currentFeaturePolicy ( r . Context ())
if req . ExpectedRevision != current . Revision {
writeError ( w , http . StatusConflict , "feature flags changed in another admin session; refresh before saving" )
return
}
2026-08-22 12:38:26 +12:00
next := store . FeaturePolicy { Overrides : map [ string ] bool {}, Values : req . Values , UserValues : req . UserValues , DeviceValues : req . DeviceValues , Experimental : req . Experimental , SafeMode : current . SafeMode }
2026-08-02 22:10:19 +12:00
switch strings . TrimSpace ( req . Action ) {
case "save" :
for key , enabled := range req . Overrides {
if _ , ok := knownFeature ( key ); ! ok {
writeError ( w , http . StatusBadRequest , "unknown feature flag: " + key )
return
}
next . Overrides [ key ] = enabled
}
2026-08-22 12:38:26 +12:00
if err := validateConfigurationValues ( next ); err != nil {
writeError ( w , http . StatusBadRequest , err . Error ())
return
}
2026-08-02 22:10:19 +12:00
case "safe-mode" :
next . Overrides = current . Overrides
2026-08-22 12:38:26 +12:00
next . Values , next . UserValues , next . DeviceValues , next . Experimental = current . Values , current . UserValues , current . DeviceValues , current . Experimental
2026-08-02 22:10:19 +12:00
next . SafeMode = true
case "leave-safe-mode" :
next . Overrides = current . Overrides
2026-08-22 12:38:26 +12:00
next . Values , next . UserValues , next . DeviceValues , next . Experimental = current . Values , current . UserValues , current . DeviceValues , current . Experimental
2026-08-02 22:10:19 +12:00
next . SafeMode = false
case "reset" :
next . SafeMode = false
case "rollback" :
if current . Previous == nil {
writeError ( w , http . StatusConflict , "there is no previous feature revision to restore" )
return
}
next . Overrides = current . Previous . Overrides
2026-08-22 12:38:26 +12:00
next . Values , next . UserValues , next . DeviceValues , next . Experimental = current . Previous . Values , current . Previous . UserValues , current . Previous . DeviceValues , current . Previous . Experimental
2026-08-02 22:10:19 +12:00
next . SafeMode = current . Previous . SafeMode
default :
writeError ( w , http . StatusBadRequest , "unknown feature policy action" )
return
}
2026-08-19 18:08:00 +12:00
s . featurePolicy . invalidate ()
2026-08-02 22:10:19 +12:00
stored , err := s . store . SetFeaturePolicy ( r . Context (), next , req . ExpectedRevision )
if err != nil {
if errors . Is ( err , store . ErrFeaturePolicyConflict ) {
writeError ( w , http . StatusConflict , "feature flags changed in another admin session; refresh before saving" )
return
}
2026-08-06 22:33:56 +12:00
s . loggerFor ( r . Context ()). Error ( "feature policy write failed" , "error" , err )
2026-08-02 22:10:19 +12:00
writeError ( w , http . StatusInternalServerError , "could not save feature flags" )
return
}
2026-08-06 22:33:56 +12:00
s . loggerFor ( r . Context ()). Info ( "feature policy changed" , "action" , req . Action , "revision" , stored . Revision ,
2026-08-02 22:10:19 +12:00
"safe_mode" , stored . SafeMode , "overrides" , len ( stored . Overrides ))
2026-08-06 22:33:56 +12:00
writeJSON ( w , http . StatusOK , featurePayload ( stored , ProtocolVersion ))
2026-08-02 22:10:19 +12:00
}
2026-08-22 12:38:26 +12:00
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 )
}
2026-08-25 11:39:55 +12:00
if err := validateSectionDefinitions ( definition . Key , raw ); err != nil {
return err
}
2026-08-22 12:38:26 +12:00
case "string" :
if _ , ok := value .( string ); ! ok {
return errors . New ( "configuration value must be text: " + definition . Key )
}
}
return nil
}
2026-08-02 22:10:19 +12:00
func parseCapabilities ( raw string ) [] string {
seen := map [ string ] bool {}
values := [] string {}
for _ , value := range strings . Split ( raw , "," ) {
value = strings . ToLower ( strings . TrimSpace ( value ))
if value == "" || len ( value ) > 64 || seen [ value ] {
continue
}
seen [ value ] = true
values = append ( values , value )
}
slices . Sort ( values )
return values
}
func clientCapabilities ( r * http . Request ) [] string {
return parseCapabilities ( r . Header . Get ( "X-Memby-Capabilities" ))
}