0.2.73
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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 "":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user