Server changes/Sonarr
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{})
|
||||
|
||||
|
||||
@@ -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 --------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user