feat: add Unicom cloud drive source operations

Add Unicom cloud drive support for source-file deletion and crawler uploads.

- Implement source-file removal for Unicom cloud drive so deleting videos can also remove the original cloud-drive file when requested.

- Resolve Unicom cloud drive source identifiers across file FID, object ID, directory ID, rename, and delete flows.

- Add upload support for Spider91 crawler imports targeting Unicom cloud drive storage.

- Add Unicom cloud drive QR login backend APIs, frontend form support, and tests.

- Extend drive capability metadata, scanner behavior, proxy handling, preview handling, and migration coverage for cloud-drive source operations.

- Rename Chinese display labels from 联通沃盘 to 联通网盘 and from 123 云盘 to 123网盘 while keeping the root README aligned with origin/main.

- Add referrer-policy coverage for 302 video playback and update related frontend playback tests.
This commit is contained in:
nianzhibai
2026-06-12 15:49:15 +08:00
parent 7f1e4eaa29
commit ae324d3752
41 changed files with 1956 additions and 95 deletions
+2 -2
View File
@@ -2,7 +2,7 @@
视频聚合站的 Go 后端。提供三件事:
1. 多家网盘统一抽象(夸克 / 115 / PikPak / 联通盘 / OneDrive / Google Drive / 本地存储)
1. 多家网盘统一抽象(夸克 / 115 / PikPak / 联通盘 / OneDrive / Google Drive / 本地存储)
2. 视频元数据目录(SQLite)+ 扫描 + 预览视频预生成
3. REST API(前台)+ 管理后台 + 直链代理
4. 标签池、视频隐藏、按网盘统计和详情页来源网盘类型展示能力
@@ -19,7 +19,7 @@ internal/
quark/ 夸克(自己实现,参考 OpenList quark_uc
p115/ 115(壳子 + SheltonZhu/115driver
pikpak/ PikPak(自己实现,参考 OpenList pikpak
wopan/ 联通盘(壳子 + OpenListTeam/wopan-sdk-go
wopan/ 联通盘(壳子 + OpenListTeam/wopan-sdk-go
onedrive/ OneDriveOpenList 在线续期 + Microsoft Graph 文件接口)
googledrive/ Google DriveOpenList 在线续期 + Google Drive API;播放走后端代理)
localstorage/ 本地目录扫描(服务器已有视频目录)
+36 -10
View File
@@ -332,10 +332,10 @@ type App struct {
// 全站主题("dark" | "pink"),从 DB 读
theme string
// 显式指定的 spider91 上传目标 drive ID。
// 空字符串表示本地保存不上传,不再自动挑选 pikpak/p115/p123/onedrive drive。
// 空字符串表示本地保存不上传,不再自动挑选 pikpak/p115/p123/onedrive/wopan drive。
spider91UploadDriveID string
// spider91Migrator 把 spider91 视频上传到目标 drivePikPak、115、123、OneDriveGoogle Drive)。
// spider91Migrator 把 spider91 视频上传到目标 drivePikPak、115、123、OneDriveGoogle Drive 或联通网盘)。
spider91Migrator spider91MigrationRunner
// nightlyRunner 是凌晨流水线调度器:每天 cron_hour 串行跑扫盘 → 91 爬虫 → 迁移。
@@ -449,7 +449,7 @@ func (a *App) loadTheme(ctx context.Context) {
}
// Spider91UploadDriveID 返回当前配置的 spider91 上传目标 drive ID。
// 空字符串表示本地保存不上传;只有管理员显式选择 pikpak/p115/p123/onedrive/googledrive drive 时才迁移上传。
// 空字符串表示本地保存不上传;只有管理员显式选择 pikpak/p115/p123/onedrive/googledrive/wopan drive 时才迁移上传。
func (a *App) Spider91UploadDriveID() string {
a.mu.Lock()
explicit := a.spider91UploadDriveID
@@ -466,7 +466,7 @@ func (a *App) Spider91UploadDriveID() string {
// SetSpider91UploadDriveID 设置 spider91 上传目标 drive ID 并持久化。
// 接受空字符串(本地保存不上传)。
// 设置一个不存在或 kind 不是 pikpak / p115 / p123 / onedrive / googledrive 的 drive 会返回错误。
// 设置一个不存在或 kind 不是 pikpak / p115 / p123 / onedrive / googledrive / wopan 的 drive 会返回错误。
func (a *App) SetSpider91UploadDriveID(ctx context.Context, driveID string) error {
driveID = strings.TrimSpace(driveID)
if driveID != "" {
@@ -475,7 +475,7 @@ func (a *App) SetSpider91UploadDriveID(ctx context.Context, driveID string) erro
return fmt.Errorf("drive %q not found", driveID)
}
if !isSpider91UploadKind(d.Kind()) {
return fmt.Errorf("drive %q kind=%s, only pikpak, p115, p123, onedrive or googledrive can be spider91 upload target", driveID, 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())
}
}
a.mu.Lock()
@@ -508,7 +508,7 @@ func formatOptionalRFC3339(t time.Time) string {
// isSpider91UploadKind 是 spider91 迁移目标盘的 allowlist。
// 与 spider91migrate.adaptUploadTarget 的支持范围保持一致。
func isSpider91UploadKind(kind string) bool {
return kind == "pikpak" || kind == "p115" || kind == "p123" || kind == "onedrive" || kind == "googledrive"
return kind == "pikpak" || kind == "p115" || kind == "p123" || kind == "onedrive" || kind == "googledrive" || kind == "wopan"
}
// loadSpider91UploadDriveID 从 DB 读上传目标 drive ID 设置;不存在时使用空串。
@@ -904,9 +904,24 @@ func (a *App) newDriveGenerationWorkers(drv drives.Drive) (*preview.Worker, *pre
}
}
gen := preview.New(previewCfg)
return preview.NewWorker(gen, a.cat, drv),
preview.NewThumbWorker(gen, a.cat, drv),
fingerprint.NewWorker(a.cat, drv, fingerprintConfigForDrive(drv))
previewWorker := preview.NewWorker(gen, a.cat, drv)
thumbWorker := preview.NewThumbWorker(gen, a.cat, drv)
if cooldown := generationCooldownForDrive(drv); cooldown > 0 {
previewWorker.RateLimitCooldown = cooldown
thumbWorker.RateLimitCooldown = cooldown
}
return previewWorker, thumbWorker, fingerprint.NewWorker(a.cat, drv, fingerprintConfigForDrive(drv))
}
func generationCooldownForDrive(drv drives.Drive) time.Duration {
if drv == nil {
return 0
}
switch strings.ToLower(drv.Kind()) {
case "wopan":
return 10 * time.Minute
}
return 0
}
func (a *App) startDriveGenerationWorkers(ctx context.Context, driveID string, drv drives.Drive, enqueue bool) {
@@ -929,7 +944,7 @@ func fingerprintConfigForDrive(drv drives.Drive) fingerprint.Config {
return cfg
}
switch strings.ToLower(drv.Kind()) {
case "p115", "p123", "onedrive":
case "p115", "p123", "onedrive", "wopan":
cfg.RateLimitCooldown = 10 * time.Minute
case "pikpak":
cfg.RateLimitCooldown = 5 * time.Minute
@@ -1867,6 +1882,17 @@ func (a *App) removeVideoSourceFile(ctx context.Context, v *catalog.Video) (bool
if !ok {
return false, fmt.Errorf("remove video source %s: drive %s not attached: %w", v.ID, v.DriveID, drives.ErrNotSupported)
}
if sourceRemover, ok := drv.(drives.SourceRemover); ok {
if err := sourceRemover.RemoveSource(ctx, drives.SourceFile{
FileID: fileID,
ParentID: strings.TrimSpace(v.ParentID),
Name: strings.TrimSpace(v.FileName),
Size: v.Size,
}); err != nil {
return false, fmt.Errorf("remove video source %s from drive %s: %w", v.ID, v.DriveID, err)
}
return true, nil
}
remover, ok := drv.(drives.Remover)
if !ok {
return false, fmt.Errorf("remove video source %s: drive %s (%s) does not support source deletion: %w", v.ID, v.DriveID, drv.Kind(), drives.ErrNotSupported)
+6
View File
@@ -40,6 +40,7 @@ func TestSpider91UploadDriveIDDoesNotAutoSelectTarget(t *testing.T) {
reg.Set("p115-one", &spider91UploadTargetFakeDrive{id: "p115-one", kind: "p115"})
reg.Set("p123-one", &spider91UploadTargetFakeDrive{id: "p123-one", kind: "p123"})
reg.Set("onedrive-one", &spider91UploadTargetFakeDrive{id: "onedrive-one", kind: "onedrive"})
reg.Set("wopan-one", &spider91UploadTargetFakeDrive{id: "wopan-one", kind: "wopan"})
app := &App{registry: reg}
if got := app.Spider91UploadDriveID(); got != "" {
@@ -61,6 +62,11 @@ func TestSpider91UploadDriveIDDoesNotAutoSelectTarget(t *testing.T) {
t.Fatalf("explicit onedrive upload target = %q, want onedrive-one", got)
}
app.spider91UploadDriveID = "wopan-one"
if got := app.Spider91UploadDriveID(); got != "wopan-one" {
t.Fatalf("explicit wopan upload target = %q, want wopan-one", got)
}
app.spider91UploadDriveID = "missing"
if got := app.Spider91UploadDriveID(); got != "" {
t.Fatalf("missing upload target = %q, want empty", got)
+77
View File
@@ -1337,6 +1337,59 @@ func TestDeleteVideoRemovesSourceFileWhenRequested(t *testing.T) {
}
}
func TestDeleteVideoUsesSourceRemoverWithCatalogMetadata(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(filepath.Join(t.TempDir(), "catalog.db"))
if err != nil {
t.Fatalf("open catalog: %v", err)
}
t.Cleanup(func() { _ = cat.Close() })
now := time.Now()
if err := cat.UpsertVideo(ctx, &catalog.Video{
ID: "video-with-rich-source",
DriveID: "source-drive",
FileID: "source-fid",
ParentID: "parent-dir",
FileName: "clip.mp4",
Title: "Source File",
Size: 123,
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("seed video: %v", err)
}
registry := proxy.NewRegistry()
drv := &serverSourceRemovableFakeDrive{id: "source-drive"}
registry.Set(drv.ID(), drv)
app := &App{
cfg: &config.Config{Storage: config.Storage{LocalPreviewDir: filepath.Join(t.TempDir(), "previews")}},
cat: cat,
registry: registry,
}
result, err := app.deleteVideo(ctx, "video-with-rich-source", true)
if err != nil {
t.Fatalf("delete video: %v", err)
}
if !result.OK || !result.DeletedSource {
t.Fatalf("delete result = %#v, want source deleted", result)
}
if drv.fallbackRemoveCalled {
t.Fatal("fallback Remove was called, want SourceRemover")
}
want := drives.SourceFile{
FileID: "source-fid",
ParentID: "parent-dir",
Name: "clip.mp4",
Size: 123,
}
if drv.removedSource != want {
t.Fatalf("removed source = %#v, want %#v", drv.removedSource, want)
}
}
func TestDeleteVideoRemovesSpider91SourceFile(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
@@ -1823,6 +1876,30 @@ func (d *serverRemovableFakeDrive) Remove(ctx context.Context, fileID string) er
return nil
}
type serverSourceRemovableFakeDrive struct {
serverFakeDrive
id string
removedSource drives.SourceFile
fallbackRemoveCalled bool
}
func (d *serverSourceRemovableFakeDrive) Kind() string { return "fake-source-removable" }
func (d *serverSourceRemovableFakeDrive) ID() string { return d.id }
func (d *serverSourceRemovableFakeDrive) RemoveSource(ctx context.Context, source drives.SourceFile) error {
if err := ctx.Err(); err != nil {
return err
}
d.removedSource = source
return nil
}
func (d *serverSourceRemovableFakeDrive) Remove(ctx context.Context, fileID string) error {
if err := ctx.Err(); err != nil {
return err
}
d.fallbackRemoveCalled = true
return nil
}
type serverFakeSpider91MigrationRunner struct {
called int
}
+42 -4
View File
@@ -24,6 +24,7 @@ import (
"github.com/video-site/backend/internal/drives/p123"
"github.com/video-site/backend/internal/drives/scriptcrawler"
"github.com/video-site/backend/internal/drives/spider91"
"github.com/video-site/backend/internal/drives/wopan"
)
type AdminServer struct {
@@ -67,7 +68,7 @@ type AdminServer struct {
// Theme 读写("dark" | "pink"
GetTheme func() string
SetTheme func(theme string) error
// Spider91 → 115/123/PikPak/OneDrive 上传目标 drive ID 读写
// Spider91 → 115/123/PikPak/OneDrive/Google Drive/联通网盘 上传目标 drive ID 读写
GetSpider91UploadDriveID func() string
SetSpider91UploadDriveID func(driveID string) error
// OnRunNightlyJob 触发一次完整的凌晨流水线(Phase1 扫盘 + Phase2 91 爬虫 +
@@ -81,9 +82,12 @@ type AdminServer struct {
// 用于"设置跳过目录"弹窗按需展开浏览网盘目录树;只返回目录条目,文件忽略。
// 调用方应当处理 error 并以 5xx 返回前端。
ListDriveDirChildren func(ctx context.Context, driveID, parentID string) ([]DriveDirEntry, error)
// 123盘扫码登录接口测试注入;生产留空走官方 user.123pan.cn。
// 123盘扫码登录接口测试注入;生产留空走官方 user.123pan.cn。
P123UserAPIBaseURL string
P123HTTPClient *http.Client
// 联通网盘扫码登录接口测试注入;生产留空走官方 panservice.mail.wo.cn。
WopanQRAPIBaseURL string
WopanQRHTTPClient *http.Client
}
const (
@@ -154,6 +158,8 @@ func (a *AdminServer) Register(r chi.Router) {
r.Post("/drives", a.handleUpsertDrive)
r.Post("/drives/p123/qr", a.handleP123QRStart)
r.Get("/drives/p123/qr/{uniID}", a.handleP123QRStatus)
r.Post("/drives/wopan/qr", a.handleWopanQRStart)
r.Get("/drives/wopan/qr/{uuid}", a.handleWopanQRStatus)
r.Delete("/drives/{id}", a.handleDeleteDrive)
r.Post("/drives/{id}/rescan", a.handleRescan)
r.Post("/drives/{id}/tasks/stop", a.handleStopDriveTasks)
@@ -888,14 +894,14 @@ func (a *AdminServer) validateCrawlerUploadDrive(ctx context.Context, driveID st
return fmt.Errorf("上传目标网盘 %q 不存在", driveID)
}
if !isCrawlerUploadTargetKind(d.Kind) {
return fmt.Errorf("上传目标网盘 %q 类型为 %s,仅支持 115网盘、PikPak、123网盘、Google Drive、OneDrive", driveID, d.Kind)
return fmt.Errorf("上传目标网盘 %q 类型为 %s,仅支持 115网盘、PikPak、123网盘、Google Drive、OneDrive、联通网盘", driveID, d.Kind)
}
return nil
}
func isCrawlerUploadTargetKind(kind string) bool {
switch strings.TrimSpace(kind) {
case "p115", "pikpak", "p123", "googledrive", "onedrive":
case "p115", "pikpak", "p123", "googledrive", "onedrive", "wopan":
return true
default:
return false
@@ -1574,6 +1580,38 @@ func (a *AdminServer) handleP123QRStatus(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, status)
}
func (a *AdminServer) wopanQRClient() *wopan.QRClient {
return wopan.NewQRClient(wopan.QRConfig{
APIBaseURL: a.WopanQRAPIBaseURL,
HTTPClient: a.WopanQRHTTPClient,
})
}
func (a *AdminServer) handleWopanQRStart(w http.ResponseWriter, r *http.Request) {
session, err := a.wopanQRClient().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) handleWopanQRStatus(w http.ResponseWriter, r *http.Request) {
uuid := chi.URLParam(r, "uuid")
if strings.TrimSpace(uuid) == "" {
http.Error(w, "uuid is required", http.StatusBadRequest)
return
}
status, err := a.wopanQRClient().Poll(r.Context(), uuid)
if err != nil {
writeErr(w, http.StatusBadGateway, err)
return
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, status)
}
// handleRunNightlyJob 触发一次完整的凌晨流水线(不论当前时间,不论今日是否已跑)。
// 立即返回 202;进度通过 backend 日志和下次 GET /admin/api/drives 的状态变化观察。
// 流水线已在跑或已排队时,Runner 会拒绝重复触发。
+99
View File
@@ -1270,6 +1270,7 @@ func TestHandleUpsertCrawlerPersistsAndValidatesUploadDrive(t *testing.T) {
}
for _, d := range []*catalog.Drive{
{ID: "p115-target", Kind: "p115", Name: "115", RootID: "0", Credentials: map[string]string{"cookie": "x"}},
{ID: "wopan-target", Kind: "wopan", Name: "沃盘", RootID: "0", Credentials: map[string]string{"access_token": "a", "refresh_token": "r"}},
{ID: "local-target", Kind: "localstorage", Name: "Local", RootID: "/", Credentials: map[string]string{"path": tmp}},
} {
if err := cat.UpsertDrive(ctx, d); err != nil {
@@ -1296,6 +1297,24 @@ func TestHandleUpsertCrawlerPersistsAndValidatesUploadDrive(t *testing.T) {
t.Fatalf("upload_drive_id = %q, want p115-target", got.Credentials["upload_drive_id"])
}
req = httptest.NewRequest(http.MethodPost, "/admin/api/crawlers", strings.NewReader(`{
"id": "crawler-upload",
"scriptPath": "`+scriptPath+`",
"uploadDriveId": "wopan-target"
}`))
rr = httptest.NewRecorder()
srv.handleUpsertCrawler(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("wopan target status = %d, body = %s", rr.Code, rr.Body.String())
}
got, err = cat.GetDrive(ctx, "crawler-upload")
if err != nil {
t.Fatalf("get crawler after wopan target: %v", err)
}
if got.Credentials["upload_drive_id"] != "wopan-target" {
t.Fatalf("upload_drive_id = %q, want wopan-target", got.Credentials["upload_drive_id"])
}
req = httptest.NewRequest(http.MethodPost, "/admin/api/crawlers", strings.NewReader(`{
"id": "crawler-upload",
"scriptPath": "`+scriptPath+`",
@@ -1605,6 +1624,86 @@ func TestHandleImportCrawlerScriptURLRejectsNonPython(t *testing.T) {
}
}
func TestHandleWopanQRStart(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 != "/QRCode/generate" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"meta": map[string]string{"code": "0000", "message": "ok"},
"result": map[string]string{
"uuid": "uuid-1",
"image": "iVBORw0KGgo=",
},
})
}))
t.Cleanup(upstream.Close)
req := httptest.NewRequest(http.MethodPost, "/admin/api/drives/wopan/qr", nil)
rr := httptest.NewRecorder()
(&AdminServer{WopanQRAPIBaseURL: upstream.URL + "/QRCode"}).handleWopanQRStart(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
var got struct {
UUID string `json:"uuid"`
QRImageDataURL string `json:"qrImageDataUrl"`
}
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.UUID != "uuid-1" || got.QRImageDataURL != "data:image/png;base64,iVBORw0KGgo=" {
t.Fatalf("response = %#v", got)
}
}
func TestHandleWopanQRStatus(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 != "/QRCode/query" {
http.NotFound(w, r)
return
}
if r.URL.Query().Get("uuid") != "uuid-1" {
t.Fatalf("uuid = %q, want uuid-1", r.URL.Query().Get("uuid"))
}
_ = json.NewEncoder(w).Encode(map[string]any{
"meta": map[string]string{"code": "0000", "message": "ok"},
"result": map[string]any{
"state": 3,
"token": "access-1",
"refreshToken": "refresh-1",
},
})
}))
t.Cleanup(upstream.Close)
req := httptest.NewRequest(http.MethodGet, "/admin/api/drives/wopan/qr/uuid-1", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("uuid", "uuid-1")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rr := httptest.NewRecorder()
(&AdminServer{WopanQRAPIBaseURL: upstream.URL + "/QRCode"}).handleWopanQRStatus(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
var got struct {
State int `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 != 3 || got.AccessToken != "access-1" || got.RefreshToken != "refresh-1" {
t.Fatalf("response = %#v", got)
}
}
func TestHandleTestCrawlerScriptRunsImportedScript(t *testing.T) {
if _, err := exec.LookPath("python3"); err != nil {
t.Skip("python3 is required for crawler script dry-run")
+74 -25
View File
@@ -11,6 +11,7 @@ import (
"io"
"math/rand/v2"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
@@ -146,7 +147,7 @@ func (s *Server) RegisterRoutes(r chi.Router, a *auth.Authenticator) {
r.Post("/api/shorts/next", s.handleShortsNext)
// 代理路由同样需要鉴权,防止绕过
r.Get("/p/stream/{driveID}/{fileID}", s.handleStream)
r.Get("/p/stream/{driveID}/*", s.handleStream)
r.Get("/p/upload/{videoID}", s.handleUploadedVideo)
r.Get("/p/spider91/{videoID}", s.handleSpider91Video)
r.Get("/p/preview/{videoID}", s.handlePreview)
@@ -313,7 +314,7 @@ func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleVideoDetail(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
id := routeParam(r, "id")
v, err := s.Catalog.GetVideo(r.Context(), id)
if err != nil {
writeErr(w, http.StatusNotFound, err)
@@ -343,7 +344,7 @@ func (s *Server) handleVideoDetail(w http.ResponseWriter, r *http.Request) {
VideoSrc: s.videoSource(v),
Poster: thumbnailURL(v),
Description: v.Description,
EmbedURL: fmt.Sprintf(`<iframe src="/embed/%s" width="640" height="360" frameborder="0" allowfullscreen></iframe>`, v.ID),
EmbedURL: fmt.Sprintf(`<iframe src="/embed/%s" width="640" height="360" frameborder="0" allowfullscreen></iframe>`, pathSegment(v.ID)),
AuthorProfile: AuthorProfile{
ID: "author-" + v.Author,
Name: v.Author,
@@ -622,7 +623,7 @@ type updateVideoTagsReq struct {
}
func (s *Server) handleUpdateVideoTags(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
id := routeParam(r, "id")
var body updateVideoTagsReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, http.StatusBadRequest, err)
@@ -645,7 +646,7 @@ func (s *Server) handleUpdateVideoTags(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleLike(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
id := routeParam(r, "id")
likes, err := s.Catalog.IncrementLike(r.Context(), id)
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
@@ -657,7 +658,7 @@ func (s *Server) handleLike(w http.ResponseWriter, r *http.Request) {
// handleUnlike 取消点赞:likes - 1(保底 0)。
// 短视频模式中爱心按钮点击切换状态时使用。
func (s *Server) handleUnlike(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
id := routeParam(r, "id")
likes, err := s.Catalog.DecrementLike(r.Context(), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -671,7 +672,7 @@ func (s *Server) handleUnlike(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleView(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
id := routeParam(r, "id")
views, err := s.Catalog.IncrementView(r.Context(), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -685,7 +686,7 @@ func (s *Server) handleView(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleHideVideo(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
id := routeParam(r, "id")
if err := s.Catalog.HideVideo(r.Context(), id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeErr(w, http.StatusNotFound, err)
@@ -802,12 +803,12 @@ func (s *Server) handleUploadVideo(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
driveID := chi.URLParam(r, "driveID")
fileID := chi.URLParam(r, "fileID")
driveID := routeParam(r, "driveID")
fileID := routeWildcardParam(r, "*")
s.Proxy.ServeStream(w, r, driveID, fileID)
}
func (s *Server) handleUploadedVideo(w http.ResponseWriter, r *http.Request) {
videoID := chi.URLParam(r, "videoID")
videoID := routeParam(r, "videoID")
v, err := s.Catalog.GetVideo(r.Context(), videoID)
if err != nil || v.Hidden || v.DriveID != localUploadDriveID {
http.NotFound(w, r)
@@ -831,7 +832,7 @@ func (s *Server) handleUploadedVideo(w http.ResponseWriter, r *http.Request) {
// 路径形如 /p/spider91/<videoID>videoID = "spider91-<driveID>-<sourceID>"。
// 通过 catalog 拿到 file_id"<sourceID>.mp4"),再让 driver 解析到绝对路径并 ServeFile。
func (s *Server) handleSpider91Video(w http.ResponseWriter, r *http.Request) {
videoID := chi.URLParam(r, "videoID")
videoID := routeParam(r, "videoID")
v, err := s.Catalog.GetVideo(r.Context(), videoID)
if err != nil || v.Hidden {
http.NotFound(w, r)
@@ -866,7 +867,7 @@ func (s *Server) handleSpider91Video(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
videoID := chi.URLParam(r, "videoID")
videoID := routeParam(r, "videoID")
v, err := s.Catalog.GetVideo(r.Context(), videoID)
if err != nil {
http.NotFound(w, r)
@@ -891,7 +892,7 @@ func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleThumb(w http.ResponseWriter, r *http.Request) {
videoID := chi.URLParam(r, "videoID")
videoID := routeParam(r, "videoID")
var clean string
for _, path := range mediaasset.ThumbnailPathCandidates(s.LocalDir, videoID) {
candidate := filepath.Clean(path)
@@ -926,7 +927,7 @@ func mapVideo(v *catalog.Video) VideoDTO {
}
return VideoDTO{
ID: v.ID,
Href: "/video/" + v.ID,
Href: "/video/" + pathSegment(v.ID),
Title: v.Title,
Thumbnail: thumbnailURL(v),
PreviewSrc: previewURL(v),
@@ -948,7 +949,7 @@ func mapVideo(v *catalog.Video) VideoDTO {
}
func previewURL(v *catalog.Video) string {
base := "/p/preview/" + v.ID
base := "/p/preview/" + pathSegment(v.ID)
if v.UpdatedAt.IsZero() {
return base
}
@@ -956,9 +957,12 @@ func previewURL(v *catalog.Video) string {
}
func thumbnailURL(v *catalog.Video) string {
base := "/p/thumb/" + v.ID
base := "/p/thumb/" + pathSegment(v.ID)
if v.ThumbnailURL != "" {
base = v.ThumbnailURL
if thumbnailURLMatchesVideoID(base, v.ID) {
base = "/p/thumb/" + pathSegment(v.ID)
}
}
if !strings.HasPrefix(base, "/p/thumb/") || v.UpdatedAt.IsZero() {
return base
@@ -968,23 +972,68 @@ func thumbnailURL(v *catalog.Video) string {
func (s *Server) videoSource(v *catalog.Video) string {
if v.DriveID == localUploadDriveID {
return "/p/upload/" + v.ID
return "/p/upload/" + pathSegment(v.ID)
}
if s.Proxy != nil && s.Proxy.Registry != nil {
if d, ok := s.Proxy.Registry.Get(v.DriveID); ok && d.Kind() == spider91.Kind {
return "/p/spider91/" + v.ID
if d, ok := s.Proxy.Registry.Get(v.DriveID); ok {
switch d.Kind() {
case spider91.Kind:
return "/p/spider91/" + pathSegment(v.ID)
}
}
}
return fmt.Sprintf("/p/stream/%s/%s", v.DriveID, v.FileID)
return fmt.Sprintf("/p/stream/%s/%s", pathSegment(v.DriveID), pathSegment(v.FileID))
}
// videoSource 兼容旧调用点,没有 server context 时按之前逻辑回退到 /p/stream。
// 内部新增的代码请使用 (*Server).videoSource。
func videoSource(v *catalog.Video) string {
if v.DriveID == localUploadDriveID {
return "/p/upload/" + v.ID
return "/p/upload/" + pathSegment(v.ID)
}
return fmt.Sprintf("/p/stream/%s/%s", v.DriveID, v.FileID)
return fmt.Sprintf("/p/stream/%s/%s", pathSegment(v.DriveID), pathSegment(v.FileID))
}
func pathSegment(value string) string {
return url.PathEscape(value)
}
func routeParam(r *http.Request, key string) string {
value := chi.URLParam(r, key)
if value == "" {
return ""
}
if decoded, err := url.PathUnescape(value); err == nil {
return decoded
}
return value
}
func routeWildcardParam(r *http.Request, key string) string {
value := chi.URLParam(r, key)
if value == "" {
return ""
}
value = strings.TrimPrefix(value, "/")
if decoded, err := url.PathUnescape(value); err == nil {
return decoded
}
return value
}
func thumbnailURLMatchesVideoID(value, videoID string) bool {
if !strings.HasPrefix(value, "/p/thumb/") {
return false
}
tail := strings.TrimPrefix(value, "/p/thumb/")
if idx := strings.IndexByte(tail, '?'); idx >= 0 {
tail = tail[:idx]
}
if tail == videoID {
return true
}
decoded, err := url.PathUnescape(tail)
return err == nil && decoded == videoID
}
func driveKindLabel(kind string) string {
@@ -994,11 +1043,11 @@ func driveKindLabel(kind string) string {
case "p115":
return "115 网盘"
case "p123":
return "123盘"
return "123盘"
case "pikpak":
return "PikPak"
case "wopan":
return "联通盘"
return "联通盘"
case "onedrive":
return "OneDrive"
case "googledrive":
+138
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
@@ -17,6 +18,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/video-site/backend/internal/catalog"
"github.com/video-site/backend/internal/drives"
"github.com/video-site/backend/internal/mediaasset"
"github.com/video-site/backend/internal/proxy"
)
@@ -66,6 +68,68 @@ func TestVideoSourceKeepsDirectStreamForMp4(t *testing.T) {
}
}
func TestVideoURLsEscapePathSegments(t *testing.T) {
updated := time.UnixMilli(1778863000123)
v := &catalog.Video{
ID: "wopan-drive-fid/with space",
DriveID: "drive-1",
FileID: "fid/with space",
Title: "Video",
UpdatedAt: updated,
}
dto := mapVideo(v)
if dto.Href != "/video/wopan-drive-fid%2Fwith%20space" {
t.Fatalf("href = %q, want escaped video id", dto.Href)
}
if dto.PreviewSrc != "/p/preview/wopan-drive-fid%2Fwith%20space?v=1778863000123" {
t.Fatalf("preview = %q, want escaped video id", dto.PreviewSrc)
}
if dto.Thumbnail != "/p/thumb/wopan-drive-fid%2Fwith%20space?v=1778863000123" {
t.Fatalf("thumbnail = %q, want escaped video id", dto.Thumbnail)
}
if got := videoSource(v); got != "/p/stream/drive-1/fid%2Fwith%20space" {
t.Fatalf("video source = %q, want escaped file id", got)
}
}
func TestThumbnailURLRewritesStoredLocalURLForUnsafeVideoID(t *testing.T) {
got := thumbnailURL(&catalog.Video{
ID: "wopan-drive-fid/with space",
ThumbnailURL: "/p/thumb/wopan-drive-fid/with space",
UpdatedAt: time.UnixMilli(1778863000123),
})
if got != "/p/thumb/wopan-drive-fid%2Fwith%20space?v=1778863000123" {
t.Fatalf("thumbnail URL = %q, want escaped local URL", got)
}
}
func TestHandleStreamDecodesEscapedWildcardFileID(t *testing.T) {
local := filepath.Join(t.TempDir(), "video.mp4")
if err := os.WriteFile(local, []byte("ok"), 0o644); err != nil {
t.Fatalf("write local video: %v", err)
}
drv := &apiStreamFakeDrive{localPath: local}
reg := proxy.NewRegistry()
reg.Set("drive-1", drv)
srv := &Server{Proxy: proxy.New(reg)}
router := chi.NewRouter()
router.Get("/p/stream/{driveID}/*", srv.handleStream)
req := httptest.NewRequest(http.MethodGet, "/p/stream/drive-1/fid%2Fwith%20space", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if drv.fileID != "fid/with space" {
t.Fatalf("fileID = %q, want decoded original", drv.fileID)
}
}
func TestVideoSourceUsesLocalUploadRoute(t *testing.T) {
v := &catalog.Video{
ID: "video-1",
@@ -100,6 +164,49 @@ func TestPreviewURLFallsBackWithoutUpdatedAt(t *testing.T) {
}
}
func TestHandleVideoDetailDecodesEscapedVideoID(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
if err != nil {
t.Fatalf("open catalog: %v", err)
}
t.Cleanup(func() {
if err := cat.Close(); err != nil {
t.Fatalf("close catalog: %v", err)
}
})
now := time.Now()
if err := cat.UpsertVideo(ctx, &catalog.Video{
ID: "wopan-drive-fid/with space",
DriveID: "drive-1",
FileID: "fid/with space",
Title: "Video",
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("seed video: %v", err)
}
router := chi.NewRouter()
router.Get("/api/video/{id}", (&Server{Catalog: cat}).handleVideoDetail)
req := httptest.NewRequest(http.MethodGet, "/api/video/wopan-drive-fid%2Fwith%20space", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
var got VideoDetailDTO
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.ID != "wopan-drive-fid/with space" {
t.Fatalf("id = %q, want original video id", got.ID)
}
}
func TestThumbnailURLVersionsLocalGeneratedThumbnails(t *testing.T) {
got := thumbnailURL(&catalog.Video{
ID: "video-1",
@@ -1084,6 +1191,37 @@ func sameStringSet(a, b []string) bool {
return true
}
type apiStreamFakeDrive struct {
localPath string
fileID string
}
func (d *apiStreamFakeDrive) Kind() string { return "fake" }
func (d *apiStreamFakeDrive) ID() string { return "drive-1" }
func (d *apiStreamFakeDrive) Init(context.Context) error {
return nil
}
func (d *apiStreamFakeDrive) List(context.Context, string) ([]drives.Entry, error) {
return nil, drives.ErrNotSupported
}
func (d *apiStreamFakeDrive) Stat(context.Context, string) (*drives.Entry, error) {
return nil, drives.ErrNotSupported
}
func (d *apiStreamFakeDrive) StreamURL(_ context.Context, fileID string) (*drives.StreamLink, error) {
d.fileID = fileID
return &drives.StreamLink{
URL: d.localPath,
Expires: time.Now().Add(time.Minute),
}, nil
}
func (d *apiStreamFakeDrive) Upload(context.Context, string, string, io.Reader, int64) (string, error) {
return "", drives.ErrNotSupported
}
func (d *apiStreamFakeDrive) EnsureDir(context.Context, string) (string, error) {
return "", drives.ErrNotSupported
}
func (d *apiStreamFakeDrive) RootID() string { return "root" }
func requestWithVideoID(method, target, videoID string, body *strings.Reader) *http.Request {
return requestWithRouteParam(method, target, "id", videoID, body)
}
+1 -1
View File
@@ -297,7 +297,7 @@ UPDATE videos
}
func (c *Catalog) clearRemoteP123ThumbnailsOnce(ctx context.Context) error {
// 123盘列表返回的缩略图尺寸和稳定性都不适合作为站内封面;清空历史写入的
// 123盘列表返回的缩略图尺寸和稳定性都不适合作为站内封面;清空历史写入的
// 远程 URL,让封面 worker 统一从视频直链抽帧生成本地 /p/thumb/<id>。
const markerKey = "videos.p123.remote_thumbnails_cleared"
marker, err := c.GetSetting(ctx, markerKey, "")
+15
View File
@@ -46,6 +46,21 @@ type Remover interface {
Remove(ctx context.Context, fileID string) error
}
// SourceFile carries the catalog metadata available when an administrator
// requests deletion of the original source file.
type SourceFile struct {
FileID string
ParentID string
Name string
Size int64
}
// SourceRemover is an optional, richer removal capability for providers whose
// playback ID is not the same ID required by their delete API.
type SourceRemover interface {
RemoveSource(ctx context.Context, source SourceFile) error
}
type Entry struct {
ID string
Name string
+6 -6
View File
@@ -260,8 +260,8 @@ func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader,
// UploadResult 是 UploadAndReportHash 的返回值。
//
// FileID 是 123盘分配的新文件 ID;Hash 是本次上传的 MD5 HEX(小写),
// 与 123盘列表返回的 Etag 一致;Size 是实际上传字节数。
// FileID 是 123盘分配的新文件 ID;Hash 是本次上传的 MD5 HEX(小写),
// 与 123盘列表返回的 Etag 一致;Size 是实际上传字节数。
type UploadResult struct {
FileID string
Hash string
@@ -270,7 +270,7 @@ type UploadResult struct {
// UploadAndReportHash 把 r 上传到 parentID 目录下的指定文件名,返回新文件元数据。
//
// 123盘 Web 上传协议需要先计算文件 MD5 作为 etag 申请 upload_request。
// 123盘 Web 上传协议需要先计算文件 MD5 作为 etag 申请 upload_request。
// 命中 Reuse 时服务端已经秒传;否则用返回的 S3 预签名 URL 分片 PUT,最后
// 调 upload_complete/v2 完成。
func (d *Driver) UploadAndReportHash(ctx context.Context, parentID, name string, r io.Reader, size int64) (UploadResult, error) {
@@ -523,7 +523,7 @@ func (d *Driver) cacheUploadedFile(fileID, parentID, name, md5Hex string, size i
}, parentID)
}
// Rename 调用 123盘 Web API 把指定 fileID 重命名为 newName。
// Rename 调用 123盘 Web API 把指定 fileID 重命名为 newName。
func (d *Driver) Rename(ctx context.Context, fileID, newName string) error {
fileID = strings.TrimSpace(fileID)
if fileID == "" {
@@ -610,7 +610,7 @@ func (d *Driver) makeDir(ctx context.Context, parentID, name string) (string, er
if resp.Data.FileID != 0 {
return strconv.FormatInt(resp.Data.FileID, 10), nil
}
// 123盘创建目录的返回字段不稳定;创建成功但没回 fileId 时回读父目录确认。
// 123盘创建目录的返回字段不稳定;创建成功但没回 fileId 时回读父目录确认。
childID, err := d.findChildDir(ctx, parentID, name)
if err != nil {
return "", err
@@ -1041,7 +1041,7 @@ func loginError(message string) error {
message = strings.TrimSpace(message)
if strings.Contains(message, "境外登录风险") ||
(strings.Contains(message, "短信验证码") && strings.Contains(message, "微信")) {
return errors.New("123pan login: 账号密码登录被 123盘风控拦截,请在浏览器完成短信/微信验证后复制 access_token,并在后台编辑该 123盘时只填写 access_token")
return errors.New("123pan login: 账号密码登录被 123盘风控拦截,请在浏览器完成短信/微信验证后复制 access_token,并在后台编辑该 123盘时只填写 access_token")
}
if message == "" {
message = "login failed"
+1 -1
View File
@@ -278,7 +278,7 @@ func qrScanPlatformText(platform int) string {
case 4:
return "微信"
case 7:
return "123盘 App"
return "123盘 App"
default:
return ""
}
+1 -1
View File
@@ -150,7 +150,7 @@ func TestQRCodePollUsesAppToken(t *testing.T) {
if wxCodeRequested {
t.Fatalf("wx_code should not be called when app token is already returned")
}
if got.AccessToken != "app-token" || got.PlatformText != "123盘 App" {
if got.AccessToken != "app-token" || got.PlatformText != "123盘 App" {
t.Fatalf("status = %#v, want app token", got)
}
}
+337 -13
View File
@@ -2,12 +2,15 @@ package wopan
import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path"
"strings"
"sync"
"time"
sdk "github.com/OpenListTeam/wopan-sdk-go"
@@ -15,7 +18,7 @@ import (
"github.com/video-site/backend/internal/drives"
)
// Driver 封装联通
// Driver 封装联通
type Driver struct {
id string
rootID string
@@ -24,6 +27,14 @@ type Driver struct {
refreshToken string
client *sdk.WoClient
onTokenUpdate func(access, refresh string)
listMu sync.Mutex
lastListAt time.Time
listInterval time.Duration
listCooldown time.Duration
fileIDMu sync.RWMutex
fidToID map[string]string
}
type Config struct {
@@ -48,6 +59,9 @@ func New(c Config) *Driver {
accessToken: c.AccessToken,
refreshToken: c.RefreshToken,
onTokenUpdate: c.OnTokenUpdate,
listInterval: 800 * time.Millisecond,
listCooldown: 5 * time.Minute,
fidToID: make(map[string]string),
}
}
@@ -79,15 +93,41 @@ func (d *Driver) spaceType() string {
}
func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) {
d.listMu.Lock()
defer d.listMu.Unlock()
var result []drives.Entry
pageNum := 0
pageSize := 100
for {
data, err := d.client.QueryAllFiles(d.spaceType(), dirID, pageNum, pageSize, 0, d.familyID)
if err != nil {
return nil, fmt.Errorf("wopan list: %w", err)
var data *sdk.QueryAllFilesData
for attempt := 0; ; attempt++ {
if err := d.waitForListSlotLocked(ctx); err != nil {
return nil, err
}
var err error
data, err = d.client.QueryAllFiles(d.spaceType(), dirID, pageNum, pageSize, 0, d.familyID, func(req *resty.Request) {
req.SetContext(ctx)
})
if err == nil {
break
}
err = wopanRequestError("list", err)
wait, ok := drives.RateLimitRetryAfter(err)
if !ok {
return nil, err
}
if wait <= 0 {
wait = d.listCooldown
}
log.Printf("[wopan] list cooling down drive=%s dir=%s page=%d cooldown=%s attempt=%d err=%v",
d.id, dirID, pageNum, wait, attempt+1, err)
if err := sleepContext(ctx, wait); err != nil {
return nil, err
}
}
for _, f := range data.Files {
d.rememberFileID(f)
result = append(result, fileToEntry(f, dirID))
}
if len(data.Files) < pageSize {
@@ -104,9 +144,11 @@ func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error)
}
func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) {
data, err := d.client.GetDownloadUrlV2([]string{fileID})
data, err := d.client.GetDownloadUrlV2([]string{fileID}, func(req *resty.Request) {
req.SetContext(ctx)
})
if err != nil {
return nil, fmt.Errorf("wopan download url: %w", err)
return nil, wopanRequestError("download url", err)
}
if len(data.List) == 0 {
return nil, fmt.Errorf("wopan download url: empty response")
@@ -143,9 +185,44 @@ func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader,
if err != nil {
return "", fmt.Errorf("wopan upload: %w", err)
}
if fid != "" {
if objectID, err := d.findDeleteFileIDInParent(ctx, parentID, drives.SourceFile{
FileID: fid,
Name: name,
Size: size,
}); err == nil {
d.rememberFIDMapping(fid, objectID)
} else {
log.Printf("[wopan] upload drive=%s parent=%s fid=%s resolve object id: %v", d.id, parentID, fid, err)
}
}
return fid, nil
}
func (d *Driver) Rename(ctx context.Context, fileID, newName string) error {
if d.client == nil {
return fmt.Errorf("wopan rename: driver not initialized")
}
fileID = strings.TrimSpace(fileID)
if fileID == "" {
return fmt.Errorf("wopan rename: empty file id")
}
newName = strings.TrimSpace(newName)
if newName == "" {
return fmt.Errorf("wopan rename: empty new name")
}
renameID := fileID
if cached := d.cachedDeleteFileID(fileID); cached != "" {
renameID = cached
}
if err := d.client.RenameFileOrDirectory(d.spaceType(), 1, renameID, newName, d.familyID, func(req *resty.Request) {
req.SetContext(ctx)
}); err != nil {
return wopanRequestError("rename", err)
}
return nil
}
func (d *Driver) Remove(ctx context.Context, fileID string) error {
if d.client == nil {
return fmt.Errorf("wopan remove: driver not initialized")
@@ -154,14 +231,105 @@ func (d *Driver) Remove(ctx context.Context, fileID string) error {
if fileID == "" {
return fmt.Errorf("wopan remove: empty file id")
}
if err := d.client.DeleteFile(d.spaceType(), nil, []string{fileID}, func(req *resty.Request) {
req.SetContext(ctx)
}); err != nil {
deleteID := fileID
if cached := d.cachedDeleteFileID(fileID); cached != "" {
deleteID = cached
}
if err := d.deleteFileByObjectID(ctx, deleteID); err != nil {
return fmt.Errorf("wopan remove: %w", err)
}
return nil
}
func (d *Driver) RemoveSource(ctx context.Context, source drives.SourceFile) error {
if d.client == nil {
return fmt.Errorf("wopan remove: driver not initialized")
}
fileID := strings.TrimSpace(source.FileID)
if fileID == "" {
return fmt.Errorf("wopan remove: empty file id")
}
deleteID, err := d.resolveDeleteFileID(ctx, source)
if err != nil {
return err
}
if err := d.deleteFileByObjectID(ctx, deleteID); err != nil {
return fmt.Errorf("wopan remove: %w", err)
}
return nil
}
func (d *Driver) deleteFileByObjectID(ctx context.Context, fileID string) error {
if err := d.client.DeleteFile(d.spaceType(), nil, []string{fileID}, func(req *resty.Request) {
req.SetContext(ctx)
}); err != nil {
return err
}
return nil
}
func (d *Driver) resolveDeleteFileID(ctx context.Context, source drives.SourceFile) (string, error) {
fileID := strings.TrimSpace(source.FileID)
if fileID == "" {
return "", fmt.Errorf("wopan remove: empty file id")
}
if cached := d.cachedDeleteFileID(fileID); cached != "" {
return cached, nil
}
parentID := strings.TrimSpace(source.ParentID)
if parentID == "" {
return fileID, nil
}
return d.findDeleteFileIDInParent(ctx, parentID, source)
}
func (d *Driver) findDeleteFileIDInParent(ctx context.Context, parentID string, source drives.SourceFile) (string, error) {
d.listMu.Lock()
defer d.listMu.Unlock()
pageNum := 0
pageSize := 100
for {
var data *sdk.QueryAllFilesData
for attempt := 0; ; attempt++ {
if err := d.waitForListSlotLocked(ctx); err != nil {
return "", err
}
var err error
data, err = d.client.QueryAllFiles(d.spaceType(), parentID, pageNum, pageSize, 0, d.familyID, func(req *resty.Request) {
req.SetContext(ctx)
})
if err == nil {
break
}
err = wopanRequestError("resolve delete id", err)
wait, ok := drives.RateLimitRetryAfter(err)
if !ok {
return "", err
}
if wait <= 0 {
wait = d.listCooldown
}
log.Printf("[wopan] resolve delete id cooling down drive=%s parent=%s page=%d cooldown=%s attempt=%d err=%v",
d.id, parentID, pageNum, wait, attempt+1, err)
if err := sleepContext(ctx, wait); err != nil {
return "", err
}
}
for _, f := range data.Files {
d.rememberFileID(f)
if id, ok := deleteFileIDFromWopanFile(f, source); ok {
return id, nil
}
}
if len(data.Files) < pageSize {
break
}
pageNum++
}
return "", fmt.Errorf("wopan remove: source file %q not found under parent %q", source.FileID, parentID)
}
func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) {
parts := splitPath(pathFromRoot)
currentID := d.rootID
@@ -171,9 +339,11 @@ func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, er
return "", err
}
if childID == "" {
resp, err := d.client.CreateDirectory(d.spaceType(), currentID, name, d.familyID)
resp, err := d.client.CreateDirectory(d.spaceType(), currentID, name, d.familyID, func(req *resty.Request) {
req.SetContext(ctx)
})
if err != nil {
return "", fmt.Errorf("wopan mkdir %s: %w", name, err)
return "", wopanRequestError("mkdir "+name, err)
}
childID = resp.Id
}
@@ -207,9 +377,12 @@ func fileToEntry(f *sdk.File, parentID string) drives.Entry {
mod, _ := time.Parse("2006-01-02 15:04:05", f.CreateTime)
name := f.Name
isDir := f.Type == 0
id := f.Fid
id := f.Id
if !isDir && f.Fid != "" {
id = f.Fid
}
if id == "" {
id = f.Id
id = f.Fid
}
if isDir && !strings.HasSuffix(name, "/") {
// 不改 name,只标志
@@ -225,6 +398,156 @@ func fileToEntry(f *sdk.File, parentID string) drives.Entry {
}
}
func (d *Driver) rememberFileID(f *sdk.File) {
if f == nil || f.Type == 0 {
return
}
objectID := strings.TrimSpace(f.Id)
fid := strings.TrimSpace(f.Fid)
if objectID == "" {
return
}
d.fileIDMu.Lock()
if d.fidToID == nil {
d.fidToID = make(map[string]string)
}
d.fidToID[objectID] = objectID
if fid != "" {
d.fidToID[fid] = objectID
}
d.fileIDMu.Unlock()
}
func (d *Driver) rememberFIDMapping(fid, objectID string) {
fid = strings.TrimSpace(fid)
objectID = strings.TrimSpace(objectID)
if fid == "" || objectID == "" {
return
}
d.fileIDMu.Lock()
if d.fidToID == nil {
d.fidToID = make(map[string]string)
}
d.fidToID[fid] = objectID
d.fidToID[objectID] = objectID
d.fileIDMu.Unlock()
}
func (d *Driver) cachedDeleteFileID(fileID string) string {
fileID = strings.TrimSpace(fileID)
if fileID == "" {
return ""
}
d.fileIDMu.RLock()
defer d.fileIDMu.RUnlock()
return strings.TrimSpace(d.fidToID[fileID])
}
func deleteFileIDFromWopanFile(f *sdk.File, source drives.SourceFile) (string, bool) {
if f == nil || f.Type == 0 {
return "", false
}
sourceID := strings.TrimSpace(source.FileID)
if sourceID == "" {
return "", false
}
objectID := strings.TrimSpace(f.Id)
fid := strings.TrimSpace(f.Fid)
if objectID == "" {
return "", false
}
if sourceID != objectID && sourceID != fid {
return "", false
}
return objectID, true
}
func (d *Driver) waitForListSlotLocked(ctx context.Context) error {
if d.listInterval <= 0 || d.lastListAt.IsZero() {
d.lastListAt = time.Now()
return ctx.Err()
}
next := d.lastListAt.Add(d.listInterval)
now := time.Now()
if now.Before(next) {
if err := sleepContext(ctx, next.Sub(now)); err != nil {
return err
}
}
d.lastListAt = time.Now()
return ctx.Err()
}
func sleepContext(ctx context.Context, d time.Duration) error {
if d <= 0 {
return ctx.Err()
}
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func wopanRequestError(step string, err error) error {
if err == nil {
return nil
}
wrapped := fmt.Errorf("wopan %s: %w", step, err)
if isWopanRateLimitError(err) {
return &drives.RateLimitError{
Provider: "wopan",
Err: wrapped,
}
}
return wrapped
}
func isWopanRateLimitError(err error) bool {
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
text := strings.ToLower(strings.TrimSpace(err.Error()))
if text == "" {
return false
}
return strings.Contains(text, "status: 429") ||
strings.Contains(text, "status 429") ||
strings.Contains(text, "http status: 429") ||
strings.Contains(text, "status: 500") ||
strings.Contains(text, "status 500") ||
strings.Contains(text, "status: 502") ||
strings.Contains(text, "status 502") ||
strings.Contains(text, "status: 503") ||
strings.Contains(text, "status 503") ||
strings.Contains(text, "status: 504") ||
strings.Contains(text, "status 504") ||
strings.Contains(text, "status: 509") ||
strings.Contains(text, "status 509") ||
strings.Contains(text, "too many request") ||
strings.Contains(text, "too many requests") ||
strings.Contains(text, "rate limit") ||
strings.Contains(text, "rate-limit") ||
strings.Contains(text, "throttl") ||
strings.Contains(text, "blocked") ||
strings.Contains(text, "request has been blocked") ||
strings.Contains(text, "操作频繁") ||
strings.Contains(text, "请求频繁") ||
strings.Contains(text, "请求太频繁") ||
strings.Contains(text, "请求过于频繁") ||
strings.Contains(text, "频率限制") ||
strings.Contains(text, "请求次数过多") ||
strings.Contains(text, "系统繁忙") ||
strings.Contains(text, "服务繁忙") ||
strings.Contains(text, "稍后再试") ||
strings.Contains(text, "稍后重试") ||
strings.Contains(text, "访问被阻断") ||
strings.Contains(text, "风控")
}
func guessMime(name string) string {
ext := strings.ToLower(path.Ext(name))
switch ext {
@@ -247,3 +570,4 @@ func guessMime(name string) string {
// 确保实现接口
var _ drives.Drive = (*Driver)(nil)
var _ drives.Remover = (*Driver)(nil)
var _ drives.SourceRemover = (*Driver)(nil)
@@ -0,0 +1,113 @@
package wopan
import (
"errors"
"testing"
sdk "github.com/OpenListTeam/wopan-sdk-go"
"github.com/video-site/backend/internal/drives"
)
func TestFileToEntryUsesDirectoryIDAndFileFID(t *testing.T) {
dir := fileToEntry(&sdk.File{
Id: "dir-object-id",
Fid: "0",
Type: 0,
Name: "collection",
}, "root")
if !dir.IsDir {
t.Fatal("directory entry IsDir = false")
}
if dir.ID != "dir-object-id" {
t.Fatalf("directory id = %q, want object id", dir.ID)
}
file := fileToEntry(&sdk.File{
Id: "file-object-id",
Fid: "fid/with/slash",
Type: 1,
Name: "clip.mp4",
Size: 123,
}, "dir-object-id")
if file.IsDir {
t.Fatal("file entry IsDir = true")
}
if file.ID != "fid/with/slash" {
t.Fatalf("file id = %q, want fid for download", file.ID)
}
}
func TestDeleteFileIDFromWopanFileUsesObjectIDForFID(t *testing.T) {
got, ok := deleteFileIDFromWopanFile(&sdk.File{
Id: "file-object-id",
Fid: "fid/with/slash",
Type: 1,
Name: "clip.mp4",
Size: 123,
}, drives.SourceFile{
FileID: "fid/with/slash",
Name: "clip.mp4",
Size: 123,
})
if !ok {
t.Fatal("delete file id not resolved")
}
if got != "file-object-id" {
t.Fatalf("delete file id = %q, want object id", got)
}
}
func TestDeleteFileIDFromWopanFileAcceptsObjectID(t *testing.T) {
got, ok := deleteFileIDFromWopanFile(&sdk.File{
Id: "file-object-id",
Fid: "fid-1",
Type: 1,
Name: "clip.mp4",
Size: 123,
}, drives.SourceFile{
FileID: "file-object-id",
Name: "clip.mp4",
Size: 123,
})
if !ok {
t.Fatal("delete file id not resolved")
}
if got != "file-object-id" {
t.Fatalf("delete file id = %q, want object id", got)
}
}
func TestDeleteFileIDFromWopanFileRejectsIDMismatch(t *testing.T) {
if _, ok := deleteFileIDFromWopanFile(&sdk.File{
Id: "file-object-id",
Fid: "fid-1",
Type: 1,
Name: "clip.mp4",
Size: 123,
}, drives.SourceFile{
FileID: "other-fid",
Name: "clip.mp4",
Size: 123,
}); ok {
t.Fatal("delete file id resolved despite id mismatch")
}
}
func TestWopanRequestErrorWrapsRateLimit(t *testing.T) {
err := wopanRequestError("list", errors.New("request failed with status: 429 Too Many Requests"))
var rateLimit *drives.RateLimitError
if !errors.As(err, &rateLimit) {
t.Fatalf("error = %T %[1]v, want RateLimitError", err)
}
if rateLimit.Provider != "wopan" {
t.Fatalf("provider = %q, want wopan", rateLimit.Provider)
}
}
func TestWopanRequestErrorLeavesNormalErrors(t *testing.T) {
err := wopanRequestError("download url", errors.New("invalid access token"))
var rateLimit *drives.RateLimitError
if errors.As(err, &rateLimit) {
t.Fatalf("error = %T %[1]v, want non-rate-limit error", err)
}
}
+349
View File
@@ -0,0 +1,349 @@
package wopan
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/go-resty/resty/v2"
)
const (
defaultQRCodeAPIBase = "https://panservice.mail.wo.cn/wohome/open/v1/QRCode"
defaultQRCodeClient = "1001000021"
)
type QRConfig struct {
APIBaseURL string
HTTPClient *http.Client
Now func() time.Time
}
type QRClient struct {
apiBase string
client *resty.Client
now func() time.Time
}
type QRCodeSession struct {
UUID string `json:"uuid"`
QRImageDataURL string `json:"qrImageDataUrl"`
ExpiresAt string `json:"expiresAt,omitempty"`
}
type QRCodeStatus struct {
State int `json:"state"`
StatusText string `json:"statusText"`
AccessToken string `json:"accessToken,omitempty"`
RefreshToken string `json:"refreshToken,omitempty"`
FamilyID string `json:"familyID,omitempty"`
}
func NewQRClient(c QRConfig) *QRClient {
apiBase := strings.TrimRight(strings.TrimSpace(c.APIBaseURL), "/")
if apiBase == "" {
apiBase = defaultQRCodeAPIBase
}
httpClient := c.HTTPClient
if httpClient == nil {
httpClient = &http.Client{Timeout: 20 * time.Second}
}
now := c.Now
if now == nil {
now = time.Now
}
return &QRClient{
apiBase: apiBase,
client: resty.NewWithClient(httpClient).
SetTimeout(20*time.Second).
SetHeader("Accept", "application/json"),
now: now,
}
}
func (c *QRClient) Generate(ctx context.Context) (QRCodeSession, error) {
var envelope qrEnvelope
res, err := c.request(ctx).
SetResult(&envelope).
Get(c.apiBase + "/generate")
if err != nil {
return QRCodeSession{}, err
}
if res.IsError() {
return QRCodeSession{}, qrAPIError(envelope.message(), res.StatusCode())
}
var result qrGenerateResult
if err := decodeResult(envelope.Result, &result); err != nil {
return QRCodeSession{}, err
}
result.UUID = strings.TrimSpace(result.UUID)
result.Image = strings.TrimSpace(result.Image)
if result.UUID == "" {
return QRCodeSession{}, errors.New("wopan qr: empty uuid")
}
if result.Image == "" {
return QRCodeSession{}, errors.New("wopan qr: empty image")
}
return QRCodeSession{
UUID: result.UUID,
QRImageDataURL: qrImageDataURL(result.Image),
ExpiresAt: c.now().Add(60 * time.Second).Format(time.RFC3339),
}, nil
}
func (c *QRClient) Poll(ctx context.Context, uuid string) (QRCodeStatus, error) {
uuid = strings.TrimSpace(uuid)
if uuid == "" {
return QRCodeStatus{}, errors.New("uuid is required")
}
var envelope qrEnvelope
res, err := c.request(ctx).
SetQueryParam("uuid", uuid).
SetResult(&envelope).
Get(c.apiBase + "/query")
if err != nil {
return QRCodeStatus{}, err
}
if res.IsError() {
return QRCodeStatus{}, qrAPIError(envelope.message(), res.StatusCode())
}
result, err := decodeResultMap(envelope.Result)
if err != nil {
return QRCodeStatus{}, err
}
state := intValue(result["state"])
status := QRCodeStatus{
State: state,
StatusText: qrStateText(state),
}
if state != 3 {
return status, nil
}
status.AccessToken = findStringByKeys(result, "access_token", "accessToken", "token", "tokenValue")
status.RefreshToken = findStringByKeys(result, "refresh_token", "refreshToken")
status.FamilyID = findStringByKeys(result, "family_id", "familyId", "familyID", "defaultFamilyId", "defaultHomeId", "homeId")
if status.AccessToken == "" || status.RefreshToken == "" {
missing := make([]string, 0, 2)
if status.AccessToken == "" {
missing = append(missing, "access_token")
}
if status.RefreshToken == "" {
missing = append(missing, "refresh_token")
}
return QRCodeStatus{}, fmt.Errorf("wopan qr: login succeeded but missing %s; available keys: %s",
strings.Join(missing, ", "), strings.Join(collectJSONKeys(result), ", "))
}
return status, nil
}
func (c *QRClient) request(ctx context.Context) *resty.Request {
return c.client.R().
SetContext(ctx).
SetHeaders(map[string]string{
"client-id": defaultQRCodeClient,
"x-yp-client-id": defaultQRCodeClient,
"Accept": "application/json",
"Accept-Language": "zh-CN,zh;q=0.9",
})
}
type qrEnvelope struct {
Meta qrMeta `json:"meta"`
Result json.RawMessage `json:"result"`
Code any `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Msg string `json:"msg,omitempty"`
}
type qrMeta struct {
Code any `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Msg string `json:"msg,omitempty"`
}
type qrGenerateResult struct {
UUID string `json:"uuid"`
Image string `json:"image"`
}
func (e qrEnvelope) message() string {
for _, s := range []string{e.Message, e.Msg, e.Meta.Message, e.Meta.Msg} {
if strings.TrimSpace(s) != "" {
return strings.TrimSpace(s)
}
}
return ""
}
func decodeResult(raw json.RawMessage, dst any) error {
if len(raw) == 0 || string(raw) == "null" {
return errors.New("wopan qr: empty result")
}
if err := json.Unmarshal(raw, dst); err != nil {
return fmt.Errorf("wopan qr: decode result: %w", err)
}
return nil
}
func decodeResultMap(raw json.RawMessage) (map[string]any, error) {
var result map[string]any
if err := decodeResult(raw, &result); err != nil {
return nil, err
}
if result == nil {
return nil, errors.New("wopan qr: empty result")
}
return result, nil
}
func qrImageDataURL(image string) string {
image = strings.TrimSpace(image)
if strings.HasPrefix(strings.ToLower(image), "data:image/") {
return image
}
return "data:image/png;base64," + image
}
func qrAPIError(message string, httpStatus int) error {
message = strings.TrimSpace(message)
if message == "" {
message = fmt.Sprintf("HTTP %d", httpStatus)
}
return errors.New(message)
}
func qrStateText(state int) string {
switch state {
case 1:
return "等待扫码"
case 2:
return "已扫码,请在联通网盘 App 确认"
case 3:
return "登录成功"
case 4:
return "二维码已过期"
default:
return "未知状态"
}
}
func intValue(v any) int {
switch x := v.(type) {
case int:
return x
case int64:
return int(x)
case float64:
return int(x)
case json.Number:
n, _ := x.Int64()
return int(n)
case string:
n, _ := strconv.Atoi(strings.TrimSpace(x))
return n
default:
return 0
}
}
func findStringByKeys(v any, keys ...string) string {
targets := make(map[string]struct{}, len(keys))
for _, key := range keys {
targets[normalizeJSONKey(key)] = struct{}{}
}
return findStringByNormalizedKeys(v, targets)
}
func findStringByNormalizedKeys(v any, targets map[string]struct{}) string {
switch x := v.(type) {
case map[string]any:
for key, value := range x {
if _, ok := targets[normalizeJSONKey(key)]; ok {
if s := stringValue(value); s != "" {
return s
}
}
}
for _, value := range x {
if s := findStringByNormalizedKeys(value, targets); s != "" {
return s
}
}
case []any:
for _, value := range x {
if s := findStringByNormalizedKeys(value, targets); s != "" {
return s
}
}
}
return ""
}
func stringValue(v any) string {
switch x := v.(type) {
case string:
return strings.TrimSpace(x)
case int:
return strconv.Itoa(x)
case int64:
return strconv.FormatInt(x, 10)
case float64:
if x == float64(int64(x)) {
return strconv.FormatInt(int64(x), 10)
}
return strconv.FormatFloat(x, 'f', -1, 64)
case json.Number:
return strings.TrimSpace(x.String())
default:
return ""
}
}
func normalizeJSONKey(key string) string {
key = strings.ToLower(strings.TrimSpace(key))
key = strings.ReplaceAll(key, "_", "")
key = strings.ReplaceAll(key, "-", "")
key = strings.ReplaceAll(key, " ", "")
return key
}
func collectJSONKeys(v any) []string {
seen := map[string]struct{}{}
var walk func(any)
walk = func(value any) {
switch x := value.(type) {
case map[string]any:
for key, child := range x {
if strings.TrimSpace(key) != "" {
seen[key] = struct{}{}
}
walk(child)
}
case []any:
for _, child := range x {
walk(child)
}
}
}
walk(v)
keys := make([]string, 0, len(seen))
for key := range seen {
keys = append(keys, key)
}
sort.Strings(keys)
if len(keys) > 16 {
keys = append(keys[:16], "...")
}
return keys
}
+128
View File
@@ -0,0 +1,128 @@
package wopan
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestQRCodeGenerateUsesServiceImage(t *testing.T) {
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/QRCode/generate" {
http.NotFound(w, r)
return
}
if r.Header.Get("client-id") != defaultQRCodeClient {
t.Fatalf("client-id = %q, want %q", r.Header.Get("client-id"), defaultQRCodeClient)
}
if r.Header.Get("x-yp-client-id") != defaultQRCodeClient {
t.Fatalf("x-yp-client-id = %q, want %q", r.Header.Get("x-yp-client-id"), defaultQRCodeClient)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"meta": map[string]string{"code": "0000", "message": "ok"},
"result": map[string]string{
"uuid": "uuid-1",
"image": "iVBORw0KGgo=",
},
})
}))
t.Cleanup(api.Close)
got, err := NewQRClient(QRConfig{APIBaseURL: api.URL + "/QRCode"}).Generate(context.Background())
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
if got.UUID != "uuid-1" {
t.Fatalf("uuid = %q, want uuid-1", got.UUID)
}
if got.QRImageDataURL != "data:image/png;base64,iVBORw0KGgo=" {
t.Fatalf("qrImageDataUrl = %q, want PNG data URL", got.QRImageDataURL)
}
if got.ExpiresAt == "" {
t.Fatalf("expiresAt is empty")
}
}
func TestQRCodePollPending(t *testing.T) {
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/QRCode/query" {
http.NotFound(w, r)
return
}
if r.URL.Query().Get("uuid") != "uuid-1" {
t.Fatalf("uuid query = %q, want uuid-1", r.URL.Query().Get("uuid"))
}
_ = json.NewEncoder(w).Encode(map[string]any{
"meta": map[string]string{"code": "0000", "message": "ok"},
"result": map[string]any{
"state": 1,
"token": nil,
"refreshToken": nil,
},
})
}))
t.Cleanup(api.Close)
got, err := NewQRClient(QRConfig{APIBaseURL: api.URL + "/QRCode"}).Poll(context.Background(), "uuid-1")
if err != nil {
t.Fatalf("Poll() error = %v", err)
}
if got.State != 1 || got.StatusText != "等待扫码" || got.AccessToken != "" || got.RefreshToken != "" {
t.Fatalf("status = %#v, want pending without tokens", got)
}
}
func TestQRCodePollSuccessMapsTokenFields(t *testing.T) {
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/QRCode/query" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"meta": map[string]string{"code": "0000", "message": "ok"},
"result": map[string]any{
"state": 3,
"token": "access-1",
"refreshToken": "refresh-1",
},
})
}))
t.Cleanup(api.Close)
got, err := NewQRClient(QRConfig{APIBaseURL: api.URL + "/QRCode"}).Poll(context.Background(), "uuid-1")
if err != nil {
t.Fatalf("Poll() error = %v", err)
}
if got.State != 3 || got.AccessToken != "access-1" || got.RefreshToken != "refresh-1" {
t.Fatalf("status = %#v, want token and refreshToken mapped", got)
}
}
func TestQRCodePollSuccessReportsMissingTokenKeys(t *testing.T) {
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"meta": map[string]string{"code": "0000", "message": "ok"},
"result": map[string]any{
"state": 3,
"user": map[string]string{"name": "demo"},
},
})
}))
t.Cleanup(api.Close)
_, err := NewQRClient(QRConfig{APIBaseURL: api.URL + "/QRCode"}).Poll(context.Background(), "uuid-1")
if err == nil {
t.Fatal("Poll() error is nil, want missing token error")
}
if !strings.Contains(err.Error(), "missing access_token, refresh_token") ||
!strings.Contains(err.Error(), "available keys") {
t.Fatalf("error = %q, want missing token keys", err.Error())
}
}
+31
View File
@@ -366,12 +366,30 @@ func remoteRangeResponseLooksRateLimited(rawURL string, status int, body []byte)
if status == http.StatusTooManyRequests {
return true
}
if isWopanMediaURL(rawURL) && (status == http.StatusForbidden || status == http.StatusTooManyRequests ||
status == http.StatusInternalServerError || status == http.StatusBadGateway ||
status == http.StatusServiceUnavailable || status == http.StatusGatewayTimeout ||
status == 509) {
return true
}
text := strings.ToLower(strings.TrimSpace(string(body)))
compact := compactRemoteRangeErrorText(text)
if strings.Contains(text, "too many request") ||
strings.Contains(text, "too many requests") ||
strings.Contains(text, "rate limit") ||
strings.Contains(text, "quota exceeded") ||
strings.Contains(text, "操作频繁") ||
strings.Contains(text, "请求频繁") ||
strings.Contains(text, "请求太频繁") ||
strings.Contains(text, "请求过于频繁") ||
strings.Contains(text, "频率限制") ||
strings.Contains(text, "请求次数过多") ||
strings.Contains(text, "系统繁忙") ||
strings.Contains(text, "服务繁忙") ||
strings.Contains(text, "稍后再试") ||
strings.Contains(text, "稍后重试") ||
strings.Contains(text, "访问被阻断") ||
strings.Contains(text, "风控") ||
strings.Contains(text, "download quota") ||
strings.Contains(text, "sharing rate") ||
strings.Contains(text, "daily limit") ||
@@ -393,6 +411,19 @@ func remoteRangeResponseLooksRateLimited(rawURL string, status int, body []byte)
return false
}
func isWopanMediaURL(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil {
return false
}
host := strings.ToLower(u.Hostname())
path := strings.ToLower(u.Path)
return (strings.HasSuffix(host, "pan.wo.cn") ||
strings.HasSuffix(host, "smartont.net") ||
strings.Contains(host, "wo.cn")) &&
strings.Contains(path, "/openapi/download")
}
func isGoogleDriveMediaURL(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil {
@@ -113,6 +113,24 @@ func TestComputeRemoteGoogleQuotaExceededReturnsRateLimit(t *testing.T) {
}
}
func TestWopanRemoteRangeErrorsLookRateLimited(t *testing.T) {
for _, tc := range []struct {
rawURL string
status int
}{
{rawURL: "https://gxdownload.pan.wo.cn:8445/openapi/download?fid=encoded", status: http.StatusForbidden},
{rawURL: "https://du.smartont.net:8445/openapi/download?fid=encoded", status: http.StatusServiceUnavailable},
{rawURL: "https://du.smartont.net:8445/openapi/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 wopan rate limit")
}
}
type fakeDrive struct {
paths map[string]string
}
+46 -3
View File
@@ -1432,7 +1432,10 @@ func (w *Worker) pauseForRateLimit(err error, step, title string) bool {
return false
}
if wait <= 0 {
wait = defaultGenerationRateLimitCooldown
wait = w.RateLimitCooldown
if wait <= 0 {
wait = defaultGenerationRateLimitCooldown
}
}
until := w.rateLimit.pause(time.Now(), wait)
log.Printf("[preview] drive=%s rate-limited until=%s step=%s video=%s: %v", w.Drive.ID(), until.Format(time.RFC3339), step, title, err)
@@ -1468,7 +1471,10 @@ func (w *ThumbWorker) pauseForRateLimit(err error, step, title string) bool {
return false
}
if wait <= 0 {
wait = defaultGenerationRateLimitCooldown
wait = w.RateLimitCooldown
if wait <= 0 {
wait = defaultGenerationRateLimitCooldown
}
}
until := w.rateLimit.pause(time.Now(), wait)
log.Printf("[thumb] drive=%s rate-limited until=%s step=%s video=%s: %v", w.Drive.ID(), until.Format(time.RFC3339), step, title, err)
@@ -1544,7 +1550,7 @@ func driveErrorShouldCooldown(d drives.Drive, err error) bool {
strings.Contains(text, "partial file") ||
strings.Contains(text, "service unavailable")
case "p123":
// 123盘直链解析 / ffmpeg 读取阶段可能返回 429、5xx,或 WAF 类
// 123盘直链解析 / ffmpeg 读取阶段可能返回 429、5xx,或 WAF 类
// blocked / 访问阻断文本。命中时冷却,避免封面和预览视频生成连续打接口。
text := strings.ToLower(err.Error())
return strings.Contains(text, "请求太频繁") ||
@@ -1566,6 +1572,43 @@ func driveErrorShouldCooldown(d drives.Drive, err error) bool {
strings.Contains(text, "blocked") ||
strings.Contains(text, "访问被阻断") ||
strings.Contains(text, "service unavailable")
case "wopan":
// 联通网盘的取链接口和下载直链都可能返回"操作频繁"、429、5xx
// 或 WAF 阻断文本。封面/预览失败时先冷却,避免持续触发风控。
text := strings.ToLower(err.Error())
return strings.Contains(text, "请求太频繁") ||
strings.Contains(text, "请求过于频繁") ||
strings.Contains(text, "请求频繁") ||
strings.Contains(text, "操作频繁") ||
strings.Contains(text, "频率限制") ||
strings.Contains(text, "请求次数过多") ||
strings.Contains(text, "系统繁忙") ||
strings.Contains(text, "服务繁忙") ||
strings.Contains(text, "稍后再试") ||
strings.Contains(text, "稍后重试") ||
strings.Contains(text, "429") ||
strings.Contains(text, "http 500") ||
strings.Contains(text, "http 502") ||
strings.Contains(text, "http 503") ||
strings.Contains(text, "http 504") ||
strings.Contains(text, "http 509") ||
strings.Contains(text, "server returned 403") ||
strings.Contains(text, "403 forbidden") ||
strings.Contains(text, "server returned 429") ||
strings.Contains(text, "server returned 500") ||
strings.Contains(text, "server returned 502") ||
strings.Contains(text, "server returned 503") ||
strings.Contains(text, "server returned 504") ||
strings.Contains(text, "too many request") ||
strings.Contains(text, "too many requests") ||
strings.Contains(text, "rate limit") ||
strings.Contains(text, "rate-limit") ||
strings.Contains(text, "throttl") ||
strings.Contains(text, "blocked") ||
strings.Contains(text, "request has been blocked") ||
strings.Contains(text, "访问被阻断") ||
strings.Contains(text, "风控") ||
strings.Contains(text, "service unavailable")
case "googledrive":
// Google Drive 下载/取样阶段常把频控和配额问题包装成 403,
// 具体标识在 error.errors[].reason/message 里(OpenList 也按该结构解析)。
+17
View File
@@ -661,6 +661,23 @@ func TestP123TransientErrorsShouldCooldown(t *testing.T) {
}
}
func TestWopanTransientErrorsShouldCooldown(t *testing.T) {
drv := &previewFakeDrive{kind: "wopan"}
for _, err := range []error{
errors.New("ffmpeg: Server returned 403 Forbidden"),
errors.New("wopan download url: request failed with status: 429 Too Many Requests"),
errors.New("操作频繁,请稍后重试"),
errors.New("http 503 service unavailable"),
} {
if !driveErrorShouldCooldown(drv, err) {
t.Fatalf("driveErrorShouldCooldown(%v) = false, want true", err)
}
}
if driveErrorShouldCooldown(drv, errors.New("invalid access token")) {
t.Fatal("invalid access token should not trigger wopan cooldown")
}
}
func TestGoogleDriveMediaErrorsShouldCooldown(t *testing.T) {
drv := &previewFakeDrive{kind: "googledrive"}
for _, err := range []error{
+5 -3
View File
@@ -147,15 +147,17 @@ func (p *Proxy) ServeStream(w http.ResponseWriter, r *http.Request, driveID, fil
// CDN 不校验请求头,直连可获得最佳带宽并避免占用 backend 出站
// - onedriveMicrosoft Graph 返回的 @microsoft.graph.downloadUrl 是短期
// 免鉴权下载 URL,不需要后端继续代传视频字节
// - p123123盘 download_info 返回的下载页会再跳 CDNdriver 已在后端
// - p123123盘 download_info 返回的下载页会再跳 CDNdriver 已在后端
// 先解出最终 Location,浏览器可直接 302 到该短期地址
// - wopan:联通网盘 GetDownloadUrlV2 返回的是短期直链,OpenList 也是直接
// 将该 URL 交给客户端使用;不需要后端持续代传视频字节
//
// 其余网盘(如沃盘 / 夸克等)仍走反代,因为它们的下载
// 其余网盘(如夸克等)仍走反代,因为它们的下载
// 链接通常需要随请求带上后端持有的 Cookie / Authorization / Range
// 的特殊处理,浏览器拿不到这些上下文。
func shouldRedirect(d drives.Drive) bool {
switch d.Kind() {
case "p115", "pikpak", "onedrive", "p123":
case "p115", "pikpak", "onedrive", "p123", "wopan":
return true
}
return false
+25
View File
@@ -201,6 +201,31 @@ func TestServeStreamRedirectsP123(t *testing.T) {
}
}
func TestServeStreamRedirectsWopan(t *testing.T) {
reg := NewRegistry()
drv := &proxyFakeSimpleDrive{
kind: "wopan",
url: "https://du.smartont.net:8445/openapi/download?fid=encoded",
}
reg.Set("wopan", drv)
p := New(reg)
req := httptest.NewRequest(http.MethodGet, "/p/stream/wopan/file-1", nil)
rr := httptest.NewRecorder()
p.ServeStream(rr, req, "wopan", "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://du.smartont.net:8445/openapi/download?fid=encoded" {
t.Fatalf("Location = %q", got)
}
if drv.calls != 1 {
t.Fatalf("link calls = %d, want 1", drv.calls)
}
}
func TestServeStreamServesLocalFilePath(t *testing.T) {
path := filepath.Join(t.TempDir(), "video.mp4")
if err := os.WriteFile(path, []byte("0123456789"), 0o644); err != nil {
+9 -1
View File
@@ -2,6 +2,7 @@ package scanner
import (
"context"
"encoding/base64"
"fmt"
"log"
"path"
@@ -165,7 +166,7 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
progress(dirName)
stats.SeenFileIDs[e.ID] = struct{}{}
id := s.Drive.Kind() + "-" + s.Drive.ID() + "-" + e.ID
id := s.Drive.Kind() + "-" + s.Drive.ID() + "-" + videoIDFilePart(e.ID)
if deleted, err := s.Catalog.IsDeletedVideoCandidate(ctx, id, s.Drive.ID(), e.ID, e.Hash, e.Name, e.Size); err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
@@ -339,3 +340,10 @@ func mergeTags(lists ...[]string) []string {
}
return out
}
func videoIDFilePart(fileID string) string {
if !strings.ContainsAny(fileID, `/\`+"\x00") {
return fileID
}
return "b64_" + base64.RawURLEncoding.EncodeToString([]byte(fileID))
}
+45
View File
@@ -124,6 +124,51 @@ func TestRunScannedCountsOnlyVideoCandidates(t *testing.T) {
}
}
func TestRunUsesPathSafeVideoIDForUnsafeFileID(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
if err != nil {
t.Fatalf("open catalog: %v", err)
}
t.Cleanup(func() {
if err := cat.Close(); err != nil {
t.Fatalf("close catalog: %v", err)
}
})
drv := &scannerFakeDrive{
entries: []drives.Entry{{
ID: "fid/with space",
Name: "clip.mp4",
Size: 123,
}},
}
sc := New(cat, drv, []string{".mp4"}, nil, nil)
stats, err := sc.Run(ctx, "")
if err != nil {
t.Fatalf("scan: %v", err)
}
if stats.Added != 1 {
t.Fatalf("added = %d, want 1", stats.Added)
}
if _, ok := stats.SeenFileIDs["fid/with space"]; !ok {
t.Fatalf("seen file ids = %#v, want original file id", stats.SeenFileIDs)
}
wantID := "fake-drive-b64_ZmlkL3dpdGggc3BhY2U"
got, err := cat.GetVideo(ctx, wantID)
if err != nil {
t.Fatalf("get video %s: %v", wantID, err)
}
if strings.Contains(got.ID, "/") {
t.Fatalf("video id = %q, must not contain slash", got.ID)
}
if got.FileID != "fid/with space" {
t.Fatalf("file id = %q, want original", got.FileID)
}
}
func TestRunStopsWhenContextCanceledDuringFileLoop(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
+30 -5
View File
@@ -1,5 +1,5 @@
// Package spider91migrate 周期性把 spider91 drive 下载到本地的视频
// 上传到一个指定的目标 drive 目录(PikPak、115、123、OneDriveGoogle Drive),上传成功后:
// 上传到一个指定的目标 drive 目录(PikPak、115、123、OneDriveGoogle Drive 或联通网盘),上传成功后:
//
// - 改写 catalog 行:drive_id / file_id / content_hash 改成目标盘的;
// 视频自身的 id 不变(仍是 spider91-<driveID>-<viewkey>),video_tags、
@@ -37,11 +37,12 @@ import (
"github.com/video-site/backend/internal/drives/pikpak"
"github.com/video-site/backend/internal/drives/scriptcrawler"
"github.com/video-site/backend/internal/drives/spider91"
"github.com/video-site/backend/internal/drives/wopan"
"github.com/video-site/backend/internal/mediaasset"
)
// uploadTarget 是 migrator 调用目标 drive 的最小接口。任何一种"接收 spider91 上传"的
// 网盘都要实现它;当前 PikPak、115、123、OneDriveGoogle Drive 各自通过适配器满足。
// 网盘都要实现它;当前 PikPak、115、123、OneDriveGoogle Drive 和联通网盘各自通过适配器满足。
//
// 这一层抽象把"迁移调用方"和"具体盘的 SDK 协议"解耦:
// - PikPak 走 GCID + OSS PutObjectpikpak.UploadResult
@@ -49,6 +50,7 @@ import (
// - 123 走 MD5 + 秒传 / S3 预签名分片(p123.UploadResult
// - OneDrive 走 SHA1 + 小文件 PUT / 大文件 upload session
// - Google Drive 走 MD5 + resumable upload session
// - 联通网盘 走 SDK Upload2C,当前上游不返回内容 hash
//
// 各家返回值都被归一成本地的 UploadResult,并在 catalog 改写阶段统一处理。
type uploadTarget interface {
@@ -74,7 +76,7 @@ type Spider91LocalSource interface {
// UploadResult 是 uploadTarget.UploadAndReportHash 的归一返回。
//
// 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 实际上传字节数。
type UploadResult struct {
FileID string
@@ -108,7 +110,7 @@ type migrationPlan struct {
legacyBackfill bool
}
// pikpakAdapter / p115Adapter / p123Adapter / onedriveAdapter / googledriveAdapter 把具体 driver 包装成 uploadTarget。
// pikpakAdapter / p115Adapter / p123Adapter / onedriveAdapter / googledriveAdapter / wopanAdapter 把具体 driver 包装成 uploadTarget。
//
// 之所以不让 driver 直接实现 uploadTarget
//
@@ -220,6 +222,27 @@ func (a *googledriveAdapter) Rename(ctx context.Context, fileID, newName string)
return a.d.Rename(ctx, fileID, newName)
}
type wopanAdapter struct {
d *wopan.Driver
}
func (a *wopanAdapter) ID() string { return a.d.ID() }
func (a *wopanAdapter) Kind() string { return a.d.Kind() }
func (a *wopanAdapter) RootID() string { return a.d.RootID() }
func (a *wopanAdapter) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) {
return a.d.EnsureDir(ctx, pathFromRoot)
}
func (a *wopanAdapter) 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 *wopanAdapter) Rename(ctx context.Context, fileID, newName string) error {
return a.d.Rename(ctx, fileID, newName)
}
// adaptUploadTarget 把通用 drive 包装成 uploadTarget。
// 不支持的盘 kind 返回 error;调用方静默跳过。
func adaptUploadTarget(d drives.Drive) (uploadTarget, error) {
@@ -234,6 +257,8 @@ func adaptUploadTarget(d drives.Drive) (uploadTarget, error) {
return &onedriveAdapter{d: v}, nil
case *googledrive.Driver:
return &googledriveAdapter{d: v}, nil
case *wopan.Driver:
return &wopanAdapter{d: v}, nil
case uploadTarget:
// 测试或自定义实现可以直接传入;优先使用具体类型分支以拿到适配器。
return v, nil
@@ -1158,7 +1183,7 @@ func (m *Migrator) cleanupOldLocalVideos(ctx context.Context, plan migrationPlan
return deleted, nil
}
// backfillFileNames 扫描目标 drivePikPak、115、123、OneDriveGoogle Drive)下所有 spider91-* 起始 ID 的视频,
// backfillFileNames 扫描目标 drivePikPak、115、123、OneDriveGoogle Drive 或联通网盘)下所有 spider91-* 起始 ID 的视频,
// 对文件名不是 desiredPikPakName(...) 期望格式的,调 target.Rename 修正,
// 并把 catalog.file_name 同步到新名字。
//
@@ -19,6 +19,7 @@ import (
"github.com/video-site/backend/internal/drives/pikpak"
"github.com/video-site/backend/internal/drives/scriptcrawler"
"github.com/video-site/backend/internal/drives/spider91"
"github.com/video-site/backend/internal/drives/wopan"
)
// fakeRegistry 是 Registry 接口的最小实现。
@@ -1447,7 +1448,23 @@ func TestAdaptUploadTargetSupportsGoogleDriveDriver(t *testing.T) {
}
}
// TestResolveTargetRejectsUnsupportedKind 验证当目标 drive 既不是 PikPak、115、123、OneDrive 也不是 Google Drive 时,
func TestAdaptUploadTargetSupportsWopanDriver(t *testing.T) {
d := wopan.New(wopan.Config{
ID: "wopan-target",
RootID: "root-wopan",
AccessToken: "access-token",
RefreshToken: "refresh-token",
})
target, err := adaptUploadTarget(d)
if err != nil {
t.Fatalf("adaptUploadTarget() error = %v", err)
}
if target.ID() != "wopan-target" || target.Kind() != "wopan" || target.RootID() != "root-wopan" {
t.Fatalf("target id/kind/root = %q/%q/%q, want wopan-target/wopan/root-wopan", target.ID(), target.Kind(), target.RootID())
}
}
// TestResolveTargetRejectsUnsupportedKind 验证当目标 drive 既不是 PikPak、115、123、OneDrive、Google Drive 也不是联通网盘时,
// resolveTarget 拒绝并返回 error,让 runOnce 静默跳过(不会做破坏性变更)。
func TestResolveTargetRejectsUnsupportedKind(t *testing.T) {
cat := setupCatalog(t)
+1
View File
@@ -2,6 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="referrer" content="no-referrer" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="description" content="91 视频站" />
+1 -1
View File
@@ -33,7 +33,7 @@ import { SpiderIcon } from "./icons/SpiderIcon";
const BUSY_STATES = new Set(["scanning", "generating", "uploading", "queued"]);
const POLL_INTERVAL_MS = 5000;
const UPLOAD_TARGET_KINDS = new Set(["p115", "pikpak", "p123", "googledrive", "onedrive"]);
const UPLOAD_TARGET_KINDS = new Set(["p115", "pikpak", "p123", "googledrive", "onedrive", "wopan"]);
function statusBusy(status?: api.DriveGenerationStatus) {
return BUSY_STATES.has(status?.state ?? "");
+2 -1
View File
@@ -99,7 +99,8 @@ export function DrivesPage() {
d.kind === "p115" ||
d.kind === "p123" ||
d.kind === "onedrive" ||
d.kind === "googledrive"
d.kind === "googledrive" ||
d.kind === "wopan"
),
[list]
);
+24 -2
View File
@@ -346,6 +346,28 @@ export function getP123QRStatus(uniID: string, loginUuid: string) {
);
}
export type WopanQRSession = {
uuid: string;
qrImageDataUrl: string;
expiresAt?: string;
};
export type WopanQRStatus = {
state: number;
statusText: string;
accessToken?: string;
refreshToken?: string;
familyID?: string;
};
export function startWopanQRLogin() {
return request<WopanQRSession>("/drives/wopan/qr", { method: "POST" });
}
export function getWopanQRStatus(uuid: string) {
return request<WopanQRStatus>(`/drives/wopan/qr/${encodeURIComponent(uuid)}`);
}
/**
* toggle
*
@@ -541,9 +563,9 @@ export type Theme = "dark" | "pink";
export type Settings = {
theme: Theme;
/**
* spider91 drive ID pikpakp115p123onedrive googledrive drive
* spider91 drive ID pikpakp115p123onedrivegoogledrive wopan drive
* -
* - drive kind {pikpak, p115, p123, onedrive, googledrive}
* - drive kind {pikpak, p115, p123, onedrive, googledrive, wopan}
*/
spider91UploadDriveId: string;
};
+19 -2
View File
@@ -1,6 +1,7 @@
import { useId, useMemo, useState } from "react";
import { ArrowLeft, ChevronDown } from "lucide-react";
import { P123QRCodeLogin } from "./P123QRCodeLogin";
import { WopanQRCodeLogin } from "./WopanQRCodeLogin";
import { Spider91UploadTargetField } from "./Spider91UploadTargetField";
import {
FormState,
@@ -21,13 +22,13 @@ type DriveOption = {
const DRIVE_OPTIONS: DriveOption[] = [
{ 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: "onedrive", label: "OneDrive", abbr: "OD", desc: "302直链,微软网盘" },
{ kind: "googledrive", label: "Google Drive", abbr: "GD", desc: "服务器中转模式" },
{ kind: "localstorage", label: "本地存储", abbr: "Lo", desc: "本机文件目录" },
{ kind: "quark", label: "夸克网盘", abbr: "Qk", desc: "302直链" },
{ kind: "wopan", label: "联通盘", abbr: "Wo", desc: "302直链" },
{ kind: "wopan", label: "联通盘", abbr: "Wo", desc: "302直链" },
];
export function DriveForm({
@@ -177,6 +178,22 @@ export function DriveForm({
/>
)}
{form.kind === "wopan" && (
<WopanQRCodeLogin
onCredentials={(credentials) =>
onChange({
...form,
creds: {
...form.creds,
access_token: credentials.accessToken,
refresh_token: credentials.refreshToken,
...(credentials.familyID ? { family_id: credentials.familyID } : {}),
},
})
}
/>
)}
{fields.map((f) => (
<div key={f.key} className="admin-form__row">
{f.type === "select" ? (
+2 -2
View File
@@ -113,11 +113,11 @@ export function P123QRCodeLogin({ onToken }: { onToken: (token: string) => void
<img
className="admin-p123-qr__image"
src={session.qrImageDataUrl}
alt="123盘扫码登录二维码"
alt="123盘扫码登录二维码"
/>
<div className="admin-p123-qr__meta">
<div className="admin-form__help">
使 123 App access_token
使 123 App access_token
</div>
{session.expiresAt && (
<div className="admin-form__help">
+148
View File
@@ -0,0 +1,148 @@
import { useEffect, useState } from "react";
import { QrCode } from "lucide-react";
import * as api from "../api";
import { useToast } from "../ToastContext";
function wopanQRStatusClass(
status: api.WopanQRStatus | null,
completed: boolean,
error: string
): string {
if (completed || status?.state === 3) return "is-ok";
if (error || status?.state === 4) return "is-error";
return "is-pending";
}
export function WopanQRCodeLogin({
onCredentials,
}: {
onCredentials: (credentials: {
accessToken: string;
refreshToken: string;
familyID?: string;
}) => void;
}) {
const { show } = useToast();
const [session, setSession] = useState<api.WopanQRSession | null>(null);
const [status, setStatus] = useState<api.WopanQRStatus | 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.startWopanQRLogin();
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 inFlight = false;
let timer: number | undefined;
async function poll() {
if (stopped || inFlight) return;
inFlight = true;
try {
const next = await api.getWopanQRStatus(activeSession.uuid);
if (stopped) return;
setStatus(next);
setPollingError("");
if (next.accessToken && next.refreshToken) {
stopped = true;
if (timer) window.clearInterval(timer);
setCompleted(true);
onCredentials({
accessToken: next.accessToken,
refreshToken: next.refreshToken,
familyID: next.familyID,
});
show("扫码成功,已填入 access_token 和 refresh_token,保存后生效", "success");
return;
}
if (next.state === 4) {
stopped = true;
if (timer) window.clearInterval(timer);
}
} catch (e) {
if (stopped) return;
setPollingError(e instanceof Error ? e.message : "查询扫码状态失败");
} finally {
inFlight = false;
}
}
poll();
timer = window.setInterval(poll, 1200);
return () => {
stopped = true;
if (timer) window.clearInterval(timer);
};
}, [session, completed, onCredentials, show]);
const statusText = completed
? "已获取凭证"
: pollingError || status?.statusText || (session ? "等待扫码" : "未生成二维码");
const statusClass = wopanQRStatusClass(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 === 4 && (
<div className="admin-form__help">
</div>
)}
</div>
</div>
)}
</div>
</div>
);
}
+6 -5
View File
@@ -25,9 +25,9 @@ export function driveKindAbbr(kind: string): string {
export const kindLabel: Record<string, string> = {
quark: "夸克网盘",
p115: "115 网盘",
p123: "123盘",
p123: "123盘",
pikpak: "PikPak",
wopan: "联通盘",
wopan: "联通盘",
onedrive: "OneDrive",
googledrive: "Google Drive",
localstorage: "本地存储",
@@ -150,11 +150,11 @@ export function credentialHelp(kind: Kind, isEdit: boolean): string {
case "p115":
return `登录 115.com 后复制 Cookie,形如 "UID=...; CID=...; SEID=...; KID=..."。${note}`;
case "p123":
return `推荐使用扫码登录自动获取 access_token;账号密码登录被 123盘风控拦截时,也可以只填写 access_token。播放走 302 跳转到 123盘返回的短期 CDN 地址。${note}`;
return `推荐使用扫码登录自动获取 access_token;账号密码登录被 123盘风控拦截时,也可以只填写 access_token。播放走 302 跳转到 123盘返回的短期 CDN 地址。${note}`;
case "pikpak":
return `填写 PikPak 账号和密码即可。平台、设备 ID、验证码 token 和 refresh token 会由服务端自动处理并保存。${note}`;
case "wopan":
return `需要 access_token 和 refresh_token。后续会加扫码/短信登录入口,第一版只能手工粘贴${note}`;
return `推荐使用扫码登录自动获取 access_token 和 refresh_token;也可以手工粘贴已有凭证${note}`;
case "onedrive":
return `按 OpenList 默认应用在线挂载,只需要 refresh_token;保存时会自动刷新并保存 token。${note}`;
case "googledrive":
@@ -226,7 +226,7 @@ export function credentialFields(kind: Kind, creds: Record<string, string> = {})
{
key: "password",
label: "密码(可选)",
placeholder: "123盘密码",
placeholder: "123盘密码",
},
{
key: "access_token",
@@ -258,6 +258,7 @@ export function credentialFields(kind: Kind, creds: Record<string, string> = {})
label: "access_token",
placeholder: "",
required: true,
help: "扫码成功后会自动填入该字段;如果 token 过期,重新扫码后保存即可。",
},
{
key: "refresh_token",
+1 -1
View File
@@ -286,7 +286,7 @@ function withRetryParam(src: string, retry: number): string {
}
// 从后端返回的 sourceLabel 推断网盘类型(用于颜色标识)。
// 后端目前会下发中文名("夸克网盘" / "115 网盘" / "PikPak" / "联通盘" / "OneDrive"
// 后端目前会下发中文名("夸克网盘" / "115 网盘" / "PikPak" / "联通盘" / "OneDrive"
// 或英文 kind。两边都尝试匹配;都没匹配上时返回空字符串,CSS 会回落到默认色。
function sourceKindFromLabel(label: string): string {
const value = label.toLowerCase();
+25 -1
View File
@@ -110,6 +110,7 @@ const PLAYER_GESTURE_HUD_CLASS = "video-player__art-gesture-hud";
const PLAYER_GESTURE_HUD_ICON_CLASS = "video-player__art-gesture-hud-icon";
const PLAYER_GESTURE_HUD_VALUE_CLASS = "video-player__art-gesture-hud-value";
const PREVIEW_WIDTH = 168;
const MEDIA_REFERRER_POLICY = "no-referrer";
const BRIGHTNESS_MIN = 0.45;
const BRIGHTNESS_MAX = 1.35;
const GESTURE_ACTIVATION_PX = 12;
@@ -269,9 +270,30 @@ function inferSourceType(src: string) {
const lower = src.toLowerCase();
const cleanPath = lower.split("#")[0].split("?")[0];
if (cleanPath.endsWith(".m3u8") || lower.includes(".m3u8")) return "m3u8";
if (isBackendNativeVideoRoute(cleanPath)) return "mp4";
return undefined;
}
function isBackendNativeVideoRoute(cleanPath: string) {
const pathname = sourcePathname(cleanPath);
return (
pathname.startsWith("/p/stream/") ||
pathname.startsWith("/p/upload/") ||
pathname.startsWith("/p/spider91/")
);
}
function sourcePathname(src: string) {
if (src.startsWith("http://") || src.startsWith("https://")) {
try {
return new URL(src).pathname.toLowerCase();
} catch {
return src;
}
}
return src;
}
function mountArtPlayer({
mount,
src,
@@ -305,7 +327,7 @@ function mountArtPlayer({
const option: Option = {
id: "91-detail-player",
container: mount,
url: src,
url: "",
poster,
theme: "var(--video-player-progress)",
lang: "zh-cn",
@@ -350,12 +372,14 @@ function mountArtPlayer({
artRef.current = art;
const video = art.video as VideoElementWithHls;
video.setAttribute("referrerpolicy", MEDIA_REFERRER_POLICY);
video.setAttribute("aria-label", title);
video.setAttribute("controlsList", "nodownload");
video.setAttribute("webkit-playsinline", "true");
video.disablePictureInPicture = false;
video.playbackRate = settings.playbackRate;
applyPlayerBrightness(art, settings.brightness);
art.url = src;
function preventContextMenu(event: Event) {
event.preventDefault();
+22 -1
View File
@@ -774,6 +774,7 @@
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: var(--space-3);
min-width: 0;
}
.admin-crawler-detail__grid .admin-gen-col {
@@ -1739,7 +1740,7 @@
display: grid;
gap: 6px;
width: 100%;
min-width: 200px;
min-width: min(200px, 100%);
max-width: 320px;
}
@@ -1748,6 +1749,7 @@
grid-template-columns: auto auto minmax(0, 1fr);
align-items: center;
gap: 6px;
min-width: 0;
}
.admin-generation-kind {
@@ -1763,6 +1765,7 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: anywhere;
color: var(--text-muted);
font-size: var(--font-xs);
}
@@ -4262,6 +4265,7 @@
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--space-3);
margin-bottom: var(--space-4);
min-width: 0;
}
.admin-gen-col {
@@ -4272,6 +4276,8 @@
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
overflow: hidden;
}
.admin-gen-col__head {
@@ -4280,24 +4286,35 @@
justify-content: space-between;
gap: var(--space-2);
flex-wrap: wrap;
min-width: 0;
}
.admin-gen-col__label {
min-width: 0;
font-size: var(--font-sm);
font-weight: var(--weight-semibold);
color: var(--text-strong);
}
.admin-gen-col__detail {
display: -webkit-box;
max-width: 100%;
min-width: 0;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
font-size: var(--font-xs);
color: var(--text-muted);
line-height: var(--line-tight);
overflow-wrap: anywhere;
word-break: break-word;
}
.admin-gen-col__counts {
display: grid;
gap: 5px;
margin-top: 2px;
min-width: 0;
}
.admin-gen-col__count {
@@ -4305,10 +4322,14 @@
justify-content: space-between;
align-items: baseline;
font-size: var(--font-xs);
gap: var(--space-2);
min-width: 0;
}
.admin-gen-col__count span {
min-width: 0;
color: var(--text-faint);
overflow-wrap: anywhere;
}
.admin-gen-col__count strong {
+4 -3
View File
@@ -78,8 +78,9 @@ test("spider91 upload target uses explicit local-save option instead of auto tar
assert.match(combinedSource, /本地保存,不上传/);
assert.match(
combinedSource,
/d\.kind === "pikpak"[\s\S]*d\.kind === "p115"[\s\S]*d\.kind === "p123"[\s\S]*d\.kind === "onedrive"[\s\S]*d\.kind === "googledrive"/
/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"/
);
assert.match(crawlerPageSource, /UPLOAD_TARGET_KINDS[\s\S]*"wopan"/);
assert.doesNotMatch(combinedSource, /自动:唯一/);
assert.doesNotMatch(combinedSource, /自动模式/);
assert.doesNotMatch(combinedSource, /较早的视频会上传到该云盘根目录下的 91 Spider 文件夹/);
@@ -193,13 +194,13 @@ test("localstorage drive form asks for a server directory path", () => {
test("drive type selector keeps primary source order", () => {
assert.deepEqual(driveTypeOptions(), [
{ value: "p115", label: "115 网盘" },
{ value: "p123", label: "123盘" },
{ value: "p123", label: "123盘" },
{ value: "pikpak", label: "PikPak" },
{ value: "onedrive", label: "OneDrive" },
{ value: "googledrive", label: "Google Drive" },
{ value: "localstorage", label: "本地存储" },
{ value: "quark", label: "夸克网盘" },
{ value: "wopan", label: "联通盘" },
{ value: "wopan", label: "联通盘" },
]);
});
+15
View File
@@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const indexHtml = readFileSync(
new URL("../index.html", import.meta.url),
"utf8"
);
test("app shell prevents referrer leakage for 302 video playback", () => {
assert.match(
indexHtml,
/<meta\s+name="referrer"\s+content="no-referrer"\s*\/?>/
);
});
+17
View File
@@ -108,6 +108,23 @@ test("detail player uses custom mobile gestures instead of ArtPlayer native gest
assert.match(playerSource, /addEventListener\("touchmove", handleTouchMove, \{ passive: false \}\)/);
});
test("detail player treats backend video routes as native mp4 sources", () => {
assert.match(playerSource, /if \(isBackendNativeVideoRoute\(cleanPath\)\) return "mp4"/);
assert.match(playerSource, /pathname\.startsWith\("\/p\/stream\/"\)/);
assert.match(playerSource, /pathname\.startsWith\("\/p\/upload\/"\)/);
assert.match(playerSource, /pathname\.startsWith\("\/p\/spider91\/"\)/);
assert.doesNotMatch(playerSource, /crossOrigin/);
});
test("detail player sets referrer policy before loading media url", () => {
assert.match(playerSource, /const MEDIA_REFERRER_POLICY = "no-referrer"/);
assert.match(playerSource, /url:\s*""/);
assert.match(
playerSource,
/video\.setAttribute\("referrerpolicy", MEDIA_REFERRER_POLICY\);[\s\S]*art\.url = src;/
);
});
test("detail player fullscreen long-press rate hint lives inside ArtPlayer", () => {
assert.match(
detailCss,