diff --git a/backend/README.md b/backend/README.md index 09455b5..5be02b3 100644 --- a/backend/README.md +++ b/backend/README.md @@ -2,7 +2,7 @@ 视频聚合站的 Go 后端。提供三件事: -1. 多家网盘统一抽象(夸克 / 115 / PikPak / 联通沃盘 / OneDrive / Google Drive / 本地存储) +1. 多家网盘统一抽象(夸克 / 115 / PikPak / 联通网盘 / OneDrive / Google Drive / 本地存储) 2. 视频元数据目录(SQLite)+ 扫描 + 预览视频预生成 3. REST API(前台)+ 管理后台 + 直链代理 4. 标签池、视频隐藏、按网盘统计和详情页来源网盘类型展示能力 @@ -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/ OneDrive(OpenList 在线续期 + Microsoft Graph 文件接口) googledrive/ Google Drive(OpenList 在线续期 + Google Drive API;播放走后端代理) localstorage/ 本地目录扫描(服务器已有视频目录) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 4d257b5..8504859 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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 视频上传到目标 drive(PikPak、115、123、OneDrive 或 Google Drive)。 + // spider91Migrator 把 spider91 视频上传到目标 drive(PikPak、115、123、OneDrive、Google 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) diff --git a/backend/cmd/server/main_spider91_test.go b/backend/cmd/server/main_spider91_test.go index 8aa2bd4..ddeaaf7 100644 --- a/backend/cmd/server/main_spider91_test.go +++ b/backend/cmd/server/main_spider91_test.go @@ -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) diff --git a/backend/cmd/server/main_test.go b/backend/cmd/server/main_test.go index 571f534..1ebbc4e 100644 --- a/backend/cmd/server/main_test.go +++ b/backend/cmd/server/main_test.go @@ -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 } diff --git a/backend/internal/api/admin.go b/backend/internal/api/admin.go index 31132a5..7731195 100644 --- a/backend/internal/api/admin.go +++ b/backend/internal/api/admin.go @@ -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 会拒绝重复触发。 diff --git a/backend/internal/api/admin_test.go b/backend/internal/api/admin_test.go index 36a46aa..4583588 100644 --- a/backend/internal/api/admin_test.go +++ b/backend/internal/api/admin_test.go @@ -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") diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 1972cf1..a057756 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -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(``, v.ID), + EmbedURL: fmt.Sprintf(``, 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 = "spider91--"。 // 通过 catalog 拿到 file_id(".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": diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go index 918eb9f..761c661 100644 --- a/backend/internal/api/api_test.go +++ b/backend/internal/api/api_test.go @@ -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) } diff --git a/backend/internal/catalog/tags.go b/backend/internal/catalog/tags.go index 33b0f86..2717c12 100644 --- a/backend/internal/catalog/tags.go +++ b/backend/internal/catalog/tags.go @@ -297,7 +297,7 @@ UPDATE videos } func (c *Catalog) clearRemoteP123ThumbnailsOnce(ctx context.Context) error { - // 123 云盘列表返回的缩略图尺寸和稳定性都不适合作为站内封面;清空历史写入的 + // 123网盘列表返回的缩略图尺寸和稳定性都不适合作为站内封面;清空历史写入的 // 远程 URL,让封面 worker 统一从视频直链抽帧生成本地 /p/thumb/。 const markerKey = "videos.p123.remote_thumbnails_cleared" marker, err := c.GetSetting(ctx, markerKey, "") diff --git a/backend/internal/drives/iface.go b/backend/internal/drives/iface.go index ff1f910..8a9d690 100644 --- a/backend/internal/drives/iface.go +++ b/backend/internal/drives/iface.go @@ -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 diff --git a/backend/internal/drives/p123/driver.go b/backend/internal/drives/p123/driver.go index db62d3d..5f862aa 100644 --- a/backend/internal/drives/p123/driver.go +++ b/backend/internal/drives/p123/driver.go @@ -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" diff --git a/backend/internal/drives/p123/qr.go b/backend/internal/drives/p123/qr.go index f49c411..5cc3343 100644 --- a/backend/internal/drives/p123/qr.go +++ b/backend/internal/drives/p123/qr.go @@ -278,7 +278,7 @@ func qrScanPlatformText(platform int) string { case 4: return "微信" case 7: - return "123 云盘 App" + return "123网盘 App" default: return "" } diff --git a/backend/internal/drives/p123/qr_test.go b/backend/internal/drives/p123/qr_test.go index ec4728b..6fd1cbf 100644 --- a/backend/internal/drives/p123/qr_test.go +++ b/backend/internal/drives/p123/qr_test.go @@ -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) } } diff --git a/backend/internal/drives/wopan/driver.go b/backend/internal/drives/wopan/driver.go index 82a52fa..bf9aac3 100644 --- a/backend/internal/drives/wopan/driver.go +++ b/backend/internal/drives/wopan/driver.go @@ -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) diff --git a/backend/internal/drives/wopan/driver_test.go b/backend/internal/drives/wopan/driver_test.go new file mode 100644 index 0000000..8ab1af7 --- /dev/null +++ b/backend/internal/drives/wopan/driver_test.go @@ -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) + } +} diff --git a/backend/internal/drives/wopan/qr.go b/backend/internal/drives/wopan/qr.go new file mode 100644 index 0000000..03f7571 --- /dev/null +++ b/backend/internal/drives/wopan/qr.go @@ -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 +} diff --git a/backend/internal/drives/wopan/qr_test.go b/backend/internal/drives/wopan/qr_test.go new file mode 100644 index 0000000..b93dba4 --- /dev/null +++ b/backend/internal/drives/wopan/qr_test.go @@ -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()) + } +} diff --git a/backend/internal/fingerprint/worker.go b/backend/internal/fingerprint/worker.go index aa9c181..3e36d3a 100644 --- a/backend/internal/fingerprint/worker.go +++ b/backend/internal/fingerprint/worker.go @@ -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 { diff --git a/backend/internal/fingerprint/worker_test.go b/backend/internal/fingerprint/worker_test.go index a4ade52..dbd1206 100644 --- a/backend/internal/fingerprint/worker_test.go +++ b/backend/internal/fingerprint/worker_test.go @@ -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 } diff --git a/backend/internal/preview/ffmpeg.go b/backend/internal/preview/ffmpeg.go index f0453c4..f4a2d55 100644 --- a/backend/internal/preview/ffmpeg.go +++ b/backend/internal/preview/ffmpeg.go @@ -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 也按该结构解析)。 diff --git a/backend/internal/preview/worker_test.go b/backend/internal/preview/worker_test.go index 046a200..50672ff 100644 --- a/backend/internal/preview/worker_test.go +++ b/backend/internal/preview/worker_test.go @@ -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{ diff --git a/backend/internal/proxy/proxy.go b/backend/internal/proxy/proxy.go index 6d5f1c2..cb37696 100644 --- a/backend/internal/proxy/proxy.go +++ b/backend/internal/proxy/proxy.go @@ -147,15 +147,17 @@ func (p *Proxy) ServeStream(w http.ResponseWriter, r *http.Request, driveID, fil // CDN 不校验请求头,直连可获得最佳带宽并避免占用 backend 出站 // - onedrive:Microsoft Graph 返回的 @microsoft.graph.downloadUrl 是短期 // 免鉴权下载 URL,不需要后端继续代传视频字节 -// - p123:123 云盘 download_info 返回的下载页会再跳 CDN;driver 已在后端 +// - p123:123网盘 download_info 返回的下载页会再跳 CDN;driver 已在后端 // 先解出最终 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 diff --git a/backend/internal/proxy/proxy_test.go b/backend/internal/proxy/proxy_test.go index 5693a8c..5d1c98f 100644 --- a/backend/internal/proxy/proxy_test.go +++ b/backend/internal/proxy/proxy_test.go @@ -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 { diff --git a/backend/internal/scanner/scanner.go b/backend/internal/scanner/scanner.go index ab12d57..b7c175c 100644 --- a/backend/internal/scanner/scanner.go +++ b/backend/internal/scanner/scanner.go @@ -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)) +} diff --git a/backend/internal/scanner/scanner_test.go b/backend/internal/scanner/scanner_test.go index 87471d5..6ed8fb8 100644 --- a/backend/internal/scanner/scanner_test.go +++ b/backend/internal/scanner/scanner_test.go @@ -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") diff --git a/backend/internal/spider91migrate/migrator.go b/backend/internal/spider91migrate/migrator.go index 6a0cbb6..d9e7328 100644 --- a/backend/internal/spider91migrate/migrator.go +++ b/backend/internal/spider91migrate/migrator.go @@ -1,5 +1,5 @@ // Package spider91migrate 周期性把 spider91 drive 下载到本地的视频 -// 上传到一个指定的目标 drive 目录(PikPak、115、123、OneDrive 或 Google Drive),上传成功后: +// 上传到一个指定的目标 drive 目录(PikPak、115、123、OneDrive、Google Drive 或联通网盘),上传成功后: // // - 改写 catalog 行:drive_id / file_id / content_hash 改成目标盘的; // 视频自身的 id 不变(仍是 spider91--),video_tags、 @@ -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、OneDrive 和 Google Drive 各自通过适配器满足。 +// 网盘都要实现它;当前 PikPak、115、123、OneDrive、Google Drive 和联通网盘各自通过适配器满足。 // // 这一层抽象把"迁移调用方"和"具体盘的 SDK 协议"解耦: // - PikPak 走 GCID + OSS PutObject(pikpak.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 GCID(PikPak)、MD5 HEX(123 / Google Drive)或 SHA1 HEX(115 / OneDrive),写入 catalog.content_hash 用于跨盘去重; +// Hash GCID(PikPak)、MD5 HEX(123 / Google Drive)或 SHA1 HEX(115 / OneDrive),写入 catalog.content_hash 用于跨盘去重;联通网盘暂为空; // Size 实际上传字节数。 type UploadResult struct { FileID string @@ -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 扫描目标 drive(PikPak、115、123、OneDrive 或 Google Drive)下所有 spider91-* 起始 ID 的视频, +// backfillFileNames 扫描目标 drive(PikPak、115、123、OneDrive、Google Drive 或联通网盘)下所有 spider91-* 起始 ID 的视频, // 对文件名不是 desiredPikPakName(...) 期望格式的,调 target.Rename 修正, // 并把 catalog.file_name 同步到新名字。 // diff --git a/backend/internal/spider91migrate/migrator_test.go b/backend/internal/spider91migrate/migrator_test.go index dab9314..b62f728 100644 --- a/backend/internal/spider91migrate/migrator_test.go +++ b/backend/internal/spider91migrate/migrator_test.go @@ -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) diff --git a/index.html b/index.html index 2e520d9..f498112 100644 --- a/index.html +++ b/index.html @@ -2,6 +2,7 @@ + diff --git a/src/admin/CrawlersPage.tsx b/src/admin/CrawlersPage.tsx index 404725f..7549f38 100644 --- a/src/admin/CrawlersPage.tsx +++ b/src/admin/CrawlersPage.tsx @@ -33,7 +33,7 @@ import { SpiderIcon } from "./icons/SpiderIcon"; const BUSY_STATES = new Set(["scanning", "generating", "uploading", "queued"]); const POLL_INTERVAL_MS = 5000; -const UPLOAD_TARGET_KINDS = new Set(["p115", "pikpak", "p123", "googledrive", "onedrive"]); +const UPLOAD_TARGET_KINDS = new Set(["p115", "pikpak", "p123", "googledrive", "onedrive", "wopan"]); function statusBusy(status?: api.DriveGenerationStatus) { return BUSY_STATES.has(status?.state ?? ""); diff --git a/src/admin/DrivesPage.tsx b/src/admin/DrivesPage.tsx index 1fe4d1f..31681ee 100644 --- a/src/admin/DrivesPage.tsx +++ b/src/admin/DrivesPage.tsx @@ -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] ); diff --git a/src/admin/api.ts b/src/admin/api.ts index f2be2f3..4e82538 100644 --- a/src/admin/api.ts +++ b/src/admin/api.ts @@ -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("/drives/wopan/qr", { method: "POST" }); +} + +export function getWopanQRStatus(uuid: string) { + return request(`/drives/wopan/qr/${encodeURIComponent(uuid)}`); +} + /** * 切换某个云盘的预览视频生成开关。点击网盘列表里行内的 toggle 按钮时调用。 * @@ -541,9 +563,9 @@ export type Theme = "dark" | "pink"; export type Settings = { theme: Theme; /** - * spider91 视频迁移到云盘时的目标 drive ID(必须是已挂载的 pikpak、p115、p123、onedrive 或 googledrive drive)。 + * spider91 视频迁移到云盘时的目标 drive ID(必须是已挂载的 pikpak、p115、p123、onedrive、googledrive 或 wopan drive)。 * - 空字符串:本地保存,不上传到云盘。 - * - 非空:显式指定。后端会校验 drive 存在且 kind ∈ {pikpak, p115, p123, onedrive, googledrive}。 + * - 非空:显式指定。后端会校验 drive 存在且 kind ∈ {pikpak, p115, p123, onedrive, googledrive, wopan}。 */ spider91UploadDriveId: string; }; diff --git a/src/admin/drive/DriveForm.tsx b/src/admin/drive/DriveForm.tsx index ce9cab4..8984c5d 100644 --- a/src/admin/drive/DriveForm.tsx +++ b/src/admin/drive/DriveForm.tsx @@ -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" && ( + + onChange({ + ...form, + creds: { + ...form.creds, + access_token: credentials.accessToken, + refresh_token: credentials.refreshToken, + ...(credentials.familyID ? { family_id: credentials.familyID } : {}), + }, + }) + } + /> + )} + {fields.map((f) => (
{f.type === "select" ? ( diff --git a/src/admin/drive/P123QRCodeLogin.tsx b/src/admin/drive/P123QRCodeLogin.tsx index 14ad2a2..fe5286b 100644 --- a/src/admin/drive/P123QRCodeLogin.tsx +++ b/src/admin/drive/P123QRCodeLogin.tsx @@ -113,11 +113,11 @@ export function P123QRCodeLogin({ onToken }: { onToken: (token: string) => void 123 云盘扫码登录二维码
- 使用微信或 123 云盘 App 扫码并确认登录;确认后系统会自动填入 access_token。 + 使用微信或 123网盘 App 扫码并确认登录;确认后系统会自动填入 access_token。
{session.expiresAt && (
diff --git a/src/admin/drive/WopanQRCodeLogin.tsx b/src/admin/drive/WopanQRCodeLogin.tsx new file mode 100644 index 0000000..af6705a --- /dev/null +++ b/src/admin/drive/WopanQRCodeLogin.tsx @@ -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(null); + const [status, setStatus] = useState(null); + const [starting, setStarting] = useState(false); + const [pollingError, setPollingError] = useState(""); + const [completed, setCompleted] = useState(false); + + async function start() { + setStarting(true); + setPollingError(""); + setCompleted(false); + setStatus(null); + try { + const next = await api.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 ( +
+ +
+
+ + {statusText} +
+ + {session && ( +
+ 联通网盘扫码登录二维码 +
+
+ 使用联通网盘 App 扫码并确认登录;确认后系统会自动填入 access_token 和 refresh_token。 +
+ {session.expiresAt && ( +
+ 过期时间:{new Date(session.expiresAt).toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + })} +
+ )} + {status?.state === 4 && ( +
+ 当前二维码已过期,请重新生成。 +
+ )} +
+
+ )} +
+
+ ); +} diff --git a/src/admin/drive/constants.ts b/src/admin/drive/constants.ts index e253020..72c6393 100644 --- a/src/admin/drive/constants.ts +++ b/src/admin/drive/constants.ts @@ -25,9 +25,9 @@ export function driveKindAbbr(kind: string): string { export const kindLabel: Record = { 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 = {}) { key: "password", label: "密码(可选)", - placeholder: "123 云盘密码", + placeholder: "123网盘密码", }, { key: "access_token", @@ -258,6 +258,7 @@ export function credentialFields(kind: Kind, creds: Record = {}) label: "access_token", placeholder: "", required: true, + help: "扫码成功后会自动填入该字段;如果 token 过期,重新扫码后保存即可。", }, { key: "refresh_token", diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index 6f69e0c..ee94fcb 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -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(); diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx index 5e84b3a..f521426 100644 --- a/src/components/VideoPlayer.tsx +++ b/src/components/VideoPlayer.tsx @@ -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(); diff --git a/src/styles/admin.css b/src/styles/admin.css index 6b3d2e0..bf61cc8 100644 --- a/src/styles/admin.css +++ b/src/styles/admin.css @@ -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 { diff --git a/tests/adminDriveForm.test.ts b/tests/adminDriveForm.test.ts index 222ba36..ac1c98f 100644 --- a/tests/adminDriveForm.test.ts +++ b/tests/adminDriveForm.test.ts @@ -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: "联通网盘" }, ]); }); diff --git a/tests/indexReferrerPolicy.test.ts b/tests/indexReferrerPolicy.test.ts new file mode 100644 index 0000000..99b0e40 --- /dev/null +++ b/tests/indexReferrerPolicy.test.ts @@ -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, + // + ); +}); diff --git a/tests/videoPlayerPoster.test.ts b/tests/videoPlayerPoster.test.ts index 440631b..c9dcac6 100644 --- a/tests/videoPlayerPoster.test.ts +++ b/tests/videoPlayerPoster.test.ts @@ -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,