0.3.00
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user