mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
@@ -73,13 +73,26 @@ func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
subtitles = discovered
|
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,
|
"media": m,
|
||||||
"stream_url": "/api/stream/" + m.ID + "?token=" + url.QueryEscape(token) + profileQuery,
|
"stream_url": "/api/stream/" + m.ID + "?token=" + url.QueryEscape(token) + profileQuery,
|
||||||
"hls_url": hlsURL,
|
"hls_url": hlsURL,
|
||||||
"resume": resumePayload,
|
"resume": resumePayload,
|
||||||
"subtitles": subtitles,
|
"subtitles": subtitles,
|
||||||
})
|
}
|
||||||
|
if hasDirectURL {
|
||||||
|
payload["direct_url"] = directURL
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/truewhile/MeBox/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
"github.com/truewhile/MeBox/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
"github.com/truewhile/MeBox/internal/service/cloud"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExternalURLUsesMediaScopedPlaybackToken(t *testing.T) {
|
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) {
|
func TestSubtitleListOnlyProbesEmbeddedTracksForHLS(t *testing.T) {
|
||||||
router, svc, secret := newPlaybackScopeTestRouter(t)
|
router, svc, secret := newPlaybackScopeTestRouter(t)
|
||||||
loginToken := signedTestToken(t, secret)
|
loginToken := signedTestToken(t, secret)
|
||||||
|
|||||||
@@ -110,6 +110,29 @@ func setCloudRedirectNoStore(w http.ResponseWriter) {
|
|||||||
// 由 /api/strm/play 再去换链,最坏情况只是回到改动前的行为。
|
// 由 /api/strm/play 再去换链,最坏情况只是回到改动前的行为。
|
||||||
const directPlayResolveTimeout = 10 * time.Second
|
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 目标解析成客户端可直接拉取的
|
// resolveDirectPlayTargetURL 尝试在服务端把 strm 目标解析成客户端可直接拉取的
|
||||||
// 最终直链,供调用方直接 302。
|
// 最终直链,供调用方直接 302。
|
||||||
//
|
//
|
||||||
@@ -117,10 +140,18 @@ const directPlayResolveTimeout = 10 * time.Second
|
|||||||
// 解析到本地文件、以及解析失败都会返回 false,由调用方按改动前的方式回退到
|
// 解析到本地文件、以及解析失败都会返回 false,由调用方按改动前的方式回退到
|
||||||
// strm 端点跳转,行为不会变差。
|
// strm 端点跳转,行为不会变差。
|
||||||
func (s *StreamService) resolveDirectPlayTargetURL(r *http.Request, raw string) (string, bool) {
|
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 {
|
if s == nil || s.strmResolve == nil || r == nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), directPlayResolveTimeout)
|
ctx, cancel := context.WithTimeout(r.Context(), timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
// 必须带播放器 UA:115 直链绑定换取时的 UA,且按 UA 分键缓存,换错会拿到
|
// 必须带播放器 UA:115 直链绑定换取时的 UA,且按 UA 分键缓存,换错会拿到
|
||||||
// 与播放器不匹配(或未命中缓存)的地址。
|
// 与播放器不匹配(或未命中缓存)的地址。
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user