Files
MeBox/internal/handler/emby_compat_features_test.go
T
2026-09-06 22:10:23 +08:00

319 lines
8.7 KiB
Go

package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestNormalizeEmbyPath(t *testing.T) {
tests := []struct {
input string
wantPath string
changed bool
}{
{
input: "/emby/System/Info",
wantPath: "/emby/system/info",
changed: true,
},
{
input: "/emby/emby/System/Info",
wantPath: "/emby/system/info",
changed: true,
},
{
input: "/emby/emby/emby/items/123/playbackInfo",
wantPath: "/emby/items/123/playbackinfo",
changed: true,
},
{
input: "//emby//System//Info//Public",
wantPath: "/emby/system/info/public",
changed: true,
},
{
input: "/Items/msgo-series-1/PlaybackInfo",
wantPath: "/items/msgo-series-1/playbackinfo",
changed: true,
},
{
input: "/Videos/m-123/Master.m3u8",
wantPath: "/videos/m-123/master.m3u8",
changed: true,
},
{
input: "/api/unknown/other",
wantPath: "/api/unknown/other",
changed: false,
},
}
for _, tt := range tests {
gotPath, changed := NormalizeEmbyPath(tt.input)
if gotPath != tt.wantPath || changed != tt.changed {
t.Errorf("NormalizeEmbyPath(%q) = (%q, %v), want (%q, %v)", tt.input, gotPath, changed, tt.wantPath, tt.changed)
}
}
}
func TestEmbyDuplicatePrefixHandling(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)
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(nil, nil, repos),
}
router := gin.New()
registerEmbyRoutes(router, "secret", svc)
// 模拟重复拼接前缀的客户端请求: /emby/emby/System/Info/Public
req := httptest.NewRequest(http.MethodGet, "/emby/emby/System/Info/Public", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK for /emby/emby/System/Info/Public, got %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "ServerName") {
t.Fatalf("expected server info body, got: %s", w.Body.String())
}
}
func TestEmbyMixedCaseHandling(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)
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(nil, nil, repos),
}
router := gin.New()
registerEmbyRoutes(router, "secret", svc)
// 混合大小写驼峰: /emby/system/Info/Public
req := httptest.NewRequest(http.MethodGet, "/emby/system/Info/Public", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK for /emby/system/Info/Public, got %d: %s", w.Code, w.Body.String())
}
}
func TestEmbyClientIdentification(t *testing.T) {
tests := []struct {
name string
ua string
query string
headerAuth string
wantClient string
}{
{
name: "CapyPlayer via UA",
ua: "CapyPlayer/1.2.0 (iOS)",
wantClient: "CapyPlayer",
},
{
name: "SenPlayer via UA",
ua: "SenPlayer/2.1",
wantClient: "SenPlayer",
},
{
name: "Fileball via UA",
ua: "Fileball/1.0.0",
wantClient: "Fileball",
},
{
name: "Kodi via UA",
ua: "Kodi/20.2",
wantClient: "Kodi",
},
{
name: "Client in query",
ua: "CustomApp/1.0",
query: "?X-Emby-Client=CapyPlayer",
wantClient: "CapyPlayer",
},
{
name: "Client in auth header",
ua: "Custom/1.0",
headerAuth: `MediaBrowser Client="SenPlayer", Device="AppleTV", DeviceId="abc"`,
wantClient: "SenPlayer",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
url := "/test"
if tt.query != "" {
url += tt.query
}
req := httptest.NewRequest(http.MethodGet, url, nil)
if tt.ua != "" {
req.Header.Set("User-Agent", tt.ua)
}
if tt.headerAuth != "" {
req.Header.Set("X-Emby-Authorization", tt.headerAuth)
}
c.Request = req
info := embyClientInfoFromRequest(c)
if info.Client != tt.wantClient {
t.Fatalf("embyClientInfoFromRequest Client = %q, want %q", info.Client, tt.wantClient)
}
})
}
}
func TestEmbyAdaptivePrefixPlaybackInfo(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.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(nil, nil, repos),
}
const secret = "test-secret"
router := gin.New()
registerEmbyRoutes(router, secret, svc)
token := signedTestToken(t, secret)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
if err := db.Create(&model.Media{
Base: model.Base{ID: "m-adaptive-1"},
Title: "测试媒体",
Path: "D:\\media\\test.mkv",
LibraryID: "lib-1",
}).Error; err != nil {
t.Fatalf("create media: %v", err)
}
// 1. 从 /emby 前缀请求 PlaybackInfo
req1 := httptest.NewRequest(http.MethodGet, "/emby/Items/m-adaptive-1/PlaybackInfo", nil)
req1.Header.Set("X-Emby-Token", token)
w1 := httptest.NewRecorder()
router.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("playbackinfo /emby code = %d: %s", w1.Code, w1.Body.String())
}
var res1 struct {
MediaSources []struct {
DirectStreamURL string `json:"DirectStreamUrl"`
} `json:"MediaSources"`
}
if err := json.Unmarshal(w1.Body.Bytes(), &res1); err != nil || len(res1.MediaSources) == 0 {
t.Fatalf("unmarshal /emby response: %v, body: %s", err, w1.Body.String())
}
if !strings.Contains(res1.MediaSources[0].DirectStreamURL, "/Videos/m-adaptive-1/stream") {
t.Fatalf("DirectStreamUrl should point to video stream endpoint, got: %s", res1.MediaSources[0].DirectStreamURL)
}
if !strings.Contains(res1.MediaSources[0].DirectStreamURL, "api_key="+token) {
t.Fatalf("DirectStreamUrl should carry api_key token, got: %s", res1.MediaSources[0].DirectStreamURL)
}
// 2. 从重复前缀 /emby/emby 请求 PlaybackInfo (模拟客户端再次追加 BaseUrl 场景)
req2 := httptest.NewRequest(http.MethodGet, "/emby/emby/Items/m-adaptive-1/PlaybackInfo", nil)
req2.Header.Set("X-Emby-Token", token)
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("playbackinfo /emby/emby code = %d: %s", w2.Code, w2.Body.String())
}
var res2 struct {
MediaSources []struct {
DirectStreamURL string `json:"DirectStreamUrl"`
} `json:"MediaSources"`
}
if err := json.Unmarshal(w2.Body.Bytes(), &res2); err != nil || len(res2.MediaSources) == 0 {
t.Fatalf("unmarshal /emby/emby response: %v, body: %s", err, w2.Body.String())
}
// 3. 从根路径 /Items 请求 PlaybackInfo
req3 := httptest.NewRequest(http.MethodGet, "/Items/m-adaptive-1/PlaybackInfo", nil)
req3.Header.Set("X-Emby-Token", token)
w3 := httptest.NewRecorder()
router.ServeHTTP(w3, req3)
if w3.Code != http.StatusOK {
t.Fatalf("playbackinfo root code = %d: %s", w3.Code, w3.Body.String())
}
}
func TestEmbyImageClearNoStore(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.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(nil, nil, repos),
}
router := gin.New()
registerEmbyRoutes(router, "secret", svc)
req := httptest.NewRequest(http.MethodGet, "/emby/Items/non-existent-item/Images/Primary", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("placeholder image should return 200, got %d", w.Code)
}
cacheControl := w.Header().Get("Cache-Control")
if strings.Contains(cacheControl, "no-store") {
t.Fatalf("image response should not have no-store, got: %s", cacheControl)
}
if !strings.Contains(cacheControl, "public") {
t.Fatalf("image response should have public cache-control, got: %s", cacheControl)
}
}