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.
This commit is contained in:
nianzhibai
2026-06-14 15:44:50 +08:00
parent 9cc8e02bec
commit 7e5e67697e
53 changed files with 3167 additions and 510 deletions
+2 -2
View File
@@ -20,8 +20,8 @@
## 功能特性 ## 功能特性
- **多后端支持** — 兼容 115 云盘、PikPak 云盘、123网盘、联通网盘、OneDrive、Google Drive 和本地存储 - **多后端支持** — 兼容 115 云盘、PikPak 云盘、123网盘、联通网盘、光鸭网盘、OneDrive、Google Drive 和本地存储
- **低带宽播放** — 115 云盘、PikPak 云盘、123网盘、联通网盘、OneDrive 支持302模式,在线播放视频时,不占用服务器带宽,播放体验不受服务器带宽影响;Google Drive 不支持302模式,走服务器中转,观看体验会受服务器带宽影响 - **低带宽播放** — 115 云盘、PikPak 云盘、123网盘、联通网盘、光鸭网盘、OneDrive 支持302模式,在线播放视频时,不占用服务器带宽,播放体验不受服务器带宽影响;Google Drive 不支持302模式,走服务器中转,观看体验会受服务器带宽影响
- **封面 & 预览片段** — 自动为每个视频生成封面图和预览片段,首页快速选片 - **封面 & 预览片段** — 自动为每个视频生成封面图和预览片段,首页快速选片
- **爬虫脚本** — 项目支持导入自定义脚本,但是有一些规范,具体可以参考 [SpiderFor91](https://github.com/Just-Spider/SpiderFor91),项目不再内置任何爬虫脚本 - **爬虫脚本** — 项目支持导入自定义脚本,但是有一些规范,具体可以参考 [SpiderFor91](https://github.com/Just-Spider/SpiderFor91),项目不再内置任何爬虫脚本
- **短视频模式** — 一键切换抖音风格,沉浸刷片 - **短视频模式** — 一键切换抖音风格,沉浸刷片
+3 -1
View File
@@ -2,7 +2,7 @@
视频聚合站的 Go 后端。提供三件事: 视频聚合站的 Go 后端。提供三件事:
1. 多家网盘统一抽象(夸克 / 115 / PikPak / 联通网盘 / OneDrive / Google Drive / 本地存储) 1. 多家网盘统一抽象(夸克 / 115 / PikPak / 联通网盘 / 光鸭网盘 / OneDrive / Google Drive / 本地存储)
2. 视频元数据目录(SQLite)+ 扫描 + 预览视频预生成 2. 视频元数据目录(SQLite)+ 扫描 + 预览视频预生成
3. REST API(前台)+ 管理后台 + 直链代理 3. REST API(前台)+ 管理后台 + 直链代理
4. 标签池、视频隐藏、按网盘统计和详情页来源网盘类型展示能力 4. 标签池、视频隐藏、按网盘统计和详情页来源网盘类型展示能力
@@ -20,6 +20,7 @@ internal/
p115/ 115(壳子 + SheltonZhu/115driver p115/ 115(壳子 + SheltonZhu/115driver
pikpak/ PikPak(自己实现,参考 OpenList pikpak pikpak/ PikPak(自己实现,参考 OpenList pikpak
wopan/ 联通网盘(壳子 + OpenListTeam/wopan-sdk-go wopan/ 联通网盘(壳子 + OpenListTeam/wopan-sdk-go
guangyapan/ 光鸭网盘(参考 AList GuangYaPan
onedrive/ OneDriveOpenList 在线续期 + Microsoft Graph 文件接口) onedrive/ OneDriveOpenList 在线续期 + Microsoft Graph 文件接口)
googledrive/ Google DriveOpenList 在线续期 + Google Drive API;播放走后端代理) googledrive/ Google DriveOpenList 在线续期 + Google Drive API;播放走后端代理)
localstorage/ 本地目录扫描(服务器已有视频目录) localstorage/ 本地目录扫描(服务器已有视频目录)
@@ -108,6 +109,7 @@ go run ./cmd/server 后端 9192
| p115 | `cookie`(形如 `UID=...; CID=...; SEID=...; KID=...` | | p115 | `cookie`(形如 `UID=...; CID=...; SEID=...; KID=...` |
| pikpak | `username`、`password`(token、验证码和设备 ID 由服务端自动处理并保存) | | pikpak | `username`、`password`(token、验证码和设备 ID 由服务端自动处理并保存) |
| wopan | `access_token`、`refresh_token`,可选 `family_id` | | wopan | `access_token`、`refresh_token`,可选 `family_id` |
| guangyapan | 推荐后台扫码登录自动写入 `access_token`、`refresh_token`;也可手工填写 token;可选 `root_path` |
| onedrive | `refresh_token` | | onedrive | `refresh_token` |
| googledrive | 默认只需 `refresh_token`;自建 OAuth 客户端模式还需 `use_online_api=false`、`client_id`、`client_secret` | | googledrive | 默认只需 `refresh_token`;自建 OAuth 客户端模式还需 `use_online_api=false`、`client_id`、`client_secret` |
| localstorage | `path`(服务器上的已有视频目录,如 `/mnt/videos` | | localstorage | `path`(服务器上的已有视频目录,如 `/mnt/videos` |
+128 -12
View File
@@ -26,6 +26,7 @@ import (
"github.com/video-site/backend/internal/config" "github.com/video-site/backend/internal/config"
"github.com/video-site/backend/internal/drives" "github.com/video-site/backend/internal/drives"
"github.com/video-site/backend/internal/drives/googledrive" "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/localstorage"
"github.com/video-site/backend/internal/drives/localupload" "github.com/video-site/backend/internal/drives/localupload"
"github.com/video-site/backend/internal/drives/onedrive" "github.com/video-site/backend/internal/drives/onedrive"
@@ -355,10 +356,10 @@ type App struct {
// 全站主题("dark" | "pink" | "sky"),从 DB 读 // 全站主题("dark" | "pink" | "sky"),从 DB 读
theme string theme string
// 显式指定的 spider91 上传目标 drive ID。 // 显式指定的 spider91 上传目标 drive ID。
// 空字符串表示本地保存不上传,不再自动挑选 pikpak/p115/p123/onedrive/wopan drive。 // 空字符串表示本地保存不上传,不再自动挑选 pikpak/p115/p123/onedrive/wopan/guangyapan drive。
spider91UploadDriveID string spider91UploadDriveID string
// spider91Migrator 把 spider91 视频上传到目标 drivePikPak、115、123、OneDrive、Google Drive 或联通网盘)。 // spider91Migrator 把 spider91 视频上传到目标 drivePikPak、115、123、OneDrive、Google Drive、联通网盘或光鸭网盘)。
spider91Migrator spider91MigrationRunner spider91Migrator spider91MigrationRunner
// nightlyRunner 是凌晨流水线调度器:每天 cron_hour 串行跑扫盘 → 91 爬虫 → 迁移。 // nightlyRunner 是凌晨流水线调度器:每天 cron_hour 串行跑扫盘 → 91 爬虫 → 迁移。
@@ -401,8 +402,9 @@ type App struct {
} }
type driveScanProgress struct { type driveScanProgress struct {
Scanned int Scanned int
Added int Added int
CooldownUntil time.Time
} }
type driveUploadProgress struct { type driveUploadProgress struct {
@@ -479,7 +481,7 @@ func (a *App) loadTheme(ctx context.Context) {
} }
// Spider91UploadDriveID 返回当前配置的 spider91 上传目标 drive ID。 // Spider91UploadDriveID 返回当前配置的 spider91 上传目标 drive ID。
// 空字符串表示本地保存不上传;只有管理员显式选择 pikpak/p115/p123/onedrive/googledrive/wopan drive 时才迁移上传。 // 空字符串表示本地保存不上传;只有管理员显式选择 pikpak/p115/p123/onedrive/googledrive/wopan/guangyapan drive 时才迁移上传。
func (a *App) Spider91UploadDriveID() string { func (a *App) Spider91UploadDriveID() string {
a.mu.Lock() a.mu.Lock()
explicit := a.spider91UploadDriveID explicit := a.spider91UploadDriveID
@@ -496,7 +498,7 @@ func (a *App) Spider91UploadDriveID() string {
// SetSpider91UploadDriveID 设置 spider91 上传目标 drive ID 并持久化。 // 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 { func (a *App) SetSpider91UploadDriveID(ctx context.Context, driveID string) error {
driveID = strings.TrimSpace(driveID) driveID = strings.TrimSpace(driveID)
if 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) return fmt.Errorf("drive %q not found", driveID)
} }
if !isSpider91UploadKind(d.Kind()) { 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() a.mu.Lock()
@@ -538,7 +540,7 @@ func formatOptionalRFC3339(t time.Time) string {
// isSpider91UploadKind 是 spider91 迁移目标盘的 allowlist。 // isSpider91UploadKind 是 spider91 迁移目标盘的 allowlist。
// 与 spider91migrate.adaptUploadTarget 的支持范围保持一致。 // 与 spider91migrate.adaptUploadTarget 的支持范围保持一致。
func isSpider91UploadKind(kind string) bool { 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 设置;不存在时使用空串。 // loadSpider91UploadDriveID 从 DB 读上传目标 drive ID 设置;不存在时使用空串。
@@ -595,17 +597,25 @@ func (a *App) driveGenerationStatuses() map[string]api.DriveGenerationStatuses {
a.transcodeMu.Unlock() a.transcodeMu.Unlock()
out := make(map[string]api.DriveGenerationStatuses, len(scanningDrives)+len(previewWorkers)+len(thumbWorkers)+len(fingerprintWorkers)+len(uploadProgresses)+len(transcodeWorkers)) 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 { for id, running := range scanningDrives {
if !running { if !running {
continue continue
} }
progress := scanProgresses[id] progress := scanProgresses[id]
state := "scanning"
if progress.CooldownUntil.After(now) {
state = "cooling"
}
status := out[id] status := out[id]
status.Scan = api.GenerationStatus{ status.Scan = api.GenerationStatus{
State: "scanning", State: state,
ScannedCount: progress.Scanned, ScannedCount: progress.Scanned,
AddedCount: progress.Added, AddedCount: progress.Added,
} }
if !progress.CooldownUntil.IsZero() {
status.Scan.CooldownUntil = progress.CooldownUntil.Format(time.RFC3339)
}
out[id] = status out[id] = status
} }
for id, worker := range previewWorkers { 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) _ = 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": case "onedrive":
drv = onedrive.New(onedrive.Config{ drv = onedrive.New(onedrive.Config{
ID: d.ID, ID: d.ID,
@@ -1081,7 +1118,7 @@ func generationCooldownForDrive(drv drives.Drive) time.Duration {
return 0 return 0
} }
switch strings.ToLower(drv.Kind()) { switch strings.ToLower(drv.Kind()) {
case "wopan": case "wopan", "guangyapan":
return 10 * time.Minute return 10 * time.Minute
} }
return 0 return 0
@@ -1107,7 +1144,7 @@ func fingerprintConfigForDrive(drv drives.Drive) fingerprint.Config {
return cfg return cfg
} }
switch strings.ToLower(drv.Kind()) { switch strings.ToLower(drv.Kind()) {
case "p115", "p123", "onedrive", "wopan": case "p115", "p123", "onedrive", "wopan", "guangyapan":
cfg.RateLimitCooldown = 10 * time.Minute cfg.RateLimitCooldown = 10 * time.Minute
case "pikpak": case "pikpak":
cfg.RateLimitCooldown = 5 * time.Minute cfg.RateLimitCooldown = 5 * time.Minute
@@ -1439,11 +1476,77 @@ func (a *App) updateDriveScanProgress(driveID string, scanned, added int) {
if a.scanProgress == nil { if a.scanProgress == nil {
a.scanProgress = make(map[string]driveScanProgress) 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() 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 { func (a *App) driveHasActiveWork(driveID string) bool {
driveID = strings.TrimSpace(driveID) driveID = strings.TrimSpace(driveID)
if driveID == "" { if driveID == "" {
@@ -1908,6 +2011,8 @@ func (a *App) runScanWithTaskContext(ctx context.Context, driveID string) {
if err != nil { if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
log.Printf("[scan] drive=%s canceled: %v", driveID, err) log.Printf("[scan] drive=%s canceled: %v", driveID, err)
} else if a.pauseDriveScanForRateLimit(ctx, driveID, drv, err) {
return
} else { } else {
log.Printf("[scan] drive=%s error: %v", driveID, err) log.Printf("[scan] drive=%s error: %v", driveID, err)
} }
@@ -3365,3 +3470,14 @@ func parseBoolDefault(raw string, def bool) bool {
} }
return v 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
}
+6
View File
@@ -41,6 +41,7 @@ func TestSpider91UploadDriveIDDoesNotAutoSelectTarget(t *testing.T) {
reg.Set("p123-one", &spider91UploadTargetFakeDrive{id: "p123-one", kind: "p123"}) reg.Set("p123-one", &spider91UploadTargetFakeDrive{id: "p123-one", kind: "p123"})
reg.Set("onedrive-one", &spider91UploadTargetFakeDrive{id: "onedrive-one", kind: "onedrive"}) reg.Set("onedrive-one", &spider91UploadTargetFakeDrive{id: "onedrive-one", kind: "onedrive"})
reg.Set("wopan-one", &spider91UploadTargetFakeDrive{id: "wopan-one", kind: "wopan"}) reg.Set("wopan-one", &spider91UploadTargetFakeDrive{id: "wopan-one", kind: "wopan"})
reg.Set("guangyapan-one", &spider91UploadTargetFakeDrive{id: "guangyapan-one", kind: "guangyapan"})
app := &App{registry: reg} app := &App{registry: reg}
if got := app.Spider91UploadDriveID(); got != "" { 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) 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" app.spider91UploadDriveID = "missing"
if got := app.Spider91UploadDriveID(); got != "" { if got := app.Spider91UploadDriveID(); got != "" {
t.Fatalf("missing upload target = %q, want empty", got) t.Fatalf("missing upload target = %q, want empty", got)
+31
View File
@@ -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) { func TestRunSpider91MigrationAfterManualCrawlRequiresConfiguredUploadTarget(t *testing.T) {
ctx := context.Background() ctx := context.Background()
registry := proxy.NewRegistry() registry := proxy.NewRegistry()
+12 -1
View File
@@ -56,7 +56,7 @@ preview:
width: 480 width: 480
# 盘列表。上线后请通过管理后台添加,本文件可留空。 # 盘列表。上线后请通过管理后台添加,本文件可留空。
# kind 支持 quark / p115 / p123 / pikpak / wopan / onedrive / googledrive / localstorage。 # kind 支持 quark / p115 / p123 / pikpak / wopan / guangyapan / onedrive / googledrive / localstorage。
# OneDrive 示例: # OneDrive 示例:
# - id: "my-onedrive" # - id: "my-onedrive"
# kind: "onedrive" # kind: "onedrive"
@@ -76,6 +76,17 @@ preview:
# # use_online_api: "false" # # use_online_api: "false"
# # client_id: "..." # # client_id: "..."
# # client_secret: "..." # # 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" # - id: "local-media"
# kind: "localstorage" # kind: "localstorage"
+61 -23
View File
@@ -21,6 +21,7 @@ import (
"github.com/video-site/backend/internal/auth" "github.com/video-site/backend/internal/auth"
"github.com/video-site/backend/internal/catalog" "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/p123"
"github.com/video-site/backend/internal/drives/scriptcrawler" "github.com/video-site/backend/internal/drives/scriptcrawler"
"github.com/video-site/backend/internal/drives/spider91" "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 is the local directory that stores generated preview videos and thumbs.
LocalPreviewDir string LocalPreviewDir string
// Hooks:外层注入实际执行者 // Hooks:外层注入实际执行者
OnDriveSaved func(driveID string) error OnDriveSaved func(driveID string) error
OnDriveDeleteCleanup func(ctx context.Context, driveID string) (int, error) OnDriveDeleteCleanup func(ctx context.Context, driveID string) (int, error)
OnDriveRemoved func(driveID string) OnDriveRemoved func(driveID string)
OnScanRequested func(driveID string) bool OnScanRequested func(driveID string) bool
OnStopDriveTasks func(driveID string) bool OnStopDriveTasks func(driveID string) bool
OnStopAllTasks func() int OnStopAllTasks func() int
OnRegenPreview func(videoID string) OnRegenPreview func(videoID string)
OnRegenAllPreviews func() OnRegenAllPreviews func()
OnRegenFailedPreviews func(driveID string) OnRegenFailedPreviews func(driveID string)
OnRegenFailedThumbnails func(driveID string) OnRegenFailedThumbnails func(driveID string)
OnRegenFailedFingerprints func(driveID string) OnRegenFailedFingerprints func(driveID string)
// OnStartDriveTranscode 手动开启某盘的浏览器兼容性转码任务。 // OnStartDriveTranscode 手动开启某盘的浏览器兼容性转码任务。
// 返回 (是否接受, 拒绝原因)。转码从不自动运行,只能在这里手动触发; // 返回 (是否接受, 拒绝原因)。转码从不自动运行,只能在这里手动触发;
// 处理完候选列表后任务自然结束。 // 处理完候选列表后任务自然结束。
OnStartDriveTranscode func(driveID string) (bool, string) OnStartDriveTranscode func(driveID string) (bool, string)
// OnStopDriveTranscode 手动停止某盘正在进行的转码任务。返回是否有任务被停。 // OnStopDriveTranscode 手动停止某盘正在进行的转码任务。返回是否有任务被停。
OnStopDriveTranscode func(driveID string) bool OnStopDriveTranscode func(driveID string) bool
OnDeleteVideo func(ctx context.Context, videoID string, deleteSource bool) (DeleteVideoResult, error) OnDeleteVideo func(ctx context.Context, videoID string, deleteSource bool) (DeleteVideoResult, error)
GetDriveGenerationStatuses func() map[string]DriveGenerationStatuses GetDriveGenerationStatuses func() map[string]DriveGenerationStatuses
// OnTeaserEnabledChanged 在 per-drive 预览视频开关被切换后调用。 // OnTeaserEnabledChanged 在 per-drive 预览视频开关被切换后调用。
// enabled=true 时上层应该重新把 pending 预览视频入队(类似旧的全局开关从关到开); // enabled=true 时上层应该重新把 pending 预览视频入队(类似旧的全局开关从关到开);
@@ -74,7 +75,7 @@ type AdminServer struct {
// Theme 读写("dark" | "pink" | "sky" // Theme 读写("dark" | "pink" | "sky"
GetTheme func() string GetTheme func() string
SetTheme func(theme string) error 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 GetSpider91UploadDriveID func() string
SetSpider91UploadDriveID func(driveID string) error SetSpider91UploadDriveID func(driveID string) error
// OnRunNightlyJob 触发一次完整的凌晨流水线(Phase1 扫盘 + Phase2 91 爬虫 + // OnRunNightlyJob 触发一次完整的凌晨流水线(Phase1 扫盘 + Phase2 91 爬虫 +
@@ -94,6 +95,9 @@ type AdminServer struct {
// 联通网盘扫码登录接口测试注入;生产留空走官方 panservice.mail.wo.cn。 // 联通网盘扫码登录接口测试注入;生产留空走官方 panservice.mail.wo.cn。
WopanQRAPIBaseURL string WopanQRAPIBaseURL string
WopanQRHTTPClient *http.Client WopanQRHTTPClient *http.Client
// 光鸭网盘扫码登录接口测试注入;生产留空走官方 account.guangyapan.com。
GuangYaPanAccountBaseURL string
GuangYaPanHTTPClient *http.Client
} }
const ( const (
@@ -167,6 +171,8 @@ func (a *AdminServer) Register(r chi.Router) {
r.Get("/drives/p123/qr/{uniID}", a.handleP123QRStatus) r.Get("/drives/p123/qr/{uniID}", a.handleP123QRStatus)
r.Post("/drives/wopan/qr", a.handleWopanQRStart) r.Post("/drives/wopan/qr", a.handleWopanQRStart)
r.Get("/drives/wopan/qr/{uuid}", a.handleWopanQRStatus) 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.Delete("/drives/{id}", a.handleDeleteDrive)
r.Post("/drives/{id}/rescan", a.handleRescan) r.Post("/drives/{id}/rescan", a.handleRescan)
r.Post("/drives/{id}/tasks/stop", a.handleStopDriveTasks) 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"` SkipDirIDs []string `json:"skipDirIds"`
// LastCrawlAt 是 spider91 上次成功爬取的 unix 秒(来自 credentials.last_crawl_at)。 // LastCrawlAt 是 spider91 上次成功爬取的 unix 秒(来自 credentials.last_crawl_at)。
// 其它 kind 留 0;前端用它显示"上次抓取: N 小时前"。 // 其它 kind 留 0;前端用它显示"上次抓取: N 小时前"。
Spider91Proxy string `json:"spider91Proxy,omitempty"` Spider91Proxy string `json:"spider91Proxy,omitempty"`
LastCrawlAt int64 `json:"lastCrawlAt,omitempty"` LastCrawlAt int64 `json:"lastCrawlAt,omitempty"`
GoogleDriveUseOnlineAPI *bool `json:"googleDriveUseOnlineAPI,omitempty"` GoogleDriveUseOnlineAPI *bool `json:"googleDriveUseOnlineAPI,omitempty"`
// STRMAllowOutsideRoot 是 localstorage 的 .strm 越root开关;其它 kind 省略。 // STRMAllowOutsideRoot 是 localstorage 的 .strm 越root开关;其它 kind 省略。
STRMAllowOutsideRoot *bool `json:"strmAllowOutsideRoot,omitempty"` STRMAllowOutsideRoot *bool `json:"strmAllowOutsideRoot,omitempty"`
ScanGenerationStatus GenerationStatus `json:"scanGenerationStatus"` ScanGenerationStatus GenerationStatus `json:"scanGenerationStatus"`
ThumbnailGenerationStatus GenerationStatus `json:"thumbnailGenerationStatus"` ThumbnailGenerationStatus GenerationStatus `json:"thumbnailGenerationStatus"`
PreviewGenerationStatus GenerationStatus `json:"previewGenerationStatus"` PreviewGenerationStatus GenerationStatus `json:"previewGenerationStatus"`
@@ -620,9 +626,9 @@ func (a *AdminServer) handleUpsertDrive(w http.ResponseWriter, r *http.Request)
return return
} }
body.Credentials = credentials body.Credentials = credentials
} else if body.Kind == "googledrive" || body.Kind == "localstorage" { } else if body.Kind == "googledrive" || body.Kind == "localstorage" || body.Kind == "guangyapan" {
// 按键合并、空值沿用旧值:localstorage 编辑表单里 path 留空表示不改 // 按键合并、空值沿用旧值:这些网盘的编辑表单允许只改某几个字段
// 但 strm_allow_outside_root 开关每次都会带值,必须逐键合并而不是整体替换 // 其它 token / 路径 / 开关字段应保留旧值
body.Credentials = mergeNonEmptyCredentials(existing, body.Credentials) body.Credentials = mergeNonEmptyCredentials(existing, body.Credentials)
} else if len(body.Credentials) == 0 && existing != nil && len(existing.Credentials) > 0 { } else if len(body.Credentials) == 0 && existing != nil && len(existing.Credentials) > 0 {
body.Credentials = existing.Credentials body.Credentials = existing.Credentials
@@ -931,7 +937,7 @@ func (a *AdminServer) validateCrawlerUploadDrive(ctx context.Context, driveID st
return fmt.Errorf("上传目标网盘 %q 不存在", driveID) return fmt.Errorf("上传目标网盘 %q 不存在", driveID)
} }
if !isCrawlerUploadTargetKind(d.Kind) { 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 return nil
} }
@@ -1395,7 +1401,7 @@ func googleDriveUseOnlineAPIForDrive(d *catalog.Drive) *bool {
} }
// mergeNonEmptyCredentials 逐键合并凭证:incoming 里非空的键覆盖旧值, // mergeNonEmptyCredentials 逐键合并凭证:incoming 里非空的键覆盖旧值,
// 空值/缺失的键沿用旧值。googledrivelocalstorage 的编辑表单都依赖 // 空值/缺失的键沿用旧值。googledrivelocalstorage 和 guangyapan 的编辑表单都依赖
// 这个语义(留空 = 不修改)。 // 这个语义(留空 = 不修改)。
func mergeNonEmptyCredentials(existing *catalog.Drive, incoming map[string]string) map[string]string { func mergeNonEmptyCredentials(existing *catalog.Drive, incoming map[string]string) map[string]string {
merged := 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) 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 触发一次完整的凌晨流水线(不论当前时间,不论今日是否已跑)。 // handleRunNightlyJob 触发一次完整的凌晨流水线(不论当前时间,不论今日是否已跑)。
// 立即返回 202;进度通过 backend 日志和下次 GET /admin/api/drives 的状态变化观察。 // 立即返回 202;进度通过 backend 日志和下次 GET /admin/api/drives 的状态变化观察。
// 流水线已在跑或已排队时,Runner 会拒绝重复触发。 // 流水线已在跑或已排队时,Runner 会拒绝重复触发。
+88
View File
@@ -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) { func TestHandleTestCrawlerScriptRunsImportedScript(t *testing.T) {
if _, err := exec.LookPath("python3"); err != nil { if _, err := exec.LookPath("python3"); err != nil {
t.Skip("python3 is required for crawler script dry-run") t.Skip("python3 is required for crawler script dry-run")
+2
View File
@@ -1068,6 +1068,8 @@ func driveKindLabel(kind string) string {
return "PikPak" return "PikPak"
case "wopan": case "wopan":
return "联通网盘" return "联通网盘"
case "guangyapan":
return "光鸭网盘"
case "onedrive": case "onedrive":
return "OneDrive" return "OneDrive"
case "googledrive": case "googledrive":
+1 -1
View File
@@ -1992,7 +1992,7 @@ func normalizeDriveRootFields(d *Drive) {
func normalizeDriveRootID(kind, rootID string) string { func normalizeDriveRootID(kind, rootID string) string {
rootID = strings.TrimSpace(rootID) rootID = strings.TrimSpace(rootID)
switch kind { switch kind {
case "pikpak": case "pikpak", "guangyapan":
if rootID == "0" { if rootID == "0" {
return "" return ""
} }
+1
View File
@@ -58,6 +58,7 @@ func TestUpsertDriveDefaultsRootIDByKind(t *testing.T) {
}{ }{
{id: "p115", kind: "p115", want: "0"}, {id: "p115", kind: "p115", want: "0"},
{id: "pikpak", kind: "pikpak", want: ""}, {id: "pikpak", kind: "pikpak", want: ""},
{id: "guangyapan", kind: "guangyapan", want: ""},
{id: "onedrive", kind: "onedrive", want: "root"}, {id: "onedrive", kind: "onedrive", want: "root"},
{id: "googledrive", kind: "googledrive", want: "root"}, {id: "googledrive", kind: "googledrive", want: "root"},
{id: "localstorage", kind: "localstorage", want: "/"}, {id: "localstorage", kind: "localstorage", want: "/"},
+1 -1
View File
@@ -114,7 +114,7 @@ CREATE INDEX IF NOT EXISTS idx_crawler_seen_sources_drive
-- 网盘账户 -- 网盘账户
CREATE TABLE IF NOT EXISTS drives ( CREATE TABLE IF NOT EXISTS drives (
id TEXT PRIMARY KEY, 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, name TEXT NOT NULL,
root_id TEXT NOT NULL DEFAULT '0', root_id TEXT NOT NULL DEFAULT '0',
scan_root_id TEXT, -- deprecated: 扫描起点固定等于 root_id scan_root_id TEXT, -- deprecated: 扫描起点固定等于 root_id
+21
View File
@@ -124,6 +124,9 @@ CREATE TABLE IF NOT EXISTS deleted_videos (
if err := c.reconcileThumbnailStatusOnce(ctx); err != nil { if err := c.reconcileThumbnailStatusOnce(ctx); err != nil {
return err 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 { if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_videos_content_hash ON videos(content_hash)`); err != nil {
return err return err
} }
@@ -296,6 +299,24 @@ UPDATE videos
return nil 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 { func (c *Catalog) clearVolatileOneDriveThumbnails(ctx context.Context) error {
// 把 OneDrive 过期的 mediap.svc.ms thumb URL 清空,让 worker 重新抽帧生成本地封面。 // 把 OneDrive 过期的 mediap.svc.ms thumb URL 清空,让 worker 重新抽帧生成本地封面。
// 同步把 thumbnail_status 重置为 'pending':清空后 url 是空的,本应进 worker 重做, // 同步把 thumbnail_status 重置为 'pending':清空后 url 是空的,本应进 worker 重做,
+64
View File
@@ -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 创建/补回视频时 // TestUpsertVideoSyncsThumbnailStatus 验证 scanner 创建/补回视频时
// thumbnail_status 跟随 thumbnail_url 自动设。这是历史 bug 的修复回归测试 —— // thumbnail_status 跟随 thumbnail_url 自动设。这是历史 bug 的修复回归测试 ——
// 之前 UpsertVideo 的 SQL 不带 thumbnail_status 列,所有新视频都依赖 // 之前 UpsertVideo 的 SQL 不带 thumbnail_status 列,所有新视频都依赖
+1 -1
View File
@@ -207,7 +207,7 @@ type Nightly struct {
// 这里保留 yaml 中的静态定义,用于启动时预置盘。生产建议只在 DB 里维护。 // 这里保留 yaml 中的静态定义,用于启动时预置盘。生产建议只在 DB 里维护。
type Drive struct { type Drive struct {
ID string `yaml:"id"` 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"` Name string `yaml:"name"`
RootID string `yaml:"root_id"` RootID string `yaml:"root_id"`
Params map[string]string `yaml:"params,omitempty"` Params map[string]string `yaml:"params,omitempty"`
+4 -31
View File
@@ -647,7 +647,7 @@ func isGoogleUploadHTTPRateLimit(status int, header http.Header, body []byte, ap
if isGoogleRateLimit(nil, apiErr) { if isGoogleRateLimit(nil, apiErr) {
return true return true
} }
return googleLimitText(string(body)) return false
} }
func googleUploadRateLimitError(status int, header http.Header, body []byte, message string) error { 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 return true
} }
for _, e := range body.Errors { for _, e := range body.Errors {
if googleLimitReason(e.Reason) || googleLimitText(e.Message) { if googleLimitReason(e.Reason) {
return true return true
} }
domain := compactGoogleLimitText(e.Domain) domain := compactGoogleLimitText(e.Domain)
@@ -918,7 +918,7 @@ func isGoogleRateLimit(res *resty.Response, body apiErrorBody) bool {
return true return true
} }
} }
return googleLimitText(body.Message) return false
} }
func isGoogleTokenRateLimit(res *resty.Response, out tokenResp) bool { func isGoogleTokenRateLimit(res *resty.Response, out tokenResp) bool {
@@ -930,9 +930,7 @@ func isGoogleTokenRateLimit(res *resty.Response, out tokenResp) bool {
return true return true
} }
} }
return googleLimitText(out.Text) || return googleLimitReason(out.Error)
googleLimitText(out.Error) ||
googleLimitText(out.ErrorDescription)
} }
func googleLimitReason(reason string) bool { 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 { func compactGoogleLimitText(text string) string {
text = strings.ToLower(strings.TrimSpace(text)) text = strings.ToLower(strings.TrimSpace(text))
replacer := strings.NewReplacer("_", "", "-", "", " ", "", ".", "", ":", "") replacer := strings.NewReplacer("_", "", "-", "", " ", "", ".", "", ":", "")
File diff suppressed because it is too large Load Diff
@@ -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)
}
}
+244
View File
@@ -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
}
@@ -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)
}
}
+129
View File
@@ -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)
}
+42 -1
View File
@@ -5,12 +5,14 @@ import (
"errors" "errors"
"io" "io"
"net/http" "net/http"
"strconv"
"strings"
"time" "time"
) )
// Drive 是多家网盘统一抽象。上层不区分盘,只区分 Kind。 // Drive 是多家网盘统一抽象。上层不区分盘,只区分 Kind。
type Drive interface { type Drive interface {
// Kind 返回驱动代号:"quark" / "p115" / "p123" / "pikpak" / "wopan" / "onedrive" / "googledrive" / "localstorage" // Kind 返回驱动代号:"quark" / "p115" / "p123" / "pikpak" / "wopan" / "guangyapan" / "onedrive" / "googledrive" / "localstorage"
Kind() string Kind() string
// ID 返回该盘在 catalog 中的唯一标识 // ID 返回该盘在 catalog 中的唯一标识
@@ -119,3 +121,42 @@ func RateLimitRetryAfter(err error) (time.Duration, bool) {
} }
return 0, false 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...)
}
+24
View File
@@ -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)
}
})
}
}
+2 -14
View File
@@ -594,8 +594,8 @@ func (d *Driver) refresh(ctx context.Context) error {
return nil return nil
} }
func isRateLimitResponse(res *resty.Response, code, message string) bool { func isRateLimitResponse(res *resty.Response, code, _ string) bool {
if isRateLimitCode(code) || isRateLimitMessage(message) { if isRateLimitCode(code) {
return true return true
} }
if res == nil { 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 { func onedriveRateLimitError(res *resty.Response, message string) error {
if strings.TrimSpace(message) == "" { if strings.TrimSpace(message) == "" {
message = "onedrive rate limited" message = "onedrive rate limited"
@@ -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) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
@@ -238,11 +238,11 @@ func TestGraphThrottleMessageReturnsRateLimitError(t *testing.T) {
_, err := d.StreamURL(context.Background(), "file-id") _, err := d.StreamURL(context.Background(), "file-id")
if err == nil { if err == nil {
t.Fatal("list succeeded, want rate limit error") t.Fatal("list succeeded, want graph error")
} }
var rateLimit *drives.RateLimitError var rateLimit *drives.RateLimitError
if !errors.As(err, &rateLimit) { if errors.As(err, &rateLimit) {
t.Fatalf("error = %T %[1]v, want RateLimitError", err) t.Fatalf("error = %T %[1]v, want non-rate-limit error", err)
} }
} }
+2 -12
View File
@@ -87,7 +87,7 @@ func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error)
// p115ListCooldown 是列目录触发疑似风控错误时的冷却时长。 // p115ListCooldown 是列目录触发疑似风控错误时的冷却时长。
// //
// 历史上是 [30min × 3],3 次都失败就放弃;新策略改为 10 分钟无限重试 —— // 历史上是 [30min × 3],3 次都失败就放弃;新策略改为 10 分钟无限重试 ——
// 只要错误仍属 transient429 / 405 / WAF / blocked / 安全威胁 / unexpected), // 只要错误仍属明确 HTTP transient 状态429 / 405),
// 就持续等 10 分钟再发一次列目录请求,直到成功或 ctx 取消。这样即使 115 // 就持续等 10 分钟再发一次列目录请求,直到成功或 ctx 取消。这样即使 115
// 风控持续较长时间,扫描会自然延后到风控结束,不再丢半棵子树。 // 风控持续较长时间,扫描会自然延后到风控结束,不再丢半棵子树。
const p115ListCooldown = 10 * time.Minute const p115ListCooldown = 10 * time.Minute
@@ -156,17 +156,7 @@ func isTransient115UpstreamError(err error) bool {
if err == nil { if err == nil {
return false return false
} }
text := strings.ToLower(err.Error()) return drives.ErrorMentionsHTTPStatus(err, http.StatusMethodNotAllowed, http.StatusTooManyRequests)
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, "安全威胁")
} }
// ListDirsOnly 只列指定目录的直接**子目录**,不返回文件条目。专为 admin 后台 // ListDirsOnly 只列指定目录的直接**子目录**,不返回文件条目。专为 admin 后台
+5 -4
View File
@@ -22,8 +22,9 @@ func TestIsTransient115ListError(t *testing.T) {
want bool want bool
}{ }{
{name: "nil", err: nil, want: false}, {name: "nil", err: nil, want: false},
{name: "blocked html", err: errors.New(`<!doctype html><title>405</title>Sorry, your request has been blocked as it may cause potential threats to the server's security.`), want: true}, {name: "blocked html without status context", err: errors.New(`<!doctype html><title>405</title>Sorry, 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: true}, {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: "rate limit", err: errors.New("429 too many requests"), want: true},
{name: "regular auth error", err: errors.New("invalid credential"), want: false}, {name: "regular auth error", err: errors.New("invalid credential"), want: false},
} }
@@ -43,10 +44,10 @@ func TestWrap115StreamTransientError(t *testing.T) {
err error err error
wantRateLimit bool 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: "405 blocked", err: errors.New("405 request has been blocked"), wantRateLimit: true},
{name: "429", err: errors.New("429 too many requests"), 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}, {name: "auth", err: errors.New("invalid credential"), wantRateLimit: false},
} }
+3 -29
View File
@@ -754,8 +754,8 @@ func (d *Driver) request(ctx context.Context, endpoint, method string, configure
return nil, errors.New("123pan request: unauthorized") return nil, errors.New("123pan request: unauthorized")
} }
func isP123RateLimitResponse(res *resty.Response, code int, message string) bool { func isP123RateLimitResponse(res *resty.Response, code int, _ string) bool {
if code == http.StatusTooManyRequests || isP123RateLimitMessage(message) { if code == http.StatusTooManyRequests {
return true return true
} }
if res == nil { 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()) 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 { if status == http.StatusTooManyRequests {
return true return true
} }
@@ -774,35 +774,9 @@ func isP123RateLimitHTTPResponse(status int, retryAfter, body string) bool {
return true return true
} }
} }
if isP123RateLimitMessage(body) {
return true
}
return false 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 { func p123RateLimitError(res *resty.Response, code int, message string) error {
if strings.TrimSpace(message) == "" { if strings.TrimSpace(message) == "" {
message = "123pan rate limited" message = "123pan rate limited"
+10 -19
View File
@@ -175,8 +175,8 @@ func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error)
// pikpakListCooldown 是列目录触发疑似限流错误时的冷却时长。 // pikpakListCooldown 是列目录触发疑似限流错误时的冷却时长。
// //
// 与 p115 driver 的 listCooldown 同语义:只要错误属 transient // 与 p115 driver 的 listCooldown 同语义:只要错误属明确限流/临时状态
// error_code=10 / HTTP 429 / 5xx / 通用 "rate limit" 文本),就持续 // 结构化 error_code=10 / HTTP 429 / 5xx),就持续
// 等 10 分钟再发一次列目录请求,直到成功或 ctx 取消。这样即使 PikPak // 等 10 分钟再发一次列目录请求,直到成功或 ctx 取消。这样即使 PikPak
// 风控持续较长时间,扫描会自然延后到风控结束,不再丢半棵子树。 // 风控持续较长时间,扫描会自然延后到风控结束,不再丢半棵子树。
const pikpakListCooldown = 10 * time.Minute 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) // - PikPak 业务码 error_code=10 ("操作频繁",见 OpenList drivers/pikpak/util.go)
// - HTTP 429 / 500 / 502 / 503 / 504 / 509rclone 也把这些归为 retry // - HTTP 429 / 500 / 502 / 503 / 504 / 509rclone 也把这些归为 retry
// - 通用文本:rate limit / too many requests / blocked / temporarily unavailable
// //
// 不包含 4122/4121/16access_token 过期)和 9/4002captcha 过期)—— 这些 // 不包含 4122/4121/16access_token 过期)和 9/4002captcha 过期)—— 这些
// 由 requestOnce 内部已经做过一次自动恢复重试;如果恢复后仍然报这类错误, // 由 requestOnce 内部已经做过一次自动恢复重试;如果恢复后仍然报这类错误,
@@ -259,22 +258,14 @@ func isTransientPikPakListError(err error) bool {
return true return true
} }
} }
text := strings.ToLower(err.Error()) return drives.ErrorMentionsHTTPStatus(err,
return strings.Contains(text, "error_code=10") || http.StatusTooManyRequests,
strings.Contains(text, "429") || http.StatusInternalServerError,
strings.Contains(text, "http 500") || http.StatusBadGateway,
strings.Contains(text, "http 502") || http.StatusServiceUnavailable,
strings.Contains(text, "http 503") || http.StatusGatewayTimeout,
strings.Contains(text, "http 504") || 509,
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")
} }
func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) { func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) {
+8 -36
View File
@@ -510,42 +510,14 @@ func isWopanRateLimitError(err error) bool {
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false return false
} }
text := strings.ToLower(strings.TrimSpace(err.Error())) return drives.ErrorMentionsHTTPStatus(err,
if text == "" { http.StatusTooManyRequests,
return false http.StatusInternalServerError,
} http.StatusBadGateway,
return strings.Contains(text, "status: 429") || http.StatusServiceUnavailable,
strings.Contains(text, "status 429") || http.StatusGatewayTimeout,
strings.Contains(text, "http status: 429") || 509,
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, "风控")
} }
func guessMime(name string) string { func guessMime(name string) string {
+14 -36
View File
@@ -372,37 +372,10 @@ func remoteRangeResponseLooksRateLimited(rawURL string, status int, body []byte)
status == 509) { status == 509) {
return true return true
} }
text := strings.ToLower(strings.TrimSpace(string(body))) if isGuangYaPanMediaURL(rawURL) && (status == http.StatusForbidden || status == http.StatusTooManyRequests ||
compact := compactRemoteRangeErrorText(text) status == http.StatusInternalServerError || status == http.StatusBadGateway ||
if strings.Contains(text, "too many request") || status == http.StatusServiceUnavailable || status == http.StatusGatewayTimeout ||
strings.Contains(text, "too many requests") || status == 509) {
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") {
return true return true
} }
if status == http.StatusForbidden && isGoogleDriveMediaURL(rawURL) { if status == http.StatusForbidden && isGoogleDriveMediaURL(rawURL) {
@@ -424,6 +397,16 @@ func isWopanMediaURL(rawURL string) bool {
strings.Contains(path, "/openapi/download") 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 { func isGoogleDriveMediaURL(rawURL string) bool {
u, err := url.Parse(rawURL) u, err := url.Parse(rawURL)
if err != nil { if err != nil {
@@ -434,11 +417,6 @@ func isGoogleDriveMediaURL(rawURL string) bool {
return strings.Contains(host, "googleapis.com") && strings.Contains(path, "/drive/") 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 { func parseRetryAfter(raw string) time.Duration {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {
+28 -4
View File
@@ -86,16 +86,16 @@ func TestComputeRemoteUsesRangeSamples(t *testing.T) {
} }
} }
func TestComputeRemoteGoogleQuotaExceededReturnsRateLimit(t *testing.T) { func TestComputeRemote429ReturnsRateLimit(t *testing.T) {
ctx := context.Background() ctx := context.Background()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Retry-After", "60") w.Header().Set("Retry-After", "60")
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusTooManyRequests)
_, _ = 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.Write([]byte(`{"error":{"code":429}}`))
})) }))
defer srv.Close() 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{ _, err := Compute(ctx, drv, &catalog.Video{ID: "remote", FileID: "remote", Size: 1024 * 1024}, Config{
SampleSizeBytes: 4, SampleSizeBytes: 4,
FullHashMaxSize: 8, 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 { type fakeDrive struct {
paths map[string]string paths map[string]string
} }
+12 -159
View File
@@ -952,15 +952,7 @@ func redactURLs(text string) string {
} }
func ffmpegOutputLooksRateLimited(output []byte) bool { func ffmpegOutputLooksRateLimited(output []byte) bool {
text := strings.ToLower(string(output)) return drives.TextMentionsHTTPStatus(string(output), http.StatusTooManyRequests)
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")
} }
// --- 本地落盘 --- // --- 本地落盘 ---
@@ -1064,12 +1056,10 @@ type ThumbWorker struct {
} }
const ( const (
defaultTransientMediaCooldown = 5 * time.Minute defaultTransientMediaCooldown = 5 * time.Minute
defaultGenerationRateLimitCooldown = 5 * time.Minute defaultGenerationRateLimitCooldown = 5 * time.Minute
defaultThumbTransientMediaMaxFailures = 3 defaultThumbTransientMediaMaxFailures = 3
defaultWorkerQueueSize = 10000 defaultWorkerQueueSize = 10000
maxPreviewTeaserSizeBytes int64 = 5 * 1024 * 1024 * 1024
previewStatusSkipped = "skipped"
) )
type rateLimitState struct { type rateLimitState struct {
@@ -1518,145 +1508,21 @@ func driveErrorShouldCooldown(d drives.Drive, err error) bool {
} }
switch d.Kind() { switch d.Kind() {
case "p115": case "p115":
text := strings.ToLower(err.Error()) return drives.ErrorMentionsHTTPStatus(err, http.StatusForbidden, http.StatusMethodNotAllowed, http.StatusTooManyRequests)
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, "访问被阻断")
case "pikpak": case "pikpak":
// PikPak 在预览视频 / 封面生成阶段(取链或拉直链字节)可能命中: return drives.ErrorMentionsHTTPStatus(err, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout, 509)
// - 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")
case "p123": case "p123":
// 123网盘直链解析 / ffmpeg 读取阶段可能返回 429、5xx,或 WAF 类 return drives.ErrorMentionsHTTPStatus(err, http.StatusForbidden, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout)
// 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")
case "wopan": case "wopan":
// 联通网盘的取链接口和下载直链都可能返回"操作频繁"、429、5xx return drives.ErrorMentionsHTTPStatus(err, http.StatusForbidden, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout, 509)
// 或 WAF 阻断文本。封面/预览失败时先冷却,避免持续触发风控。 case "guangyapan":
text := strings.ToLower(err.Error()) return drives.ErrorMentionsHTTPStatus(err, http.StatusForbidden, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout, 509)
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")
case "googledrive": case "googledrive":
// Google Drive 下载/取样阶段常把频控和配额问题包装成 403, return drives.ErrorMentionsHTTPStatus(err, http.StatusForbidden, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout)
// 具体标识在 error.errors[].reason/message 里(OpenList 也按该结构解析)。
// ffmpeg/ffprobe 只能看到 stderr 文本时,按这些 reason/文本兜底冷却。
text := strings.ToLower(err.Error())
return googleDriveMediaErrorShouldCooldown(text)
} }
return false 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 { func (w *ThumbWorker) process(ctx context.Context, v *catalog.Video) bool {
if w.skipIfRateLimited(v) { if w.skipIfRateLimited(v) {
return false return false
@@ -1806,15 +1672,6 @@ func localPreviewLink(v *catalog.Video) (*drives.StreamLink, bool) {
} }
func (w *Worker) process(ctx context.Context, v *catalog.Video) { 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) { if w.skipIfRateLimited(v) {
return 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) 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) { func (w *Worker) generateTeaser(ctx context.Context, v *catalog.Video, link *drives.StreamLink, duration float64) (string, error) {
gen, ok := w.Gen.(refreshingTeaserGenerator) gen, ok := w.Gen.(refreshingTeaserGenerator)
if !ok || w.Drive == nil || w.Drive.Kind() != "p115" { if !ok || w.Drive == nil || w.Drive.Kind() != "p115" {
+56 -105
View File
@@ -349,42 +349,10 @@ func TestPreviewWorkerNeverCallsDriveUploadOrEnsureDir(t *testing.T) {
} }
} }
func TestPreviewWorkerSkipsTeaserForVideoLargerThanFiveGiB(t *testing.T) { func TestPreviewWorkerGeneratesTeaserForLargeVideo(t *testing.T) {
ctx := context.Background() ctx := context.Background()
cat, video := seedPreviewTestVideo(t, "preview-large-video") cat, video := seedPreviewTestVideo(t, "preview-large-video")
video.Size = maxPreviewTeaserSizeBytes + 1 video.Size = 6 * 1024 * 1024 * 1024
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
if err := cat.UpsertVideo(ctx, video); err != nil { if err := cat.UpsertVideo(ctx, video); err != nil {
t.Fatalf("update video: %v", err) t.Fatalf("update video: %v", err)
} }
@@ -485,9 +453,9 @@ func TestThumbWorkerRateLimitHonorsRetryAfter(t *testing.T) {
assertCooldownAround(t, worker.Status().CooldownUntil, before, 2*time.Hour) assertCooldownAround(t, worker.Status().CooldownUntil, before, 2*time.Hour)
} }
func TestThumbWorkerP115TransientErrorFailsAfterRetryLimit(t *testing.T) { func TestThumbWorkerP115MessageOnlyErrorFailsWithoutCooldown(t *testing.T) {
ctx := context.Background() ctx := context.Background()
cat, video := seedPreviewTestVideo(t, "thumb-p115-transient") cat, video := seedPreviewTestVideo(t, "thumb-p115-message-only")
gen := &fakeThumbGenerator{ gen := &fakeThumbGenerator{
generateErr: errors.New("ffmpeg thumb: exit status 183, stderr: partial file Cannot determine format of input 0:0 after EOF"), 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"} drv := &previewFakeDrive{kind: "p115"}
worker := NewThumbWorker(gen, cat, drv) 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) 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 { 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 { if len(failed) != 1 || failed[0].ID != video.ID {
t.Fatalf("pending thumbnails after reset = %#v, want only %s", pending, 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() ctx := context.Background()
cat, video := seedPreviewTestVideo(t, "thumb-p115-requeue") cat, video := seedPreviewTestVideo(t, "thumb-p115-no-requeue")
gen := &fakeThumbGenerator{ gen := &fakeThumbGenerator{
generateErr: errors.New("ffmpeg thumb: partial file Cannot determine format of input 0:0 after EOF"), 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 { select {
case queued := <-worker.ch: case queued := <-worker.ch:
if queued.ID != video.ID { t.Fatalf("unexpected requeued video id = %q", queued.ID)
t.Fatalf("requeued video id = %q, want %q", queued.ID, video.ID)
}
default: default:
t.Fatal("expected transient thumbnail failure to requeue the same video")
} }
got, err := cat.GetVideo(ctx, video.ID) got, err := cat.GetVideo(ctx, video.ID)
@@ -581,14 +503,14 @@ func TestThumbWorkerRequeuesP115TransientErrorBeforeRetryLimit(t *testing.T) {
t.Fatalf("get video: %v", err) t.Fatalf("get video: %v", err)
} }
if got.ThumbnailURL != "" { 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 { 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 { if len(failed) != 1 || failed[0].ID != video.ID {
t.Fatalf("pending thumbnails = %#v, want only %s", pending, 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"} drv := &previewFakeDrive{kind: "p123"}
for _, err := range []error{ for _, err := range []error{
errors.New("Server returned 403 Forbidden"), errors.New("Server returned 403 Forbidden"),
errors.New("请求太频繁"),
errors.New("http 503 service unavailable"), errors.New("http 503 service unavailable"),
} { } {
if !driveErrorShouldCooldown(drv, err) { if !driveErrorShouldCooldown(drv, err) {
t.Fatalf("driveErrorShouldCooldown(%v) = false, want true", 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")) { if driveErrorShouldCooldown(drv, errors.New("invalid credential")) {
t.Fatal("invalid credential should not trigger p123 cooldown") t.Fatal("invalid credential should not trigger p123 cooldown")
} }
@@ -666,31 +590,58 @@ func TestWopanTransientErrorsShouldCooldown(t *testing.T) {
for _, err := range []error{ for _, err := range []error{
errors.New("ffmpeg: Server returned 403 Forbidden"), errors.New("ffmpeg: Server returned 403 Forbidden"),
errors.New("wopan download url: request failed with status: 429 Too Many Requests"), errors.New("wopan download url: request failed with status: 429 Too Many Requests"),
errors.New("操作频繁,请稍后重试"),
errors.New("http 503 service unavailable"), errors.New("http 503 service unavailable"),
} { } {
if !driveErrorShouldCooldown(drv, err) { if !driveErrorShouldCooldown(drv, err) {
t.Fatalf("driveErrorShouldCooldown(%v) = false, want true", 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")) { if driveErrorShouldCooldown(drv, errors.New("invalid access token")) {
t.Fatal("invalid access token should not trigger wopan cooldown") 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) { func TestGoogleDriveMediaErrorsShouldCooldown(t *testing.T) {
drv := &previewFakeDrive{kind: "googledrive"} drv := &previewFakeDrive{kind: "googledrive"}
for _, err := range []error{ for _, err := range []error{
errors.New("google drive api error: usageLimits userRateLimitExceeded"),
errors.New("ffmpeg: Server returned 403 Forbidden"), 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"), errors.New("http 503 service unavailable"),
} { } {
if !driveErrorShouldCooldown(drv, err) { if !driveErrorShouldCooldown(drv, err) {
t.Fatalf("driveErrorShouldCooldown(%v) = false, want true", 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")) { if driveErrorShouldCooldown(drv, errors.New("invalid credentials")) {
t.Fatal("invalid credentials should not trigger googledrive cooldown") t.Fatal("invalid credentials should not trigger googledrive cooldown")
} }
+3 -1
View File
@@ -151,13 +151,15 @@ func (p *Proxy) ServeStream(w http.ResponseWriter, r *http.Request, driveID, fil
// 先解出最终 Location,浏览器可直接 302 到该短期地址 // 先解出最终 Location,浏览器可直接 302 到该短期地址
// - wopan:联通网盘 GetDownloadUrlV2 返回的是短期直链,OpenList 也是直接 // - wopan:联通网盘 GetDownloadUrlV2 返回的是短期直链,OpenList 也是直接
// 将该 URL 交给客户端使用;不需要后端持续代传视频字节 // 将该 URL 交给客户端使用;不需要后端持续代传视频字节
// - guangyapan:光鸭 get_res_download_url 返回 signedURL / downloadUrl
// 浏览器可直接访问,不需要后端持续代传视频字节
// //
// 其余网盘(如夸克等)仍走反代,因为它们的下载 // 其余网盘(如夸克等)仍走反代,因为它们的下载
// 链接通常需要随请求带上后端持有的 Cookie / Authorization / Range // 链接通常需要随请求带上后端持有的 Cookie / Authorization / Range
// 的特殊处理,浏览器拿不到这些上下文。 // 的特殊处理,浏览器拿不到这些上下文。
func shouldRedirect(d drives.Drive) bool { func shouldRedirect(d drives.Drive) bool {
switch d.Kind() { switch d.Kind() {
case "p115", "pikpak", "onedrive", "p123", "wopan": case "p115", "pikpak", "onedrive", "p123", "wopan", "guangyapan":
return true return true
} }
return false return false
+25
View File
@@ -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) { func TestServeStreamServesLocalFilePath(t *testing.T) {
path := filepath.Join(t.TempDir(), "video.mp4") path := filepath.Join(t.TempDir(), "video.mp4")
if err := os.WriteFile(path, []byte("0123456789"), 0o644); err != nil { if err := os.WriteFile(path, []byte("0123456789"), 0o644); err != nil {
+30 -5
View File
@@ -1,5 +1,5 @@
// Package spider91migrate 周期性把 spider91 drive 下载到本地的视频 // 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 改成目标盘的; // - 改写 catalog 行:drive_id / file_id / content_hash 改成目标盘的;
// 视频自身的 id 不变(仍是 spider91-<driveID>-<viewkey>),video_tags、 // 视频自身的 id 不变(仍是 spider91-<driveID>-<viewkey>),video_tags、
@@ -31,6 +31,7 @@ import (
"github.com/video-site/backend/internal/catalog" "github.com/video-site/backend/internal/catalog"
"github.com/video-site/backend/internal/drives" "github.com/video-site/backend/internal/drives"
"github.com/video-site/backend/internal/drives/googledrive" "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/onedrive"
"github.com/video-site/backend/internal/drives/p115" "github.com/video-site/backend/internal/drives/p115"
"github.com/video-site/backend/internal/drives/p123" "github.com/video-site/backend/internal/drives/p123"
@@ -42,7 +43,7 @@ import (
) )
// uploadTarget 是 migrator 调用目标 drive 的最小接口。任何一种"接收 spider91 上传"的 // uploadTarget 是 migrator 调用目标 drive 的最小接口。任何一种"接收 spider91 上传"的
// 网盘都要实现它;当前 PikPak、115、123、OneDrive、Google Drive 和联通网盘各自通过适配器满足。 // 网盘都要实现它;当前 PikPak、115、123、OneDrive、Google Drive、联通网盘和光鸭网盘各自通过适配器满足。
// //
// 这一层抽象把"迁移调用方"和"具体盘的 SDK 协议"解耦: // 这一层抽象把"迁移调用方"和"具体盘的 SDK 协议"解耦:
// - PikPak 走 GCID + OSS PutObjectpikpak.UploadResult // - PikPak 走 GCID + OSS PutObjectpikpak.UploadResult
@@ -51,6 +52,7 @@ import (
// - OneDrive 走 SHA1 + 小文件 PUT / 大文件 upload session // - OneDrive 走 SHA1 + 小文件 PUT / 大文件 upload session
// - Google Drive 走 MD5 + resumable upload session // - Google Drive 走 MD5 + resumable upload session
// - 联通网盘 走 SDK Upload2C,当前上游不返回内容 hash // - 联通网盘 走 SDK Upload2C,当前上游不返回内容 hash
// - 光鸭网盘 走 OSS 分片上传,当前上游不返回内容 hash
// //
// 各家返回值都被归一成本地的 UploadResult,并在 catalog 改写阶段统一处理。 // 各家返回值都被归一成本地的 UploadResult,并在 catalog 改写阶段统一处理。
type uploadTarget interface { type uploadTarget interface {
@@ -76,7 +78,7 @@ type Spider91LocalSource interface {
// UploadResult 是 uploadTarget.UploadAndReportHash 的归一返回。 // UploadResult 是 uploadTarget.UploadAndReportHash 的归一返回。
// //
// FileID 目标盘上的新文件 ID; // FileID 目标盘上的新文件 ID;
// Hash GCIDPikPak)、MD5 HEX123 / Google Drive)或 SHA1 HEX115 / OneDrive),写入 catalog.content_hash 用于跨盘去重;联通网盘暂为空; // Hash GCIDPikPak)、MD5 HEX123 / Google Drive)或 SHA1 HEX115 / OneDrive),写入 catalog.content_hash 用于跨盘去重;联通网盘和光鸭网盘暂为空;
// Size 实际上传字节数。 // Size 实际上传字节数。
type UploadResult struct { type UploadResult struct {
FileID string FileID string
@@ -110,7 +112,7 @@ type migrationPlan struct {
legacyBackfill bool legacyBackfill bool
} }
// pikpakAdapter / p115Adapter / p123Adapter / onedriveAdapter / googledriveAdapter / wopanAdapter 把具体 driver 包装成 uploadTarget。 // pikpakAdapter / p115Adapter / p123Adapter / onedriveAdapter / googledriveAdapter / wopanAdapter / guangyapanAdapter 把具体 driver 包装成 uploadTarget。
// //
// 之所以不让 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) 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。 // adaptUploadTarget 把通用 drive 包装成 uploadTarget。
// 不支持的盘 kind 返回 error;调用方静默跳过。 // 不支持的盘 kind 返回 error;调用方静默跳过。
func adaptUploadTarget(d drives.Drive) (uploadTarget, error) { func adaptUploadTarget(d drives.Drive) (uploadTarget, error) {
@@ -259,6 +282,8 @@ func adaptUploadTarget(d drives.Drive) (uploadTarget, error) {
return &googledriveAdapter{d: v}, nil return &googledriveAdapter{d: v}, nil
case *wopan.Driver: case *wopan.Driver:
return &wopanAdapter{d: v}, nil return &wopanAdapter{d: v}, nil
case *guangyapan.Driver:
return &guangyapanAdapter{d: v}, nil
case uploadTarget: case uploadTarget:
// 测试或自定义实现可以直接传入;优先使用具体类型分支以拿到适配器。 // 测试或自定义实现可以直接传入;优先使用具体类型分支以拿到适配器。
return v, nil return v, nil
@@ -1183,7 +1208,7 @@ func (m *Migrator) cleanupOldLocalVideos(ctx context.Context, plan migrationPlan
return deleted, nil return deleted, nil
} }
// backfillFileNames 扫描目标 drivePikPak、115、123、OneDrive、Google Drive 或联通网盘)下所有 spider91-* 起始 ID 的视频, // backfillFileNames 扫描目标 drivePikPak、115、123、OneDrive、Google Drive、联通网盘或光鸭网盘)下所有 spider91-* 起始 ID 的视频,
// 对文件名不是 desiredPikPakName(...) 期望格式的,调 target.Rename 修正, // 对文件名不是 desiredPikPakName(...) 期望格式的,调 target.Rename 修正,
// 并把 catalog.file_name 同步到新名字。 // 并把 catalog.file_name 同步到新名字。
// //
@@ -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 静默跳过(不会做破坏性变更)。 // resolveTarget 拒绝并返回 error,让 runOnce 静默跳过(不会做破坏性变更)。
func TestResolveTargetRejectsUnsupportedKind(t *testing.T) { func TestResolveTargetRejectsUnsupportedKind(t *testing.T) {
cat := setupCatalog(t) cat := setupCatalog(t)
+283
View File
@@ -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()
+1 -1
View File
@@ -33,7 +33,7 @@ import { SpiderIcon } from "./icons/SpiderIcon";
const BUSY_STATES = new Set(["scanning", "generating", "uploading", "queued"]); const BUSY_STATES = new Set(["scanning", "generating", "uploading", "queued"]);
const POLL_INTERVAL_MS = 5000; 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) { function statusBusy(status?: api.DriveGenerationStatus) {
return BUSY_STATES.has(status?.state ?? ""); return BUSY_STATES.has(status?.state ?? "");
+2 -1
View File
@@ -102,7 +102,8 @@ export function DrivesPage() {
d.kind === "p123" || d.kind === "p123" ||
d.kind === "onedrive" || d.kind === "onedrive" ||
d.kind === "googledrive" || d.kind === "googledrive" ||
d.kind === "wopan" d.kind === "wopan" ||
d.kind === "guangyapan"
), ),
[list] [list]
); );
+29 -2
View File
@@ -78,7 +78,7 @@ export function checkUpdate() {
export type AdminDrive = { export type AdminDrive = {
id: string; 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; name: string;
rootId: string; rootId: string;
status: string; status: string;
@@ -155,7 +155,7 @@ export function getDriveStorage() {
export type UpsertDriveInput = { export type UpsertDriveInput = {
id: string; 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; name: string;
rootId: string; rootId: string;
credentials: Record<string, string>; credentials: Record<string, string>;
@@ -376,6 +376,33 @@ export function getWopanQRStatus(uuid: string) {
return request<WopanQRStatus>(`/drives/wopan/qr/${encodeURIComponent(uuid)}`); return request<WopanQRStatus>(`/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<GuangYaPanQRSession>("/drives/guangyapan/qr", { method: "POST" });
}
export function getGuangYaPanQRStatus(deviceCode: string) {
const qs = new URLSearchParams({ deviceCode });
return request<GuangYaPanQRStatus>(`/drives/guangyapan/qr/status?${qs.toString()}`);
}
/** /**
* toggle * toggle
* *
+17
View File
@@ -2,6 +2,7 @@ import { useId, useMemo, useState } from "react";
import { ArrowLeft, ChevronDown } from "lucide-react"; import { ArrowLeft, ChevronDown } from "lucide-react";
import { P123QRCodeLogin } from "./P123QRCodeLogin"; import { P123QRCodeLogin } from "./P123QRCodeLogin";
import { WopanQRCodeLogin } from "./WopanQRCodeLogin"; import { WopanQRCodeLogin } from "./WopanQRCodeLogin";
import { GuangYaPanQRCodeLogin } from "./GuangYaPanQRCodeLogin";
import { Spider91UploadTargetField } from "./Spider91UploadTargetField"; import { Spider91UploadTargetField } from "./Spider91UploadTargetField";
import { import {
FormState, FormState,
@@ -24,6 +25,7 @@ const DRIVE_OPTIONS: DriveOption[] = [
{ kind: "p115", label: "115 网盘", abbr: "115", desc: "302直链,不占带宽" }, { kind: "p115", label: "115 网盘", abbr: "115", desc: "302直链,不占带宽" },
{ kind: "p123", label: "123网盘", abbr: "123", desc: "扫码登录,302直链" }, { kind: "p123", label: "123网盘", abbr: "123", desc: "扫码登录,302直链" },
{ kind: "pikpak", label: "PikPak", abbr: "Pk", 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: "onedrive", label: "OneDrive", abbr: "OD", desc: "302直链,微软网盘" },
{ kind: "googledrive", label: "Google Drive", abbr: "GD", desc: "服务器中转模式" }, { kind: "googledrive", label: "Google Drive", abbr: "GD", desc: "服务器中转模式" },
{ kind: "localstorage", label: "本地存储", abbr: "Lo", desc: "本机文件目录" }, { kind: "localstorage", label: "本地存储", abbr: "Lo", desc: "本机文件目录" },
@@ -194,6 +196,21 @@ export function DriveForm({
/> />
)} )}
{form.kind === "guangyapan" && (
<GuangYaPanQRCodeLogin
onCredentials={(credentials) =>
onChange({
...form,
creds: {
...form.creds,
access_token: credentials.accessToken,
refresh_token: credentials.refreshToken,
},
})
}
/>
)}
{fields.map((f) => ( {fields.map((f) => (
<div key={f.key} className="admin-form__row"> <div key={f.key} className="admin-form__row">
{f.type === "select" ? ( {f.type === "select" ? (
+150
View File
@@ -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<api.GuangYaPanQRSession | null>(null);
const [status, setStatus] = useState<api.GuangYaPanQRStatus | null>(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 (
<div className="admin-form__row">
<label></label>
<div className="admin-p123-qr">
<div className="admin-p123-qr__actions">
<button
type="button"
className="admin-btn"
onClick={start}
disabled={starting}
>
<QrCode size={14} />
{starting ? "生成中..." : session ? "重新生成二维码" : "生成二维码"}
</button>
<span className={`admin-status ${statusClass}`}>{statusText}</span>
</div>
{session && (
<div className="admin-p123-qr__body">
<img
className="admin-p123-qr__image"
src={session.qrImageDataUrl}
alt="光鸭网盘扫码登录二维码"
/>
<div className="admin-p123-qr__meta">
<div className="admin-form__help">
使 App access_token refresh_token
</div>
{session.expiresAt && (
<div className="admin-form__help">
{new Date(session.expiresAt).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})}
</div>
)}
{(status?.state === "expired" || status?.state === "denied") && (
<div className="admin-form__help">
{status.state === "denied" ? "已被拒绝" : "已过期"}
</div>
)}
</div>
</div>
)}
</div>
</div>
);
}
+29 -1
View File
@@ -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<string, string> = { export const kindAbbr: Record<string, string> = {
quark: "Qk", quark: "Qk",
@@ -6,6 +6,7 @@ export const kindAbbr: Record<string, string> = {
p123: "123", p123: "123",
pikpak: "Pk", pikpak: "Pk",
wopan: "Wo", wopan: "Wo",
guangyapan: "GY",
onedrive: "OD", onedrive: "OD",
googledrive: "GD", googledrive: "GD",
localstorage: "Lo", localstorage: "Lo",
@@ -28,6 +29,7 @@ export const kindLabel: Record<string, string> = {
p123: "123网盘", p123: "123网盘",
pikpak: "PikPak", pikpak: "PikPak",
wopan: "联通网盘", wopan: "联通网盘",
guangyapan: "光鸭网盘",
onedrive: "OneDrive", onedrive: "OneDrive",
googledrive: "Google Drive", googledrive: "Google Drive",
localstorage: "本地存储", localstorage: "本地存储",
@@ -126,6 +128,7 @@ export function formatClock(value: string): string {
export function defaultRootId(kind: Kind): string { export function defaultRootId(kind: Kind): string {
if (kind === "pikpak") return ""; if (kind === "pikpak") return "";
if (kind === "guangyapan") return "";
if (kind === "onedrive") return "root"; if (kind === "onedrive") return "root";
if (kind === "googledrive") return "root"; if (kind === "googledrive") return "root";
if (kind === "localstorage") return "/"; if (kind === "localstorage") return "/";
@@ -155,6 +158,8 @@ export function credentialHelp(kind: Kind, isEdit: boolean): string {
return `填写 PikPak 账号和密码即可。平台、设备 ID、验证码 token 和 refresh token 会由服务端自动处理并保存。${note}`; return `填写 PikPak 账号和密码即可。平台、设备 ID、验证码 token 和 refresh token 会由服务端自动处理并保存。${note}`;
case "wopan": case "wopan":
return `推荐使用扫码登录自动获取 access_token 和 refresh_token;也可以手工粘贴已有凭证。${note}`; return `推荐使用扫码登录自动获取 access_token 和 refresh_token;也可以手工粘贴已有凭证。${note}`;
case "guangyapan":
return `推荐使用扫码登录自动获取 access_token 和 refresh_token;也可以手工粘贴已有 token。${note}`;
case "onedrive": case "onedrive":
return `按 OpenList 默认应用在线挂载,只需要 refresh_token;保存时会自动刷新并保存 token。${note}`; return `按 OpenList 默认应用在线挂载,只需要 refresh_token;保存时会自动刷新并保存 token。${note}`;
case "googledrive": case "googledrive":
@@ -272,6 +277,29 @@ export function credentialFields(kind: Kind, creds: Record<string, string> = {})
placeholder: "留空走个人空间", 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": case "onedrive":
return [ return [
{ {
+1
View File
@@ -295,6 +295,7 @@ function sourceKindFromLabel(label: string): string {
if (value.includes("123") || value.includes("p123")) return "p123"; if (value.includes("123") || value.includes("p123")) return "p123";
if (value.includes("pikpak")) return "pikpak"; if (value.includes("pikpak")) return "pikpak";
if (value.includes("沃盘") || value.includes("wopan") || value.includes("联通")) return "wopan"; 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("onedrive") || value.includes("one drive")) return "onedrive";
if (value.includes("本地") || value.includes("localstorage") || value.includes("local storage")) return "localstorage"; if (value.includes("本地") || value.includes("localstorage") || value.includes("local storage")) return "localstorage";
return ""; return "";
+2
View File
@@ -74,6 +74,8 @@ function sourceKindFromLabel(label: string): string {
if (value.includes("pikpak")) return "pikpak"; if (value.includes("pikpak")) return "pikpak";
if (value.includes("沃盘") || value.includes("wopan") || value.includes("联通")) if (value.includes("沃盘") || value.includes("wopan") || value.includes("联通"))
return "wopan"; 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("onedrive") || value.includes("one drive")) return "onedrive";
if (value.includes("本地") || value.includes("localstorage") || value.includes("local storage")) if (value.includes("本地") || value.includes("localstorage") || value.includes("local storage"))
return "localstorage"; return "localstorage";
+1
View File
@@ -1532,6 +1532,7 @@ function getDriveShortName(source: string): string {
if (s.includes("quark") || s.includes("夸克")) return "Quak"; if (s.includes("quark") || s.includes("夸克")) return "Quak";
if (s.includes("onedrive")) return "OneDrive"; if (s.includes("onedrive")) return "OneDrive";
if (s.includes("wopan") || s.includes("沃盘")) return "沃盘"; 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("localstorage") || s.includes("本地")) return "本地";
if (s.includes("spider") || s.includes("爬虫")) return "爬虫"; if (s.includes("spider") || s.includes("爬虫")) return "爬虫";
return source.substring(0, 4); return source.substring(0, 4);
+4
View File
@@ -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="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="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="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 { .admin-drive-type-card__icon {
display: grid; 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="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="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="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 { .admin-drive-type-card__label {
font-size: var(--font-sm); 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="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="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="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 { .admin-drive-selected-bar__text {
flex: 1; 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="p123"] { background: var(--drive-p123); }
.admin-drive-card__brand-icon[data-kind="pikpak"] { background: var(--drive-pikpak); } .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="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="onedrive"] { background: var(--drive-onedrive); }
.admin-drive-card__brand-icon[data-kind="googledrive"] { background: #4285f4; } .admin-drive-card__brand-icon[data-kind="googledrive"] { background: #4285f4; }
.admin-drive-card__brand-icon[data-kind="localstorage"] { background: var(--drive-localstorage); } .admin-drive-card__brand-icon[data-kind="localstorage"] { background: var(--drive-localstorage); }
+3
View File
@@ -135,6 +135,7 @@
--drive-p123: #22b8c8; --drive-p123: #22b8c8;
--drive-pikpak: #8a6dff; --drive-pikpak: #8a6dff;
--drive-wopan: #ff8a3c; --drive-wopan: #ff8a3c;
--drive-guangyapan: #30c3a8;
--drive-onedrive: #4cabea; --drive-onedrive: #4cabea;
--drive-localstorage: #35b88f; --drive-localstorage: #35b88f;
@@ -227,6 +228,7 @@
--drive-p123: #1596a8; --drive-p123: #1596a8;
--drive-pikpak: #8466e6; --drive-pikpak: #8466e6;
--drive-wopan: #e57a36; --drive-wopan: #e57a36;
--drive-guangyapan: #229f8b;
--drive-onedrive: #2f95cf; --drive-onedrive: #2f95cf;
--drive-localstorage: #239978; --drive-localstorage: #239978;
@@ -329,6 +331,7 @@
--drive-p123: #128da0; --drive-p123: #128da0;
--drive-pikpak: #6b4ed4; --drive-pikpak: #6b4ed4;
--drive-wopan: #dc6d28; --drive-wopan: #dc6d28;
--drive-guangyapan: #158b7a;
--drive-onedrive: #1f7fc0; --drive-onedrive: #1f7fc0;
--drive-localstorage: #198866; --drive-localstorage: #198866;
+9
View File
@@ -305,6 +305,14 @@
--drive-shadow: rgba(255, 138, 60, 0.15); --drive-shadow: rgba(255, 138, 60, 0.15);
--drive-shadow-strong: rgba(255, 138, 60, 0.4); --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"] { .source-badge[data-kind="onedrive"] {
--drive-color: var(--drive-onedrive); --drive-color: var(--drive-onedrive);
--drive-bg: rgba(76, 171, 234, 0.12); --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="p123"] { --source-color: var(--drive-p123); }
.video-card__source[data-kind="pikpak"] { --source-color: var(--drive-pikpak); } .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="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="onedrive"] { --source-color: var(--drive-onedrive); }
.video-card__source[data-kind="localstorage"] { --source-color: var(--drive-localstorage); } .video-card__source[data-kind="localstorage"] { --source-color: var(--drive-localstorage); }
+6
View File
@@ -676,6 +676,12 @@
color: var(--drive-wopan); 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"] { .vd-meta__chip[data-tone="onedrive"] {
background: rgba(76, 171, 234, 0.14); background: rgba(76, 171, 234, 0.14);
border-color: rgba(76, 171, 234, 0.3); border-color: rgba(76, 171, 234, 0.3);
+30 -2
View File
@@ -78,9 +78,9 @@ test("spider91 upload target uses explicit local-save option instead of auto tar
assert.match(combinedSource, /本地保存,不上传/); assert.match(combinedSource, /本地保存,不上传/);
assert.match( assert.match(
combinedSource, 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, /自动模式/); assert.doesNotMatch(combinedSource, /自动模式/);
assert.doesNotMatch(combinedSource, /较早的视频会上传到该云盘根目录下的 91 Spider 文件夹/); 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"/); 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", () => { test("localstorage drive form asks for a server directory path", () => {
assertDriveTypeOption("localstorage", "本地存储"); assertDriveTypeOption("localstorage", "本地存储");
@@ -196,6 +222,7 @@ test("drive type selector keeps primary source order", () => {
{ value: "p115", label: "115 网盘" }, { value: "p115", label: "115 网盘" },
{ value: "p123", label: "123网盘" }, { value: "p123", label: "123网盘" },
{ value: "pikpak", label: "PikPak" }, { value: "pikpak", label: "PikPak" },
{ value: "guangyapan", label: "光鸭网盘" },
{ value: "onedrive", label: "OneDrive" }, { value: "onedrive", label: "OneDrive" },
{ value: "googledrive", label: "Google Drive" }, { value: "googledrive", label: "Google Drive" },
{ value: "localstorage", label: "本地存储" }, { 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(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\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="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", () => { test("drive management exposes stop task controls", () => {