mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 00:44:30 +08:00
7e5e67697e
Implement a new GuangYaPan cloud drive integration across the backend, admin UI, playback proxy, and Spider91 migration flow. Backend changes:\n- Add a GuangYaPan drive driver with token refresh, QR/device login support, directory listing, stream link resolution, directory creation, rename/delete operations, OSS multipart upload, and upload task polling.\n- Register GuangYaPan as a supported storage kind in configuration, catalog normalization, admin APIs, public drive labels, and 302 playback redirects.\n- Allow Spider91 crawler uploads to target GuangYaPan through a dedicated migration adapter.\n- Add scan, thumbnail, preview, and fingerprint cooldown handling for GuangYaPan based on explicit HTTP status codes, Retry-After values, and structured provider codes instead of natural-language message matching.\n- Tighten existing provider cooldown detectors so OneDrive, Google Drive, 115, PikPak, 123pan, Wopan, and media workers avoid treating arbitrary response text as a rate-limit signal.\n- Keep large videos eligible for preview generation unless the user disables preview generation. Admin and tooling changes:\n- Add GuangYaPan as a selectable drive type with QR login UI and token/root-path credential fields.\n- Add crawler upload target support for GuangYaPan in the admin UI.\n- Add drive branding, labels, metadata display, and docs/config examples for GuangYaPan.\n- Include a standalone GuangYaPan QR login helper script for manual credential acquisition. Tests:\n- Add GuangYaPan driver, QR login, proxy, admin API, crawler upload target, fingerprint, cooldown, and form coverage.\n- Update rate-limit tests to assert that message-only throttling text no longer starts cooldowns.\n- Cover explicit HTTP status parsing through shared drive helper tests.
573 lines
15 KiB
Go
573 lines
15 KiB
Go
package pikpak
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"path"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/go-resty/resty/v2"
|
||
"github.com/video-site/backend/internal/drives"
|
||
)
|
||
|
||
const (
|
||
filesURL = "https://api-drive.mypikpak.net/drive/v1/files"
|
||
signinURL = "https://user.mypikpak.net/v1/auth/signin"
|
||
tokenURL = "https://user.mypikpak.net/v1/auth/token"
|
||
captchaInitURL = "https://user.mypikpak.net/v1/shield/captcha/init"
|
||
)
|
||
|
||
type Driver struct {
|
||
id string
|
||
rootID string
|
||
username string
|
||
password string
|
||
platform string
|
||
refreshToken string
|
||
accessToken string
|
||
captchaToken string
|
||
deviceID string
|
||
userID string
|
||
disableMediaLink bool
|
||
|
||
clientID string
|
||
clientSecret string
|
||
clientVersion string
|
||
packageName string
|
||
algorithms []string
|
||
userAgent string
|
||
|
||
client *resty.Client
|
||
onTokenUpdate func(access, refresh, captcha, deviceID string)
|
||
uploadToOSSFunc func(context.Context, *s3Params, io.Reader) error
|
||
|
||
// captchaMu serializes captcha-token refreshes triggered by 4002 / 9
|
||
// recovery in requestOnce. Without it, N concurrent callers all hitting
|
||
// 4002 at once would each post to /v1/shield/captcha/init, racing to
|
||
// overwrite d.captchaToken — wasteful and likely to be flagged by
|
||
// PikPak as abuse. With it, only one refresh is in flight; later
|
||
// callers observe d.captchaToken has changed and skip the refresh.
|
||
captchaMu sync.Mutex
|
||
|
||
// listMu / lastListAt / listInterval 做和 p115 driver 一样的列目录限频 +
|
||
// 冷却保护。listMu 保证整个 drive 同一时刻只有一次 list 在跑(避免并发
|
||
// 触发 PikPak 的"操作频繁 error_code=10");listInterval 是相邻 list 调用
|
||
// 的最小间隔(默认 1 秒);命中疑似限流错误时进入 pikpakListCooldown
|
||
// 冷却 10 分钟后再重试,循环直到成功或 ctx 取消。
|
||
listMu sync.Mutex
|
||
lastListAt time.Time
|
||
listInterval time.Duration
|
||
}
|
||
|
||
type Config struct {
|
||
ID string
|
||
Username string
|
||
Password string
|
||
Platform string
|
||
RefreshToken string
|
||
AccessToken string
|
||
CaptchaToken string
|
||
DeviceID string
|
||
RootID string
|
||
DisableMediaLink bool
|
||
OnTokenUpdate func(access, refresh, captcha, deviceID string)
|
||
}
|
||
|
||
func New(c Config) *Driver {
|
||
rootID := strings.TrimSpace(c.RootID)
|
||
if rootID == "0" {
|
||
rootID = ""
|
||
}
|
||
platform := strings.ToLower(strings.TrimSpace(c.Platform))
|
||
if platform == "" {
|
||
platform = "web"
|
||
}
|
||
deviceID := strings.TrimSpace(c.DeviceID)
|
||
if deviceID == "" {
|
||
seed := c.Username + c.Password
|
||
if seed == "" {
|
||
seed = c.ID
|
||
}
|
||
deviceID = md5Hex(seed)
|
||
}
|
||
d := &Driver{
|
||
id: c.ID,
|
||
rootID: rootID,
|
||
username: c.Username,
|
||
password: c.Password,
|
||
platform: platform,
|
||
refreshToken: c.RefreshToken,
|
||
accessToken: c.AccessToken,
|
||
captchaToken: c.CaptchaToken,
|
||
deviceID: deviceID,
|
||
disableMediaLink: c.DisableMediaLink,
|
||
onTokenUpdate: c.OnTokenUpdate,
|
||
client: resty.New().
|
||
SetTimeout(30*time.Second).
|
||
SetHeader("Accept", "application/json, text/plain, */*"),
|
||
listInterval: 1 * time.Second,
|
||
}
|
||
d.applyPlatformDefaults()
|
||
return d
|
||
}
|
||
|
||
func (d *Driver) Kind() string { return "pikpak" }
|
||
func (d *Driver) ID() string { return d.id }
|
||
func (d *Driver) RootID() string { return d.rootID }
|
||
|
||
func (d *Driver) Init(ctx context.Context) error {
|
||
clearPersistedCaptcha := func() {
|
||
if d.captchaToken == "" {
|
||
return
|
||
}
|
||
d.captchaToken = ""
|
||
d.persistTokens()
|
||
}
|
||
|
||
if d.refreshToken != "" {
|
||
if err := d.refresh(ctx, d.refreshToken); err != nil {
|
||
if !IsCaptchaError(err) || d.username == "" || d.password == "" {
|
||
return err
|
||
}
|
||
clearPersistedCaptcha()
|
||
if err := d.login(ctx); err != nil {
|
||
return fmt.Errorf("pikpak refresh captcha recovery login: %w", err)
|
||
}
|
||
} else {
|
||
// Persisted captcha tokens are short-lived. With a refresh token we can
|
||
// safely request a fresh captcha token after auth, and avoiding the
|
||
// stored value prevents known-stale tokens from poisoning startup.
|
||
clearPersistedCaptcha()
|
||
}
|
||
} else {
|
||
if err := d.login(ctx); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := d.refreshCaptchaTokenAtLogin(ctx, getAction(http.MethodGet, filesURL), d.userID); err != nil {
|
||
return err
|
||
}
|
||
d.persistTokens()
|
||
return nil
|
||
}
|
||
|
||
func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) {
|
||
if dirID == "" {
|
||
dirID = d.rootID
|
||
}
|
||
files, err := d.listWithRetry(ctx, dirID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]drives.Entry, 0, len(files))
|
||
for _, f := range files {
|
||
out = append(out, fileToEntry(f, dirID))
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// pikpakListCooldown 是列目录触发疑似限流错误时的冷却时长。
|
||
//
|
||
// 与 p115 driver 的 listCooldown 同语义:只要错误属明确限流/临时状态
|
||
// (结构化 error_code=10 / HTTP 429 / 5xx),就持续
|
||
// 等 10 分钟再发一次列目录请求,直到成功或 ctx 取消。这样即使 PikPak
|
||
// 风控持续较长时间,扫描会自然延后到风控结束,不再丢半棵子树。
|
||
const pikpakListCooldown = 10 * time.Minute
|
||
|
||
func (d *Driver) listWithRetry(ctx context.Context, dirID string) ([]file, error) {
|
||
d.listMu.Lock()
|
||
defer d.listMu.Unlock()
|
||
|
||
for attempt := 0; ; attempt++ {
|
||
if err := d.waitForListSlotLocked(ctx); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
files, err := d.getFiles(ctx, dirID)
|
||
if err == nil {
|
||
return files, nil
|
||
}
|
||
// 非 transient 错误(如 cookie 失效、目录不存在)直接返回;继续重试也只会反复失败。
|
||
if !isTransientPikPakListError(err) {
|
||
return nil, err
|
||
}
|
||
log.Printf("[pikpak] list cooling down drive=%s dir=%s cooldown=%s attempt=%d err=%v",
|
||
d.id, dirID, pikpakListCooldown, attempt+1, err)
|
||
if err := pikpakSleepContext(ctx, pikpakListCooldown); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
}
|
||
|
||
// waitForListSlotLocked 节流相邻 list 调用。调用方必须已持有 d.listMu。
|
||
func (d *Driver) waitForListSlotLocked(ctx context.Context) error {
|
||
if d.listInterval <= 0 || d.lastListAt.IsZero() {
|
||
d.lastListAt = time.Now()
|
||
return ctx.Err()
|
||
}
|
||
next := d.lastListAt.Add(d.listInterval)
|
||
now := time.Now()
|
||
if now.Before(next) {
|
||
if err := pikpakSleepContext(ctx, next.Sub(now)); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
d.lastListAt = time.Now()
|
||
return ctx.Err()
|
||
}
|
||
|
||
func pikpakSleepContext(ctx context.Context, d time.Duration) error {
|
||
if d <= 0 {
|
||
return ctx.Err()
|
||
}
|
||
timer := time.NewTimer(d)
|
||
defer timer.Stop()
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
case <-timer.C:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// isTransientPikPakListError 判断 List 返回的错误是否属"瞬时限频/服务端不可用"
|
||
// 类型,需要冷却后重试。覆盖:
|
||
//
|
||
// - PikPak 业务码 error_code=10 ("操作频繁",见 OpenList drivers/pikpak/util.go)
|
||
// - HTTP 429 / 500 / 502 / 503 / 504 / 509(rclone 也把这些归为 retry)
|
||
//
|
||
// 不包含 4122/4121/16(access_token 过期)和 9/4002(captcha 过期)—— 这些
|
||
// 由 requestOnce 内部已经做过一次自动恢复重试;如果恢复后仍然报这类错误,
|
||
// 大概率是凭证或账号本身有问题,继续冷却重试无意义。
|
||
func isTransientPikPakListError(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
// 命中 PikPak 业务错误对象
|
||
var apiErr *errResp
|
||
if errors.As(err, &apiErr) {
|
||
switch apiErr.ErrorCode {
|
||
case 10: // 操作频繁
|
||
return true
|
||
}
|
||
}
|
||
return drives.ErrorMentionsHTTPStatus(err,
|
||
http.StatusTooManyRequests,
|
||
http.StatusInternalServerError,
|
||
http.StatusBadGateway,
|
||
http.StatusServiceUnavailable,
|
||
http.StatusGatewayTimeout,
|
||
509,
|
||
)
|
||
}
|
||
|
||
func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) {
|
||
var f file
|
||
err := d.request(ctx, filesURL+"/"+fileID, http.MethodGet, func(req *resty.Request) {
|
||
req.SetQueryParams(map[string]string{
|
||
"_magic": "2021",
|
||
"usage": "FETCH",
|
||
"thumbnail_size": "SIZE_LARGE",
|
||
})
|
||
}, &f)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("pikpak stat: %w", err)
|
||
}
|
||
e := fileToEntry(f, "")
|
||
return &e, nil
|
||
}
|
||
|
||
func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) {
|
||
var f file
|
||
usage := "FETCH"
|
||
if !d.disableMediaLink {
|
||
usage = "CACHE"
|
||
}
|
||
err := d.request(ctx, filesURL+"/"+fileID, http.MethodGet, func(req *resty.Request) {
|
||
req.SetQueryParams(map[string]string{
|
||
"_magic": "2021",
|
||
"usage": usage,
|
||
"thumbnail_size": "SIZE_LARGE",
|
||
})
|
||
}, &f)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("pikpak download url: %w", err)
|
||
}
|
||
|
||
url := f.WebContentLink
|
||
expires := time.Now().Add(10 * time.Minute)
|
||
if !d.disableMediaLink {
|
||
if m, ok := pickMediaLink(f.Medias); ok {
|
||
url = m.Link.URL
|
||
if !m.Link.Expire.IsZero() {
|
||
expires = m.Link.Expire
|
||
}
|
||
}
|
||
}
|
||
if url == "" {
|
||
return nil, errors.New("pikpak download url: empty")
|
||
}
|
||
headers := http.Header{}
|
||
if d.userAgent != "" {
|
||
headers.Set("User-Agent", d.userAgent)
|
||
}
|
||
return &drives.StreamLink{
|
||
URL: url,
|
||
Headers: headers,
|
||
Expires: expires,
|
||
}, nil
|
||
}
|
||
|
||
// Upload 的真正实现见 upload.go。
|
||
|
||
// Rename 把 fileID 这个文件改名为 newName(不能是空字符串)。
|
||
// PikPak API:PATCH /drive/v1/files/<id> 带 body {"name": newName}。
|
||
// 与 OpenList drivers/pikpak/driver.go 的 Rename 行为一致。
|
||
func (d *Driver) Rename(ctx context.Context, fileID, newName string) error {
|
||
fileID = strings.TrimSpace(fileID)
|
||
if fileID == "" {
|
||
return errors.New("pikpak rename: empty file id")
|
||
}
|
||
newName = strings.TrimSpace(newName)
|
||
if newName == "" {
|
||
return errors.New("pikpak rename: empty new name")
|
||
}
|
||
if err := d.request(ctx, filesURL+"/"+fileID, http.MethodPatch, func(req *resty.Request) {
|
||
req.SetBody(map[string]any{"name": newName})
|
||
}, nil); err != nil {
|
||
return fmt.Errorf("pikpak rename: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (d *Driver) Remove(ctx context.Context, fileID string) error {
|
||
fileID = strings.TrimSpace(fileID)
|
||
if fileID == "" {
|
||
return errors.New("pikpak remove: empty file id")
|
||
}
|
||
if err := d.request(ctx, filesURL+":batchTrash", http.MethodPost, func(req *resty.Request) {
|
||
req.SetBody(map[string]any{"ids": []string{fileID}})
|
||
}, nil); err != nil {
|
||
return fmt.Errorf("pikpak remove: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) {
|
||
currentID := d.rootID
|
||
for _, name := range splitPath(pathFromRoot) {
|
||
childID, err := d.findChildDir(ctx, currentID, name)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if childID == "" {
|
||
childID, err = d.makeDir(ctx, currentID, name)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
}
|
||
currentID = childID
|
||
}
|
||
return currentID, nil
|
||
}
|
||
|
||
func (d *Driver) findChildDir(ctx context.Context, parentID, name string) (string, error) {
|
||
entries, err := d.List(ctx, parentID)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
for _, e := range entries {
|
||
if e.IsDir && e.Name == name {
|
||
return e.ID, nil
|
||
}
|
||
}
|
||
return "", nil
|
||
}
|
||
|
||
func (d *Driver) makeDir(ctx context.Context, parentID, name string) (string, error) {
|
||
var out file
|
||
err := d.request(ctx, filesURL, http.MethodPost, func(req *resty.Request) {
|
||
req.SetBody(map[string]any{
|
||
"kind": "drive#folder",
|
||
"parent_id": parentID,
|
||
"name": name,
|
||
})
|
||
}, &out)
|
||
if err != nil {
|
||
return "", fmt.Errorf("pikpak mkdir %s: %w", name, err)
|
||
}
|
||
if out.ID == "" {
|
||
return "", fmt.Errorf("pikpak mkdir %s: empty folder id", name)
|
||
}
|
||
return out.ID, nil
|
||
}
|
||
|
||
func splitPath(p string) []string {
|
||
p = strings.Trim(p, "/")
|
||
if p == "" {
|
||
return nil
|
||
}
|
||
return strings.Split(p, "/")
|
||
}
|
||
|
||
func (d *Driver) getFiles(ctx context.Context, parentID string) ([]file, error) {
|
||
out := make([]file, 0)
|
||
pageToken := "first"
|
||
for pageToken != "" {
|
||
if pageToken == "first" {
|
||
pageToken = ""
|
||
}
|
||
query := map[string]string{
|
||
"parent_id": parentID,
|
||
"thumbnail_size": "SIZE_LARGE",
|
||
"with_audit": "true",
|
||
"limit": "100",
|
||
"filters": `{"phase":{"eq":"PHASE_TYPE_COMPLETE"},"trashed":{"eq":false}}`,
|
||
"page_token": pageToken,
|
||
}
|
||
var resp filesResp
|
||
if err := d.request(ctx, filesURL, http.MethodGet, func(req *resty.Request) {
|
||
req.SetQueryParams(query)
|
||
}, &resp); err != nil {
|
||
return nil, fmt.Errorf("pikpak list: %w", err)
|
||
}
|
||
out = append(out, resp.Files...)
|
||
pageToken = resp.NextPageToken
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (d *Driver) request(ctx context.Context, url, method string, configure func(*resty.Request), out any) error {
|
||
return d.requestOnce(ctx, url, method, configure, out, true)
|
||
}
|
||
|
||
func (d *Driver) requestOnce(ctx context.Context, url, method string, configure func(*resty.Request), out any, retry bool) error {
|
||
req := d.client.R().
|
||
SetContext(ctx).
|
||
SetHeader("User-Agent", d.userAgent).
|
||
SetHeader("X-Device-ID", d.deviceID).
|
||
SetHeader("X-Captcha-Token", d.captchaToken)
|
||
if d.accessToken != "" {
|
||
req.SetHeader("Authorization", "Bearer "+d.accessToken)
|
||
}
|
||
if configure != nil {
|
||
configure(req)
|
||
}
|
||
if out != nil {
|
||
req.SetResult(out)
|
||
}
|
||
var e errResp
|
||
req.SetError(&e)
|
||
|
||
res, err := req.Execute(method, url)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if e.isError() {
|
||
switch e.ErrorCode {
|
||
case 4122, 4121, 16:
|
||
if retry {
|
||
if err := d.refresh(ctx, d.refreshToken); err != nil {
|
||
return err
|
||
}
|
||
return d.requestOnce(ctx, url, method, configure, out, false)
|
||
}
|
||
case 9, 4002:
|
||
if retry {
|
||
// Snapshot the token we *just used* (which the server rejected).
|
||
// Then take captchaMu so concurrent recovery attempts are
|
||
// serialized. Once we hold the lock, if d.captchaToken has
|
||
// already moved past staleToken, another goroutine has refreshed
|
||
// it for us — we skip the refresh and just retry. Otherwise we
|
||
// clear the cached token before asking /v1/shield/captcha/init
|
||
// for a fresh one. PikPak may report stale captcha as either
|
||
// 4002 or 9, and sending the rejected token into captcha init can
|
||
// keep returning captcha_invalid.
|
||
staleToken := d.captchaToken
|
||
d.captchaMu.Lock()
|
||
var refreshErr error
|
||
if d.captchaToken == staleToken {
|
||
if d.captchaToken != "" {
|
||
d.captchaToken = ""
|
||
}
|
||
refreshErr = d.refreshCaptchaTokenAtLogin(ctx, getAction(method, url), d.userID)
|
||
}
|
||
d.captchaMu.Unlock()
|
||
if refreshErr != nil {
|
||
return refreshErr
|
||
}
|
||
return d.requestOnce(ctx, url, method, configure, out, false)
|
||
}
|
||
}
|
||
return &e
|
||
}
|
||
if res.IsError() {
|
||
return fmt.Errorf("pikpak http %d: %s", res.StatusCode(), string(res.Body()))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func pickMediaLink(items []media) (media, bool) {
|
||
if len(items) == 0 {
|
||
return media{}, false
|
||
}
|
||
for _, m := range items {
|
||
if m.IsOrigin && m.Link.URL != "" {
|
||
return m, true
|
||
}
|
||
}
|
||
for _, m := range items {
|
||
if m.IsDefault && m.Link.URL != "" {
|
||
return m, true
|
||
}
|
||
}
|
||
for _, m := range items {
|
||
if m.Link.URL != "" {
|
||
return m, true
|
||
}
|
||
}
|
||
return media{}, false
|
||
}
|
||
|
||
func guessMime(name string) string {
|
||
ext := strings.ToLower(path.Ext(name))
|
||
switch ext {
|
||
case ".mp4":
|
||
return "video/mp4"
|
||
case ".mkv":
|
||
return "video/x-matroska"
|
||
case ".mov":
|
||
return "video/quicktime"
|
||
case ".webm":
|
||
return "video/webm"
|
||
case ".avi":
|
||
return "video/x-msvideo"
|
||
case ".jpg", ".jpeg":
|
||
return "image/jpeg"
|
||
case ".png":
|
||
return "image/png"
|
||
}
|
||
return "application/octet-stream"
|
||
}
|
||
|
||
func ParseBoolDefault(raw string, def bool) bool {
|
||
if raw == "" {
|
||
return def
|
||
}
|
||
v, err := strconv.ParseBool(raw)
|
||
if err != nil {
|
||
return def
|
||
}
|
||
return v
|
||
}
|
||
|
||
var _ drives.Drive = (*Driver)(nil)
|
||
var _ drives.Remover = (*Driver)(nil)
|