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{
|
||||
|
||||
@@ -79,6 +79,10 @@ func Decide(policy Policy, clientVersion string) Decision {
|
||||
return none
|
||||
}
|
||||
|
||||
// CompareVersions returns -1, 0, or 1 and is shared by policy decisions and the release
|
||||
// publisher's downgrade guard.
|
||||
func CompareVersions(a, b string) int { return compare(parseVersion(a), parseVersion(b)) }
|
||||
|
||||
// compare returns -1, 0 or 1. Missing components count as zero, so 0.1 == 0.1.0.
|
||||
func compare(a, b []int) int {
|
||||
for i := 0; i < len(a) || i < len(b); i++ {
|
||||
|
||||
Vendored
+1
-1
@@ -87,7 +87,7 @@ func UserKey(userID, view string) string { return fmt.Sprintf("u:%s:%s", userID,
|
||||
// Recommendations cost several Emby queries to build, so they must survive the cache
|
||||
// wipe that every favourite toggle triggers. Only a genuine change in viewing history
|
||||
// — a finished playback — retires them, via [Cache.InvalidateRecommendations].
|
||||
func RecommendationsKey(userID string) string { return fmt.Sprintf("r:%s:rows", userID) }
|
||||
func RecommendationsKey(userID string) string { return fmt.Sprintf("r:%s:rows:v2", userID) }
|
||||
|
||||
func (c *Cache) InvalidateRecommendations(ctx context.Context, userID string) error {
|
||||
return c.Delete(ctx, RecommendationsKey(userID))
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
_ "time/tzdata"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -33,6 +34,8 @@ type Config struct {
|
||||
SessionTTL time.Duration
|
||||
// SessionIdleExpiry retires gateway tokens that go unused for this long.
|
||||
SessionIdleExpiry time.Duration
|
||||
// MaxClientsPerUser is enforced transactionally when a new TV signs in.
|
||||
MaxClientsPerUser int
|
||||
|
||||
// RecommendTTL is how long computed recommendation rows stay warm. Long, because
|
||||
// taste moves slowly and each rebuild costs several Emby queries.
|
||||
@@ -47,6 +50,14 @@ type Config struct {
|
||||
// unconfigured deployment cannot leave it exposed.
|
||||
AdminToken string
|
||||
|
||||
// PublicURL is the externally reachable Memby gateway address used in update links.
|
||||
PublicURL string
|
||||
// ReleaseDir persists signed APKs published by CI.
|
||||
ReleaseDir string
|
||||
// ReleasePublishToken authorizes the CI-only release upload endpoint. It is separate
|
||||
// from AdminToken so a compromised build runner cannot change maintenance settings.
|
||||
ReleasePublishToken string
|
||||
|
||||
// SyncInterval is how often the library import runs. Zero disables the schedule.
|
||||
SyncInterval time.Duration
|
||||
// SyncTimeout bounds one import; a full pass over a large library is slow.
|
||||
@@ -60,6 +71,13 @@ type Config struct {
|
||||
|
||||
// AnalyticsRetention is how long raw row events are kept before being pruned.
|
||||
AnalyticsRetention time.Duration
|
||||
|
||||
// Sonarr is optional. When configured, its local calendar supplies the informational
|
||||
// "Shows airing today" home row. The API key never leaves this server.
|
||||
SonarrURL string
|
||||
SonarrAPIKey string
|
||||
SonarrTTL time.Duration
|
||||
SonarrLocation *time.Location
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
@@ -76,10 +94,16 @@ func Load() (Config, error) {
|
||||
ScreensaverTTL: duration("MEMBY_SCREENSAVER_TTL", 10*time.Minute),
|
||||
SessionTTL: duration("MEMBY_SESSION_CACHE_TTL", 5*time.Minute),
|
||||
SessionIdleExpiry: duration("MEMBY_SESSION_IDLE_EXPIRY", 90*24*time.Hour),
|
||||
MaxClientsPerUser: integer("MEMBY_MAX_CLIENTS_PER_USER", 1),
|
||||
RecommendTTL: duration("MEMBY_RECOMMEND_TTL", 2*time.Hour),
|
||||
RecommendTimeout: duration("MEMBY_RECOMMEND_TIMEOUT", 60*time.Second),
|
||||
|
||||
AdminToken: strings.TrimSpace(os.Getenv("MEMBY_ADMIN_TOKEN")),
|
||||
AdminToken: strings.TrimSpace(os.Getenv("MEMBY_ADMIN_TOKEN")),
|
||||
PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_PUBLIC_URL")), "/"),
|
||||
ReleaseDir: env("MEMBY_RELEASE_DIR", "/data/releases"),
|
||||
ReleasePublishToken: strings.TrimSpace(
|
||||
os.Getenv("MEMBY_RELEASE_PUBLISH_TOKEN"),
|
||||
),
|
||||
SyncInterval: duration("MEMBY_SYNC_INTERVAL", time.Hour),
|
||||
SyncTimeout: duration("MEMBY_SYNC_TIMEOUT", 30*time.Minute),
|
||||
SyncOnStart: boolean("MEMBY_SYNC_ON_START", false),
|
||||
@@ -87,6 +111,9 @@ func Load() (Config, error) {
|
||||
SyncAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SYNC_API_KEY")),
|
||||
AnalyticsRetention: duration("MEMBY_ANALYTICS_RETENTION", 90*24*time.Hour),
|
||||
UpstreamTimeout: duration("MEMBY_UPSTREAM_TIMEOUT", 20*time.Second),
|
||||
SonarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_SONARR_URL")), "/"),
|
||||
SonarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SONARR_API_KEY")),
|
||||
SonarrTTL: duration("MEMBY_SONARR_TTL", 5*time.Minute),
|
||||
}
|
||||
|
||||
if c.EmbyURL == "" {
|
||||
@@ -95,9 +122,23 @@ func Load() (Config, error) {
|
||||
if c.DatabaseURL == "" {
|
||||
return c, fmt.Errorf("MEMBY_DATABASE_URL is required")
|
||||
}
|
||||
if c.MaxClientsPerUser < 1 {
|
||||
return c, fmt.Errorf("MEMBY_MAX_CLIENTS_PER_USER must be at least 1")
|
||||
}
|
||||
if c.EmbyPublicURL == "" {
|
||||
c.EmbyPublicURL = c.EmbyURL
|
||||
}
|
||||
if c.ReleasePublishToken != "" && c.PublicURL == "" {
|
||||
return c, fmt.Errorf("MEMBY_PUBLIC_URL is required when release publishing is enabled")
|
||||
}
|
||||
if (c.SonarrURL == "") != (c.SonarrAPIKey == "") {
|
||||
return c, fmt.Errorf("MEMBY_SONARR_URL and MEMBY_SONARR_API_KEY must be set together")
|
||||
}
|
||||
location, err := time.LoadLocation(env("MEMBY_TIMEZONE", "Pacific/Auckland"))
|
||||
if err != nil {
|
||||
return c, fmt.Errorf("MEMBY_TIMEZONE: %w", err)
|
||||
}
|
||||
c.SonarrLocation = location
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@@ -134,3 +175,15 @@ func duration(key string, fallback time.Duration) time.Duration {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func integer(key string, fallback int) int {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -27,9 +27,10 @@ type Client struct {
|
||||
|
||||
// Credentials identify one signed-in Emby user.
|
||||
type Credentials struct {
|
||||
UserID string
|
||||
Token string
|
||||
DeviceID string
|
||||
UserID string
|
||||
Token string
|
||||
DeviceID string
|
||||
DeviceName string
|
||||
}
|
||||
|
||||
type ItemsResult struct {
|
||||
@@ -82,13 +83,13 @@ func New(baseURL, publicURL, clientName string, timeout time.Duration) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Authenticate(ctx context.Context, username, password, deviceID string) (*AuthResult, error) {
|
||||
func (c *Client) Authenticate(ctx context.Context, username, password, deviceID, deviceName string) (*AuthResult, error) {
|
||||
body, err := json.Marshal(map[string]string{"Username": username, "Pw": password})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := c.newRequest(ctx, http.MethodPost, "/Users/AuthenticateByName", nil,
|
||||
Credentials{DeviceID: deviceID}, bytes.NewReader(body))
|
||||
Credentials{DeviceID: deviceID, DeviceName: deviceName}, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -104,6 +105,16 @@ func (c *Client) Authenticate(ctx context.Context, username, password, deviceID
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// Logout retires a token created during authentication. The gateway uses this when a
|
||||
// device is refused by policy so Emby is not left holding an orphaned session.
|
||||
func (c *Client) Logout(ctx context.Context, cred Credentials) error {
|
||||
req, err := c.newRequest(ctx, http.MethodPost, "/Sessions/Logout", nil, cred, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.do(req, nil)
|
||||
}
|
||||
|
||||
func (c *Client) Items(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) {
|
||||
return c.items(ctx, cred, "/Users/"+url.PathEscape(cred.UserID)+"/Items", params)
|
||||
}
|
||||
@@ -285,10 +296,14 @@ func (c *Client) newRequest(ctx context.Context, method, path string, params url
|
||||
if deviceID == "" {
|
||||
deviceID = "memby-gateway"
|
||||
}
|
||||
deviceName := strings.TrimSpace(cred.DeviceName)
|
||||
if deviceName == "" {
|
||||
deviceName = "Memby TV"
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-Emby-Authorization", fmt.Sprintf(
|
||||
`MediaBrowser Client="%s", Device="Memby Gateway", DeviceId="%s", Version="1.0"`,
|
||||
c.clientName, deviceID,
|
||||
`MediaBrowser Client="%s", Device="%s", DeviceId="%s", Version="1.0"`,
|
||||
c.clientName, strings.ReplaceAll(deviceName, `"`, ""), deviceID,
|
||||
))
|
||||
if cred.Token != "" {
|
||||
req.Header.Set("X-Emby-Token", cred.Token)
|
||||
|
||||
@@ -122,6 +122,7 @@ func (s *Syncer) Sync(ctx context.Context, kind, trigger string) (Result, error)
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
s.log.Info("library sync started", "kind", kind, "trigger", trigger)
|
||||
result, syncErr := s.run(ctx, cred, kind, since, startedAt)
|
||||
result.Kind = kind
|
||||
result.Duration = time.Since(startedAt)
|
||||
@@ -147,7 +148,8 @@ func (s *Syncer) Sync(ctx context.Context, kind, trigger string) (Result, error)
|
||||
}
|
||||
s.log.Info("library sync finished",
|
||||
"kind", kind, "trigger", trigger, "seen", result.Seen,
|
||||
"upserted", result.Upserted, "removed", result.Removed, "ms", result.DurationMs)
|
||||
"upserted", result.Upserted, "removed", result.Removed,
|
||||
"duration", result.Duration.Round(time.Millisecond))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -204,6 +206,11 @@ func (s *Syncer) run(
|
||||
|
||||
result.Seen += len(page.Items)
|
||||
result.Upserted += int(written)
|
||||
s.log.Info("library sync progress",
|
||||
"kind", kind,
|
||||
"seen", result.Seen,
|
||||
"upserted", result.Upserted,
|
||||
)
|
||||
|
||||
if len(page.Items) < pageSize {
|
||||
break
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -33,6 +34,27 @@ type LibrarySource interface {
|
||||
LibraryCandidates(ctx context.Context, genres []string, limit int) ([]json.RawMessage, error)
|
||||
}
|
||||
|
||||
// CuratedLibrarySource is the optional richer catalogue query used for server-authored
|
||||
// collections. Keeping it separate preserves compatibility with simpler test sources.
|
||||
type CuratedLibrarySource interface {
|
||||
CuratedCandidates(
|
||||
ctx context.Context,
|
||||
itemTypes, genres, studios []string,
|
||||
limit int,
|
||||
) ([]json.RawMessage, error)
|
||||
}
|
||||
|
||||
// CuratedRow defines one reusable server-side shelf. Filtering determines membership;
|
||||
// the user's profile determines both item order and shelf order.
|
||||
type CuratedRow struct {
|
||||
ID string
|
||||
Title string
|
||||
Kind string
|
||||
ItemTypes []string
|
||||
Genres []string
|
||||
Studios []string
|
||||
}
|
||||
|
||||
type Engine struct {
|
||||
source Source
|
||||
log *slog.Logger
|
||||
@@ -47,6 +69,7 @@ type Engine struct {
|
||||
// screen rather than a wall of near-duplicates.
|
||||
MaxSimilarRows int
|
||||
RowSize int
|
||||
CuratedRows []CuratedRow
|
||||
}
|
||||
|
||||
func NewEngine(source Source, log *slog.Logger) *Engine {
|
||||
@@ -56,6 +79,29 @@ func NewEngine(source Source, log *slog.Logger) *Engine {
|
||||
MinRowItems: 4,
|
||||
MaxSimilarRows: 2,
|
||||
RowSize: 20,
|
||||
CuratedRows: []CuratedRow{
|
||||
{
|
||||
ID: "curated:apple-tv",
|
||||
Title: "Apple TV+ Shows",
|
||||
Kind: "shows",
|
||||
ItemTypes: []string{"Series"},
|
||||
Studios: []string{"Apple TV+", "Apple TV Plus", "Apple Studios"},
|
||||
},
|
||||
{
|
||||
ID: "curated:drama-shows",
|
||||
Title: "Drama TV Shows",
|
||||
Kind: "shows",
|
||||
ItemTypes: []string{"Series"},
|
||||
Genres: []string{"Drama"},
|
||||
},
|
||||
{
|
||||
ID: "curated:comedy-shows",
|
||||
Title: "Comedy TV Shows",
|
||||
Kind: "shows",
|
||||
ItemTypes: []string{"Series"},
|
||||
Genres: []string{"Comedy"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,25 +122,77 @@ func (e *Engine) BuildRows(ctx context.Context, cred emby.Credentials) ([]Row, e
|
||||
}
|
||||
|
||||
profile := BuildProfile(history, favorites)
|
||||
if profile.IsEmpty() {
|
||||
// A brand-new user has nothing to recommend from. No rows is the honest answer.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rows := make([]Row, 0, e.MaxSimilarRows+1)
|
||||
for _, seed := range e.seedsFor(profile) {
|
||||
row, ok := e.similarRow(ctx, cred, profile, seed)
|
||||
if ok {
|
||||
rows := make([]Row, 0, e.MaxSimilarRows+1+len(e.CuratedRows))
|
||||
if !profile.IsEmpty() {
|
||||
for _, seed := range e.seedsFor(profile) {
|
||||
row, ok := e.similarRow(ctx, cred, profile, seed)
|
||||
if ok {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
|
||||
if row, ok := e.historyRow(ctx, cred, profile); ok {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
|
||||
if row, ok := e.historyRow(ctx, cred, profile); ok {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
rows = append(rows, e.buildCuratedRows(ctx, profile)...)
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
|
||||
library, ok := e.Library.(CuratedLibrarySource)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
type rankedDefinition struct {
|
||||
definition CuratedRow
|
||||
affinity float64
|
||||
order int
|
||||
}
|
||||
definitions := make([]rankedDefinition, 0, len(e.CuratedRows))
|
||||
for order, definition := range e.CuratedRows {
|
||||
definitions = append(definitions, rankedDefinition{
|
||||
definition: definition,
|
||||
affinity: profile.CollectionAffinity(definition.Genres, definition.Studios),
|
||||
order: order,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(definitions, func(i, j int) bool {
|
||||
if definitions[i].affinity != definitions[j].affinity {
|
||||
return definitions[i].affinity > definitions[j].affinity
|
||||
}
|
||||
return definitions[i].order < definitions[j].order
|
||||
})
|
||||
|
||||
rows := make([]Row, 0, len(definitions))
|
||||
for _, ranked := range definitions {
|
||||
definition := ranked.definition
|
||||
raws, err := library.CuratedCandidates(
|
||||
ctx,
|
||||
definition.ItemTypes,
|
||||
definition.Genres,
|
||||
definition.Studios,
|
||||
e.RowSize*6,
|
||||
)
|
||||
if err != nil {
|
||||
e.log.Warn("curated row failed", "row", definition.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
items := RankCollection(profile, Decode(raws), e.RowSize)
|
||||
if len(items) < e.MinRowItems {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, Row{
|
||||
ID: definition.ID,
|
||||
Title: definition.Title,
|
||||
Kind: definition.Kind,
|
||||
Items: Raws(items),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// gatherSignals reads what the user has watched and favourited, in parallel.
|
||||
func (e *Engine) gatherSignals(ctx context.Context, cred emby.Credentials) (history, favorites []Item, err error) {
|
||||
var (
|
||||
|
||||
@@ -27,6 +27,31 @@ type fakeSource struct {
|
||||
similarSeeds []string
|
||||
}
|
||||
|
||||
type fakeCuratedLibrary struct {
|
||||
byGenre map[string][]json.RawMessage
|
||||
}
|
||||
|
||||
func (f *fakeCuratedLibrary) LibraryCandidates(
|
||||
_ context.Context,
|
||||
_ []string,
|
||||
_ int,
|
||||
) ([]json.RawMessage, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeCuratedLibrary) CuratedCandidates(
|
||||
_ context.Context,
|
||||
_ []string,
|
||||
genres, studios []string,
|
||||
_ int,
|
||||
) ([]json.RawMessage, error) {
|
||||
key := strings.Join(genres, "|")
|
||||
if len(studios) > 0 {
|
||||
key = "studio:" + studios[0]
|
||||
}
|
||||
return f.byGenre[key], nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) Items(_ context.Context, _ emby.Credentials, params url.Values) (*emby.ItemsResult, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@@ -191,6 +216,67 @@ func TestBuildRowsReturnsNothingForAUserWithNoHistory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCuratedShowRowsAndItemsAreOrderedByViewingAffinity(t *testing.T) {
|
||||
source := &fakeSource{
|
||||
itemsByFilter: map[string][]json.RawMessage{
|
||||
"IsPlayed": {
|
||||
raw("history", "Funny History", "Episode", "Comedy"),
|
||||
},
|
||||
},
|
||||
similar: map[string][]json.RawMessage{},
|
||||
}
|
||||
engine := testEngine(source)
|
||||
engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
|
||||
"Comedy": {
|
||||
raw("comedy-low", "Lower Rated Match", "Series", "Comedy"),
|
||||
raw("comedy-high", "Higher Rated Match", "Series", "Comedy"),
|
||||
},
|
||||
"Drama": {
|
||||
raw("drama-1", "Drama One", "Series", "Drama"),
|
||||
raw("drama-2", "Drama Two", "Series", "Drama"),
|
||||
},
|
||||
}}
|
||||
engine.CuratedRows = []CuratedRow{
|
||||
{ID: "drama", Title: "Drama TV Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Drama"}},
|
||||
{ID: "comedy", Title: "Comedy TV Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Comedy"}},
|
||||
}
|
||||
|
||||
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("expected two curated rows, got %v", rowTitles(rows))
|
||||
}
|
||||
if rows[0].ID != "comedy" || rows[1].ID != "drama" {
|
||||
t.Fatalf("user's comedy affinity should order shelves, got %v", rowTitles(rows))
|
||||
}
|
||||
if rows[0].Kind != "shows" {
|
||||
t.Fatalf("curated TV shelf kind = %q", rows[0].Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCuratedRowsFallBackToRatingForANewUser(t *testing.T) {
|
||||
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{}}
|
||||
engine := testEngine(source)
|
||||
engine.MinRowItems = 1
|
||||
engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
|
||||
"Drama": {raw("drama", "Strong Drama", "Series", "Drama")},
|
||||
}}
|
||||
engine.CuratedRows = []CuratedRow{{
|
||||
ID: "drama", Title: "Drama TV Shows", Kind: "shows",
|
||||
ItemTypes: []string{"Series"}, Genres: []string{"Drama"},
|
||||
}}
|
||||
|
||||
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "new"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].ID != "drama" {
|
||||
t.Fatalf("new profiles should receive quality-ranked curated rows: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// A failing similarity lookup is one dead row, not a dead home screen.
|
||||
func TestBuildRowsSurvivesASimilarLookupFailure(t *testing.T) {
|
||||
source := &fakeSource{
|
||||
|
||||
@@ -193,8 +193,39 @@ func (p Profile) Score(candidate Item) float64 {
|
||||
return genreScore + studioScore + ratingScore
|
||||
}
|
||||
|
||||
// CollectionAffinity decides which curated shelf appears first for this user.
|
||||
func (p Profile) CollectionAffinity(genres, studios []string) float64 {
|
||||
var score float64
|
||||
for _, genre := range genres {
|
||||
score += weightFold(p.GenreWeights, genre)
|
||||
}
|
||||
for _, studio := range studios {
|
||||
score += weightFold(p.StudioWeights, studio)
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func weightFold(weights map[string]float64, wanted string) float64 {
|
||||
for key, value := range weights {
|
||||
if strings.EqualFold(strings.TrimSpace(key), strings.TrimSpace(wanted)) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Rank scores, filters and truncates candidates, dropping duplicates by id.
|
||||
func Rank(profile Profile, candidates []Item, limit int) []Item {
|
||||
return rank(profile, candidates, limit, false)
|
||||
}
|
||||
|
||||
// RankCollection keeps unseen candidates with no affinity/rating score at the end,
|
||||
// ensuring a curated shelf remains useful for a new profile or unrated library.
|
||||
func RankCollection(profile Profile, candidates []Item, limit int) []Item {
|
||||
return rank(profile, candidates, limit, true)
|
||||
}
|
||||
|
||||
func rank(profile Profile, candidates []Item, limit int, includeZero bool) []Item {
|
||||
type scored struct {
|
||||
item Item
|
||||
score float64
|
||||
@@ -207,7 +238,7 @@ func Rank(profile Profile, candidates []Item, limit int) []Item {
|
||||
continue
|
||||
}
|
||||
seen[candidate.ID] = true
|
||||
if score := profile.Score(candidate); score > 0 {
|
||||
if score := profile.Score(candidate); score > 0 || includeZero && score == 0 {
|
||||
ranked = append(ranked, scored{candidate, score})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,16 @@ func TestRankRespectsLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionAffinityIsCaseInsensitive(t *testing.T) {
|
||||
profile := Profile{
|
||||
GenreWeights: map[string]float64{"Comedy": 2},
|
||||
StudioWeights: map[string]float64{"Apple TV+": 0.5},
|
||||
}
|
||||
if got := profile.CollectionAffinity([]string{"comedy"}, []string{"apple tv+"}); got != 2.5 {
|
||||
t.Fatalf("CollectionAffinity = %v, want 2.5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeKeepsRawPayload(t *testing.T) {
|
||||
raw := json.RawMessage(`{"Id":"1","Name":"Dune","Type":"Movie","Genres":["Science Fiction"],"ImageTags":{"Primary":"abc"}}`)
|
||||
items := Decode([]json.RawMessage{raw, json.RawMessage(`{"broken":`), json.RawMessage(`{"Name":"no id"}`)})
|
||||
|
||||
@@ -138,6 +138,51 @@ func (s *Store) LibraryCandidates(ctx context.Context, genres []string, limit in
|
||||
return collectPayloads(rows)
|
||||
}
|
||||
|
||||
// CuratedCandidates filters the imported catalogue for a server-authored shelf. Arrays
|
||||
// are matched case-insensitively because Emby studio capitalisation is not consistent.
|
||||
func (s *Store) CuratedCandidates(
|
||||
ctx context.Context,
|
||||
itemTypes, genres, studios []string,
|
||||
limit int,
|
||||
) ([]json.RawMessage, error) {
|
||||
if len(itemTypes) == 0 || (len(genres) == 0 && len(studios) == 0) {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT payload
|
||||
FROM library_items
|
||||
WHERE type = ANY($1)
|
||||
AND (
|
||||
cardinality($2::text[]) = 0 OR
|
||||
EXISTS (
|
||||
SELECT 1 FROM unnest(genres) AS genre
|
||||
WHERE lower(genre) = ANY($2)
|
||||
)
|
||||
)
|
||||
AND (
|
||||
cardinality($3::text[]) = 0 OR
|
||||
EXISTS (
|
||||
SELECT 1 FROM unnest(studios) AS studio
|
||||
WHERE lower(studio) = ANY($3)
|
||||
)
|
||||
)
|
||||
ORDER BY community_rating DESC NULLS LAST, date_created DESC NULLS LAST
|
||||
LIMIT $4`,
|
||||
itemTypes, lowerStrings(genres), lowerStrings(studios), limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: curated candidates: %w", err)
|
||||
}
|
||||
return collectPayloads(rows)
|
||||
}
|
||||
|
||||
func lowerStrings(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
out = append(out, strings.ToLower(strings.TrimSpace(value)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) LibraryStats(ctx context.Context) (LibraryStats, error) {
|
||||
stats := LibraryStats{ByType: map[string]int64{}}
|
||||
|
||||
|
||||
@@ -10,12 +10,28 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
username TEXT NOT NULL,
|
||||
server_id TEXT NOT NULL DEFAULT '',
|
||||
device_id TEXT NOT NULL DEFAULT '',
|
||||
device_name TEXT NOT NULL DEFAULT 'Memby TV',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS device_name TEXT NOT NULL DEFAULT 'Memby TV';
|
||||
|
||||
-- Older builds could create more than one token for the same physical TV. Keep the most
|
||||
-- recently used row before adding the identity constraint.
|
||||
DELETE FROM sessions older
|
||||
USING sessions newer
|
||||
WHERE older.emby_user_id = newer.emby_user_id
|
||||
AND older.device_id = newer.device_id
|
||||
AND (
|
||||
older.last_seen_at < newer.last_seen_at
|
||||
OR (older.last_seen_at = newer.last_seen_at AND older.token_hash < newer.token_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sessions_emby_user_idx ON sessions (emby_user_id);
|
||||
CREATE INDEX IF NOT EXISTS sessions_last_seen_idx ON sessions (last_seen_at);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS sessions_user_device_idx
|
||||
ON sessions (emby_user_id, device_id);
|
||||
|
||||
-- The imported library.
|
||||
--
|
||||
|
||||
+104
-11
@@ -17,6 +17,7 @@ var schema string
|
||||
|
||||
// ErrNotFound is returned when a token does not match a live session.
|
||||
var ErrNotFound = errors.New("store: session not found")
|
||||
var ErrDeviceLimit = errors.New("store: device limit reached")
|
||||
|
||||
type Session struct {
|
||||
TokenHash []byte
|
||||
@@ -25,6 +26,7 @@ type Session struct {
|
||||
Username string
|
||||
ServerID string
|
||||
DeviceID string
|
||||
DeviceName string
|
||||
LastSeenAt time.Time
|
||||
}
|
||||
|
||||
@@ -56,30 +58,73 @@ func (s *Store) Migrate(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateSession(ctx context.Context, sess Session) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO sessions (token_hash, emby_user_id, emby_token, username, server_id, device_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (token_hash) DO UPDATE SET
|
||||
// CreateSession enforces a user's device allowance under a per-user transaction lock.
|
||||
// Re-authenticating the same stable device replaces its token and never consumes a slot.
|
||||
// The replaced hash is returned so its Redis entry can be invalidated immediately.
|
||||
func (s *Store) CreateSession(ctx context.Context, sess Session, maxClients int) ([]byte, int, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("store: begin session: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, sess.EmbyUserID); err != nil {
|
||||
return nil, 0, fmt.Errorf("store: lock user sessions: %w", err)
|
||||
}
|
||||
|
||||
var previousHash []byte
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT token_hash FROM sessions
|
||||
WHERE emby_user_id = $1 AND device_id = $2`,
|
||||
sess.EmbyUserID, sess.DeviceID).Scan(&previousHash)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, 0, fmt.Errorf("store: find device session: %w", err)
|
||||
}
|
||||
existingDevice := err == nil
|
||||
|
||||
var activeClients int
|
||||
if err := tx.QueryRow(ctx,
|
||||
`SELECT count(*) FROM sessions WHERE emby_user_id = $1`,
|
||||
sess.EmbyUserID).Scan(&activeClients); err != nil {
|
||||
return nil, 0, fmt.Errorf("store: count user sessions: %w", err)
|
||||
}
|
||||
if !existingDevice && activeClients >= maxClients {
|
||||
return nil, activeClients, ErrDeviceLimit
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO sessions (
|
||||
token_hash, emby_user_id, emby_token, username, server_id, device_id, device_name
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (emby_user_id, device_id) DO UPDATE SET
|
||||
token_hash = EXCLUDED.token_hash,
|
||||
emby_token = EXCLUDED.emby_token,
|
||||
username = EXCLUDED.username,
|
||||
server_id = EXCLUDED.server_id,
|
||||
device_id = EXCLUDED.device_id,
|
||||
device_name = EXCLUDED.device_name,
|
||||
last_seen_at = now()`,
|
||||
sess.TokenHash, sess.EmbyUserID, sess.EmbyToken, sess.Username, sess.ServerID, sess.DeviceID)
|
||||
sess.TokenHash, sess.EmbyUserID, sess.EmbyToken, sess.Username,
|
||||
sess.ServerID, sess.DeviceID, sess.DeviceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: create session: %w", err)
|
||||
return nil, 0, fmt.Errorf("store: create session: %w", err)
|
||||
}
|
||||
return nil
|
||||
if !existingDevice {
|
||||
activeClients++
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, 0, fmt.Errorf("store: commit session: %w", err)
|
||||
}
|
||||
return previousHash, activeClients, nil
|
||||
}
|
||||
|
||||
func (s *Store) SessionByTokenHash(ctx context.Context, hash []byte) (Session, error) {
|
||||
var sess Session
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id, last_seen_at
|
||||
SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id, device_name, last_seen_at
|
||||
FROM sessions WHERE token_hash = $1`, hash).
|
||||
Scan(&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
|
||||
&sess.ServerID, &sess.DeviceID, &sess.LastSeenAt)
|
||||
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.LastSeenAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Session{}, ErrNotFound
|
||||
}
|
||||
@@ -111,3 +156,51 @@ func (s *Store) DeleteIdleSessions(ctx context.Context, idle time.Duration) (int
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// TrimSessionsToLimit brings data created under an older, more generous policy back
|
||||
// within the current allowance. The most recently active devices survive.
|
||||
func (s *Store) TrimSessionsToLimit(ctx context.Context, maxClients int) ([]Session, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
WITH ranked AS (
|
||||
SELECT token_hash,
|
||||
row_number() OVER (
|
||||
PARTITION BY emby_user_id
|
||||
ORDER BY last_seen_at DESC, created_at DESC, token_hash DESC
|
||||
) AS device_rank
|
||||
FROM sessions
|
||||
),
|
||||
retired AS (
|
||||
DELETE FROM sessions current
|
||||
USING ranked
|
||||
WHERE current.token_hash = ranked.token_hash
|
||||
AND ranked.device_rank > $1
|
||||
RETURNING current.token_hash, current.emby_user_id, current.emby_token,
|
||||
current.username, current.server_id, current.device_id,
|
||||
current.device_name, current.last_seen_at
|
||||
)
|
||||
SELECT token_hash, emby_user_id, emby_token, username, server_id,
|
||||
device_id, device_name, last_seen_at
|
||||
FROM retired`,
|
||||
maxClients,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: trim sessions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var retired []Session
|
||||
for rows.Next() {
|
||||
var sess Session
|
||||
if err := rows.Scan(
|
||||
&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
|
||||
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.LastSeenAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("store: scan trimmed session: %w", err)
|
||||
}
|
||||
retired = append(retired, sess)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: trim sessions rows: %w", err)
|
||||
}
|
||||
return retired, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user