fix: refresh emby realtime user sessions

This commit is contained in:
ShukeBta
2026-06-25 00:36:41 +08:00
parent f49ef7f1df
commit 29f5df673c
6 changed files with 196 additions and 8 deletions
+3
View File
@@ -10,6 +10,8 @@ import (
"github.com/ShukeBta/MediaStationGo/internal/service"
)
const embyCtxUserName = "emby_user_name"
func activeUserRequired(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
@@ -72,6 +74,7 @@ func activeEmbyUserRequired(svc *service.Container) gin.HandlerFunc {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"Code": 40303, "Message": "User account has expired"})
return
}
c.Set(embyCtxUserName, u.Username)
c.Next()
}
}
+29
View File
@@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
// embyError 返回 Emby 风格的错误(顶层 Code/Message)。
@@ -49,6 +50,34 @@ func embyAuthRequiredWithSessionFallback(secret string) gin.HandlerFunc {
}
}
func embyRealtimeSessionActivity(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc != nil && svc.Sessions != nil {
if uid := embyUserID(c); uid != "" {
clientInfo := embyClientInfoFromRequest(c)
svc.Sessions.RecordActivity(c.Request.Context(), uid, embyContextUserName(c),
clientInfo.DeviceID,
clientInfo.DeviceName,
clientInfo.Client,
c.ClientIP())
}
}
c.Next()
}
}
func embyContextUserName(c *gin.Context) string {
if c == nil {
return ""
}
if value, ok := c.Get(embyCtxUserName); ok {
if username, ok := value.(string); ok {
return strings.TrimSpace(username)
}
}
return ""
}
func embyRememberCompatSession(c *gin.Context, token string) {
token = strings.TrimSpace(token)
if token == "" {
+1 -1
View File
@@ -20,7 +20,7 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container)
registerEmbyPublicImageRoutes(grp, svc)
// 鉴权后端点
auth := grp.Group("", embyAuthRequiredWithSessionFallback(jwtSecret), activeEmbyUserRequired(svc))
auth := grp.Group("", embyAuthRequiredWithSessionFallback(jwtSecret), activeEmbyUserRequired(svc), embyRealtimeSessionActivity(svc))
registerEmbyAuthenticatedRoutes(auth, prefix, svc)
}
}
@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
@@ -153,6 +154,80 @@ func TestEmbyCompatSessionAllowsSameClientRequestsWithoutToken(t *testing.T) {
}
}
func TestEmbyAuthenticatedRequestRefreshesRealtimeUserActivity(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if sqlDB, err := db.DB(); err == nil {
sqlDB.SetMaxOpenConns(1)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
oldLogin := time.Now().Add(-6 * time.Hour)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "viewer",
Role: "admin",
Tier: "plus",
IsActive: true,
LastLoginAt: &oldLogin,
}); err != nil {
t.Fatalf("create user: %v", err)
}
const secret = "test-secret"
log := zap.NewNop()
tracker := service.NewSessionTrackerService(log)
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, log, repos),
Sessions: tracker,
}
router := gin.New()
registerEmbyRoutes(router, secret, svc)
router.GET("/admin/users", listUsersHandler(svc))
req := httptest.NewRequest(http.MethodGet, "/emby/Users/Me", nil)
req.Header.Set("X-MediaBrowser-Authorization", `MediaBrowser Client="Infuse", Device="iPhone", DeviceId="phone-1", Token="`+signedTestToken(t, secret)+`"`)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("me status: %d body=%s", w.Code, w.Body.String())
}
sessions := tracker.List(t.Context())
if len(sessions) != 1 {
t.Fatalf("sessions = %#v, want one realtime session", sessions)
}
if sessions[0].UserID != "user-1" || sessions[0].DeviceID != "phone-1" || sessions[0].Client != "Infuse" {
t.Fatalf("session did not capture client info: %#v", sessions[0])
}
req = httptest.NewRequest(http.MethodGet, "/admin/users", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("admin users status: %d body=%s", w.Code, w.Body.String())
}
var users []model.User
if err := json.Unmarshal(w.Body.Bytes(), &users); err != nil {
t.Fatalf("decode users: %v", err)
}
if len(users) != 1 {
t.Fatalf("users = %#v", users)
}
if users[0].LastLoginAt == nil || !users[0].LastLoginAt.After(oldLogin) {
t.Fatalf("last_login_at = %v, want realtime value after %v", users[0].LastLoginAt, oldLogin)
}
if !users[0].RealtimeOnline || users[0].RealtimeDeviceCount != 1 {
t.Fatalf("realtime flags online=%v devices=%d", users[0].RealtimeOnline, users[0].RealtimeDeviceCount)
}
}
func TestEmbyUppercaseSessionCapabilitiesRouteNoContent(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})