优化起播

This commit is contained in:
truewhile
2026-09-22 22:30:52 +08:00
parent b5ce4646cf
commit 016c6687e9
12 changed files with 861 additions and 19 deletions
+24 -2
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha1"
"encoding/hex"
"strings"
"time"
)
// Emby 发现类接口:NextUp / Similar / Genres。
@@ -67,6 +68,19 @@ func (e *EmbyService) SimilarItems(ctx context.Context, mediaID, userID string,
return emptyItemsEnvelope(0), nil
}
// 详情页每次打开都会请求相似推荐,而重建要走「取候选池 + 内存打分」
// (实测冷 340ms / 热 70ms)。推荐列表短暂陈旧无害,用短 TTL 缓存,
// 新建库或换用户都会因为键名不同而自然隔离。
cacheKey := e.embySimilarCacheKey(mediaID, userID, limit)
if e.cache != nil {
var cached map[string]any
if e.cache.GetJSON(ctx, cacheKey, &cached) && cached != nil {
if _, ok := cached["Items"]; ok {
return cached, nil
}
}
}
// Bug 2 fix: resolve virtual series IDs (msgo-series-*) and real series
// table IDs to a representative episode so SimilarCandidates (which calls
// Media.FindByID) can seed similarity from concrete media metadata.
@@ -109,12 +123,20 @@ func (e *EmbyService) SimilarItems(ctx context.Context, mediaID, userID string,
if err != nil {
return nil, err
}
return map[string]any{
out := map[string]any{
"Items": items,
"TotalRecordCount": int64(len(items)),
}, nil
}
if e.cache != nil {
e.cache.SetJSON(ctx, cacheKey, out, embySimilarCacheTTL)
}
return out, nil
}
// embySimilarCacheTTL 是「相似推荐」结果的缓存时长。列表只是推荐,短暂陈旧
// 无害;TTL 取短一些,让新入库的内容尽快出现。
const embySimilarCacheTTL = 2 * time.Minute
// Genres 返回类型清单。parentID 非空时(客户端按媒体库浏览类型)只统计该库。
func (e *EmbyService) Genres(ctx context.Context, userID, parentID string) (map[string]any, error) {
discovery := e.discoveryService()
+9
View File
@@ -67,6 +67,15 @@ func (e *EmbyService) embyLatestCacheKey(userID, parentID string, limit int) str
return "media:emby:" + hex.EncodeToString(sum[:])
}
// embySimilarCacheKey 是「相似推荐」结果的缓存键。
//
// userID 必须参与键名:候选集的可见性(AllowedLibraryIDs、NSFW)由用户决定,
// 混用会把别的用户可见的条目推荐给当前用户。limit 同理影响结果条数与排序。
func (e *EmbyService) embySimilarCacheKey(mediaID, userID string, limit int) string {
sum := sha256.Sum256([]byte(strings.Join([]string{"similar-v1", mediaID, userID, strconv.Itoa(limit)}, "|")))
return "media:emby:" + hex.EncodeToString(sum[:])
}
// defaultEmbyLatestCacheTTLSeconds 是 Emby「最新添加」缓存的兜底时长。
const defaultEmbyLatestCacheTTLSeconds = 300
+60 -15
View File
@@ -86,6 +86,54 @@ type EmbyRemoteService struct {
// (没有它时 URL 恒定,缩略图会永久停留在旧版本)。
imageTagMu sync.RWMutex
imageTags map[string]string
// remoteGate 是发往远程 Emby 的并发闸门。第三方客户端刷新首页时会为每个
// 远程媒体库各请求一次 /Items/Latest,挂着几十个库就是几十路并发(生产环境
// 实测 50 路同时打进来,单个请求被拖到 5s+)。限制在途请求数后单个请求的
// 等待时间反而下降,也不会把 2C 小机和对方服务器一起打满。
//
// nil 表示不限流(测试直接构造结构体时走这条路)。
remoteGate chan struct{}
}
// embyRemoteConcurrencyLimit 是同时发往远程 Emby 的请求数上限。
const embyRemoteConcurrencyLimit = 8
// enterRemoteGate 取得一个远程请求名额,返回释放函数。未配置闸门时返回空操作。
func (r *EmbyRemoteService) enterRemoteGate(ctx context.Context) (func(), error) {
if r == nil || r.remoteGate == nil {
return func() {}, nil
}
select {
case r.remoteGate <- struct{}{}:
return func() { <-r.remoteGate }, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
// fetchRemoteBody 在并发闸门内发起请求并读完响应体,返回状态码与字节。
func (r *EmbyRemoteService) fetchRemoteBody(ctx context.Context, req *http.Request, path string) (int, []byte, error) {
release, err := r.enterRemoteGate(ctx)
if err != nil {
return 0, nil, err
}
defer release()
resp, err := r.http.Do(req)
if err != nil {
return 0, nil, redactSensitiveError(fmt.Errorf("请求远程 Emby 失败: %w", err))
}
defer resp.Body.Close()
// 读 8MB+1 以区分"刚好 8MB"与"被截断":截断的 JSON 会让
// Unmarshal 报 unexpected end,难以定位;这里显式报错。
data, readErr := io.ReadAll(io.LimitReader(resp.Body, (8<<20)+1))
if readErr != nil {
return resp.StatusCode, nil, readErr
}
if len(data) > 8<<20 {
return resp.StatusCode, data, fmt.Errorf("远程 Emby 响应超过 8MB 上限(路径 %s):请减小分页或 Fields 字段", path)
}
return resp.StatusCode, data, nil
}
type embyRemotePersonImageRef struct {
@@ -113,6 +161,7 @@ func NewEmbyRemoteService(cfg *config.Config, log *zap.Logger, repo *repository.
stream: &http.Client{
Transport: &embyRemoteTransport{base: http.DefaultTransport},
},
remoteGate: make(chan struct{}, embyRemoteConcurrencyLimit),
}
}
@@ -645,21 +694,11 @@ func (r *EmbyRemoteService) doGetOnLine(ctx context.Context, acct *model.StrmAcc
return err
}
req.Header.Set("X-Emby-Token", cfg.Token)
resp, err := r.http.Do(req)
status, data, err := r.fetchRemoteBody(ctx, req, path)
if err != nil {
return redactSensitiveError(fmt.Errorf("请求远程 Emby 失败: %w", err))
return err
}
// 读 8MB+1 以区分"刚好 8MB"与"被截断":截断的 JSON 会让
// Unmarshal 报 unexpected end,难以定位;这里显式报错。
data, readErr := io.ReadAll(io.LimitReader(resp.Body, (8<<20)+1))
resp.Body.Close()
if readErr != nil {
return readErr
}
if len(data) > 8<<20 {
return fmt.Errorf("远程 Emby 响应超过 8MB 上限(路径 %s):请减小分页或 Fields 字段", path)
}
if resp.StatusCode == http.StatusUnauthorized && attempt == 0 {
if status == http.StatusUnauthorized && attempt == 0 {
// 401:只清当前线路的内存 token 并立即重认证;不在此时删除
// DB 里的 api_key——①外层还会按线路故障转移(其他线路可能
// 存有自己的 token);②纯 api_key 账号删除后无法再认证,一次
@@ -673,8 +712,8 @@ func (r *EmbyRemoteService) doGetOnLine(ctx context.Context, acct *model.StrmAcc
master.RemoteUserID = cfg.RemoteUserID
continue
}
if resp.StatusCode >= 300 {
return redactSensitiveError(fmt.Errorf("远程 Emby 请求失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data))))
if status >= 300 {
return redactSensitiveError(fmt.Errorf("远程 Emby 请求失败(%d): %s", status, strings.TrimSpace(string(data))))
}
if out == nil {
return nil
@@ -1617,6 +1656,12 @@ func (r *EmbyRemoteService) doMutateOnLine(ctx context.Context, cfg *EmbyRemoteC
return err
}
req.Header.Set("X-Emby-Token", cfg.Token)
// 状态同步同样走远程并发闸门:它和首页那批 Latest 请求共用对方服务器。
release, err := r.enterRemoteGate(ctx)
if err != nil {
return err
}
defer release()
resp, err := r.http.Do(req)
if err != nil {
return redactSensitiveError(fmt.Errorf("请求远程 Emby 失败: %w", err))
+112
View File
@@ -0,0 +1,112 @@
package service
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
)
// 远程 Emby 的并发闸门必须真的把在途请求数压在上限之内:第三方客户端首页会为
// 每个远程媒体库各请求一次 /Items/Latest,几十个库就是几十路并发。
func TestRemoteGateLimitsConcurrentRequests(t *testing.T) {
const gate = 3
const requests = 12
var mu sync.Mutex
inflight, peak := 0, 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
inflight++
if inflight > peak {
peak = inflight
}
mu.Unlock()
time.Sleep(20 * time.Millisecond)
mu.Lock()
inflight--
mu.Unlock()
_, _ = w.Write([]byte(`{"Items":[]}`))
}))
defer srv.Close()
svc := &EmbyRemoteService{http: srv.Client(), remoteGate: make(chan struct{}, gate)}
var wg sync.WaitGroup
for i := 0; i < requests; i++ {
wg.Add(1)
go func() {
defer wg.Done()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL+"/Items/Latest", nil)
if err != nil {
return
}
if _, _, err := svc.fetchRemoteBody(context.Background(), req, "/Items/Latest"); err != nil {
t.Errorf("fetchRemoteBody: %v", err)
}
}()
}
wg.Wait()
mu.Lock()
defer mu.Unlock()
if peak == 0 {
t.Fatal("test server never saw a request")
}
if peak > gate {
t.Fatalf("peak concurrency = %d, want <= %d", peak, gate)
}
}
// 闸门排队时要响应请求取消,不能把整个 HTTP 请求挂死。
func TestRemoteGateHonoursContextCancellation(t *testing.T) {
svc := &EmbyRemoteService{remoteGate: make(chan struct{}, 1)}
svc.remoteGate <- struct{}{} // 占满名额
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := svc.enterRemoteGate(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("err = %v, want context.Canceled", err)
}
// 释放名额后必须能正常拿到。
<-svc.remoteGate
release, err := svc.enterRemoteGate(context.Background())
if err != nil {
t.Fatalf("enterRemoteGate after release: %v", err)
}
release()
if len(svc.remoteGate) != 0 {
t.Fatalf("gate leaked a permit: len = %d", len(svc.remoteGate))
}
}
// 未配置闸门(测试里直接构造结构体)时不应限流,保持旧行为。
func TestRemoteGateAbsentIsUnlimited(t *testing.T) {
svc := &EmbyRemoteService{}
release, err := svc.enterRemoteGate(context.Background())
if err != nil {
t.Fatalf("enterRemoteGate: %v", err)
}
release()
}
// 生产路径构造出来的服务必须带闸门,否则上面的限制形同虚设。
func TestNewEmbyRemoteServiceInitialisesGate(t *testing.T) {
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), nil, nil)
if svc.remoteGate == nil {
t.Fatal("remoteGate must be initialised by the constructor")
}
if cap(svc.remoteGate) != embyRemoteConcurrencyLimit {
t.Fatalf("gate capacity = %d, want %d", cap(svc.remoteGate), embyRemoteConcurrencyLimit)
}
}
+118
View File
@@ -0,0 +1,118 @@
package service
import (
"context"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
// totalRecordCount 兼容两种来源:直算出来的是 int64,经过进程内缓存 JSON
// 往返后是 float64。两者序列化成 Emby 响应时完全一致。
func totalRecordCount(t *testing.T, out map[string]any) int {
t.Helper()
switch v := out["TotalRecordCount"].(type) {
case int64:
return int(v)
case int:
return v
case float64:
return int(v)
default:
t.Fatalf("unexpected TotalRecordCount type %T (%v)", out["TotalRecordCount"], out["TotalRecordCount"])
return 0
}
}
// 相似推荐的结果要缓存:详情页每次打开都会请求它,重建要走「取候选池 + 内存打分」
// (实测冷 340ms / 热 70ms)。
func TestSimilarItemsServesCachedPayload(t *testing.T) {
svc := newTestEmbyService(t)
repos := svc.repo
svc.SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
libID := seedDiscoveryLibrary(t, repos, "movie")
source := seedSimilarMedia(t, repos, libID, "源片", "Action", 2010, 7)
seedSimilarMedia(t, repos, libID, "候选甲", "Action", 2010, 7)
seedSimilarMedia(t, repos, libID, "候选乙", "Comedy", 2011, 6)
ctx := context.Background()
first, err := svc.SimilarItems(ctx, source.ID, "user-1", 10)
if err != nil {
t.Fatalf("first SimilarItems: %v", err)
}
want := totalRecordCount(t, first)
if want == 0 {
t.Fatalf("first call returned no candidates, test data is wrong: %+v", first)
}
// 把候选全部删掉:第二次如果还返回原结果,只可能是命中缓存。
if err := repos.DB.Where("id <> ?", source.ID).Delete(&model.Media{}).Error; err != nil {
t.Fatal(err)
}
second, err := svc.SimilarItems(ctx, source.ID, "user-1", 10)
if err != nil {
t.Fatalf("second SimilarItems: %v", err)
}
if got := totalRecordCount(t, second); got != want {
t.Fatalf("cached call returned %d items, want %d (cache miss?)", got, want)
}
// 另一个用户(不同的可见性)不能复用别人的缓存:这里应当重新查询并得到 0。
other, err := svc.SimilarItems(ctx, source.ID, "user-2", 10)
if err != nil {
t.Fatalf("other user SimilarItems: %v", err)
}
if got := totalRecordCount(t, other); got != 0 {
t.Fatalf("other user got %d items, want 0 (per-user cache key)", got)
}
}
// limit 不同必须分开缓存,否则一次小 limit 请求会污染后续更大的请求。
func TestSimilarItemsCacheSeparatesLimit(t *testing.T) {
svc := newTestEmbyService(t)
repos := svc.repo
svc.SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
libID := seedDiscoveryLibrary(t, repos, "movie")
source := seedSimilarMedia(t, repos, libID, "源片", "Action", 2010, 7)
for _, title := range []string{"甲", "乙", "丙", "丁"} {
seedSimilarMedia(t, repos, libID, "候选"+title, "Action", 2010, 7)
}
ctx := context.Background()
small, err := svc.SimilarItems(ctx, source.ID, "user-1", 2)
if err != nil {
t.Fatal(err)
}
if got := totalRecordCount(t, small); got != 2 {
t.Fatalf("limit=2 returned %d items, want 2", got)
}
large, err := svc.SimilarItems(ctx, source.ID, "user-1", 4)
if err != nil {
t.Fatal(err)
}
if got := totalRecordCount(t, large); got != 4 {
t.Fatalf("limit=4 returned %d items, want 4 (limit must be part of the cache key)", got)
}
}
// 没有注入缓存时(测试/精简部署)也必须正常工作。
func TestSimilarItemsWithoutCacheStillWorks(t *testing.T) {
svc := newTestEmbyService(t)
libID := seedDiscoveryLibrary(t, svc.repo, "movie")
source := seedSimilarMedia(t, svc.repo, libID, "源片", "Action", 2010, 7)
seedSimilarMedia(t, svc.repo, libID, "候选甲", "Action", 2010, 7)
out, err := svc.SimilarItems(context.Background(), source.ID, "user-1", 10)
if err != nil {
t.Fatalf("SimilarItems: %v", err)
}
if got := totalRecordCount(t, out); got == 0 {
t.Fatalf("expected candidates without a cache, got %+v", out)
}
}
+3
View File
@@ -145,6 +145,9 @@ func (b *serviceContainerBuilder) initContentServices() {
b.c.Transcoder.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget)
b.c.Transcoder.SetProbe(b.c.FFprobe)
b.c.Subtitle.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget)
// 播放链路:/Videos/{id}/stream 与 /api/stream/{id} 在服务端完成换链后直接
// 302 到最终直链,客户端少跟随一次 302(高延迟线路上省一个往返)。
b.c.Stream.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTargetWithUA)
// 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。
if b.c.EmbyRemote != nil {
b.c.Danmaku.SetRemoteMediaResolver(func(ctx context.Context, encodedID string) (*model.Media, string, error) {
@@ -0,0 +1,200 @@
package service
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service/cloud"
)
// directRedirectTestRepo 建一个带网盘账号表的库:normalizeCloudPlayTarget 需要
// StrmAccount 才能判断 strm 目标是不是本机账号生成的。
func directRedirectTestRepo(t *testing.T) *repository.Container {
t.Helper()
return repository.New(newServiceTestDB(t, &model.Media{}, &model.Setting{}, &model.StrmAccount{}))
}
func seedCloudSTRMMedia(t *testing.T, repos *repository.Container, id, strmURL string) {
t.Helper()
if err := repos.DB.Create(&model.Media{
Base: model.Base{ID: id},
Title: "Cloud",
Path: "cloud://cloud115/Movie.mkv",
Container: "strm",
STRMURL: strmURL,
}).Error; err != nil {
t.Fatal(err)
}
}
// 服务端能换到最终直链时必须直接 302 过去:客户端原本要跟着
// /Videos/{id}/stream → /api/strm/play 两次 302,现在缩成一跳。
func TestServeFileRedirectsStraightToResolvedDirectURL(t *testing.T) {
repos := directRedirectTestRepo(t)
seedCloudSTRMMedia(t, repos, "cloud-direct", "/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1")
direct := "https://cdnfhnfile.115cdn.net/637b/Movie.mkv?t=1&k=sig"
var gotRaw, gotUA string
svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil).
SetStrmPlayTargetResolver(func(_ context.Context, raw, userAgent string) (*StrmPlayResult, error) {
gotRaw, gotUA = raw, userAgent
// 生产环境 115 直链就是这样返回的:绑定 UA、Proxy=false。
return &StrmPlayResult{
RedirectURL: direct,
Link: &cloud.DirectLink{URL: direct, Headers: map[string]string{"User-Agent": userAgent}},
}, nil
})
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/cloud-direct?token=jwt123", nil)
req.Header.Set("User-Agent", "RodelPlayer/2.2607.7.0")
w := httptest.NewRecorder()
if err := svc.ServeFile(w, req, "cloud-direct"); err != nil {
t.Fatalf("ServeFile: %v", err)
}
if w.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", w.Code)
}
loc := w.Header().Get("Location")
if loc != direct {
t.Fatalf("Location = %q, want the resolved direct link %q", loc, direct)
}
if strings.Contains(loc, "jwt123") || strings.Contains(loc, "media_id=") {
t.Fatalf("internal auth query must not leak to the CDN link: %q", loc)
}
if got := w.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") {
t.Fatalf("direct redirect must stay uncacheable, got %q", got)
}
// 换链必须带播放器 UA(115 直链绑定换取时的 UA,且按 UA 分键缓存)。
if gotUA != "RodelPlayer/2.2607.7.0" {
t.Fatalf("resolver UA = %q, want the player UA", gotUA)
}
if !strings.Contains(gotRaw, "pickcode=pc1") {
t.Fatalf("resolver raw = %q, want the strm target", gotRaw)
}
}
// 只要拿不到「客户端自己能直接拉取的直链」,就必须回退到改动前的 strm 端点跳转,
// 保证行为不会比改动前更差。
func TestServeFileFallsBackWhenDirectResolveUnavailable(t *testing.T) {
strmURL := "/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1"
cases := []struct {
name string
result *StrmPlayResult
wantErr error
}{
{name: "换链失败", wantErr: errors.New("115 换链失败")},
{
name: "需要服务端反向代理",
result: &StrmPlayResult{
Proxy: true,
Link: &cloud.DirectLink{URL: "https://cdn.example/x", Headers: map[string]string{"Authorization": "Basic x"}},
},
},
{name: "没有直链(别的 MeBox 实例)", result: &StrmPlayResult{RedirectURL: "https://other.example/api/strm/play/cloud115/video.mkv?acct=o&pickcode=p"}},
{name: "解析到本地文件", result: &StrmPlayResult{LocalPath: "/media/Movie.mkv"}},
{name: "返回 nil", result: nil},
{
name: "链接要求额外请求头",
result: &StrmPlayResult{
RedirectURL: "https://cdn.example/x",
Link: &cloud.DirectLink{
URL: "https://cdn.example/x",
Headers: map[string]string{"User-Agent": "ua", "Authorization": "Bearer t"},
},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
repos := directRedirectTestRepo(t)
seedCloudSTRMMedia(t, repos, "cloud-fallback", strmURL)
svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil).
SetStrmPlayTargetResolver(func(context.Context, string, string) (*StrmPlayResult, error) {
return tc.result, tc.wantErr
})
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/cloud-fallback?token=jwt123", nil)
w := httptest.NewRecorder()
if err := svc.ServeFile(w, req, "cloud-fallback"); err != nil {
t.Fatalf("ServeFile: %v", err)
}
if w.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", w.Code)
}
loc := w.Header().Get("Location")
if !strings.Contains(loc, "/api/strm/play/cloud115/video.mkv") {
t.Fatalf("Location = %q, want the strm endpoint fallback", loc)
}
if strings.Contains(loc, "cdn.example") || strings.Contains(loc, "other.example") {
t.Fatalf("Location = %q, must not point at an unusable direct link", loc)
}
})
}
}
// 没有注入解析器(测试/精简部署)时保持原有跳转,不受本次优化影响。
func TestServeFileKeepsSTRMEndpointWithoutResolver(t *testing.T) {
repos := directRedirectTestRepo(t)
seedCloudSTRMMedia(t, repos, "cloud-plain", "/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1")
svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil)
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/cloud-plain?token=jwt123", nil)
w := httptest.NewRecorder()
if err := svc.ServeFile(w, req, "cloud-plain"); err != nil {
t.Fatalf("ServeFile: %v", err)
}
if loc := w.Header().Get("Location"); !strings.Contains(loc, "/api/strm/play/cloud115/video.mkv") {
t.Fatalf("Location = %q, want the strm endpoint", loc)
}
}
// playbackQueryWithUA 负责把播放器 UA 透传给换链方,同时不能改动原 URL。
func TestPlaybackQueryWithUAInjectsUserAgent(t *testing.T) {
u, err := url.Parse("/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1")
if err != nil {
t.Fatal(err)
}
q := playbackQueryWithUA(u, " RodelPlayer/2.2607.7.0 ")
if got := q.Get("__ua"); got != "RodelPlayer/2.2607.7.0" {
t.Fatalf("__ua = %q, want the trimmed player UA", got)
}
if q.Get("pickcode") != "pc1" || q.Get("acct") != "a1" {
t.Fatalf("original query lost: %v", q)
}
if strings.Contains(u.RawQuery, "__ua") {
t.Fatalf("source URL must not be mutated: %q", u.RawQuery)
}
if got := playbackQueryWithUA(u, " ").Get("__ua"); got != "" {
t.Fatalf("blank UA must not be injected, got %q", got)
}
if got := playbackQueryWithUA(nil, "ua").Get("__ua"); got != "ua" {
t.Fatalf("nil URL must still accept the UA, got %q", got)
}
}
// ResolvePlayTargetWithUA 是 ResolvePlayTarget 的 UA 版本:空 UA 时行为必须与
// 原方法完全一致(外部直链透传)。
func TestResolvePlayTargetWithUAKeepsPassthroughBehaviour(t *testing.T) {
svc := &StrmService{}
raw := "https://cdn.example.test/Movie.mkv?sign=1"
got, err := svc.ResolvePlayTargetWithUA(context.Background(), raw, "RodelPlayer/1.0")
if err != nil {
t.Fatalf("ResolvePlayTargetWithUA: %v", err)
}
if got == nil || got.RedirectURL != raw {
t.Fatalf("result = %+v, want passthrough of %q", got, raw)
}
}
+46
View File
@@ -6,6 +6,7 @@ import (
"net/url"
"os"
"strings"
"time"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
@@ -33,6 +34,14 @@ func (s *StreamService) ServeFileWithCloudMode(w http.ResponseWriter, r *http.Re
if !cloudPlaybackModeEnabled(r.Context(), s.repo, cloudMode) {
return ErrCloudPlaybackDisabled
}
// 能在服务端换到最终直链就直接 302 过去:客户端少跟随一次 302,等于
// 省掉一次「DNS+TCP+TLS+请求」的往返。解析结果同时写进 strm 层直链
// 缓存,后续 /api/strm/play 请求直接命中。
if direct, ok := s.resolveDirectPlayTargetURL(r, strmURL); ok {
setCloudRedirectNoStore(w)
http.Redirect(w, r, direct, http.StatusFound)
return nil
}
// 云盘播放 URL 先规范化为相对路径,免疫扫描时固化的旧 host;
// 指向别的 MeBox 实例的地址保持原样,按第三方直链透传。
target := normalizeCloudPlayTarget(r.Context(), s.repo, s.cfg, r, strmURL)
@@ -72,6 +81,43 @@ func setCloudRedirectNoStore(w http.ResponseWriter) {
w.Header().Set("Expires", "0")
}
// directPlayResolveTimeout 是服务端换链的等待上限。115 开放平台在跨太平洋线路
// 上单次换链实测 0.4–1.1s,这里给足余量;一旦超时就回退到原来的 strm 端点跳转,
// 由 /api/strm/play 再去换链,最坏情况只是回到改动前的行为。
const directPlayResolveTimeout = 10 * time.Second
// resolveDirectPlayTargetURL 尝试在服务端把 strm 目标解析成客户端可直接拉取的
// 最终直链,供调用方直接 302。
//
// 只在「明确的直链」上短路:需要服务端反向代理(云盘 WebDAV 等必须附加请求头)、
// 解析到本地文件、以及解析失败都会返回 false,由调用方按改动前的方式回退到
// strm 端点跳转,行为不会变差。
func (s *StreamService) resolveDirectPlayTargetURL(r *http.Request, raw string) (string, bool) {
if s == nil || s.strmResolve == nil || r == nil {
return "", false
}
ctx, cancel := context.WithTimeout(r.Context(), directPlayResolveTimeout)
defer cancel()
// 必须带播放器 UA:115 直链绑定换取时的 UA,且按 UA 分键缓存,换错会拿到
// 与播放器不匹配(或未命中缓存)的地址。
result, err := s.strmResolve(ctx, raw, r.Header.Get("User-Agent"))
if err != nil || result == nil || result.Proxy || result.Link == nil {
return "", false
}
// 客户端只能自带 User-Agent 这类基础请求头。链接一旦要求其它头(Referer /
// Authorization),就必须继续由服务端反向代理,不能在这里短路。
for name := range result.Link.Headers {
if !strings.EqualFold(strings.TrimSpace(name), "User-Agent") {
return "", false
}
}
direct := strings.TrimSpace(result.RedirectURL)
if direct == "" {
return "", false
}
return direct, true
}
func isCloudPlaybackTarget(raw string) bool {
_, _, ok := parseCloudMediaPlaybackURL(raw)
return ok
+14
View File
@@ -33,6 +33,10 @@ type StreamService struct {
log *zap.Logger
repo *repository.Container
transcoder *TranscoderService
// strmResolve 把 strm 目标解析成最终直链。注入后 /Videos/{id}/stream 能直接
// 302 到 CDN 地址,省掉 /api/strm/play 那一跳;未注入时保持原有的两跳行为,
// 因此测试与精简部署不受影响。
strmResolve func(ctx context.Context, raw, userAgent string) (*StrmPlayResult, error)
}
// NewStreamService is the constructor.
@@ -45,6 +49,16 @@ func NewStreamService(cfg *config.Config, log *zap.Logger, repo *repository.Cont
}
}
// SetStrmPlayTargetResolver 注入 strm 播放目标解析器(通常为
// StrmService.ResolvePlayTargetWithUA)。注入后播放链路会在服务端完成换链并直接
// 302 到最终直链,避免客户端在高延迟线路上多跟随一次 302。
func (s *StreamService) SetStrmPlayTargetResolver(resolve func(ctx context.Context, raw, userAgent string) (*StrmPlayResult, error)) *StreamService {
if s != nil {
s.strmResolve = resolve
}
return s
}
// ErrMediaNotFound is returned when the media row or its file is missing.
var ErrMediaNotFound = errors.New("media not found")
+25 -2
View File
@@ -124,6 +124,16 @@ func (s *StrmService) resolveLocalPlay(ctx context.Context, rawPath string) (*St
// - 绝对 http(s) 链接(直接透传,包含别的 MeBox / MediaStationGo 实例的播放端点)
// - 其余协议(webdav:// 等)返回错误,由调用方决定是否静默跳过
func (s *StrmService) ResolvePlayTarget(ctx context.Context, raw string) (*StrmPlayResult, error) {
return s.ResolvePlayTargetWithUA(ctx, raw, "")
}
// ResolvePlayTargetWithUA 与 ResolvePlayTarget 相同,但会把调用方的 User-Agent
// 透传给需要按 UA 换取直链的提供方(115 直链绑定换取时的 UA,换错会被 CDN 拒绝)。
//
// 用途:播放链路在服务端直接把 strm 目标解析成最终直链并 302(见
// StreamService.resolveDirectPlayTargetURL),此时必须带上播放器的 UA,才能拿到
// 与 /api/strm/play 端点一致的、按 UA 分键缓存的那条直链。
func (s *StrmService) ResolvePlayTargetWithUA(ctx context.Context, raw, userAgent string) (*StrmPlayResult, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, errors.New("空播放目标")
@@ -150,14 +160,14 @@ func (s *StrmService) ResolvePlayTarget(ctx context.Context, raw string) (*StrmP
if len(segs) < 1 || strings.TrimSpace(segs[0]) == "" {
return nil, errors.New("无效的 strm 播放地址")
}
return s.ResolvePlay(ctx, segs[0], u.Query())
return s.ResolvePlay(ctx, segs[0], playbackQueryWithUA(u, userAgent))
case strings.HasPrefix(lowerPath, "/api/cloud/play/"):
typ := strings.TrimSpace(strings.TrimPrefix(u.Path, "/api/cloud/play/"))
acct, err := s.firstEnabledAccountOf(ctx, typ)
if err != nil || acct == nil {
return nil, errors.New("没有可用的网盘账号,无法解析直链")
}
q := u.Query()
q := playbackQueryWithUA(u, userAgent)
q.Set("acct", acct.ID)
return s.ResolvePlay(ctx, typ, q)
case u.Scheme == "http" || u.Scheme == "https":
@@ -167,6 +177,19 @@ func (s *StrmService) ResolvePlayTarget(ctx context.Context, raw string) (*StrmP
}
}
// playbackQueryWithUA 复制播放目标的查询串并注入 __ua。ResolvePlay 的云盘提供方
// 据此按播放器 UA 换取直链,与 /api/strm/play 端点写入 __ua 的语义保持一致。
func playbackQueryWithUA(u *url.URL, userAgent string) url.Values {
q := url.Values{}
if u != nil {
q = url.Values(u.Query())
}
if ua := strings.TrimSpace(userAgent); ua != "" {
q.Set("__ua", ua)
}
return q
}
// isLocalPlaybackTarget 报告播放地址是否属于本机。这里没有 HTTP 请求上下文,
// 「本机」由 strm.base_url / 各同步目录覆盖的 base_url / 本机网盘账号共同界定
// (见 isInternalPlaybackTarget)。