From 2faedae0bfa79e8599ac470c941d470b8ff209b3 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:43:43 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/playback_extra.go | 17 +++- internal/handler/playback_extra_test.go | 55 +++++++++++++ internal/service/stream_file.go | 33 +++++++- internal/service/stream_info_direct_test.go | 89 +++++++++++++++++++++ 4 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 internal/service/stream_info_direct_test.go diff --git a/internal/handler/playback_extra.go b/internal/handler/playback_extra.go index 29efc69..d7b7fcb 100644 --- a/internal/handler/playback_extra.go +++ b/internal/handler/playback_extra.go @@ -73,13 +73,26 @@ func playbackInfoHandler(svc *service.Container) gin.HandlerFunc { subtitles = discovered } } - c.JSON(http.StatusOK, gin.H{ + // 能在服务端换链就把最终直链一并返回:客户端直接连 CDN,省掉 + // /api/stream 的 302 往返。跨境/高延迟链路上,这一跳等于一次完整的 + // DNS + TCP + TLS,实测能到数秒,正好压在起播关键路径上。 + // 换链失败、本地文件或需要服务端加头的网盘保持原样,客户端仍走 + // stream_url 跳转,行为与改动前一致。 + directURL, hasDirectURL := "", false + if svc.Stream != nil { + directURL, hasDirectURL = svc.Stream.ResolveDirectPlayURL(c.Request, m) + } + payload := gin.H{ "media": m, "stream_url": "/api/stream/" + m.ID + "?token=" + url.QueryEscape(token) + profileQuery, "hls_url": hlsURL, "resume": resumePayload, "subtitles": subtitles, - }) + } + if hasDirectURL { + payload["direct_url"] = directURL + } + c.JSON(http.StatusOK, payload) } } diff --git a/internal/handler/playback_extra_test.go b/internal/handler/playback_extra_test.go index 518982f..325a2cf 100644 --- a/internal/handler/playback_extra_test.go +++ b/internal/handler/playback_extra_test.go @@ -22,6 +22,7 @@ import ( "github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/repository" "github.com/truewhile/MeBox/internal/service" + "github.com/truewhile/MeBox/internal/service/cloud" ) func TestExternalURLUsesMediaScopedPlaybackToken(t *testing.T) { @@ -457,6 +458,60 @@ func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) { } } +// 服务端能换到最终直链时,播放地址响应要把直链一并给客户端:移动端起播就能 +// 跳过 /api/stream 的 302 往返(跨境链路上等于一次完整 DNS+TCP+TLS)。 +func TestPlaybackInfoCarriesPrefetchedDirectURL(t *testing.T) { + router, svc, secret := newPlaybackScopeTestRouter(t) + loginToken := signedTestToken(t, secret) + const direct = "https://cdnfhnfile.115cdn.net/637b/Movie.mkv?t=1&k=sig" + var gotUA string + svc.Stream.SetStrmPlayTargetResolver(func(_ context.Context, _, userAgent string) (*service.StrmPlayResult, error) { + gotUA = userAgent + return &service.StrmPlayResult{ + RedirectURL: direct, + Link: &cloud.DirectLink{URL: direct, Headers: map[string]string{"User-Agent": userAgent}}, + }, nil + }) + if err := svc.Repo.DB.Create(&model.Media{ + Base: model.Base{ID: "media-direct"}, + LibraryID: "lib-1", + Title: "Direct", + Path: "cloud://cloud115/Movie.mkv", + Container: "strm", + DurationSec: 3600, + STRMURL: "/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1", + }).Error; err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/playback/media-direct/info", nil) + req.Header.Set("Authorization", "Bearer "+loginToken) + req.Header.Set("User-Agent", "MeBox-Android/0.1.0") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) + } + var payload struct { + DirectURL string `json:"direct_url"` + StreamURL string `json:"stream_url"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode: %v", err) + } + if payload.DirectURL != direct { + t.Fatalf("direct_url = %q, want %q (body=%s)", payload.DirectURL, direct, w.Body.String()) + } + if !strings.Contains(payload.StreamURL, "/api/stream/media-direct") { + t.Fatalf("stream_url must stay as the fallback, got %q", payload.StreamURL) + } + // 115 直链绑定换取时的 UA:预置直链必须按播放器的 UA 换,否则播放会 403。 + if gotUA != "MeBox-Android/0.1.0" { + t.Fatalf("resolver UA = %q, want the client UA", gotUA) + } +} + func TestSubtitleListOnlyProbesEmbeddedTracksForHLS(t *testing.T) { router, svc, secret := newPlaybackScopeTestRouter(t) loginToken := signedTestToken(t, secret) diff --git a/internal/service/stream_file.go b/internal/service/stream_file.go index a91ea18..f6793a2 100644 --- a/internal/service/stream_file.go +++ b/internal/service/stream_file.go @@ -110,6 +110,29 @@ func setCloudRedirectNoStore(w http.ResponseWriter) { // 由 /api/strm/play 再去换链,最坏情况只是回到改动前的行为。 const directPlayResolveTimeout = 10 * time.Second +// directPlayInfoResolveTimeout 是播放地址响应(/api/playback/{id}/info)里 +// 预换链的等待上限。这条路径是用户点播放后的同步阻塞,宁可退回 /api/stream +// 跳转,也不能把起播卡在换链上;命中直链缓存时通常只有微秒级。 +const directPlayInfoResolveTimeout = 3 * time.Second + +// ResolveDirectPlayURL 尝试把媒体的 STRM/网盘目标换成客户端可直接播放的最终 +// 直链,供 /api/playback/{id}/info 预置给客户端。 +// +// 客户端拿到直链后可以跳过 /api/stream 的 302 往返;在高延迟/跨境链路上, +// 这一次「DNS + TCP + TLS + 请求」常常是数秒,正好落在起播关键路径上。 +// 返回 false 表示必须继续走 /api/stream(本地文件、需要服务端加头的网盘、 +// 解析失败或超时),行为与改动前一致。 +func (s *StreamService) ResolveDirectPlayURL(r *http.Request, m *model.Media) (string, bool) { + if s == nil || r == nil || m == nil { + return "", false + } + raw := MediaSTRMTarget(m) + if raw == "" || !playableSTRMTarget(r.Context(), s.repo, raw, m) { + return "", false + } + return s.resolveDirectPlayTargetURLWithin(r, raw, directPlayInfoResolveTimeout) +} + // resolveDirectPlayTargetURL 尝试在服务端把 strm 目标解析成客户端可直接拉取的 // 最终直链,供调用方直接 302。 // @@ -117,10 +140,18 @@ const directPlayResolveTimeout = 10 * time.Second // 解析到本地文件、以及解析失败都会返回 false,由调用方按改动前的方式回退到 // strm 端点跳转,行为不会变差。 func (s *StreamService) resolveDirectPlayTargetURL(r *http.Request, raw string) (string, bool) { + return s.resolveDirectPlayTargetURLWithin(r, raw, directPlayResolveTimeout) +} + +func (s *StreamService) resolveDirectPlayTargetURLWithin( + r *http.Request, + raw string, + timeout time.Duration, +) (string, bool) { if s == nil || s.strmResolve == nil || r == nil { return "", false } - ctx, cancel := context.WithTimeout(r.Context(), directPlayResolveTimeout) + ctx, cancel := context.WithTimeout(r.Context(), timeout) defer cancel() // 必须带播放器 UA:115 直链绑定换取时的 UA,且按 UA 分键缓存,换错会拿到 // 与播放器不匹配(或未命中缓存)的地址。 diff --git a/internal/service/stream_info_direct_test.go b/internal/service/stream_info_direct_test.go new file mode 100644 index 0000000..b959468 --- /dev/null +++ b/internal/service/stream_info_direct_test.go @@ -0,0 +1,89 @@ +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service/cloud" +) + +// /api/playback/{id}/info 预置换链:能换到直链时返回 true,并把客户端 UA +// 透传给 115(直链绑定换取时的 UA,播放器必须用同一个)。 +func TestResolveDirectPlayURLForPlaybackInfo(t *testing.T) { + const direct = "https://cdnfhnfile.115cdn.net/637b/Movie.mkv?t=1&k=sig" + var gotUA string + svc := NewStreamService(&config.Config{}, zap.NewNop(), nil, nil). + SetStrmPlayTargetResolver(func(_ context.Context, _, userAgent string) (*StrmPlayResult, error) { + gotUA = userAgent + return &StrmPlayResult{ + RedirectURL: direct, + Link: &cloud.DirectLink{URL: direct, Headers: map[string]string{"User-Agent": userAgent}}, + }, nil + }) + m := &model.Media{ + Base: model.Base{ID: "cloud-info"}, + Path: "cloud://cloud115/Movie.mkv", + Container: "strm", + STRMURL: "https://mebox.example/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1", + } + req := httptest.NewRequest(http.MethodGet, "/api/playback/cloud-info/info", nil) + req.Header.Set("User-Agent", "MeBox-Android/0.1.0") + + got, ok := svc.ResolveDirectPlayURL(req, m) + if !ok || got != direct { + t.Fatalf("ResolveDirectPlayURL() = (%q, %v), want (%q, true)", got, ok, direct) + } + if gotUA != "MeBox-Android/0.1.0" { + t.Fatalf("resolver UA = %q, want the requesting client UA", gotUA) + } +} + +// 本地文件没有可直连的 CDN 地址,信息接口不能凭空给出 direct_url。 +func TestResolveDirectPlayURLSkipsLocalMedia(t *testing.T) { + svc := NewStreamService(&config.Config{}, zap.NewNop(), nil, nil). + SetStrmPlayTargetResolver(func(context.Context, string, string) (*StrmPlayResult, error) { + t.Fatal("本地文件不应该触发换链") + return nil, nil + }) + m := &model.Media{ + Base: model.Base{ID: "local-1"}, + Path: "/media/movie.mkv", + Container: "mkv", + } + req := httptest.NewRequest(http.MethodGet, "/api/playback/local-1/info", nil) + if got, ok := svc.ResolveDirectPlayURL(req, m); ok || got != "" { + t.Fatalf("ResolveDirectPlayURL() = (%q, %v), want (\"\", false)", got, ok) + } +} + +// 需要服务端附加其它请求头(Referer 等)的直链不能短路给客户端,否则播放端 +// 少了鉴权头会 403;这类媒体继续走 /api/stream 由服务端转发。 +func TestResolveDirectPlayURLSkipsHeaderBoundLinks(t *testing.T) { + const direct = "https://cdn.example.com/Movie.mkv" + svc := NewStreamService(&config.Config{}, zap.NewNop(), nil, nil). + SetStrmPlayTargetResolver(func(_ context.Context, _, userAgent string) (*StrmPlayResult, error) { + return &StrmPlayResult{ + RedirectURL: direct, + Link: &cloud.DirectLink{ + URL: direct, + Headers: map[string]string{"User-Agent": userAgent, "Referer": "https://pan.example"}, + }, + }, nil + }) + m := &model.Media{ + Base: model.Base{ID: "cloud-header"}, + Path: "cloud://cloud115/Movie.mkv", + Container: "strm", + STRMURL: "/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1", + } + req := httptest.NewRequest(http.MethodGet, "/api/playback/cloud-header/info", nil) + if got, ok := svc.ResolveDirectPlayURL(req, m); ok || got != "" { + t.Fatalf("ResolveDirectPlayURL() = (%q, %v), want (\"\", false)", got, ok) + } +}