diff --git a/drivers/baidu_share2/dlink.go b/drivers/baidu_share2/dlink.go new file mode 100644 index 000000000..2b5f77789 --- /dev/null +++ b/drivers/baidu_share2/dlink.go @@ -0,0 +1,291 @@ +package baidu_share + +import ( + "crypto/sha1" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "github.com/OpenListTeam/OpenList/v4/drivers/baidu_netdisk" + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/OpenListTeam/OpenList/v4/internal/cache" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/op" + "github.com/OpenListTeam/OpenList/v4/pkg/cookie" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" + log "github.com/sirupsen/logrus" +) + +// 免转存直链: +// 用账号 BDUSS 做 DLNA 签名,直接调 /share/list?origin=dlna 拿分享文件 dlink, +// 跟一次 302 得 d.pcs.baidu.com 上的最终 CDN 直链。不把文件转存进任何个人账号。 +// 最终直链仅凭 DLNA UA 即可播:免 Cookie、Range 完美、不限速、无 100 秒试看限制。 +// 失败时返回 error,交由 driver.Link() 回退到转存(save+delete)兜底。 +const ( + DLNAUA = "netdisk;P2SP;2.2.91.136;android-android;" + baiduDevUID = "73CED981D0F186D12BC18CAE1684FFD5|VSRCQTF6W" + baiduChannel = "android_12_zhao_bd-netdisk_1024266h" + baiduVersion = "11.30.2" + baiduSaltA = "ebrcUYiuxaZv2XGu7KIYKxUrqfnOfpDF" + baiduSaltB = baiduDevUID + baiduVersion + "ae5821440fab5e1a61a025f014bd8972" + + baiduWebUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36" + // 登录账号 uid(签名要用),从 mbd 接口解析。fields=["uid"] 已 URL 编码。 + baiduUIDURL = "https://mbd.baidu.com/userx/v1/info/get?appname=baiduboxapp&fields=%5B%22uid%22%5D&client&clientfrom&lang=zh-cn&tpl&ttt" +) + +var uidRegexp = regexp.MustCompile(`"uid"\s*:\s*"?([0-9]+)"?`) + +// baiduUIDCache 按账号 ID 缓存 uid(签名用),避免每次取链都打 mbd 接口。 +var baiduUIDCache = cache.NewKeyedCache[string](30 * time.Minute) + +func baiduSha1(s string) string { + h := sha1.Sum([]byte(s)) + return hex.EncodeToString(h[:]) +} + +// 签名: sha1( sha1(BDUSS) + uid + saltA + time + saltB )。 +func baiduDlnaRand(bduss, uid, t string) string { + return baiduSha1(baiduSha1(bduss) + uid + baiduSaltA + t + baiduSaltB) +} + +// baiduDlnaSekey 把 sekey 归一化为「单次 URL 编码」形态,再交由 resty 的 SetQueryParam 编码一次上链。 +// d.Token 来源不一:无提取码分享取自 BDCLND cookie(已 URL 编码,含 %),带提取码分享取自 verify 的 randsk +// (原始 base64,字符集 [A-Za-z0-9+/=],不含 %)。/share/list?origin=dlna 期望接收编码后的 sekey。 +// 故:已编码(含 %)原样用;原始 randsk 用 QueryEscape 编码一次。两种来源上链后服务端拿到一致的编码 sekey。 +// 注意:不能用 QueryUnescape 归一化——它把 randsk 里的字面 '+' 当作空格,破坏 base64 的 '+'。 +func baiduDlnaSekey(token string) string { + if strings.Contains(token, "%") { + return token + } + return url.QueryEscape(token) +} + +// fetchBaiduUID 用 web UA + 账号 Cookie 从 mbd 接口取当前登录账号 uid(签名用),按账号缓存。 +func fetchBaiduUID(bd *baidu_netdisk.BaiduNetdisk) (string, error) { + key := fmt.Sprintf("%v", bd.ID) + if uid, ok := baiduUIDCache.Get(key); ok && uid != "" { + return uid, nil + } + resp, err := base.NoRedirectClient.R(). + SetHeaders(map[string]string{ + "User-Agent": baiduWebUA, + "Accept": "application/json, text/plain, */*", + }). + SetHeader("Cookie", bd.Cookie). + Get(baiduUIDURL) + if err != nil { + return "", fmt.Errorf("百度原画(无限) uid 请求失败: %w", err) + } + m := uidRegexp.FindStringSubmatch(resp.String()) + uid := "" + if len(m) >= 2 { + uid = m[1] + } + if uid == "" { + return "", errors.New("百度原画(无限) 未能解析 uid,请检查 Cookie") + } + baiduUIDCache.Set(key, uid) + return uid, nil +} + +// pickDlink 深度遍历响应体,找 dlink/downloadurl/download_url/url 字段中的 http 直链。 +// 严格模式只认这几个字段;找不到再退化为任意 http 字符串(对齐 JS baiduPickHttpUrl)。 +func pickDlink(body []byte) string { + var v interface{} + if err := json.Unmarshal(body, &v); err != nil { + return "" + } + if s := walkDlink(v, true); s != "" { + return s + } + return walkDlink(v, false) +} + +func walkDlink(node interface{}, strict bool) string { + isHTTP := func(s string) bool { + return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") + } + switch n := node.(type) { + case []interface{}: + for _, item := range n { + if f := walkDlink(item, strict); f != "" { + return f + } + } + case map[string]interface{}: + for k, val := range n { + if s, ok := val.(string); ok { + lk := strings.ToLower(k) + nameHit := lk == "dlink" || lk == "downloadurl" || lk == "download_url" || lk == "url" + if (!strict || nameHit) && isHTTP(s) { + return s + } + } + if f := walkDlink(val, strict); f != "" { + return f + } + } + case string: + if !strict && isHTTP(n) { + return n + } + } + return "" +} + +// followDlnaRedirect 一次性跟随一层 302,把 dlink 换成 d.pcs.baidu.com 上的最终直链。 +// 跟不到(非 302 / 无 Location)返回空串,由调用方用原 dlink 兜底(原 dlink 本身也可播)。 +func followDlnaRedirect(dlink string) string { + resp, err := base.NoRedirectClient.R(). + SetHeader("User-Agent", DLNAUA). + Get(dlink) + if err != nil { + return "" + } + return resp.Header().Get("Location") +} + +// mergeCookies 把响应 Set-Cookie 合并进 cookie 字符串(同名覆盖),用于跨请求保持账号+分享会话。 +func mergeCookies(base string, cs []*http.Cookie) string { + out := base + for _, c := range cs { + out = cookie.SetStr(out, c.Name, c.Value) + } + return out +} + +// fetchFreshSekey 每次取链时,用账号 Cookie 开分享页拿新鲜的 BDCLND(sekey)。 +// 关键:必须用账号 Cookie 开页/verify,使 BDCLND 会话与 DLNA 签名所用账号同源 +// (参考 cloud-drive.js: baiduOpenSharePage/baiduVerifySharePassword 均带 accountCookie)。 +// 带提取码先 /share/verify 建立会话,响应 cookie 合并后带入分享页 GET。 +// 返回 BDCLND(URL 编码形态);拿不到返回错误,交由调用方回退 d.Token。 +func (d *BaiduShare2) fetchFreshSekey(accountCookie string) (string, error) { + hdr := accountCookie + if d.Pwd != "" { + verifyResp := struct { + Errno int64 `json:"errno"` + }{} + res, err := d.client.R(). + SetHeader("Cookie", hdr). + SetFormData(map[string]string{"pwd": d.Pwd}). + SetHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8"). + SetResult(&verifyResp). + Post("/share/verify?channel=chunlei&clienttype=0&web=1&app_id=250528&surl=" + d.Surl[1:]) + if err != nil { + return "", fmt.Errorf("分享验证请求失败: %w", err) + } + if verifyResp.Errno != 0 { + return "", fmt.Errorf("分享验证失败(errno=%d): %s", verifyResp.Errno, res.String()) + } + hdr = mergeCookies(hdr, res.Cookies()) + } + res, err := d.client.R().SetHeader("Cookie", hdr).Get("/s/" + d.Surl) + if err != nil { + return "", fmt.Errorf("分享页请求失败: %w", err) + } + hdr = mergeCookies(hdr, res.Cookies()) + if bdclnd := cookie.GetStr(hdr, "BDCLND"); bdclnd != "" { + return bdclnd, nil + } + return "", errors.New("未能获取分享 BDCLND") +} + +// resolveShareDirectLink 免转存取链:DLNA 签名接口直接换分享 dlink → 302 到 CDN,不转存。 +// 需账号 Cookie 里的 BDUSS + 分享专属 sekey(d.Token)。声明为 var 以便测试替换。 +var resolveShareDirectLink = func(d *BaiduShare2, file model.Obj) (*model.Link, error) { + storage := op.GetFirstDriver("BaiduNetdisk", idx) + idx++ + if storage == nil { + return nil, errors.New("找不到百度网盘帐号") + } + bd := storage.(*baidu_netdisk.BaiduNetdisk) + + bduss := cookie.GetStr(bd.Cookie, "BDUSS") + if bduss == "" { + return nil, errors.New("百度 Cookie 缺少 BDUSS,免转存不可用") + } + if d.ShareId == "" || d.ShareUk == "" || d.Token == "" { + if err := d.Validate(); err != nil { + return nil, err + } + } + // sekey 优先每次用账号 Cookie 开分享页取新鲜 BDCLND(防过期/形态不一致/会话不同源); + // 失败回退 d.Token(再经 baiduDlnaSekey 归一化)。 + sekey, serr := d.fetchFreshSekey(bd.Cookie) + sekeyFresh := serr == nil && sekey != "" + if !sekeyFresh { + log.Warnf("获取新鲜 BDCLND 失败,回退 d.Token: %v", serr) + sekey = d.Token + } + uid, err := fetchBaiduUID(bd) + if err != nil { + return nil, err + } + t := strconv.FormatInt(time.Now().UnixMilli(), 10) + rand := baiduDlnaRand(bduss, uid, t) + + // DLNA 请求带「账号 Cookie + BDCLND(sekey)」,与签名账号同源(参考 JS baiduDlnaHeaders 用合并 cookie)。 + dlnaCookie := cookie.SetStr(bd.Cookie, "BDCLND", sekey) + res, err := d.client.R(). + SetHeader("User-Agent", DLNAUA). + SetHeader("Accept", "application/json, text/plain, */*"). + SetHeader("Cookie", dlnaCookie). + SetQueryParams(map[string]string{ + "shareid": d.ShareId, + "uk": d.ShareUk, + "fid": file.GetID(), + "sekey": baiduDlnaSekey(sekey), + "origin": "dlna", + "devuid": baiduDevUID, + "clienttype": "1", + "channel": baiduChannel, + "version": baiduVersion, + "time": t, + "rand": rand, + }). + Get("/share/list") + if err != nil { + return nil, fmt.Errorf("百度原画(无限) share/list 请求失败: %w", err) + } + body := res.Body() + errno := utils.Json.Get(body, "errno").ToInt() + if errno == 0 { + errno = utils.Json.Get(body, "error_code").ToInt() + } + if errno != 0 { + msg := utils.Json.Get(body, "show_msg").ToString() + if msg == "" { + msg = utils.Json.Get(body, "errmsg").ToString() + } + if msg == "" { + msg = utils.Json.Get(body, "error_msg").ToString() + } + if msg == "" { + msg = strconv.Itoa(errno) + } + return nil, fmt.Errorf("百度原画(无限) 请求失败: %s (errno=%d sekey: fresh=%v len=%d encoded=%v)", + msg, errno, sekeyFresh, len(sekey), strings.Contains(sekey, "%")) + } + dlink := pickDlink(body) + if dlink == "" { + return nil, errors.New("百度原画(无限) 未返回直链") + } + finalURL := followDlnaRedirect(dlink) + if finalURL == "" { + finalURL = dlink + } + // UA 由 alist-tvbox 对 BAIDU 直接下发 DLNA UA;后端代理则由 link.Header 生效。URL 无需内嵌标记。 + log.Infof("[%v] 百度免转存直链 %v %v %v", bd.ID, file.GetName(), file.GetID(), file.GetSize()) + return &model.Link{ + URL: finalURL, + Header: http.Header{"User-Agent": []string{DLNAUA}}, + }, nil +} diff --git a/drivers/baidu_share2/driver.go b/drivers/baidu_share2/driver.go index 2230c3ad0..a590f7723 100644 --- a/drivers/baidu_share2/driver.go +++ b/drivers/baidu_share2/driver.go @@ -28,6 +28,12 @@ import ( var idx = 0 var baiduShareLinkCache = cache.NewKeyedCache[*model.Link](time.Hour) +// baiduShareDirectEnabled 是否启用百度分享免转存(DLNA 签名直链为主、转存兜底)。默认关:关时直接走转存。 +// 声明为 var 便于单测替换(测试里 op 未初始化,直接 setting.GetBool 会死锁)。 +var baiduShareDirectEnabled = func() bool { + return setting.GetBool(conf.BaiduShareDirect) +} + var resolveBaiduShareLink = func(ctx context.Context, d *BaiduShare2, file model.Obj, args model.LinkArgs) (*model.Link, error) { count := op.GetDriverCount("BaiduNetdisk") var lastErr error @@ -229,7 +235,20 @@ func (d *BaiduShare2) Link(ctx context.Context, file model.Obj, args model.LinkA return link, nil } - link, err := resolveBaiduShareLink(ctx, d, file, args) + // 免转存(原画(无限),DLNA 签名直链)为主、转存(save+delete)兜底,两条路互为补充。 + // 免转存直链不限速、免 Cookie、省空间省等待;失败时回退转存,保证可用性。 + // 开关默认关:关时跳过免转存,直接走转存(分支前行为)。 + var link *model.Link + var err error + if baiduShareDirectEnabled() { + link, err = resolveShareDirectLink(d, file) + } + if err != nil || link == nil { + if err != nil { + log.Warnf("百度免转存失败,回退转存: %v", err) + } + link, err = resolveBaiduShareLink(ctx, d, file, args) + } if err == nil && link != nil { baiduShareLinkCache.Set(key, link) } diff --git a/drivers/baidu_share2/driver_test.go b/drivers/baidu_share2/driver_test.go index 49d52f0b4..b8ad8e397 100644 --- a/drivers/baidu_share2/driver_test.go +++ b/drivers/baidu_share2/driver_test.go @@ -3,6 +3,7 @@ package baidu_share import ( "context" "errors" + "strings" "testing" "time" @@ -10,76 +11,203 @@ import ( "github.com/OpenListTeam/OpenList/v4/internal/model" ) -func TestBaiduShare2Link_CachesByFileID(t *testing.T) { +// stubResolvers 替换两条取链路径为可控桩函数,隔离 op/storage,返回还原函数。 +func stubResolvers(direct func(d *BaiduShare2, file model.Obj) (*model.Link, error), + transfer func(ctx context.Context, d *BaiduShare2, file model.Obj, args model.LinkArgs) (*model.Link, error)) func() { origCache := baiduShareLinkCache - origResolver := resolveBaiduShareLink + origDirect := resolveShareDirectLink + origTransfer := resolveBaiduShareLink + origEnabled := baiduShareDirectEnabled baiduShareLinkCache = cache.NewKeyedCache[*model.Link](time.Hour) - resolveCalls := 0 - resolveBaiduShareLink = func(ctx context.Context, d *BaiduShare2, file model.Obj, args model.LinkArgs) (*model.Link, error) { - resolveCalls++ - return &model.Link{URL: "https://example.com/baidu/" + file.GetID()}, nil - } - t.Cleanup(func() { + resolveShareDirectLink = direct + resolveBaiduShareLink = transfer + baiduShareDirectEnabled = func() bool { return true } // 现有用例测免转存路径,默认开 + return func() { baiduShareLinkCache = origCache - resolveBaiduShareLink = origResolver - }) + resolveShareDirectLink = origDirect + resolveBaiduShareLink = origTransfer + baiduShareDirectEnabled = origEnabled + } +} + +// baiduDlnaSekey 必须把原始 randsk 与已编码(BDCLND)形态都归一化为单编码值, +// 使 resty 再编码后服务端拿到一致的「编码 sekey」(对已编码值幂等,不破坏无提取码分享)。 +// 关键回归:字面 '+' 必须编码为 %2B,绝不能被当作空格(若用 QueryUnescape 归一化会踩此坑)。 +func TestBaiduDlnaSekey_Normalizes(t *testing.T) { + raw := "Fk2Ab+Z9==" + encoded := "Fk2Ab%2BZ9%3D%3D" + want := encoded + if got := baiduDlnaSekey(raw); got != want { + t.Errorf("from raw randsk: got %q want %q", got, want) + } + if got := baiduDlnaSekey(encoded); got != want { + t.Errorf("from encoded BDCLND (must be idempotent): got %q want %q", got, want) + } + if strings.Contains(baiduDlnaSekey(raw), " ") || !strings.Contains(baiduDlnaSekey(raw), "%2B") { + t.Errorf("literal '+' must encode to %%2B, not space: got %q", baiduDlnaSekey(raw)) + } +} + +func TestBaiduShare2Link_CachesByFileID(t *testing.T) { + directCalls, transferCalls := 0, 0 + restore := stubResolvers( + func(d *BaiduShare2, file model.Obj) (*model.Link, error) { + directCalls++ + return &model.Link{URL: "https://example.com/baidu/" + file.GetID()}, nil + }, + func(ctx context.Context, d *BaiduShare2, file model.Obj, args model.LinkArgs) (*model.Link, error) { + transferCalls++ + return &model.Link{URL: "https://transfer/" + file.GetID()}, nil + }, + ) + defer restore() d := &BaiduShare2{} file := &model.Object{ID: "file-1", Name: "video.mp4"} _, _ = d.Link(context.Background(), file, model.LinkArgs{}) _, _ = d.Link(context.Background(), file, model.LinkArgs{Type: "ignored"}) - if resolveCalls != 1 { - t.Fatalf("expected resolver once, got %d", resolveCalls) + if directCalls != 1 { + t.Fatalf("expected resolver once, got %d", directCalls) + } + if transferCalls != 0 { + t.Fatalf("免转存命中不应回退转存, got %d transfer calls", transferCalls) } } func TestBaiduShare2Link_DoesNotCacheNilOrError(t *testing.T) { - origCache := baiduShareLinkCache - origResolver := resolveBaiduShareLink - baiduShareLinkCache = cache.NewKeyedCache[*model.Link](time.Hour) - resolveCalls := 0 - resolveBaiduShareLink = func(ctx context.Context, d *BaiduShare2, file model.Obj, args model.LinkArgs) (*model.Link, error) { - resolveCalls++ - if resolveCalls == 1 { - return nil, nil - } - return nil, errors.New("boom") - } - t.Cleanup(func() { - baiduShareLinkCache = origCache - resolveBaiduShareLink = origResolver - }) + directCalls := 0 + restore := stubResolvers( + func(d *BaiduShare2, file model.Obj) (*model.Link, error) { + directCalls++ + if directCalls == 1 { + return nil, nil // nil → 不缓存 + } + return nil, errors.New("boom") // error → 不缓存 + }, + func(ctx context.Context, d *BaiduShare2, file model.Obj, args model.LinkArgs) (*model.Link, error) { + return nil, nil // 兜底也失败,结果不被缓存 + }, + ) + defer restore() d := &BaiduShare2{} file := &model.Object{ID: "file-1", Name: "video.mp4"} _, _ = d.Link(context.Background(), file, model.LinkArgs{}) _, _ = d.Link(context.Background(), file, model.LinkArgs{}) - if resolveCalls != 2 { - t.Fatalf("expected resolver twice after nil/error results, got %d", resolveCalls) + if directCalls != 2 { + t.Fatalf("expected resolver twice after nil/error results, got %d", directCalls) } } func TestBaiduShare2Link_DifferentFileIDsDoNotShareCache(t *testing.T) { - origCache := baiduShareLinkCache - origResolver := resolveBaiduShareLink - baiduShareLinkCache = cache.NewKeyedCache[*model.Link](time.Hour) - resolveCalls := 0 - resolveBaiduShareLink = func(ctx context.Context, d *BaiduShare2, file model.Obj, args model.LinkArgs) (*model.Link, error) { - resolveCalls++ - return &model.Link{URL: "https://example.com/baidu/" + file.GetID()}, nil - } - t.Cleanup(func() { - baiduShareLinkCache = origCache - resolveBaiduShareLink = origResolver - }) + directCalls := 0 + restore := stubResolvers( + func(d *BaiduShare2, file model.Obj) (*model.Link, error) { + directCalls++ + return &model.Link{URL: "https://example.com/baidu/" + file.GetID()}, nil + }, + func(ctx context.Context, d *BaiduShare2, file model.Obj, args model.LinkArgs) (*model.Link, error) { + return &model.Link{URL: "https://transfer/" + file.GetID()}, nil + }, + ) + defer restore() d := &BaiduShare2{} _, _ = d.Link(context.Background(), &model.Object{ID: "file-1", Name: "a.mp4"}, model.LinkArgs{}) _, _ = d.Link(context.Background(), &model.Object{ID: "file-2", Name: "b.mp4"}, model.LinkArgs{}) - if resolveCalls != 2 { - t.Fatalf("expected resolver twice for different file IDs, got %d", resolveCalls) + if directCalls != 2 { + t.Fatalf("expected resolver twice for different file IDs, got %d", directCalls) + } +} + +// 免转存命中 → 不应回退到转存。 +func TestBaiduShare2Link_ShareDirectPrimarySkipsTransfer(t *testing.T) { + transferCalls := 0 + restore := stubResolvers( + func(d *BaiduShare2, file model.Obj) (*model.Link, error) { + return &model.Link{URL: "https://d.pcs.baidu.com/dlna/" + file.GetID()}, nil + }, + func(ctx context.Context, d *BaiduShare2, file model.Obj, args model.LinkArgs) (*model.Link, error) { + transferCalls++ + return &model.Link{URL: "https://transfer/" + file.GetID()}, nil + }, + ) + defer restore() + + d := &BaiduShare2{} + file := &model.Object{ID: "file-1", Name: "v.mp4"} + link, err := d.Link(context.Background(), file, model.LinkArgs{}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if transferCalls != 0 { + t.Fatalf("免转存命中不应回退转存, got %d transfer calls", transferCalls) + } + if !strings.HasPrefix(link.URL, "https://d.pcs.baidu.com/dlna/") { + t.Fatalf("expected 免转存 link, got %s", link.URL) + } +} + +// 免转存失败 → 应回退到转存一次。 +func TestBaiduShare2Link_ShareDirectFailFallsBackToTransfer(t *testing.T) { + transferCalls := 0 + restore := stubResolvers( + func(d *BaiduShare2, file model.Obj) (*model.Link, error) { + return nil, errors.New("share-direct disabled") + }, + func(ctx context.Context, d *BaiduShare2, file model.Obj, args model.LinkArgs) (*model.Link, error) { + transferCalls++ + return &model.Link{URL: "https://transfer/" + file.GetID()}, nil + }, + ) + defer restore() + + d := &BaiduShare2{} + file := &model.Object{ID: "file-1", Name: "v.mp4"} + link, err := d.Link(context.Background(), file, model.LinkArgs{}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if transferCalls != 1 { + t.Fatalf("免转存失败应回退转存一次, got %d", transferCalls) + } + if !strings.HasPrefix(link.URL, "https://transfer/") { + t.Fatalf("expected 转存 link, got %s", link.URL) + } +} + +// 开关关 → 直接走转存,免转存路径不应被调用。 +func TestBaiduShare2Link_DirectDisabledSkipsDirect(t *testing.T) { + directCalls, transferCalls := 0, 0 + restore := stubResolvers( + func(d *BaiduShare2, file model.Obj) (*model.Link, error) { + directCalls++ + return &model.Link{URL: "https://d.pcs.baidu.com/dlna/" + file.GetID()}, nil + }, + func(ctx context.Context, d *BaiduShare2, file model.Obj, args model.LinkArgs) (*model.Link, error) { + transferCalls++ + return &model.Link{URL: "https://transfer/" + file.GetID()}, nil + }, + ) + defer restore() // stubResolvers 已捕获并还原 gate + baiduShareDirectEnabled = func() bool { return false } // 覆盖为关 + + d := &BaiduShare2{} + file := &model.Object{ID: "file-1", Name: "v.mp4"} + link, err := d.Link(context.Background(), file, model.LinkArgs{}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if directCalls != 0 { + t.Fatalf("开关关时不应调用免转存, got %d direct calls", directCalls) + } + if transferCalls != 1 { + t.Fatalf("应直接走转存一次, got %d transfer calls", transferCalls) + } + if !strings.HasPrefix(link.URL, "https://transfer/") { + t.Fatalf("expected 转存 link, got %s", link.URL) } } diff --git a/drivers/quark_uc_share/driver.go b/drivers/quark_uc_share/driver.go index 4892a8a9d..746d48fb1 100755 --- a/drivers/quark_uc_share/driver.go +++ b/drivers/quark_uc_share/driver.go @@ -2,6 +2,7 @@ package quark_uc_share import ( "context" + "errors" "fmt" "time" @@ -120,7 +121,10 @@ func (d *QuarkUCShare) Link(ctx context.Context, file model.Obj, args model.Link quarkUCShareLinkCache.Set(key, link) return link, nil } - // 全部账号取链失败,回退免转存兜底。 + // 全部账号取链失败,回退免转存兜底(免转存开关关时跳过)。 + if !shareDirectEnabled(d) { + return nil, errors.New("[multi-source] 全部账号取链失败且免转存已关闭") + } log.Warnf("[multi-source] 全部账号取链失败,回退免转存") link, err := resolveShareDirectLink(d, file) if err == nil && link != nil { @@ -129,11 +133,11 @@ func (d *QuarkUCShare) Link(ctx context.Context, file model.Obj, args model.Link return link, err } - // 开关关:转存 + speedup 为主(串行轮询账号,失败换下一个),免转存兜底。 + // 开关关:转存 + speedup 为主(串行轮询账号,失败换下一个),免转存兜底(免转存开关开时)。 var link *model.Link var err error link, err = resolveQuarkUCShareLink(ctx, d, file, args) - if err != nil || link == nil { + if (err != nil || link == nil) && shareDirectEnabled(d) { log.Warnf("转存取链失败,回退免转存: %v", err) link, err = resolveShareDirectLink(d, file) } diff --git a/drivers/quark_uc_share/driver_test.go b/drivers/quark_uc_share/driver_test.go index 054631fc8..66f07c436 100644 --- a/drivers/quark_uc_share/driver_test.go +++ b/drivers/quark_uc_share/driver_test.go @@ -18,13 +18,16 @@ func stubMultiSourceDisabled(t *testing.T) { t.Helper() origMS := multiSourceEnabled origCollect := collectMultiAccountLinks + origSD := shareDirectEnabled multiSourceEnabled = func(d *QuarkUCShare) bool { return false } collectMultiAccountLinks = func(ctx context.Context, d *QuarkUCShare, file model.Obj, args model.LinkArgs) []*model.Link { return nil } + shareDirectEnabled = func(d *QuarkUCShare) bool { return true } // 现有用例测免转存兜底,默认开 t.Cleanup(func() { multiSourceEnabled = origMS collectMultiAccountLinks = origCollect + shareDirectEnabled = origSD }) } @@ -82,6 +85,7 @@ func TestQuarkUCShareLink_DoesNotCacheErrors(t *testing.T) { origResolver := resolveQuarkUCShareLink origDirect := resolveShareDirectLink origSVIP := accountIsSVIP + origSD := shareDirectEnabled quarkUCShareLinkCache = cache.NewKeyedCache[*model.Link](time.Hour) resolveCalls := 0 resolveQuarkUCShareLink = func(ctx context.Context, d *QuarkUCShare, file model.Obj, args model.LinkArgs) (*model.Link, error) { @@ -96,11 +100,13 @@ func TestQuarkUCShareLink_DoesNotCacheErrors(t *testing.T) { collectMultiAccountLinks = func(ctx context.Context, d *QuarkUCShare, file model.Obj, args model.LinkArgs) []*model.Link { return nil } + shareDirectEnabled = func(d *QuarkUCShare) bool { return true } // resolve 失败会触及免转存闸,置开避免读 setting 死锁 t.Cleanup(func() { quarkUCShareLinkCache = origCache resolveQuarkUCShareLink = origResolver resolveShareDirectLink = origDirect accountIsSVIP = origSVIP + shareDirectEnabled = origSD }) d := &QuarkUCShare{Addition: Addition{ShareToken: "share-token"}, config: driver.Config{Name: "QuarkShare"}} @@ -186,6 +192,48 @@ func TestQuarkUCShareLink_FallbackToShareDirectOnSaveFail(t *testing.T) { } } +// 免转存开关关 → 转存失败时不应回退 share-direct,直接返回错误。 +func TestQuarkUCShareLink_ShareDirectDisabledSkipsDirect(t *testing.T) { + stubMultiSourceDisabled(t) + origSD := shareDirectEnabled + shareDirectEnabled = func(d *QuarkUCShare) bool { return false } // 关 + t.Cleanup(func() { shareDirectEnabled = origSD }) + + origCache := quarkUCShareLinkCache + origResolver := resolveQuarkUCShareLink + origDirect := resolveShareDirectLink + quarkUCShareLinkCache = cache.NewKeyedCache[*model.Link](time.Hour) + resolveCalls := 0 + directCalls := 0 + resolveQuarkUCShareLink = func(ctx context.Context, d *QuarkUCShare, file model.Obj, args model.LinkArgs) (*model.Link, error) { + resolveCalls++ + return nil, errors.New("save failed") + } + resolveShareDirectLink = func(d *QuarkUCShare, file model.Obj) (*model.Link, error) { + directCalls++ + return &model.Link{URL: "https://example.com/share-direct/" + file.GetID()}, nil + } + t.Cleanup(func() { + quarkUCShareLinkCache = origCache + resolveQuarkUCShareLink = origResolver + resolveShareDirectLink = origDirect + }) + + d := &QuarkUCShare{Addition: Addition{ShareToken: "share-token"}, config: driver.Config{Name: "QuarkShare"}} + file := &model.Object{ID: "fid-fidtoken-pid", Name: "video.mp4"} + + link, err := d.Link(context.Background(), file, model.LinkArgs{}) + if err == nil { + t.Fatalf("expected error when 转存 fails and 免转存 disabled, got link %v", link) + } + if resolveCalls != 1 { + t.Fatalf("expected save attempted once, got %d", resolveCalls) + } + if directCalls != 0 { + t.Fatalf("免转存关时不应调用 share-direct, got %d", directCalls) + } +} + func TestQuarkUCShareLink_PrefersSaveAndSpeedup(t *testing.T) { // 转存(save+speedup)为主:成功时不调用免转存(免转存无 speedup,被限速,仅兜底)。 stubMultiSourceDisabled(t) diff --git a/drivers/quark_uc_share/util.go b/drivers/quark_uc_share/util.go index eecd0a424..ec722edc8 100755 --- a/drivers/quark_uc_share/util.go +++ b/drivers/quark_uc_share/util.go @@ -287,6 +287,18 @@ func rapidTo123Enabled(d *QuarkUCShare) bool { return setting.GetBool(conf.QuarkTo123) } +// shareDirectEnabled 按驱动类型(夸克/UC)选对应的「免转存(share-direct)」开关,默认开。 +// 关时 Link() 跳过 resolveShareDirectLink 兜底,只用转存/多账号取链。声明为 var 便于单测替换(测试里 op 未初始化,直接 setting.GetBool 会死锁)。 +var shareDirectEnabled = func(d *QuarkUCShare) bool { + if d == nil { + return true + } + if d.getDriverName() == "UC" { + return setting.GetBool(conf.UCShareDirect) + } + return setting.GetBool(conf.QuarkShareDirect) +} + // rapidQuarkUCTo123 按 MD5 把夸克/UC 文件秒传到 123。声明为 var 便于单测替换。 var rapidQuarkUCTo123 = func(name, md5 string, size int64) *model.Link { if len(md5) != utils.MD5.Width { diff --git a/internal/bootstrap/data/setting.go b/internal/bootstrap/data/setting.go index de7c53f24..48d21684a 100644 --- a/internal/bootstrap/data/setting.go +++ b/internal/bootstrap/data/setting.go @@ -259,6 +259,9 @@ func InitialSettings() []model.SettingItem { {Key: conf.QuarkTo123, Value: "false", Type: conf.TypeBool, Group: model.SINGLE, Flag: model.PRIVATE}, {Key: conf.UCTo123, Value: "false", Type: conf.TypeBool, Group: model.SINGLE, Flag: model.PRIVATE}, {Key: conf.GuangyaTo123, Value: "false", Type: conf.TypeBool, Group: model.SINGLE, Flag: model.PRIVATE}, + {Key: conf.BaiduShareDirect, Value: "false", Type: conf.TypeBool, Group: model.SINGLE, Flag: model.PRIVATE}, + {Key: conf.QuarkShareDirect, Value: "true", Type: conf.TypeBool, Group: model.SINGLE, Flag: model.PRIVATE}, + {Key: conf.UCShareDirect, Value: "true", Type: conf.TypeBool, Group: model.SINGLE, Flag: model.PRIVATE}, {Key: conf.DriverRoundRobin, Value: "false", Type: conf.TypeBool, Group: model.SINGLE, Flag: model.PRIVATE}, {Key: conf.UssQuarkTv, Value: "false", Type: conf.TypeBool, Group: model.SINGLE, Flag: model.PRIVATE}, {Key: conf.QuarkMultiAccountProxy, Value: "false", Type: conf.TypeBool, Group: model.SINGLE, Flag: model.PRIVATE}, diff --git a/internal/conf/const.go b/internal/conf/const.go index c0b6e1eee..5fa23cd1a 100644 --- a/internal/conf/const.go +++ b/internal/conf/const.go @@ -124,6 +124,9 @@ const ( QuarkTo123 = "quark_to_123" UCTo123 = "uc_to_123" GuangyaTo123 = "guangya_to_123" + BaiduShareDirect = "baidu_share_direct" + QuarkShareDirect = "quark_share_direct" + UCShareDirect = "uc_share_direct" AliAccountId = "ali_account_id" TempDirName = "alist-tvbox-temp" OfflineDirName = "alist-tvbox-offline"