This commit is contained in:
ponzischeme89
2026-08-22 08:26:10 +12:00
parent 3ae8ffad64
commit b131932a4e
34 changed files with 10740 additions and 9190 deletions
+2
View File
@@ -65,6 +65,8 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("POST /admin/api/for-you", s.adminAuth(s.handleAdminForYou))
mux.Handle("GET /admin/api/gateway-settings", s.adminAuth(s.handleAdminGatewaySettings))
mux.Handle("POST /admin/api/gateway-settings", s.adminAuth(s.handleAdminGatewaySettings))
mux.Handle("GET /admin/api/metadata-hero", s.adminAuth(s.handleAdminMetadataHeroSettings))
mux.Handle("POST /admin/api/metadata-hero", s.adminAuth(s.handleAdminMetadataHeroSettings))
mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance))
mux.Handle("POST /admin/api/quiet-time", s.adminAuth(s.handleAdminQuietTime))
mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
+4
View File
@@ -362,6 +362,10 @@ func TestServiceStatusCarriesEmbyHealthAndPreferenceRevision(t *testing.T) {
if _, ok := body["preferencesRevision"]; !ok {
t.Fatalf("status response carried no preferences revision: %v", body)
}
metadataOrder, ok := body["metadataHeroContentOrder"].([]any)
if !ok || len(metadataOrder) == 0 {
t.Fatalf("status response carried no global metadata hero order: %v", body)
}
// Likewise present with no store behind it. This is the whole delivery channel for an
// operator's hero change: a television comparing against a missing field would go on
// drawing yesterday's hero until it was next restarted.
+2
View File
@@ -133,6 +133,8 @@ type Server struct {
recommendationBuilds recommendationBuilds
maintenance maintenanceState
quietTime quietTimeState
// metadataHeroSettings is the shared launcher composition sent to every television.
metadataHeroSettings metadataHeroSettingsState
// gatewaySettings is the operator's runtime amendment to what the container was
// started with. deployedLogLevel is remembered beside the live level variable
// because clearing an override has to restore something, and the level variable
+4
View File
@@ -153,6 +153,10 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
// fetches /v1/preferences when they differ. That is what turns this poll into the
// delivery channel for an operator pushing someone's settings.
"preferencesRevision": s.preferenceRevisionFor(r, sess),
// Four small ids are the complete household-wide metadata hero composition. They
// ride the existing poll so a saved layout reaches an open launcher immediately,
// without manufacturing a per-user preference revision for a global change.
"metadataHeroContentOrder": s.metadataHeroSettings.get().ContentOrder,
// The theme, as an id and a revision rather than the palette itself — the
// preferencesRevision precedent, for the same reason. The set refetches /v1/theme
// only when one of these moves, which is what makes a season arriving at midnight
@@ -0,0 +1,100 @@
package api
import (
"context"
"encoding/json"
"net/http"
"slices"
"sync"
"github.com/ponzischeme89/memby/server/internal/adminevents"
"github.com/ponzischeme89/memby/server/internal/store"
)
type metadataHeroSettingsState struct {
mu sync.RWMutex
value store.MetadataHeroSettings
}
func (state *metadataHeroSettingsState) get() store.MetadataHeroSettings {
state.mu.RLock()
defer state.mu.RUnlock()
return store.NormalizeMetadataHeroSettings(state.value)
}
func (state *metadataHeroSettingsState) set(value store.MetadataHeroSettings) {
state.mu.Lock()
state.value = store.NormalizeMetadataHeroSettings(value)
state.mu.Unlock()
}
// LoadMetadataHeroSettings primes the household-wide presentation setting before the
// status endpoint starts telling televisions what to draw.
func (s *Server) LoadMetadataHeroSettings(ctx context.Context) error {
settings, err := s.store.MetadataHeroSettings(ctx)
if err != nil {
return err
}
s.metadataHeroSettings.set(settings)
return nil
}
type metadataHeroOption struct {
Value string `json:"value"`
Label string `json:"label"`
Description string `json:"description"`
}
var metadataHeroOptions = []metadataHeroOption{
{store.MetadataHeroTitle, "Movie logo or title", "The focused title's logo artwork, with a text title as the fallback."},
{store.MetadataHeroRatings, "Ratings", "The available third-party and community ratings."},
{store.MetadataHeroFacts, "Metadata information", "Year, runtime, classification, genres and outlined media badges."},
{store.MetadataHeroSummary, "Plot summary", "A short overview of the focused title."},
{store.MetadataHeroReason, "Why this was recommended", "The gateway's short explanation, such as Because you watched a related title."},
{store.MetadataHeroTime, "Time remaining", "A resume progress bar followed by the number of minutes remaining."},
}
type metadataHeroSettingsResponse struct {
Settings store.MetadataHeroSettings `json:"settings"`
Options []metadataHeroOption `json:"options"`
}
func (s *Server) metadataHeroSettingsResponse() metadataHeroSettingsResponse {
return metadataHeroSettingsResponse{
Settings: s.metadataHeroSettings.get(),
Options: metadataHeroOptions,
}
}
func (s *Server) handleAdminMetadataHeroSettings(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
writeJSON(w, http.StatusOK, s.metadataHeroSettingsResponse())
return
}
var req store.MetadataHeroSettings
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
previous := s.metadataHeroSettings.get()
_, operator, _ := s.browserSession(r, adminSessionPurpose)
req.UpdatedBy = operator
stored, err := s.store.SetMetadataHeroSettings(r.Context(), req)
if err != nil {
s.loggerFor(r.Context()).Error("metadata hero settings write failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not save metadata hero settings")
return
}
s.metadataHeroSettings.set(stored)
if !slices.Equal(previous.ContentOrder, stored.ContentOrder) {
s.publishAdmin(r.Context(), adminevents.Event{
Type: adminevents.TypeSettingsChanged,
Severity: adminevents.SeverityInfo,
Title: "Metadata hero changed",
Summary: "The household-wide metadata hero content order was changed",
Actor: operator,
Link: "/admin/metadata-hero",
})
}
writeJSON(w, http.StatusOK, s.metadataHeroSettingsResponse())
}
@@ -0,0 +1,25 @@
package api
import (
"reflect"
"testing"
"github.com/ponzischeme89/memby/server/internal/store"
)
func TestMetadataHeroSettingsStateHasAUsableGlobalDefault(t *testing.T) {
var state metadataHeroSettingsState
got := state.get().ContentOrder
if !reflect.DeepEqual(got, store.DefaultMetadataHeroContentOrder) {
t.Fatalf("content order = %v, want %v", got, store.DefaultMetadataHeroContentOrder)
}
}
func TestMetadataHeroSettingsStateNormalisesEveryWrite(t *testing.T) {
var state metadataHeroSettingsState
state.set(store.MetadataHeroSettings{ContentOrder: []string{"summary", "unknown", "summary", "title"}})
want := []string{"summary", "title"}
if got := state.get().ContentOrder; !reflect.DeepEqual(got, want) {
t.Fatalf("content order = %v, want %v", got, want)
}
}
-20
View File
@@ -130,20 +130,6 @@ var preferenceCatalogue = []preferenceDefinition{
Description: "Show third-party ratings on browse and detail pages.",
Kind: preferenceToggle, Default: true,
},
{
// This is operator-owned because the hero is a shared piece of the launcher's
// composition, not a per-television preference. The multi value keeps its order.
Key: "metadataHeroContentOrder", Name: "Metadata hero content order", Area: "Home layout",
Description: "The order of title, ratings, metadata information and plot summary in the focused metadata hero.",
Kind: preferenceMulti, AdminOnly: true,
Default: []string{"title", "ratings", "facts", "summary"},
Options: []preferenceOption{
option("title", "Movie logo or title"),
option("ratings", "Ratings"),
option("facts", "Metadata information"),
option("summary", "Plot summary"),
},
},
{
Key: "hideWatchedMovies", Name: "Hide watched films", Area: "Home",
Description: "Keep fully watched films out of browse rows and the hero.",
@@ -336,12 +322,6 @@ func normalizePreference(definition preferenceDefinition, value any) any {
if selected := stringList(value); len(selected) > 0 {
kept := []string{}
for _, entry := range selected {
// Genre used to be a separate metadata-hero block. It now lives inside
// Metadata information with runtime and the sound/video badges. Map the
// stored token instead of silently removing that block from existing users.
if definition.Key == "metadataHeroContentOrder" && entry == "genres" {
entry = "facts"
}
if hasOption(definition.Options, entry) && !slices.Contains(kept, entry) {
kept = append(kept, entry)
}
+3 -4
View File
@@ -126,13 +126,12 @@ func TestNormalizePreferencesKeepsMultiOrderAndDedupes(t *testing.T) {
}
}
func TestNormalizePreferencesFoldsLegacyGenreIntoMetadataInformation(t *testing.T) {
func TestNormalizePreferencesDropsRetiredMetadataHeroSetting(t *testing.T) {
result := normalizePreferences(map[string]any{
"metadataHeroContentOrder": []any{"title", "ratings", "genres", "summary"},
})
want := []string{"title", "ratings", "facts", "summary"}
if !reflect.DeepEqual(result["metadataHeroContentOrder"], want) {
t.Errorf("metadataHeroContentOrder = %v, want %v", result["metadataHeroContentOrder"], want)
if _, exists := result["metadataHeroContentOrder"]; exists {
t.Fatal("the household-wide metadata hero setting remained in a user's preferences")
}
}