Big changes
This commit is contained in:
+105
-23
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user