Fix Emby playback routes and stream compatibility

This commit is contained in:
ShukeBta
2026-05-28 16:00:09 +08:00
parent e93eb40f4e
commit 68a5a1e3c0
8 changed files with 1143 additions and 56 deletions
+151 -24
View File
@@ -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))
+196
View File
@@ -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()), "<!doctype html>") {
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
}
+1
View File
@@ -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))