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
+3
View File
@@ -357,6 +357,9 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro
if err := server.LoadGatewaySettings(ctx); err != nil {
return err
}
if err := server.LoadMetadataHeroSettings(ctx); err != nil {
return err
}
// Every long-running worker below is started through runtimestats.Go rather than with
// a bare `go`, so the admin console can name what is running instead of reporting a
// count of goroutines nobody can interpret. The name is the worker's identity across
+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")
}
}
+103
View File
@@ -0,0 +1,103 @@
package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// MetadataHeroSettingsKey is the app_settings row backing the household-wide metadata
// hero composition.
const MetadataHeroSettingsKey = "metadata_hero_settings"
const (
MetadataHeroTitle = "title"
MetadataHeroRatings = "ratings"
MetadataHeroFacts = "facts"
MetadataHeroSummary = "summary"
MetadataHeroReason = "recommendation_reason"
MetadataHeroTime = "time_remaining"
)
var DefaultMetadataHeroContentOrder = []string{
MetadataHeroTitle,
MetadataHeroRatings,
MetadataHeroFacts,
MetadataHeroSummary,
}
var MetadataHeroContentOptions = []string{
MetadataHeroTitle,
MetadataHeroRatings,
MetadataHeroFacts,
MetadataHeroSummary,
MetadataHeroReason,
MetadataHeroTime,
}
// MetadataHeroSettings is one presentation decision for the whole household. It lives
// outside user preferences because the hero is part of the product's shared composition,
// not something that should change when the viewer changes.
type MetadataHeroSettings struct {
ContentOrder []string `json:"contentOrder"`
UpdatedAt time.Time `json:"updatedAt"`
UpdatedBy string `json:"updatedBy,omitempty"`
}
func NormalizeMetadataHeroSettings(settings MetadataHeroSettings) MetadataHeroSettings {
seen := map[string]bool{}
order := make([]string, 0, len(MetadataHeroContentOptions))
for _, entry := range settings.ContentOrder {
if seen[entry] || !containsString(MetadataHeroContentOptions, entry) {
continue
}
seen[entry] = true
order = append(order, entry)
}
if len(order) == 0 {
order = append([]string(nil), DefaultMetadataHeroContentOrder...)
}
settings.ContentOrder = order
return settings
}
func (s *Store) MetadataHeroSettings(ctx context.Context) (MetadataHeroSettings, error) {
var raw []byte
err := s.pool.QueryRow(ctx,
`SELECT value FROM app_settings WHERE key = $1`, MetadataHeroSettingsKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return NormalizeMetadataHeroSettings(MetadataHeroSettings{}), nil
}
if err != nil {
return MetadataHeroSettings{}, fmt.Errorf("store: read metadata hero settings: %w", err)
}
var settings MetadataHeroSettings
if err := json.Unmarshal(raw, &settings); err != nil {
return MetadataHeroSettings{}, fmt.Errorf("store: decode metadata hero settings: %w", err)
}
return NormalizeMetadataHeroSettings(settings), nil
}
func (s *Store) SetMetadataHeroSettings(
ctx context.Context, settings MetadataHeroSettings,
) (MetadataHeroSettings, error) {
settings = NormalizeMetadataHeroSettings(settings)
settings.UpdatedAt = time.Now().UTC()
raw, err := json.Marshal(settings)
if err != nil {
return MetadataHeroSettings{}, err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO app_settings (key, value, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
MetadataHeroSettingsKey, string(raw))
if err != nil {
return MetadataHeroSettings{}, fmt.Errorf("store: write metadata hero settings: %w", err)
}
return settings, nil
}
@@ -0,0 +1,37 @@
package store
import (
"reflect"
"testing"
)
func TestNormalizeMetadataHeroSettingsKeepsAnOrderedSubset(t *testing.T) {
got := NormalizeMetadataHeroSettings(MetadataHeroSettings{
ContentOrder: []string{"summary", "unknown", "title", "summary"},
}).ContentOrder
want := []string{"summary", "title"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("content order = %v, want %v", got, want)
}
}
func TestNormalizeMetadataHeroSettingsRestoresTheDefault(t *testing.T) {
got := NormalizeMetadataHeroSettings(MetadataHeroSettings{}).ContentOrder
if !reflect.DeepEqual(got, DefaultMetadataHeroContentOrder) {
t.Fatalf("content order = %v, want %v", got, DefaultMetadataHeroContentOrder)
}
got[0] = "changed"
if DefaultMetadataHeroContentOrder[0] != MetadataHeroTitle {
t.Fatal("normalising an empty document returned the shared default slice")
}
}
func TestNormalizeMetadataHeroSettingsAcceptsOptionalContent(t *testing.T) {
got := NormalizeMetadataHeroSettings(MetadataHeroSettings{
ContentOrder: []string{MetadataHeroTitle, MetadataHeroReason, MetadataHeroTime},
}).ContentOrder
want := []string{MetadataHeroTitle, MetadataHeroReason, MetadataHeroTime}
if !reflect.DeepEqual(got, want) {
t.Fatalf("content order = %v, want %v", got, want)
}
}