0.2.55 - Remote config/Request fixes

This commit is contained in:
ponzischeme89
2026-08-12 09:57:56 +12:00
parent 4b31946635
commit 9777eb0952
35 changed files with 1086 additions and 78 deletions
+2 -2
View File
@@ -53,8 +53,8 @@ Admin.onStatus((status) => {
const mdblist = status.mdblist || {};
const forYou = status.forYou || {};
$('overview-services').innerHTML =
row('Radarr', status.radarrReady ? ui.tag('ready', 'ok') : ui.tag('not configured', 'idle')) +
row('Sonarr', status.sonarrReady ? ui.tag('ready', 'ok') : ui.tag('not configured', 'idle')) +
row('Movies', status.radarrReady ? ui.tag('ready', 'ok') : ui.tag('not configured', 'idle')) +
row('Series', status.sonarrReady ? ui.tag('ready', 'ok') : ui.tag('not configured', 'idle')) +
row('MDBList ratings', mdblist.enabled
? ui.tag(fmt.number(mdblist.cachedTitles) + ' titles stored', 'ok')
: ui.tag(mdblist.apiKeyConfigured ? 'off · key saved' : 'off · no key', 'idle')) +
@@ -2,8 +2,8 @@
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="inbox" data-icon-tone="info">Where a request goes</h2>
<p class="card-note">A film is added to Radarr and a show to Sonarr, both unmonitored.
No download search starts on its own.</p>
<p class="card-note">A movie or series is monitored and searched for immediately.
The configured download service handles it from there.</p>
</div>
<span class="row tight" id="request-services"></span>
</div>
+2 -2
View File
@@ -2,9 +2,9 @@ const { fmt, ui, $ } = Admin;
Admin.onStatus((status) => {
$('request-services').innerHTML =
ui.tag('Radarr ' + (status.radarrReady ? 'ready' : 'not configured'),
ui.tag('Movies ' + (status.radarrReady ? 'ready' : 'not configured'),
status.radarrReady ? 'ok' : 'bad') +
ui.tag('Sonarr ' + (status.sonarrReady ? 'ready' : 'not configured'),
ui.tag('Series ' + (status.sonarrReady ? 'ready' : 'not configured'),
status.sonarrReady ? 'ok' : 'bad');
const box = $('request-users');
+4
View File
@@ -205,6 +205,10 @@ func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.handleHealth)
mux.HandleFunc("GET /readyz", s.handleReady)
// Remote Config is app-scoped, contains presentation data only, and warms the next
// process. Keep it outside authentication and maintenance so offline/start-up fallback
// never depends on a session being available.
mux.HandleFunc("GET /v1/config", s.handleRemoteConfig)
// Update policy is app-scoped, not user-scoped. Keep it outside authentication and
// maintenance so a fresh install, a signed-out TV, and a retired build can all learn
// whether the server requires an update. A valid session enriches only its log context.
+36
View File
@@ -0,0 +1,36 @@
package api
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"strconv"
)
// handleRemoteConfig serves one app-scoped, immutable-at-runtime document. It is public
// for the same reason the update verdict is public: a fresh install and a signed-out TV
// must be able to warm the next launch. No viewer or session data belongs in this answer.
func (s *Server) handleRemoteConfig(w http.ResponseWriter, r *http.Request) {
body, err := json.Marshal(s.cfg.RemoteConfig)
if err != nil {
// Config is validated during start-up, so this is defensive rather than an expected
// operational failure.
writeError(w, http.StatusInternalServerError, "remote configuration unavailable")
return
}
digest := sha256.Sum256(body)
etag := `"rc-` + hex.EncodeToString(digest[:12]) + `"`
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("ETag", etag)
w.Header().Set("X-Memby-Config-Version", configVersionHeader(s.cfg.RemoteConfig.ConfigVersion))
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
writeRaw(w, http.StatusOK, body)
}
func configVersionHeader(version int64) string {
return strconv.FormatInt(version, 10)
}
+33
View File
@@ -0,0 +1,33 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/ponzischeme89/memby/server/internal/config"
)
func TestRemoteConfigSupportsETagRevalidationWithoutAuthentication(t *testing.T) {
server := &Server{cfg: config.Config{RemoteConfig: config.DefaultRemoteConfig()}}
first := httptest.NewRecorder()
server.handleRemoteConfig(first, httptest.NewRequest(http.MethodGet, "/v1/config", nil))
if first.Code != http.StatusOK {
t.Fatalf("status = %d", first.Code)
}
etag := first.Header().Get("ETag")
if etag == "" {
t.Fatal("missing ETag")
}
if got := first.Header().Get("X-Memby-Config-Version"); got != "1" {
t.Fatalf("version header = %q", got)
}
request := httptest.NewRequest(http.MethodGet, "/v1/config", nil)
request.Header.Set("If-None-Match", etag)
second := httptest.NewRecorder()
server.handleRemoteConfig(second, request)
if second.Code != http.StatusNotModified {
t.Fatalf("revalidation status = %d", second.Code)
}
}
+48 -10
View File
@@ -169,6 +169,7 @@ func sonarrCoverURL(images []sonarr.Image, kind string) string {
type requestPayload struct {
MediaType string `json:"mediaType"`
ForeignID int `json:"foreignId"`
Title string `json:"title"`
}
func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess store.Session) {
@@ -185,16 +186,22 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
writeError(w, http.StatusBadRequest, "foreignId is required")
return
}
req.Title = strings.TrimSpace(req.Title)
if titleRunes := []rune(req.Title); len(titleRunes) > 240 {
req.Title = string(titleRunes[:240])
}
switch req.MediaType {
case "movie":
if s.radarr == nil {
writeError(w, http.StatusServiceUnavailable, "Radarr is not configured")
s.logMediaRequest(r.Context(), req, "failed", errors.New("movie requests are not configured"))
writeError(w, http.StatusServiceUnavailable, "movie requests are not configured")
return
}
movies, err := s.radarr.Lookup(r.Context(), "tmdb:"+strconv.Itoa(req.ForeignID))
if err != nil {
s.writeRequestUpstreamError(r.Context(), w, err, "Radarr lookup failed")
s.logMediaRequest(r.Context(), req, "failed", err)
s.writeRequestUpstreamError(r.Context(), w, err, "movie lookup failed")
return
}
for _, movie := range movies {
@@ -205,26 +212,33 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
// Idempotent under a lost response: OkHttp may replay a repeatable POST after
// a connection reset. If the first request already added it, the retry is the
// same successful action rather than an error shown to the viewer.
req.Title = movie.Title
s.logMediaRequest(r.Context(), req, "already added", nil)
writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": movie.Title})
return
}
added, err := s.radarr.AddUnmonitored(r.Context(), movie)
req.Title = movie.Title
added, err := s.radarr.AddRequested(r.Context(), movie)
if err != nil {
s.writeRequestUpstreamError(r.Context(), w, err, "could not add movie to Radarr")
s.logMediaRequest(r.Context(), req, "failed", err)
s.writeRequestUpstreamError(r.Context(), w, err, "could not request that movie")
return
}
s.loggerFor(r.Context()).Info("media requested", "user", sess.Username, "type", "movie", "title", added.Title)
req.Title = added.Title
s.logMediaRequest(r.Context(), req, "successful", nil)
writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title})
return
}
case "series":
if s.sonarr == nil {
writeError(w, http.StatusServiceUnavailable, "Sonarr is not configured")
s.logMediaRequest(r.Context(), req, "failed", errors.New("series requests are not configured"))
writeError(w, http.StatusServiceUnavailable, "series requests are not configured")
return
}
series, err := s.sonarr.Lookup(r.Context(), "tvdb:"+strconv.Itoa(req.ForeignID))
if err != nil {
s.writeRequestUpstreamError(r.Context(), w, err, "Sonarr lookup failed")
s.logMediaRequest(r.Context(), req, "failed", err)
s.writeRequestUpstreamError(r.Context(), w, err, "series lookup failed")
return
}
for _, show := range series {
@@ -232,25 +246,49 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
continue
}
if show.ID > 0 {
req.Title = show.Title
s.logMediaRequest(r.Context(), req, "already added", nil)
writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": show.Title})
return
}
added, err := s.sonarr.AddUnmonitored(r.Context(), show)
req.Title = show.Title
added, err := s.sonarr.AddRequested(r.Context(), show)
if err != nil {
s.writeRequestUpstreamError(r.Context(), w, err, "could not add series to Sonarr")
s.logMediaRequest(r.Context(), req, "failed", err)
s.writeRequestUpstreamError(r.Context(), w, err, "could not request that series")
return
}
s.loggerFor(r.Context()).Info("media requested", "user", sess.Username, "type", "series", "title", added.Title)
req.Title = added.Title
s.logMediaRequest(r.Context(), req, "successful", nil)
writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title})
return
}
default:
s.logMediaRequest(r.Context(), req, "failed", errors.New("unsupported media type"))
writeError(w, http.StatusBadRequest, `mediaType must be "movie" or "series"`)
return
}
s.logMediaRequest(r.Context(), req, "failed", errors.New("title was not found"))
writeError(w, http.StatusNotFound, "title was not found")
}
func (s *Server) logMediaRequest(
ctx context.Context, req requestPayload, outcome string, err error,
) {
fields := []any{
"type", req.MediaType,
"title", clientLogValue(req.Title),
"foreign_id", req.ForeignID,
"outcome", outcome,
}
if err != nil {
fields = append(fields, "error", err)
s.loggerFor(ctx).Warn("media request failed", fields...)
return
}
s.loggerFor(ctx).Info("media request "+outcome, fields...)
}
func (s *Server) writeRequestUpstreamError(
ctx context.Context, w http.ResponseWriter, err error, message string,
) {
+27 -1
View File
@@ -1,6 +1,14 @@
package api
import "testing"
import (
"bytes"
"context"
"log/slog"
"strings"
"testing"
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
)
func TestRequestMatchScorePrefersTheSpecificTitle(t *testing.T) {
if requestMatchScore("the office", "The Office") >=
@@ -11,3 +19,21 @@ func TestRequestMatchScorePrefersTheSpecificTitle(t *testing.T) {
t.Fatal("exact title should rank ahead of a contained match")
}
}
func TestMediaRequestLogNamesTitleAndOutcome(t *testing.T) {
var output bytes.Buffer
server := &Server{log: serverlogging.New(&output, slog.LevelInfo)}
server.logMediaRequest(context.Background(), requestPayload{
MediaType: "series", ForeignID: 123, Title: "Severance",
}, "successful", nil)
line := output.String()
for _, want := range []string{
"media request successful", "type=series", "title=Severance",
"foreign_id=123", "outcome=successful",
} {
if !strings.Contains(line, want) {
t.Fatalf("log %q does not contain %q", line, want)
}
}
}