diff --git a/gui-test-screenshots/t1_player_danmaku.png b/gui-test-screenshots/t1_player_danmaku.png new file mode 100644 index 0000000..81e4101 Binary files /dev/null and b/gui-test-screenshots/t1_player_danmaku.png differ diff --git a/gui-test-screenshots/t2_danmaku_live.png b/gui-test-screenshots/t2_danmaku_live.png new file mode 100644 index 0000000..eb05935 Binary files /dev/null and b/gui-test-screenshots/t2_danmaku_live.png differ diff --git a/internal/service/cloud/pan115_openapi.go b/internal/service/cloud/pan115_openapi.go index d15e31d..3419358 100644 --- a/internal/service/cloud/pan115_openapi.go +++ b/internal/service/cloud/pan115_openapi.go @@ -85,7 +85,14 @@ func (p *openAPI115Provider) ResolveWithUA(ctx context.Context, fileRef, ua stri if err != nil { return nil, err } - return &DirectLink{URL: url, Proxy: false}, nil + // 115 CDN 防盗链白名单:直链绑定换取时的 User-Agent(调用方 UA 或 + // DefaultUA),后续请求必须携带同一 UA,否则 403。302 播放由客户端 + // 自带 UA 天然满足;服务端直连(弹幕 hash 等)依赖这里的 Headers。 + bound := strings.TrimSpace(ua) + if bound == "" { + bound = cloud115.DefaultUA + } + return &DirectLink{URL: url, Proxy: false, Headers: map[string]string{"User-Agent": bound}}, nil } // OpenClient 暴露底层客户端(token 刷新用)。 diff --git a/internal/service/danmaku_match_test.go b/internal/service/danmaku_match_test.go index 8eefba9..b7c7074 100644 --- a/internal/service/danmaku_match_test.go +++ b/internal/service/danmaku_match_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/require" "github.com/ShukeBta/MMTL/internal/model" + "github.com/ShukeBta/MMTL/internal/service/cloud" ) // overrideDanmakuOfficialBase points the "official" endpoint (match + fallback) @@ -178,14 +179,21 @@ func TestDanmakuFetchHashMissFallsBackToFileNameSearch(t *testing.T) { } // strm:通过解析出的直链 Range 拉 16MB 前缀算 hash → match → 拉弹幕。 +// range server 模拟 115 CDN 防盗链:UA 不匹配直接 403(真实部署中 +// 直链绑定换取时的 UA,不带绑定 UA 拉取会失败,见 pan115_openapi.go)。 func TestDanmakuFetchStrmHashViaDirectLink(t *testing.T) { content := bytes.Repeat([]byte("strm-video-bytes-0123456789"), 400) sum := md5.Sum(content) wantHash := hex.EncodeToString(sum[:]) - var gotRange string + var gotRange, gotUA string rangeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotRange = r.Header.Get("Range") + gotUA = r.Header.Get("User-Agent") + if gotUA != "bound-ua-115" { + w.WriteHeader(http.StatusForbidden) + return + } w.Header().Set("Content-Type", "application/octet-stream") _, _ = w.Write(content) })) @@ -202,7 +210,11 @@ func TestDanmakuFetchStrmHashViaDirectLink(t *testing.T) { var gotProvider string svc.SetStrmResolver(func(_ context.Context, provider string, _ url.Values) (*StrmPlayResult, error) { gotProvider = provider - return &StrmPlayResult{RedirectURL: rangeSrv.URL}, nil + // 播放链路 302 直链 + 绑定的 UA 头(防服务端直连 403)。 + return &StrmPlayResult{ + RedirectURL: rangeSrv.URL, + Link: &cloud.DirectLink{URL: rangeSrv.URL, Headers: map[string]string{"User-Agent": "bound-ua-115"}}, + }, nil }) ctx := context.Background() strmPath := filepath.Join(t.TempDir(), "远程动画.第01话.strm") @@ -220,6 +232,7 @@ func TestDanmakuFetchStrmHashViaDirectLink(t *testing.T) { require.Contains(t, res.Raw, "弹幕Strm命中") require.Equal(t, "115", gotProvider) require.Contains(t, gotRange, "bytes=0-") + require.Equal(t, "bound-ua-115", gotUA) // 防盗链 UA 必须透传 require.Contains(t, seen, `"fileHash":"`+wantHash+`"`) require.Contains(t, seen, `"fileName":"`+url.QueryEscape("远程动画.第01话")+`"`) // strm 的 SizeBytes 是文本大小,不参与 match。 diff --git a/internal/service/danmaku_service.go b/internal/service/danmaku_service.go index db9636a..461be0d 100644 --- a/internal/service/danmaku_service.go +++ b/internal/service/danmaku_service.go @@ -545,6 +545,8 @@ func (s *DanmakuService) hashStrmTarget(ctx context.Context, raw string) (string } src, err = s.strmResolve(ctx, segs[0], u.Query()) if err != nil || src == nil { + s.log.Warn("danmaku strm resolve failed, hash layer skipped", + zap.String("provider", segs[0]), zap.Error(err)) return "", false } case u.Scheme == "http" || u.Scheme == "https": @@ -557,13 +559,20 @@ func (s *DanmakuService) hashStrmTarget(ctx context.Context, raw string) (string case src.LocalPath != "": return s.hashLocalFile(src.LocalPath) case src.RedirectURL != "": - body, err = s.openRangeBody(ctx, src.RedirectURL, nil) + var headers map[string]string + if src.Link != nil { + headers = src.Link.Headers // 115 直链防盗链要求携带绑定 UA + } + body, err = s.openRangeBody(ctx, src.RedirectURL, headers) case src.Link != nil && src.Link.URL != "": body, err = s.openRangeBody(ctx, src.Link.URL, src.Link.Headers) default: return "", false } if err != nil || body == nil { + if err != nil { + s.log.Warn("danmaku hash prefix fetch failed, hash layer skipped", zap.String("target", raw), zap.Error(err)) + } return "", false } defer body.Close() diff --git a/internal/service/strm_play.go b/internal/service/strm_play.go index fdd7239..5aa3067 100644 --- a/internal/service/strm_play.go +++ b/internal/service/strm_play.go @@ -84,7 +84,9 @@ func (s *StrmService) resolveCloudPlay(ctx context.Context, provider string, q u if link.Proxy { return &StrmPlayResult{Link: link, Proxy: true}, nil } - return &StrmPlayResult{RedirectURL: link.URL}, nil + // Link 一并保留:302 处理器只认 RedirectURL,但服务端直连(弹幕 hash + // 拉取)需要 link.Headers 才能通过直链防盗链校验。 + return &StrmPlayResult{RedirectURL: link.URL, Link: link}, nil } // resolveLocalPlay 本地源:校验路径位于某个本地同步目录的源目录内。 diff --git a/server.exe.old b/server.exe.old new file mode 100644 index 0000000..ebf5f23 Binary files /dev/null and b/server.exe.old differ diff --git a/server.exe.old2 b/server.exe.old2 new file mode 100644 index 0000000..829885c Binary files /dev/null and b/server.exe.old2 differ diff --git a/web/src/pages/PlayerVideoStage.tsx b/web/src/pages/PlayerVideoStage.tsx index c7dc125..81e706b 100644 --- a/web/src/pages/PlayerVideoStage.tsx +++ b/web/src/pages/PlayerVideoStage.tsx @@ -66,19 +66,50 @@ export function PlayerVideoStage({ // 把用户选择的字幕轨道应用到