diff --git a/internal/handler/playback_extra.go b/internal/handler/playback_extra.go index d7b7fcb..1bde4fb 100644 --- a/internal/handler/playback_extra.go +++ b/internal/handler/playback_extra.go @@ -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{ diff --git a/internal/service/cloud115/url_cache.go b/internal/service/cloud115/url_cache.go index 383dd29..fcc5d5b 100644 --- a/internal/service/cloud115/url_cache.go +++ b/internal/service/cloud115/url_cache.go @@ -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 +} diff --git a/internal/service/cloud115/url_cache_test.go b/internal/service/cloud115/url_cache_test.go index bf5e1bc..b2de90b 100644 --- a/internal/service/cloud115/url_cache_test.go +++ b/internal/service/cloud115/url_cache_test.go @@ -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) + } +} diff --git a/internal/service/stream_direct_redirect_test.go b/internal/service/stream_direct_redirect_test.go index c1fefd2..296e408 100644 --- a/internal/service/stream_direct_redirect_test.go +++ b/internal/service/stream_direct_redirect_test.go @@ -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) diff --git a/internal/service/stream_file.go b/internal/service/stream_file.go index f6793a2..80da6d0 100644 --- a/internal/service/stream_file.go +++ b/internal/service/stream_file.go @@ -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) diff --git a/internal/service/strm_play.go b/internal/service/strm_play.go index e615514..a42c4f6 100644 --- a/internal/service/strm_play.go +++ b/internal/service/strm_play.go @@ -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 } diff --git a/internal/service/strm_proxy_test.go b/internal/service/strm_proxy_test.go index 105cb9a..ba674cc 100644 --- a/internal/service/strm_proxy_test.go +++ b/internal/service/strm_proxy_test.go @@ -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)