This commit is contained in:
truewhile
2026-10-18 22:30:02 +08:00
parent 4f19e2210e
commit ca9a80b862
7 changed files with 475 additions and 21 deletions
+5
View File
@@ -80,6 +80,11 @@ func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
// stream_url 跳转,行为与改动前一致。
directURL, hasDirectURL := "", false
if svc.Stream != nil {
// `?refresh=1`:客户端手上的直链已失效(CDN 403),要求丢掉缓存
// 重新换链,别把同一条死链再发一次。
if strings.EqualFold(strings.TrimSpace(c.Query("refresh")), "1") {
service.InvalidateMediaLinkCache(m)
}
directURL, hasDirectURL = svc.Stream.ResolveDirectPlayURL(c.Request, m)
}
payload := gin.H{
+89 -10
View File
@@ -1,26 +1,34 @@
// 下载直链进程级缓存。
//
// 115 的 /open/ufile/downurl 换取结果在一段时间内有效,且换取请求是
// WAF 风控重点关注的密集接口。缓存按 pickcode 存直链、避免对同一文件
// 反复换取;下载失败(http 403/404 等)时由调用方主动清除对应条目,
// 下一轮重试会重新换取链接。
// 115 的 /open/ufile/downurl 换取结果带 `t=` 过期时间戳(实测为「换取时刻 +
// 1800 秒」,即 30 分钟),且换取请求是 WAF 风控重点关注的密集接口。缓存按
// pickcode 存直链、避免对同一文件反复换取;下载失败(http 403/404 等)时由
// 调用方主动清除对应条目,下一轮重试会重新换取链接。
package cloud115
import (
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// downloadURLCacheTTL 直链缓存时长。115 直链一般有效 1 小时左右,
// 取 45 分钟留出余量。QMediaSync 同类缓存使用 50 分钟。
const downloadURLCacheTTL = 45 * time.Minute
// downloadURLCacheMaxTTL 直链缓存上限:仅在解析不出直链自带过期时间(`t=`)
// 时使用。115 直链实际有效 30 分钟,这里取 45 分钟只是兜底上限,正常情况下
// 由 linkExpirySafety 提前失效,避免把已经过期的直链发给客户端。
const downloadURLCacheMaxTTL = 45 * time.Minute
// linkExpirySafety 提前量:直链自身的过期时间戳到达前就停止使用,留出客户端
// 建连与传输的余量,防止出现「服务端认为还有效、CDN 已经 403」的窗口。
const linkExpirySafety = 2 * time.Minute
// maxCachedURLs 超过该数量时顺带清理过期条目,防止 map 无限膨胀。
const maxCachedURLs = 10000
type urlCacheEntry struct {
url string
createdAt time.Time
expiresAt time.Time
}
@@ -37,6 +45,43 @@ func urlCacheKey(pickCode, ua string) string {
return pickCode + "@" + ua
}
// LinkExpiry 解析 115 直链自带的过期时间戳(`t=` 参数,单位秒)。
// 解析不出时返回零值,调用方按兜底 TTL 处理。
func LinkExpiry(rawURL string) time.Time {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || u == nil {
return time.Time{}
}
raw := strings.TrimSpace(u.Query().Get("t"))
if raw == "" {
return time.Time{}
}
sec, err := strconv.ParseInt(raw, 10, 64)
if err != nil || sec <= 0 {
return time.Time{}
}
return time.Unix(sec, 0)
}
// cacheExpiry 计算写入缓存时应使用的过期时刻:跟随直链自身的 `t=` 并提前
// linkExpirySafety,解析不出时退回 downloadURLCacheMaxTTL。返回零值表示这条
// 直链已经过期,不应写入缓存。
func cacheExpiry(rawURL string, now time.Time) time.Time {
maxExpiry := now.Add(downloadURLCacheMaxTTL)
linkExpiry := LinkExpiry(rawURL)
if linkExpiry.IsZero() {
return maxExpiry
}
expiry := linkExpiry.Add(-linkExpirySafety)
if !expiry.After(now) {
return time.Time{}
}
if expiry.After(maxExpiry) {
return maxExpiry
}
return expiry
}
// GetDownloadURLCache 返回未过期的缓存直链;不存在或已过期返回空串。
func GetDownloadURLCache(pickCode string, uas ...string) string {
if pickCode == "" {
@@ -59,11 +104,17 @@ func GetDownloadURLCache(pickCode string, uas ...string) string {
return entry.url
}
// SetDownloadURLCache 写入直链缓存。
// SetDownloadURLCache 写入直链缓存。过期时刻跟随直链自身的 `t=`,已经过期的
// 直链直接丢弃,避免把死链缓存下来。
func SetDownloadURLCache(pickCode string, url string, uas ...string) {
if pickCode == "" || url == "" {
return
}
now := time.Now()
expiresAt := cacheExpiry(url, now)
if expiresAt.IsZero() {
return
}
ua := ""
if len(uas) > 0 {
ua = uas[0]
@@ -71,7 +122,6 @@ func SetDownloadURLCache(pickCode string, url string, uas ...string) {
urlCacheMu.Lock()
defer urlCacheMu.Unlock()
if len(urlCache) >= maxCachedURLs {
now := time.Now()
for k, e := range urlCache {
if now.After(e.expiresAt) {
delete(urlCache, k)
@@ -79,7 +129,7 @@ func SetDownloadURLCache(pickCode string, url string, uas ...string) {
}
}
key := urlCacheKey(pickCode, ua)
urlCache[key] = urlCacheEntry{url: url, expiresAt: time.Now().Add(downloadURLCacheTTL)}
urlCache[key] = urlCacheEntry{url: url, createdAt: now, expiresAt: expiresAt}
}
// ClearDownloadURLCache 删除指定 pickcode 的所有缓存(下载得到非 2xx 时调用)。
@@ -95,3 +145,32 @@ func ClearDownloadURLCache(pickCode string) {
}
}
}
// ClearDownloadURLCacheIfOlderThan 删除指定 pickcode 下「已经存在超过 minAge」
// 的缓存条目,返回是否删除了至少一条。
//
// 客户端上报「直链被 CDN 拒绝」时用它强制换链:既让下一次解析拿到新地址,
// 又不会让同一秒内的重试风暴把 115 换链接口打出突发。
func ClearDownloadURLCacheIfOlderThan(pickCode string, minAge time.Duration) bool {
if pickCode == "" {
return false
}
if minAge < 0 {
minAge = 0
}
now := time.Now()
removed := false
urlCacheMu.Lock()
defer urlCacheMu.Unlock()
for k, e := range urlCache {
if k != pickCode && !strings.HasPrefix(k, pickCode+"@") {
continue
}
if now.Sub(e.createdAt) < minAge {
continue
}
delete(urlCache, k)
removed = true
}
return removed
}
@@ -1,6 +1,7 @@
package cloud115
import (
"fmt"
"testing"
"time"
)
@@ -29,3 +30,72 @@ func TestDownloadURLCache(t *testing.T) {
t.Fatalf("cleared entry should be a miss, got %q", got)
}
}
// 115 直链自带 `t=` 过期时间戳(实测为换取时刻 + 1800 秒)。缓存必须跟随它,
// 否则会出现「服务端认为还有效、CDN 已经 403」的窗口:直链被缓存到 45 分钟,
// 但第 30 分钟起客户端就必然拿到 403。
func TestDownloadURLCacheExpiryFollowsLinkTimestamp(t *testing.T) {
now := time.Now()
link := fmt.Sprintf("https://cdn/1/a.mkv?t=%d&u=1&k=abc", now.Add(30*time.Minute).Unix())
got := cacheExpiry(link, now)
want := now.Add(30*time.Minute - linkExpirySafety)
if diff := got.Sub(want); diff > time.Second || diff < -time.Second {
t.Fatalf("cacheExpiry = %v, want %v", got, want)
}
// 解析不出 t= 时退回兜底 TTL,保持旧行为。
fallback := cacheExpiry("https://cdn/1/a.mkv", now)
if diff := fallback.Sub(now.Add(downloadURLCacheMaxTTL)); diff > time.Second || diff < -time.Second {
t.Fatalf("fallback cacheExpiry = %v, want %v", fallback, now.Add(downloadURLCacheMaxTTL))
}
// 直链本身已经过期(或只剩不足提前量的寿命)时不进缓存。
if got := cacheExpiry(fmt.Sprintf("https://cdn/1/a.mkv?t=%d", now.Add(-time.Minute).Unix()), now); !got.IsZero() {
t.Fatalf("expired link must not be cached, got expiry %v", got)
}
if got := cacheExpiry(fmt.Sprintf("https://cdn/1/a.mkv?t=%d", now.Add(time.Minute).Unix()), now); !got.IsZero() {
t.Fatalf("link expiring inside the safety margin must not be cached, got %v", got)
}
}
// 客户端上报「直链被 CDN 拒绝」时按 pickcode 清缓存,但要挡住刚写入的条目,
// 避免重试风暴把 115 换链接口打出突发。
func TestClearDownloadURLCacheIfOlderThan(t *testing.T) {
urlCacheMu.Lock()
urlCache = map[string]urlCacheEntry{}
urlCacheMu.Unlock()
t.Cleanup(func() {
urlCacheMu.Lock()
urlCache = map[string]urlCacheEntry{}
urlCacheMu.Unlock()
})
future := time.Now().Add(30 * time.Minute)
link := fmt.Sprintf("https://cdn/1/a.mkv?t=%d", future.Unix())
SetDownloadURLCache("pick-fresh", link, "ua-a")
if ClearDownloadURLCacheIfOlderThan("pick-fresh", time.Minute) {
t.Fatal("fresh entry must survive the min-age guard")
}
if got := GetDownloadURLCache("pick-fresh", "ua-a"); got != link {
t.Fatalf("fresh entry lost, got %q", got)
}
SetDownloadURLCache("pick-old", link, "ua-a")
urlCacheMu.Lock()
entry := urlCache[urlCacheKey("pick-old", "ua-a")]
entry.createdAt = time.Now().Add(-10 * time.Minute)
urlCache[urlCacheKey("pick-old", "ua-a")] = entry
urlCacheMu.Unlock()
if !ClearDownloadURLCacheIfOlderThan("pick-old", time.Minute) {
t.Fatal("stale entry should be cleared")
}
if got := GetDownloadURLCache("pick-old", "ua-a"); got != "" {
t.Fatalf("stale entry should be gone, got %q", got)
}
// 无关 pickcode 不受影响。
if got := GetDownloadURLCache("pick-fresh", "ua-a"); got != link {
t.Fatalf("unrelated pickcode lost, got %q", got)
}
}
@@ -3,11 +3,13 @@ package service
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"go.uber.org/zap"
@@ -15,6 +17,7 @@ import (
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service/cloud"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
// directRedirectTestRepo 建一个带网盘账号表的库:normalizeCloudPlayTarget 需要
@@ -145,6 +148,53 @@ func TestServeFileFallsBackWhenDirectResolveUnavailable(t *testing.T) {
}
}
// `?refresh=1`:客户端手上的直链已被 CDN 403,服务端必须丢掉缓存重新换链,
// 不能把同一条死链再发一次;不带该参数时缓存照旧命中,保住快路径。
func TestServeFileRefreshDropsCachedDirectLink(t *testing.T) {
repos := directRedirectTestRepo(t)
seedCloudSTRMMedia(t, repos, "cloud-refresh", "/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc-refresh")
cached := fmt.Sprintf("https://cdn.example/stale.mkv?t=%d", time.Now().Add(30*time.Minute).Unix())
fresh := "https://cdn.example/fresh.mkv?t=2"
cloud115.SetDownloadURLCache("pc-refresh", cached, "RodelPlayer/2.0")
t.Cleanup(func() { cloud115.ClearDownloadURLCache("pc-refresh") })
oldMinAge := directLinkRefreshMinAge
directLinkRefreshMinAge = 0
defer func() { directLinkRefreshMinAge = oldMinAge }()
run := func(t *testing.T, query string) (cachedAtResolve, location string) {
t.Helper()
svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil).
SetStrmPlayTargetResolver(func(_ context.Context, _ string, userAgent string) (*StrmPlayResult, error) {
cachedAtResolve = cloud115.GetDownloadURLCache("pc-refresh", userAgent)
return &StrmPlayResult{
RedirectURL: fresh,
Link: &cloud.DirectLink{URL: fresh, Headers: map[string]string{"User-Agent": userAgent}},
}, nil
})
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/cloud-refresh?token=jwt123"+query, nil)
req.Header.Set("User-Agent", "RodelPlayer/2.0")
w := httptest.NewRecorder()
if err := svc.ServeFile(w, req, "cloud-refresh"); err != nil {
t.Fatalf("ServeFile: %v", err)
}
return cachedAtResolve, w.Header().Get("Location")
}
// 正常播放:缓存里的直链照旧命中,服务端不该白跑一次换链。
if got, loc := run(t, ""); got != cached {
t.Fatalf("cached link = %q, want the cached %q", got, cached)
} else if loc != fresh {
t.Fatalf("Location = %q, want the resolved direct link", loc)
}
// 客户端报告直链失效:换链前必须先清掉缓存。
if got, loc := run(t, "&refresh=1"); got != "" {
t.Fatalf("cached link must be dropped before resolving, got %q", got)
} else if loc != fresh {
t.Fatalf("Location = %q, want the freshly resolved direct link", loc)
}
}
// 没有注入解析器(测试/精简部署)时保持原有跳转,不受本次优化影响。
func TestServeFileKeepsSTRMEndpointWithoutResolver(t *testing.T) {
repos := directRedirectTestRepo(t)
+47
View File
@@ -10,6 +10,7 @@ import (
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
// ServeFile streams the file backing the given media ID using
@@ -22,6 +23,47 @@ func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaI
return s.ServeFileWithCloudMode(w, r, mediaID, "")
}
// PickCodeFromPlayTarget 从 /api/strm/play/... 形式的播放目标里取出 pickcode。
// 115 直链缓存按 pickcode 分键,客户端要求强制换链时用它定位缓存条目。
func PickCodeFromPlayTarget(raw string) string {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u == nil {
return ""
}
return strings.TrimSpace(u.Query().Get("pickcode"))
}
// directLinkRefreshMinAge 是「强制换链」的最小间隔:缓存条目刚写入不久就
// 收到换链请求时忽略它,避免客户端重试风暴把 115 换链接口打出突发。
// 测试会临时改成 0,让「CDN 拒绝 → 立刻换链」的路径可验证。
var directLinkRefreshMinAge = 30 * time.Second
// wantsFreshLink 判断客户端是否要求跳过直链缓存重新换链(`?refresh=1`)。
//
// 115 直链只有 30 分钟有效期,客户端拿到过期直链被 CDN 403 后需要一条新链;
// 正常路径下缓存会先于直链过期,这里是客户端上报失败时的兜底。
func wantsFreshLink(r *http.Request) bool {
if r == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("refresh"))) {
case "1", "true", "yes":
return true
default:
return false
}
}
// InvalidateMediaLinkCache 清除媒体播放目标对应的云盘直链缓存,让下一次解析
// 重新换链。非云盘/无 pickcode 的媒体是空操作。
func InvalidateMediaLinkCache(m *model.Media) {
pickCode := PickCodeFromPlayTarget(MediaSTRMTarget(m))
if pickCode == "" {
return
}
cloud115.ClearDownloadURLCacheIfOlderThan(pickCode, directLinkRefreshMinAge)
}
// MediaSTRMTarget 返回媒体行固化的 STRM 播放目标。STRMURL 为空时回读本地
// .strm 文件内容兜底(扫描时内容解析失败的行只剩 Container=strm + Path),
// 仍拿不到返回空串。
@@ -57,6 +99,11 @@ func (s *StreamService) ServeFileWithCloudMode(w http.ResponseWriter, r *http.Re
// 能在服务端换到最终直链就直接 302 过去:客户端少跟随一次 302,等于
// 省掉一次「DNS+TCP+TLS+请求」的往返。解析结果同时写进 strm 层直链
// 缓存,后续 /api/strm/play 请求直接命中。
// `?refresh=1` 表示客户端手上的直链已经失效(被 CDN 403),要求丢掉
// 缓存重新换链。
if wantsFreshLink(r) {
InvalidateMediaLinkCache(m)
}
if direct, ok := s.resolveDirectPlayTargetURL(r, strmURL); ok {
setCloudRedirectNoStore(w)
http.Redirect(w, r, direct, http.StatusFound)
+82 -11
View File
@@ -16,6 +16,7 @@ import (
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service/cloud"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
// ErrStrmPlayNotFound 表示 strm 播放目标不存在(handler 返回 404)。
@@ -250,21 +251,87 @@ func (s *StrmService) ProxyMediaDirect(ctx context.Context, w http.ResponseWrite
if err != nil {
return err
}
switch {
case result.Link != nil && result.Link.URL != "":
return s.ProxyDirect(ctx, w, r, result.Link)
case result.RedirectURL != "":
return s.ProxyDirect(ctx, w, r, &cloud.DirectLink{URL: result.RedirectURL})
default:
link := directLinkOfPlayResult(result)
if link == nil {
// 本地文件(LocalPath)由静态文件处理器提供,本身就是同源。
return ErrStrmProxyNotApplicable
}
resp, method, err := s.openDirectUpstream(ctx, r, link)
if err != nil {
return err
}
// 直链被 CDN 拒绝:115 直链带 30 分钟过期时间戳,缓存命中也可能拿到已经
// 失效的地址。这里清掉缓存重新换链再试一次,避免客户端只看到 403。
if directLinkRejected(resp.StatusCode) {
_ = resp.Body.Close()
if fresh := s.refreshDirectLink(ctx, raw); fresh != nil {
if retry, retryMethod, retryErr := s.openDirectUpstream(ctx, r, fresh); retryErr == nil {
resp, method = retry, retryMethod
}
}
}
defer resp.Body.Close()
return writeDirectUpstream(w, resp, method)
}
// directLinkOfPlayResult 取出可反向代理的直链;本地文件返回 nil。
func directLinkOfPlayResult(result *StrmPlayResult) *cloud.DirectLink {
if result == nil {
return nil
}
switch {
case result.Link != nil && result.Link.URL != "":
return result.Link
case result.RedirectURL != "":
return &cloud.DirectLink{URL: result.RedirectURL}
default:
return nil
}
}
// directLinkRejected 判断上游状态码是否属于「直链本身失效」(而不是文件/权限
// 之类与链接无关的问题)。
func directLinkRejected(status int) bool {
switch status {
case http.StatusForbidden, http.StatusNotFound, http.StatusGone:
return true
default:
return false
}
}
// refreshDirectLink 丢弃当前直链缓存并重新换链,返回新的直链;无法换链时返回
// nil,调用方继续用原来那条。
func (s *StrmService) refreshDirectLink(ctx context.Context, raw string) *cloud.DirectLink {
pickCode := PickCodeFromPlayTarget(raw)
if pickCode == "" {
return nil
}
if !cloud115.ClearDownloadURLCacheIfOlderThan(pickCode, directLinkRefreshMinAge) {
// 缓存刚写入不久:说明失败与链接过期无关,重换一次也会拿到同一条。
return nil
}
fresh, err := s.ResolvePlayTarget(ctx, raw)
if err != nil {
return nil
}
return directLinkOfPlayResult(fresh)
}
// ProxyDirect 反向代理渲染直链内容(保留 Range 请求头以支持拖动播放)。
func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, link *cloud.DirectLink) error {
resp, method, err := s.openDirectUpstream(ctx, r, link)
if err != nil {
return err
}
defer resp.Body.Close()
return writeDirectUpstream(w, resp, method)
}
// openDirectUpstream 向网盘直链发起一次请求,保留 Range 与 UA 语义。
func (s *StrmService) openDirectUpstream(ctx context.Context, r *http.Request, link *cloud.DirectLink) (*http.Response, string, error) {
if link == nil || link.URL == "" {
return errors.New("空直链")
return nil, "", errors.New("空直链")
}
method := http.MethodGet
if r != nil && r.Method == http.MethodHead {
@@ -272,7 +339,7 @@ func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r
}
req, err := http.NewRequestWithContext(ctx, method, link.URL, nil)
if err != nil {
return err
return nil, "", err
}
for k, v := range link.Headers {
req.Header.Set(k, v)
@@ -288,9 +355,13 @@ func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r
}
resp, err := s.streamHTTP.Do(req)
if err != nil {
return err
return nil, "", err
}
defer resp.Body.Close()
return resp, method, nil
}
// writeDirectUpstream 把上游响应原样写给客户端。
func writeDirectUpstream(w http.ResponseWriter, resp *http.Response, method string) error {
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified"} {
if value := resp.Header.Get(header); value != "" {
w.Header().Set(header, value)
@@ -310,7 +381,7 @@ func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r
if flusher, ok := w.(http.Flusher); ok {
writer = &flushWriter{writer: w, flusher: flusher}
}
_, err = io.Copy(writer, resp.Body)
_, err := io.Copy(writer, resp.Body)
return err
}
+132
View File
@@ -3,12 +3,16 @@ package service
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"sync"
"testing"
"time"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
// 同源转发必须原样保留 Range 语义:把 206 改写成 200 会让浏览器误判响应长度,
@@ -55,6 +59,134 @@ func TestProxyMediaDirectForwardsRangeAndStatus(t *testing.T) {
}
}
// 115 直链只有 30 分钟寿命,缓存命中也可能拿到已经失效的地址。同源转发遇到
// CDN 403 时必须丢掉缓存重新换链再试一次,而不是把 403 直接透给客户端。
func TestProxyMediaDirectRetriesWithFreshLinkWhenCDNRejects(t *testing.T) {
svc := testStrmService(t)
acct := &model.StrmAccount{Name: "fake115", Provider: model.StrmProvider115, Config: "{}", Enabled: true}
if err := svc.repo.StrmAccount.Create(context.Background(), acct); err != nil {
t.Fatal(err)
}
cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/fresh.mkv" {
w.WriteHeader(http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "video/x-matroska")
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write([]byte("fresh-bytes"))
}))
defer cdn.Close()
// 第一次换链给出「已过期」的旧地址,重新换链才给新地址。
var mu sync.Mutex
var calls int
expiry := time.Now().Add(30 * time.Minute).Unix()
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
calls++
attempt := calls
mu.Unlock()
link := cdn.URL + "/old.mkv"
if attempt > 1 {
link = cdn.URL + "/fresh.mkv"
}
_, _ = fmt.Fprintf(w, `{"state":true,"data":{"1":{"pick_code":"pc-retry","url":{"url":%q}}}}`,
fmt.Sprintf("%s?t=%d&u=1&k=abc", link, expiry))
}))
defer api.Close()
oldPro := cloud115.ProAPIBase
cloud115.ProAPIBase = api.URL
defer func() { cloud115.ProAPIBase = oldPro }()
defer cloud115.ClearDownloadURLCache("pc-retry")
oldMinAge := directLinkRefreshMinAge
directLinkRefreshMinAge = 0
defer func() { directLinkRefreshMinAge = oldMinAge }()
media := &model.Media{
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=" + acct.ID + "&pickcode=pc-retry",
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?proxy=1", nil)
req.Header.Set("User-Agent", "MeBoxTest/1.0")
if err := svc.ProxyMediaDirect(context.Background(), rec, req, media); err != nil {
t.Fatalf("ProxyMediaDirect: %v", err)
}
if rec.Code != http.StatusPartialContent {
t.Fatalf("status = %d, want 206 (body=%s)", rec.Code, rec.Body.String())
}
if rec.Body.String() != "fresh-bytes" {
t.Fatalf("body = %q, want the fresh link payload", rec.Body.String())
}
mu.Lock()
defer mu.Unlock()
if calls != 2 {
t.Fatalf("downurl calls = %d, want 2 (one retry after the CDN rejected the stale link)", calls)
}
}
// 换链后依然是同一条失效直链时,保持原有行为:原样透传 CDN 的状态码。
func TestProxyMediaDirectPassesRejectedStatusWhenRefreshCannotHelp(t *testing.T) {
svc := testStrmService(t)
acct := &model.StrmAccount{Name: "fake115", Provider: model.StrmProvider115, Config: "{}", Enabled: true}
if err := svc.repo.StrmAccount.Create(context.Background(), acct); err != nil {
t.Fatal(err)
}
cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer cdn.Close()
expiry := time.Now().Add(30 * time.Minute).Unix()
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, `{"state":true,"data":{"1":{"pick_code":"pc-dead","url":{"url":%q}}}}`,
fmt.Sprintf("%s/dead.mkv?t=%d&u=1&k=abc", cdn.URL, expiry))
}))
defer api.Close()
oldPro := cloud115.ProAPIBase
cloud115.ProAPIBase = api.URL
defer func() { cloud115.ProAPIBase = oldPro }()
defer cloud115.ClearDownloadURLCache("pc-dead")
oldMinAge := directLinkRefreshMinAge
directLinkRefreshMinAge = 0
defer func() { directLinkRefreshMinAge = oldMinAge }()
media := &model.Media{
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=" + acct.ID + "&pickcode=pc-dead",
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?proxy=1", nil)
if err := svc.ProxyMediaDirect(context.Background(), rec, req, media); err != nil {
t.Fatalf("ProxyMediaDirect: %v", err)
}
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want the upstream 403 passed through", rec.Code)
}
}
// 强制换链只认 /api/strm/play 形式播放目标里的 pickcode。
func TestPickCodeFromPlayTarget(t *testing.T) {
cases := []struct {
raw string
want string
}{
{"/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1", "pc1"},
{"https://nas.local/api/strm/play/cloud115/video.mkv?pickcode=pc2&acct=a1", "pc2"},
{"/api/strm/play/cloud115/video.mkv?acct=a1", ""},
{"https://cdn.example.test/Movie.mkv?sign=1", ""},
{"", ""},
}
for _, tc := range cases {
if got := PickCodeFromPlayTarget(tc.raw); got != tc.want {
t.Fatalf("PickCodeFromPlayTarget(%q) = %q, want %q", tc.raw, got, tc.want)
}
}
}
// 本地文件本身就是同源资源,不应该被代理(调用方按原静态文件逻辑处理)。
func TestProxyMediaDirectSkipsLocalFile(t *testing.T) {
svc := testStrmService(t)