0.2.72 - Magic button, Next up fixes
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 @@
|
||||
0.1.49
|
||||
0.1.50
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user