From 132096a59626d0ee708f69b1a9a90fd2cf3e6c3e Mon Sep 17 00:00:00 2001 From: ShukeBta Date: Sun, 7 Jun 2026 16:07:54 +0800 Subject: [PATCH] fix(emby): support lowercase client routes --- internal/handler/emby.go | 76 ++++++++++++++++++++++ internal/handler/emby_test.go | 117 ++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) diff --git a/internal/handler/emby.go b/internal/handler/emby.go index 9985e9c..e368889 100644 --- a/internal/handler/emby.go +++ b/internal/handler/emby.go @@ -704,6 +704,23 @@ func embySessionsHandler(_ *service.Container) gin.HandlerFunc { } } +func embyNoContentHandler(_ *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + c.Status(http.StatusNoContent) + } +} + +func embyServerConfigurationHandler(_ *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "EnableFolderView": true, + "EnableGroupingIntoCollections": true, + "EnableExternalContentInSuggestions": false, + "ImageSavingConvention": "Compatible", + }) + } +} + func embyEmptyItemsHandler(_ *service.Container) gin.HandlerFunc { return func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"Items": []any{}, "TotalRecordCount": 0}) @@ -777,6 +794,9 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container) grp.GET("/Items/:id/Images/:type", embyItemImageHandler(svc)) grp.GET("/Items/:id/Images/:type/:index", embyItemImageHandler(svc)) grp.HEAD("/Items/:id/Images/:type", embyItemImageHandler(svc)) + grp.GET("/items/:id/images/:type", embyItemImageHandler(svc)) + grp.GET("/items/:id/images/:type/:index", embyItemImageHandler(svc)) + grp.HEAD("/items/:id/images/:type", embyItemImageHandler(svc)) // 鉴权后端点 auth := grp.Group("", middleware.EmbyAuthRequired(jwtSecret), activeEmbyUserRequired(svc)) @@ -828,5 +848,61 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container) auth.POST("/DisplayPreferences/:id", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + + registerLowercaseEmbyAuthRoutes(auth, svc) } } + +func registerLowercaseEmbyAuthRoutes(auth *gin.RouterGroup, svc *service.Container) { + auth.GET("/users/me", embyMeHandler(svc)) + auth.GET("/users", embyListUsersHandler(svc)) + auth.GET("/users/:userId", embyGetUserByIDHandler(svc)) + auth.GET("/users/:userId/views", embyViewsHandler(svc)) + auth.GET("/library/mediafolders", embyViewsHandler(svc)) + auth.GET("/library/virtualfolders", embyVirtualFoldersHandler(svc)) + auth.GET("/library/selectablemediafolders", embyVirtualFoldersHandler(svc)) + + auth.GET("/items", embyItemsHandler(svc)) + auth.GET("/users/:userId/items", embyItemsHandler(svc)) + auth.GET("/items/:id", embyItemByIDHandler(svc)) + auth.GET("/users/:userId/items/:id", embyUserItemByIDHandler(svc)) + auth.GET("/shows/:id/seasons", embyShowSeasonsHandler(svc)) + auth.GET("/shows/:id/episodes", embyShowEpisodesHandler(svc)) + auth.GET("/users/:userId/shows/:id/seasons", embyShowSeasonsHandler(svc)) + auth.GET("/users/:userId/shows/:id/episodes", embyShowEpisodesHandler(svc)) + auth.GET("/shows/nextup", embyEmptyItemsHandler(svc)) + auth.GET("/users/:userId/shows/nextup", embyEmptyItemsHandler(svc)) + auth.GET("/mediasegments/:id", embyEmptyItemsHandler(svc)) + + auth.GET("/items/:id/playbackinfo", embyPlaybackInfoHandler(svc)) + auth.POST("/items/:id/playbackinfo", embyPlaybackInfoHandler(svc)) + auth.GET("/users/:userId/items/:id/playbackinfo", embyPlaybackInfoHandler(svc)) + auth.POST("/users/:userId/items/:id/playbackinfo", embyPlaybackInfoHandler(svc)) + + auth.GET("/videos/:id/stream", embyVideoStreamHandler(svc)) + auth.HEAD("/videos/:id/stream", embyVideoStreamHandler(svc)) + auth.GET("/videos/:id/stream.:container", embyVideoStreamHandler(svc)) + auth.HEAD("/videos/:id/stream.:container", embyVideoStreamHandler(svc)) + auth.GET("/videos/:id/original", embyVideoStreamHandler(svc)) + auth.GET("/videos/:id/original.:container", embyVideoStreamHandler(svc)) + + auth.POST("/sessions/playing", embyPlayingProgressHandler(svc)) + auth.POST("/sessions/playing/progress", embyPlayingProgressHandler(svc)) + auth.POST("/sessions/playing/stopped", embyPlayingProgressHandler(svc)) + auth.POST("/sessions/capabilities", embyNoContentHandler(svc)) + auth.POST("/sessions/capabilities/full", embyNoContentHandler(svc)) + + auth.POST("/users/:userId/favoriteitems/:itemId", embyFavoriteHandler(svc, true)) + auth.DELETE("/users/:userId/favoriteitems/:itemId", embyFavoriteHandler(svc, false)) + auth.POST("/users/:userId/playeditems/:itemId", embyMarkPlayedHandler(svc, true)) + auth.DELETE("/users/:userId/playeditems/:itemId", embyMarkPlayedHandler(svc, false)) + + auth.GET("/sessions", embySessionsHandler(svc)) + auth.GET("/system/configuration", embyServerConfigurationHandler(svc)) + auth.GET("/displaypreferences/:id", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"Id": c.Param("id"), "CustomPrefs": gin.H{}}) + }) + auth.POST("/displaypreferences/:id", func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) +} diff --git a/internal/handler/emby_test.go b/internal/handler/emby_test.go index 744bd9c..bc0c567 100644 --- a/internal/handler/emby_test.go +++ b/internal/handler/emby_test.go @@ -269,6 +269,123 @@ func TestEmbyUserItemByIDRouteReturnsJSON(t *testing.T) { } } +func TestEmbyLowercasePlaybackInfoRouteReturnsJSON(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) + 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) + } + lib := model.Library{Name: "电影", Path: t.TempDir(), Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + if err := db.Create(&model.Media{ + Base: model.Base{ID: "media-1"}, + LibraryID: lib.ID, + Title: "Lowercase Playback", + Path: filepath.Join(lib.Path, "lowercase-playback.mp4"), + Container: "mp4", + }).Error; err != nil { + t.Fatalf("create media: %v", err) + } + + const secret = "test-secret" + router := gin.New() + registerEmbyRoutes(router, secret, &service.Container{ + Repo: repos, + Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos), + }) + + req := httptest.NewRequest(http.MethodGet, "/users/user-1/items/media-1/playbackinfo", nil) + req.Header.Set("X-Emby-Token", signedTestToken(t, secret)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String()) + } + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode playback info: %v", err) + } + if _, ok := body["MediaSources"]; !ok { + t.Fatalf("missing MediaSources: %#v", body) + } +} + +func TestEmbyLowercaseVideoStreamRouteServesMedia(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) + 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) + } + dir := t.TempDir() + mediaPath := filepath.Join(dir, "sample.mp4") + if err := os.WriteFile(mediaPath, []byte("fake-video-bytes"), 0o644); err != nil { + t.Fatalf("write media: %v", err) + } + lib := model.Library{Name: "电影", Path: dir, Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + if err := db.Create(&model.Media{ + Base: model.Base{ID: "media-1"}, + LibraryID: lib.ID, + Title: "Lowercase Stream", + Path: mediaPath, + Container: "mp4", + }).Error; err != nil { + t.Fatalf("create media: %v", err) + } + + const secret = "test-secret" + router := gin.New() + registerEmbyRoutes(router, secret, &service.Container{ + Repo: repos, + Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos), + Stream: service.NewStreamService(&config.Config{}, zap.NewNop(), repos, nil), + }) + + req := httptest.NewRequest(http.MethodGet, "/videos/media-1/stream?api_key="+signedTestToken(t, secret), nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String()) + } + if got := w.Body.String(); got != "fake-video-bytes" { + t.Fatalf("unexpected stream body: %q", got) + } +} + func signedTestToken(t *testing.T, secret string) string { t.Helper() claims := middleware.Claims{