0.2.55 - Remote config/Request fixes
This commit is contained in:
@@ -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,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');
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user