From 68a5a1e3c078b7d26d852f2d55f7d7ef1fe679c8 Mon Sep 17 00:00:00 2001 From: ShukeBta Date: Thu, 28 May 2026 16:00:09 +0800 Subject: [PATCH] Fix Emby playback routes and stream compatibility --- .tmp-deploy-server.pid | 2 +- cmd/server/main.go | 29 +- internal/handler/emby.go | 175 ++++++- internal/handler/emby_test.go | 196 ++++++++ internal/handler/handler.go | 1 + internal/middleware/middleware.go | 11 +- internal/service/emby_compat.go | 671 +++++++++++++++++++++++++-- internal/service/emby_compat_test.go | 114 +++++ 8 files changed, 1143 insertions(+), 56 deletions(-) create mode 100644 internal/service/emby_compat_test.go diff --git a/.tmp-deploy-server.pid b/.tmp-deploy-server.pid index 552313f..554f591 100644 --- a/.tmp-deploy-server.pid +++ b/.tmp-deploy-server.pid @@ -1 +1 @@ -20380 +29292 diff --git a/cmd/server/main.go b/cmd/server/main.go index ec1bff2..70dee06 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -19,6 +19,7 @@ import ( "os" "os/signal" "path/filepath" + "strings" "syscall" "time" @@ -149,8 +150,9 @@ func serveSPA(r *gin.Engine, webDir string) { r.StaticFile("/favicon.ico", filepath.Join(webDir, "favicon.ico")) r.NoRoute(func(c *gin.Context) { path := c.Request.URL.Path - // Do not swallow API or WebSocket routes; let them 404 naturally. - if len(path) >= 5 && path[:5] == "/api/" { + // Do not swallow API / Emby compatibility routes; clients expect JSON + // or 404, not the React index.html fallback. + if shouldBypassSPAFallback(path) { c.Status(http.StatusNotFound) return } @@ -158,6 +160,29 @@ func serveSPA(r *gin.Engine, webDir string) { }) } +func shouldBypassSPAFallback(path string) bool { + lower := strings.ToLower(path) + for _, prefix := range []string{ + "/api/", + "/emby/", + "/system/", + "/users/", + "/items/", + "/shows/", + "/library/", + "/videos/", + "/sessions/", + "/displaypreferences/", + "/branding/", + "/localization/", + } { + if strings.HasPrefix(lower, prefix) { + return true + } + } + return false +} + func newLogger(cfg *config.Config) (*zap.Logger, error) { if cfg.App.Debug { return zap.NewDevelopment() diff --git a/internal/handler/emby.go b/internal/handler/emby.go index a2c91bd..38bf597 100644 --- a/internal/handler/emby.go +++ b/internal/handler/emby.go @@ -9,7 +9,6 @@ import ( "errors" "io" "net/http" - "net/url" "strconv" "strings" @@ -275,11 +274,43 @@ func embyMeHandler(svc *service.Container) gin.HandlerFunc { func embyGetUserByIDHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { u, err := svc.Emby.FindUser(c.Request.Context(), c.Param("userId")) - if err != nil || u == nil { - embyError(c, http.StatusNotFound, "user not found") + if err == nil && u != nil { + c.JSON(http.StatusOK, u) return } - c.JSON(http.StatusOK, u) + if authUID := embyUserID(c); authUID != "" && authUID != c.Param("userId") { + u, err = svc.Emby.FindUser(c.Request.Context(), authUID) + if err == nil && u != nil { + c.JSON(http.StatusOK, u) + return + } + } + c.JSON(http.StatusOK, embyFallbackUser(c.Param("userId"))) + } +} + +func embyFallbackUser(id string) gin.H { + if strings.TrimSpace(id) == "" { + id = "mediastation-user" + } + return gin.H{ + "Id": id, + "Name": "MediaStation", + "ServerId": "mediastation-go-001", + "HasPassword": true, + "HasConfiguredPassword": true, + "HasConfiguredEasyPassword": false, + "EnableAutoLogin": false, + "Policy": gin.H{ + "IsAdministrator": true, + "EnableContentDeletion": true, + "EnableRemoteControlOfOtherUsers": true, + "EnableSharedDeviceControl": true, + "EnableRemoteAccess": true, + "EnableAllDevices": true, + "EnableAllChannels": true, + "EnableAllFolders": true, + }, } } @@ -296,6 +327,38 @@ func embyViewsHandler(svc *service.Container) gin.HandlerFunc { } } +func embyVirtualFoldersHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Cache-Control", "no-store") + libs, err := svc.Repo.Library.List(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + out := make([]gin.H, 0, len(libs)) + for _, lib := range libs { + collectionType := "movies" + switch lib.Type { + case "tv", "anime", "variety": + collectionType = "tvshows" + case "music": + collectionType = "music" + } + out = append(out, gin.H{ + "Name": lib.Name, + "Locations": []string{lib.Path}, + "CollectionType": collectionType, + "ItemId": lib.ID, + "Id": lib.ID, + "PrimaryImageItemId": lib.ID, + "RefreshStatus": "Idle", + "LibraryOptions": gin.H{}, + }) + } + c.JSON(http.StatusOK, out) + } +} + // ─── Items ─────────────────────────────────────────────────────────────────── func parseEmbyItemsParams(c *gin.Context) service.ItemsParams { @@ -364,6 +427,19 @@ func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc { } } +func embyUserItemByIDHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + switch strings.ToLower(c.Param("id")) { + case "latest": + embyLatestItemsHandler(svc)(c) + case "resume": + embyResumeItemsHandler(svc)(c) + default: + embyItemByIDHandler(svc)(c) + } + } +} + func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { uid := c.Param("userId") @@ -398,34 +474,63 @@ func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc { // ─── Images ────────────────────────────────────────────────────────────────── -// embyItemImageHandler 把 /Items/{id}/Images/Primary 等请求重定向到 -// 我们的 /api/img 代理。Emby 客户端会自动追加 ?api_key=... 或 ?tag=... -// 我们只关心 id+type,从 media row 拉到 PosterURL/BackdropURL 后转成 -// /api/img?url=... 重定向。 +// embyItemImageHandler 把 /Items/{id}/Images/Primary 等请求直接输出为图片。 +// Emby 客户端缓存图片 URL 时经常不会继续携带 token;如果重定向到受保护的 +// /api/img 会变成 401,所以这里复用 ImageProxy 但不再走 /api 路由。 func embyItemImageHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") imgType := strings.ToLower(c.Param("type")) - m, err := svc.Repo.Media.FindByID(c.Request.Context(), id) - if err != nil || m == nil { + raw, err := svc.Emby.ImageURL(c.Request.Context(), id, imgType) + if err != nil || raw == "" { c.Status(http.StatusNotFound) return } - var raw string - switch imgType { - case "primary", "thumb", "banner", "logo": - raw = m.PosterURL - case "backdrop", "art": - raw = m.BackdropURL - default: - raw = m.PosterURL - } - if raw == "" { + if svc.ImageProxy == nil { c.Status(http.StatusNotFound) return } - // 直接重定向到 /api/img;image proxy 自己缓存 + 兜底 1×1 PNG。 - c.Redirect(http.StatusFound, "/api/img?url="+url.QueryEscape(raw)) + if err := svc.ImageProxy.Serve(c.Request.Context(), c.Writer, c.Request, raw); err != nil { + c.Status(http.StatusNotFound) + } + } +} + +func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + params := service.ItemsParams{ + UserID: firstQueryValue(c, "UserId", "userId"), + ParentID: c.Param("id"), + Limit: 500, + } + out, err := svc.Emby.Items(c.Request.Context(), params) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, out) + } +} + +func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + parentID := firstQueryValue(c, "SeasonId", "seasonId") + if parentID == "" { + parentID = c.Param("id") + } + params := service.ItemsParams{ + UserID: firstQueryValue(c, "UserId", "userId"), + ParentID: parentID, + IncludeItemTypes: []string{"Episode"}, + Recursive: true, + Limit: 500, + } + out, err := svc.Emby.Items(c.Request.Context(), params) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, out) } } @@ -551,6 +656,12 @@ func embySessionsHandler(_ *service.Container) gin.HandlerFunc { } } +func embyEmptyItemsHandler(_ *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"Items": []any{}, "TotalRecordCount": 0}) + } +} + func embyBrandingConfigHandler(_ *service.Container) gin.HandlerFunc { return func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ @@ -574,6 +685,12 @@ func embyLocalizationOptionsHandler(_ *service.Container) gin.HandlerFunc { func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container) { for _, prefix := range []string{"/emby", ""} { grp := r.Group(prefix) + grp.Use(func(c *gin.Context) { + c.Header("Cache-Control", "no-store") + c.Header("Pragma", "no-cache") + c.Header("Expires", "0") + c.Next() + }) // 公开端点 for _, path := range []string{"/System/Info/Public", "/system/info/public"} { @@ -617,15 +734,25 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container) 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/Latest", embyLatestItemsHandler(svc)) - auth.GET("/Users/:userId/Items/Resume", embyResumeItemsHandler(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)) diff --git a/internal/handler/emby_test.go b/internal/handler/emby_test.go index d539b1d..187aa2c 100644 --- a/internal/handler/emby_test.go +++ b/internal/handler/emby_test.go @@ -1,12 +1,26 @@ package handler import ( + "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" + "time" "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/golang-jwt/jwt/v5" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/middleware" + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" + "github.com/ShukeBta/MediaStationGo/internal/service" ) func TestParseEmbyAuthByNameReqAcceptsLowercaseJSON(t *testing.T) { @@ -71,3 +85,185 @@ func TestEmbyWithRequestAddressHonorsForwardedHeaders(t *testing.T) { t.Fatalf("unexpected LocalAddress: %#v", payload["LocalAddress"]) } } + +func TestEmbyVirtualFoldersRouteReturnsJSON(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.Library{}); err != nil { + t.Fatalf("migrate: %v", err) + } + repos := repository.New(db) + for _, lib := range []model.Library{ + {Name: "电影", Path: "D:\\media\\movies", Type: "movie", Enabled: true}, + {Name: "剧集", Path: "D:\\media\\tv", Type: "tv", Enabled: true}, + {Name: "综艺", Path: "D:\\media\\variety", Type: "variety", Enabled: true}, + } { + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + } + + const secret = "test-secret" + router := gin.New() + registerEmbyRoutes(router, secret, &service.Container{Repo: repos}) + + req := httptest.NewRequest(http.MethodGet, "/Library/VirtualFolders", 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()) + } + if contentType := w.Header().Get("Content-Type"); !strings.Contains(contentType, "application/json") { + t.Fatalf("expected JSON content type, got %q body=%s", contentType, w.Body.String()) + } + if strings.HasPrefix(strings.TrimSpace(w.Body.String()), "") { + t.Fatalf("route returned frontend HTML instead of JSON") + } + + var folders []map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &folders); err != nil { + t.Fatalf("decode folders: %v", err) + } + if len(folders) != 3 { + t.Fatalf("expected 3 folders, got %d: %#v", len(folders), folders) + } + if folders[1]["CollectionType"] != "tvshows" || folders[2]["CollectionType"] != "tvshows" { + t.Fatalf("episodic libraries should expose tvshows collection type: %#v", folders) + } +} + +func TestEmbyItemImageServesWithoutAPIAuth(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.Media{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + posterPath := filepath.Join(t.TempDir(), "poster.png") + if err := os.WriteFile(posterPath, []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, + 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, + 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, + 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, + 0x42, 0x60, 0x82, + }, 0o644); err != nil { + t.Fatalf("write poster: %v", err) + } + + repos := repository.New(db) + cfg := &config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}} + if err := db.Create(&model.Media{ + Base: model.Base{ID: "media-1"}, + Title: "Poster Test", + Path: "D:\\media\\poster-test.mp4", + PosterURL: posterPath, + }).Error; err != nil { + t.Fatalf("create media: %v", err) + } + + router := gin.New() + registerEmbyRoutes(router, "test-secret", &service.Container{ + Repo: repos, + Emby: service.NewEmbyService(cfg, zap.NewNop(), repos), + ImageProxy: service.NewImageProxy(cfg, zap.NewNop()), + }) + + req := httptest.NewRequest(http.MethodGet, "/Items/media-1/Images/Primary", 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 location := w.Header().Get("Location"); location != "" { + t.Fatalf("expected direct image response, got redirect to %q", location) + } + if contentType := w.Header().Get("Content-Type"); !strings.Contains(contentType, "image/png") { + t.Fatalf("expected png content type, got %q", contentType) + } +} + +func TestEmbyUserItemByIDRouteReturnsJSON(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.Library{}, &model.Media{}, &model.Favorite{}, &model.PlaybackHistory{}); err != nil { + t.Fatalf("migrate: %v", err) + } + repos := repository.New(db) + lib := model.Library{Name: "剧集", Path: "D:\\media\\tv", Type: "tv", 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: "episode-1"}, + LibraryID: lib.ID, + Title: "Test Show", + Path: "D:\\media\\tv\\Test Show\\Season 01\\Test Show - S01E01.mkv", + SeasonNum: 1, + EpisodeNum: 1, + Container: "mkv", + }).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/episode-1", nil) + req.Header.Set("X-Emby-Token", signedTestToken(t, secret)) + req.Header.Set("If-None-Match", `"stale-client-cache"`) + 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 contentType := w.Header().Get("Content-Type"); !strings.Contains(contentType, "application/json") { + t.Fatalf("expected JSON content type, got %q body=%s", contentType, w.Body.String()) + } + var item map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &item); err != nil { + t.Fatalf("decode item: %v", err) + } + if item["Id"] != "episode-1" || item["Type"] != "Episode" { + t.Fatalf("unexpected item payload: %#v", item) + } +} + +func signedTestToken(t *testing.T, secret string) string { + t.Helper() + claims := middleware.Claims{ + UserID: "user-1", + Role: "admin", + Tier: "plus", + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + Issuer: "mediastationgo-test", + Subject: "user-1", + }, + } + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret)) + if err != nil { + t.Fatalf("sign token: %v", err) + } + return token +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index c88ca51..b21b073 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -72,6 +72,7 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C // Streaming. authed.GET("/stream/:id", streamHandler(svc)) + authed.HEAD("/stream/:id", streamHandler(svc)) authed.GET("/hls/:id/index.m3u8", hlsPlaylistHandler(svc)) authed.GET("/hls/:id/:seg", hlsSegmentHandler(svc)) authed.DELETE("/hls/:id", stopTranscodeHandler(svc)) diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index a76048a..7256572 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -165,8 +165,15 @@ func extractToken(c *gin.Context) string { if h := c.GetHeader("Authorization"); strings.HasPrefix(h, "Bearer ") { return strings.TrimSpace(strings.TrimPrefix(h, "Bearer ")) } - if q := c.Query("token"); q != "" { - return q + for _, header := range []string{"X-Emby-Token", "X-MediaBrowser-Token"} { + if value := strings.TrimSpace(c.GetHeader(header)); value != "" { + return value + } + } + for _, key := range []string{"token", "api_key", "apiKey", "ApiKey"} { + if value := strings.TrimSpace(c.Query(key)); value != "" { + return value + } } return "" } diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index fe8c544..ba571e7 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -6,15 +6,20 @@ // MediaStationGo without a custom plugin. // // The shim is read-mostly: items, images, playback are fully covered; -//播放进度上报 / 收藏切换 是写路径但走我们自己的 PlaybackHistory / +// 播放进度上报 / 收藏切换 是写路径但走我们自己的 PlaybackHistory / // Favorite 表,所以 Emby 客户端的"标记已看 / 收藏"也会反向同步到 // 我们自己的 React UI。 package service import ( "context" + "crypto/sha1" + "encoding/hex" "errors" "fmt" + "path/filepath" + "regexp" + "sort" "strconv" "strings" "time" @@ -220,7 +225,45 @@ type ItemsParams struct { StartIndex int } -// Items paginates media in Emby's flat shape. +const ( + embyVirtualSeriesPrefix = "msgo-series-" + embyVirtualSeasonPrefix = "msgo-season-" +) + +var ( + embySeasonDirRE = regexp.MustCompile(`(?i)^(season[\s._-]*\d+|s\d+|第\s*\d+\s*季)$`) + embyYearSuffixRE = regexp.MustCompile(`\s*[\((\[]\d{4}[\))\]]\s*$`) + embyEpisodeTitleRE = regexp.MustCompile(`(?i)\s*[-_ ]*s\d{1,2}e\d{1,3}.*$`) +) + +type embySeriesGroup struct { + ID string + LibraryID string + Name string + PosterURL string + BackdropURL string + Overview string + Rating float32 + Year int + TMDbID int + BangumiID int + CreatedAt time.Time + Episodes []model.Media +} + +type embySeasonGroup struct { + ID string + SeriesID string + LibraryID string + Name string + SeasonNum int + Series embySeriesGroup + Episodes []model.Media +} + +// Items paginates media in Emby's hierarchy. Episodic libraries are exposed as +// Series -> Season -> Episode so Infuse/Vidhub/SenPlayer stop treating every +// episode as a separate movie card. func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any, error) { if p.Limit <= 0 || p.Limit > 500 { p.Limit = 50 @@ -228,24 +271,74 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any, if p.StartIndex < 0 { p.StartIndex = 0 } - q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL") + + if len(p.IDs) > 0 { + items := make([]map[string]any, 0, len(p.IDs)) + for _, id := range p.IDs { + item, err := e.Item(ctx, id, p.UserID) + if err != nil { + return nil, err + } + if item != nil { + items = append(items, item) + } + } + return map[string]any{"Items": items, "TotalRecordCount": len(items), "StartIndex": 0}, nil + } + + if season, ok, err := e.findSeasonGroup(ctx, p.ParentID); err != nil { + return nil, err + } else if ok { + return e.episodeItems(ctx, season.Episodes, p) + } + + if series, ok, err := e.findSeriesGroup(ctx, p.ParentID); err != nil { + return nil, err + } else if ok { + if p.Recursive || containsItemType(p.IncludeItemTypes, "Episode") { + return e.episodeItems(ctx, series.Episodes, p) + } + seasons := e.seasonsForSeries(series) + items := make([]map[string]any, 0, len(seasons)) + for _, season := range pageSlice(seasons, p.StartIndex, p.Limit) { + items = append(items, e.seasonPayload(season)) + } + return map[string]any{"Items": items, "TotalRecordCount": len(seasons), "StartIndex": p.StartIndex}, nil + } + + if p.ParentID != "" { + if episodic, err := e.libraryIsEpisodic(ctx, p.ParentID); err != nil { + return nil, err + } else if episodic && !p.Recursive && !containsItemType(p.IncludeItemTypes, "Episode") { + return e.seriesItemsForLibrary(ctx, p.ParentID, p) + } + } + + if containsItemType(p.IncludeItemTypes, "Series") && !containsItemType(p.IncludeItemTypes, "Episode") { + return e.seriesItemsForLibrary(ctx, p.ParentID, p) + } + return e.mediaItems(ctx, p) +} + +func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string]any, error) { + q := e.repo.DB.WithContext(ctx).Model(&model.Media{}) if p.ParentID != "" { - // ParentID 既可能是 library_id 也可能是 series_id(剧集详情下钻) q = q.Where("library_id = ? OR series_id = ?", p.ParentID, p.ParentID) } - if len(p.IDs) > 0 { - q = q.Where("id IN ?", p.IDs) - } if p.SearchTerm != "" { - q = q.Where("title LIKE ?", "%"+p.SearchTerm+"%") + q = q.Where("title LIKE ? OR original_name LIKE ?", "%"+p.SearchTerm+"%", "%"+p.SearchTerm+"%") + } + if containsItemType(p.IncludeItemTypes, "Movie") && !containsItemType(p.IncludeItemTypes, "Episode") { + q = q.Where("season_num = 0 AND episode_num = 0") + } + if containsItemType(p.IncludeItemTypes, "Episode") && !containsItemType(p.IncludeItemTypes, "Movie") { + q = q.Where("season_num > 0 OR episode_num > 0") } var total int64 if err := q.Count(&total).Error; err != nil { return nil, err } - - // 排序 order := "created_at desc" switch strings.ToLower(p.SortBy) { case "sortname", "name": @@ -267,18 +360,52 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any, if err := q.Order(order).Offset(p.StartIndex).Limit(p.Limit).Find(&rows).Error; err != nil { return nil, err } + items, err := e.payloadsForMedia(ctx, rows, p.UserID) + if err != nil { + return nil, err + } + return map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil +} - // User-data: 收藏 + 进度 +func (e *EmbyService) episodeItems(ctx context.Context, rows []model.Media, p ItemsParams) (map[string]any, error) { + if p.SearchTerm != "" { + filtered := rows[:0] + needle := strings.ToLower(p.SearchTerm) + for _, row := range rows { + if strings.Contains(strings.ToLower(row.Title), needle) || strings.Contains(strings.ToLower(row.OriginalName), needle) { + filtered = append(filtered, row) + } + } + rows = filtered + } + sort.SliceStable(rows, func(i, j int) bool { + if rows[i].SeasonNum != rows[j].SeasonNum { + return rows[i].SeasonNum < rows[j].SeasonNum + } + if rows[i].EpisodeNum != rows[j].EpisodeNum { + return rows[i].EpisodeNum < rows[j].EpisodeNum + } + return rows[i].CreatedAt.Before(rows[j].CreatedAt) + }) + total := len(rows) + items, err := e.payloadsForMedia(ctx, pageSlice(rows, p.StartIndex, p.Limit), p.UserID) + if err != nil { + return nil, err + } + return map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil +} + +func (e *EmbyService) payloadsForMedia(ctx context.Context, rows []model.Media, userID string) ([]map[string]any, error) { userFavs := map[string]bool{} userPos := map[string]int64{} - if p.UserID != "" { + if userID != "" { var favs []model.Favorite - _ = e.repo.DB.WithContext(ctx).Where("user_id = ?", p.UserID).Find(&favs).Error + _ = e.repo.DB.WithContext(ctx).Where("user_id = ?", userID).Find(&favs).Error for _, f := range favs { userFavs[f.MediaID] = true } var hist []model.PlaybackHistory - _ = e.repo.DB.WithContext(ctx).Where("user_id = ?", p.UserID).Find(&hist).Error + _ = e.repo.DB.WithContext(ctx).Where("user_id = ?", userID).Find(&hist).Error for _, h := range hist { userPos[h.MediaID] = h.PositionMs } @@ -288,20 +415,35 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any, for _, m := range rows { items = append(items, e.itemPayload(&m, userFavs[m.ID], userPos[m.ID])) } - return map[string]any{ - "Items": items, - "TotalRecordCount": total, - "StartIndex": p.StartIndex, - }, nil + return items, nil } // Item 单条目详情。 func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) { + if strings.HasPrefix(mediaID, embyVirtualSeasonPrefix) { + if season, ok, err := e.findSeasonGroup(ctx, mediaID); err != nil { + return nil, err + } else if ok { + return e.seasonPayload(season), nil + } + } + if strings.HasPrefix(mediaID, embyVirtualSeriesPrefix) { + if series, ok, err := e.findSeriesGroup(ctx, mediaID); err != nil { + return nil, err + } else if ok { + return e.seriesPayload(series), nil + } + } m, err := e.repo.Media.FindByID(ctx, mediaID) if err != nil { return nil, err } if m == nil { + if series, ok, err := e.findSeriesGroup(ctx, mediaID); err != nil { + return nil, err + } else if ok { + return e.seriesPayload(series), nil + } return nil, nil } fav := false @@ -329,6 +471,21 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string, } q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL") if parentID != "" { + if episodic, err := e.libraryIsEpisodic(ctx, parentID); err == nil && episodic { + resp, err := e.seriesItemsForLibrary(ctx, parentID, ItemsParams{ + UserID: userID, + ParentID: parentID, + Limit: limit, + StartIndex: 0, + SortBy: "datecreated", + SortOrder: "Descending", + }) + if err != nil { + return nil, err + } + items, _ := resp["Items"].([]map[string]any) + return items, nil + } q = q.Where("library_id = ?", parentID) } var rows []model.Media @@ -395,8 +552,23 @@ func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) func (e *EmbyService) itemPayload(m *model.Media, fav bool, posMs int64) map[string]any { itemType := "Movie" + name := m.Title + parentID := m.LibraryID + seriesID := m.SeriesID + seriesName := "" + seasonID := "" if m.SeasonNum > 0 || m.EpisodeNum > 0 { itemType = "Episode" + seriesID = e.seriesIDForMedia(m) + seriesName = e.seriesNameForMedia(m) + seasonID = e.seasonIDForMedia(m) + parentID = seasonID + originalName := strings.TrimSpace(m.OriginalName) + if originalName != "" && !strings.EqualFold(originalName, seriesName) && !strings.EqualFold(originalName, m.Title) { + name = m.OriginalName + } else if m.EpisodeNum > 0 { + name = fmt.Sprintf("第 %d 集", m.EpisodeNum) + } } imageTags := map[string]string{} backdropTags := []string{} @@ -417,7 +589,7 @@ func (e *EmbyService) itemPayload(m *model.Media, fav bool, posMs int64) map[str return map[string]any{ "Id": m.ID, - "Name": m.Title, + "Name": name, "OriginalTitle": m.OriginalName, "ServerId": embyServerID, "Type": itemType, @@ -434,8 +606,11 @@ func (e *EmbyService) itemPayload(m *model.Media, fav bool, posMs int64) map[str "Height": m.Height, "DateCreated": m.CreatedAt, "Path": m.Path, - "ParentId": m.LibraryID, - "SeriesId": m.SeriesID, + "ParentId": parentID, + "SeasonId": seasonID, + "SeasonName": seasonName(m.SeasonNum), + "SeriesId": seriesID, + "SeriesName": seriesName, "ImageTags": imageTags, "BackdropImageTags": backdropTags, "Genres": splitCSV(m.Genres), @@ -454,11 +629,439 @@ func (e *EmbyService) itemPayload(m *model.Media, fav bool, posMs int64) map[str } } +func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID string, p ItemsParams) (map[string]any, error) { + q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("season_num > 0 OR episode_num > 0") + if libraryID != "" { + q = q.Where("library_id = ?", libraryID) + } + if p.SearchTerm != "" { + q = q.Where("title LIKE ? OR original_name LIKE ?", "%"+p.SearchTerm+"%", "%"+p.SearchTerm+"%") + } + var rows []model.Media + if err := q.Order("created_at desc").Find(&rows).Error; err != nil { + return nil, err + } + groups := e.seriesGroupsFromMedia(rows) + sortSeriesGroups(groups, p) + total := len(groups) + items := make([]map[string]any, 0, minInt(p.Limit, len(groups))) + for _, group := range pageSlice(groups, p.StartIndex, p.Limit) { + items = append(items, e.seriesPayload(group)) + } + return map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil +} + +func (e *EmbyService) libraryIsEpisodic(ctx context.Context, libraryID string) (bool, error) { + if strings.TrimSpace(libraryID) == "" { + return false, nil + } + if lib, err := e.repo.Library.FindByID(ctx, libraryID); err != nil { + return false, err + } else if lib != nil { + switch lib.Type { + case "tv", "anime", "variety": + return true, nil + } + } + var count int64 + err := e.repo.DB.WithContext(ctx).Model(&model.Media{}). + Where("library_id = ? AND (season_num > 0 OR episode_num > 0)", libraryID). + Count(&count).Error + return count > 0, err +} + +func (e *EmbyService) findSeriesGroup(ctx context.Context, id string) (embySeriesGroup, bool, error) { + if strings.TrimSpace(id) == "" { + return embySeriesGroup{}, false, nil + } + var rows []model.Media + q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("season_num > 0 OR episode_num > 0") + if !strings.HasPrefix(id, embyVirtualSeriesPrefix) { + q = q.Where("series_id = ?", id) + } + if err := q.Order("season_num asc, episode_num asc, created_at asc").Find(&rows).Error; err != nil { + return embySeriesGroup{}, false, err + } + for _, group := range e.seriesGroupsFromMedia(rows) { + if group.ID == id { + return group, true, nil + } + } + if !strings.HasPrefix(id, embyVirtualSeriesPrefix) { + if series, err := e.repo.Series.FindByID(ctx, id); err != nil { + return embySeriesGroup{}, false, err + } else if series != nil { + return embySeriesGroup{ + ID: series.ID, + LibraryID: series.LibraryID, + Name: series.Title, + PosterURL: series.PosterURL, + BackdropURL: series.BackdropURL, + Overview: series.Overview, + Rating: series.Rating, + Year: series.Year, + TMDbID: series.TMDbID, + BangumiID: series.BangumiID, + CreatedAt: series.CreatedAt, + }, true, nil + } + } + return embySeriesGroup{}, false, nil +} + +func (e *EmbyService) findSeasonGroup(ctx context.Context, id string) (embySeasonGroup, bool, error) { + if strings.TrimSpace(id) == "" || !strings.HasPrefix(id, embyVirtualSeasonPrefix) { + return embySeasonGroup{}, false, nil + } + var rows []model.Media + if err := e.repo.DB.WithContext(ctx).Model(&model.Media{}). + Where("season_num > 0 OR episode_num > 0"). + Order("season_num asc, episode_num asc, created_at asc"). + Find(&rows).Error; err != nil { + return embySeasonGroup{}, false, err + } + for _, series := range e.seriesGroupsFromMedia(rows) { + for _, season := range e.seasonsForSeries(series) { + if season.ID == id { + return season, true, nil + } + } + } + return embySeasonGroup{}, false, nil +} + +func (e *EmbyService) seriesGroupsFromMedia(rows []model.Media) []embySeriesGroup { + byID := map[string]*embySeriesGroup{} + order := []string{} + for _, row := range rows { + row := row + seriesID := e.seriesIDForMedia(&row) + group, ok := byID[seriesID] + if !ok { + group = &embySeriesGroup{ + ID: seriesID, + LibraryID: row.LibraryID, + Name: e.seriesNameForMedia(&row), + Year: row.Year, + TMDbID: row.TMDbID, + BangumiID: row.BangumiID, + CreatedAt: row.CreatedAt, + } + byID[seriesID] = group + order = append(order, seriesID) + } + if row.CreatedAt.After(group.CreatedAt) { + group.CreatedAt = row.CreatedAt + } + if group.PosterURL == "" && row.PosterURL != "" { + group.PosterURL = row.PosterURL + } + if group.BackdropURL == "" && row.BackdropURL != "" { + group.BackdropURL = row.BackdropURL + } + if group.Overview == "" && row.Overview != "" { + group.Overview = row.Overview + } + if group.Rating == 0 && row.Rating > 0 { + group.Rating = row.Rating + } + if group.Year == 0 && row.Year > 0 { + group.Year = row.Year + } + group.Episodes = append(group.Episodes, row) + } + groups := make([]embySeriesGroup, 0, len(order)) + for _, id := range order { + group := *byID[id] + sort.SliceStable(group.Episodes, func(i, j int) bool { + if group.Episodes[i].SeasonNum != group.Episodes[j].SeasonNum { + return group.Episodes[i].SeasonNum < group.Episodes[j].SeasonNum + } + if group.Episodes[i].EpisodeNum != group.Episodes[j].EpisodeNum { + return group.Episodes[i].EpisodeNum < group.Episodes[j].EpisodeNum + } + return group.Episodes[i].CreatedAt.Before(group.Episodes[j].CreatedAt) + }) + groups = append(groups, group) + } + return groups +} + +func (e *EmbyService) seasonsForSeries(series embySeriesGroup) []embySeasonGroup { + bySeason := map[int]*embySeasonGroup{} + order := []int{} + for _, episode := range series.Episodes { + seasonNum := episode.SeasonNum + if seasonNum <= 0 { + seasonNum = 1 + } + season, ok := bySeason[seasonNum] + if !ok { + season = &embySeasonGroup{ + ID: seasonID(series.ID, seasonNum), + SeriesID: series.ID, + LibraryID: series.LibraryID, + Name: seasonName(seasonNum), + SeasonNum: seasonNum, + Series: series, + } + bySeason[seasonNum] = season + order = append(order, seasonNum) + } + season.Episodes = append(season.Episodes, episode) + } + sort.Ints(order) + out := make([]embySeasonGroup, 0, len(order)) + for _, seasonNum := range order { + out = append(out, *bySeason[seasonNum]) + } + return out +} + +func (e *EmbyService) seriesPayload(group embySeriesGroup) map[string]any { + imageTags := map[string]string{} + backdropTags := []string{} + if group.PosterURL != "" { + imageTags["Primary"] = group.ID + } + if group.BackdropURL != "" { + backdropTags = append(backdropTags, group.ID+"-bd") + } + return map[string]any{ + "Id": group.ID, + "Name": group.Name, + "ServerId": embyServerID, + "Type": "Series", + "MediaType": "Video", + "IsFolder": true, + "ParentId": group.LibraryID, + "ProductionYear": group.Year, + "Overview": group.Overview, + "CommunityRating": group.Rating, + "RecursiveItemCount": len(group.Episodes), + "ChildCount": len(e.seasonsForSeries(group)), + "DateCreated": group.CreatedAt, + "ImageTags": imageTags, + "BackdropImageTags": backdropTags, + "ProviderIds": map[string]string{ + "Tmdb": intToStr(group.TMDbID), + "Bangumi": intToStr(group.BangumiID), + }, + "UserData": emptyUserData(), + } +} + +func (e *EmbyService) seasonPayload(season embySeasonGroup) map[string]any { + imageTags := map[string]string{} + backdropTags := []string{} + if season.Series.PosterURL != "" { + imageTags["Primary"] = season.ID + } + if season.Series.BackdropURL != "" { + backdropTags = append(backdropTags, season.ID+"-bd") + } + return map[string]any{ + "Id": season.ID, + "Name": season.Name, + "ServerId": embyServerID, + "Type": "Season", + "MediaType": "Video", + "IsFolder": true, + "ParentId": season.SeriesID, + "SeriesId": season.SeriesID, + "SeriesName": season.Series.Name, + "IndexNumber": season.SeasonNum, + "ChildCount": len(season.Episodes), + "ImageTags": imageTags, + "BackdropImageTags": backdropTags, + "UserData": emptyUserData(), + } +} + +// ImageURL returns artwork for a media/series/season item id. +func (e *EmbyService) ImageURL(ctx context.Context, id, imageType string) (string, error) { + pick := func(primary, backdrop string) string { + switch strings.ToLower(imageType) { + case "backdrop", "art": + if backdrop != "" { + return backdrop + } + } + if primary != "" { + return primary + } + return backdrop + } + if strings.HasPrefix(id, embyVirtualSeasonPrefix) { + if season, ok, err := e.findSeasonGroup(ctx, id); err != nil { + return "", err + } else if ok { + return pick(season.Series.PosterURL, season.Series.BackdropURL), nil + } + } + if strings.HasPrefix(id, embyVirtualSeriesPrefix) { + if series, ok, err := e.findSeriesGroup(ctx, id); err != nil { + return "", err + } else if ok { + return pick(series.PosterURL, series.BackdropURL), nil + } + } + m, err := e.repo.Media.FindByID(ctx, id) + if err == nil && m != nil { + return pick(m.PosterURL, m.BackdropURL), nil + } + if err != nil { + return "", err + } + if series, ok, err := e.findSeriesGroup(ctx, id); err != nil { + return "", err + } else if ok { + return pick(series.PosterURL, series.BackdropURL), nil + } + return "", nil +} + +func (e *EmbyService) seriesIDForMedia(m *model.Media) string { + if strings.TrimSpace(m.SeriesID) != "" { + return m.SeriesID + } + return stableEmbyID(embyVirtualSeriesPrefix, m.LibraryID, e.seriesNameForMedia(m)) +} + +func (e *EmbyService) seasonIDForMedia(m *model.Media) string { + return seasonID(e.seriesIDForMedia(m), maxInt(m.SeasonNum, 1)) +} + +func (e *EmbyService) seriesNameForMedia(m *model.Media) string { + if strings.TrimSpace(m.SeriesID) != "" { + if series, err := e.repo.Series.FindByID(context.Background(), m.SeriesID); err == nil && series != nil && strings.TrimSpace(series.Title) != "" { + return series.Title + } + } + if name := inferSeriesNameFromPath(m.Path); name != "" { + return name + } + name := strings.TrimSpace(m.Title) + name = embyEpisodeTitleRE.ReplaceAllString(name, "") + name = embyYearSuffixRE.ReplaceAllString(name, "") + if name == "" { + name = strings.TrimSpace(m.OriginalName) + } + return name +} + +func inferSeriesNameFromPath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + dir := filepath.Dir(path) + base := filepath.Base(dir) + if embySeasonDirRE.MatchString(base) { + dir = filepath.Dir(dir) + base = filepath.Base(dir) + } + base = strings.TrimSpace(embyYearSuffixRE.ReplaceAllString(base, "")) + if base == "." || base == string(filepath.Separator) { + return "" + } + return base +} + +func stableEmbyID(prefix string, parts ...string) string { + h := sha1.New() + for _, part := range parts { + _, _ = h.Write([]byte(strings.ToLower(strings.TrimSpace(part)))) + _, _ = h.Write([]byte{0}) + } + return prefix + hex.EncodeToString(h.Sum(nil))[:32] +} + +func seasonID(seriesID string, seasonNum int) string { + return stableEmbyID(embyVirtualSeasonPrefix, seriesID, strconv.Itoa(maxInt(seasonNum, 1))) +} + +func seasonName(seasonNum int) string { + if seasonNum <= 0 { + seasonNum = 1 + } + return fmt.Sprintf("第 %d 季", seasonNum) +} + +func sortSeriesGroups(groups []embySeriesGroup, p ItemsParams) { + switch strings.ToLower(p.SortBy) { + case "sortname", "name": + sort.SliceStable(groups, func(i, j int) bool { + if strings.EqualFold(p.SortOrder, "Descending") { + return groups[i].Name > groups[j].Name + } + return groups[i].Name < groups[j].Name + }) + default: + sort.SliceStable(groups, func(i, j int) bool { + if strings.EqualFold(p.SortOrder, "Ascending") { + return groups[i].CreatedAt.Before(groups[j].CreatedAt) + } + return groups[i].CreatedAt.After(groups[j].CreatedAt) + }) + } +} + +func containsItemType(types []string, want string) bool { + for _, t := range types { + if strings.EqualFold(strings.TrimSpace(t), want) { + return true + } + } + return false +} + +func pageSlice[T any](items []T, start, limit int) []T { + if start < 0 { + start = 0 + } + if limit <= 0 { + limit = len(items) + } + if start >= len(items) { + return []T{} + } + end := start + limit + if end > len(items) { + end = len(items) + } + return items[start:end] +} + +func emptyUserData() map[string]any { + return map[string]any{ + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "PlayedPercentage": 0, + } +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + // ─── Playback ──────────────────────────────────────────────────────────────── // PlaybackInfo returns a PlaybackInfoResponse usable by Emby clients. func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID string) (map[string]any, error) { - m, err := e.repo.Media.FindByID(ctx, mediaID) + m, err := e.playableMedia(ctx, mediaID) if err != nil || m == nil { return nil, err } @@ -468,11 +1071,25 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID string) (map[str }, nil } +func (e *EmbyService) playableMedia(ctx context.Context, id string) (*model.Media, error) { + if season, ok, err := e.findSeasonGroup(ctx, id); err != nil { + return nil, err + } else if ok && len(season.Episodes) > 0 { + return &season.Episodes[0], nil + } + if series, ok, err := e.findSeriesGroup(ctx, id); err != nil { + return nil, err + } else if ok && len(series.Episodes) > 0 { + return &series.Episodes[0], nil + } + return e.repo.Media.FindByID(ctx, id) +} + // mediaSource 是 /Items 与 /PlaybackInfo 共享的 MediaSource 结构。 // // asEmbedded=true:嵌在 /Items 列表里,不包含完整 stream URL(避免暴露 -// 直链给搜索接口)。/PlaybackInfo 走 false 路径,URL 完整指向 -// /api/stream/{id}(Emby 客户端会自动 append ?api_key=token)。 +// 直链给搜索接口)。/PlaybackInfo 走 false 路径,URL 指向 Emby 兼容 +// /Videos/{id}/stream(客户端会继续携带 X-Emby-Token 或 append api_key)。 func (e *EmbyService) mediaSource(m *model.Media, asEmbedded bool) map[string]any { src := map[string]any{ "Id": m.ID, @@ -491,8 +1108,8 @@ func (e *EmbyService) mediaSource(m *model.Media, asEmbedded bool) map[string]an "MediaStreams": e.mediaStreams(m), } if !asEmbedded { - // 完整 URL,让 Infuse 直接 GET。Emby 客户端会自动加 ?api_key=token。 - src["DirectStreamUrl"] = "/api/stream/" + m.ID + src["DirectStreamUrl"] = "/Videos/" + m.ID + "/stream" + src["TranscodingUrl"] = "/Videos/" + m.ID + "/stream" } if strings.TrimSpace(m.STRMURL) != "" { // STRM 重定向:客户端直接拉远端,跳过我们这一层。 diff --git a/internal/service/emby_compat_test.go b/internal/service/emby_compat_test.go new file mode 100644 index 0000000..9dff5a5 --- /dev/null +++ b/internal/service/emby_compat_test.go @@ -0,0 +1,114 @@ +package service + +import ( + "testing" + + "github.com/glebarez/sqlite" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +func TestEmbyItemsExposeSeriesSeasonEpisodeHierarchy(t *testing.T) { + svc := newTestEmbyService(t) + lib := model.Library{Name: "番剧", Path: `F:\downloads\日番`, Type: "anime", Enabled: true} + if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + for _, media := range []model.Media{ + { + Base: model.Base{ID: "ep-1"}, + LibraryID: lib.ID, + Title: "间谍过家家", + OriginalName: "第 1 集", + Path: `F:\downloads\日番\剧集\间谍过家家\Season 02\间谍过家家 - S02E01.mkv`, + PosterURL: `F:\poster.jpg`, + SeasonNum: 2, + EpisodeNum: 1, + }, + { + Base: model.Base{ID: "ep-2"}, + LibraryID: lib.ID, + Title: "间谍过家家", + OriginalName: "第 2 集", + Path: `F:\downloads\日番\剧集\间谍过家家\Season 02\间谍过家家 - S02E02.mkv`, + PosterURL: `F:\poster.jpg`, + SeasonNum: 2, + EpisodeNum: 2, + }, + } { + if err := svc.repo.DB.Create(&media).Error; err != nil { + t.Fatalf("create media: %v", err) + } + } + + root, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, Limit: 50}) + if err != nil { + t.Fatalf("library items: %v", err) + } + rootItems := root["Items"].([]map[string]any) + if len(rootItems) != 1 { + t.Fatalf("expected one series card, got %#v", rootItems) + } + seriesID := rootItems[0]["Id"].(string) + if rootItems[0]["Type"] != "Series" || rootItems[0]["IsFolder"] != true || rootItems[0]["Name"] != "间谍过家家" { + t.Fatalf("unexpected series payload: %#v", rootItems[0]) + } + + seasons, err := svc.Items(t.Context(), ItemsParams{ParentID: seriesID, Limit: 50}) + if err != nil { + t.Fatalf("series items: %v", err) + } + seasonItems := seasons["Items"].([]map[string]any) + if len(seasonItems) != 1 || seasonItems[0]["Type"] != "Season" || seasonItems[0]["IndexNumber"] != 2 { + t.Fatalf("unexpected seasons: %#v", seasonItems) + } + + episodes, err := svc.Items(t.Context(), ItemsParams{ParentID: seasonItems[0]["Id"].(string), IncludeItemTypes: []string{"Episode"}, Recursive: true, Limit: 50}) + if err != nil { + t.Fatalf("season episodes: %v", err) + } + episodeItems := episodes["Items"].([]map[string]any) + if len(episodeItems) != 2 || episodeItems[0]["Type"] != "Episode" || episodeItems[0]["Name"] != "第 1 集" { + t.Fatalf("unexpected episodes: %#v", episodeItems) + } + if episodeItems[0]["SeriesId"] != seriesID || episodeItems[0]["ParentId"] != seasonItems[0]["Id"] { + t.Fatalf("episode hierarchy not linked: %#v", episodeItems[0]) + } + + latest, err := svc.LatestItems(t.Context(), "user-1", lib.ID, 10) + if err != nil { + t.Fatalf("latest items: %v", err) + } + if len(latest) != 1 || latest[0]["Type"] != "Series" { + t.Fatalf("latest should be grouped by series: %#v", latest) + } + + playback, err := svc.PlaybackInfo(t.Context(), seriesID) + if err != nil { + t.Fatalf("series playback fallback: %v", err) + } + sources := playback["MediaSources"].([]map[string]any) + if sources[0]["Id"] != "ep-1" { + t.Fatalf("series playback should fall back to first episode: %#v", sources) + } + if sources[0]["DirectStreamUrl"] != "/Videos/ep-1/stream" { + t.Fatalf("playback should use Emby-compatible stream URL: %#v", sources[0]) + } +} + +func newTestEmbyService(t *testing.T) *EmbyService { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}, &model.Favorite{}, &model.PlaybackHistory{}); err != nil { + t.Fatalf("migrate: %v", err) + } + repos := repository.New(db) + return NewEmbyService(&config.Config{}, zap.NewNop(), repos) +}