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{
+4
View File
@@ -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++ {
+1 -1
View File
@@ -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))
+54 -1
View File
@@ -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
}
+22 -7
View File
@@ -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)
+8 -1
View File
@@ -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
+110 -12
View File
@@ -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 (
+86
View File
@@ -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{
+32 -1
View File
@@ -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})
}
}
+10
View File
@@ -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"}`)})
+45
View File
@@ -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{}}
+16
View File
@@ -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
View File
@@ -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
}