From 7e5e67697e9f0c546e063bebc2462b80fc489832 Mon Sep 17 00:00:00 2001 From: nianzhibai Date: Sun, 14 Jun 2026 15:44:50 +0800 Subject: [PATCH] feat: add GuangYaPan drive support 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. --- README.md | 4 +- backend/README.md | 4 +- backend/cmd/server/main.go | 140 +- backend/cmd/server/main_spider91_test.go | 6 + backend/cmd/server/main_test.go | 31 + backend/config.example.yaml | 13 +- backend/internal/api/admin.go | 84 +- backend/internal/api/admin_test.go | 88 ++ backend/internal/api/api.go | 2 + backend/internal/catalog/catalog.go | 2 +- backend/internal/catalog/drives_test.go | 1 + backend/internal/catalog/schema.sql | 2 +- backend/internal/catalog/tags.go | 21 + backend/internal/catalog/tags_test.go | 64 + backend/internal/config/config.go | 2 +- backend/internal/drives/googledrive/driver.go | 35 +- backend/internal/drives/guangyapan/driver.go | 1130 +++++++++++++++++ .../internal/drives/guangyapan/driver_test.go | 300 +++++ backend/internal/drives/guangyapan/qr.go | 244 ++++ backend/internal/drives/guangyapan/qr_test.go | 102 ++ backend/internal/drives/guangyapan/types.go | 129 ++ backend/internal/drives/iface.go | 43 +- backend/internal/drives/iface_test.go | 24 + backend/internal/drives/onedrive/driver.go | 16 +- .../internal/drives/onedrive/driver_test.go | 8 +- backend/internal/drives/p115/driver.go | 14 +- backend/internal/drives/p115/driver_test.go | 9 +- backend/internal/drives/p123/driver.go | 32 +- backend/internal/drives/pikpak/driver.go | 29 +- backend/internal/drives/wopan/driver.go | 44 +- backend/internal/fingerprint/worker.go | 50 +- backend/internal/fingerprint/worker_test.go | 32 +- backend/internal/preview/ffmpeg.go | 171 +-- backend/internal/preview/worker_test.go | 161 +-- backend/internal/proxy/proxy.go | 4 +- backend/internal/proxy/proxy_test.go | 25 + backend/internal/spider91migrate/migrator.go | 35 +- .../internal/spider91migrate/migrator_test.go | 2 +- gy_login_qr.py | 283 +++++ src/admin/CrawlersPage.tsx | 2 +- src/admin/DrivesPage.tsx | 3 +- src/admin/api.ts | 31 +- src/admin/drive/DriveForm.tsx | 17 + src/admin/drive/GuangYaPanQRCodeLogin.tsx | 150 +++ src/admin/drive/constants.ts | 30 +- src/components/VideoCard.tsx | 1 + src/components/VideoMetaHeader.tsx | 2 + src/pages/ShortsPage.tsx | 1 + src/styles/admin.css | 4 + src/styles/tokens.css | 3 + src/styles/video-card.css | 9 + src/styles/video-detail.css | 6 + tests/adminDriveForm.test.ts | 32 +- 53 files changed, 3167 insertions(+), 510 deletions(-) create mode 100644 backend/internal/drives/guangyapan/driver.go create mode 100644 backend/internal/drives/guangyapan/driver_test.go create mode 100644 backend/internal/drives/guangyapan/qr.go create mode 100644 backend/internal/drives/guangyapan/qr_test.go create mode 100644 backend/internal/drives/guangyapan/types.go create mode 100644 backend/internal/drives/iface_test.go create mode 100644 gy_login_qr.py create mode 100644 src/admin/drive/GuangYaPanQRCodeLogin.tsx diff --git a/README.md b/README.md index 55f751b..7afa7dd 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ ## 功能特性 -- **多后端支持** — 兼容 115 云盘、PikPak 云盘、123网盘、联通网盘、OneDrive、Google Drive 和本地存储 -- **低带宽播放** — 115 云盘、PikPak 云盘、123网盘、联通网盘、OneDrive 支持302模式,在线播放视频时,不占用服务器带宽,播放体验不受服务器带宽影响;Google Drive 不支持302模式,走服务器中转,观看体验会受服务器带宽影响 +- **多后端支持** — 兼容 115 云盘、PikPak 云盘、123网盘、联通网盘、光鸭网盘、OneDrive、Google Drive 和本地存储 +- **低带宽播放** — 115 云盘、PikPak 云盘、123网盘、联通网盘、光鸭网盘、OneDrive 支持302模式,在线播放视频时,不占用服务器带宽,播放体验不受服务器带宽影响;Google Drive 不支持302模式,走服务器中转,观看体验会受服务器带宽影响 - **封面 & 预览片段** — 自动为每个视频生成封面图和预览片段,首页快速选片 - **爬虫脚本** — 项目支持导入自定义脚本,但是有一些规范,具体可以参考 [SpiderFor91](https://github.com/Just-Spider/SpiderFor91),项目不再内置任何爬虫脚本 - **短视频模式** — 一键切换抖音风格,沉浸刷片 diff --git a/backend/README.md b/backend/README.md index 06a4d66..f8d96cf 100644 --- a/backend/README.md +++ b/backend/README.md @@ -2,7 +2,7 @@ 视频聚合站的 Go 后端。提供三件事: -1. 多家网盘统一抽象(夸克 / 115 / PikPak / 联通网盘 / OneDrive / Google Drive / 本地存储) +1. 多家网盘统一抽象(夸克 / 115 / PikPak / 联通网盘 / 光鸭网盘 / OneDrive / Google Drive / 本地存储) 2. 视频元数据目录(SQLite)+ 扫描 + 预览视频预生成 3. REST API(前台)+ 管理后台 + 直链代理 4. 标签池、视频隐藏、按网盘统计和详情页来源网盘类型展示能力 @@ -20,6 +20,7 @@ internal/ p115/ 115(壳子 + SheltonZhu/115driver) pikpak/ PikPak(自己实现,参考 OpenList pikpak) wopan/ 联通网盘(壳子 + OpenListTeam/wopan-sdk-go) + guangyapan/ 光鸭网盘(参考 AList GuangYaPan) onedrive/ OneDrive(OpenList 在线续期 + Microsoft Graph 文件接口) googledrive/ Google Drive(OpenList 在线续期 + Google Drive API;播放走后端代理) localstorage/ 本地目录扫描(服务器已有视频目录) @@ -108,6 +109,7 @@ go run ./cmd/server 后端 9192 | p115 | `cookie`(形如 `UID=...; CID=...; SEID=...; KID=...`) | | pikpak | `username`、`password`(token、验证码和设备 ID 由服务端自动处理并保存) | | wopan | `access_token`、`refresh_token`,可选 `family_id` | +| guangyapan | 推荐后台扫码登录自动写入 `access_token`、`refresh_token`;也可手工填写 token;可选 `root_path` | | onedrive | `refresh_token` | | googledrive | 默认只需 `refresh_token`;自建 OAuth 客户端模式还需 `use_online_api=false`、`client_id`、`client_secret` | | localstorage | `path`(服务器上的已有视频目录,如 `/mnt/videos`) | diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index ae823b7..812f5b2 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -26,6 +26,7 @@ import ( "github.com/video-site/backend/internal/config" "github.com/video-site/backend/internal/drives" "github.com/video-site/backend/internal/drives/googledrive" + "github.com/video-site/backend/internal/drives/guangyapan" "github.com/video-site/backend/internal/drives/localstorage" "github.com/video-site/backend/internal/drives/localupload" "github.com/video-site/backend/internal/drives/onedrive" @@ -355,10 +356,10 @@ type App struct { // 全站主题("dark" | "pink" | "sky"),从 DB 读 theme string // 显式指定的 spider91 上传目标 drive ID。 - // 空字符串表示本地保存不上传,不再自动挑选 pikpak/p115/p123/onedrive/wopan drive。 + // 空字符串表示本地保存不上传,不再自动挑选 pikpak/p115/p123/onedrive/wopan/guangyapan drive。 spider91UploadDriveID string - // spider91Migrator 把 spider91 视频上传到目标 drive(PikPak、115、123、OneDrive、Google Drive 或联通网盘)。 + // spider91Migrator 把 spider91 视频上传到目标 drive(PikPak、115、123、OneDrive、Google Drive、联通网盘或光鸭网盘)。 spider91Migrator spider91MigrationRunner // nightlyRunner 是凌晨流水线调度器:每天 cron_hour 串行跑扫盘 → 91 爬虫 → 迁移。 @@ -401,8 +402,9 @@ type App struct { } type driveScanProgress struct { - Scanned int - Added int + Scanned int + Added int + CooldownUntil time.Time } type driveUploadProgress struct { @@ -479,7 +481,7 @@ func (a *App) loadTheme(ctx context.Context) { } // Spider91UploadDriveID 返回当前配置的 spider91 上传目标 drive ID。 -// 空字符串表示本地保存不上传;只有管理员显式选择 pikpak/p115/p123/onedrive/googledrive/wopan drive 时才迁移上传。 +// 空字符串表示本地保存不上传;只有管理员显式选择 pikpak/p115/p123/onedrive/googledrive/wopan/guangyapan drive 时才迁移上传。 func (a *App) Spider91UploadDriveID() string { a.mu.Lock() explicit := a.spider91UploadDriveID @@ -496,7 +498,7 @@ func (a *App) Spider91UploadDriveID() string { // SetSpider91UploadDriveID 设置 spider91 上传目标 drive ID 并持久化。 // 接受空字符串(本地保存不上传)。 -// 设置一个不存在或 kind 不是 pikpak / p115 / p123 / onedrive / googledrive / wopan 的 drive 会返回错误。 +// 设置一个不存在或 kind 不是 pikpak / p115 / p123 / onedrive / googledrive / wopan / guangyapan 的 drive 会返回错误。 func (a *App) SetSpider91UploadDriveID(ctx context.Context, driveID string) error { driveID = strings.TrimSpace(driveID) if driveID != "" { @@ -505,7 +507,7 @@ func (a *App) SetSpider91UploadDriveID(ctx context.Context, driveID string) erro return fmt.Errorf("drive %q not found", driveID) } if !isSpider91UploadKind(d.Kind()) { - return fmt.Errorf("drive %q kind=%s, only pikpak, p115, p123, onedrive, googledrive or wopan can be spider91 upload target", driveID, d.Kind()) + return fmt.Errorf("drive %q kind=%s, only pikpak, p115, p123, onedrive, googledrive, wopan or guangyapan can be spider91 upload target", driveID, d.Kind()) } } a.mu.Lock() @@ -538,7 +540,7 @@ func formatOptionalRFC3339(t time.Time) string { // isSpider91UploadKind 是 spider91 迁移目标盘的 allowlist。 // 与 spider91migrate.adaptUploadTarget 的支持范围保持一致。 func isSpider91UploadKind(kind string) bool { - return kind == "pikpak" || kind == "p115" || kind == "p123" || kind == "onedrive" || kind == "googledrive" || kind == "wopan" + return kind == "pikpak" || kind == "p115" || kind == "p123" || kind == "onedrive" || kind == "googledrive" || kind == "wopan" || kind == guangyapan.Kind } // loadSpider91UploadDriveID 从 DB 读上传目标 drive ID 设置;不存在时使用空串。 @@ -595,17 +597,25 @@ func (a *App) driveGenerationStatuses() map[string]api.DriveGenerationStatuses { a.transcodeMu.Unlock() out := make(map[string]api.DriveGenerationStatuses, len(scanningDrives)+len(previewWorkers)+len(thumbWorkers)+len(fingerprintWorkers)+len(uploadProgresses)+len(transcodeWorkers)) + now := time.Now() for id, running := range scanningDrives { if !running { continue } progress := scanProgresses[id] + state := "scanning" + if progress.CooldownUntil.After(now) { + state = "cooling" + } status := out[id] status.Scan = api.GenerationStatus{ - State: "scanning", + State: state, ScannedCount: progress.Scanned, AddedCount: progress.Added, } + if !progress.CooldownUntil.IsZero() { + status.Scan.CooldownUntil = progress.CooldownUntil.Format(time.RFC3339) + } out[id] = status } for id, worker := range previewWorkers { @@ -961,6 +971,33 @@ func (a *App) attachDriveUnlocked(ctx context.Context, d *catalog.Drive) error { _ = a.cat.UpsertDrive(ctx, d) }, }) + case guangyapan.Kind: + drv = guangyapan.New(guangyapan.Config{ + ID: d.ID, + RootID: d.RootID, + RootPath: d.Credentials["root_path"], + PhoneNumber: d.Credentials["phone_number"], + CaptchaToken: d.Credentials["captcha_token"], + SendCode: parseBoolDefault(strings.TrimSpace(d.Credentials["send_code"]), false), + VerifyCode: d.Credentials["verify_code"], + VerificationID: d.Credentials["verification_id"], + AccessToken: d.Credentials["access_token"], + RefreshToken: d.Credentials["refresh_token"], + ClientID: d.Credentials["client_id"], + DeviceID: d.Credentials["device_id"], + PageSize: parseIntDefault(strings.TrimSpace(d.Credentials["page_size"]), 100), + OrderBy: parseIntDefault(strings.TrimSpace(d.Credentials["order_by"]), 3), + SortType: parseIntDefault(strings.TrimSpace(d.Credentials["sort_type"]), 1), + OnCredentialsUpdate: func(updated map[string]string) { + if d.Credentials == nil { + d.Credentials = make(map[string]string) + } + for k, v := range updated { + d.Credentials[k] = v + } + _ = a.cat.UpsertDrive(ctx, d) + }, + }) case "onedrive": drv = onedrive.New(onedrive.Config{ ID: d.ID, @@ -1081,7 +1118,7 @@ func generationCooldownForDrive(drv drives.Drive) time.Duration { return 0 } switch strings.ToLower(drv.Kind()) { - case "wopan": + case "wopan", "guangyapan": return 10 * time.Minute } return 0 @@ -1107,7 +1144,7 @@ func fingerprintConfigForDrive(drv drives.Drive) fingerprint.Config { return cfg } switch strings.ToLower(drv.Kind()) { - case "p115", "p123", "onedrive", "wopan": + case "p115", "p123", "onedrive", "wopan", "guangyapan": cfg.RateLimitCooldown = 10 * time.Minute case "pikpak": cfg.RateLimitCooldown = 5 * time.Minute @@ -1439,11 +1476,77 @@ func (a *App) updateDriveScanProgress(driveID string, scanned, added int) { if a.scanProgress == nil { a.scanProgress = make(map[string]driveScanProgress) } - a.scanProgress[driveID] = driveScanProgress{Scanned: scanned, Added: added} + progress := a.scanProgress[driveID] + progress.Scanned = scanned + progress.Added = added + a.scanProgress[driveID] = progress } a.scanQueueMu.Unlock() } +func (a *App) updateDriveScanCooldown(driveID string, until time.Time) { + driveID = strings.TrimSpace(driveID) + if driveID == "" { + return + } + a.scanQueueMu.Lock() + if a.scanQueued[driveID] { + if a.scanProgress == nil { + a.scanProgress = make(map[string]driveScanProgress) + } + progress := a.scanProgress[driveID] + progress.CooldownUntil = until + a.scanProgress[driveID] = progress + } + a.scanQueueMu.Unlock() +} + +func (a *App) pauseDriveScanForRateLimit(ctx context.Context, driveID string, drv drives.Drive, err error) bool { + wait, ok := drives.RateLimitRetryAfter(err) + if !ok { + return false + } + if wait <= 0 { + wait = scanCooldownForDrive(drv) + } + if wait <= 0 { + wait = 5 * time.Minute + } + until := time.Now().Add(wait) + a.updateDriveScanCooldown(driveID, until) + log.Printf("[scan] drive=%s rate limited; cooling until=%s wait=%s: %v", driveID, until.Format(time.RFC3339), wait, err) + if !sleepDriveScanCooldown(ctx, wait) { + log.Printf("[scan] drive=%s cooldown canceled: %v", driveID, ctx.Err()) + } + return true +} + +func scanCooldownForDrive(drv drives.Drive) time.Duration { + if drv == nil { + return 5 * time.Minute + } + switch strings.ToLower(drv.Kind()) { + case "guangyapan": + return 10 * time.Minute + default: + return 5 * time.Minute + } +} + +func sleepDriveScanCooldown(ctx context.Context, d time.Duration) bool { + if d <= 0 { + return true + } + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} + func (a *App) driveHasActiveWork(driveID string) bool { driveID = strings.TrimSpace(driveID) if driveID == "" { @@ -1908,6 +2011,8 @@ func (a *App) runScanWithTaskContext(ctx context.Context, driveID string) { if err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { log.Printf("[scan] drive=%s canceled: %v", driveID, err) + } else if a.pauseDriveScanForRateLimit(ctx, driveID, drv, err) { + return } else { log.Printf("[scan] drive=%s error: %v", driveID, err) } @@ -3365,3 +3470,14 @@ func parseBoolDefault(raw string, def bool) bool { } return v } + +func parseIntDefault(raw string, def int) int { + if raw == "" { + return def + } + v, err := strconv.Atoi(raw) + if err != nil { + return def + } + return v +} diff --git a/backend/cmd/server/main_spider91_test.go b/backend/cmd/server/main_spider91_test.go index ddeaaf7..8cfcd6b 100644 --- a/backend/cmd/server/main_spider91_test.go +++ b/backend/cmd/server/main_spider91_test.go @@ -41,6 +41,7 @@ func TestSpider91UploadDriveIDDoesNotAutoSelectTarget(t *testing.T) { reg.Set("p123-one", &spider91UploadTargetFakeDrive{id: "p123-one", kind: "p123"}) reg.Set("onedrive-one", &spider91UploadTargetFakeDrive{id: "onedrive-one", kind: "onedrive"}) reg.Set("wopan-one", &spider91UploadTargetFakeDrive{id: "wopan-one", kind: "wopan"}) + reg.Set("guangyapan-one", &spider91UploadTargetFakeDrive{id: "guangyapan-one", kind: "guangyapan"}) app := &App{registry: reg} if got := app.Spider91UploadDriveID(); got != "" { @@ -67,6 +68,11 @@ func TestSpider91UploadDriveIDDoesNotAutoSelectTarget(t *testing.T) { t.Fatalf("explicit wopan upload target = %q, want wopan-one", got) } + app.spider91UploadDriveID = "guangyapan-one" + if got := app.Spider91UploadDriveID(); got != "guangyapan-one" { + t.Fatalf("explicit guangyapan upload target = %q, want guangyapan-one", got) + } + app.spider91UploadDriveID = "missing" if got := app.Spider91UploadDriveID(); got != "" { t.Fatalf("missing upload target = %q, want empty", got) diff --git a/backend/cmd/server/main_test.go b/backend/cmd/server/main_test.go index 1ebbc4e..3969c0a 100644 --- a/backend/cmd/server/main_test.go +++ b/backend/cmd/server/main_test.go @@ -391,6 +391,37 @@ func TestDriveGenerationStatusIncludesScanState(t *testing.T) { } } +func TestDriveGenerationStatusIncludesScanCooldown(t *testing.T) { + until := time.Now().Add(time.Hour).Round(time.Second) + app := &App{ + scanQueued: map[string]bool{"drive-id": true}, + scanProgress: map[string]driveScanProgress{ + "drive-id": {Scanned: 12, Added: 3, CooldownUntil: until}, + }, + } + + status := app.driveGenerationStatuses()["drive-id"].Scan + if status.State != "cooling" { + t.Fatalf("scan status = %#v, want cooling", status) + } + if status.CooldownUntil != until.Format(time.RFC3339) { + t.Fatalf("cooldown until = %q, want %q", status.CooldownUntil, until.Format(time.RFC3339)) + } +} + +func TestGuangYaPanGenerationCooldowns(t *testing.T) { + drv := &serverFakeKindDrive{id: "gy", kind: "guangyapan"} + if got := generationCooldownForDrive(drv); got != 10*time.Minute { + t.Fatalf("generation cooldown = %s, want 10m", got) + } + if got := fingerprintConfigForDrive(drv).RateLimitCooldown; got != 10*time.Minute { + t.Fatalf("fingerprint cooldown = %s, want 10m", got) + } + if got := scanCooldownForDrive(drv); got != 10*time.Minute { + t.Fatalf("scan cooldown = %s, want 10m", got) + } +} + func TestRunSpider91MigrationAfterManualCrawlRequiresConfiguredUploadTarget(t *testing.T) { ctx := context.Background() registry := proxy.NewRegistry() diff --git a/backend/config.example.yaml b/backend/config.example.yaml index c1bea7f..e6278ae 100644 --- a/backend/config.example.yaml +++ b/backend/config.example.yaml @@ -56,7 +56,7 @@ preview: width: 480 # 盘列表。上线后请通过管理后台添加,本文件可留空。 -# kind 支持 quark / p115 / p123 / pikpak / wopan / onedrive / googledrive / localstorage。 +# kind 支持 quark / p115 / p123 / pikpak / wopan / guangyapan / onedrive / googledrive / localstorage。 # OneDrive 示例: # - id: "my-onedrive" # kind: "onedrive" @@ -76,6 +76,17 @@ preview: # # use_online_api: "false" # # client_id: "..." # # client_secret: "..." +# 光鸭网盘示例: +# - id: "my-guangyapan" +# kind: "guangyapan" +# name: "我的光鸭网盘" +# # 留空表示光鸭网盘根目录;也可以填写光鸭目录 fileId +# root_id: "" +# params: +# # 推荐在后台使用扫码登录自动写入 access_token / refresh_token。 +# refresh_token: "..." +# # 可选:按路径解析扫描根目录,优先于 root_id +# # root_path: "影视/电影" # 本地存储示例: # - id: "local-media" # kind: "localstorage" diff --git a/backend/internal/api/admin.go b/backend/internal/api/admin.go index 616ec07..cec70e2 100644 --- a/backend/internal/api/admin.go +++ b/backend/internal/api/admin.go @@ -21,6 +21,7 @@ import ( "github.com/video-site/backend/internal/auth" "github.com/video-site/backend/internal/catalog" + "github.com/video-site/backend/internal/drives/guangyapan" "github.com/video-site/backend/internal/drives/p123" "github.com/video-site/backend/internal/drives/scriptcrawler" "github.com/video-site/backend/internal/drives/spider91" @@ -48,24 +49,24 @@ type AdminServer struct { // LocalPreviewDir is the local directory that stores generated preview videos and thumbs. LocalPreviewDir string // Hooks:外层注入实际执行者 - OnDriveSaved func(driveID string) error - OnDriveDeleteCleanup func(ctx context.Context, driveID string) (int, error) - OnDriveRemoved func(driveID string) - OnScanRequested func(driveID string) bool - OnStopDriveTasks func(driveID string) bool - OnStopAllTasks func() int - OnRegenPreview func(videoID string) - OnRegenAllPreviews func() - OnRegenFailedPreviews func(driveID string) - OnRegenFailedThumbnails func(driveID string) - OnRegenFailedFingerprints func(driveID string) + OnDriveSaved func(driveID string) error + OnDriveDeleteCleanup func(ctx context.Context, driveID string) (int, error) + OnDriveRemoved func(driveID string) + OnScanRequested func(driveID string) bool + OnStopDriveTasks func(driveID string) bool + OnStopAllTasks func() int + OnRegenPreview func(videoID string) + OnRegenAllPreviews func() + OnRegenFailedPreviews func(driveID string) + OnRegenFailedThumbnails func(driveID string) + OnRegenFailedFingerprints func(driveID string) // OnStartDriveTranscode 手动开启某盘的浏览器兼容性转码任务。 // 返回 (是否接受, 拒绝原因)。转码从不自动运行,只能在这里手动触发; // 处理完候选列表后任务自然结束。 OnStartDriveTranscode func(driveID string) (bool, string) // OnStopDriveTranscode 手动停止某盘正在进行的转码任务。返回是否有任务被停。 - OnStopDriveTranscode func(driveID string) bool - OnDeleteVideo func(ctx context.Context, videoID string, deleteSource bool) (DeleteVideoResult, error) + OnStopDriveTranscode func(driveID string) bool + OnDeleteVideo func(ctx context.Context, videoID string, deleteSource bool) (DeleteVideoResult, error) GetDriveGenerationStatuses func() map[string]DriveGenerationStatuses // OnTeaserEnabledChanged 在 per-drive 预览视频开关被切换后调用。 // enabled=true 时上层应该重新把 pending 预览视频入队(类似旧的全局开关从关到开); @@ -74,7 +75,7 @@ type AdminServer struct { // Theme 读写("dark" | "pink" | "sky") GetTheme func() string SetTheme func(theme string) error - // Spider91 → 115/123/PikPak/OneDrive/Google Drive/联通网盘 上传目标 drive ID 读写 + // Spider91 → 115/123/PikPak/OneDrive/Google Drive/联通网盘/光鸭网盘 上传目标 drive ID 读写 GetSpider91UploadDriveID func() string SetSpider91UploadDriveID func(driveID string) error // OnRunNightlyJob 触发一次完整的凌晨流水线(Phase1 扫盘 + Phase2 91 爬虫 + @@ -94,6 +95,9 @@ type AdminServer struct { // 联通网盘扫码登录接口测试注入;生产留空走官方 panservice.mail.wo.cn。 WopanQRAPIBaseURL string WopanQRHTTPClient *http.Client + // 光鸭网盘扫码登录接口测试注入;生产留空走官方 account.guangyapan.com。 + GuangYaPanAccountBaseURL string + GuangYaPanHTTPClient *http.Client } const ( @@ -167,6 +171,8 @@ func (a *AdminServer) Register(r chi.Router) { r.Get("/drives/p123/qr/{uniID}", a.handleP123QRStatus) r.Post("/drives/wopan/qr", a.handleWopanQRStart) r.Get("/drives/wopan/qr/{uuid}", a.handleWopanQRStatus) + r.Post("/drives/guangyapan/qr", a.handleGuangYaPanQRStart) + r.Get("/drives/guangyapan/qr/status", a.handleGuangYaPanQRStatus) r.Delete("/drives/{id}", a.handleDeleteDrive) r.Post("/drives/{id}/rescan", a.handleRescan) r.Post("/drives/{id}/tasks/stop", a.handleStopDriveTasks) @@ -471,11 +477,11 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) { SkipDirIDs []string `json:"skipDirIds"` // LastCrawlAt 是 spider91 上次成功爬取的 unix 秒(来自 credentials.last_crawl_at)。 // 其它 kind 留 0;前端用它显示"上次抓取: N 小时前"。 - Spider91Proxy string `json:"spider91Proxy,omitempty"` - LastCrawlAt int64 `json:"lastCrawlAt,omitempty"` - GoogleDriveUseOnlineAPI *bool `json:"googleDriveUseOnlineAPI,omitempty"` + Spider91Proxy string `json:"spider91Proxy,omitempty"` + LastCrawlAt int64 `json:"lastCrawlAt,omitempty"` + GoogleDriveUseOnlineAPI *bool `json:"googleDriveUseOnlineAPI,omitempty"` // STRMAllowOutsideRoot 是 localstorage 的 .strm 越root开关;其它 kind 省略。 - STRMAllowOutsideRoot *bool `json:"strmAllowOutsideRoot,omitempty"` + STRMAllowOutsideRoot *bool `json:"strmAllowOutsideRoot,omitempty"` ScanGenerationStatus GenerationStatus `json:"scanGenerationStatus"` ThumbnailGenerationStatus GenerationStatus `json:"thumbnailGenerationStatus"` PreviewGenerationStatus GenerationStatus `json:"previewGenerationStatus"` @@ -620,9 +626,9 @@ func (a *AdminServer) handleUpsertDrive(w http.ResponseWriter, r *http.Request) return } body.Credentials = credentials - } else if body.Kind == "googledrive" || body.Kind == "localstorage" { - // 按键合并、空值沿用旧值:localstorage 编辑表单里 path 留空表示不改, - // 但 strm_allow_outside_root 开关每次都会带值,必须逐键合并而不是整体替换。 + } else if body.Kind == "googledrive" || body.Kind == "localstorage" || body.Kind == "guangyapan" { + // 按键合并、空值沿用旧值:这些网盘的编辑表单允许只改某几个字段, + // 其它 token / 路径 / 开关字段应保留旧值。 body.Credentials = mergeNonEmptyCredentials(existing, body.Credentials) } else if len(body.Credentials) == 0 && existing != nil && len(existing.Credentials) > 0 { body.Credentials = existing.Credentials @@ -931,7 +937,7 @@ func (a *AdminServer) validateCrawlerUploadDrive(ctx context.Context, driveID st return fmt.Errorf("上传目标网盘 %q 不存在", driveID) } if !isCrawlerUploadTargetKind(d.Kind) { - return fmt.Errorf("上传目标网盘 %q 类型为 %s,仅支持 115网盘、PikPak、123网盘、Google Drive、OneDrive、联通网盘", driveID, d.Kind) + return fmt.Errorf("上传目标网盘 %q 类型为 %s,仅支持 115网盘、PikPak、123网盘、Google Drive、OneDrive、联通网盘、光鸭网盘", driveID, d.Kind) } return nil } @@ -1395,7 +1401,7 @@ func googleDriveUseOnlineAPIForDrive(d *catalog.Drive) *bool { } // mergeNonEmptyCredentials 逐键合并凭证:incoming 里非空的键覆盖旧值, -// 空值/缺失的键沿用旧值。googledrive 和 localstorage 的编辑表单都依赖 +// 空值/缺失的键沿用旧值。googledrive、localstorage 和 guangyapan 的编辑表单都依赖 // 这个语义(留空 = 不修改)。 func mergeNonEmptyCredentials(existing *catalog.Drive, incoming map[string]string) map[string]string { merged := map[string]string{} @@ -1696,6 +1702,38 @@ func (a *AdminServer) handleWopanQRStatus(w http.ResponseWriter, r *http.Request writeJSON(w, http.StatusOK, status) } +func (a *AdminServer) guangYaPanQRClient() *guangyapan.QRClient { + return guangyapan.NewQRClient(guangyapan.QRConfig{ + AccountBaseURL: a.GuangYaPanAccountBaseURL, + HTTPClient: a.GuangYaPanHTTPClient, + }) +} + +func (a *AdminServer) handleGuangYaPanQRStart(w http.ResponseWriter, r *http.Request) { + session, err := a.guangYaPanQRClient().Generate(r.Context()) + if err != nil { + writeErr(w, http.StatusBadGateway, err) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, session) +} + +func (a *AdminServer) handleGuangYaPanQRStatus(w http.ResponseWriter, r *http.Request) { + deviceCode := r.URL.Query().Get("deviceCode") + if strings.TrimSpace(deviceCode) == "" { + http.Error(w, "deviceCode is required", http.StatusBadRequest) + return + } + status, err := a.guangYaPanQRClient().Poll(r.Context(), deviceCode) + if err != nil { + writeErr(w, http.StatusBadGateway, err) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, status) +} + // handleRunNightlyJob 触发一次完整的凌晨流水线(不论当前时间,不论今日是否已跑)。 // 立即返回 202;进度通过 backend 日志和下次 GET /admin/api/drives 的状态变化观察。 // 流水线已在跑或已排队时,Runner 会拒绝重复触发。 diff --git a/backend/internal/api/admin_test.go b/backend/internal/api/admin_test.go index 4583588..2ef7e28 100644 --- a/backend/internal/api/admin_test.go +++ b/backend/internal/api/admin_test.go @@ -1704,6 +1704,94 @@ func TestHandleWopanQRStatus(t *testing.T) { } } +func TestHandleGuangYaPanQRStart(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path != "/v1/auth/device/code" { + http.NotFound(w, r) + return + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body["scope"] != "user" { + t.Fatalf("scope = %#v, want user", body["scope"]) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "device_code": "device-1", + "verification_uri_complete": "https://account.guangyapan.example/device?code=abc", + "interval": 5, + "expires_in": 300, + }) + })) + t.Cleanup(upstream.Close) + + req := httptest.NewRequest(http.MethodPost, "/admin/api/drives/guangyapan/qr", nil) + rr := httptest.NewRecorder() + (&AdminServer{GuangYaPanAccountBaseURL: upstream.URL}).handleGuangYaPanQRStart(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + var got struct { + DeviceCode string `json:"deviceCode"` + QRCodeURL string `json:"qrCodeUrl"` + QRImageDataURL string `json:"qrImageDataUrl"` + } + if err := json.NewDecoder(rr.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.DeviceCode != "device-1" || got.QRCodeURL != "https://account.guangyapan.example/device?code=abc" { + t.Fatalf("response = %#v", got) + } + if !strings.HasPrefix(got.QRImageDataURL, "data:image/png;base64,") { + t.Fatalf("qr image = %q", got.QRImageDataURL) + } +} + +func TestHandleGuangYaPanQRStatus(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path != "/v1/auth/token" { + http.NotFound(w, r) + return + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body["device_code"] != "device-1" { + t.Fatalf("device_code = %#v, want device-1", body["device_code"]) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "access-1", + "refresh_token": "refresh-1", + "token_type": "Bearer", + }) + })) + t.Cleanup(upstream.Close) + + req := httptest.NewRequest(http.MethodGet, "/admin/api/drives/guangyapan/qr/status?deviceCode=device-1", nil) + rr := httptest.NewRecorder() + (&AdminServer{GuangYaPanAccountBaseURL: upstream.URL}).handleGuangYaPanQRStatus(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + var got struct { + State string `json:"state"` + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + } + if err := json.NewDecoder(rr.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.State != "success" || got.AccessToken != "access-1" || got.RefreshToken != "refresh-1" { + t.Fatalf("response = %#v", got) + } +} + func TestHandleTestCrawlerScriptRunsImportedScript(t *testing.T) { if _, err := exec.LookPath("python3"); err != nil { t.Skip("python3 is required for crawler script dry-run") diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 6b1c227..a5d5b40 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -1068,6 +1068,8 @@ func driveKindLabel(kind string) string { return "PikPak" case "wopan": return "联通网盘" + case "guangyapan": + return "光鸭网盘" case "onedrive": return "OneDrive" case "googledrive": diff --git a/backend/internal/catalog/catalog.go b/backend/internal/catalog/catalog.go index 00dd549..e2a3dfe 100644 --- a/backend/internal/catalog/catalog.go +++ b/backend/internal/catalog/catalog.go @@ -1992,7 +1992,7 @@ func normalizeDriveRootFields(d *Drive) { func normalizeDriveRootID(kind, rootID string) string { rootID = strings.TrimSpace(rootID) switch kind { - case "pikpak": + case "pikpak", "guangyapan": if rootID == "0" { return "" } diff --git a/backend/internal/catalog/drives_test.go b/backend/internal/catalog/drives_test.go index 57535e5..67be673 100644 --- a/backend/internal/catalog/drives_test.go +++ b/backend/internal/catalog/drives_test.go @@ -58,6 +58,7 @@ func TestUpsertDriveDefaultsRootIDByKind(t *testing.T) { }{ {id: "p115", kind: "p115", want: "0"}, {id: "pikpak", kind: "pikpak", want: ""}, + {id: "guangyapan", kind: "guangyapan", want: ""}, {id: "onedrive", kind: "onedrive", want: "root"}, {id: "googledrive", kind: "googledrive", want: "root"}, {id: "localstorage", kind: "localstorage", want: "/"}, diff --git a/backend/internal/catalog/schema.sql b/backend/internal/catalog/schema.sql index bc4ce69..540dd0b 100644 --- a/backend/internal/catalog/schema.sql +++ b/backend/internal/catalog/schema.sql @@ -114,7 +114,7 @@ CREATE INDEX IF NOT EXISTS idx_crawler_seen_sources_drive -- 网盘账户 CREATE TABLE IF NOT EXISTS drives ( id TEXT PRIMARY KEY, - kind TEXT NOT NULL, -- quark / p115 / p123 / pikpak / wopan / onedrive / googledrive / localstorage / spider91 + kind TEXT NOT NULL, -- quark / p115 / p123 / pikpak / wopan / guangyapan / onedrive / googledrive / localstorage / spider91 name TEXT NOT NULL, root_id TEXT NOT NULL DEFAULT '0', scan_root_id TEXT, -- deprecated: 扫描起点固定等于 root_id diff --git a/backend/internal/catalog/tags.go b/backend/internal/catalog/tags.go index 534861d..c0557e6 100644 --- a/backend/internal/catalog/tags.go +++ b/backend/internal/catalog/tags.go @@ -124,6 +124,9 @@ CREATE TABLE IF NOT EXISTS deleted_videos ( if err := c.reconcileThumbnailStatusOnce(ctx); err != nil { return err } + if err := c.requeueSkippedPreviews(ctx); err != nil { + return err + } if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_videos_content_hash ON videos(content_hash)`); err != nil { return err } @@ -296,6 +299,24 @@ UPDATE videos return nil } +func (c *Catalog) requeueSkippedPreviews(ctx context.Context) error { + res, err := c.db.ExecContext(ctx, ` +UPDATE videos + SET preview_file_id = '', + preview_local = '', + preview_status = 'pending', + updated_at = ? + WHERE COALESCE(preview_status, 'pending') = 'skipped' +`, time.Now().UnixMilli()) + if err != nil { + return fmt.Errorf("requeue skipped previews: %w", err) + } + if affected, err := res.RowsAffected(); err == nil && affected > 0 { + log.Printf("[catalog] requeued %d skipped preview(s) for generation", affected) + } + return nil +} + func (c *Catalog) clearVolatileOneDriveThumbnails(ctx context.Context) error { // 把 OneDrive 过期的 mediap.svc.ms thumb URL 清空,让 worker 重新抽帧生成本地封面。 // 同步把 thumbnail_status 重置为 'pending':清空后 url 是空的,本应进 worker 重做, diff --git a/backend/internal/catalog/tags_test.go b/backend/internal/catalog/tags_test.go index 10210ad..0228d85 100644 --- a/backend/internal/catalog/tags_test.go +++ b/backend/internal/catalog/tags_test.go @@ -1539,6 +1539,70 @@ func TestReconcileThumbnailStatusOnce(t *testing.T) { } } +func TestRequeueSkippedPreviews(t *testing.T) { + ctx := context.Background() + cat, err := Open(t.TempDir() + "/catalog.db") + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { cat.Close() }) + + now := time.Now() + cases := []struct { + id string + status string + local string + fileID string + wantStatus string + wantLocal string + wantFileID string + }{ + {"preview-skipped", "skipped", "/tmp/old-preview.mp4", "old-preview-file", "pending", "", ""}, + {"preview-ready", "ready", "/tmp/ready-preview.mp4", "ready-preview-file", "ready", "/tmp/ready-preview.mp4", "ready-preview-file"}, + {"preview-failed", "failed", "/tmp/failed-preview.mp4", "failed-preview-file", "failed", "/tmp/failed-preview.mp4", "failed-preview-file"}, + } + for _, c := range cases { + if err := cat.UpsertVideo(ctx, &Video{ + ID: c.id, DriveID: "d", FileID: "source-" + c.id, Title: c.id, + PreviewStatus: c.status, PreviewLocal: c.local, PreviewFileID: c.fileID, + PublishedAt: now, CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("seed %s: %v", c.id, err) + } + } + + if err := cat.requeueSkippedPreviews(ctx); err != nil { + t.Fatalf("requeue skipped previews: %v", err) + } + if err := cat.requeueSkippedPreviews(ctx); err != nil { + t.Fatalf("second requeue skipped previews: %v", err) + } + + for _, c := range cases { + got, err := cat.GetVideo(ctx, c.id) + if err != nil { + t.Fatalf("get %s: %v", c.id, err) + } + if got.PreviewStatus != c.wantStatus { + t.Errorf("%s: preview status = %q, want %q", c.id, got.PreviewStatus, c.wantStatus) + } + if got.PreviewLocal != c.wantLocal { + t.Errorf("%s: preview local = %q, want %q", c.id, got.PreviewLocal, c.wantLocal) + } + if got.PreviewFileID != c.wantFileID { + t.Errorf("%s: preview file id = %q, want %q", c.id, got.PreviewFileID, c.wantFileID) + } + } + + pending, err := cat.ListVideosByPreviewStatus(ctx, "d", "pending", 0) + if err != nil { + t.Fatalf("list pending previews: %v", err) + } + if len(pending) != 1 || pending[0].ID != "preview-skipped" { + t.Fatalf("pending previews = %#v, want only preview-skipped", pending) + } +} + // TestUpsertVideoSyncsThumbnailStatus 验证 scanner 创建/补回视频时 // thumbnail_status 跟随 thumbnail_url 自动设。这是历史 bug 的修复回归测试 —— // 之前 UpsertVideo 的 SQL 不带 thumbnail_status 列,所有新视频都依赖 diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 74a4295..cd0a2f0 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -207,7 +207,7 @@ type Nightly struct { // 这里保留 yaml 中的静态定义,用于启动时预置盘。生产建议只在 DB 里维护。 type Drive struct { ID string `yaml:"id"` - Kind string `yaml:"kind"` // quark / p115 / p123 / pikpak / wopan / onedrive / googledrive / localstorage + Kind string `yaml:"kind"` // quark / p115 / p123 / pikpak / wopan / guangyapan / onedrive / googledrive / localstorage Name string `yaml:"name"` RootID string `yaml:"root_id"` Params map[string]string `yaml:"params,omitempty"` diff --git a/backend/internal/drives/googledrive/driver.go b/backend/internal/drives/googledrive/driver.go index 1454dfc..2ab1baa 100644 --- a/backend/internal/drives/googledrive/driver.go +++ b/backend/internal/drives/googledrive/driver.go @@ -647,7 +647,7 @@ func isGoogleUploadHTTPRateLimit(status int, header http.Header, body []byte, ap if isGoogleRateLimit(nil, apiErr) { return true } - return googleLimitText(string(body)) + return false } func googleUploadRateLimitError(status int, header http.Header, body []byte, message string) error { @@ -910,7 +910,7 @@ func isGoogleRateLimit(res *resty.Response, body apiErrorBody) bool { return true } for _, e := range body.Errors { - if googleLimitReason(e.Reason) || googleLimitText(e.Message) { + if googleLimitReason(e.Reason) { return true } domain := compactGoogleLimitText(e.Domain) @@ -918,7 +918,7 @@ func isGoogleRateLimit(res *resty.Response, body apiErrorBody) bool { return true } } - return googleLimitText(body.Message) + return false } func isGoogleTokenRateLimit(res *resty.Response, out tokenResp) bool { @@ -930,9 +930,7 @@ func isGoogleTokenRateLimit(res *resty.Response, out tokenResp) bool { return true } } - return googleLimitText(out.Text) || - googleLimitText(out.Error) || - googleLimitText(out.ErrorDescription) + return googleLimitReason(out.Error) } func googleLimitReason(reason string) bool { @@ -953,31 +951,6 @@ func googleLimitReason(reason string) bool { } } -func googleLimitText(text string) bool { - text = strings.ToLower(strings.TrimSpace(text)) - if text == "" { - return false - } - compact := compactGoogleLimitText(text) - if strings.Contains(compact, "ratelimitexceeded") || - strings.Contains(compact, "userratelimitexceeded") || - strings.Contains(compact, "dailylimitexceeded") || - strings.Contains(compact, "downloadquotaexceeded") || - strings.Contains(compact, "sharingratelimitexceeded") || - strings.Contains(compact, "quotaexceeded") || - strings.Contains(compact, "toomanyrequests") { - return true - } - return strings.Contains(text, "rate limit") || - strings.Contains(text, "too many requests") || - strings.Contains(text, "quota exceeded") || - strings.Contains(text, "download quota") || - strings.Contains(text, "sharing rate") || - strings.Contains(text, "daily limit") || - strings.Contains(text, "user rate") || - strings.Contains(text, "usage limit") -} - func compactGoogleLimitText(text string) string { text = strings.ToLower(strings.TrimSpace(text)) replacer := strings.NewReplacer("_", "", "-", "", " ", "", ".", "", ":", "") diff --git a/backend/internal/drives/guangyapan/driver.go b/backend/internal/drives/guangyapan/driver.go new file mode 100644 index 0000000..511872a --- /dev/null +++ b/backend/internal/drives/guangyapan/driver.go @@ -0,0 +1,1130 @@ +package guangyapan + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "reflect" + "strconv" + "strings" + "sync" + "time" + + "github.com/aliyun/aliyun-oss-go-sdk/oss" + "github.com/go-resty/resty/v2" + + "github.com/video-site/backend/internal/drives" +) + +const ( + Kind = "guangyapan" + + defaultAccountBaseURL = "https://account.guangyapan.com" + defaultAPIBaseURL = "https://api.guangyapan.com" + defaultClientID = "aMe-8VSlkrbQXpUR" + defaultPageSize = 100 +) + +type Driver struct { + id string + rootID string + rootPath string + phoneNumber string + captchaToken string + sendCode bool + verifyCode string + verificationID string + accessToken string + refreshToken string + clientID string + deviceID string + pageSize int + orderBy int + sortType int + accountBaseURL string + apiBaseURL string + accountClient *resty.Client + apiClient *resty.Client + + onCredentialsUpdate func(map[string]string) + + fileMu sync.RWMutex + files map[string]drives.Entry +} + +type Config struct { + ID string + RootID string + RootPath string + PhoneNumber string + CaptchaToken string + SendCode bool + VerifyCode string + VerificationID string + AccessToken string + RefreshToken string + ClientID string + DeviceID string + PageSize int + OrderBy int + SortType int + AccountBaseURL string + APIBaseURL string + + OnCredentialsUpdate func(map[string]string) +} + +func New(c Config) *Driver { + rootID := strings.TrimSpace(c.RootID) + if rootID == "0" { + rootID = "" + } + clientID := strings.TrimSpace(c.ClientID) + if clientID == "" { + clientID = defaultClientID + } + deviceID := normalizeDeviceID(c.DeviceID) + if deviceID == "" { + deviceID = randomDeviceID() + } + pageSize := c.PageSize + if pageSize <= 0 { + pageSize = defaultPageSize + } + orderBy := c.OrderBy + if orderBy < 0 { + orderBy = 3 + } + sortType := c.SortType + if sortType != 0 && sortType != 1 { + sortType = 1 + } + accountBaseURL := strings.TrimRight(strings.TrimSpace(c.AccountBaseURL), "/") + if accountBaseURL == "" { + accountBaseURL = defaultAccountBaseURL + } + apiBaseURL := strings.TrimRight(strings.TrimSpace(c.APIBaseURL), "/") + if apiBaseURL == "" { + apiBaseURL = defaultAPIBaseURL + } + d := &Driver{ + id: strings.TrimSpace(c.ID), + rootID: rootID, + rootPath: strings.TrimSpace(c.RootPath), + phoneNumber: strings.TrimSpace(c.PhoneNumber), + captchaToken: strings.TrimSpace(c.CaptchaToken), + sendCode: c.SendCode, + verifyCode: strings.TrimSpace(c.VerifyCode), + verificationID: strings.TrimSpace(c.VerificationID), + accessToken: normalizeAccessToken(c.AccessToken), + refreshToken: strings.TrimSpace(c.RefreshToken), + clientID: clientID, + deviceID: deviceID, + pageSize: pageSize, + orderBy: orderBy, + sortType: sortType, + accountBaseURL: accountBaseURL, + apiBaseURL: apiBaseURL, + onCredentialsUpdate: c.OnCredentialsUpdate, + files: make(map[string]drives.Entry), + } + d.accountClient = d.newAccountClient() + d.apiClient = d.newAPIClient() + return d +} + +func (d *Driver) Kind() string { return Kind } +func (d *Driver) ID() string { return d.id } +func (d *Driver) RootID() string { return d.rootID } + +func (d *Driver) Init(ctx context.Context) error { + d.saveCredentials() + + if d.accessToken != "" { + if err := d.validateToken(ctx); err == nil { + return d.prepareRootFolder(ctx) + } + d.accessToken = "" + } + if d.refreshToken != "" { + if err := d.refresh(ctx); err == nil { + if err := d.validateToken(ctx); err == nil { + return d.prepareRootFolder(ctx) + } + } + } + if d.phoneNumber != "" && d.verifyCode != "" { + if err := d.loginBySMSCode(ctx); err != nil { + return err + } + if err := d.validateToken(ctx); err != nil { + return err + } + return d.prepareRootFolder(ctx) + } + if d.phoneNumber != "" && d.sendCode { + if err := d.prepareSMSCode(ctx); err != nil { + return err + } + return errors.New("光鸭验证码已发送,请填写 verify_code 后再次保存") + } + return errors.New("guangyapan init: provide access_token / refresh_token, or use QR login in admin") +} + +func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) { + return d.list(ctx, dirID, true) +} + +func (d *Driver) list(ctx context.Context, dirID string, applyDefaultRoot bool) ([]drives.Entry, error) { + if applyDefaultRoot && strings.TrimSpace(dirID) == "" { + dirID = d.rootID + } + if err := d.ensureAccessToken(ctx); err != nil { + return nil, err + } + out := make([]drives.Entry, 0, d.pageSize) + for pageNo := 0; ; pageNo++ { + var resp listResp + if err := d.postAPI(ctx, "/userres/v1/file/get_file_list", map[string]any{ + "parentId": dirID, + "page": pageNo, + "pageSize": d.pageSize, + "orderBy": d.orderBy, + "sortType": d.sortType, + "fileTypes": []int{}, + }, &resp); err != nil { + return nil, err + } + for _, item := range resp.Data.List { + entry := fileItemToEntry(item, dirID) + out = append(out, entry) + d.remember(entry) + } + if len(resp.Data.List) < d.pageSize { + return out, nil + } + if resp.Data.Total > 0 && len(out) >= resp.Data.Total { + return out, nil + } + } +} + +func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) { + d.fileMu.RLock() + entry, ok := d.files[fileID] + d.fileMu.RUnlock() + if !ok { + return nil, drives.ErrNotSupported + } + return &entry, nil +} + +func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) { + if strings.TrimSpace(fileID) == "" { + return nil, errors.New("guangyapan stream: empty file id") + } + if err := d.ensureAccessToken(ctx); err != nil { + return nil, err + } + var resp downloadResp + if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/get_res_download_url", map[string]any{ + "fileId": fileID, + }, &resp); err != nil { + return nil, err + } + u := strings.TrimSpace(resp.Data.SignedURL) + if u == "" { + u = strings.TrimSpace(resp.Data.DownloadURL) + } + if u == "" { + return nil, errors.New("guangyapan stream: empty download url") + } + return &drives.StreamLink{URL: u, Headers: http.Header{}, Expires: time.Now().Add(10 * time.Minute)}, nil +} + +func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) { + if err := d.ensureAccessToken(ctx); err != nil { + return "", err + } + parentID = strings.TrimSpace(parentID) + if parentID == "" { + parentID = d.rootID + } + name = strings.TrimSpace(name) + if name == "" { + return "", errors.New("guangyapan upload: empty file name") + } + if r == nil { + return "", errors.New("guangyapan upload: nil reader") + } + if size < 0 { + return "", errors.New("guangyapan upload: invalid file size") + } + token, code, err := d.getUploadToken(ctx, parentID, name, size) + if err != nil { + return "", err + } + taskID := strings.TrimSpace(token.TaskID) + if code == 156 { + return d.waitUploadTaskInfo(ctx, taskID) + } + if token.ObjectPath == "" || token.BucketName == "" || token.EndPoint == "" || token.AccessKeyID == "" || token.SecretAccessKey == "" { + return "", errors.New("guangyapan upload: incomplete upload token") + } + + client, err := oss.New(normalizeOSSEndpoint(token.EndPoint, token.BucketName), token.AccessKeyID, token.SecretAccessKey, oss.SecurityToken(token.SessionToken)) + if err != nil { + return "", fmt.Errorf("guangyapan upload: create oss client: %w", err) + } + bucket, err := client.Bucket(token.BucketName) + if err != nil { + return "", fmt.Errorf("guangyapan upload: create oss bucket: %w", err) + } + if size == 0 { + if err := bucket.PutObject(token.ObjectPath, strings.NewReader("")); err != nil { + return "", err + } + } else if err := multipartUploadToOSS(ctx, bucket, token.ObjectPath, r, size); err != nil { + return "", err + } + fileID, err := d.waitUploadTaskInfo(ctx, taskID) + if err != nil { + return "", err + } + d.remember(drives.Entry{ID: fileID, ParentID: parentID, Name: name, Size: size}) + return fileID, nil +} + +func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) { + if err := d.ensureAccessToken(ctx); err != nil { + return "", err + } + clean := strings.Trim(strings.ReplaceAll(strings.TrimSpace(pathFromRoot), "\\", "/"), "/") + if clean == "" { + return d.rootID, nil + } + parentID := d.rootID + for _, name := range strings.Split(clean, "/") { + name = strings.TrimSpace(name) + if name == "" { + continue + } + childID, err := d.findChildFolderID(ctx, parentID, name) + if err == nil { + parentID = childID + continue + } + created, err := d.createDir(ctx, parentID, name) + if err != nil { + return "", err + } + parentID = created + } + return parentID, nil +} + +func (d *Driver) Remove(ctx context.Context, fileID string) error { + if err := d.ensureAccessToken(ctx); err != nil { + return err + } + fileID = strings.TrimSpace(fileID) + if fileID == "" { + return errors.New("guangyapan remove: empty file id") + } + var resp deleteResp + if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/delete_file", map[string]any{ + "fileIds": []string{fileID}, + }, &resp); err != nil { + return err + } + if !successMessage(resp.Msg) { + return fmt.Errorf("guangyapan remove: %s", strings.TrimSpace(resp.Msg)) + } + if taskID := strings.TrimSpace(resp.Data.TaskID); taskID != "" { + return d.waitTaskDone(ctx, taskID) + } + return nil +} + +func (d *Driver) Rename(ctx context.Context, fileID, newName string) error { + if err := d.ensureAccessToken(ctx); err != nil { + return err + } + fileID = strings.TrimSpace(fileID) + if fileID == "" { + return errors.New("guangyapan rename: empty file id") + } + newName = strings.TrimSpace(newName) + if newName == "" { + return errors.New("guangyapan rename: empty new name") + } + var resp struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/rename", map[string]any{ + "fileId": fileID, + "newName": newName, + }, &resp); err != nil { + return err + } + if !successMessage(resp.Msg) { + return fmt.Errorf("guangyapan rename: %s", strings.TrimSpace(resp.Msg)) + } + return nil +} + +func (d *Driver) prepareRootFolder(ctx context.Context) error { + if d.rootPath == "" { + return nil + } + rootID, err := d.resolveFolderPath(ctx, d.rootPath) + if err != nil { + return err + } + d.rootID = rootID + return nil +} + +func (d *Driver) resolveFolderPath(ctx context.Context, rootPath string) (string, error) { + clean := strings.Trim(strings.ReplaceAll(strings.TrimSpace(rootPath), "\\", "/"), "/") + if clean == "" { + return "", nil + } + parentID := "" + for _, name := range strings.Split(clean, "/") { + if name == "" { + continue + } + childID, err := d.findChildFolderID(ctx, parentID, name) + if err != nil { + return "", err + } + parentID = childID + } + return parentID, nil +} + +func (d *Driver) findChildFolderID(ctx context.Context, parentID, name string) (string, error) { + entries, err := d.list(ctx, parentID, false) + if err != nil { + return "", err + } + for _, entry := range entries { + if entry.IsDir && entry.Name == name { + return entry.ID, nil + } + } + if parentID == "" { + return "", fmt.Errorf("guangyapan folder %q not found under /", name) + } + return "", fmt.Errorf("guangyapan folder %q not found under parent %s", name, parentID) +} + +func (d *Driver) createDir(ctx context.Context, parentID, name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", errors.New("guangyapan create dir: empty name") + } + var resp createDirResp + if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/create_dir", map[string]any{ + "parentId": parentID, + "dirName": name, + }, &resp); err != nil { + return "", err + } + if !successMessage(resp.Msg) { + return "", fmt.Errorf("guangyapan create dir: %s", strings.TrimSpace(resp.Msg)) + } + id := strings.TrimSpace(resp.Data.FileID) + if id == "" { + return "", errors.New("guangyapan create dir: empty file id") + } + d.remember(drives.Entry{ID: id, ParentID: parentID, Name: name, IsDir: true}) + return id, nil +} + +func (d *Driver) ensureAccessToken(ctx context.Context) error { + if strings.TrimSpace(d.accessToken) != "" { + return nil + } + if strings.TrimSpace(d.refreshToken) != "" { + return d.refresh(ctx) + } + if d.phoneNumber != "" && d.verifyCode != "" { + return d.loginBySMSCode(ctx) + } + return errors.New("guangyapan auth: access token is empty; use QR login in admin or provide refresh_token") +} + +func (d *Driver) validateToken(ctx context.Context) error { + var out userMeResp + resp, err := d.accountClient.R(). + SetContext(ctx). + SetHeader("Authorization", "Bearer "+d.accessToken). + SetResult(&out). + Get("/v1/user/me") + if err != nil { + return err + } + if resp.IsError() { + return fmt.Errorf("guangyapan validate token: status=%d body=%s", resp.StatusCode(), resp.String()) + } + if strings.TrimSpace(out.Sub) == "" { + return errors.New("guangyapan validate token: empty user sub") + } + return nil +} + +func (d *Driver) refresh(ctx context.Context) error { + if strings.TrimSpace(d.refreshToken) == "" { + return errors.New("guangyapan refresh: refresh_token is empty") + } + var out tokenResp + resp, err := d.accountClient.R(). + SetContext(ctx). + SetBody(map[string]any{ + "client_id": d.clientID, + "grant_type": "refresh_token", + "refresh_token": d.refreshToken, + }). + SetResult(&out). + Post("/v1/auth/token") + if err != nil { + return err + } + if resp.IsError() || out.Error != "" || strings.TrimSpace(out.AccessToken) == "" { + return fmt.Errorf("guangyapan refresh: %s", accountErr(out.ErrorDesc, out.Error, resp)) + } + d.accessToken = strings.TrimSpace(out.AccessToken) + if strings.TrimSpace(out.RefreshToken) != "" { + d.refreshToken = strings.TrimSpace(out.RefreshToken) + } + d.saveCredentials() + return nil +} + +func (d *Driver) loginBySMSCode(ctx context.Context) error { + verificationID := strings.TrimSpace(d.verificationID) + if verificationID == "" { + var err error + verificationID, err = d.requestVerificationID(ctx) + if err != nil { + return err + } + } + + var step2 verifyResp + resp, err := d.accountClient.R(). + SetContext(ctx). + SetBody(map[string]any{ + "verification_id": verificationID, + "verification_code": d.verifyCode, + "client_id": d.clientID, + }). + SetResult(&step2). + Post("/v1/auth/verification/verify") + if err != nil { + return err + } + if resp.IsError() || step2.Error != "" || strings.TrimSpace(step2.VerificationToken) == "" { + return fmt.Errorf("guangyapan verify code: %s", accountErr(step2.ErrorDesc, step2.Error, resp)) + } + + var out tokenResp + resp, err = d.accountClient.R(). + SetContext(ctx). + SetBody(map[string]any{ + "verification_code": d.verifyCode, + "verification_token": step2.VerificationToken, + "username": normalizePhoneE164(d.phoneNumber), + "client_id": d.clientID, + }). + SetResult(&out). + Post("/v1/auth/signin") + if err != nil { + return err + } + if resp.IsError() || out.Error != "" || strings.TrimSpace(out.AccessToken) == "" { + return fmt.Errorf("guangyapan signin: %s", accountErr(out.ErrorDesc, out.Error, resp)) + } + d.accessToken = strings.TrimSpace(out.AccessToken) + d.refreshToken = strings.TrimSpace(out.RefreshToken) + d.verificationID = "" + d.verifyCode = "" + d.sendCode = false + d.saveCredentials() + return nil +} + +func (d *Driver) prepareSMSCode(ctx context.Context) error { + d.verificationID = "" + if err := d.ensureCaptchaToken(ctx, false); err != nil { + return err + } + id, err := d.requestVerificationID(ctx) + if err != nil { + return err + } + d.verificationID = id + d.sendCode = false + d.saveCredentials() + return nil +} + +func (d *Driver) requestVerificationID(ctx context.Context) (string, error) { + if d.captchaToken != "" { + d.accountClient.SetHeader("X-Captcha-Token", d.captchaToken) + } + var out verificationResp + resp, err := d.accountClient.R(). + SetContext(ctx). + SetBody(map[string]any{ + "phone_number": normalizePhoneE164(d.phoneNumber), + "target": "ANY", + "client_id": d.clientID, + }). + SetResult(&out). + Post("/v1/auth/verification") + if err != nil { + return "", err + } + if resp.IsError() || out.Error != "" || strings.TrimSpace(out.VerificationID) == "" { + if strings.Contains(out.Error, "captcha_invalid") || strings.Contains(out.ErrorDesc, "captcha_token expired") { + if err := d.ensureCaptchaToken(ctx, true); err == nil { + return d.requestVerificationID(ctx) + } + } + return "", fmt.Errorf("guangyapan request verification: %s", accountErr(out.ErrorDesc, out.Error, resp)) + } + return strings.TrimSpace(out.VerificationID), nil +} + +func (d *Driver) ensureCaptchaToken(ctx context.Context, force bool) error { + if !force && d.captchaToken != "" { + d.accountClient.SetHeader("X-Captcha-Token", d.captchaToken) + return nil + } + var out captchaInitResp + resp, err := d.accountClient.R(). + SetContext(ctx). + SetBody(map[string]any{ + "client_id": d.clientID, + "action": "POST:/v1/auth/verification", + "device_id": d.deviceID, + "meta": map[string]any{ + "username": normalizePhoneE164(d.phoneNumber), + "phone_number": normalizePhoneE164(d.phoneNumber), + "VERIFICATION_PHONE": normalizePhoneE164(d.phoneNumber), + }, + }). + SetResult(&out). + Post("/v1/shield/captcha/init") + if err != nil { + return err + } + if resp.IsError() || out.Error != "" || strings.TrimSpace(out.CaptchaToken) == "" { + return fmt.Errorf("guangyapan captcha init: %s", accountErr(out.ErrorDesc, out.Error, resp)) + } + d.captchaToken = strings.TrimSpace(out.CaptchaToken) + d.accountClient.SetHeader("X-Captcha-Token", d.captchaToken) + d.saveCredentials() + return nil +} + +func (d *Driver) postAPI(ctx context.Context, p string, body any, out any) error { + if strings.TrimSpace(d.accessToken) == "" { + return errors.New("guangyapan api: access token is empty") + } + resp, err := d.apiClient.R(). + SetContext(ctx). + SetHeader("Authorization", "Bearer "+d.accessToken). + SetBody(body). + SetResult(out). + Post(p) + if err != nil { + return err + } + if resp.StatusCode() == http.StatusUnauthorized || resp.StatusCode() == http.StatusForbidden { + if strings.TrimSpace(d.refreshToken) == "" { + code, msg := guangYaPanResponseCodeMsg(resp, out) + if guangYaPanLooksRateLimited(resp.StatusCode(), code, msg) { + return guangYaPanRateLimitError(p, resp.Header().Get("Retry-After"), resp.StatusCode(), code, msg) + } + return fmt.Errorf("guangyapan api: status=%d body=%s", resp.StatusCode(), resp.String()) + } + if err := d.refresh(ctx); err != nil { + return err + } + resp, err = d.apiClient.R(). + SetContext(ctx). + SetHeader("Authorization", "Bearer "+d.accessToken). + SetBody(body). + SetResult(out). + Post(p) + if err != nil { + return err + } + } + if resp.IsError() { + code, msg := guangYaPanResponseCodeMsg(resp, out) + if guangYaPanLooksRateLimited(resp.StatusCode(), code, msg) { + return guangYaPanRateLimitError(p, resp.Header().Get("Retry-After"), resp.StatusCode(), code, msg) + } + return fmt.Errorf("guangyapan api: status=%d body=%s", resp.StatusCode(), resp.String()) + } + code, msg := guangYaPanResponseCodeMsg(resp, out) + if guangYaPanLooksRateLimited(resp.StatusCode(), code, msg) { + return guangYaPanRateLimitError(p, resp.Header().Get("Retry-After"), resp.StatusCode(), code, msg) + } + return nil +} + +func guangYaPanResponseCodeMsg(resp *resty.Response, out any) (int, string) { + if resp != nil { + body := resp.Body() + if len(body) > 0 { + var env struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + if err := json.Unmarshal(body, &env); err == nil && (env.Code != 0 || strings.TrimSpace(env.Msg) != "") { + return env.Code, strings.TrimSpace(env.Msg) + } + if resp.IsError() { + return 0, strings.TrimSpace(resp.String()) + } + } + } + if code, msg, ok := guangYaPanCodeMsgFromValue(out); ok { + return code, msg + } + if resp != nil && resp.IsError() { + return 0, strings.TrimSpace(resp.String()) + } + return 0, "" +} + +func guangYaPanCodeMsgFromValue(v any) (int, string, bool) { + rv := reflect.ValueOf(v) + for rv.IsValid() && rv.Kind() == reflect.Pointer { + if rv.IsNil() { + return 0, "", false + } + rv = rv.Elem() + } + if !rv.IsValid() || rv.Kind() != reflect.Struct { + return 0, "", false + } + codeField := rv.FieldByName("Code") + msgField := rv.FieldByName("Msg") + if !codeField.IsValid() && !msgField.IsValid() { + return 0, "", false + } + code := 0 + if codeField.IsValid() && codeField.CanInt() { + code = int(codeField.Int()) + } + msg := "" + if msgField.IsValid() && msgField.Kind() == reflect.String { + msg = strings.TrimSpace(msgField.String()) + } + return code, msg, true +} + +func guangYaPanLooksRateLimited(status int, code int, _ string) bool { + if status == http.StatusTooManyRequests || code == http.StatusTooManyRequests { + return true + } + switch status { + case http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout, 509: + return true + } + return false +} + +func guangYaPanRateLimitError(step, retryAfter string, status int, code int, message string) error { + message = strings.TrimSpace(message) + if message == "" { + message = "guangyapan api rate limited" + } + if len(message) > 1024 { + message = message[:1024] + "...(truncated)" + } + return &drives.RateLimitError{ + Provider: Kind, + RetryAfter: parseRetryAfterHeader(retryAfter), + Err: fmt.Errorf("guangyapan api rate limited: step=%s status=%d code=%d msg=%s", step, status, code, message), + } +} + +func parseRetryAfterHeader(raw string) time.Duration { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0 + } + if seconds, err := strconv.Atoi(raw); err == nil && seconds > 0 { + return time.Duration(seconds) * time.Second + } + if when, err := http.ParseTime(raw); err == nil { + d := time.Until(when) + if d > 0 { + return d + } + } + return 0 +} + +func (d *Driver) waitTaskDone(ctx context.Context, taskID string) error { + const ( + maxTry = 30 + interval = 300 * time.Millisecond + ) + for i := 0; i < maxTry; i++ { + var out taskStatusResp + if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/get_task_status", map[string]any{"taskId": taskID}, &out); err != nil { + return err + } + if !successMessage(out.Msg) { + return fmt.Errorf("guangyapan task status: %s", strings.TrimSpace(out.Msg)) + } + switch out.Data.Status { + case 2: + return nil + case -1, 3: + return fmt.Errorf("guangyapan task %s failed with status=%d", taskID, out.Data.Status) + } + if i == maxTry-1 { + break + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(interval): + } + } + return fmt.Errorf("guangyapan task %s timeout", taskID) +} + +func (d *Driver) getUploadToken(ctx context.Context, parentID, name string, size int64) (*uploadTokenData, int, error) { + var out uploadTokenResp + if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/get_res_center_token", map[string]any{ + "capacity": 2, + "name": name, + "parentId": parentID, + "res": map[string]any{"fileSize": size}, + }, &out); err != nil { + return nil, 0, err + } + if strings.TrimSpace(out.Msg) != "" && !successMessage(out.Msg) { + return nil, out.Code, fmt.Errorf("guangyapan upload token: %s", strings.TrimSpace(out.Msg)) + } + if out.Data.TaskID == "" { + return nil, out.Code, errors.New("guangyapan upload token: empty task id") + } + if out.Data.AccessKeyID == "" { + out.Data.AccessKeyID = out.Data.Creds.AccessKeyID + } + if out.Data.SecretAccessKey == "" { + out.Data.SecretAccessKey = out.Data.Creds.SecretAccessKey + } + if out.Data.SessionToken == "" { + out.Data.SessionToken = out.Data.Creds.SessionToken + } + if strings.TrimSpace(out.Data.EndPoint) == "" { + out.Data.EndPoint = strings.TrimSpace(out.Data.FullEndPoint) + } + if strings.TrimSpace(out.Data.EndPoint) != "" && !strings.HasPrefix(out.Data.EndPoint, "http://") && !strings.HasPrefix(out.Data.EndPoint, "https://") { + if strings.TrimSpace(out.Data.FullEndPoint) != "" { + out.Data.EndPoint = strings.TrimSpace(out.Data.FullEndPoint) + } else if strings.TrimSpace(out.Data.BucketName) != "" { + host := strings.TrimSpace(out.Data.EndPoint) + prefix := strings.TrimSpace(out.Data.BucketName) + "." + if strings.HasPrefix(host, prefix) { + out.Data.EndPoint = "https://" + host + } else { + out.Data.EndPoint = "https://" + strings.TrimSpace(out.Data.BucketName) + "." + host + } + } else { + out.Data.EndPoint = "https://" + strings.TrimSpace(out.Data.EndPoint) + } + } + return &out.Data, out.Code, nil +} + +func (d *Driver) waitUploadTaskInfo(ctx context.Context, taskID string) (string, error) { + const ( + maxTry = 300 + interval = time.Second + ) + for i := 0; i < maxTry; i++ { + var out taskInfoResp + if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/get_info_by_task_id", map[string]any{"taskId": taskID}, &out); err != nil { + return "", err + } + if out.Data.FileID != "" { + return out.Data.FileID, nil + } + switch out.Code { + case 0, 145, 146, 147, 155, 163: + default: + if strings.TrimSpace(out.Msg) != "" { + return "", fmt.Errorf("guangyapan upload task failed: code=%d msg=%s", out.Code, strings.TrimSpace(out.Msg)) + } + } + if i == maxTry-1 { + break + } + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(interval): + } + } + return "", fmt.Errorf("guangyapan upload task %s timeout", taskID) +} + +func multipartUploadToOSS(ctx context.Context, bucket *oss.Bucket, objectPath string, r io.Reader, size int64) error { + partSize := calcUploadPartSize(size) + upload, err := bucket.InitiateMultipartUpload(objectPath, oss.Sequential()) + if err != nil { + return err + } + partCount := int((size + partSize - 1) / partSize) + parts := make([]oss.UploadPart, 0, partCount) + uploaded := int64(0) + partNumber := 1 + for uploaded < size { + if err := ctx.Err(); err != nil { + return err + } + cur := partSize + if left := size - uploaded; left < cur { + cur = left + } + part, err := bucket.UploadPart(upload, &contextReader{ctx: ctx, r: io.LimitReader(r, cur)}, cur, partNumber) + if err != nil { + return err + } + parts = append(parts, part) + uploaded += cur + partNumber++ + } + _, err = bucket.CompleteMultipartUpload(upload, parts) + return err +} + +type contextReader struct { + ctx context.Context + r io.Reader +} + +func (r *contextReader) Read(p []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.r.Read(p) +} + +func calcUploadPartSize(size int64) int64 { + const mb = int64(1024 * 1024) + const gb = int64(1024 * 1024 * 1024) + switch { + case size <= 100*mb: + return mb + case size <= 16*gb: + return 2 * mb + case size <= 160*gb: + return 4 * mb + default: + return 8 * mb + } +} + +func (d *Driver) newAccountClient() *resty.Client { + client := resty.New(). + SetTimeout(30*time.Second). + SetBaseURL(d.accountBaseURL). + SetHeader("Accept", "application/json, text/plain, */*"). + SetHeader("Content-Type", "application/json"). + SetHeader("X-Device-Model", "chrome%2F147.0.0.0"). + SetHeader("X-Device-Name", "PC-Chrome"). + SetHeader("X-Device-Sign", "wdi10."+d.deviceID+"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"). + SetHeader("X-Net-Work-Type", "NONE"). + SetHeader("X-OS-Version", "MacIntel"). + SetHeader("X-Platform-Version", "1"). + SetHeader("X-Protocol-Version", "301"). + SetHeader("X-Provider-Name", "NONE"). + SetHeader("X-SDK-Version", "9.0.2"). + SetHeader("X-Client-Id", d.clientID). + SetHeader("X-Client-Version", "0.0.1"). + SetHeader("X-Device-Id", d.deviceID) + if d.captchaToken != "" { + client.SetHeader("X-Captcha-Token", d.captchaToken) + } + return client +} + +func (d *Driver) newAPIClient() *resty.Client { + return resty.New(). + SetTimeout(30*time.Second). + SetBaseURL(d.apiBaseURL). + SetHeader("Accept", "application/json, text/plain, */*"). + SetHeader("Content-Type", "application/json"). + SetHeader("Did", d.deviceID). + SetHeader("Dt", "4") +} + +func (d *Driver) saveCredentials() { + if d.onCredentialsUpdate == nil { + return + } + d.onCredentialsUpdate(map[string]string{ + "access_token": d.accessToken, + "refresh_token": d.refreshToken, + "captcha_token": d.captchaToken, + "device_id": d.deviceID, + "client_id": d.clientID, + "verification_id": d.verificationID, + "verify_code": d.verifyCode, + "send_code": strconv.FormatBool(d.sendCode), + }) +} + +func (d *Driver) remember(entry drives.Entry) { + if entry.ID == "" { + return + } + d.fileMu.Lock() + d.files[entry.ID] = entry + d.fileMu.Unlock() +} + +func fileItemToEntry(item fileItem, parentID string) drives.Entry { + if item.ParentID != "" { + parentID = item.ParentID + } + return drives.Entry{ + ID: item.FileID, + Name: item.FileName, + Size: item.FileSize, + IsDir: item.ResType == 2, + ParentID: parentID, + ModTime: unixOrZero(item.UTime), + } +} + +func successMessage(msg string) bool { + return strings.EqualFold(strings.TrimSpace(msg), "success") +} + +func accountErr(desc, short string, resp *resty.Response) string { + msg := strings.TrimSpace(desc) + if msg == "" { + msg = strings.TrimSpace(short) + } + if msg == "" && resp != nil { + msg = strings.TrimSpace(resp.String()) + } + if msg == "" && resp != nil { + msg = fmt.Sprintf("status=%d", resp.StatusCode()) + } + if msg == "" { + msg = "unknown error" + } + return msg +} + +func normalizeAccessToken(v string) string { + v = strings.TrimSpace(v) + if strings.HasPrefix(strings.ToLower(v), "bearer ") { + return strings.TrimSpace(v[7:]) + } + return v +} + +func normalizeCaptchaUsername(phone string) string { + p := strings.TrimSpace(phone) + p = strings.ReplaceAll(p, " ", "") + p = strings.TrimPrefix(p, "+") + b := make([]rune, 0, len(p)) + for _, ch := range p { + if ch >= '0' && ch <= '9' { + b = append(b, ch) + } + } + digits := string(b) + if strings.HasPrefix(digits, "86") && len(digits) > 11 { + digits = digits[2:] + } + return digits +} + +func normalizePhoneE164(phone string) string { + p := strings.TrimSpace(phone) + if p == "" { + return "" + } + p = strings.ReplaceAll(p, " ", "") + if strings.HasPrefix(p, "+") { + if strings.HasPrefix(p, "+86") && len(p) > 3 { + return "+86 " + strings.TrimPrefix(p, "+86") + } + return p + } + digits := normalizeCaptchaUsername(p) + if len(digits) == 11 { + return "+86 " + digits + } + return p +} + +func normalizeDeviceID(v string) string { + v = strings.ToLower(strings.TrimSpace(v)) + v = strings.ReplaceAll(v, "-", "") + if len(v) != 32 { + return "" + } + for _, ch := range v { + if (ch < '0' || ch > '9') && (ch < 'a' || ch > 'f') { + return "" + } + } + return v +} + +func randomDeviceID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "0123456789abcdef0123456789abcdef" + } + return hex.EncodeToString(b) +} + +func normalizeOSSEndpoint(endpoint, bucket string) string { + ep := strings.TrimSpace(endpoint) + if ep == "" { + return ep + } + if !strings.HasPrefix(ep, "http://") && !strings.HasPrefix(ep, "https://") { + ep = "https://" + ep + } + u, err := url.Parse(ep) + if err != nil || u.Host == "" { + return ep + } + prefix := strings.TrimSpace(bucket) + if prefix != "" && strings.HasPrefix(u.Host, prefix+".") { + u.Host = strings.TrimPrefix(u.Host, prefix+".") + } + return u.String() +} + +var _ drives.Drive = (*Driver)(nil) +var _ drives.Remover = (*Driver)(nil) diff --git a/backend/internal/drives/guangyapan/driver_test.go b/backend/internal/drives/guangyapan/driver_test.go new file mode 100644 index 0000000..e4dcf8a --- /dev/null +++ b/backend/internal/drives/guangyapan/driver_test.go @@ -0,0 +1,300 @@ +package guangyapan + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/video-site/backend/internal/drives" +) + +func TestDriverRefreshListAndStream(t *testing.T) { + var refreshed bool + var listedRoot bool + updates := map[string]string{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/auth/token": + refreshed = true + writeTestJSON(w, map[string]any{ + "access_token": "new-access", + "refresh_token": "new-refresh", + }) + case "/v1/user/me": + if got := r.Header.Get("Authorization"); got != "Bearer new-access" { + t.Fatalf("auth header = %q, want new access token", got) + } + writeTestJSON(w, map[string]any{"sub": "user-1"}) + case "/userres/v1/file/get_file_list": + if got := r.Header.Get("Authorization"); got != "Bearer new-access" { + t.Fatalf("api auth header = %q, want new access token", got) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode list body: %v", err) + } + if body["parentId"] != "" { + t.Fatalf("parentId = %#v, want root empty string", body["parentId"]) + } + listedRoot = true + writeTestJSON(w, map[string]any{ + "code": 0, + "msg": "success", + "data": map[string]any{ + "total": 2, + "list": []map[string]any{ + {"fileId": "dir-1", "parentId": "", "fileName": "Movies", "resType": 2}, + {"fileId": "file-1", "parentId": "", "fileName": "clip.mp4", "fileSize": 123, "resType": 1, "utime": 1700000000}, + }, + }, + }) + case "/nd.bizuserres.s/v1/get_res_download_url": + writeTestJSON(w, map[string]any{ + "code": 0, + "msg": "success", + "data": map[string]any{"signedURL": "https://cdn.example.test/clip.mp4"}, + }) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + + d := New(Config{ + ID: "gy", + RefreshToken: "old-refresh", + AccountBaseURL: srv.URL, + APIBaseURL: srv.URL, + OnCredentialsUpdate: func(values map[string]string) { + for k, v := range values { + updates[k] = v + } + }, + }) + if err := d.Init(context.Background()); err != nil { + t.Fatalf("init: %v", err) + } + if !refreshed { + t.Fatal("refresh token endpoint was not called") + } + if updates["access_token"] != "new-access" || updates["refresh_token"] != "new-refresh" { + t.Fatalf("updates = %#v, want refreshed tokens", updates) + } + + entries, err := d.List(context.Background(), "") + if err != nil { + t.Fatalf("list: %v", err) + } + if !listedRoot || len(entries) != 2 { + t.Fatalf("listedRoot=%v entries=%#v", listedRoot, entries) + } + if !entries[0].IsDir || entries[1].ID != "file-1" || entries[1].Size != 123 { + t.Fatalf("entries = %#v", entries) + } + + link, err := d.StreamURL(context.Background(), "file-1") + if err != nil { + t.Fatalf("stream url: %v", err) + } + if link.URL != "https://cdn.example.test/clip.mp4" { + t.Fatalf("stream url = %q", link.URL) + } +} + +func TestDriverResolvesRootPath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/user/me": + writeTestJSON(w, map[string]any{"sub": "user-1"}) + case "/userres/v1/file/get_file_list": + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode list body: %v", err) + } + parent, _ := body["parentId"].(string) + switch parent { + case "": + writeTestJSON(w, listTestResponse([]map[string]any{ + {"fileId": "folder-a", "parentId": "", "fileName": "影视", "resType": 2}, + })) + case "folder-a": + writeTestJSON(w, listTestResponse([]map[string]any{ + {"fileId": "folder-b", "parentId": "folder-a", "fileName": "电影", "resType": 2}, + })) + case "folder-b": + writeTestJSON(w, listTestResponse([]map[string]any{ + {"fileId": "file-1", "parentId": "folder-b", "fileName": "movie.mp4", "fileSize": 456, "resType": 1}, + })) + default: + t.Fatalf("unexpected parent %q", parent) + } + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + + d := New(Config{ + ID: "gy", + RootID: "configured-root", + RootPath: "影视/电影", + AccessToken: "access", + AccountBaseURL: srv.URL, + APIBaseURL: srv.URL, + }) + if err := d.Init(context.Background()); err != nil { + t.Fatalf("init: %v", err) + } + if d.RootID() != "folder-b" { + t.Fatalf("root id = %q, want folder-b", d.RootID()) + } + entries, err := d.List(context.Background(), "") + if err != nil { + t.Fatalf("list resolved root: %v", err) + } + if len(entries) != 1 || entries[0].ID != "file-1" { + t.Fatalf("entries = %#v", entries) + } +} + +func TestDriverSendSMSCodeUpdatesVerificationState(t *testing.T) { + updates := map[string]string{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/shield/captcha/init": + writeTestJSON(w, map[string]any{"captcha_token": "captcha-1"}) + case "/v1/auth/verification": + writeTestJSON(w, map[string]any{"verification_id": "verify-1"}) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + + d := New(Config{ + ID: "gy", + PhoneNumber: "13800000000", + SendCode: true, + AccountBaseURL: srv.URL, + APIBaseURL: srv.URL, + OnCredentialsUpdate: func(values map[string]string) { + for k, v := range values { + updates[k] = v + } + }, + }) + err := d.Init(context.Background()) + if err == nil || !strings.Contains(err.Error(), "验证码已发送") { + t.Fatalf("init err = %v, want verification prompt", err) + } + if updates["captcha_token"] != "captcha-1" || updates["verification_id"] != "verify-1" || updates["send_code"] != "false" { + t.Fatalf("updates = %#v, want sms state saved", updates) + } + if updates["device_id"] == "" { + t.Fatalf("updates = %#v, want generated device id saved", updates) + } +} + +func TestListHTTP429ReturnsRateLimitError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/userres/v1/file/get_file_list" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + w.Header().Set("Retry-After", "120") + w.WriteHeader(http.StatusTooManyRequests) + writeTestJSON(w, map[string]any{"code": 429, "msg": "操作频繁,请稍后重试"}) + })) + defer srv.Close() + + d := New(Config{ + ID: "gy", + AccessToken: "access", + AccountBaseURL: srv.URL, + APIBaseURL: srv.URL, + }) + _, err := d.List(context.Background(), "") + if err == nil { + t.Fatal("list succeeded, want rate limit error") + } + var rateLimit *drives.RateLimitError + if !errors.As(err, &rateLimit) { + t.Fatalf("error = %T %[1]v, want RateLimitError", err) + } + if rateLimit.RetryAfter != 2*time.Minute { + t.Fatalf("retry after = %s, want 2m", rateLimit.RetryAfter) + } +} + +func TestListCode429ReturnsRateLimitError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/userres/v1/file/get_file_list" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + writeTestJSON(w, map[string]any{"code": 429, "msg": "操作频繁,请稍后再试"}) + })) + defer srv.Close() + + d := New(Config{ + ID: "gy", + AccessToken: "access", + AccountBaseURL: srv.URL, + APIBaseURL: srv.URL, + }) + _, err := d.List(context.Background(), "") + if err == nil { + t.Fatal("list succeeded, want rate limit error") + } + var rateLimit *drives.RateLimitError + if !errors.As(err, &rateLimit) { + t.Fatalf("error = %T %[1]v, want RateLimitError", err) + } +} + +func TestListInvalidToken403DoesNotReturnRateLimitError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/userres/v1/file/get_file_list" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + w.WriteHeader(http.StatusForbidden) + writeTestJSON(w, map[string]any{"code": 401, "msg": "invalid access token"}) + })) + defer srv.Close() + + d := New(Config{ + ID: "gy", + AccessToken: "access", + AccountBaseURL: srv.URL, + APIBaseURL: srv.URL, + }) + _, err := d.List(context.Background(), "") + if err == nil { + t.Fatal("list succeeded, want auth error") + } + var rateLimit *drives.RateLimitError + if errors.As(err, &rateLimit) { + t.Fatalf("error = %T %[1]v, want non-rate-limit error", err) + } +} + +func listTestResponse(items []map[string]any) map[string]any { + return map[string]any{ + "code": 0, + "msg": "success", + "data": map[string]any{ + "total": len(items), + "list": items, + }, + } +} + +func writeTestJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v); err != nil { + panic(err) + } +} diff --git a/backend/internal/drives/guangyapan/qr.go b/backend/internal/drives/guangyapan/qr.go new file mode 100644 index 0000000..d0d1798 --- /dev/null +++ b/backend/internal/drives/guangyapan/qr.go @@ -0,0 +1,244 @@ +package guangyapan + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/go-resty/resty/v2" + "github.com/skip2/go-qrcode" +) + +const ( + defaultQRScope = "user" + deviceCodeGrantType = "urn:ietf:params:oauth:grant-type:device_code" + defaultQRUserAgent = "GuangYaPan-Login/1.0" +) + +type QRConfig struct { + AccountBaseURL string + HTTPClient *http.Client + Now func() time.Time +} + +type QRClient struct { + accountBaseURL string + client *resty.Client + now func() time.Time +} + +type QRCodeSession struct { + DeviceCode string `json:"deviceCode"` + QRCodeURL string `json:"qrCodeUrl"` + QRImageDataURL string `json:"qrImageDataUrl"` + IntervalSeconds int `json:"intervalSeconds"` + ExpiresAt string `json:"expiresAt,omitempty"` +} + +type QRCodeStatus struct { + State string `json:"state"` + StatusText string `json:"statusText"` + IntervalSeconds int `json:"intervalSeconds,omitempty"` + AccessToken string `json:"accessToken,omitempty"` + RefreshToken string `json:"refreshToken,omitempty"` + TokenType string `json:"tokenType,omitempty"` + ExpiresIn int64 `json:"expiresIn,omitempty"` +} + +type deviceCodeResp struct { + DeviceCode string `json:"device_code"` + VerificationURIComplete string `json:"verification_uri_complete"` + ShortURIComplete string `json:"short_uri_complete"` + Interval int `json:"interval"` + ExpiresIn int `json:"expires_in"` + Error string `json:"error"` + ErrorCode int `json:"error_code"` + ErrorDesc string `json:"error_description"` +} + +type deviceTokenResp struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + Scope string `json:"scope"` + Error string `json:"error"` + ErrorCode int `json:"error_code"` + ErrorDesc string `json:"error_description"` +} + +func NewQRClient(c QRConfig) *QRClient { + accountBaseURL := strings.TrimRight(strings.TrimSpace(c.AccountBaseURL), "/") + if accountBaseURL == "" { + accountBaseURL = defaultAccountBaseURL + } + httpClient := c.HTTPClient + if httpClient == nil { + httpClient = &http.Client{Timeout: 20 * time.Second} + } + now := c.Now + if now == nil { + now = time.Now + } + return &QRClient{ + accountBaseURL: accountBaseURL, + client: resty.NewWithClient(httpClient). + SetTimeout(20*time.Second). + SetBaseURL(accountBaseURL). + SetHeader("User-Agent", defaultQRUserAgent). + SetHeader("Accept", "application/json"). + SetHeader("Content-Type", "application/json"), + now: now, + } +} + +func (c *QRClient) Generate(ctx context.Context) (QRCodeSession, error) { + var out deviceCodeResp + var errOut deviceCodeResp + resp, err := c.client.R(). + SetContext(ctx). + SetBody(map[string]any{ + "client_id": defaultClientID, + "scope": defaultQRScope, + }). + SetResult(&out). + SetError(&errOut). + Post("/v1/auth/device/code") + if err != nil { + return QRCodeSession{}, err + } + if resp.IsError() || out.Error != "" { + if out.Error == "" { + out = errOut + } + return QRCodeSession{}, fmt.Errorf("guangyapan qr: %s", deviceAPIError(out.ErrorDesc, out.Error, resp)) + } + + deviceCode := strings.TrimSpace(out.DeviceCode) + if deviceCode == "" { + return QRCodeSession{}, errors.New("guangyapan qr: empty device_code") + } + qrURL := strings.TrimSpace(out.VerificationURIComplete) + if qrURL == "" { + qrURL = strings.TrimSpace(out.ShortURIComplete) + } + if qrURL == "" { + return QRCodeSession{}, errors.New("guangyapan qr: empty verification uri") + } + interval := out.Interval + if interval <= 0 { + interval = 5 + } + expiresIn := out.ExpiresIn + if expiresIn <= 0 { + expiresIn = 300 + } + png, err := qrcode.Encode(qrURL, qrcode.Medium, 220) + if err != nil { + return QRCodeSession{}, err + } + return QRCodeSession{ + DeviceCode: deviceCode, + QRCodeURL: qrURL, + QRImageDataURL: "data:image/png;base64," + base64.StdEncoding.EncodeToString(png), + IntervalSeconds: interval, + ExpiresAt: c.now().Add(time.Duration(expiresIn) * time.Second).Format(time.RFC3339), + }, nil +} + +func (c *QRClient) Poll(ctx context.Context, deviceCode string) (QRCodeStatus, error) { + deviceCode = strings.TrimSpace(deviceCode) + if deviceCode == "" { + return QRCodeStatus{}, errors.New("deviceCode is required") + } + + var out deviceTokenResp + var errOut deviceTokenResp + resp, err := c.client.R(). + SetContext(ctx). + SetBody(map[string]any{ + "client_id": defaultClientID, + "grant_type": deviceCodeGrantType, + "device_code": deviceCode, + }). + SetResult(&out). + SetError(&errOut). + Post("/v1/auth/token") + if err != nil { + return QRCodeStatus{}, err + } + if resp.IsError() && out.Error == "" { + out = errOut + } + if resp.IsError() && out.Error == "" { + _ = json.Unmarshal(resp.Body(), &out) + } + if out.Error != "" { + return qrStatusForDeviceError(out), nil + } + if resp.IsError() { + return QRCodeStatus{}, fmt.Errorf("guangyapan qr: status=%d body=%s", resp.StatusCode(), resp.String()) + } + access := strings.TrimSpace(out.AccessToken) + refresh := strings.TrimSpace(out.RefreshToken) + if access == "" || refresh == "" { + return QRCodeStatus{}, errors.New("guangyapan qr: login succeeded but token response is incomplete") + } + tokenType := strings.TrimSpace(out.TokenType) + if tokenType == "" { + tokenType = "Bearer" + } + return QRCodeStatus{ + State: "success", + StatusText: "登录成功", + AccessToken: access, + RefreshToken: refresh, + TokenType: tokenType, + ExpiresIn: out.ExpiresIn, + }, nil +} + +func qrStatusForDeviceError(out deviceTokenResp) QRCodeStatus { + errCode := strings.TrimSpace(out.Error) + switch errCode { + case "authorization_pending": + return QRCodeStatus{State: "pending", StatusText: "等待扫码确认"} + case "slow_down": + return QRCodeStatus{State: "pending", StatusText: "等待扫码确认,已降低查询频率", IntervalSeconds: 10} + case "expired_token": + return QRCodeStatus{State: "expired", StatusText: "二维码已过期"} + case "access_denied": + return QRCodeStatus{State: "denied", StatusText: "用户拒绝了授权"} + default: + msg := strings.TrimSpace(out.ErrorDesc) + if msg == "" { + msg = errCode + } + if msg == "" { + msg = "未知错误" + } + return QRCodeStatus{State: "error", StatusText: msg} + } +} + +func deviceAPIError(desc, short string, resp *resty.Response) string { + msg := strings.TrimSpace(desc) + if msg == "" { + msg = strings.TrimSpace(short) + } + if msg == "" && resp != nil { + msg = strings.TrimSpace(resp.String()) + } + if msg == "" && resp != nil { + msg = fmt.Sprintf("status=%d", resp.StatusCode()) + } + if msg == "" { + msg = "unknown error" + } + return msg +} diff --git a/backend/internal/drives/guangyapan/qr_test.go b/backend/internal/drives/guangyapan/qr_test.go new file mode 100644 index 0000000..e280a56 --- /dev/null +++ b/backend/internal/drives/guangyapan/qr_test.go @@ -0,0 +1,102 @@ +package guangyapan + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestQRClientGenerate(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/auth/device/code" { + t.Fatalf("path = %s, want device code endpoint", r.URL.Path) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body["client_id"] != defaultClientID || body["scope"] != defaultQRScope { + t.Fatalf("body = %#v", body) + } + writeTestJSON(w, map[string]any{ + "device_code": "device-1", + "verification_uri_complete": "https://account.guangyapan.com/device?code=abc", + "interval": 7, + "expires_in": 180, + }) + })) + defer srv.Close() + + client := NewQRClient(QRConfig{ + AccountBaseURL: srv.URL, + Now: func() time.Time { return time.Unix(1700000000, 0) }, + }) + session, err := client.Generate(context.Background()) + if err != nil { + t.Fatalf("generate: %v", err) + } + if session.DeviceCode != "device-1" || session.QRCodeURL != "https://account.guangyapan.com/device?code=abc" { + t.Fatalf("session = %#v", session) + } + if session.IntervalSeconds != 7 { + t.Fatalf("interval = %d, want 7", session.IntervalSeconds) + } + if session.ExpiresAt != time.Unix(1700000180, 0).Format(time.RFC3339) { + t.Fatalf("expiresAt = %q", session.ExpiresAt) + } + if !strings.HasPrefix(session.QRImageDataURL, "data:image/png;base64,") { + t.Fatalf("qr image = %q", session.QRImageDataURL) + } +} + +func TestQRClientPollPendingAndSuccess(t *testing.T) { + var calls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/auth/token" { + t.Fatalf("path = %s, want token endpoint", r.URL.Path) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body["client_id"] != defaultClientID || + body["grant_type"] != deviceCodeGrantType || + body["device_code"] != "device-1" { + t.Fatalf("body = %#v", body) + } + calls++ + if calls == 1 { + w.WriteHeader(http.StatusBadRequest) + writeTestJSON(w, map[string]any{"error": "authorization_pending"}) + return + } + writeTestJSON(w, map[string]any{ + "access_token": "access-1", + "refresh_token": "refresh-1", + "token_type": "Bearer", + "expires_in": 7200, + }) + })) + defer srv.Close() + + client := NewQRClient(QRConfig{AccountBaseURL: srv.URL}) + pending, err := client.Poll(context.Background(), "device-1") + if err != nil { + t.Fatalf("poll pending: %v", err) + } + if pending.State != "pending" || pending.AccessToken != "" { + t.Fatalf("pending = %#v", pending) + } + + success, err := client.Poll(context.Background(), "device-1") + if err != nil { + t.Fatalf("poll success: %v", err) + } + if success.State != "success" || success.AccessToken != "access-1" || success.RefreshToken != "refresh-1" { + t.Fatalf("success = %#v", success) + } +} diff --git a/backend/internal/drives/guangyapan/types.go b/backend/internal/drives/guangyapan/types.go new file mode 100644 index 0000000..aa0f6e7 --- /dev/null +++ b/backend/internal/drives/guangyapan/types.go @@ -0,0 +1,129 @@ +package guangyapan + +import "time" + +type tokenResp struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Error string `json:"error"` + ErrorCode int `json:"error_code"` + ErrorDesc string `json:"error_description"` +} + +type verificationResp struct { + VerificationID string `json:"verification_id"` + Error string `json:"error"` + ErrorCode int `json:"error_code"` + ErrorDesc string `json:"error_description"` +} + +type captchaInitResp struct { + CaptchaToken string `json:"captcha_token"` + Error string `json:"error"` + ErrorCode int `json:"error_code"` + ErrorDesc string `json:"error_description"` +} + +type verifyResp struct { + VerificationToken string `json:"verification_token"` + Error string `json:"error"` + ErrorCode int `json:"error_code"` + ErrorDesc string `json:"error_description"` +} + +type userMeResp struct { + Sub string `json:"sub"` +} + +type listResp struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Total int `json:"total"` + List []fileItem `json:"list"` + } `json:"data"` +} + +type fileItem struct { + FileID string `json:"fileId"` + ParentID string `json:"parentId"` + FileName string `json:"fileName"` + FileSize int64 `json:"fileSize"` + ResType int `json:"resType"` + CTime int64 `json:"ctime"` + UTime int64 `json:"utime"` +} + +type downloadResp struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + SignedURL string `json:"signedURL"` + DownloadURL string `json:"downloadUrl"` + } `json:"data"` +} + +type createDirResp struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + FileID string `json:"fileId"` + FileName string `json:"fileName"` + ResType int `json:"resType"` + CTime int64 `json:"ctime"` + UTime int64 `json:"utime"` + } `json:"data"` +} + +type deleteResp struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + TaskID string `json:"taskId"` + } `json:"data"` +} + +type taskStatusResp struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Status int `json:"status"` + } `json:"data"` +} + +type uploadTokenResp struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data uploadTokenData `json:"data"` +} + +type uploadTokenData struct { + TaskID string `json:"taskId"` + ObjectPath string `json:"objectPath"` + BucketName string `json:"bucketName"` + EndPoint string `json:"endPoint"` + FullEndPoint string `json:"fullEndPoint"` + AccessKeyID string `json:"accessKeyID"` + SecretAccessKey string `json:"secretAccessKey"` + SessionToken string `json:"sessionToken"` + Creds struct { + AccessKeyID string `json:"accessKeyID"` + SecretAccessKey string `json:"secretAccessKey"` + SessionToken string `json:"sessionToken"` + } `json:"creds"` +} + +type taskInfoResp struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + FileID string `json:"fileId"` + } `json:"data"` +} + +func unixOrZero(v int64) time.Time { + if v <= 0 { + return time.Time{} + } + return time.Unix(v, 0) +} diff --git a/backend/internal/drives/iface.go b/backend/internal/drives/iface.go index 8a9d690..4d965e7 100644 --- a/backend/internal/drives/iface.go +++ b/backend/internal/drives/iface.go @@ -5,12 +5,14 @@ import ( "errors" "io" "net/http" + "strconv" + "strings" "time" ) // Drive 是多家网盘统一抽象。上层不区分盘,只区分 Kind。 type Drive interface { - // Kind 返回驱动代号:"quark" / "p115" / "p123" / "pikpak" / "wopan" / "onedrive" / "googledrive" / "localstorage" + // Kind 返回驱动代号:"quark" / "p115" / "p123" / "pikpak" / "wopan" / "guangyapan" / "onedrive" / "googledrive" / "localstorage" Kind() string // ID 返回该盘在 catalog 中的唯一标识 @@ -119,3 +121,42 @@ func RateLimitRetryAfter(err error) (time.Duration, bool) { } return 0, false } + +// TextMentionsHTTPStatus only looks for explicit numeric HTTP status contexts +// in errors from tools that do not expose structured response metadata. +func TextMentionsHTTPStatus(text string, statuses ...int) bool { + text = strings.ToLower(strings.TrimSpace(text)) + if text == "" { + return false + } + for _, status := range statuses { + if status <= 0 { + continue + } + code := strconv.Itoa(status) + if strings.HasPrefix(text, code+" ") || + strings.Contains(text, "status="+code) || + strings.Contains(text, "status: "+code) || + strings.Contains(text, "status "+code) || + strings.Contains(text, "status code "+code) || + strings.Contains(text, "http "+code) || + strings.Contains(text, "http status="+code) || + strings.Contains(text, "http status: "+code) || + strings.Contains(text, "http status "+code) || + strings.Contains(text, "server returned "+code) || + strings.Contains(text, "code="+code) || + strings.Contains(text, "code: "+code) || + strings.Contains(text, "error_code="+code) || + strings.Contains(text, "error_code: "+code) { + return true + } + } + return false +} + +func ErrorMentionsHTTPStatus(err error, statuses ...int) bool { + if err == nil { + return false + } + return TextMentionsHTTPStatus(err.Error(), statuses...) +} diff --git a/backend/internal/drives/iface_test.go b/backend/internal/drives/iface_test.go new file mode 100644 index 0000000..6c9d4c6 --- /dev/null +++ b/backend/internal/drives/iface_test.go @@ -0,0 +1,24 @@ +package drives + +import "testing" + +func TestTextMentionsHTTPStatus(t *testing.T) { + tests := []struct { + name string + text string + want bool + }{ + {name: "status context", text: "request failed with status: 429 Too Many Requests", want: true}, + {name: "http context", text: "http 503 service unavailable", want: true}, + {name: "server returned context", text: "Server returned 403 Forbidden", want: true}, + {name: "message only", text: "操作频繁,请稍后重试", want: false}, + {name: "unrelated number", text: "generated 429 bytes", want: false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := TextMentionsHTTPStatus(tc.text, 403, 429, 503); got != tc.want { + t.Fatalf("TextMentionsHTTPStatus(%q) = %v, want %v", tc.text, got, tc.want) + } + }) + } +} diff --git a/backend/internal/drives/onedrive/driver.go b/backend/internal/drives/onedrive/driver.go index a722368..1e9d5f6 100644 --- a/backend/internal/drives/onedrive/driver.go +++ b/backend/internal/drives/onedrive/driver.go @@ -594,8 +594,8 @@ func (d *Driver) refresh(ctx context.Context) error { return nil } -func isRateLimitResponse(res *resty.Response, code, message string) bool { - if isRateLimitCode(code) || isRateLimitMessage(message) { +func isRateLimitResponse(res *resty.Response, code, _ string) bool { + if isRateLimitCode(code) { return true } if res == nil { @@ -632,18 +632,6 @@ func isRateLimitCode(code string) bool { } } -func isRateLimitMessage(message string) bool { - text := strings.ToLower(strings.TrimSpace(message)) - if text == "" { - return false - } - return strings.Contains(text, "too many requests") || - strings.Contains(text, "throttl") || - strings.Contains(text, "rate limit") || - strings.Contains(text, "activity limit") || - strings.Contains(text, "temporarily blocked") -} - func onedriveRateLimitError(res *resty.Response, message string) error { if strings.TrimSpace(message) == "" { message = "onedrive rate limited" diff --git a/backend/internal/drives/onedrive/driver_test.go b/backend/internal/drives/onedrive/driver_test.go index ed695df..312d262 100644 --- a/backend/internal/drives/onedrive/driver_test.go +++ b/backend/internal/drives/onedrive/driver_test.go @@ -214,7 +214,7 @@ func TestGraph429ReturnsRateLimitErrorWithRetryAfter(t *testing.T) { } } -func TestGraphThrottleMessageReturnsRateLimitError(t *testing.T) { +func TestGraphThrottleMessageDoesNotReturnRateLimitError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) @@ -238,11 +238,11 @@ func TestGraphThrottleMessageReturnsRateLimitError(t *testing.T) { _, err := d.StreamURL(context.Background(), "file-id") if err == nil { - t.Fatal("list succeeded, want rate limit error") + t.Fatal("list succeeded, want graph error") } var rateLimit *drives.RateLimitError - if !errors.As(err, &rateLimit) { - t.Fatalf("error = %T %[1]v, want RateLimitError", err) + if errors.As(err, &rateLimit) { + t.Fatalf("error = %T %[1]v, want non-rate-limit error", err) } } diff --git a/backend/internal/drives/p115/driver.go b/backend/internal/drives/p115/driver.go index eb67129..7cfaa53 100644 --- a/backend/internal/drives/p115/driver.go +++ b/backend/internal/drives/p115/driver.go @@ -87,7 +87,7 @@ func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) // p115ListCooldown 是列目录触发疑似风控错误时的冷却时长。 // // 历史上是 [30min × 3],3 次都失败就放弃;新策略改为 10 分钟无限重试 —— -// 只要错误仍属 transient(429 / 405 / WAF / blocked / 安全威胁 / unexpected), +// 只要错误仍属明确 HTTP transient 状态(429 / 405), // 就持续等 10 分钟再发一次列目录请求,直到成功或 ctx 取消。这样即使 115 // 风控持续较长时间,扫描会自然延后到风控结束,不再丢半棵子树。 const p115ListCooldown = 10 * time.Minute @@ -156,17 +156,7 @@ func isTransient115UpstreamError(err error) bool { if err == nil { return false } - text := strings.ToLower(err.Error()) - return strings.Contains(text, "405") || - strings.Contains(text, "429") || - strings.Contains(text, "too many request") || - strings.Contains(text, "too many requests") || - strings.Contains(text, "blocked") || - strings.Contains(text, "security") || - strings.Contains(text, "waf") || - strings.Contains(text, "unexpected error") || - strings.Contains(text, "访问被阻断") || - strings.Contains(text, "安全威胁") + return drives.ErrorMentionsHTTPStatus(err, http.StatusMethodNotAllowed, http.StatusTooManyRequests) } // ListDirsOnly 只列指定目录的直接**子目录**,不返回文件条目。专为 admin 后台 diff --git a/backend/internal/drives/p115/driver_test.go b/backend/internal/drives/p115/driver_test.go index 13af5a8..0796cd4 100644 --- a/backend/internal/drives/p115/driver_test.go +++ b/backend/internal/drives/p115/driver_test.go @@ -22,8 +22,9 @@ func TestIsTransient115ListError(t *testing.T) { want bool }{ {name: "nil", err: nil, want: false}, - {name: "blocked html", err: errors.New(`405Sorry, your request has been blocked as it may cause potential threats to the server's security.`), want: true}, - {name: "chinese waf", err: errors.New("很抱歉,由于您访问的URL有可能对网站造成安全威胁,您的访问被阻断。"), want: true}, + {name: "blocked html without status context", err: errors.New(`405Sorry, your request has been blocked as it may cause potential threats to the server's security.`), want: false}, + {name: "chinese waf", err: errors.New("很抱歉,由于您访问的URL有可能对网站造成安全威胁,您的访问被阻断。"), want: false}, + {name: "status 405", err: errors.New("request failed with status: 405"), want: true}, {name: "rate limit", err: errors.New("429 too many requests"), want: true}, {name: "regular auth error", err: errors.New("invalid credential"), want: false}, } @@ -43,10 +44,10 @@ func TestWrap115StreamTransientError(t *testing.T) { err error wantRateLimit bool }{ - {name: "unexpected", err: errors.New("unexpected error"), wantRateLimit: true}, + {name: "unexpected", err: errors.New("unexpected error"), wantRateLimit: false}, {name: "405 blocked", err: errors.New("405 request has been blocked"), wantRateLimit: true}, {name: "429", err: errors.New("429 too many requests"), wantRateLimit: true}, - {name: "blocked", err: errors.New("blocked by waf"), wantRateLimit: true}, + {name: "blocked", err: errors.New("blocked by waf"), wantRateLimit: false}, {name: "auth", err: errors.New("invalid credential"), wantRateLimit: false}, } diff --git a/backend/internal/drives/p123/driver.go b/backend/internal/drives/p123/driver.go index 5f862aa..7c292dc 100644 --- a/backend/internal/drives/p123/driver.go +++ b/backend/internal/drives/p123/driver.go @@ -754,8 +754,8 @@ func (d *Driver) request(ctx context.Context, endpoint, method string, configure return nil, errors.New("123pan request: unauthorized") } -func isP123RateLimitResponse(res *resty.Response, code int, message string) bool { - if code == http.StatusTooManyRequests || isP123RateLimitMessage(message) { +func isP123RateLimitResponse(res *resty.Response, code int, _ string) bool { + if code == http.StatusTooManyRequests { return true } if res == nil { @@ -764,7 +764,7 @@ func isP123RateLimitResponse(res *resty.Response, code int, message string) bool return isP123RateLimitHTTPResponse(res.StatusCode(), res.Header().Get("Retry-After"), res.String()) } -func isP123RateLimitHTTPResponse(status int, retryAfter, body string) bool { +func isP123RateLimitHTTPResponse(status int, retryAfter, _ string) bool { if status == http.StatusTooManyRequests { return true } @@ -774,35 +774,9 @@ func isP123RateLimitHTTPResponse(status int, retryAfter, body string) bool { return true } } - if isP123RateLimitMessage(body) { - return true - } return false } -func isP123RateLimitMessage(message string) bool { - text := strings.ToLower(strings.TrimSpace(message)) - if text == "" { - return false - } - return strings.Contains(text, "请求太频繁") || - strings.Contains(text, "请求过于频繁") || - strings.Contains(text, "请求频繁") || - strings.Contains(text, "操作频繁") || - strings.Contains(text, "频率限制") || - strings.Contains(text, "请求次数过多") || - strings.Contains(text, "too many request") || - strings.Contains(text, "too many requests") || - strings.Contains(text, "rate limit") || - strings.Contains(text, "rate-limit") || - strings.Contains(text, "ratelimit") || - strings.Contains(text, "throttl") || - strings.Contains(text, "temporarily blocked") || - strings.Contains(text, "request has been blocked") || - strings.Contains(text, "blocked") || - strings.Contains(text, "访问被阻断") -} - func p123RateLimitError(res *resty.Response, code int, message string) error { if strings.TrimSpace(message) == "" { message = "123pan rate limited" diff --git a/backend/internal/drives/pikpak/driver.go b/backend/internal/drives/pikpak/driver.go index 5486461..8c87f13 100644 --- a/backend/internal/drives/pikpak/driver.go +++ b/backend/internal/drives/pikpak/driver.go @@ -175,8 +175,8 @@ func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) // pikpakListCooldown 是列目录触发疑似限流错误时的冷却时长。 // -// 与 p115 driver 的 listCooldown 同语义:只要错误属 transient -// (error_code=10 / HTTP 429 / 5xx / 通用 "rate limit" 文本),就持续 +// 与 p115 driver 的 listCooldown 同语义:只要错误属明确限流/临时状态 +// (结构化 error_code=10 / HTTP 429 / 5xx),就持续 // 等 10 分钟再发一次列目录请求,直到成功或 ctx 取消。这样即使 PikPak // 风控持续较长时间,扫描会自然延后到风控结束,不再丢半棵子树。 const pikpakListCooldown = 10 * time.Minute @@ -242,7 +242,6 @@ func pikpakSleepContext(ctx context.Context, d time.Duration) error { // // - PikPak 业务码 error_code=10 ("操作频繁",见 OpenList drivers/pikpak/util.go) // - HTTP 429 / 500 / 502 / 503 / 504 / 509(rclone 也把这些归为 retry) -// - 通用文本:rate limit / too many requests / blocked / temporarily unavailable // // 不包含 4122/4121/16(access_token 过期)和 9/4002(captcha 过期)—— 这些 // 由 requestOnce 内部已经做过一次自动恢复重试;如果恢复后仍然报这类错误, @@ -259,22 +258,14 @@ func isTransientPikPakListError(err error) bool { return true } } - text := strings.ToLower(err.Error()) - return strings.Contains(text, "error_code=10") || - strings.Contains(text, "429") || - strings.Contains(text, "http 500") || - strings.Contains(text, "http 502") || - strings.Contains(text, "http 503") || - strings.Contains(text, "http 504") || - strings.Contains(text, "http 509") || - strings.Contains(text, "too many request") || - strings.Contains(text, "too many requests") || - strings.Contains(text, "rate limit") || - strings.Contains(text, "operation frequent") || - strings.Contains(text, "操作频繁") || - strings.Contains(text, "blocked") || - strings.Contains(text, "temporarily unavailable") || - strings.Contains(text, "service unavailable") + 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) { diff --git a/backend/internal/drives/wopan/driver.go b/backend/internal/drives/wopan/driver.go index bf9aac3..244210b 100644 --- a/backend/internal/drives/wopan/driver.go +++ b/backend/internal/drives/wopan/driver.go @@ -510,42 +510,14 @@ func isWopanRateLimitError(err error) bool { if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return false } - text := strings.ToLower(strings.TrimSpace(err.Error())) - if text == "" { - return false - } - return strings.Contains(text, "status: 429") || - strings.Contains(text, "status 429") || - strings.Contains(text, "http status: 429") || - strings.Contains(text, "status: 500") || - strings.Contains(text, "status 500") || - strings.Contains(text, "status: 502") || - strings.Contains(text, "status 502") || - strings.Contains(text, "status: 503") || - strings.Contains(text, "status 503") || - strings.Contains(text, "status: 504") || - strings.Contains(text, "status 504") || - strings.Contains(text, "status: 509") || - strings.Contains(text, "status 509") || - strings.Contains(text, "too many request") || - strings.Contains(text, "too many requests") || - strings.Contains(text, "rate limit") || - strings.Contains(text, "rate-limit") || - strings.Contains(text, "throttl") || - strings.Contains(text, "blocked") || - strings.Contains(text, "request has been blocked") || - strings.Contains(text, "操作频繁") || - strings.Contains(text, "请求频繁") || - strings.Contains(text, "请求太频繁") || - strings.Contains(text, "请求过于频繁") || - strings.Contains(text, "频率限制") || - strings.Contains(text, "请求次数过多") || - strings.Contains(text, "系统繁忙") || - strings.Contains(text, "服务繁忙") || - strings.Contains(text, "稍后再试") || - strings.Contains(text, "稍后重试") || - strings.Contains(text, "访问被阻断") || - strings.Contains(text, "风控") + return drives.ErrorMentionsHTTPStatus(err, + http.StatusTooManyRequests, + http.StatusInternalServerError, + http.StatusBadGateway, + http.StatusServiceUnavailable, + http.StatusGatewayTimeout, + 509, + ) } func guessMime(name string) string { diff --git a/backend/internal/fingerprint/worker.go b/backend/internal/fingerprint/worker.go index 3e36d3a..5f35cdf 100644 --- a/backend/internal/fingerprint/worker.go +++ b/backend/internal/fingerprint/worker.go @@ -372,37 +372,10 @@ func remoteRangeResponseLooksRateLimited(rawURL string, status int, body []byte) status == 509) { return true } - text := strings.ToLower(strings.TrimSpace(string(body))) - compact := compactRemoteRangeErrorText(text) - if strings.Contains(text, "too many request") || - strings.Contains(text, "too many requests") || - strings.Contains(text, "rate limit") || - strings.Contains(text, "quota exceeded") || - strings.Contains(text, "操作频繁") || - strings.Contains(text, "请求频繁") || - strings.Contains(text, "请求太频繁") || - strings.Contains(text, "请求过于频繁") || - strings.Contains(text, "频率限制") || - strings.Contains(text, "请求次数过多") || - strings.Contains(text, "系统繁忙") || - strings.Contains(text, "服务繁忙") || - strings.Contains(text, "稍后再试") || - strings.Contains(text, "稍后重试") || - strings.Contains(text, "访问被阻断") || - strings.Contains(text, "风控") || - strings.Contains(text, "download quota") || - strings.Contains(text, "sharing rate") || - strings.Contains(text, "daily limit") || - strings.Contains(text, "user rate") || - strings.Contains(text, "usage limit") || - strings.Contains(compact, "ratelimitexceeded") || - strings.Contains(compact, "userratelimitexceeded") || - strings.Contains(compact, "dailylimitexceeded") || - strings.Contains(compact, "downloadquotaexceeded") || - strings.Contains(compact, "sharingratelimitexceeded") || - strings.Contains(compact, "quotaexceeded") || - strings.Contains(compact, "toomanyrequests") || - strings.Contains(compact, "usagelimits") { + if isGuangYaPanMediaURL(rawURL) && (status == http.StatusForbidden || status == http.StatusTooManyRequests || + status == http.StatusInternalServerError || status == http.StatusBadGateway || + status == http.StatusServiceUnavailable || status == http.StatusGatewayTimeout || + status == 509) { return true } if status == http.StatusForbidden && isGoogleDriveMediaURL(rawURL) { @@ -424,6 +397,16 @@ func isWopanMediaURL(rawURL string) bool { strings.Contains(path, "/openapi/download") } +func isGuangYaPanMediaURL(rawURL string) bool { + u, err := url.Parse(rawURL) + if err != nil { + return false + } + host := strings.ToLower(u.Hostname()) + return strings.HasSuffix(host, "guangyacdn.com") || + strings.HasSuffix(host, "guangyapan.com") +} + func isGoogleDriveMediaURL(rawURL string) bool { u, err := url.Parse(rawURL) if err != nil { @@ -434,11 +417,6 @@ func isGoogleDriveMediaURL(rawURL string) bool { return strings.Contains(host, "googleapis.com") && strings.Contains(path, "/drive/") } -func compactRemoteRangeErrorText(text string) string { - replacer := strings.NewReplacer("_", "", "-", "", " ", "", ".", "", ":", "") - return replacer.Replace(strings.ToLower(strings.TrimSpace(text))) -} - func parseRetryAfter(raw string) time.Duration { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/backend/internal/fingerprint/worker_test.go b/backend/internal/fingerprint/worker_test.go index dbd1206..284ef6a 100644 --- a/backend/internal/fingerprint/worker_test.go +++ b/backend/internal/fingerprint/worker_test.go @@ -86,16 +86,16 @@ func TestComputeRemoteUsesRangeSamples(t *testing.T) { } } -func TestComputeRemoteGoogleQuotaExceededReturnsRateLimit(t *testing.T) { +func TestComputeRemote429ReturnsRateLimit(t *testing.T) { ctx := context.Background() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Retry-After", "60") - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`{"error":{"code":403,"message":"The download quota for this file has been exceeded.","errors":[{"domain":"usageLimits","reason":"downloadQuotaExceeded","message":"The download quota for this file has been exceeded."}]}}`)) + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"error":{"code":429}}`)) })) defer srv.Close() - drv := &fakeDrive{paths: map[string]string{"remote": srv.URL + "/drive/v3/files/file-1?alt=media"}} + drv := &fakeDrive{paths: map[string]string{"remote": srv.URL + "/video.mp4"}} _, err := Compute(ctx, drv, &catalog.Video{ID: "remote", FileID: "remote", Size: 1024 * 1024}, Config{ SampleSizeBytes: 4, FullHashMaxSize: 8, @@ -131,6 +131,30 @@ func TestWopanRemoteRangeErrorsLookRateLimited(t *testing.T) { } } +func TestGuangYaPanRemoteRangeErrorsLookRateLimited(t *testing.T) { + for _, tc := range []struct { + rawURL string + status int + }{ + {rawURL: "https://txgz02-httpdown.guangyacdn.com/download/?fid=encoded", status: http.StatusForbidden}, + {rawURL: "https://txgz02-httpdown.guangyacdn.com/download/?fid=encoded", status: http.StatusServiceUnavailable}, + {rawURL: "https://txgz02-httpdown.guangyacdn.com/download/?fid=encoded", status: 509}, + } { + if !remoteRangeResponseLooksRateLimited(tc.rawURL, tc.status, nil) { + t.Fatalf("remoteRangeResponseLooksRateLimited(%q, %d) = false, want true", tc.rawURL, tc.status) + } + } + if remoteRangeResponseLooksRateLimited("https://example.com/video.mp4", http.StatusForbidden, nil) { + t.Fatal("generic 403 should not be treated as guangyapan rate limit") + } +} + +func TestGoogleDriveRemoteRangeForbiddenLooksRateLimitedByURL(t *testing.T) { + if !remoteRangeResponseLooksRateLimited("https://www.googleapis.com/drive/v3/files/file-1?alt=media", http.StatusForbidden, nil) { + t.Fatal("google drive media 403 should be treated as rate limit by URL and status") + } +} + type fakeDrive struct { paths map[string]string } diff --git a/backend/internal/preview/ffmpeg.go b/backend/internal/preview/ffmpeg.go index f4a2d55..17f9437 100644 --- a/backend/internal/preview/ffmpeg.go +++ b/backend/internal/preview/ffmpeg.go @@ -952,15 +952,7 @@ func redactURLs(text string) string { } func ffmpegOutputLooksRateLimited(output []byte) bool { - text := strings.ToLower(string(output)) - if !strings.Contains(text, "429") { - return false - } - return strings.Contains(text, "too many requests") || - strings.Contains(text, "throttl") || - strings.Contains(text, "rate limit") || - strings.Contains(text, "rate-limit") || - strings.Contains(text, "server returned 429") + return drives.TextMentionsHTTPStatus(string(output), http.StatusTooManyRequests) } // --- 本地落盘 --- @@ -1064,12 +1056,10 @@ type ThumbWorker struct { } const ( - defaultTransientMediaCooldown = 5 * time.Minute - defaultGenerationRateLimitCooldown = 5 * time.Minute - defaultThumbTransientMediaMaxFailures = 3 - defaultWorkerQueueSize = 10000 - maxPreviewTeaserSizeBytes int64 = 5 * 1024 * 1024 * 1024 - previewStatusSkipped = "skipped" + defaultTransientMediaCooldown = 5 * time.Minute + defaultGenerationRateLimitCooldown = 5 * time.Minute + defaultThumbTransientMediaMaxFailures = 3 + defaultWorkerQueueSize = 10000 ) type rateLimitState struct { @@ -1518,145 +1508,21 @@ func driveErrorShouldCooldown(d drives.Drive, err error) bool { } switch d.Kind() { case "p115": - text := strings.ToLower(err.Error()) - return strings.Contains(text, "server returned 403") || - strings.Contains(text, "403 forbidden") || - strings.Contains(text, "server returned 405") || - strings.Contains(text, "405 method") || - strings.Contains(text, "access denied") || - strings.Contains(text, "moov atom not found") || - strings.Contains(text, "partial file") || - strings.Contains(text, "request has been blocked") || - strings.Contains(text, "访问被阻断") + return drives.ErrorMentionsHTTPStatus(err, http.StatusForbidden, http.StatusMethodNotAllowed, http.StatusTooManyRequests) case "pikpak": - // PikPak 在预览视频 / 封面生成阶段(取链或拉直链字节)可能命中: - // - error_code=10 操作频繁 - // - HTTP 429 / 5xx / 509 限流和服务端不可用 - // - 通用文本:rate limit / too many requests / blocked - // 命中时让 worker 冷却 5 分钟,避免连续请求加重风控。 - text := strings.ToLower(err.Error()) - return strings.Contains(text, "error_code=10") || - strings.Contains(text, "操作频繁") || - strings.Contains(text, "429") || - strings.Contains(text, "http 500") || - strings.Contains(text, "http 502") || - strings.Contains(text, "http 503") || - strings.Contains(text, "http 504") || - strings.Contains(text, "http 509") || - strings.Contains(text, "too many request") || - strings.Contains(text, "too many requests") || - strings.Contains(text, "rate limit") || - strings.Contains(text, "blocked") || - strings.Contains(text, "partial file") || - strings.Contains(text, "service unavailable") + return drives.ErrorMentionsHTTPStatus(err, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout, 509) case "p123": - // 123网盘直链解析 / ffmpeg 读取阶段可能返回 429、5xx,或 WAF 类 - // blocked / 访问阻断文本。命中时冷却,避免封面和预览视频生成连续打接口。 - text := strings.ToLower(err.Error()) - return strings.Contains(text, "请求太频繁") || - strings.Contains(text, "请求过于频繁") || - strings.Contains(text, "请求频繁") || - strings.Contains(text, "操作频繁") || - strings.Contains(text, "频率限制") || - strings.Contains(text, "请求次数过多") || - strings.Contains(text, "429") || - strings.Contains(text, "http 500") || - strings.Contains(text, "http 502") || - strings.Contains(text, "http 503") || - strings.Contains(text, "http 504") || - strings.Contains(text, "server returned 403") || - strings.Contains(text, "403 forbidden") || - strings.Contains(text, "too many request") || - strings.Contains(text, "too many requests") || - strings.Contains(text, "rate limit") || - strings.Contains(text, "blocked") || - strings.Contains(text, "访问被阻断") || - strings.Contains(text, "service unavailable") + return drives.ErrorMentionsHTTPStatus(err, http.StatusForbidden, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout) case "wopan": - // 联通网盘的取链接口和下载直链都可能返回"操作频繁"、429、5xx - // 或 WAF 阻断文本。封面/预览失败时先冷却,避免持续触发风控。 - text := strings.ToLower(err.Error()) - return strings.Contains(text, "请求太频繁") || - strings.Contains(text, "请求过于频繁") || - strings.Contains(text, "请求频繁") || - strings.Contains(text, "操作频繁") || - strings.Contains(text, "频率限制") || - strings.Contains(text, "请求次数过多") || - strings.Contains(text, "系统繁忙") || - strings.Contains(text, "服务繁忙") || - strings.Contains(text, "稍后再试") || - strings.Contains(text, "稍后重试") || - strings.Contains(text, "429") || - strings.Contains(text, "http 500") || - strings.Contains(text, "http 502") || - strings.Contains(text, "http 503") || - strings.Contains(text, "http 504") || - strings.Contains(text, "http 509") || - strings.Contains(text, "server returned 403") || - strings.Contains(text, "403 forbidden") || - strings.Contains(text, "server returned 429") || - strings.Contains(text, "server returned 500") || - strings.Contains(text, "server returned 502") || - strings.Contains(text, "server returned 503") || - strings.Contains(text, "server returned 504") || - strings.Contains(text, "too many request") || - strings.Contains(text, "too many requests") || - strings.Contains(text, "rate limit") || - strings.Contains(text, "rate-limit") || - strings.Contains(text, "throttl") || - strings.Contains(text, "blocked") || - strings.Contains(text, "request has been blocked") || - strings.Contains(text, "访问被阻断") || - strings.Contains(text, "风控") || - strings.Contains(text, "service unavailable") + return drives.ErrorMentionsHTTPStatus(err, http.StatusForbidden, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout, 509) + case "guangyapan": + return drives.ErrorMentionsHTTPStatus(err, http.StatusForbidden, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout, 509) case "googledrive": - // Google Drive 下载/取样阶段常把频控和配额问题包装成 403, - // 具体标识在 error.errors[].reason/message 里(OpenList 也按该结构解析)。 - // ffmpeg/ffprobe 只能看到 stderr 文本时,按这些 reason/文本兜底冷却。 - text := strings.ToLower(err.Error()) - return googleDriveMediaErrorShouldCooldown(text) + return drives.ErrorMentionsHTTPStatus(err, http.StatusForbidden, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout) } return false } -func googleDriveMediaErrorShouldCooldown(text string) bool { - if text == "" { - return false - } - compact := compactGoogleDriveErrorText(text) - return strings.Contains(text, "server returned 403") || - strings.Contains(text, "403 forbidden") || - strings.Contains(text, "server returned 429") || - strings.Contains(text, "http 429") || - strings.Contains(text, "http 500") || - strings.Contains(text, "http 502") || - strings.Contains(text, "http 503") || - strings.Contains(text, "http 504") || - strings.Contains(text, "too many request") || - strings.Contains(text, "too many requests") || - strings.Contains(text, "rate limit") || - strings.Contains(text, "quota exceeded") || - strings.Contains(text, "download quota") || - strings.Contains(text, "sharing rate") || - strings.Contains(text, "daily limit") || - strings.Contains(text, "user rate") || - strings.Contains(text, "usage limit") || - strings.Contains(text, "service unavailable") || - strings.Contains(compact, "ratelimitexceeded") || - strings.Contains(compact, "userratelimitexceeded") || - strings.Contains(compact, "dailylimitexceeded") || - strings.Contains(compact, "downloadquotaexceeded") || - strings.Contains(compact, "sharingratelimitexceeded") || - strings.Contains(compact, "quotaexceeded") || - strings.Contains(compact, "toomanyrequests") || - strings.Contains(compact, "usagelimits") -} - -func compactGoogleDriveErrorText(text string) string { - replacer := strings.NewReplacer("_", "", "-", "", " ", "", ".", "", ":", "") - return replacer.Replace(strings.ToLower(strings.TrimSpace(text))) -} - func (w *ThumbWorker) process(ctx context.Context, v *catalog.Video) bool { if w.skipIfRateLimited(v) { return false @@ -1806,15 +1672,6 @@ func localPreviewLink(v *catalog.Video) (*drives.StreamLink, bool) { } func (w *Worker) process(ctx context.Context, v *catalog.Video) { - if shouldSkipTeaser(v) { - removePreviousLocalTeaser(v.PreviewLocal, "") - if err := w.Catalog.UpdatePreview(ctx, v.ID, "", previewStatusSkipped); err != nil { - log.Printf("[preview] skip %s: update status: %v", v.Title, err) - return - } - log.Printf("[preview] skip %s: size=%d exceeds 5GiB teaser limit", v.Title, v.Size) - return - } if w.skipIfRateLimited(v) { return } @@ -1867,10 +1724,6 @@ func (w *Worker) process(ctx context.Context, v *catalog.Video) { log.Printf("[preview] ready %s (duration=%.1fs)", v.Title, duration) } -func shouldSkipTeaser(v *catalog.Video) bool { - return v != nil && v.Size > maxPreviewTeaserSizeBytes -} - func (w *Worker) generateTeaser(ctx context.Context, v *catalog.Video, link *drives.StreamLink, duration float64) (string, error) { gen, ok := w.Gen.(refreshingTeaserGenerator) if !ok || w.Drive == nil || w.Drive.Kind() != "p115" { diff --git a/backend/internal/preview/worker_test.go b/backend/internal/preview/worker_test.go index 50672ff..750f5b0 100644 --- a/backend/internal/preview/worker_test.go +++ b/backend/internal/preview/worker_test.go @@ -349,42 +349,10 @@ func TestPreviewWorkerNeverCallsDriveUploadOrEnsureDir(t *testing.T) { } } -func TestPreviewWorkerSkipsTeaserForVideoLargerThanFiveGiB(t *testing.T) { +func TestPreviewWorkerGeneratesTeaserForLargeVideo(t *testing.T) { ctx := context.Background() cat, video := seedPreviewTestVideo(t, "preview-large-video") - video.Size = maxPreviewTeaserSizeBytes + 1 - if err := cat.UpsertVideo(ctx, video); err != nil { - t.Fatalf("update video: %v", err) - } - - gen := &fakeTeaserGenerator{} - drv := &previewFakeDrive{} - worker := NewWorker(gen, cat, drv) - - worker.process(ctx, video) - - got, err := cat.GetVideo(ctx, video.ID) - if err != nil { - t.Fatalf("get video: %v", err) - } - if got.PreviewStatus != previewStatusSkipped { - t.Fatalf("preview status = %q, want skipped", got.PreviewStatus) - } - if got.PreviewLocal != "" { - t.Fatalf("preview local = %q, want empty", got.PreviewLocal) - } - if drv.streamCalls != 0 { - t.Fatalf("stream calls = %d, want 0", drv.streamCalls) - } - if gen.generateCalls != 0 { - t.Fatalf("generate calls = %d, want 0", gen.generateCalls) - } -} - -func TestPreviewWorkerGeneratesTeaserAtFiveGiBBoundary(t *testing.T) { - ctx := context.Background() - cat, video := seedPreviewTestVideo(t, "preview-five-gib-video") - video.Size = maxPreviewTeaserSizeBytes + video.Size = 6 * 1024 * 1024 * 1024 if err := cat.UpsertVideo(ctx, video); err != nil { t.Fatalf("update video: %v", err) } @@ -485,9 +453,9 @@ func TestThumbWorkerRateLimitHonorsRetryAfter(t *testing.T) { assertCooldownAround(t, worker.Status().CooldownUntil, before, 2*time.Hour) } -func TestThumbWorkerP115TransientErrorFailsAfterRetryLimit(t *testing.T) { +func TestThumbWorkerP115MessageOnlyErrorFailsWithoutCooldown(t *testing.T) { ctx := context.Background() - cat, video := seedPreviewTestVideo(t, "thumb-p115-transient") + cat, video := seedPreviewTestVideo(t, "thumb-p115-message-only") gen := &fakeThumbGenerator{ generateErr: errors.New("ffmpeg thumb: exit status 183, stderr: partial file Cannot determine format of input 0:0 after EOF"), @@ -495,69 +463,26 @@ func TestThumbWorkerP115TransientErrorFailsAfterRetryLimit(t *testing.T) { drv := &previewFakeDrive{kind: "p115"} worker := NewThumbWorker(gen, cat, drv) - for attempt := 1; attempt <= defaultThumbTransientMediaMaxFailures; attempt++ { - worker.rateLimit = rateLimitState{} - worker.process(ctx, video) - - if attempt < defaultThumbTransientMediaMaxFailures { - pending, err := cat.ListVideosByThumbnailStatus(ctx, video.DriveID, "pending", 0) - if err != nil { - t.Fatalf("list pending thumbnails: %v", err) - } - if len(pending) != 1 || pending[0].ID != video.ID { - t.Fatalf("attempt %d pending thumbnails = %#v, want only %s", attempt, pending, video.ID) - } - missing, err := cat.CountVideosNeedingThumbnail(ctx, video.DriveID) - if err != nil { - t.Fatalf("count missing thumbnails: %v", err) - } - if missing != 1 { - t.Fatalf("attempt %d missing thumbnails = %d, want 1 before retry limit", attempt, missing) - } - continue - } - - failed, err := cat.ListVideosByThumbnailStatus(ctx, video.DriveID, "failed", 0) - if err != nil { - t.Fatalf("list failed thumbnails: %v", err) - } - if len(failed) != 1 || failed[0].ID != video.ID { - t.Fatalf("failed thumbnails = %#v, want only %s", failed, video.ID) - } - missing, err := cat.CountVideosNeedingThumbnail(ctx, video.DriveID) - if err != nil { - t.Fatalf("count missing thumbnails: %v", err) - } - if missing != 0 { - t.Fatalf("missing thumbnails = %d, want 0 after retry limit marks failed", missing) - } - } - - if gen.generateCalls != defaultThumbTransientMediaMaxFailures { - t.Fatalf("generate calls = %d, want %d", gen.generateCalls, defaultThumbTransientMediaMaxFailures) - } - - if err := cat.UpdateVideoMeta(ctx, video.ID, catalog.VideoMetaPatch{ - ThumbnailStatus: "pending", - ResetThumbnailFailures: true, - }); err != nil { - t.Fatalf("reset thumbnail status: %v", err) - } - worker.rateLimit = rateLimitState{} worker.process(ctx, video) - pending, err := cat.ListVideosByThumbnailStatus(ctx, video.DriveID, "pending", 0) + failed, err := cat.ListVideosByThumbnailStatus(ctx, video.DriveID, "failed", 0) if err != nil { - t.Fatalf("list pending thumbnails after reset: %v", err) + t.Fatalf("list failed thumbnails: %v", err) } - if len(pending) != 1 || pending[0].ID != video.ID { - t.Fatalf("pending thumbnails after reset = %#v, want only %s", pending, video.ID) + if len(failed) != 1 || failed[0].ID != video.ID { + t.Fatalf("failed thumbnails = %#v, want only %s", failed, video.ID) + } + if !worker.Status().CooldownUntil.IsZero() { + t.Fatalf("cooldown until = %s, want no cooldown for message-only media error", worker.Status().CooldownUntil) + } + if gen.generateCalls != 1 { + t.Fatalf("generate calls = %d, want 1", gen.generateCalls) } } -func TestThumbWorkerRequeuesP115TransientErrorBeforeRetryLimit(t *testing.T) { +func TestThumbWorkerDoesNotRequeueP115MessageOnlyError(t *testing.T) { ctx := context.Background() - cat, video := seedPreviewTestVideo(t, "thumb-p115-requeue") + cat, video := seedPreviewTestVideo(t, "thumb-p115-no-requeue") gen := &fakeThumbGenerator{ generateErr: errors.New("ffmpeg thumb: partial file Cannot determine format of input 0:0 after EOF"), @@ -569,11 +494,8 @@ func TestThumbWorkerRequeuesP115TransientErrorBeforeRetryLimit(t *testing.T) { select { case queued := <-worker.ch: - if queued.ID != video.ID { - t.Fatalf("requeued video id = %q, want %q", queued.ID, video.ID) - } + t.Fatalf("unexpected requeued video id = %q", queued.ID) default: - t.Fatal("expected transient thumbnail failure to requeue the same video") } got, err := cat.GetVideo(ctx, video.ID) @@ -581,14 +503,14 @@ func TestThumbWorkerRequeuesP115TransientErrorBeforeRetryLimit(t *testing.T) { t.Fatalf("get video: %v", err) } if got.ThumbnailURL != "" { - t.Fatalf("thumbnail = %q, want empty after transient failure", got.ThumbnailURL) + t.Fatalf("thumbnail = %q, want empty after message-only failure", got.ThumbnailURL) } - pending, err := cat.ListVideosByThumbnailStatus(ctx, video.DriveID, "pending", 0) + failed, err := cat.ListVideosByThumbnailStatus(ctx, video.DriveID, "failed", 0) if err != nil { - t.Fatalf("list pending thumbnails: %v", err) + t.Fatalf("list failed thumbnails: %v", err) } - if len(pending) != 1 || pending[0].ID != video.ID { - t.Fatalf("pending thumbnails = %#v, want only %s", pending, video.ID) + if len(failed) != 1 || failed[0].ID != video.ID { + t.Fatalf("failed thumbnails = %#v, want only %s", failed, video.ID) } } @@ -649,13 +571,15 @@ func TestP123TransientErrorsShouldCooldown(t *testing.T) { drv := &previewFakeDrive{kind: "p123"} for _, err := range []error{ errors.New("Server returned 403 Forbidden"), - errors.New("请求太频繁"), errors.New("http 503 service unavailable"), } { if !driveErrorShouldCooldown(drv, err) { t.Fatalf("driveErrorShouldCooldown(%v) = false, want true", err) } } + if driveErrorShouldCooldown(drv, errors.New("请求太频繁")) { + t.Fatal("message-only throttling text should not trigger p123 cooldown") + } if driveErrorShouldCooldown(drv, errors.New("invalid credential")) { t.Fatal("invalid credential should not trigger p123 cooldown") } @@ -666,31 +590,58 @@ func TestWopanTransientErrorsShouldCooldown(t *testing.T) { for _, err := range []error{ errors.New("ffmpeg: Server returned 403 Forbidden"), errors.New("wopan download url: request failed with status: 429 Too Many Requests"), - errors.New("操作频繁,请稍后重试"), errors.New("http 503 service unavailable"), } { if !driveErrorShouldCooldown(drv, err) { t.Fatalf("driveErrorShouldCooldown(%v) = false, want true", err) } } + if driveErrorShouldCooldown(drv, errors.New("操作频繁,请稍后重试")) { + t.Fatal("message-only throttling text should not trigger wopan cooldown") + } if driveErrorShouldCooldown(drv, errors.New("invalid access token")) { t.Fatal("invalid access token should not trigger wopan cooldown") } } +func TestGuangYaPanTransientErrorsShouldCooldown(t *testing.T) { + drv := &previewFakeDrive{kind: "guangyapan"} + for _, err := range []error{ + errors.New("ffmpeg: Server returned 403 Forbidden"), + errors.New("guangyapan api rate limited: status=429 msg=操作频繁,请稍后重试"), + errors.New("http 503 service unavailable"), + } { + if !driveErrorShouldCooldown(drv, err) { + t.Fatalf("driveErrorShouldCooldown(%v) = false, want true", err) + } + } + if driveErrorShouldCooldown(drv, errors.New("操作频繁,请稍后重试")) { + t.Fatal("message-only throttling text should not trigger guangyapan cooldown") + } + if driveErrorShouldCooldown(drv, errors.New("invalid access token")) { + t.Fatal("invalid access token should not trigger guangyapan cooldown") + } +} + func TestGoogleDriveMediaErrorsShouldCooldown(t *testing.T) { drv := &previewFakeDrive{kind: "googledrive"} for _, err := range []error{ - errors.New("google drive api error: usageLimits userRateLimitExceeded"), errors.New("ffmpeg: Server returned 403 Forbidden"), - errors.New("downloadQuotaExceeded: The download quota for this file has been exceeded"), - errors.New("sharingRateLimitExceeded"), errors.New("http 503 service unavailable"), } { if !driveErrorShouldCooldown(drv, err) { t.Fatalf("driveErrorShouldCooldown(%v) = false, want true", err) } } + for _, err := range []error{ + errors.New("google drive api error: usageLimits userRateLimitExceeded"), + errors.New("downloadQuotaExceeded: The download quota for this file has been exceeded"), + errors.New("sharingRateLimitExceeded"), + } { + if driveErrorShouldCooldown(drv, err) { + t.Fatalf("message-only google drive error %v should not trigger cooldown", err) + } + } if driveErrorShouldCooldown(drv, errors.New("invalid credentials")) { t.Fatal("invalid credentials should not trigger googledrive cooldown") } diff --git a/backend/internal/proxy/proxy.go b/backend/internal/proxy/proxy.go index cb37696..e845640 100644 --- a/backend/internal/proxy/proxy.go +++ b/backend/internal/proxy/proxy.go @@ -151,13 +151,15 @@ func (p *Proxy) ServeStream(w http.ResponseWriter, r *http.Request, driveID, fil // 先解出最终 Location,浏览器可直接 302 到该短期地址 // - wopan:联通网盘 GetDownloadUrlV2 返回的是短期直链,OpenList 也是直接 // 将该 URL 交给客户端使用;不需要后端持续代传视频字节 +// - guangyapan:光鸭 get_res_download_url 返回 signedURL / downloadUrl, +// 浏览器可直接访问,不需要后端持续代传视频字节 // // 其余网盘(如夸克等)仍走反代,因为它们的下载 // 链接通常需要随请求带上后端持有的 Cookie / Authorization / Range // 的特殊处理,浏览器拿不到这些上下文。 func shouldRedirect(d drives.Drive) bool { switch d.Kind() { - case "p115", "pikpak", "onedrive", "p123", "wopan": + case "p115", "pikpak", "onedrive", "p123", "wopan", "guangyapan": return true } return false diff --git a/backend/internal/proxy/proxy_test.go b/backend/internal/proxy/proxy_test.go index 5d1c98f..651c562 100644 --- a/backend/internal/proxy/proxy_test.go +++ b/backend/internal/proxy/proxy_test.go @@ -226,6 +226,31 @@ func TestServeStreamRedirectsWopan(t *testing.T) { } } +func TestServeStreamRedirectsGuangYaPan(t *testing.T) { + reg := NewRegistry() + drv := &proxyFakeSimpleDrive{ + kind: "guangyapan", + url: "https://cdn.guangyapan.example/video.mp4?sign=encoded", + } + reg.Set("guangyapan", drv) + + p := New(reg) + req := httptest.NewRequest(http.MethodGet, "/p/stream/guangyapan/file-1", nil) + rr := httptest.NewRecorder() + + p.ServeStream(rr, req, "guangyapan", "file-1") + + if rr.Code != http.StatusFound { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusFound) + } + if got := rr.Header().Get("Location"); got != "https://cdn.guangyapan.example/video.mp4?sign=encoded" { + t.Fatalf("Location = %q", got) + } + if drv.calls != 1 { + t.Fatalf("link calls = %d, want 1", drv.calls) + } +} + func TestServeStreamServesLocalFilePath(t *testing.T) { path := filepath.Join(t.TempDir(), "video.mp4") if err := os.WriteFile(path, []byte("0123456789"), 0o644); err != nil { diff --git a/backend/internal/spider91migrate/migrator.go b/backend/internal/spider91migrate/migrator.go index d9e7328..9429156 100644 --- a/backend/internal/spider91migrate/migrator.go +++ b/backend/internal/spider91migrate/migrator.go @@ -1,5 +1,5 @@ // Package spider91migrate 周期性把 spider91 drive 下载到本地的视频 -// 上传到一个指定的目标 drive 目录(PikPak、115、123、OneDrive、Google Drive 或联通网盘),上传成功后: +// 上传到一个指定的目标 drive 目录(PikPak、115、123、OneDrive、Google Drive、联通网盘或光鸭网盘),上传成功后: // // - 改写 catalog 行:drive_id / file_id / content_hash 改成目标盘的; // 视频自身的 id 不变(仍是 spider91--),video_tags、 @@ -31,6 +31,7 @@ import ( "github.com/video-site/backend/internal/catalog" "github.com/video-site/backend/internal/drives" "github.com/video-site/backend/internal/drives/googledrive" + "github.com/video-site/backend/internal/drives/guangyapan" "github.com/video-site/backend/internal/drives/onedrive" "github.com/video-site/backend/internal/drives/p115" "github.com/video-site/backend/internal/drives/p123" @@ -42,7 +43,7 @@ import ( ) // uploadTarget 是 migrator 调用目标 drive 的最小接口。任何一种"接收 spider91 上传"的 -// 网盘都要实现它;当前 PikPak、115、123、OneDrive、Google Drive 和联通网盘各自通过适配器满足。 +// 网盘都要实现它;当前 PikPak、115、123、OneDrive、Google Drive、联通网盘和光鸭网盘各自通过适配器满足。 // // 这一层抽象把"迁移调用方"和"具体盘的 SDK 协议"解耦: // - PikPak 走 GCID + OSS PutObject(pikpak.UploadResult) @@ -51,6 +52,7 @@ import ( // - OneDrive 走 SHA1 + 小文件 PUT / 大文件 upload session // - Google Drive 走 MD5 + resumable upload session // - 联通网盘 走 SDK Upload2C,当前上游不返回内容 hash +// - 光鸭网盘 走 OSS 分片上传,当前上游不返回内容 hash // // 各家返回值都被归一成本地的 UploadResult,并在 catalog 改写阶段统一处理。 type uploadTarget interface { @@ -76,7 +78,7 @@ type Spider91LocalSource interface { // UploadResult 是 uploadTarget.UploadAndReportHash 的归一返回。 // // FileID 目标盘上的新文件 ID; -// Hash GCID(PikPak)、MD5 HEX(123 / Google Drive)或 SHA1 HEX(115 / OneDrive),写入 catalog.content_hash 用于跨盘去重;联通网盘暂为空; +// Hash GCID(PikPak)、MD5 HEX(123 / Google Drive)或 SHA1 HEX(115 / OneDrive),写入 catalog.content_hash 用于跨盘去重;联通网盘和光鸭网盘暂为空; // Size 实际上传字节数。 type UploadResult struct { FileID string @@ -110,7 +112,7 @@ type migrationPlan struct { legacyBackfill bool } -// pikpakAdapter / p115Adapter / p123Adapter / onedriveAdapter / googledriveAdapter / wopanAdapter 把具体 driver 包装成 uploadTarget。 +// pikpakAdapter / p115Adapter / p123Adapter / onedriveAdapter / googledriveAdapter / wopanAdapter / guangyapanAdapter 把具体 driver 包装成 uploadTarget。 // // 之所以不让 driver 直接实现 uploadTarget: // @@ -243,6 +245,27 @@ func (a *wopanAdapter) Rename(ctx context.Context, fileID, newName string) error return a.d.Rename(ctx, fileID, newName) } +type guangyapanAdapter struct { + d *guangyapan.Driver +} + +func (a *guangyapanAdapter) ID() string { return a.d.ID() } +func (a *guangyapanAdapter) Kind() string { return a.d.Kind() } +func (a *guangyapanAdapter) RootID() string { return a.d.RootID() } +func (a *guangyapanAdapter) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) { + return a.d.EnsureDir(ctx, pathFromRoot) +} +func (a *guangyapanAdapter) UploadAndReportHash(ctx context.Context, parentID, name string, r io.Reader, size int64) (UploadResult, error) { + fileID, err := a.d.Upload(ctx, parentID, name, r, size) + if err != nil { + return UploadResult{}, err + } + return UploadResult{FileID: fileID, Size: size}, nil +} +func (a *guangyapanAdapter) Rename(ctx context.Context, fileID, newName string) error { + return a.d.Rename(ctx, fileID, newName) +} + // adaptUploadTarget 把通用 drive 包装成 uploadTarget。 // 不支持的盘 kind 返回 error;调用方静默跳过。 func adaptUploadTarget(d drives.Drive) (uploadTarget, error) { @@ -259,6 +282,8 @@ func adaptUploadTarget(d drives.Drive) (uploadTarget, error) { return &googledriveAdapter{d: v}, nil case *wopan.Driver: return &wopanAdapter{d: v}, nil + case *guangyapan.Driver: + return &guangyapanAdapter{d: v}, nil case uploadTarget: // 测试或自定义实现可以直接传入;优先使用具体类型分支以拿到适配器。 return v, nil @@ -1183,7 +1208,7 @@ func (m *Migrator) cleanupOldLocalVideos(ctx context.Context, plan migrationPlan return deleted, nil } -// backfillFileNames 扫描目标 drive(PikPak、115、123、OneDrive、Google Drive 或联通网盘)下所有 spider91-* 起始 ID 的视频, +// backfillFileNames 扫描目标 drive(PikPak、115、123、OneDrive、Google Drive、联通网盘或光鸭网盘)下所有 spider91-* 起始 ID 的视频, // 对文件名不是 desiredPikPakName(...) 期望格式的,调 target.Rename 修正, // 并把 catalog.file_name 同步到新名字。 // diff --git a/backend/internal/spider91migrate/migrator_test.go b/backend/internal/spider91migrate/migrator_test.go index b62f728..28b2d59 100644 --- a/backend/internal/spider91migrate/migrator_test.go +++ b/backend/internal/spider91migrate/migrator_test.go @@ -1464,7 +1464,7 @@ func TestAdaptUploadTargetSupportsWopanDriver(t *testing.T) { } } -// TestResolveTargetRejectsUnsupportedKind 验证当目标 drive 既不是 PikPak、115、123、OneDrive、Google Drive 也不是联通网盘时, +// TestResolveTargetRejectsUnsupportedKind 验证当目标 drive 既不是 PikPak、115、123、OneDrive、Google Drive、联通网盘也不是光鸭网盘时, // resolveTarget 拒绝并返回 error,让 runOnce 静默跳过(不会做破坏性变更)。 func TestResolveTargetRejectsUnsupportedKind(t *testing.T) { cat := setupCatalog(t) diff --git a/gy_login_qr.py b/gy_login_qr.py new file mode 100644 index 0000000..eb8fc4b --- /dev/null +++ b/gy_login_qr.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +光鸭网盘 - 扫码登录脚本 +======================== +1. 调用 API 获取登录二维码 +2. 保存二维码图片,等待用户扫描 +3. 扫描成功后保存用户凭证信息 +""" + +import io +import sys + +# 修复 Windows 终端 GBK 编码问题 +if sys.platform == 'win32': + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') + +import requests +import json +import time +import os +import sys +from datetime import datetime + +# ========== 配置 ========== +API_ORIGIN = "https://account.guangyapan.com" +CLIENT_ID = "aMe-8VSlkrbQXpUR" +SCOPE = "user" +QR_IMAGE_PATH = "login_qr.png" +CREDENTIALS_PATH = "credentials.json" + +# ========== 可选依赖 ========== +try: + import qrcode + HAS_QRCODE = True +except ImportError: + HAS_QRCODE = False + +try: + from PIL import Image + HAS_PIL = True +except ImportError: + HAS_PIL = False + + +def generate_qr_image(url: str, path: str): + """生成二维码图片""" + if HAS_QRCODE: + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_M, + box_size=10, + border=4, + ) + qr.add_data(url) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white") + img.save(path) + print(f"[✓] 二维码已保存到: {path}") + else: + # Fallback: 使用 qrencode 命令行工具 + import subprocess + try: + subprocess.run(["qrencode", "-o", path, url], check=True) + print(f"[✓] 二维码已保存到: {path}") + except FileNotFoundError: + print("[✗] 需要安装 qrcode 库: pip install qrcode[pil]") + print(f"[!] 请手动访问以下链接扫码:") + print(f" {url}") + return + + # 尝试直接显示二维码到终端 + try: + if HAS_PIL: + img = Image.open(path) + img.show() + print("[✓] 二维码已在图片查看器中打开") + except Exception: + pass + + # 终端内显示小二维码 + if HAS_QRCODE: + try: + qr.print_ascii(invert=True) + except Exception: + pass + + +def main(): + session = requests.Session() + session.headers.update({ + "User-Agent": "GuangYaPan-Login/1.0", + "Accept": "application/json", + "Content-Type": "application/json", + }) + + # ====== Step 1: 获取设备码和二维码链接 ====== + print("=" * 60) + print("Step 1: 请求登录二维码...") + print("=" * 60) + + device_code_url = f"{API_ORIGIN}/v1/auth/device/code" + device_payload = { + "client_id": CLIENT_ID, + "scope": SCOPE, + } + + try: + resp = session.post(device_code_url, json=device_payload, timeout=30) + resp.raise_for_status() + device_data = resp.json() + except requests.exceptions.RequestException as e: + print(f"[✗] 请求失败: {e}") + if hasattr(e, 'response') and e.response is not None: + print(f" 响应内容: {e.response.text[:500]}") + sys.exit(1) + + print(f"[✓] 设备码获取成功") + print(f" device_code: {device_data.get('device_code', 'N/A')[:30]}...") + print(f" interval: {device_data.get('interval', 5)} 秒") + print(f" expires_in: {device_data.get('expires_in', 'N/A')} 秒") + + device_code = device_data["device_code"] + interval = int(device_data.get("interval", 5)) + expires_in = int(device_data.get("expires_in", 300)) + + # 二维码链接 + qr_url = device_data.get("verification_uri_complete") or device_data.get("short_uri_complete") + if not qr_url: + print("[✗] 响应中没有找到二维码链接") + print(f" 完整响应: {json.dumps(device_data, indent=2, ensure_ascii=False)}") + sys.exit(1) + + print(f" qr_url: {qr_url}") + print() + + # ====== Step 2: 生成并保存二维码 ====== + print("=" * 60) + print("Step 2: 生成二维码图片...") + print("=" * 60) + + generate_qr_image(qr_url, QR_IMAGE_PATH) + + print() + print("!" * 60) + print("! 请使用「光鸭APP」扫描二维码登录") + print("!" * 60) + print() + + # ====== Step 3: 轮询等待用户扫描 ====== + print("=" * 60) + print("Step 3: 等待扫码授权...") + print("=" * 60) + + token_url = f"{API_ORIGIN}/v1/auth/token" + token_payload = { + "client_id": CLIENT_ID, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": device_code, + } + + start_time = time.time() + attempt = 0 + + while True: + attempt += 1 + elapsed = time.time() - start_time + + # 检查是否超时 + if elapsed > expires_in: + print(f"\n[✗] 二维码已过期({expires_in}秒),请重新运行脚本") + sys.exit(1) + + time.sleep(interval) + + try: + resp = session.post(token_url, json=token_payload, timeout=30) + token_data = resp.json() + except requests.exceptions.RequestException as e: + print(f"\n[!] 网络错误: {e},重试中...") + continue + + if "error" in token_data: + error = token_data["error"] + if error in ("authorization_pending", "slow_down"): + # 用户还未扫描或确认 + dots = "." * ((attempt % 10) + 1) + print(f"\r 等待中{dots:<10} ({int(elapsed)}s / {expires_in}s)", end="", flush=True) + + if error == "slow_down": + interval = min(interval * 2, 60) + continue + + elif error == "expired_token": + print(f"\n[✗] 二维码已过期,请重新运行脚本") + sys.exit(1) + + elif error == "access_denied": + print(f"\n[✗] 用户拒绝了授权") + sys.exit(1) + + else: + print(f"\n[✗] 未知错误: {error}") + print(f" 完整响应: {json.dumps(token_data, indent=2, ensure_ascii=False)}") + sys.exit(1) + else: + # 成功! + print(f"\n[✓] 扫码授权成功!({int(elapsed)}s)") + break + + # ====== Step 4: 保存凭证 ====== + print() + print("=" * 60) + print("Step 4: 保存用户凭证...") + print("=" * 60) + + # 保存完整 token 响应 + credentials = { + "saved_at": datetime.now().isoformat(), + "api_origin": API_ORIGIN, + "client_id": CLIENT_ID, + "token_response": token_data, + "cookies": dict(session.cookies), + } + + with open(CREDENTIALS_PATH, "w", encoding="utf-8") as f: + json.dump(credentials, f, indent=2, ensure_ascii=False) + print(f"[✓] 完整凭证已保存到: {CREDENTIALS_PATH}") + + # 提取关键信息 + access_token = token_data.get("access_token", "") + refresh_token = token_data.get("refresh_token", "") + id_token = token_data.get("id_token", "") + token_type = token_data.get("token_type", "Bearer") + expires_in = token_data.get("expires_in", 0) + + print() + print("-" * 60) + print("凭证摘要:") + print("-" * 60) + print(f" access_token: {access_token[:50]}..." if access_token else " access_token: (无)") + print(f" refresh_token: {refresh_token[:50]}..." if refresh_token else " refresh_token: (无)") + print(f" id_token: {id_token[:50]}..." if id_token else " id_token: (无)") + print(f" token_type: {token_type}") + print(f" expires_in: {expires_in} 秒") + print(f" scope: {token_data.get('scope', SCOPE)}") + print("-" * 60) + + # 尝试获取用户信息 + print() + print("=" * 60) + print("Step 5: 获取用户信息...") + print("=" * 60) + + user_info_url = f"{API_ORIGIN}/v1/user/me" + try: + user_headers = { + "Authorization": f"{token_type} {access_token}", + } + user_resp = requests.get(user_info_url, headers=user_headers, timeout=15) + if user_resp.status_code == 200: + user_data = user_resp.json() + print("[✓] 用户信息获取成功:") + print(json.dumps(user_data, indent=2, ensure_ascii=False)) + + # 追加用户信息到凭证文件 + credentials["user_info"] = user_data + with open(CREDENTIALS_PATH, "w", encoding="utf-8") as f: + json.dump(credentials, f, indent=2, ensure_ascii=False) + else: + print(f"[!] 获取用户信息返回 {user_resp.status_code}: {user_resp.text[:200]}") + except Exception as e: + print(f"[!] 获取用户信息失败: {e}") + + print() + print("=" * 60) + print("完成!凭证文件: " + CREDENTIALS_PATH) + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/src/admin/CrawlersPage.tsx b/src/admin/CrawlersPage.tsx index 7549f38..5ed367e 100644 --- a/src/admin/CrawlersPage.tsx +++ b/src/admin/CrawlersPage.tsx @@ -33,7 +33,7 @@ import { SpiderIcon } from "./icons/SpiderIcon"; const BUSY_STATES = new Set(["scanning", "generating", "uploading", "queued"]); const POLL_INTERVAL_MS = 5000; -const UPLOAD_TARGET_KINDS = new Set(["p115", "pikpak", "p123", "googledrive", "onedrive", "wopan"]); +const UPLOAD_TARGET_KINDS = new Set(["p115", "pikpak", "p123", "googledrive", "onedrive", "wopan", "guangyapan"]); function statusBusy(status?: api.DriveGenerationStatus) { return BUSY_STATES.has(status?.state ?? ""); diff --git a/src/admin/DrivesPage.tsx b/src/admin/DrivesPage.tsx index 2613cb0..1d033ab 100644 --- a/src/admin/DrivesPage.tsx +++ b/src/admin/DrivesPage.tsx @@ -102,7 +102,8 @@ export function DrivesPage() { d.kind === "p123" || d.kind === "onedrive" || d.kind === "googledrive" || - d.kind === "wopan" + d.kind === "wopan" || + d.kind === "guangyapan" ), [list] ); diff --git a/src/admin/api.ts b/src/admin/api.ts index 1b8e94b..ac735eb 100644 --- a/src/admin/api.ts +++ b/src/admin/api.ts @@ -78,7 +78,7 @@ export function checkUpdate() { export type AdminDrive = { id: string; - kind: "quark" | "p115" | "p123" | "pikpak" | "wopan" | "onedrive" | "googledrive" | "localstorage" | "spider91"; + kind: "quark" | "p115" | "p123" | "pikpak" | "wopan" | "guangyapan" | "onedrive" | "googledrive" | "localstorage" | "spider91"; name: string; rootId: string; status: string; @@ -155,7 +155,7 @@ export function getDriveStorage() { export type UpsertDriveInput = { id: string; - kind: "quark" | "p115" | "p123" | "pikpak" | "wopan" | "onedrive" | "googledrive" | "localstorage" | "spider91"; + kind: "quark" | "p115" | "p123" | "pikpak" | "wopan" | "guangyapan" | "onedrive" | "googledrive" | "localstorage" | "spider91"; name: string; rootId: string; credentials: Record; @@ -376,6 +376,33 @@ export function getWopanQRStatus(uuid: string) { return request(`/drives/wopan/qr/${encodeURIComponent(uuid)}`); } +export type GuangYaPanQRSession = { + deviceCode: string; + qrCodeUrl: string; + qrImageDataUrl: string; + intervalSeconds: number; + expiresAt?: string; +}; + +export type GuangYaPanQRStatus = { + state: "pending" | "success" | "expired" | "denied" | "error"; + statusText: string; + intervalSeconds?: number; + accessToken?: string; + refreshToken?: string; + tokenType?: string; + expiresIn?: number; +}; + +export function startGuangYaPanQRLogin() { + return request("/drives/guangyapan/qr", { method: "POST" }); +} + +export function getGuangYaPanQRStatus(deviceCode: string) { + const qs = new URLSearchParams({ deviceCode }); + return request(`/drives/guangyapan/qr/status?${qs.toString()}`); +} + /** * 切换某个云盘的预览视频生成开关。点击网盘列表里行内的 toggle 按钮时调用。 * diff --git a/src/admin/drive/DriveForm.tsx b/src/admin/drive/DriveForm.tsx index 8984c5d..5b0a395 100644 --- a/src/admin/drive/DriveForm.tsx +++ b/src/admin/drive/DriveForm.tsx @@ -2,6 +2,7 @@ import { useId, useMemo, useState } from "react"; import { ArrowLeft, ChevronDown } from "lucide-react"; import { P123QRCodeLogin } from "./P123QRCodeLogin"; import { WopanQRCodeLogin } from "./WopanQRCodeLogin"; +import { GuangYaPanQRCodeLogin } from "./GuangYaPanQRCodeLogin"; import { Spider91UploadTargetField } from "./Spider91UploadTargetField"; import { FormState, @@ -24,6 +25,7 @@ const DRIVE_OPTIONS: DriveOption[] = [ { kind: "p115", label: "115 网盘", abbr: "115", desc: "302直链,不占带宽" }, { kind: "p123", label: "123网盘", abbr: "123", desc: "扫码登录,302直链" }, { kind: "pikpak", label: "PikPak", abbr: "Pk", desc: "302直链,稳定快速" }, + { kind: "guangyapan", label: "光鸭网盘", abbr: "GY", desc: "扫码登录,302直链" }, { kind: "onedrive", label: "OneDrive", abbr: "OD", desc: "302直链,微软网盘" }, { kind: "googledrive", label: "Google Drive", abbr: "GD", desc: "服务器中转模式" }, { kind: "localstorage", label: "本地存储", abbr: "Lo", desc: "本机文件目录" }, @@ -194,6 +196,21 @@ export function DriveForm({ /> )} + {form.kind === "guangyapan" && ( + + onChange({ + ...form, + creds: { + ...form.creds, + access_token: credentials.accessToken, + refresh_token: credentials.refreshToken, + }, + }) + } + /> + )} + {fields.map((f) => (
{f.type === "select" ? ( diff --git a/src/admin/drive/GuangYaPanQRCodeLogin.tsx b/src/admin/drive/GuangYaPanQRCodeLogin.tsx new file mode 100644 index 0000000..2da2cf7 --- /dev/null +++ b/src/admin/drive/GuangYaPanQRCodeLogin.tsx @@ -0,0 +1,150 @@ +import { useEffect, useState } from "react"; +import { QrCode } from "lucide-react"; +import * as api from "../api"; +import { useToast } from "../ToastContext"; + +function guangYaPanQRStatusClass( + status: api.GuangYaPanQRStatus | null, + completed: boolean, + error: string +): string { + if (completed || status?.state === "success") return "is-ok"; + if (error || status?.state === "expired" || status?.state === "denied" || status?.state === "error") + return "is-error"; + return "is-pending"; +} + +export function GuangYaPanQRCodeLogin({ + onCredentials, +}: { + onCredentials: (credentials: { + accessToken: string; + refreshToken: string; + }) => void; +}) { + const { show } = useToast(); + const [session, setSession] = useState(null); + const [status, setStatus] = useState(null); + const [starting, setStarting] = useState(false); + const [pollingError, setPollingError] = useState(""); + const [completed, setCompleted] = useState(false); + + async function start() { + setStarting(true); + setPollingError(""); + setCompleted(false); + setStatus(null); + try { + const next = await api.startGuangYaPanQRLogin(); + setSession(next); + } catch (e) { + setSession(null); + show(e instanceof Error ? e.message : "生成二维码失败", "error"); + } finally { + setStarting(false); + } + } + + useEffect(() => { + if (!session || completed) return; + const activeSession = session; + let stopped = false; + let timer: number | undefined; + let delayMs = Math.max(1000, (activeSession.intervalSeconds || 5) * 1000); + + async function poll() { + if (stopped) return; + try { + const next = await api.getGuangYaPanQRStatus(activeSession.deviceCode); + if (stopped) return; + setStatus(next); + setPollingError(""); + if (next.intervalSeconds && next.intervalSeconds > 0) { + delayMs = Math.max(1000, next.intervalSeconds * 1000); + } + if (next.accessToken && next.refreshToken) { + stopped = true; + if (timer) window.clearTimeout(timer); + setCompleted(true); + onCredentials({ + accessToken: next.accessToken, + refreshToken: next.refreshToken, + }); + show("扫码成功,已填入 access_token 和 refresh_token,保存后生效", "success"); + return; + } + if (next.state === "expired" || next.state === "denied" || next.state === "error") { + stopped = true; + if (timer) window.clearTimeout(timer); + return; + } + } catch (e) { + if (stopped) return; + setPollingError(e instanceof Error ? e.message : "查询扫码状态失败"); + } + if (!stopped) { + timer = window.setTimeout(poll, delayMs); + } + } + + poll(); + return () => { + stopped = true; + if (timer) window.clearTimeout(timer); + }; + }, [session, completed, onCredentials, show]); + + const statusText = completed + ? "已获取凭证" + : pollingError || status?.statusText || (session ? "等待扫码" : "未生成二维码"); + const statusClass = guangYaPanQRStatusClass(status, completed, pollingError); + + return ( +
+ +
+
+ + {statusText} +
+ + {session && ( +
+ 光鸭网盘扫码登录二维码 +
+
+ 使用光鸭 App 扫码并确认登录;确认后系统会自动填入 access_token 和 refresh_token。 +
+ {session.expiresAt && ( +
+ 过期时间:{new Date(session.expiresAt).toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + })} +
+ )} + {(status?.state === "expired" || status?.state === "denied") && ( +
+ 当前二维码{status.state === "denied" ? "已被拒绝" : "已过期"},请重新生成。 +
+ )} +
+
+ )} +
+
+ ); +} diff --git a/src/admin/drive/constants.ts b/src/admin/drive/constants.ts index 572f34a..47a8bc3 100644 --- a/src/admin/drive/constants.ts +++ b/src/admin/drive/constants.ts @@ -1,4 +1,4 @@ -export type Kind = "quark" | "p115" | "p123" | "pikpak" | "wopan" | "onedrive" | "googledrive" | "localstorage" | "spider91"; +export type Kind = "quark" | "p115" | "p123" | "pikpak" | "wopan" | "guangyapan" | "onedrive" | "googledrive" | "localstorage" | "spider91"; export const kindAbbr: Record = { quark: "Qk", @@ -6,6 +6,7 @@ export const kindAbbr: Record = { p123: "123", pikpak: "Pk", wopan: "Wo", + guangyapan: "GY", onedrive: "OD", googledrive: "GD", localstorage: "Lo", @@ -28,6 +29,7 @@ export const kindLabel: Record = { p123: "123网盘", pikpak: "PikPak", wopan: "联通网盘", + guangyapan: "光鸭网盘", onedrive: "OneDrive", googledrive: "Google Drive", localstorage: "本地存储", @@ -126,6 +128,7 @@ export function formatClock(value: string): string { export function defaultRootId(kind: Kind): string { if (kind === "pikpak") return ""; + if (kind === "guangyapan") return ""; if (kind === "onedrive") return "root"; if (kind === "googledrive") return "root"; if (kind === "localstorage") return "/"; @@ -155,6 +158,8 @@ export function credentialHelp(kind: Kind, isEdit: boolean): string { return `填写 PikPak 账号和密码即可。平台、设备 ID、验证码 token 和 refresh token 会由服务端自动处理并保存。${note}`; case "wopan": return `推荐使用扫码登录自动获取 access_token 和 refresh_token;也可以手工粘贴已有凭证。${note}`; + case "guangyapan": + return `推荐使用扫码登录自动获取 access_token 和 refresh_token;也可以手工粘贴已有 token。${note}`; case "onedrive": return `按 OpenList 默认应用在线挂载,只需要 refresh_token;保存时会自动刷新并保存 token。${note}`; case "googledrive": @@ -272,6 +277,29 @@ export function credentialFields(kind: Kind, creds: Record = {}) placeholder: "留空走个人空间", }, ]; + case "guangyapan": + return [ + { + key: "root_path", + label: "根目录路径(可选)", + placeholder: "例如:影视/电影;留空使用上方根目录 ID", + help: "如果填写 root_path,服务端会按路径解析光鸭目录,并优先作为扫描根目录。", + }, + { + key: "refresh_token", + label: "refresh_token", + placeholder: "推荐填写,服务端会自动刷新 access_token", + multiline: true, + help: "扫码成功后会自动填入该字段。", + }, + { + key: "access_token", + label: "access_token", + placeholder: "Bearer eyJ... 或直接粘贴 token", + multiline: true, + help: "扫码成功后会自动填入该字段;如果 token 过期,重新扫码后保存即可。", + }, + ]; case "onedrive": return [ { diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index ee94fcb..6593478 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -295,6 +295,7 @@ function sourceKindFromLabel(label: string): string { if (value.includes("123") || value.includes("p123")) return "p123"; if (value.includes("pikpak")) return "pikpak"; if (value.includes("沃盘") || value.includes("wopan") || value.includes("联通")) return "wopan"; + if (value.includes("光鸭") || value.includes("guangyapan") || value.includes("guangya")) return "guangyapan"; if (value.includes("onedrive") || value.includes("one drive")) return "onedrive"; if (value.includes("本地") || value.includes("localstorage") || value.includes("local storage")) return "localstorage"; return ""; diff --git a/src/components/VideoMetaHeader.tsx b/src/components/VideoMetaHeader.tsx index 92db171..c7f5859 100644 --- a/src/components/VideoMetaHeader.tsx +++ b/src/components/VideoMetaHeader.tsx @@ -74,6 +74,8 @@ function sourceKindFromLabel(label: string): string { if (value.includes("pikpak")) return "pikpak"; if (value.includes("沃盘") || value.includes("wopan") || value.includes("联通")) return "wopan"; + if (value.includes("光鸭") || value.includes("guangyapan") || value.includes("guangya")) + return "guangyapan"; if (value.includes("onedrive") || value.includes("one drive")) return "onedrive"; if (value.includes("本地") || value.includes("localstorage") || value.includes("local storage")) return "localstorage"; diff --git a/src/pages/ShortsPage.tsx b/src/pages/ShortsPage.tsx index 791c6d2..380cae0 100644 --- a/src/pages/ShortsPage.tsx +++ b/src/pages/ShortsPage.tsx @@ -1532,6 +1532,7 @@ function getDriveShortName(source: string): string { if (s.includes("quark") || s.includes("夸克")) return "Quak"; if (s.includes("onedrive")) return "OneDrive"; if (s.includes("wopan") || s.includes("沃盘")) return "沃盘"; + if (s.includes("guangyapan") || s.includes("guangya") || s.includes("光鸭")) return "光鸭"; if (s.includes("localstorage") || s.includes("本地")) return "本地"; if (s.includes("spider") || s.includes("爬虫")) return "爬虫"; return source.substring(0, 4); diff --git a/src/styles/admin.css b/src/styles/admin.css index 8a856a9..4979a6e 100644 --- a/src/styles/admin.css +++ b/src/styles/admin.css @@ -3664,6 +3664,7 @@ .admin-drive-type-card[data-kind="spider91"]:hover { border-color: var(--accent); box-shadow: 0 4px 18px var(--accent-glow); } .admin-drive-type-card[data-kind="quark"]:hover { border-color: var(--drive-quark); box-shadow: 0 4px 18px rgba(91,141,239,.2); } .admin-drive-type-card[data-kind="wopan"]:hover { border-color: var(--drive-wopan); box-shadow: 0 4px 18px rgba(255,138,60,.2); } +.admin-drive-type-card[data-kind="guangyapan"]:hover { border-color: var(--drive-guangyapan); box-shadow: 0 4px 18px rgba(48,195,168,.2); } .admin-drive-type-card__icon { display: grid; @@ -3689,6 +3690,7 @@ .admin-drive-type-card__icon[data-kind="spider91"] { background: var(--accent-soft); color: var(--accent); } .admin-drive-type-card__icon[data-kind="quark"] { background: rgba(91,141,239,.14); color: var(--drive-quark); } .admin-drive-type-card__icon[data-kind="wopan"] { background: rgba(255,138,60,.14); color: var(--drive-wopan); } +.admin-drive-type-card__icon[data-kind="guangyapan"] { background: rgba(48,195,168,.14); color: var(--drive-guangyapan); } .admin-drive-type-card__label { font-size: var(--font-sm); @@ -3747,6 +3749,7 @@ .admin-drive-selected-bar__icon[data-kind="spider91"] { background: var(--accent-soft); color: var(--accent); } .admin-drive-selected-bar__icon[data-kind="quark"] { background: rgba(91,141,239,.14); color: var(--drive-quark); } .admin-drive-selected-bar__icon[data-kind="wopan"] { background: rgba(255,138,60,.14); color: var(--drive-wopan); } +.admin-drive-selected-bar__icon[data-kind="guangyapan"] { background: rgba(48,195,168,.14); color: var(--drive-guangyapan); } .admin-drive-selected-bar__text { flex: 1; @@ -4331,6 +4334,7 @@ .admin-drive-card__brand-icon[data-kind="p123"] { background: var(--drive-p123); } .admin-drive-card__brand-icon[data-kind="pikpak"] { background: var(--drive-pikpak); } .admin-drive-card__brand-icon[data-kind="wopan"] { background: var(--drive-wopan); } +.admin-drive-card__brand-icon[data-kind="guangyapan"] { background: var(--drive-guangyapan); } .admin-drive-card__brand-icon[data-kind="onedrive"] { background: var(--drive-onedrive); } .admin-drive-card__brand-icon[data-kind="googledrive"] { background: #4285f4; } .admin-drive-card__brand-icon[data-kind="localstorage"] { background: var(--drive-localstorage); } diff --git a/src/styles/tokens.css b/src/styles/tokens.css index c8948d0..a65703c 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -135,6 +135,7 @@ --drive-p123: #22b8c8; --drive-pikpak: #8a6dff; --drive-wopan: #ff8a3c; + --drive-guangyapan: #30c3a8; --drive-onedrive: #4cabea; --drive-localstorage: #35b88f; @@ -227,6 +228,7 @@ --drive-p123: #1596a8; --drive-pikpak: #8466e6; --drive-wopan: #e57a36; + --drive-guangyapan: #229f8b; --drive-onedrive: #2f95cf; --drive-localstorage: #239978; @@ -329,6 +331,7 @@ --drive-p123: #128da0; --drive-pikpak: #6b4ed4; --drive-wopan: #dc6d28; + --drive-guangyapan: #158b7a; --drive-onedrive: #1f7fc0; --drive-localstorage: #198866; diff --git a/src/styles/video-card.css b/src/styles/video-card.css index b36727a..24840f0 100644 --- a/src/styles/video-card.css +++ b/src/styles/video-card.css @@ -305,6 +305,14 @@ --drive-shadow: rgba(255, 138, 60, 0.15); --drive-shadow-strong: rgba(255, 138, 60, 0.4); } +.source-badge[data-kind="guangyapan"] { + --drive-color: var(--drive-guangyapan); + --drive-bg: rgba(48, 195, 168, 0.12); + --drive-text: #9debdc; + --drive-border: rgba(48, 195, 168, 0.35); + --drive-shadow: rgba(48, 195, 168, 0.15); + --drive-shadow-strong: rgba(48, 195, 168, 0.4); +} .source-badge[data-kind="onedrive"] { --drive-color: var(--drive-onedrive); --drive-bg: rgba(76, 171, 234, 0.12); @@ -456,6 +464,7 @@ .video-card__source[data-kind="p123"] { --source-color: var(--drive-p123); } .video-card__source[data-kind="pikpak"] { --source-color: var(--drive-pikpak); } .video-card__source[data-kind="wopan"] { --source-color: var(--drive-wopan); } +.video-card__source[data-kind="guangyapan"] { --source-color: var(--drive-guangyapan); } .video-card__source[data-kind="onedrive"] { --source-color: var(--drive-onedrive); } .video-card__source[data-kind="localstorage"] { --source-color: var(--drive-localstorage); } diff --git a/src/styles/video-detail.css b/src/styles/video-detail.css index 6c643a9..2fd9eca 100644 --- a/src/styles/video-detail.css +++ b/src/styles/video-detail.css @@ -676,6 +676,12 @@ color: var(--drive-wopan); } +.vd-meta__chip[data-tone="guangyapan"] { + background: rgba(48, 195, 168, 0.14); + border-color: rgba(48, 195, 168, 0.3); + color: var(--drive-guangyapan); +} + .vd-meta__chip[data-tone="onedrive"] { background: rgba(76, 171, 234, 0.14); border-color: rgba(76, 171, 234, 0.3); diff --git a/tests/adminDriveForm.test.ts b/tests/adminDriveForm.test.ts index ac1c98f..91667e6 100644 --- a/tests/adminDriveForm.test.ts +++ b/tests/adminDriveForm.test.ts @@ -78,9 +78,9 @@ test("spider91 upload target uses explicit local-save option instead of auto tar assert.match(combinedSource, /本地保存,不上传/); assert.match( combinedSource, - /d\.kind === "pikpak"[\s\S]*d\.kind === "p115"[\s\S]*d\.kind === "p123"[\s\S]*d\.kind === "onedrive"[\s\S]*d\.kind === "googledrive"[\s\S]*d\.kind === "wopan"/ + /d\.kind === "pikpak"[\s\S]*d\.kind === "p115"[\s\S]*d\.kind === "p123"[\s\S]*d\.kind === "onedrive"[\s\S]*d\.kind === "googledrive"[\s\S]*d\.kind === "wopan"[\s\S]*d\.kind === "guangyapan"/ ); - assert.match(crawlerPageSource, /UPLOAD_TARGET_KINDS[\s\S]*"wopan"/); + assert.match(crawlerPageSource, /UPLOAD_TARGET_KINDS[\s\S]*"wopan"[\s\S]*"guangyapan"/); assert.doesNotMatch(combinedSource, /自动:唯一/); assert.doesNotMatch(combinedSource, /自动模式/); assert.doesNotMatch(combinedSource, /较早的视频会上传到该云盘根目录下的 91 Spider 文件夹/); @@ -175,6 +175,32 @@ test("pikpak drive form only exposes account login fields", () => { assert.doesNotMatch(fields, /key: "disable_media_link"/); }); +test("guangyapan drive form exposes qr login and token fields", () => { + assertDriveTypeOption("guangyapan", "光鸭网盘"); + assert.match(driveFormSource, /GuangYaPanQRCodeLogin/); + assert.match(driveFormSource, /form\.kind === "guangyapan"/); + assert.match(apiSource, /startGuangYaPanQRLogin/); + assert.match(apiSource, /getGuangYaPanQRStatus/); + + const match = + /case "guangyapan":\s*return \[([\s\S]*?)\];\s*case "onedrive":/.exec( + combinedSource + ); + assert.ok(match, "guangyapan credential field block should be present"); + const fields = match[1]; + + assert.match(fields, /key: "root_path"/); + assert.match(fields, /key: "refresh_token"/); + assert.match(fields, /key: "access_token"/); + assert.doesNotMatch(fields, /key: "phone_number"/); + assert.doesNotMatch(fields, /key: "send_code"/); + assert.doesNotMatch(fields, /key: "verify_code"/); + assert.doesNotMatch(fields, /key: "captcha_token"/); + assert.doesNotMatch(fields, /key: "client_id"/); + assert.doesNotMatch(fields, /key: "device_id"/); + assert.match(combinedSource, /if \(kind === "guangyapan"\) return ""/); +}); + test("localstorage drive form asks for a server directory path", () => { assertDriveTypeOption("localstorage", "本地存储"); @@ -196,6 +222,7 @@ test("drive type selector keeps primary source order", () => { { value: "p115", label: "115 网盘" }, { value: "p123", label: "123网盘" }, { value: "pikpak", label: "PikPak" }, + { value: "guangyapan", label: "光鸭网盘" }, { value: "onedrive", label: "OneDrive" }, { value: "googledrive", label: "Google Drive" }, { value: "localstorage", label: "本地存储" }, @@ -264,6 +291,7 @@ test("drive cards use configured abbreviations and visible fallback icon colors" assert.match(drivesPageSource, /driveKindAbbr\(d\.kind\)/); assert.match(adminCss, /\.admin-drive-card__brand-icon\s*\{[^}]*background:\s*var\(--accent\);/s); assert.match(adminCss, /\.admin-drive-card__brand-icon\[data-kind="googledrive"\]\s*\{\s*background:\s*#4285f4;\s*\}/); + assert.match(adminCss, /\.admin-drive-card__brand-icon\[data-kind="guangyapan"\]\s*\{\s*background:\s*var\(--drive-guangyapan\);/); }); test("drive management exposes stop task controls", () => {