From ba43117a577a7c8fd5833ac48027d54ca8c3a72a Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:17:39 +0800 Subject: [PATCH] fix: honor STRM playback mode for cloud clients --- internal/handler/emby.go | 20 ++++ internal/handler/emby_test.go | 54 +++++++++- internal/service/cloud/cloud_test.go | 71 ++++++++++++- internal/service/cloud/clouddrive2.go | 139 ++++++++++++++++++++++++++ internal/service/emby_compat.go | 37 ++++--- internal/service/emby_compat_test.go | 42 +++++++- internal/service/stream_test.go | 4 +- web/src/pages/StorageConfigPage.tsx | 2 +- web/src/pages/StrmPage.tsx | 4 +- 9 files changed, 344 insertions(+), 29 deletions(-) diff --git a/internal/handler/emby.go b/internal/handler/emby.go index 4b18133..1889bca 100644 --- a/internal/handler/emby.go +++ b/internal/handler/emby.go @@ -573,6 +573,7 @@ func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc { embyError(c, http.StatusNotFound, "item not found") return } + embyAttachRequestTokenToPlaybackInfo(c, out) c.JSON(http.StatusOK, out) } } @@ -1064,6 +1065,18 @@ func embyEmptyItemsHandler(_ *service.Container) gin.HandlerFunc { } } +func embyServerDomainsHandler(_ *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + c.JSON(http.StatusOK, []any{}) + } +} + +func embyDanmuRawHandler(_ *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + c.Data(http.StatusOK, "text/plain; charset=utf-8", nil) + } +} + func embyBrandingConfigHandler(_ *service.Container) gin.HandlerFunc { return func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ @@ -1119,6 +1132,10 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container) for _, path := range []string{"/System/Endpoint", "/system/endpoint"} { grp.GET(path, embySystemEndpointHandler(svc)) } + for _, path := range []string{"/System/Ext/ServerDomains", "/system/ext/serverdomains"} { + grp.GET(path, embyServerDomainsHandler(svc)) + grp.HEAD(path, embyServerDomainsHandler(svc)) + } for _, path := range []string{"/System/Configuration/Public", "/system/configuration/public"} { grp.GET(path, embyPublicServerConfigurationHandler(svc)) grp.HEAD(path, embyPublicServerConfigurationHandler(svc)) @@ -1193,6 +1210,8 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container) auth.GET("/Shows/NextUp", embyEmptyItemsHandler(svc)) auth.GET("/Users/:userId/Shows/NextUp", embyEmptyItemsHandler(svc)) auth.GET("/MediaSegments/:id", embyEmptyItemsHandler(svc)) + auth.GET("/Items/:id/Similar", embyEmptyItemsHandler(svc)) + auth.GET("/api/danmu/:id/raw", embyDanmuRawHandler(svc)) auth.GET("/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc)) auth.POST("/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc)) @@ -1257,6 +1276,7 @@ func registerLowercaseEmbyAuthRoutes(auth *gin.RouterGroup, svc *service.Contain auth.GET("/shows/nextup", embyEmptyItemsHandler(svc)) auth.GET("/users/:userId/shows/nextup", embyEmptyItemsHandler(svc)) auth.GET("/mediasegments/:id", embyEmptyItemsHandler(svc)) + auth.GET("/items/:id/similar", embyEmptyItemsHandler(svc)) auth.GET("/items/:id/playbackinfo", embyPlaybackInfoHandler(svc)) auth.POST("/items/:id/playbackinfo", embyPlaybackInfoHandler(svc)) diff --git a/internal/handler/emby_test.go b/internal/handler/emby_test.go index 18e2d30..c73192a 100644 --- a/internal/handler/emby_test.go +++ b/internal/handler/emby_test.go @@ -179,6 +179,56 @@ func TestEmbyPublicSystemInfoLooksLikeModernEmbyServer(t *testing.T) { } } +func TestEmbyMobileCompatibilityRoutesAvoidPlaybackBlocking404s(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) + } + if err := db.Create(&model.User{ + Base: model.Base{ID: "user-1"}, + Username: "viewer", + PasswordHash: "hash", + Role: "admin", + IsActive: true, + }).Error; err != nil { + t.Fatalf("create user: %v", err) + } + cfg := &config.Config{} + cfg.Secrets.JWTSecret = "test-secret" + repos := repository.New(db) + router := gin.New() + registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{ + Repo: repos, + Emby: service.NewEmbyService(cfg, zap.NewNop(), repos), + }) + + token := signedTestToken(t, cfg.Secrets.JWTSecret) + tests := []struct { + path string + auth bool + wantCode int + }{ + {path: "/emby/System/Ext/ServerDomains", wantCode: http.StatusOK}, + {path: "/emby/Items/msgo-series-demo/Similar", auth: true, wantCode: http.StatusOK}, + {path: "/emby/api/danmu/media-demo/raw", auth: true, wantCode: http.StatusOK}, + } + for _, tc := range tests { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + if tc.auth { + req.Header.Set("X-Emby-Token", token) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != tc.wantCode { + t.Fatalf("%s status = %d body=%s", tc.path, w.Code, w.Body.String()) + } + } +} + func TestEmbySenPlayerDiscoveryRoutesReturnProtocolResponses(t *testing.T) { gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) @@ -645,14 +695,14 @@ func TestEmbyPlaybackInfoTokenizesCloudPath(t *testing.T) { } source := body["MediaSources"].([]any)[0].(map[string]any) pathURL, _ := source["Path"].(string) - if !strings.HasPrefix(pathURL, "/Videos/cloud-1/stream") || !strings.Contains(pathURL, "api_key=") { + if !strings.HasPrefix(pathURL, "/api/stream/cloud-1") || !strings.Contains(pathURL, "api_key=") { t.Fatalf("cloud Path should be tokenized stream URL, got %#v", source) } if strings.Contains(pathURL, "/api/cloud/play/") { t.Fatalf("cloud Path should not expose naked cloud play URL: %#v", source) } directURL, _ := source["DirectStreamUrl"].(string) - if !strings.HasPrefix(directURL, "/Videos/cloud-1/stream.") || !strings.Contains(directURL, "api_key=") { + if !strings.HasPrefix(directURL, "/api/stream/cloud-1") || !strings.Contains(directURL, "api_key=") { t.Fatalf("DirectStreamUrl should stay tokenized: %#v", source) } } diff --git a/internal/service/cloud/cloud_test.go b/internal/service/cloud/cloud_test.go index 432f91f..54fe735 100644 --- a/internal/service/cloud/cloud_test.go +++ b/internal/service/cloud/cloud_test.go @@ -375,11 +375,15 @@ func TestCloudDrive2WebDAVListAndResolve(t *testing.T) { func TestOpenListWebDAVListAndResolve(t *testing.T) { var gotPath, gotDepth string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/fs/get" { + http.NotFound(w, r) + return + } + if r.Method != "PROPFIND" || r.URL.Path != "/dav" { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } gotPath = r.URL.Path gotDepth = r.Header.Get("Depth") - if r.Method != "PROPFIND" { - t.Fatalf("unexpected method %s", r.Method) - } w.Header().Set("Content-Type", "application/xml") w.WriteHeader(http.StatusMultiStatus) _, _ = w.Write([]byte(` @@ -428,6 +432,67 @@ func TestOpenListWebDAVListAndResolve(t *testing.T) { } } +func TestOpenListResolveUsesAPIRawURLFor302Playback(t *testing.T) { + var gotPath, gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + if r.Method != http.MethodPost || r.URL.Path != "/api/fs/get" { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"https://cdn.example.test/movie.mkv?sign=1"}}`)) + })) + defer srv.Close() + + p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client()) + if err != nil { + t.Fatal(err) + } + link, err := p.Resolve(context.Background(), "/Cloud/Movie.mkv") + if err != nil { + t.Fatalf("resolve: %v", err) + } + if gotPath != "/api/fs/get" { + t.Fatalf("api path = %q, want /api/fs/get", gotPath) + } + if gotAuth != "alist-token" { + t.Fatalf("Authorization = %q, want token", gotAuth) + } + if link.URL != "https://cdn.example.test/movie.mkv?sign=1" { + t.Fatalf("url = %q", link.URL) + } + if link.Proxy { + t.Fatalf("openlist raw_url without required headers should be 302 playback") + } +} + +func TestOpenListResolveFallsBackToProxyWhenAPIRawURLNeedsHeaders(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/fs/get" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"/dav/Cloud/Movie.mkv","header":{"Cookie":"sid=abc"}}}`)) + })) + defer srv.Close() + + p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client()) + if err != nil { + t.Fatal(err) + } + link, err := p.Resolve(context.Background(), "/Cloud/Movie.mkv") + if err != nil { + t.Fatalf("resolve: %v", err) + } + if link.URL != srv.URL+"/dav/Cloud/Movie.mkv" { + t.Fatalf("url = %q", link.URL) + } + if !link.Proxy || link.Headers["Cookie"] != "sid=abc" { + t.Fatalf("link should keep proxy mode with required headers: %#v", link) + } +} + func TestOpenListRootURLDefaultsToDAV(t *testing.T) { var gotPath string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/service/cloud/clouddrive2.go b/internal/service/cloud/clouddrive2.go index 8ef3eeb..5de8edc 100644 --- a/internal/service/cloud/clouddrive2.go +++ b/internal/service/cloud/clouddrive2.go @@ -211,6 +211,11 @@ func (p *cloudDrive2Provider) Resolve(ctx context.Context, fileRef string) (*Dir if ref == "/" { return nil, fmt.Errorf("%s: file reference required", p.name) } + if p.typ == TypeOpenList && p.apiBase != nil && isOpenListDirectPlaybackCandidate(ref) { + if link, err := p.resolveOpenListAPIDirect(ctx, ref); err == nil && link != nil { + return link, nil + } + } headers := map[string]string{ "User-Agent": p.ua, } @@ -222,6 +227,130 @@ func (p *cloudDrive2Provider) Resolve(ctx context.Context, fileRef string) (*Dir return &DirectLink{URL: p.urlFor(ref), Headers: headers, Proxy: p.proxy}, nil } +func (p *cloudDrive2Provider) resolveOpenListAPIDirect(ctx context.Context, fileRef string) (*DirectLink, error) { + payload, _ := json.Marshal(map[string]string{"path": normalizeCloudDAVPath(fileRef), "password": ""}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.openListAPIURL("/api/fs/get"), bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", p.ua) + if p.token != "" { + req.Header.Set("Authorization", p.token) + } + resp, err := p.client.Do(req) + if err != nil { + return nil, decorateDAVTransportError(p.name, p.openListAPIURL("/api/fs/get"), err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("%s: api get %s returned http %d", p.name, fileRef, resp.StatusCode) + } + var decoded openListGetResponse + if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&decoded); err != nil { + return nil, fmt.Errorf("%s: decode api get: %w", p.name, err) + } + if decoded.Code != 0 && decoded.Code != 200 { + msg := strings.TrimSpace(decoded.Message) + if msg == "" { + msg = fmt.Sprintf("code %d", decoded.Code) + } + return nil, fmt.Errorf("%s: api get %s failed: %s", p.name, fileRef, msg) + } + raw := firstNonEmpty(decoded.Data.RawURL, decoded.Data.URL) + if raw == "" { + return nil, fmt.Errorf("%s: api get %s returned empty raw_url", p.name, fileRef) + } + resolved, err := p.resolveOpenListPlaybackURL(raw) + if err != nil { + return nil, err + } + headers := normalizeOpenListPlaybackHeaders(decoded.Data.Header) + proxy := p.proxy && len(headers) > 0 + if !proxy { + headers = nil + } + return &DirectLink{URL: resolved, Headers: headers, Proxy: proxy}, nil +} + +func (p *cloudDrive2Provider) resolveOpenListPlaybackURL(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", fmt.Errorf("%s: empty playback URL", p.name) + } + if strings.HasPrefix(raw, "//") { + if p.apiBase == nil || p.apiBase.Scheme == "" { + return "", fmt.Errorf("%s: protocol-relative playback URL without API base", p.name) + } + raw = p.apiBase.Scheme + ":" + raw + } + u, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("%s: invalid playback URL: %w", p.name, err) + } + if u.IsAbs() { + if u.Scheme != "http" && u.Scheme != "https" { + return "", fmt.Errorf("%s: unsupported playback URL scheme %q", p.name, u.Scheme) + } + return u.String(), nil + } + if p.apiBase == nil { + return "", fmt.Errorf("%s: relative playback URL without API base", p.name) + } + base := *p.apiBase + base.RawPath = "" + base.RawQuery = "" + base.Fragment = "" + return base.ResolveReference(u).String(), nil +} + +func normalizeOpenListPlaybackHeaders(raw json.RawMessage) map[string]string { + if len(raw) == 0 || string(raw) == "null" { + return nil + } + var obj map[string]any + if err := json.Unmarshal(raw, &obj); err != nil { + return nil + } + out := make(map[string]string, len(obj)) + for k, v := range obj { + key := strings.TrimSpace(k) + if key == "" { + continue + } + switch value := v.(type) { + case string: + if strings.TrimSpace(value) != "" { + out[key] = strings.TrimSpace(value) + } + case []any: + parts := make([]string, 0, len(value)) + for _, item := range value { + if s, ok := item.(string); ok && strings.TrimSpace(s) != "" { + parts = append(parts, strings.TrimSpace(s)) + } + } + if len(parts) > 0 { + out[key] = strings.Join(parts, ", ") + } + } + } + if len(out) == 0 { + return nil + } + return out +} + +func isOpenListDirectPlaybackCandidate(fileRef string) bool { + switch strings.ToLower(path.Ext(strings.TrimSpace(fileRef))) { + case ".mkv", ".mp4", ".m4v", ".avi", ".mov", ".webm", ".ts", ".rmvb", ".rm", ".3gp", ".mpg", ".mpeg": + return true + default: + return false + } +} + func (p *cloudDrive2Provider) validate() error { if p.base == nil || p.base.Scheme == "" || p.base.Host == "" { return fmt.Errorf("%s: missing WebDAV URL", p.name) @@ -464,6 +593,16 @@ type openListListItem struct { IsDir bool `json:"is_dir"` } +type openListGetResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + RawURL string `json:"raw_url"` + URL string `json:"url"` + Header json.RawMessage `json:"header"` + } `json:"data"` +} + func normalizeCloudDAVPath(p string) string { p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/") if p == "" || p == "." { diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index 5068b48..ab15f07 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -511,7 +511,7 @@ func (e *EmbyService) payloadsForMedia(ctx context.Context, rows []model.Media, items := make([]map[string]any, 0, len(rows)) for _, m := range rows { - items = append(items, e.itemPayload(&m, userFavs[m.ID], userPos[m.ID])) + items = append(items, e.itemPayload(ctx, &m, userFavs[m.ID], userPos[m.ID])) } return items, nil } @@ -562,7 +562,7 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str pos = h.PositionMs } } - return e.itemPayload(m, fav, pos), nil + return e.itemPayload(ctx, m, fav, pos), nil } // LatestItems 最近添加,全库或指定库。 @@ -601,7 +601,7 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string, } out := make([]map[string]any, 0, len(rows)) for _, m := range rows { - out = append(out, e.itemPayload(&m, favs[m.ID], 0)) + out = append(out, e.itemPayload(ctx, &m, favs[m.ID], 0)) } return out, nil } @@ -675,13 +675,13 @@ func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) items := make([]map[string]any, 0, len(hist)) for _, h := range hist { if m, ok := byID[h.MediaID]; ok { - items = append(items, e.itemPayload(m, false, posByID[h.MediaID])) + items = append(items, e.itemPayload(ctx, m, false, posByID[h.MediaID])) } } return map[string]any{"Items": items, "TotalRecordCount": len(items)}, nil } -func (e *EmbyService) itemPayload(m *model.Media, fav bool, posMs int64) map[string]any { +func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, posMs int64) map[string]any { itemType := "Movie" name := m.Title parentID := m.LibraryID @@ -756,7 +756,7 @@ func (e *EmbyService) itemPayload(m *model.Media, fav bool, posMs int64) map[str "Played": played, "PlayedPercentage": pct, }, - "MediaSources": []map[string]any{e.mediaSource(m, true, false)}, + "MediaSources": []map[string]any{e.mediaSource(ctx, m, true, false)}, } } @@ -1474,7 +1474,7 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string) } e.ensureCloudTrackMetadata(ctx, m) return map[string]any{ - "MediaSources": []map[string]any{e.mediaSource(m, false, e.directPlayOnly(ctx))}, + "MediaSources": []map[string]any{e.mediaSource(ctx, m, false, e.directPlayOnly(ctx))}, "PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().Unix()), }, nil } @@ -1629,7 +1629,7 @@ func (e *EmbyService) playableMedia(ctx context.Context, id, userID string) (*mo // asEmbedded=true:嵌在 /Items 列表里,不包含完整 stream URL(避免暴露 // 直链给搜索接口)。/PlaybackInfo 走 false 路径,URL 指向 Emby 兼容 // /Videos/{id}/stream(客户端会继续携带 X-Emby-Token 或 append api_key)。 -func (e *EmbyService) mediaSource(m *model.Media, asEmbedded, directOnly bool) map[string]any { +func (e *EmbyService) mediaSource(ctx context.Context, m *model.Media, asEmbedded, directOnly bool) map[string]any { container := strings.Trim(strings.ToLower(m.Container), ". ") if container == "" { container = strings.TrimPrefix(strings.ToLower(filepath.Ext(m.Path)), ".") @@ -1638,6 +1638,10 @@ func (e *EmbyService) mediaSource(m *model.Media, asEmbedded, directOnly bool) m container = "strm" } isCloud := strings.TrimSpace(m.STRMURL) != "" + playURL := embyDirectStreamURL(m.ID, container) + if isCloud && STRMPlaybackEnabled(ctx, e.repo) { + playURL = embySTRMStreamURL(m.ID) + } if isCloud { // Cloud/WebDAV media is already a direct/proxy stream. Advertising HLS // transcoding makes some Emby clients pick /master.m3u8, forcing this @@ -1670,8 +1674,7 @@ func (e *EmbyService) mediaSource(m *model.Media, asEmbedded, directOnly bool) m "MediaStreams": e.mediaStreams(m), } if !asEmbedded { - streamURL := embyDirectStreamURL(m.ID, container) - src["DirectStreamUrl"] = streamURL + src["DirectStreamUrl"] = playURL // 直连解码模式下不下发 TranscodingUrl,迫使客户端本地解码直连, // 宿主机不参与转码。 if !directOnly { @@ -1679,16 +1682,20 @@ func (e *EmbyService) mediaSource(m *model.Media, asEmbedded, directOnly bool) m } } if strings.TrimSpace(m.STRMURL) != "" { - // STRM / cloud:// media plays through /Videos/{id}/stream. Some - // third-party Emby clients still prefer MediaSource.Path even when - // SupportsDirectPlay=false; pointing Path at the token-aware stream - // endpoint keeps those clients away from naked /api/cloud/play URLs. + // STRM / cloud:// media must stay behind a token-aware endpoint. When + // STRM playback is enabled we expose /api/stream so third-party clients + // follow the same STRM entry as generated .strm files; when disabled we + // expose /Videos/{id}/stream so playback uses the Emby 302/proxy path. src["IsRemote"] = true - src["Path"] = embyDirectStreamURL(m.ID, container) + src["Path"] = playURL } return src } +func embySTRMStreamURL(mediaID string) string { + return "/api/stream/" + url.PathEscape(strings.TrimSpace(mediaID)) +} + func embyDirectStreamURL(mediaID, container string) string { mediaID = strings.TrimSpace(mediaID) container = strings.Trim(strings.ToLower(container), ". ") diff --git a/internal/service/emby_compat_test.go b/internal/service/emby_compat_test.go index fef6c35..e438afe 100644 --- a/internal/service/emby_compat_test.go +++ b/internal/service/emby_compat_test.go @@ -360,11 +360,11 @@ func TestEmbyPlaybackInfoKeepsSTRMBehindStreamEndpoint(t *testing.T) { if src["IsRemote"] != true { t.Fatalf("strm media should be marked remote: %#v", src) } - if src["DirectStreamUrl"] != "/Videos/cloud-1/stream" { - t.Fatalf("strm playback must stay behind token-aware stream endpoint: %#v", src) + if src["DirectStreamUrl"] != "/api/stream/cloud-1" { + t.Fatalf("strm playback should prefer /api/stream when enabled: %#v", src) } - if src["Path"] != "/Videos/cloud-1/stream" { - t.Fatalf("path should use token-aware stream endpoint: %#v", src) + if src["Path"] != "/api/stream/cloud-1" { + t.Fatalf("path should prefer /api/stream when enabled: %#v", src) } streams := src["MediaStreams"].([]map[string]any) if len(streams) == 0 || streams[0]["Type"] != "Video" { @@ -372,6 +372,40 @@ func TestEmbyPlaybackInfoKeepsSTRMBehindStreamEndpoint(t *testing.T) { } } +func TestEmbyPlaybackInfoUsesVideoStreamWhenSTRMDisabled(t *testing.T) { + svc := newTestEmbyService(t) + if err := svc.repo.Setting.Set(t.Context(), STRMEnabledSettingKey, "false"); err != nil { + t.Fatalf("set strm disabled: %v", err) + } + lib := model.Library{Name: "OpenList", Path: `cloud://openlist/Movies`, Type: "movie", Enabled: true} + if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + media := model.Media{ + Base: model.Base{ID: "cloud-302"}, + LibraryID: lib.ID, + Title: "Cloud 302 Movie", + Path: `cloud://openlist/Movies/Movie.mkv`, + STRMURL: `/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv`, + Container: "mkv", + } + if err := svc.repo.DB.Create(&media).Error; err != nil { + t.Fatalf("create media: %v", err) + } + + pb, err := svc.PlaybackInfo(t.Context(), "cloud-302", "user-1") + if err != nil { + t.Fatalf("playback info: %v", err) + } + src := pb["MediaSources"].([]map[string]any)[0] + if src["DirectStreamUrl"] != "/Videos/cloud-302/stream.mkv" { + t.Fatalf("302/proxy mode should use Emby video stream URL: %#v", src) + } + if src["Path"] != "/Videos/cloud-302/stream.mkv" { + t.Fatalf("302/proxy mode path should use Emby video stream URL: %#v", src) + } +} + func TestEmbyPlaybackInfoProbesMissingCloudTrackMetadata(t *testing.T) { svc := newTestEmbyService(t) lib := model.Library{Name: "OpenList", Path: `cloud://openlist/Movies`, Type: "movie", Enabled: true} diff --git a/internal/service/stream_test.go b/internal/service/stream_test.go index 5cce474..84b3dde 100644 --- a/internal/service/stream_test.go +++ b/internal/service/stream_test.go @@ -115,7 +115,7 @@ func TestServeFileRedirectUsesForwardedTunnelHost(t *testing.T) { } } -func TestServeFileRedirectsCloudMediaWhenSTRMPlaybackDisabled(t *testing.T) { +func TestServeFileRedirectsCloudMediaForVideoStreamMode(t *testing.T) { repos := newStreamTestRepo(t) if err := repos.Setting.Set(t.Context(), STRMEnabledSettingKey, "false"); err != nil { t.Fatal(err) @@ -134,7 +134,7 @@ func TestServeFileRedirectsCloudMediaWhenSTRMPlaybackDisabled(t *testing.T) { err := svc.ServeFile(w, req, "cloud-1") if err != nil { - t.Fatalf("cloud playback should ignore local STRM toggle: %v", err) + t.Fatalf("video stream mode should still reach cloud playback endpoint: %v", err) } if w.Code != http.StatusFound { t.Fatalf("status = %d, want 302", w.Code) diff --git a/web/src/pages/StorageConfigPage.tsx b/web/src/pages/StorageConfigPage.tsx index 9f4bc39..359afe2 100644 --- a/web/src/pages/StorageConfigPage.tsx +++ b/web/src/pages/StorageConfigPage.tsx @@ -119,7 +119,7 @@ const FIELD_DEFS: Record 启用 STRM 播放 - 关闭后不会跳转 STRM/网盘直链;本地文件仍按本地文件播放。 + 开启后第三方播放优先走 STRM 入口;关闭后使用 302/反代播放入口。