Server changes/Sonarr

This commit is contained in:
ponzischeme89
2026-07-27 21:06:51 +12:00
parent 62f6345a40
commit 8d6cf2f5a1
42 changed files with 2388 additions and 284 deletions
+1
View File
@@ -29,6 +29,7 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance))
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
mux.Handle("POST /admin/api/release", s.releasePublishAuth(s.handleReleasePublish))
return mux
}
+22
View File
@@ -97,6 +97,28 @@ func TestHealthAndAdminStayReachableDuringMaintenance(t *testing.T) {
}
}
func TestServiceStatusReportsMaintenanceOutsideTheGate(t *testing.T) {
server := testServer(config.Config{})
server.maintenance.set(store.Maintenance{Enabled: true, Message: "Back after dinner"})
rec := httptest.NewRecorder()
server.handleServiceStatus(
rec,
httptest.NewRequest(http.MethodGet, "/v1/status", nil),
store.Session{},
)
if rec.Code != http.StatusOK {
t.Fatalf("status endpoint returned %d", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["maintenance"] != true || body["message"] != "Back after dinner" {
t.Fatalf("unexpected status response: %v", body)
}
}
func TestAdminIsDisabledWithoutAToken(t *testing.T) {
server := testServer(config.Config{})
+41 -3
View File
@@ -18,12 +18,14 @@ import (
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -33,8 +35,10 @@ type Server struct {
store *store.Store
cache *cache.Cache
recommender *recommend.Engine
sonarr *sonarr.Client
syncer syncerHandle
log *slog.Logger
sonarrMu sync.Mutex
recommendationBuilds recommendationBuilds
maintenance maintenanceState
@@ -48,17 +52,22 @@ type Deps struct {
Store *store.Store
Cache *cache.Cache
Recommender *recommend.Engine
Sonarr *sonarr.Client
Syncer syncerHandle
Log *slog.Logger
}
func New(cfg config.Config, deps Deps) *Server {
if cfg.MaxClientsPerUser < 1 {
cfg.MaxClientsPerUser = 1
}
return &Server{
cfg: cfg,
emby: deps.Emby,
store: deps.Store,
cache: deps.Cache,
recommender: deps.Recommender,
sonarr: deps.Sonarr,
syncer: deps.Syncer,
log: deps.Log,
}
@@ -70,6 +79,7 @@ func (s *Server) Routes() http.Handler {
v1 := http.NewServeMux()
v1.HandleFunc("POST /v1/auth/login", s.handleLogin)
v1.HandleFunc("GET /v1/auth/policy", s.handleAuthPolicy)
v1.Handle("POST /v1/auth/logout", s.authed(s.handleLogout))
v1.Handle("GET /v1/auth/session", s.authed(s.handleSession))
@@ -93,8 +103,12 @@ func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.handleHealth)
mux.HandleFunc("GET /readyz", s.handleReady)
// Exact route outside the maintenance gate: signed-in clients poll this lightweight
// status even while every normal /v1 operation is deliberately unavailable.
mux.Handle("GET /v1/status", s.authed(s.handleServiceStatus))
mux.Handle("/v1/", s.maintenanceGate(v1))
mux.Handle("/admin/", s.adminRoutes())
mux.HandleFunc("GET /updates/{filename}", s.handleReleaseDownload)
return s.withLogging(mux)
}
@@ -134,15 +148,33 @@ func (s *Server) withLogging(next http.Handler) http.Handler {
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
// Path only: query strings can carry image tokens.
s.log.Info("request",
level := requestLogLevel(r.URL.Path, rec.status)
s.log.Log(r.Context(), level, "HTTP request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"ms", time.Since(start).Milliseconds(),
"duration", time.Since(start).Round(time.Millisecond),
)
})
}
// Successful high-frequency probes and artwork fetches stay available at DEBUG without
// overwhelming the normal Docker log. Failures are always promoted so they remain
// visible regardless of path.
func requestLogLevel(path string, status int) slog.Level {
switch {
case status >= http.StatusInternalServerError:
return slog.LevelError
case status >= http.StatusBadRequest:
return slog.LevelWarn
case path == "/healthz", path == "/readyz", path == "/v1/status",
strings.HasPrefix(path, "/v1/images/"):
return slog.LevelDebug
default:
return slog.LevelInfo
}
}
type statusRecorder struct {
http.ResponseWriter
status int
@@ -184,6 +216,7 @@ type cachedSession struct {
Username string `json:"n"`
ServerID string `json:"s"`
DeviceID string `json:"d"`
DeviceName string `json:"dn,omitempty"`
}
// sessionFor resolves a token, using Redis to keep the hot path off Postgres.
@@ -201,6 +234,7 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e
Username: cs.Username,
ServerID: cs.ServerID,
DeviceID: cs.DeviceID,
DeviceName: cs.DeviceName,
}, nil
}
}
@@ -220,6 +254,7 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e
Username: sess.Username,
ServerID: sess.ServerID,
DeviceID: sess.DeviceID,
DeviceName: sess.DeviceName,
}); err == nil {
_ = s.cache.Set(ctx, key, raw, s.cfg.SessionTTL)
}
@@ -231,7 +266,10 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e
}
func credentials(sess store.Session) emby.Credentials {
return emby.Credentials{UserID: sess.EmbyUserID, Token: sess.EmbyToken, DeviceID: sess.DeviceID}
return emby.Credentials{
UserID: sess.EmbyUserID, Token: sess.EmbyToken,
DeviceID: sess.DeviceID, DeviceName: sess.DeviceName,
}
}
// --- responses --------------------------------------------------------------
+59
View File
@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"testing"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby"
)
@@ -57,6 +58,64 @@ func TestNewTokenIsUnique(t *testing.T) {
}
}
func TestAuthPolicyPublishesConfiguredDeviceAllowance(t *testing.T) {
s := &Server{cfg: config.Config{MaxClientsPerUser: 4}}
req := httptest.NewRequest(http.MethodGet, "/v1/auth/policy", nil)
rec := httptest.NewRecorder()
s.handleAuthPolicy(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var policy authPolicyResponse
if err := json.Unmarshal(rec.Body.Bytes(), &policy); err != nil {
t.Fatalf("decode policy: %v", err)
}
if policy.MaxClientsPerUser != 4 {
t.Fatalf("max clients = %d, want 4", policy.MaxClientsPerUser)
}
}
func TestSonarrScheduleRequiresCapableClient(t *testing.T) {
tests := map[string]bool{
"": false,
"0.1.53": false,
"0.1.54": true,
"0.2.0": true,
}
for version, want := range tests {
req := httptest.NewRequest(http.MethodGet, "/v1/home", nil)
req.Header.Set("X-Memby-Version", version)
if got := supportsSonarrSchedule(req); got != want {
t.Errorf("supportsSonarrSchedule(%q) = %v, want %v", version, got, want)
}
}
}
func TestPlaybackHintAvoidsAnUpstreamItemLookup(t *testing.T) {
req := httptest.NewRequest(
http.MethodGet,
"/v1/items/42/playback?type=Movie&title=Arrival&resumePositionMs=12000",
nil,
)
item, ok := playbackHint(req, "42")
if !ok {
t.Fatal("valid hint was rejected")
}
if item.ID != "42" || item.Type != "Movie" || item.Name != "Arrival" {
t.Fatalf("unexpected hinted item: %+v", item)
}
if item.UserData.PlaybackPositionTicks != 12_000*ticksPerMillisecond {
t.Fatalf("resume ticks = %d", item.UserData.PlaybackPositionTicks)
}
bad := httptest.NewRequest(http.MethodGet, "/v1/items/42/playback?type=Playlist", nil)
if _, ok := playbackHint(bad, "42"); ok {
t.Fatal("unsupported type should fall back to Emby")
}
}
// Empty rows must serialise as [] so kotlinx.serialization can decode them into the
// client's non-null List fields.
func TestHomeResponseEncodesEmptyRowsAsArrays(t *testing.T) {
+78 -16
View File
@@ -2,24 +2,39 @@ package api
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
)
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
DeviceID string `json:"deviceId"`
Username string `json:"username"`
Password string `json:"password"`
DeviceID string `json:"deviceId"`
DeviceName string `json:"deviceName"`
}
type loginResponse struct {
Token string `json:"token"`
UserID string `json:"userId"`
Username string `json:"username"`
ServerID string `json:"serverId"`
Token string `json:"token"`
UserID string `json:"userId"`
Username string `json:"username"`
ServerID string `json:"serverId"`
ActiveClients int `json:"activeClients,omitempty"`
MaxClientsPerUser int `json:"maxClientsPerUser"`
}
type authPolicyResponse struct {
MaxClientsPerUser int `json:"maxClientsPerUser"`
}
func (s *Server) handleAuthPolicy(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, authPolicyResponse{
MaxClientsPerUser: s.cfg.MaxClientsPerUser,
})
}
// handleLogin exchanges Emby credentials for a gateway token.
@@ -40,8 +55,21 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if req.DeviceID == "" {
req.DeviceID = "memby-tv"
}
req.DeviceName = strings.TrimSpace(req.DeviceName)
if req.DeviceName == "" {
// Compatibility for APKs released before device naming. New clients require an
// editable name in their UI, but an older TV must still be able to sign in while
// the household rollout is in progress.
req.DeviceName = "Memby TV"
}
if len([]rune(req.DeviceName)) > 80 {
writeError(w, http.StatusBadRequest, "device name is too long")
return
}
auth, err := s.emby.Authenticate(r.Context(), req.Username, req.Password, req.DeviceID)
auth, err := s.emby.Authenticate(
r.Context(), req.Username, req.Password, req.DeviceID, req.DeviceName,
)
if err != nil {
// Never echo Emby's body here: a failed sign-in is the one place a wrong
// password could be reflected back.
@@ -64,21 +92,54 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
Username: auth.User.Name,
ServerID: auth.ServerID,
DeviceID: req.DeviceID,
DeviceName: req.DeviceName,
}
if sess.Username == "" {
sess.Username = req.Username
}
if err := s.store.CreateSession(r.Context(), sess); err != nil {
replacedHash, activeClients, err := s.store.CreateSession(
r.Context(), sess, s.cfg.MaxClientsPerUser,
)
if errors.Is(err, store.ErrDeviceLimit) {
if revokeErr := s.emby.Logout(r.Context(), emby.Credentials{
UserID: auth.User.ID, Token: auth.AccessToken,
DeviceID: req.DeviceID, DeviceName: req.DeviceName,
}); revokeErr != nil {
s.log.Warn("could not retire refused emby session", "error", revokeErr)
}
s.log.Warn("device allowance reached",
"username", sess.Username,
"active_clients", activeClients,
"max_clients", s.cfg.MaxClientsPerUser,
)
writeJSON(w, http.StatusConflict, map[string]any{
"error": "device_limit_reached",
"message": "This account has reached its Memby device allowance.",
"activeClients": activeClients,
"maxClientsPerUser": s.cfg.MaxClientsPerUser,
})
return
}
if err != nil {
_ = s.emby.Logout(r.Context(), emby.Credentials{
UserID: auth.User.ID, Token: auth.AccessToken,
DeviceID: req.DeviceID, DeviceName: req.DeviceName,
})
s.log.Error("session persist failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not start a session")
return
}
if len(replacedHash) > 0 {
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(replacedHash)))
}
writeJSON(w, http.StatusOK, loginResponse{
Token: token,
UserID: sess.EmbyUserID,
Username: sess.Username,
ServerID: sess.ServerID,
Token: token,
UserID: sess.EmbyUserID,
Username: sess.Username,
ServerID: sess.ServerID,
ActiveClients: activeClients,
MaxClientsPerUser: s.cfg.MaxClientsPerUser,
})
}
@@ -94,8 +155,9 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, sess store
// handleSession lets the TV confirm a stored token is still good before rendering.
func (s *Server) handleSession(w http.ResponseWriter, _ *http.Request, sess store.Session) {
writeJSON(w, http.StatusOK, loginResponse{
UserID: sess.EmbyUserID,
Username: sess.Username,
ServerID: sess.ServerID,
UserID: sess.EmbyUserID,
Username: sess.Username,
ServerID: sess.ServerID,
MaxClientsPerUser: s.cfg.MaxClientsPerUser,
})
}
+33 -5
View File
@@ -8,6 +8,7 @@ import (
"strconv"
"sync"
"github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/recommend"
@@ -63,10 +64,11 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
cred := credentials(sess)
var (
mu sync.Mutex
failures int
out homeResponse
wg sync.WaitGroup
mu sync.Mutex
failures int
out homeResponse
sonarrRow *recommend.Row
wg sync.WaitGroup
)
run := func(dest *[]json.RawMessage, fetch func() (*emby.ItemsResult, error)) {
@@ -119,6 +121,20 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
"Limit": {itoa(limit)},
}, fieldsRow))
})
if s.sonarr != nil && supportsSonarrSchedule(r) {
wg.Add(1)
go func() {
defer wg.Done()
row, err := s.sonarrAiringTodayRow(ctx)
if err != nil {
s.log.Warn("sonarr calendar row failed", "error", err)
return
}
mu.Lock()
sonarrRow = row
mu.Unlock()
}()
}
wg.Wait()
@@ -136,7 +152,12 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
if recommendations == nil {
s.refreshRecommendationsInBackground(sess)
}
out.Rows = append(baseRows(out), recommendations...)
rows := baseRows(out)
if sonarrRow != nil {
// The schedule is most useful beside Next Up, before personal collections.
rows = append(rows[:2], append([]recommend.Row{*sonarrRow}, rows[2:]...)...)
}
out.Rows = append(rows, recommendations...)
body, err := json.Marshal(out)
if err != nil {
@@ -154,6 +175,13 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
writeRaw(w, http.StatusOK, body)
}
// Older clients render unknown rows but do not understand MembyPlayable=false, so they
// could try to send a synthetic Sonarr id to Emby. The feature ships with 0.1.54.
func supportsSonarrSchedule(r *http.Request) bool {
version := clientVersion(r)
return version != "" && appupdate.CompareVersions(version, "0.1.54") >= 0
}
// handleScreensaver serves the backdrop pool. The pool is cached and shuffled per
// request, so the Dream still looks random without re-querying Emby every few seconds.
func (s *Server) handleScreensaver(w http.ResponseWriter, r *http.Request, sess store.Session) {
+52
View File
@@ -1,11 +1,14 @@
package api
import (
"errors"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -29,6 +32,10 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request, sess store.
writeError(w, http.StatusNotFound, "unknown image")
return
}
if strings.HasPrefix(itemID, "sonarr:") {
s.handleSonarrImage(w, r, itemID, imageType)
return
}
params := url.Values{}
for _, key := range []string{"tag", "maxWidth", "maxHeight", "quality"} {
@@ -63,3 +70,48 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request, sess store.
s.log.Warn("image copy failed", "error", err)
}
}
func (s *Server) handleSonarrImage(w http.ResponseWriter, r *http.Request, itemID, imageType string) {
if s.sonarr == nil {
writeError(w, http.StatusNotFound, "unknown image")
return
}
parts := strings.Split(itemID, ":")
if len(parts) != 3 {
writeError(w, http.StatusNotFound, "unknown image")
return
}
seriesID, err := strconv.Atoi(parts[1])
if err != nil || seriesID <= 0 {
writeError(w, http.StatusNotFound, "unknown image")
return
}
coverType := map[string]string{"Primary": "poster", "Backdrop": "fanart"}[imageType]
if coverType == "" {
writeError(w, http.StatusNotFound, "unknown image")
return
}
resp, err := s.sonarr.MediaCover(r.Context(), seriesID, coverType)
if err != nil {
var apiErr *sonarr.APIError
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
writeError(w, http.StatusNotFound, "image not found")
return
}
s.log.Warn("sonarr image failed", "series_id", seriesID, "type", coverType, "error", err)
writeError(w, http.StatusBadGateway, "could not load the image")
return
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); ct != "" {
w.Header().Set("Content-Type", ct)
}
if cl := resp.Header.Get("Content-Length"); cl != "" {
w.Header().Set("Content-Length", cl)
}
w.Header().Set("Cache-Control", "private, max-age=3600")
w.WriteHeader(http.StatusOK)
if _, err := io.Copy(w, resp.Body); err != nil {
s.log.Warn("sonarr image copy failed", "error", err)
}
}
+15
View File
@@ -85,3 +85,18 @@ func (s *Server) maintenanceGate(next http.Handler) http.Handler {
})
})
}
// handleServiceStatus is the live control channel clients poll while the app is open.
// It sits outside maintenanceGate so maintenance can interrupt playback rather than only
// being discovered the next time a content request happens to run.
func (s *Server) handleServiceStatus(w http.ResponseWriter, _ *http.Request, _ store.Session) {
state := s.maintenance.get()
message := state.Message
if state.Enabled && message == "" {
message = store.DefaultMaintenanceMessage
}
writeJSON(w, http.StatusOK, map[string]any{
"maintenance": state.Enabled,
"message": message,
})
}
+38 -9
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/ponzischeme89/memby/server/internal/emby"
@@ -40,15 +41,18 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
}
cred := credentials(sess)
raw, err := s.emby.Item(ctx, cred, itemID, "RunTimeTicks,SeriesName")
if err != nil {
s.writeUpstreamError(w, err, "could not load the item")
return
}
item, err := emby.Summarise(raw)
if err != nil {
writeError(w, http.StatusBadGateway, "unreadable item from emby")
return
item, hinted := playbackHint(r, itemID)
if !hinted {
raw, err := s.emby.Item(ctx, cred, itemID, "RunTimeTicks,SeriesName")
if err != nil {
s.writeUpstreamError(w, err, "could not load the item")
return
}
item, err = emby.Summarise(raw)
if err != nil {
writeError(w, http.StatusBadGateway, "unreadable item from emby")
return
}
}
target := item
@@ -78,6 +82,31 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
})
}
// Current clients already know the selected item's type, title and cached resume point.
// Accepting those as hints removes one serial Emby request from every launch. Older
// clients omit them and retain the authoritative lookup above.
func playbackHint(r *http.Request, itemID string) (emby.Summary, bool) {
itemType := strings.TrimSpace(r.URL.Query().Get("type"))
switch {
case strings.EqualFold(itemType, "Movie"):
itemType = "Movie"
case strings.EqualFold(itemType, "Episode"):
itemType = "Episode"
case strings.EqualFold(itemType, "Series"):
itemType = "Series"
default:
return emby.Summary{}, false
}
resumeMs, _ := strconv.ParseInt(r.URL.Query().Get("resumePositionMs"), 10, 64)
item := emby.Summary{
ID: itemID,
Name: strings.TrimSpace(r.URL.Query().Get("title")),
Type: itemType,
}
item.UserData.PlaybackPositionTicks = max64(resumeMs, 0) * ticksPerMillisecond
return item, true
}
// firstPlayableEpisode prefers the server's next-up choice and falls back to episode one.
func (s *Server) firstPlayableEpisode(ctx context.Context, cred emby.Credentials, seriesID string) (*emby.Summary, error) {
nextUp, err := s.emby.NextUp(ctx, cred, url.Values{