fix: refresh emby sessions in realtime

This commit is contained in:
ShukeBta
2026-06-25 02:32:32 +08:00
parent 29f5df673c
commit baa4a38b0a
12 changed files with 441 additions and 27 deletions
+23 -10
View File
@@ -52,20 +52,23 @@ 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())
}
}
recordEmbySessionActivity(c, svc, embyUserID(c), embyContextUserName(c))
c.Next()
}
}
func recordEmbySessionActivity(c *gin.Context, svc *service.Container, userID, userName string) {
if c == nil || svc == nil || svc.Sessions == nil || strings.TrimSpace(userID) == "" {
return
}
clientInfo := embyClientInfoFromRequest(c)
svc.Sessions.RecordActivity(c.Request.Context(), userID, userName,
clientInfo.DeviceID,
clientInfo.DeviceName,
clientInfo.Client,
c.ClientIP())
}
func embyContextUserName(c *gin.Context) string {
if c == nil {
return ""
@@ -167,15 +170,25 @@ func embyClientInfoFromRequest(c *gin.Context) embyClientInfo {
info := embyClientInfo{
DeviceID: firstNonEmptyHeaderString(
firstHeaderValue(c, "X-Emby-Device-Id", "X-Emby-DeviceId", "X-MediaBrowser-Device-Id", "X-MediaBrowser-DeviceId"),
c.Query("DeviceId"),
c.Query("DeviceID"),
c.Query("deviceId"),
c.Query("deviceID"),
auth["DeviceId"],
auth["DeviceID"],
),
DeviceName: firstNonEmptyHeaderString(
firstHeaderValue(c, "X-Emby-Device-Name", "X-Emby-DeviceName", "X-MediaBrowser-Device-Name", "X-MediaBrowser-DeviceName"),
c.Query("Device"),
c.Query("DeviceName"),
c.Query("device"),
c.Query("deviceName"),
auth["Device"],
),
Client: firstNonEmptyHeaderString(
firstHeaderValue(c, "X-Emby-Client", "X-MediaBrowser-Client"),
c.Query("Client"),
c.Query("client"),
auth["Client"],
),
}
+52
View File
@@ -10,6 +10,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/gorilla/websocket"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/model"
@@ -179,3 +180,54 @@ func TestEmbyWebSocketRouteUpgradesForOfficialClients(t *testing.T) {
t.Fatalf("expected websocket upgrade, got resp=%#v", resp)
}
}
func TestEmbyWebSocketRefreshesRealtimeActivity(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 err := db.AutoMigrate(&model.User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "viewer",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
const secret = "test-secret"
tracker := service.NewSessionTrackerService(zap.NewNop())
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Sessions: tracker,
})
server := httptest.NewServer(router)
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/embywebsocket?deviceId=device-1&device=Windows&client=Emby"
header := http.Header{"X-Emby-Token": []string{signedTestToken(t, secret)}}
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, header)
if err != nil {
status := 0
if resp != nil {
status = resp.StatusCode
}
t.Fatalf("websocket dial failed status=%d err=%v", status, err)
}
defer conn.Close()
sessions := tracker.List(t.Context())
if len(sessions) != 1 {
t.Fatalf("sessions = %#v, want websocket heartbeat session", sessions)
}
if sessions[0].DeviceID != "device-1" || sessions[0].DeviceName != "Windows" || sessions[0].Client != "Emby" {
t.Fatalf("websocket did not refresh client session: %#v", sessions[0])
}
}
+14 -10
View File
@@ -16,7 +16,7 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container)
grp.Use(embyNoStoreHeaders())
registerEmbyRootRoutes(grp, prefix, svc)
registerEmbyPublicRoutes(grp, svc)
registerEmbyPublicRoutes(grp, jwtSecret, svc)
registerEmbyPublicImageRoutes(grp, svc)
// 鉴权后端点
@@ -46,10 +46,10 @@ func registerEmbyRootRoutes(grp *gin.RouterGroup, prefix string, svc *service.Co
grp.HEAD("/", embyRootHandler(svc))
}
func registerEmbyPublicRoutes(grp *gin.RouterGroup, svc *service.Container) {
func registerEmbyPublicRoutes(grp *gin.RouterGroup, jwtSecret string, svc *service.Container) {
registerEmbyPublicSystemRoutes(grp, svc)
registerEmbyPublicSessionRoutes(grp, svc)
registerEmbyPublicClientRoutes(grp, svc)
registerEmbyPublicSessionRoutes(grp, jwtSecret, svc)
registerEmbyPublicClientRoutes(grp, jwtSecret, svc)
}
func registerEmbyPublicSystemRoutes(grp *gin.RouterGroup, svc *service.Container) {
@@ -68,11 +68,13 @@ func registerEmbyPublicSystemRoutes(grp *gin.RouterGroup, svc *service.Container
}
}
func registerEmbyPublicSessionRoutes(grp *gin.RouterGroup, svc *service.Container) {
registerEmbyPostRoutes(grp, svc, []string{
func registerEmbyPublicSessionRoutes(grp *gin.RouterGroup, jwtSecret string, svc *service.Container) {
for _, path := range []string{
"/Sessions/Capabilities", "/Sessions/Capabilities/Full",
"/sessions/capabilities", "/sessions/capabilities/full",
}, embyNoContentHandler)
} {
grp.POST(path, embySessionCapabilitiesHandler(svc, jwtSecret))
}
// 30/min per IP: many Emby clients sit behind a single NAT/reverse-proxy
// IP, so a low limit would throttle legitimate logins into 429s.
@@ -84,17 +86,19 @@ func registerEmbyPublicSessionRoutes(grp *gin.RouterGroup, svc *service.Containe
registerEmbyGetRoutes(grp, svc, []string{"/Users/Public", "/users/public"}, embyPublicUsersHandler)
}
func registerEmbyPublicClientRoutes(grp *gin.RouterGroup, svc *service.Container) {
func registerEmbyPublicClientRoutes(grp *gin.RouterGroup, jwtSecret string, svc *service.Container) {
registerEmbyGetRoutes(grp, svc, []string{"/Branding/Configuration", "/branding/configuration"}, embyBrandingConfigHandler)
registerEmbyGetHeadRoutes(grp, svc, []string{"/Branding/Css", "/branding/css"}, embyBrandingCSSHandler)
registerEmbyGetRoutes(grp, svc, []string{"/Localization/Options", "/localization/options"}, embyLocalizationOptionsHandler)
registerEmbyGetRoutes(grp, svc, []string{"/Localization/Cultures", "/Localization/cultures", "/localization/cultures"}, embyLocalizationCulturesHandler)
registerEmbyGetHeadRoutes(grp, svc, []string{"/CustomCssJS/Scripts", "/customcssjs/scripts"}, embyCustomCSSJSScriptsHandler)
for _, path := range []string{"/embywebsocket", "/EmbyWebSocket"} {
grp.GET(path, embyWebSocketHandler(svc))
grp.GET(path, embyWebSocketHandler(svc, jwtSecret))
grp.HEAD(path, embyNoContentHandler(svc))
}
registerEmbyPostRoutes(grp, svc, []string{"/Sessions/Logout", "/sessions/logout"}, embySessionLogoutHandler)
for _, path := range []string{"/Sessions/Logout", "/sessions/logout"} {
grp.POST(path, embySessionLogoutHandler(svc, jwtSecret))
}
grp.GET("/DisplayPreferences/:id", embyDisplayPreferencesHandler(svc))
grp.POST("/DisplayPreferences/:id", embySaveDisplayPreferencesHandler(svc))
grp.GET("/displaypreferences/:id", embyDisplayPreferencesHandler(svc))
@@ -228,6 +228,188 @@ func TestEmbyAuthenticatedRequestRefreshesRealtimeUserActivity(t *testing.T) {
}
}
func TestEmbySessionCapabilitiesRefreshesRealtimeActivity(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 err := db.AutoMigrate(&model.User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "viewer",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
const secret = "test-secret"
tracker := service.NewSessionTrackerService(zap.NewNop())
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Sessions: tracker,
})
req := httptest.NewRequest(http.MethodPost, "/emby/Sessions/Capabilities/Full?deviceId=phone-1&device=iPhone&client=Infuse", strings.NewReader(`{}`))
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("capabilities status: %d body=%s", w.Code, w.Body.String())
}
sessions := tracker.List(t.Context())
if len(sessions) != 1 {
t.Fatalf("sessions = %#v, want one heartbeat session", sessions)
}
if sessions[0].DeviceID != "phone-1" || sessions[0].DeviceName != "iPhone" || sessions[0].Client != "Infuse" || sessions[0].UserName != "viewer" {
t.Fatalf("capabilities did not refresh client session: %#v", sessions[0])
}
}
func TestEmbySessionCapabilitiesIgnoresScopedPlaybackToken(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 err := db.AutoMigrate(&model.User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "viewer",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
const secret = "test-secret"
tracker := service.NewSessionTrackerService(zap.NewNop())
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Sessions: tracker,
})
req := httptest.NewRequest(http.MethodPost, "/emby/Sessions/Capabilities/Full?deviceId=phone-1&device=iPhone&client=Infuse", strings.NewReader(`{}`))
req.Header.Set("X-Emby-Token", signedTestTokenWithPurpose(t, secret, service.ExternalPlaybackTokenPurpose))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("capabilities status: %d body=%s", w.Code, w.Body.String())
}
if sessions := tracker.List(t.Context()); len(sessions) != 0 {
t.Fatalf("scoped external playback token must not create realtime session: %#v", sessions)
}
}
func TestEmbyLogoutRemovesRealtimeSessionFromPublicRoute(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 err := db.AutoMigrate(&model.User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "viewer",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
const secret = "test-secret"
tracker := service.NewSessionTrackerService(zap.NewNop())
tracker.RecordActivity(t.Context(), "user-1", "viewer", "phone-1", "iPhone", "Infuse", "192.0.2.10")
tracker.RecordActivity(t.Context(), "user-1", "viewer", "tv-1", "Apple TV", "Yamby", "192.0.2.11")
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Sessions: tracker,
})
req := httptest.NewRequest(http.MethodPost, "/emby/Sessions/Logout", 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.StatusNoContent {
t.Fatalf("logout status: %d body=%s", w.Code, w.Body.String())
}
sessions := tracker.List(t.Context())
if len(sessions) != 1 {
t.Fatalf("sessions after logout = %#v, want only the other device", sessions)
}
if sessions[0].DeviceID != "tv-1" {
t.Fatalf("remaining session = %#v, want tv-1", sessions[0])
}
}
func TestEmbyLogoutIgnoresScopedPlaybackToken(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 err := db.AutoMigrate(&model.User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "viewer",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
const secret = "test-secret"
tracker := service.NewSessionTrackerService(zap.NewNop())
tracker.RecordActivity(t.Context(), "user-1", "viewer", "phone-1", "iPhone", "Infuse", "192.0.2.10")
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Sessions: tracker,
})
req := httptest.NewRequest(http.MethodPost, "/emby/Sessions/Logout", nil)
req.Header.Set("X-MediaBrowser-Authorization", `MediaBrowser Client="Infuse", Device="iPhone", DeviceId="phone-1", Token="`+signedTestTokenWithPurpose(t, secret, service.ExternalPlaybackTokenPurpose)+`"`)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("logout status: %d body=%s", w.Code, w.Body.String())
}
sessions := tracker.List(t.Context())
if len(sessions) != 1 || sessions[0].DeviceID != "phone-1" {
t.Fatalf("scoped external playback token must not logout realtime session: %#v", sessions)
}
}
func TestEmbyUppercaseSessionCapabilitiesRouteNoContent(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+50 -2
View File
@@ -2,9 +2,12 @@ package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
@@ -47,13 +50,58 @@ func embySessionsHandler(svc *service.Container) gin.HandlerFunc {
}
}
func embySessionLogoutHandler(svc *service.Container) gin.HandlerFunc {
func embySessionLogoutHandler(svc *service.Container, jwtSecret string) gin.HandlerFunc {
return func(c *gin.Context) {
if svc.Sessions != nil {
uid := embyUserID(c)
uid, _ := embyPublicSessionIdentity(c, svc, jwtSecret)
clientInfo := embyClientInfoFromRequest(c)
svc.Sessions.Logout(c.Request.Context(), uid, clientInfo.DeviceID, c.ClientIP())
}
c.Status(http.StatusNoContent)
}
}
func embySessionCapabilitiesHandler(svc *service.Container, jwtSecret string) gin.HandlerFunc {
return func(c *gin.Context) {
recordEmbyPublicSessionActivity(c, svc, jwtSecret)
c.Status(http.StatusNoContent)
}
}
func recordEmbyPublicSessionActivity(c *gin.Context, svc *service.Container, jwtSecret string) {
uid, username := embyPublicSessionIdentity(c, svc, jwtSecret)
recordEmbySessionActivity(c, svc, uid, username)
}
func embyPublicSessionIdentity(c *gin.Context, svc *service.Container, jwtSecret string) (string, string) {
if uid := embyUserID(c); uid != "" {
return uid, embyContextUserName(c)
}
token := embyRequestToken(c)
if strings.TrimSpace(token) == "" || strings.TrimSpace(jwtSecret) == "" {
return "", ""
}
claims := &middleware.Claims{}
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrTokenSignatureInvalid
}
return []byte(jwtSecret), nil
})
if err != nil || !parsed.Valid {
return "", ""
}
if strings.TrimSpace(claims.Purpose) != "" {
return "", ""
}
uid := strings.TrimSpace(claims.UserID)
if uid == "" {
return "", ""
}
if svc != nil && svc.Repo != nil && svc.Repo.User != nil {
if user, err := svc.Repo.User.FindByID(c.Request.Context(), uid); err == nil && user != nil {
return uid, user.Username
}
}
return uid, ""
}
+3 -1
View File
@@ -16,8 +16,9 @@ func embyNoContentHandler(_ *service.Container) gin.HandlerFunc {
}
}
func embyWebSocketHandler(_ *service.Container) gin.HandlerFunc {
func embyWebSocketHandler(svc *service.Container, jwtSecret string) gin.HandlerFunc {
return func(c *gin.Context) {
recordEmbyPublicSessionActivity(c, svc, jwtSecret)
if !websocket.IsWebSocketUpgrade(c.Request) {
c.Status(http.StatusNoContent)
return
@@ -45,6 +46,7 @@ func embyWebSocketHandler(_ *service.Container) gin.HandlerFunc {
case <-done:
return
case <-ticker.C:
recordEmbyPublicSessionActivity(c, svc, jwtSecret)
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
+9 -3
View File
@@ -10,11 +10,17 @@ import (
)
func signedTestToken(t *testing.T, secret string) string {
t.Helper()
return signedTestTokenWithPurpose(t, secret, "")
}
func signedTestTokenWithPurpose(t *testing.T, secret, purpose string) string {
t.Helper()
claims := middleware.Claims{
UserID: "user-1",
Role: "admin",
Tier: "plus",
UserID: "user-1",
Role: "admin",
Tier: "plus",
Purpose: purpose,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
Issuer: "mediastationgo-test",