This commit is contained in:
ponzischeme89
2026-08-17 13:13:10 +12:00
parent 12b27c77c3
commit 43ab18854c
39 changed files with 1707 additions and 1528 deletions
-2
View File
@@ -60,8 +60,6 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("POST /admin/api/quiet-time", s.adminAuth(s.handleAdminQuietTime))
mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
mux.Handle("GET /admin/api/release-builder", s.adminAuth(s.handleAdminReleaseBuilderStatus))
mux.Handle("POST /admin/api/release-builder", s.adminAuth(s.handleAdminReleaseBuilderStart))
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
mux.Handle("GET /admin/api/media-reports", s.adminAuth(s.handleAdminMediaReports))
mux.Handle("POST /admin/api/media-reports/{id}/status", s.adminAuth(s.handleAdminMediaReportStatus))
+10 -3
View File
@@ -67,7 +67,7 @@ func (s *Server) handleBrowseItems(
limit := queryInt(r, "limit", genrePageSize, genrePageMax)
offset := queryOffset(r, "offset")
itemType, ok := genreItemType(r.URL.Query().Get("type"))
if !ok || (genre == "" && itemType == "Movie,Series") {
if !ok {
writeError(w, http.StatusBadRequest, "type must be Movie or Series")
return
}
@@ -145,8 +145,15 @@ func (s *Server) handleBrowseItems(
writeRaw(w, http.StatusOK, body)
}
// genreItemType keeps the old mixed search as the default for Search, while the Movies
// and TV Series destinations can ask for a shelf that never crosses media types.
// genreItemType keeps the mixed shelf as the default, which is what the Search chips and
// the Genres destination ask for, while the Movies and TV Series destinations name a type
// and get a shelf that never crosses media types.
//
// The unfiltered browse used to refuse the mixed type, on the reasoning that a whole
// library with no genre and no media type is not a shelf anybody asked for. The Genres
// destination is exactly that request — its "All genres" entry is the catalogue itself —
// and refusing it here only made the one entry at the top of that rail the one entry that
// could not answer.
func genreItemType(value string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "":
+29 -8
View File
@@ -46,21 +46,43 @@ func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.S
writeError(w, http.StatusBadRequest, "item id is required")
return
}
// Version the entry when the detail contract grows so older cached payloads cannot
// hide newly requested fields such as People or the stored ratings.
key := cache.UserKey(sess.EmbyUserID, "item:v6:"+itemID)
if raw, err := s.cache.Get(ctx, key); err == nil {
if raw, err := s.cache.Get(ctx, itemDetailKey(sess.EmbyUserID, itemID)); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
return
}
item, err := s.emby.Item(ctx, credentials(sess), itemID, fieldsDetail)
item, err := s.detailItem(ctx, sess, itemID)
if err != nil {
s.writeUpstreamError(ctx, w, err, "could not load the item")
return
}
w.Header().Set("X-Memby-Cache", "miss")
writeRaw(w, http.StatusOK, item)
}
// itemDetailKey is versioned so that when the detail contract grows, older cached payloads
// cannot hide newly requested fields such as People or the stored ratings.
func itemDetailKey(userID, itemID string) string {
return cache.UserKey(userID, "item:v6:"+itemID)
}
// detailItem is the full record for one item, decorated and kept.
//
// It is shared rather than private to the item route because a Magic press hands back a
// title the television is about to open a detail page for — and before this, that press
// paid its own uncached Emby lookup and then the page paid a second one a moment later.
func (s *Server) detailItem(
ctx context.Context, sess store.Session, itemID string,
) (json.RawMessage, error) {
key := itemDetailKey(sess.EmbyUserID, itemID)
if raw, err := s.cache.Get(ctx, key); err == nil {
return raw, nil
}
item, err := s.emby.Item(ctx, credentials(sess), itemID, fieldsDetail)
if err != nil {
return nil, err
}
// A detail page can then draw its ratings with the rest of the hero rather than
// after a second request. Anything not yet stored still arrives on /ratings.
decorated := []json.RawMessage{item}
@@ -69,8 +91,7 @@ func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.S
if err := s.cache.Set(ctx, key, item, s.cfg.ItemTTL); err != nil {
s.loggerFor(ctx).Warn("item cache write failed", "error", err)
}
w.Header().Set("X-Memby-Cache", "miss")
writeRaw(w, http.StatusOK, item)
return item, nil
}
// handleSeasonFinale verifies an episode against Sonarr's complete season, including
+52 -7
View File
@@ -1,11 +1,14 @@
package api
import (
"context"
"encoding/json"
"math/rand"
"net/http"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -53,12 +56,24 @@ func (s *Server) handleMagic(w http.ResponseWriter, r *http.Request, sess store.
_ = json.NewDecoder(r.Body).Decode(&req)
}
selection, ok := s.recommender.MagicPick(ctx, credentials(sess), recommend.MagicOptions{
pool, cached := s.magicPool(ctx, sess)
selection, ok := recommend.ChooseMagic(pool, 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 && cached {
// Everything the kept pool held has already been offered. That is a pool that has
// run its course rather than a household with nothing left, so it is rebuilt once
// before the button is allowed to say no.
pool = s.rebuildMagicPool(ctx, sess)
selection, ok = recommend.ChooseMagic(pool, recommend.MagicOptions{
ExcludeIDs: req.ExcludeIDs,
AvailableMinutes: req.AvailableMinutes,
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.
@@ -67,24 +82,54 @@ func (s *Server) handleMagic(w http.ResponseWriter, r *http.Request, sess store.
return
}
item, err := s.emby.Item(ctx, credentials(sess), selection.Item.ID, fieldsDetail)
item, err := s.detailItem(ctx, sess, selection.ItemID)
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,
"item", selection.ItemID,
"title", selection.Title,
"score", selection.Score,
"pool", selection.PoolSize,
"signals", strings.Join(selection.Signals, ","),
)
writeJSON(w, http.StatusOK, magicResponse{
Item: decorated[0],
Item: item,
Reasons: selection.Reasons,
})
}
// magicPool returns the kept pool, and whether it came from the cache.
//
// The press is made with the film paused behind a loading panel, so what happens on it
// matters: building a pool is two complete reads of this viewer's Emby history plus a
// catalogue query, and none of that answer changes between one press and the next. Keeping
// it turns every press after the first into arithmetic over a few dozen numbers.
func (s *Server) magicPool(ctx context.Context, sess store.Session) ([]recommend.MagicCandidate, bool) {
if raw, err := s.cache.Get(ctx, cache.MagicPoolKey(sess.EmbyUserID)); err == nil {
var pool []recommend.MagicCandidate
if err := json.Unmarshal(raw, &pool); err == nil && len(pool) > 0 {
return pool, true
}
}
return s.rebuildMagicPool(ctx, sess), false
}
func (s *Server) rebuildMagicPool(ctx context.Context, sess store.Session) []recommend.MagicCandidate {
pool := s.recommender.MagicPool(ctx, credentials(sess), time.Time{})
if len(pool) == 0 {
// Deliberately not cached: an empty pool is a household whose library or Emby was
// unavailable far more often than it is one with no films, and keeping that answer
// would withdraw the button for hours over a moment's trouble.
return nil
}
if raw, err := json.Marshal(pool); err == nil {
if err := s.cache.Set(ctx, cache.MagicPoolKey(sess.EmbyUserID), raw, s.cfg.MagicPoolTTL); err != nil {
s.loggerFor(ctx).Warn("magic pool cache write failed", "error", err)
}
}
return pool
}
-86
View File
@@ -1,86 +0,0 @@
package api
import (
"bytes"
"encoding/json"
"io"
"net/http"
"regexp"
"strings"
"time"
)
var releaseBuilderTagPattern = regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
type releaseBuilderRequest struct {
Tag string `json:"tag"`
Notes string `json:"notes"`
Mandatory bool `json:"mandatory"`
}
func (s *Server) handleAdminReleaseBuilderStatus(w http.ResponseWriter, r *http.Request) {
s.relayReleaseBuilder(w, r, http.MethodGet, "/v1/status", nil)
}
func (s *Server) handleAdminReleaseBuilderStart(w http.ResponseWriter, r *http.Request) {
if s.rejectWorkDuringQuietTime(w) {
return
}
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
var request releaseBuilderRequest
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
writeError(w, http.StatusBadRequest, "invalid release request")
return
}
request.Tag = strings.TrimSpace(request.Tag)
request.Notes = strings.TrimSpace(request.Notes)
if request.Tag != "" && !releaseBuilderTagPattern.MatchString(request.Tag) {
writeError(w, http.StatusBadRequest, "tag must be blank or look like v0.2.64")
return
}
if len(request.Notes) > 4000 {
writeError(w, http.StatusBadRequest, "release notes are too long")
return
}
body, err := json.Marshal(request)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not prepare release request")
return
}
s.relayReleaseBuilder(w, r, http.MethodPost, "/v1/releases", body)
}
func (s *Server) relayReleaseBuilder(w http.ResponseWriter, incoming *http.Request, method, path string, body []byte) {
if s.cfg.ReleaseBuilderURL == "" || s.cfg.ReleasePublishToken == "" {
writeError(w, http.StatusServiceUnavailable, "the Docker release builder is not configured")
return
}
request, err := http.NewRequestWithContext(incoming.Context(), method,
s.cfg.ReleaseBuilderURL+path, bytes.NewReader(body))
if err != nil {
writeError(w, http.StatusInternalServerError, "could not prepare builder request")
return
}
request.Header.Set("Authorization", "Bearer "+s.cfg.ReleasePublishToken)
if len(body) > 0 {
request.Header.Set("Content-Type", "application/json")
}
client := &http.Client{Timeout: 10 * time.Second}
response, err := client.Do(request)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "the Docker release builder is not available")
return
}
defer response.Body.Close()
payload, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
writeError(w, http.StatusBadGateway, "could not read the Docker release builder response")
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(response.StatusCode)
_, _ = w.Write(payload)
}
@@ -1,62 +0,0 @@
package api
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/ponzischeme89/memby/server/internal/config"
)
func TestAdminReleaseBuilderRelaysWithoutExposingToken(t *testing.T) {
var receivedAuth string
builder := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedAuth = r.Header.Get("Authorization")
if r.Method != http.MethodPost || r.URL.Path != "/v1/releases" {
t.Fatalf("builder request = %s %s", r.Method, r.URL.Path)
}
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), `"tag":"v0.2.64"`) || !strings.Contains(string(body), `"mandatory":true`) {
t.Fatalf("builder body = %s", body)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_, _ = io.WriteString(w, `{"state":"running","logs":[]}`)
}))
defer builder.Close()
s := &Server{cfg: config.Config{ReleaseBuilderURL: builder.URL, ReleasePublishToken: "release-secret"}}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/admin/api/release-builder",
strings.NewReader(`{"tag":"v0.2.64","notes":"Living room polish","mandatory":true}`))
s.handleAdminReleaseBuilderStart(recorder, request)
if recorder.Code != http.StatusAccepted || receivedAuth != "Bearer release-secret" {
t.Fatalf("response/auth = %d/%q", recorder.Code, receivedAuth)
}
if strings.Contains(recorder.Body.String(), "release-secret") {
t.Fatal("release token was exposed to the browser")
}
}
func TestAdminReleaseBuilderValidatesTagBeforeRelay(t *testing.T) {
s := &Server{cfg: config.Config{ReleaseBuilderURL: "http://builder", ReleasePublishToken: "secret"}}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/admin/api/release-builder",
strings.NewReader(`{"tag":"latest; rm -rf /"}`))
s.handleAdminReleaseBuilderStart(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid tag status = %d, want 400", recorder.Code)
}
}
func TestAdminReleaseBuilderIsUnavailableWhenUnconfigured(t *testing.T) {
s := &Server{}
recorder := httptest.NewRecorder()
s.handleAdminReleaseBuilderStatus(recorder, httptest.NewRequest(http.MethodGet, "/admin/api/release-builder", nil))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("unconfigured builder status = %d, want 503", recorder.Code)
}
}