mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
bug处理+emby客户端兼容
This commit is contained in:
@@ -70,8 +70,11 @@ func serveSPA(r *gin.Engine, root fs.FS) {
|
||||
r.GET(rootFile, serveFSFile(root, name))
|
||||
r.HEAD(rootFile, serveFSFile(root, name))
|
||||
}
|
||||
r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) {
|
||||
if handler.TryHandleEmbyNormalizedRoute(c, r) {
|
||||
return
|
||||
}
|
||||
path := c.Request.URL.Path
|
||||
if shouldBypassSPAFallback(path) {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
|
||||
@@ -257,7 +257,10 @@ func embyClientInfoFromRequest(c *gin.Context) embyClientInfo {
|
||||
firstHeaderValue(c, "X-Emby-Client", "X-MediaBrowser-Client"),
|
||||
c.Query("Client"),
|
||||
c.Query("client"),
|
||||
c.Query("X-Emby-Client"),
|
||||
c.Query("X-MediaBrowser-Client"),
|
||||
auth["Client"],
|
||||
auth["client"],
|
||||
),
|
||||
}
|
||||
ua := strings.TrimSpace(c.GetHeader("User-Agent"))
|
||||
@@ -315,10 +318,36 @@ func embyClientFromUserAgent(ua string) string {
|
||||
return "Emby"
|
||||
case strings.Contains(lower, "jellyfin"):
|
||||
return "Jellyfin"
|
||||
case strings.Contains(lower, "capyplayer") || strings.Contains(lower, "capy player") || strings.Contains(lower, "卡皮巴拉"):
|
||||
return "CapyPlayer"
|
||||
case strings.Contains(lower, "senplayer") || strings.Contains(lower, "sen player") || strings.Contains(lower, "森播"):
|
||||
return "SenPlayer"
|
||||
case strings.Contains(lower, "yamby"):
|
||||
return "Yamby"
|
||||
case strings.Contains(lower, "vidhub"):
|
||||
return "VidHub"
|
||||
case strings.Contains(lower, "fileball"):
|
||||
return "Fileball"
|
||||
case strings.Contains(lower, "hamhub"):
|
||||
return "HamHub"
|
||||
case strings.Contains(lower, "afusekt") || strings.Contains(lower, "afuse"):
|
||||
return "AfuseKt"
|
||||
case strings.Contains(lower, "cony"):
|
||||
return "Cony"
|
||||
case strings.Contains(lower, "kodi"):
|
||||
return "Kodi"
|
||||
case strings.Contains(lower, "mrmc"):
|
||||
return "MrMC"
|
||||
case strings.Contains(lower, "forward"):
|
||||
return "Forward"
|
||||
case strings.Contains(lower, "alpha"):
|
||||
return "Alpha"
|
||||
case strings.Contains(lower, "dandanplay") || strings.Contains(lower, "弹弹play"):
|
||||
return "DanDanPlay"
|
||||
case strings.Contains(lower, "potplayer"):
|
||||
return "PotPlayer"
|
||||
case strings.Contains(lower, "vlc"):
|
||||
return "VLC"
|
||||
case strings.Contains(lower, "hills"):
|
||||
return "Hills"
|
||||
default:
|
||||
@@ -343,7 +372,7 @@ func embyDeviceFromUserAgent(ua string) string {
|
||||
return "Mac"
|
||||
case strings.Contains(lower, "linux"):
|
||||
return "Linux PC"
|
||||
case strings.Contains(lower, "appletv") || strings.Contains(lower, "apple tv"):
|
||||
case strings.Contains(lower, "appletv") || strings.Contains(lower, "apple tv") || strings.Contains(lower, "appletvos"):
|
||||
return "Apple TV"
|
||||
default:
|
||||
return ""
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -51,13 +51,14 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
func clearEmbyImageNoStoreHeaders(c *gin.Context) {
|
||||
c.Writer.Header().Del("Cache-Control")
|
||||
c.Writer.Header().Del("Pragma")
|
||||
c.Writer.Header().Del("Expires")
|
||||
}
|
||||
|
||||
func embyServePlaceholderImage(c *gin.Context) {
|
||||
c.Header("Content-Type", "image/png")
|
||||
c.Header("Cache-Control", "public, max-age=3600")
|
||||
c.Header("Cache-Control", "public, max-age=86400")
|
||||
c.Header("Content-Length", strconv.Itoa(len(embyPlaceholderPNG)))
|
||||
if c.Request.Method == http.MethodHead {
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var (
|
||||
multipleSlashesRE = regexp.MustCompile(`/{2,}`)
|
||||
)
|
||||
|
||||
// embyStaticSegments 包含 Emby API 中已知的保留静态路由分段(全部小写),
|
||||
// 用于在遇到客户端混合大小写(如 /System/info, /items/:id/playbackInfo)时,
|
||||
// 将静态段规范化为对应的小写形式,而保留动态参数段(:id, :userId 等)的原大小写。
|
||||
var embyStaticSegments = map[string]struct{}{
|
||||
"system": {},
|
||||
"info": {},
|
||||
"public": {},
|
||||
"endpoint": {},
|
||||
"configuration": {},
|
||||
"ping": {},
|
||||
"users": {},
|
||||
"useritems": {},
|
||||
"me": {},
|
||||
"authenticatebyname": {},
|
||||
"items": {},
|
||||
"counts": {},
|
||||
"latest": {},
|
||||
"resume": {},
|
||||
"playbackinfo": {},
|
||||
"shows": {},
|
||||
"seasons": {},
|
||||
"episodes": {},
|
||||
"nextup": {},
|
||||
"upcoming": {},
|
||||
"similar": {},
|
||||
"thumbnailset": {},
|
||||
"thememedia": {},
|
||||
"specialfeatures": {},
|
||||
"intros": {},
|
||||
"videos": {},
|
||||
"stream": {},
|
||||
"subtitles": {},
|
||||
"master.m3u8": {},
|
||||
"main.m3u8": {},
|
||||
"sessions": {},
|
||||
"playing": {},
|
||||
"progress": {},
|
||||
"stopped": {},
|
||||
"capabilities": {},
|
||||
"full": {},
|
||||
"logout": {},
|
||||
"views": {},
|
||||
"library": {},
|
||||
"mediafolders": {},
|
||||
"virtualfolders": {},
|
||||
"selectablemediafolders": {},
|
||||
"branding": {},
|
||||
"css": {},
|
||||
"localization": {},
|
||||
"options": {},
|
||||
"cultures": {},
|
||||
"customcssjs": {},
|
||||
"scripts": {},
|
||||
"displaypreferences": {},
|
||||
"quickconnect": {},
|
||||
"enabled": {},
|
||||
"startup": {},
|
||||
"complete": {},
|
||||
"favoriteitems": {},
|
||||
"playeditems": {},
|
||||
"images": {},
|
||||
"primary": {},
|
||||
"backdrop": {},
|
||||
"banner": {},
|
||||
"thumb": {},
|
||||
"logo": {},
|
||||
"serverdomains": {},
|
||||
"ext": {},
|
||||
"danmu": {},
|
||||
"raw": {},
|
||||
"mediasegments": {},
|
||||
"artists": {},
|
||||
"persons": {},
|
||||
"genres": {},
|
||||
"embywebsocket": {},
|
||||
}
|
||||
|
||||
// NormalizeEmbyPath 规范化 Emby 请求路径:
|
||||
// 1. 折叠重复斜杠(如 //emby/ -> /emby/);
|
||||
// 2. 折叠重复前缀(如 /emby/emby/System/Info -> /emby/System/Info);
|
||||
// 3. 将静态关键字段归一化为小写,同时保留动态 ID/参数的原有大小写。
|
||||
func NormalizeEmbyPath(p string) (string, bool) {
|
||||
if p == "" {
|
||||
return "", false
|
||||
}
|
||||
original := p
|
||||
|
||||
// 1. 折叠多余斜杠
|
||||
p = multipleSlashesRE.ReplaceAllString(p, "/")
|
||||
|
||||
// 2. 折叠重复的 /emby 前缀
|
||||
for {
|
||||
lower := strings.ToLower(p)
|
||||
if strings.HasPrefix(lower, "/emby/emby/") {
|
||||
p = "/emby/" + p[len("/emby/emby/"):]
|
||||
continue
|
||||
}
|
||||
if lower == "/emby/emby" {
|
||||
p = "/emby"
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// 3. 分析是否具有 Emby 路由特征
|
||||
hasEmbyPrefix := false
|
||||
workPath := p
|
||||
if strings.HasPrefix(strings.ToLower(workPath), "/emby/") {
|
||||
hasEmbyPrefix = true
|
||||
workPath = workPath[len("/emby"):]
|
||||
} else if strings.EqualFold(workPath, "/emby") {
|
||||
return "/emby", original != "/emby"
|
||||
}
|
||||
|
||||
segments := strings.Split(strings.Trim(workPath, "/"), "/")
|
||||
if len(segments) == 0 || segments[0] == "" {
|
||||
return p, p != original
|
||||
}
|
||||
|
||||
// 检查第一段是否为 Emby 根路由关键字
|
||||
firstLower := strings.ToLower(segments[0])
|
||||
if _, ok := embyStaticSegments[firstLower]; !ok && firstLower != "api" {
|
||||
// 不是 Emby 相关路径,保持原样
|
||||
return original, false
|
||||
}
|
||||
|
||||
// 4. 将已知静态段转为小写,动态段保留原样
|
||||
for i, seg := range segments {
|
||||
segLower := strings.ToLower(seg)
|
||||
if _, isStatic := embyStaticSegments[segLower]; isStatic {
|
||||
if seg != segLower {
|
||||
segments[i] = segLower
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
if hasEmbyPrefix {
|
||||
builder.WriteString("/emby")
|
||||
}
|
||||
for _, seg := range segments {
|
||||
builder.WriteString("/")
|
||||
builder.WriteString(seg)
|
||||
}
|
||||
if strings.HasSuffix(original, "/") && !strings.HasSuffix(builder.String(), "/") {
|
||||
builder.WriteString("/")
|
||||
}
|
||||
|
||||
normalized := builder.String()
|
||||
return normalized, normalized != original
|
||||
}
|
||||
|
||||
const embyNormalizedCtxKey = "emby_normalized_path"
|
||||
|
||||
// TryHandleEmbyNormalizedRoute 尝试在 404 NoRoute 阶段对 Emby 路径做前缀与大小写纠偏并重定向分发。
|
||||
// 若成功分发并处理,返回 true;否则返回 false。
|
||||
func TryHandleEmbyNormalizedRoute(c *gin.Context, r *gin.Engine) bool {
|
||||
if c == nil || r == nil {
|
||||
return false
|
||||
}
|
||||
if c.GetBool(embyNormalizedCtxKey) {
|
||||
return false
|
||||
}
|
||||
normalized, changed := NormalizeEmbyPath(c.Request.URL.Path)
|
||||
if !changed {
|
||||
return false
|
||||
}
|
||||
|
||||
c.Set(embyNormalizedCtxKey, true)
|
||||
c.Request.URL.Path = normalized
|
||||
|
||||
// 重置 context 状态并由 engine 重新查找路由树
|
||||
c.Params = nil
|
||||
c.Writer.Header().Del("Content-Type")
|
||||
r.HandleContext(c)
|
||||
return true
|
||||
}
|
||||
@@ -23,6 +23,14 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container)
|
||||
auth := grp.Group("", embyAuthRequiredWithSessionFallback(jwtSecret), activeEmbyUserRequired(svc), embyRealtimeSessionActivity(svc))
|
||||
registerEmbyAuthenticatedRoutes(auth, prefix, svc)
|
||||
}
|
||||
|
||||
// 为没有设置 NoRoute 的 Engine 提供 Emby 路径前缀与大小写纠偏(如单测或独立挂载环境)
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
if TryHandleEmbyNormalizedRoute(c, r) {
|
||||
return
|
||||
}
|
||||
c.Status(404)
|
||||
})
|
||||
}
|
||||
|
||||
type embyRouteHandlerFactory func(*service.Container) gin.HandlerFunc
|
||||
|
||||
@@ -11,6 +11,10 @@ import (
|
||||
|
||||
// SystemInfo returns the full Emby identity payload.
|
||||
func (e *EmbyService) SystemInfo() map[string]any {
|
||||
port := 8096
|
||||
if e != nil && e.cfg != nil {
|
||||
port = e.cfg.App.Port
|
||||
}
|
||||
return map[string]any{
|
||||
"Id": embyServerID,
|
||||
"ServerId": embyServerID,
|
||||
@@ -27,10 +31,10 @@ func (e *EmbyService) SystemInfo() map[string]any {
|
||||
"SupportsLibraryMonitor": true,
|
||||
"SupportsHttps": false,
|
||||
"SupportsAutoDiscovery": true,
|
||||
"HttpServerPortNumber": e.cfg.App.Port,
|
||||
"HttpServerPortNumber": port,
|
||||
"HttpsPortNumber": 0,
|
||||
"PublishedServerUrl": "",
|
||||
"WebSocketPortNumber": e.cfg.App.Port,
|
||||
"WebSocketPortNumber": port,
|
||||
"CompletedInstallations": []any{},
|
||||
"CanSelfRestart": false,
|
||||
"CanLaunchWebBrowser": false,
|
||||
@@ -40,6 +44,10 @@ func (e *EmbyService) SystemInfo() map[string]any {
|
||||
|
||||
// SystemInfoPublic 是不需要认证的精简版(Emby Web 客户端登陆前会拉)。
|
||||
func (e *EmbyService) SystemInfoPublic() map[string]any {
|
||||
port := 8096
|
||||
if e != nil && e.cfg != nil {
|
||||
port = e.cfg.App.Port
|
||||
}
|
||||
return map[string]any{
|
||||
"Id": embyServerID,
|
||||
"ServerId": embyServerID,
|
||||
@@ -50,7 +58,7 @@ func (e *EmbyService) SystemInfoPublic() map[string]any {
|
||||
"OperatingSystem": "Windows",
|
||||
"LocalAddress": "",
|
||||
"WanAddress": "",
|
||||
"HttpServerPortNumber": e.cfg.App.Port,
|
||||
"HttpServerPortNumber": port,
|
||||
"HttpsPortNumber": 0,
|
||||
"SupportsHttps": false,
|
||||
"SupportsAutoDiscovery": true,
|
||||
|
||||
@@ -137,7 +137,7 @@ export function LayoutWorkspace({ routeKey, showMobileBottomNav = false }: Layou
|
||||
}
|
||||
|
||||
return (
|
||||
<main id="app-main-scroll" className={clsx('flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10', bottomPad)}>
|
||||
<main id="app-main-scroll" className={clsx('flex-1 overflow-y-auto [overflow-anchor:none] px-4 py-6 md:px-8 md:py-10', bottomPad)}>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
|
||||
@@ -1,37 +1,24 @@
|
||||
import { forwardRef, useEffect, useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import { VirtuosoGrid } from 'react-virtuoso'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// 与 LibraryMediaSections 等处的海报网格保持同一套响应式列配置。
|
||||
export const MEDIA_GRID_CLASS =
|
||||
'grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8'
|
||||
|
||||
// 首帧滚动容器尚未就绪时的占位渲染数量(约一屏)。
|
||||
const FIRST_PAINT_COUNT = 28
|
||||
function getFallbackColumns(width: number): number {
|
||||
if (width >= 1536) return 8
|
||||
if (width >= 1280) return 7
|
||||
if (width >= 1024) return 6
|
||||
if (width >= 768) return 5
|
||||
if (width >= 640) return 4
|
||||
return 3
|
||||
}
|
||||
|
||||
const GridList = forwardRef<HTMLDivElement, { style?: CSSProperties; children?: ReactNode }>(
|
||||
function GridList({ children, style, ...rest }, ref) {
|
||||
return (
|
||||
<div ref={ref} style={style} {...rest} className={MEDIA_GRID_CLASS}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const GridItem = forwardRef<HTMLDivElement, { style?: CSSProperties; children?: ReactNode }>(
|
||||
function GridItem({ children, style, ...rest }, ref) {
|
||||
return (
|
||||
<div ref={ref} style={style} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
// VirtualMediaGrid 大库性能优化:无论库里有 1 千还是 5 万张卡片,
|
||||
// 实际挂载的只有视口内及邻近一屏的节点。滚动容器是 Layout 的
|
||||
// <main id="app-main-scroll">(非 window 滚动),通过 customScrollParent 接入;
|
||||
// 元素尚未挂载时先渲染一屏卡片,挂载后切换为虚拟列表。
|
||||
// VirtualMediaGrid 大库性能优化:
|
||||
// 采用按行虚拟滚动(Row-based Virtualization)。相比 VirtuosoGrid 强制要求所有网格项
|
||||
// 绝对等高且易受 CSS Grid 亚像素尺寸扰动引发死循环闪烁,按行使用基础 Virtuoso 组件
|
||||
// 天然支持每行真实高度,并且每一行内部保持原生的响应式 CSS Grid 布局。
|
||||
export function VirtualMediaGrid({
|
||||
totalCount,
|
||||
renderItem,
|
||||
@@ -39,29 +26,98 @@ export function VirtualMediaGrid({
|
||||
totalCount: number
|
||||
renderItem: (index: number) => ReactNode
|
||||
}) {
|
||||
const [scrollParent, setScrollParent] = useState<HTMLElement | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const probeRef = useRef<HTMLDivElement>(null)
|
||||
const [columns, setColumns] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return getFallbackColumns(window.innerWidth)
|
||||
}
|
||||
return 4
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setScrollParent(document.getElementById('app-main-scroll'))
|
||||
const updateColumns = useCallback(() => {
|
||||
if (probeRef.current) {
|
||||
const computed = window.getComputedStyle(probeRef.current).gridTemplateColumns
|
||||
if (computed && computed !== 'none') {
|
||||
const count = computed.trim().split(/\s+/).filter(Boolean).length
|
||||
if (count > 0) {
|
||||
setColumns((prev) => (prev !== count ? count : prev))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
const width = containerRef.current?.clientWidth || (typeof window !== 'undefined' ? window.innerWidth : 0)
|
||||
if (width > 0) {
|
||||
const fallback = getFallbackColumns(width)
|
||||
setColumns((prev) => (prev !== fallback ? fallback : prev))
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!scrollParent) {
|
||||
return (
|
||||
<div className={MEDIA_GRID_CLASS}>
|
||||
{Array.from({ length: Math.min(totalCount, FIRST_PAINT_COUNT) }, (_, index) => (
|
||||
<div key={index}>{renderItem(index)}</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
useLayoutEffect(() => {
|
||||
updateColumns()
|
||||
}, [updateColumns])
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el || typeof ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', updateColumns)
|
||||
return () => window.removeEventListener('resize', updateColumns)
|
||||
}
|
||||
const observer = new ResizeObserver(() => {
|
||||
updateColumns()
|
||||
})
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [updateColumns])
|
||||
|
||||
const [scrollParent, setScrollParent] = useState<HTMLElement | null>(() => {
|
||||
return typeof document !== 'undefined' ? document.getElementById('app-main-scroll') : null
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollParent) {
|
||||
setScrollParent(document.getElementById('app-main-scroll'))
|
||||
}
|
||||
}, [scrollParent])
|
||||
|
||||
const rowCount = Math.ceil(totalCount / columns)
|
||||
|
||||
return (
|
||||
<VirtuosoGrid
|
||||
customScrollParent={scrollParent}
|
||||
totalCount={totalCount}
|
||||
overscan={800}
|
||||
components={{ List: GridList, Item: GridItem }}
|
||||
itemContent={renderItem}
|
||||
/>
|
||||
<div ref={containerRef} className="relative w-full">
|
||||
{/* 隐藏探针节点:跟随 Tailwind MEDIA_GRID_CLASS 响应式断点自动计算当前列数 */}
|
||||
<div
|
||||
ref={probeRef}
|
||||
className={clsx(MEDIA_GRID_CLASS, 'pointer-events-none invisible absolute h-0 w-full overflow-hidden')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{!scrollParent ? (
|
||||
<div className={MEDIA_GRID_CLASS}>
|
||||
{Array.from({ length: Math.min(totalCount, columns * 4) }, (_, index) => (
|
||||
<div key={index}>{renderItem(index)}</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Virtuoso
|
||||
customScrollParent={scrollParent}
|
||||
totalCount={rowCount}
|
||||
overscan={800}
|
||||
itemContent={(rowIndex) => {
|
||||
const start = rowIndex * columns
|
||||
return (
|
||||
<div className={clsx(MEDIA_GRID_CLASS, rowIndex < rowCount - 1 && 'pb-4')}>
|
||||
{Array.from({ length: columns }, (_, colIndex) => {
|
||||
const itemIndex = start + colIndex
|
||||
if (itemIndex >= totalCount) {
|
||||
return <div key={colIndex} aria-hidden="true" />
|
||||
}
|
||||
return <div key={itemIndex}>{renderItem(itemIndex)}</div>
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user