Big changes

This commit is contained in:
ponzischeme89
2026-07-29 15:26:27 +12:00
parent 8d6cf2f5a1
commit 70914400b4
62 changed files with 7501 additions and 744 deletions
+105 -23
View File
@@ -24,6 +24,8 @@ import (
"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/foryou"
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
@@ -35,9 +37,11 @@ type Server struct {
store *store.Store
cache *cache.Cache
recommender *recommend.Engine
forYou *foryou.Service
sonarr *sonarr.Client
syncer syncerHandle
log *slog.Logger
events *serverlogging.Buffer
sonarrMu sync.Mutex
recommendationBuilds recommendationBuilds
@@ -52,9 +56,11 @@ type Deps struct {
Store *store.Store
Cache *cache.Cache
Recommender *recommend.Engine
ForYou *foryou.Service
Sonarr *sonarr.Client
Syncer syncerHandle
Log *slog.Logger
Events *serverlogging.Buffer
}
func New(cfg config.Config, deps Deps) *Server {
@@ -67,9 +73,11 @@ func New(cfg config.Config, deps Deps) *Server {
store: deps.Store,
cache: deps.Cache,
recommender: deps.Recommender,
forYou: deps.ForYou,
sonarr: deps.Sonarr,
syncer: deps.Syncer,
log: deps.Log,
events: deps.Events,
}
}
@@ -86,13 +94,19 @@ func (s *Server) Routes() http.Handler {
v1.Handle("GET /v1/home", s.authed(s.handleHome))
v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver))
v1.Handle("GET /v1/search", s.authed(s.handleSearch))
v1.Handle("GET /v1/search/history", s.authed(s.handleRecentSearches))
v1.Handle("POST /v1/search/history", s.authed(s.handleSearchHistory))
v1.Handle("GET /v1/recommendations", s.authed(s.handleRecommendations))
v1.Handle("GET /v1/for-you", s.authed(s.handleForYou))
v1.Handle("GET /v1/preroll", s.authed(s.handlePreroll))
v1.Handle("GET /v1/update", s.authed(s.handleUpdate))
v1.Handle("GET /v1/items/{id}", s.authed(s.handleItem))
v1.Handle("GET /v1/items/{id}/episodes", s.authed(s.handleSeriesEpisodes))
v1.Handle("POST /v1/items/{id}/favorite", s.authed(s.handleFavorite))
v1.Handle("POST /v1/items/{id}/played", s.authed(s.handlePlayed))
v1.Handle("GET /v1/items/{id}/playback", s.authed(s.handlePlayback))
v1.Handle("GET /v1/items/{id}/next", s.authed(s.handleNextEpisode))
v1.Handle("GET /v1/items/{id}/trailer", s.authed(s.handleTrailer))
v1.Handle("POST /v1/playback/{phase}", s.authed(s.handlePlaybackReport))
@@ -138,15 +152,59 @@ func (s *Server) authed(h authedFunc) http.Handler {
writeError(w, http.StatusInternalServerError, "session lookup failed")
return
}
sess = s.captureClientIdentity(r, sess)
h(w, r, sess)
})
}
// captureClientIdentity makes the session the durable source of attribution. Normal API
// calls refresh it from headers; authenticated artwork requests, which can only carry a
// query token, inherit the last identity reported by that same TV.
func (s *Server) captureClientIdentity(r *http.Request, sess store.Session) store.Session {
changed := mergeClientIdentity(r, &sess)
if changed {
if err := s.store.UpdateSessionClientIdentity(
r.Context(), sess.TokenHash, sess.ClientVersion, sess.ClientProtocol,
); err != nil {
s.log.Warn("client identity update failed", "error", err)
} else {
s.cacheSession(r.Context(), sess)
}
}
return sess
}
func mergeClientIdentity(r *http.Request, sess *store.Session) bool {
version := clientVersion(r)
protocol := clientProtocol(r)
changed := false
if version != "" && version != sess.ClientVersion {
sess.ClientVersion = version
changed = true
}
if protocol != "" && protocol != sess.ClientProtocol {
sess.ClientProtocol = protocol
changed = true
}
if version == "" && sess.ClientVersion != "" {
r.Header.Set("X-Memby-Version", sess.ClientVersion)
}
if protocol == "" && sess.ClientProtocol != "" {
r.Header.Set("X-Memby-Protocol", sess.ClientProtocol)
}
return changed
}
func (s *Server) withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
// Polling the live-log endpoint must not create another live-log record and
// become a self-sustaining stream.
if r.URL.Path == "/admin/api/events" {
return
}
// Path only: query strings can carry image tokens.
level := requestLogLevel(r.URL.Path, rec.status)
s.log.Log(r.Context(), level, "HTTP request",
@@ -154,10 +212,19 @@ func (s *Server) withLogging(next http.Handler) http.Handler {
"path", r.URL.Path,
"status", rec.status,
"duration", time.Since(start).Round(time.Millisecond),
"client_version", clientLogValue(clientVersion(r)),
"client_protocol", clientLogValue(clientProtocol(r)),
)
})
}
func clientLogValue(value string) string {
if value == "" {
return "unknown"
}
return value
}
// 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.
@@ -211,12 +278,14 @@ func newToken() (string, error) {
}
type cachedSession struct {
EmbyUserID string `json:"u"`
EmbyToken string `json:"t"`
Username string `json:"n"`
ServerID string `json:"s"`
DeviceID string `json:"d"`
DeviceName string `json:"dn,omitempty"`
EmbyUserID string `json:"u"`
EmbyToken string `json:"t"`
Username string `json:"n"`
ServerID string `json:"s"`
DeviceID string `json:"d"`
DeviceName string `json:"dn,omitempty"`
ClientVersion string `json:"v,omitempty"`
ClientProtocol string `json:"p,omitempty"`
}
// sessionFor resolves a token, using Redis to keep the hot path off Postgres.
@@ -228,13 +297,15 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e
var cs cachedSession
if json.Unmarshal(raw, &cs) == nil {
return store.Session{
TokenHash: hash,
EmbyUserID: cs.EmbyUserID,
EmbyToken: cs.EmbyToken,
Username: cs.Username,
ServerID: cs.ServerID,
DeviceID: cs.DeviceID,
DeviceName: cs.DeviceName,
TokenHash: hash,
EmbyUserID: cs.EmbyUserID,
EmbyToken: cs.EmbyToken,
Username: cs.Username,
ServerID: cs.ServerID,
DeviceID: cs.DeviceID,
DeviceName: cs.DeviceName,
ClientVersion: cs.ClientVersion,
ClientProtocol: cs.ClientProtocol,
}, nil
}
}
@@ -248,16 +319,7 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e
return store.Session{}, store.ErrNotFound
}
if raw, err := json.Marshal(cachedSession{
EmbyUserID: sess.EmbyUserID,
EmbyToken: sess.EmbyToken,
Username: sess.Username,
ServerID: sess.ServerID,
DeviceID: sess.DeviceID,
DeviceName: sess.DeviceName,
}); err == nil {
_ = s.cache.Set(ctx, key, raw, s.cfg.SessionTTL)
}
s.cacheSession(ctx, sess)
// Best-effort activity stamp; a failure here must not fail the request.
if err := s.store.Touch(ctx, hash); err != nil {
s.log.Warn("touch session failed", "error", err)
@@ -265,6 +327,26 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e
return sess, nil
}
func (s *Server) cacheSession(ctx context.Context, sess store.Session) {
if raw, err := json.Marshal(cachedSession{
EmbyUserID: sess.EmbyUserID,
EmbyToken: sess.EmbyToken,
Username: sess.Username,
ServerID: sess.ServerID,
DeviceID: sess.DeviceID,
DeviceName: sess.DeviceName,
ClientVersion: sess.ClientVersion,
ClientProtocol: sess.ClientProtocol,
}); err == nil {
_ = s.cache.Set(
ctx,
cache.SessionKey(hex.EncodeToString(sess.TokenHash)),
raw,
s.cfg.SessionTTL,
)
}
}
func credentials(sess store.Session) emby.Credentials {
return emby.Credentials{
UserID: sess.EmbyUserID, Token: sess.EmbyToken,