0.2.72 - Magic button, Next up fixes

This commit is contained in:
ponzischeme89
2026-08-17 11:41:36 +12:00
parent 36cb1324fd
commit 12b27c77c3
2655 changed files with 5435 additions and 254 deletions
+7
View File
@@ -34,6 +34,7 @@ type adminOnboardingPreferences struct {
type adminMembyAccount struct {
ID string `json:"id"`
Username string `json:"username"`
Initials string `json:"initials"`
CreatedAt time.Time `json:"createdAt"`
LastSeen time.Time `json:"lastSeen"`
Devices []store.MembyDevice `json:"devices"`
@@ -138,6 +139,7 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
}
result = append(result, adminMembyAccount{
ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt,
Initials: stringPreference(accountSettings.Preferences, "profileInitials"),
LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings,
Themes: nonNilStrings(themes[account.ID]),
Notifications: notificationPrefs,
@@ -165,6 +167,11 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
})
}
func stringPreference(preferences map[string]any, key string) string {
value, _ := preferences[key].(string)
return value
}
// handleAdminNotificationPreferences changes what one person is told without requiring
// a television or an app release. The loaded value is decoded in place so a console from
// an older gateway generation cannot accidentally turn off fields it does not know.
@@ -81,6 +81,8 @@ func TestPreferenceValueLabelWordsEveryKind(t *testing.T) {
{"homeSections", []string{"latest", "continue"}, "Latest movies, Continue watching"},
{"homeHiddenRows", []string{}, "None"},
{"homeHiddenRows", []string{"recommended"}, "recommended"},
{"profileInitials", "MC", "MC"},
{"profileInitials", "", "Automatic"},
} {
definition, ok := preferenceDefinitionFor(testCase.key)
if !ok {
+2 -2
View File
@@ -335,8 +335,8 @@ func TestServiceStatusReportsMaintenanceOutsideTheGate(t *testing.T) {
func TestServiceStatusCarriesEmbyHealthAndPreferenceRevision(t *testing.T) {
server := testServer(config.Config{})
server.embyHealth.begin(60*time.Second, time.Now().UTC())
server.embyHealth.record(false, time.Now().UTC())
server.embyHealth.record(false, time.Now().UTC())
server.embyHealth.record(false, "", time.Now().UTC())
server.embyHealth.record(false, "", time.Now().UTC())
rec := httptest.NewRecorder()
server.handleServiceStatus(
+1
View File
@@ -246,6 +246,7 @@ func (s *Server) Routes() http.Handler {
v1.Handle("GET /v1/items/{id}/season-finale", s.authed(s.handleSeasonFinale))
v1.Handle("GET /v1/items/{id}/episodes", s.authed(s.handleSeriesEpisodes))
v1.Handle("GET /v1/items/{id}/related", s.authed(s.handleRelated))
v1.Handle("POST /v1/magic", s.authed(s.handleMagic))
v1.Handle("GET /v1/items/{id}/extras", s.authed(s.handleExtras))
v1.Handle("POST /v1/items/{id}/favorite", s.authed(s.handleFavorite))
v1.Handle("POST /v1/items/{id}/played", s.authed(s.handlePlayed))
+13 -1
View File
@@ -41,6 +41,10 @@ type embyHealthState struct {
checkedAt time.Time
retryEvery time.Duration
consecutive int
// version is what Emby last said it was. Kept across a failed probe on purpose: the
// About page reads it, and blanking it during an outage would replace a fact that is
// still true with nothing.
version string
}
func (h *embyHealth) get() embyHealthState {
@@ -60,10 +64,13 @@ func (h *embyHealth) begin(interval time.Duration, now time.Time) {
}
// record folds one probe result in and reports whether the published verdict changed.
func (h *embyHealth) record(ok bool, now time.Time) {
func (h *embyHealth) record(ok bool, version string, now time.Time) {
h.mu.Lock()
defer h.mu.Unlock()
h.state.checkedAt = now
if version != "" {
h.state.version = version
}
if ok {
h.state.consecutive = 0
if !h.state.reachable {
@@ -90,6 +97,10 @@ type embyHealthPayload struct {
Since string `json:"since,omitempty"`
CheckedAt string `json:"checkedAt,omitempty"`
RetrySeconds int `json:"retrySeconds"`
// Version is Emby's own, for the television's About page. Omitted rather than sent
// empty, so a client can tell "not known yet" from "known to be blank" — the first
// probe may not have completed, and with the probe switched off none ever will.
Version string `json:"version,omitempty"`
}
func embyHealthFor(state embyHealthState) embyHealthPayload {
@@ -97,6 +108,7 @@ func embyHealthFor(state embyHealthState) embyHealthPayload {
Monitored: state.monitored,
Reachable: state.reachable || !state.monitored,
RetrySeconds: int(state.retryEvery / time.Second),
Version: state.version,
}
if !state.since.IsZero() {
payload.Since = state.since.UTC().Format(time.RFC3339)
+50 -8
View File
@@ -12,12 +12,12 @@ func TestEmbyHealthWaitsForTheThresholdBeforeReportingAnOutage(t *testing.T) {
// One failed probe is a hiccup — a restart, a slow scan. A red bar across somebody's
// film for that is worse than saying nothing.
health.record(false, start.Add(time.Minute))
health.record(false, "", start.Add(time.Minute))
if !health.get().reachable {
t.Fatal("a single failure raised an outage")
}
health.record(false, start.Add(2*time.Minute))
health.record(false, "", start.Add(2*time.Minute))
state := health.get()
if state.reachable {
t.Fatal("two consecutive failures did not raise an outage")
@@ -33,10 +33,10 @@ func TestEmbyHealthRecoversOnTheFirstSuccess(t *testing.T) {
var health embyHealth
start := time.Now().UTC()
health.begin(60*time.Second, start)
health.record(false, start.Add(time.Minute))
health.record(false, start.Add(2*time.Minute))
health.record(false, "", start.Add(time.Minute))
health.record(false, "", start.Add(2*time.Minute))
health.record(true, start.Add(3*time.Minute))
health.record(true, "", start.Add(3*time.Minute))
state := health.get()
if !state.reachable {
t.Fatal("a successful probe did not clear the outage")
@@ -53,7 +53,7 @@ func TestEmbyHealthForgetsIsolatedFailures(t *testing.T) {
start := time.Now().UTC()
health.begin(60*time.Second, start)
for minute := 1; minute <= 10; minute++ {
health.record(minute%2 == 0, start.Add(time.Duration(minute)*time.Minute))
health.record(minute%2 == 0, "", start.Add(time.Duration(minute)*time.Minute))
}
if !health.get().reachable {
t.Fatal("alternating failures were reported as an outage")
@@ -79,8 +79,8 @@ func TestEmbyHealthPayloadCarriesTheRetryInterval(t *testing.T) {
var health embyHealth
start := time.Now().UTC()
health.begin(60*time.Second, start)
health.record(false, start.Add(time.Minute))
health.record(false, start.Add(2*time.Minute))
health.record(false, "", start.Add(time.Minute))
health.record(false, "", start.Add(2*time.Minute))
payload := embyHealthFor(health.get())
if payload.RetrySeconds != 60 {
@@ -93,3 +93,45 @@ func TestEmbyHealthPayloadCarriesTheRetryInterval(t *testing.T) {
t.Error("payload omitted the last probe time the bar counts down from")
}
}
// The About page prints Emby's version beside the gateway's, so it has to survive the
// outage that is exactly when somebody goes looking at that page. A probe that fails
// carries no version, and blanking the last known one would replace a fact that is still
// true with nothing.
func TestEmbyHealthKeepsTheLastKnownVersionThroughAnOutage(t *testing.T) {
var health embyHealth
start := time.Now().UTC()
health.begin(60*time.Second, start)
health.record(true, "4.10.0.21", start.Add(time.Minute))
if got := embyHealthFor(health.get()).Version; got != "4.10.0.21" {
t.Fatalf("version = %q, want the version the probe reported", got)
}
health.record(false, "", start.Add(2*time.Minute))
health.record(false, "", start.Add(3*time.Minute))
payload := embyHealthFor(health.get())
if payload.Reachable {
t.Fatal("the outage was not raised")
}
if payload.Version != "4.10.0.21" {
t.Errorf("version = %q, want it kept through the outage", payload.Version)
}
// And an upgrade is picked up on the probe that sees it.
health.record(true, "4.11.0.1", start.Add(4*time.Minute))
if got := embyHealthFor(health.get()).Version; got != "4.11.0.1" {
t.Errorf("version = %q, want the newly reported one", got)
}
}
// Omitted rather than empty, so a television can tell "no probe has completed yet" from
// "Emby answered without one".
func TestEmbyHealthOmitsAnUnknownVersion(t *testing.T) {
var health embyHealth
start := time.Now().UTC()
health.begin(60*time.Second, start)
if got := embyHealthFor(health.get()).Version; got != "" {
t.Errorf("version = %q, want empty before any probe has answered", got)
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
// Emby being unreachable is reported but does not fail readiness: cached responses
// are still worth serving, and flapping the container would not bring Emby back.
if err := s.emby.Ping(ctx); err != nil {
if _, err := s.emby.Ping(ctx); err != nil {
checks["emby"] = err.Error()
} else {
checks["emby"] = "ok"
+3 -1
View File
@@ -178,7 +178,9 @@ func componentFor(path string) string {
return "recommendations"
case strings.HasPrefix(path, "/v1/my-shows"), strings.HasPrefix(path, "/v1/notifications"):
return "my-shows"
case strings.HasPrefix(path, "/v1/playback/"), isPlaybackItemPath(path):
// Magic is a recommendation by machinery but a playback control by surface, and this
// names the part of the app a call came from: it is only ever pressed in the player.
case strings.HasPrefix(path, "/v1/playback/"), path == "/v1/magic", isPlaybackItemPath(path):
return "playback"
case strings.HasPrefix(path, "/v1/images/"):
return "artwork"
+1
View File
@@ -54,6 +54,7 @@ func TestComponentNamesThePartOfTheAppARouteBelongsTo(t *testing.T) {
"/v1/items/42/trickplay": "playback",
"/v1/items/42/trickplay/12.jpg": "playback",
"/v1/playback/started": "playback",
"/v1/magic": "playback",
"/v1/images/42/primary": "artwork",
"/v1/recommendations": "recommendations",
"/v1/for-you": "recommendations",
+90
View File
@@ -0,0 +1,90 @@
package api
import (
"encoding/json"
"math/rand"
"net/http"
"strings"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
)
// magicResponse is one drawn title and the wording that goes with it.
//
// The item rides as Emby's own JSON, the convention every other row and detail response
// takes, so the television parses it with the single BaseItem model it already has and a
// field added to the catalogue tomorrow needs nothing here. [recommend.MagicSelection]
// carries a parsed item rather than raw bytes, so the handler re-reads it — one lookup on a
// button press, against a pick the viewer is about to watch for two hours.
type magicResponse struct {
Item json.RawMessage `json:"item"`
// Reasons is the same explanation layer a detail page uses. The television prints the
// title; these are what let it say *why* without the server having to word a sentence
// the client's copy conventions would then have to match.
Reasons []string `json:"reasons,omitempty"`
}
// magicRequest is what the player knows and the server does not: the film playing right now
// and the last few this button already offered. Repetition protection lives with the caller
// because it is the caller that knows what it has already put in front of somebody.
type magicRequest struct {
ExcludeIDs []string `json:"excludeIds"`
// AvailableMinutes is zero for no limit. Nothing sends it yet; it is on the wire because
// "I have an hour" is the obvious next thing to ask and the picker already takes it.
AvailableMinutes int `json:"availableMinutes"`
}
// handleMagic answers "put something good on".
//
// Deliberately a POST with a body rather than a GET with a query string: the exclusion list
// grows with every press, and a URL that lengthens each time is one a proxy or an access log
// eventually truncates — which would silently start repeating films.
//
// It is never cached. The whole point of the button is that pressing it twice gives two
// answers, and a cached one would give the same film until the entry expired.
func (s *Server) handleMagic(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
var req magicRequest
if r.Body != nil {
// A body that will not parse is an empty one: the exclusions are an optimisation,
// and refusing the press over them would trade a slightly worse pick for no pick.
_ = json.NewDecoder(r.Body).Decode(&req)
}
selection, ok := s.recommender.MagicPick(ctx, credentials(sess), recommend.MagicOptions{
ExcludeIDs: req.ExcludeIDs,
AvailableMinutes: req.AvailableMinutes,
// The one non-deterministic thing about the feature, named in one place.
Roll: rand.Float64(),
})
if !ok {
// A household that has run out of unseen library is not an error, and the television
// says so quietly rather than showing a failure over somebody's film.
s.loggerFor(ctx).Debug("magic found nothing", "excluded", len(req.ExcludeIDs))
writeError(w, http.StatusNotFound, "nothing to suggest")
return
}
item, err := s.emby.Item(ctx, credentials(sess), selection.Item.ID, fieldsDetail)
if err != nil {
s.writeUpstreamError(ctx, w, err, "could not load the suggestion")
return
}
decorated := []json.RawMessage{item}
s.decorateItemRatings(ctx, decorated)
s.loggerFor(ctx).Info("magic picked",
"item", selection.Item.ID,
"title", selection.Item.Name,
"score", selection.Score,
"pool", selection.PoolSize,
"signals", strings.Join(selection.Signals, ","),
)
writeJSON(w, http.StatusOK, magicResponse{
Item: decorated[0],
Reasons: selection.Reasons,
})
}
+51 -4
View File
@@ -24,7 +24,7 @@ import (
// Anything that identifies a *television* rather than a person is deliberately absent:
// the device name, the update source, the screensaver's rotation and ring colour. Those
// belong to the box in the room and must not follow someone to another one.
const preferenceSchemaVersion = 1
const preferenceSchemaVersion = 2
type preferenceKind string
@@ -40,6 +40,8 @@ const (
preferenceList preferenceKind = "list"
// One of Numbers.
preferenceNumber preferenceKind = "number"
// Short free-form display text, bounded by MaxLength.
preferenceText preferenceKind = "text"
)
type preferenceOption struct {
@@ -57,8 +59,10 @@ type preferenceDefinition struct {
Numbers []int `json:"numbers,omitempty"`
// Unit names what a number counts, for the admin console's editor. Without it every
// number reads as minutes, which is what the first one to exist happened to be.
Unit string `json:"unit,omitempty"`
Default any `json:"default"`
Unit string `json:"unit,omitempty"`
MaxLength int `json:"maxLength,omitempty"`
AdminOnly bool `json:"adminOnly,omitempty"`
Default any `json:"default"`
}
func option(value, label string) preferenceOption {
@@ -66,6 +70,11 @@ func option(value, label string) preferenceOption {
}
var preferenceCatalogue = []preferenceDefinition{
{
Key: "profileInitials", Name: "Profile initials", Area: "Profile",
Description: "Up to two characters shown in this person's user-switcher avatar. Leave blank to generate them from their name.",
Kind: preferenceText, Default: "", MaxLength: 2, AdminOnly: true,
},
{
Key: "homeSections", Name: "Home rows", Area: "Home",
Description: "Which built-in rows the launcher shows, in order.",
@@ -246,6 +255,23 @@ func normalizePreferences(raw map[string]any) map[string]any {
return result
}
// Device writes must carry admin-owned fields forward even when the client predates them.
// Without this merge, changing an unrelated setting on an older television would silently
// put profile initials back on automatic.
func preserveAdminPreferences(incoming map[string]any, stored json.RawMessage) map[string]any {
merged := make(map[string]any, len(incoming)+1)
for key, value := range incoming {
merged[key] = value
}
current := decodePreferences(stored)
for _, definition := range preferenceCatalogue {
if definition.AdminOnly {
merged[definition.Key] = current[definition.Key]
}
}
return merged
}
func normalizePreference(definition preferenceDefinition, value any) any {
switch definition.Kind {
case preferenceToggle:
@@ -290,6 +316,14 @@ func normalizePreference(definition preferenceDefinition, value any) any {
if number, ok := asInt(value); ok && slices.Contains(definition.Numbers, number) {
return number
}
case preferenceText:
if typed, ok := value.(string); ok {
trimmed := strings.TrimSpace(typed)
if !strings.ContainsAny(trimmed, "\n\r") &&
(definition.MaxLength <= 0 || len([]rune(trimmed)) <= definition.MaxLength) {
return strings.ToUpper(trimmed)
}
}
}
return defaultValue(definition)
}
@@ -396,7 +430,14 @@ func (s *Server) handlePreferences(w http.ResponseWriter, r *http.Request, sess
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
raw, err := json.Marshal(normalizePreferences(req.Preferences))
current, err := s.store.UserPreferences(r.Context(), sess.EmbyUserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not read your settings")
return
}
raw, err := json.Marshal(normalizePreferences(
preserveAdminPreferences(req.Preferences, current.Preferences),
))
if err != nil {
writeError(w, http.StatusBadRequest, "could not read those settings")
return
@@ -557,6 +598,12 @@ func preferenceValueLabel(definition preferenceDefinition, value any) string {
return "None"
}
return strings.Join(entries, ", ")
case preferenceText:
text, _ := value.(string)
if text == "" {
return "Automatic"
}
return text
}
return ""
}
+28
View File
@@ -30,6 +30,34 @@ func TestNormalizePreferencesDropsUnknownKeys(t *testing.T) {
}
}
func TestNormalizePreferencesBoundsAndNormalisesProfileInitials(t *testing.T) {
if got := normalizePreferences(map[string]any{"profileInitials": " mc "})["profileInitials"]; got != "MC" {
t.Errorf("profileInitials = %v, want MC", got)
}
for _, value := range []any{"MAT", "M\nC", 12} {
if got := normalizePreferences(map[string]any{"profileInitials": value})["profileInitials"]; got != "" {
t.Errorf("profileInitials for %v = %v, want automatic fallback", value, got)
}
}
}
func TestDevicePreferenceWritePreservesAdminInitials(t *testing.T) {
stored, err := json.Marshal(normalizePreferences(map[string]any{"profileInitials": "MC"}))
if err != nil {
t.Fatal(err)
}
merged := preserveAdminPreferences(map[string]any{
"profileInitials": "XX", "showTitleLogo": false,
}, stored)
normalised := normalizePreferences(merged)
if normalised["profileInitials"] != "MC" {
t.Errorf("profileInitials = %v, want preserved MC", normalised["profileInitials"])
}
if normalised["showTitleLogo"] != false {
t.Errorf("showTitleLogo = %v, want device change false", normalised["showTitleLogo"])
}
}
func TestNormalizePreferencesRejectsIllegalValues(t *testing.T) {
result := normalizePreferences(map[string]any{
"homeCardDensity": "enormous",
+2 -2
View File
@@ -136,9 +136,9 @@ func (s *Server) WatchEmbyReachability(ctx context.Context, interval time.Durati
continue
}
probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
err := s.emby.Ping(probeCtx)
version, err := s.emby.Ping(probeCtx)
cancel()
s.embyHealth.record(err == nil, time.Now().UTC())
s.embyHealth.record(err == nil, version, time.Now().UTC())
if err != nil {
failures++
+1 -1
View File
@@ -1 +1 @@
0.1.49
0.1.50
+18 -4
View File
@@ -720,14 +720,28 @@ func (c *Client) SubtitleURL(cred Credentials, itemID, mediaSourceID string, ind
return c.DeliveryURL(cred, path)
}
// Ping checks that Emby is reachable, for readiness probes.
func (c *Client) Ping(ctx context.Context) error {
// Ping checks that Emby is reachable, for readiness probes, and reports the version it
// answered with.
//
// The version is read off the reply the probe was already making rather than through a
// call of its own: `/System/Info/Public` carries it, this runs on a timer against a server
// whose version changes a few times a year, and a second request per probe to learn a
// string that rarely moves would be the wrong trade. An Emby that answers without one
// returns an empty version and no error — reachability is what this is for, and refusing a
// probe over a missing field would report a working server as down.
func (c *Client) Ping(ctx context.Context) (string, error) {
req, err := c.newRequest(ctx, http.MethodGet, "/System/Info/Public", nil,
Credentials{Gateway: true}, nil)
if err != nil {
return err
return "", err
}
return c.do(req, nil)
var info struct {
Version string `json:"Version"`
}
if err := c.do(req, &info); err != nil {
return "", err
}
return info.Version, nil
}
func (c *Client) items(ctx context.Context, cred Credentials, path string, params url.Values) (*ItemsResult, error) {