feat(spider91,drives): 支持上传 115 + 每盘 Teaser 开关

* spider91 → 云盘迁移目标从仅 PikPak 扩展到 PikPak ∪ 115:
  - 115 driver 新增 UploadAndReportSha1(buffer 到 tmp 文件 + sha1 +
    SDK RapidUploadOrByMultipart + 父目录按 sha1 找 fileID)和 Rename
  - migrator 引入 uploadTarget 接口 + pikpakAdapter / p115Adapter,
    按 drive Kind() 路由;catalog 改写 / 本地清理 / 失败冷却 / backfill
    file_name 行为对两种目标盘统一。captcha 冷却仍只对 PikPak 4002/9 生效
  - App.Spider91UploadDriveID 校验放宽到 pikpak ∪ p115,自动选取在两类
    候选并存时拒绝(要求显式选定)
  - admin DrivesPage 在 spider91 表单里加"上传目标"下拉,文案按系统中
    实际挂载的盘 kind 自适应(只挂 PikPak 不会显示 115 字样,反之亦然)

* 全局 teaser 开关下沉为每盘 toggle 按钮:
  - drives 表加 teaser_enabled INTEGER NOT NULL DEFAULT 1
  - 删除 App.PreviewEnabled / SetPreviewEnabled / loadPreviewEnabled
    和 settings.previewEnabled 字段;前端删除 PreviewToggle 组件
  - 新增 catalog.SetDriveTeaserEnabled + POST /admin/api/drives/{id}/teaser-enabled
    接口;AdminServer 加 OnTeaserEnabledChanged hook,从关到开时立刻
    enqueueDriveGeneration 补扫 pending teaser
  - 网盘列表"操作"列加 Power / PowerOff toggle 按钮,乐观更新 + 失败回滚
  - 一次性迁移 resetDriveTeaserEnabledToDefaultOnce:把现存 drive 强制
    重置为开启,marker setting 记号防止重复(兼容短暂存在过的、把全局
    preview.enabled=0 同步成 per-drive=0 的中间版本)
  - 封面 worker 仍始终入队,开关只控制 teaser,避免越权

测试:go test ./... 全绿;npx tsc --noEmit / npm run build 通过。
This commit is contained in:
nianzhibai
2026-05-27 12:07:41 +08:00
parent 95bf67667a
commit ebd6943a10
15 changed files with 1044 additions and 285 deletions
+58 -76
View File
@@ -77,7 +77,6 @@ func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
app.loadPreviewEnabled(ctx)
app.loadTheme(ctx)
app.loadSpider91UploadDriveID(ctx)
if err := app.attachLocalUpload(ctx); err != nil {
@@ -148,9 +147,17 @@ func main() {
GetDriveGenerationStatuses: func() map[string]api.DriveGenerationStatuses {
return app.driveGenerationStatuses()
},
GetPreviewEnabled: func() bool { return app.PreviewEnabled() },
SetPreviewEnabled: func(enabled bool) error {
return app.SetPreviewEnabled(ctx, enabled)
OnTeaserEnabledChanged: func(driveID string, enabled bool) {
// 从关到开时立刻补扫该盘 pending teaser,行为对齐旧的"全局开关从关到开"。
// 关闭分支不需要做事 —— 入队前会重新查 catalog,新的 enqueue 自然停。
if !enabled {
return
}
app.mu.Lock()
worker := app.workers[driveID]
thumbWorker := app.thumbWorkers[driveID]
app.mu.Unlock()
go app.enqueueDriveGeneration(ctx, driveID, worker, thumbWorker)
},
GetTheme: func() string { return app.Theme() },
SetTheme: func(theme string) error {
@@ -213,71 +220,31 @@ type App struct {
// spider91Crawlers 按 driveID 索引,每个 spider91 drive 独立一个 Crawler
spider91Crawlers map[string]*spider91.Crawler
// 运行时 preview 开关(从 DB 读)
previewEnabled bool
// 全站主题("dark" | "pink"),从 DB 读
theme string
// 显式指定的 spider91 → PikPak 上传目标 drive ID
// 未设置时由 Spider91UploadDriveID() 自动挑唯一的 PikPak drive。
// 显式指定的 spider91 上传目标 drive ID
// 未设置时由 Spider91UploadDriveID() 在所有 pikpak/p115 drive 中自动挑选唯一一个
spider91UploadDriveID string
// spider91Migrator 周期把 spider91 视频上传到 PikPak
// spider91Migrator 周期把 spider91 视频上传到目标 drivePikPak 或 115
spider91Migrator *spider91migrate.Migrator
}
// PreviewEnabled 线程安全读
func (a *App) PreviewEnabled() bool {
a.mu.Lock()
defer a.mu.Unlock()
return a.previewEnabled
}
// SetPreviewEnabled 切换开关,写库 + 若开启则立刻补扫 pending
func (a *App) SetPreviewEnabled(ctx context.Context, enabled bool) error {
a.mu.Lock()
a.previewEnabled = enabled
a.mu.Unlock()
val := "0"
if enabled {
val = "1"
}
if err := a.cat.SetSetting(ctx, "preview.enabled", val); err != nil {
return err
}
if enabled {
// 异步补扫所有盘
go func() {
for _, d := range a.registry.All() {
a.mu.Lock()
w := a.workers[d.ID()]
tw := a.thumbWorkers[d.ID()]
a.mu.Unlock()
a.enqueueDriveGeneration(ctx, d.ID(), w, tw)
}
}()
}
return nil
}
// loadPreviewEnabled 从 DB 读运行时开关,首次启动取 config 默认值
func (a *App) loadPreviewEnabled(ctx context.Context) {
def := "0"
if a.cfg.Preview.Enabled {
def = "1"
}
v, err := a.cat.GetSetting(ctx, "preview.enabled", def)
// teaserEnabledForDrive 查询某个 drive 当前的 per-drive teaser 开关。
//
// teaser 生成不再由全局 setting 控制,而是由 catalog.drives.teaser_enabled
// 决定。任何"是否入队 preview worker"的判断都应通过这个方法读,避免把状态
// 散落到 App 内存里和 DB 不一致。
//
// 读 catalog 失败时退化成 false(不生成):比 "默认开" 更安全 —— 读不到状态时
// 倾向不消耗 ffmpeg;调用方会记日志,运维能立刻看到问题。
func (a *App) teaserEnabledForDrive(ctx context.Context, driveID string) bool {
d, err := a.cat.GetDrive(ctx, driveID)
if err != nil {
log.Printf("[preview] load setting: %v (fallback to config)", err)
a.mu.Lock()
a.previewEnabled = a.cfg.Preview.Enabled
a.mu.Unlock()
return
log.Printf("[preview] read teaser_enabled drive=%s: %v (treating as disabled)", driveID, err)
return false
}
a.mu.Lock()
a.previewEnabled = v == "1"
a.mu.Unlock()
return d.TeaserEnabled
}
// Theme 线程安全读当前主题。
@@ -319,26 +286,33 @@ func (a *App) loadTheme(ctx context.Context) {
a.mu.Unlock()
}
// Spider91UploadDriveID 返回当前生效的 PikPak 上传目标 drive ID。
// 优先返回管理员显式设置的值;未设置时,如果系统中只有一个 pikpak drive
// 返回它;多个或没有则返回空串(迁移 worker 跳过)。
// Spider91UploadDriveID 返回当前生效的 spider91 上传目标 drive ID。
//
// 解析顺序:
// 1. 管理员通过 PUT /admin/api/settings 显式设置过 → 验证该 drive 仍存在且是
// 合法目标盘(pikpak 或 p115)→ 返回该 ID。
// 2. 否则系统中如果只有一个合法目标盘(即 pikpak drive 数量+p115 drive 数量==1),
// 自动返回它。这样单网盘场景"开箱即用"。
// 3. 多个候选并存时返回空串:迁移 worker 静默跳过,等管理员显式指定。
//
// 注意"合法目标盘"目前是 pikpak ∪ p115。后续添加新的可上传盘要在两个分支同步加。
func (a *App) Spider91UploadDriveID() string {
a.mu.Lock()
explicit := a.spider91UploadDriveID
a.mu.Unlock()
if explicit != "" {
// 验证显式设置的 drive 仍然存在且是 pikpak 类型;不在则降级到自动选取
if d, ok := a.registry.Get(explicit); ok && d.Kind() == "pikpak" {
// 验证显式设置的 drive 仍然存在且 kind 合法;不在则降级到自动选取
if d, ok := a.registry.Get(explicit); ok && isSpider91UploadKind(d.Kind()) {
return explicit
}
}
var found string
for _, d := range a.registry.All() {
if d.Kind() != "pikpak" {
if !isSpider91UploadKind(d.Kind()) {
continue
}
if found != "" {
// 多个 PikPak drive 时不自动选;管理员必须显式指定
// 多个候选 drive 时不自动选;管理员必须显式指定
return ""
}
found = d.ID()
@@ -346,9 +320,9 @@ func (a *App) Spider91UploadDriveID() string {
return found
}
// SetSpider91UploadDriveID 设置 PikPak 上传目标 drive ID 并持久化。
// SetSpider91UploadDriveID 设置 spider91 上传目标 drive ID 并持久化。
// 接受空字符串(清除显式设置,回退到自动模式)。
// 设置一个不存在或不是 pikpak 类型的 drive 会返回错误。
// 设置一个不存在或 kind 不是 pikpak / p115 的 drive 会返回错误。
func (a *App) SetSpider91UploadDriveID(ctx context.Context, driveID string) error {
driveID = strings.TrimSpace(driveID)
if driveID != "" {
@@ -356,8 +330,8 @@ func (a *App) SetSpider91UploadDriveID(ctx context.Context, driveID string) erro
if !ok {
return fmt.Errorf("drive %q not found", driveID)
}
if d.Kind() != "pikpak" {
return fmt.Errorf("drive %q kind=%s, only pikpak can be spider91 upload target", driveID, d.Kind())
if !isSpider91UploadKind(d.Kind()) {
return fmt.Errorf("drive %q kind=%s, only pikpak or p115 can be spider91 upload target", driveID, d.Kind())
}
}
a.mu.Lock()
@@ -366,6 +340,12 @@ func (a *App) SetSpider91UploadDriveID(ctx context.Context, driveID string) erro
return a.cat.SetSetting(ctx, "spider91.upload_drive_id", driveID)
}
// isSpider91UploadKind 是 spider91 迁移目标盘的 allowlist。
// 与 spider91migrate.adaptUploadTarget 的支持范围保持一致。
func isSpider91UploadKind(kind string) bool {
return kind == "pikpak" || kind == "p115"
}
// loadSpider91UploadDriveID 从 DB 读上传目标 drive ID 设置;不存在时使用空串。
func (a *App) loadSpider91UploadDriveID(ctx context.Context) {
v, err := a.cat.GetSetting(ctx, "spider91.upload_drive_id", "")
@@ -643,9 +623,10 @@ func (a *App) attachSpider91Crawler(d *catalog.Drive, drv *spider91.Driver) {
// 新视频入库后,触发 teaser worker(不需要 thumb worker,封面已就绪)
a.mu.Lock()
worker := a.workers[driveID]
previewEnabled := a.previewEnabled
a.mu.Unlock()
if previewEnabled && worker != nil {
// 这里没有外层 ctx —— Crawler 是后台 workerOnNewVideo 是 fire-and-forget 回调。
// 用 Background ctx 查 catalog 即可(teaserEnabledForDrive 内部仅做一次 GetDrive)。
if worker != nil && a.teaserEnabledForDrive(context.Background(), driveID) {
worker.Enqueue(v)
}
},
@@ -730,10 +711,12 @@ func (a *App) enqueuePending(ctx context.Context, driveID string, w *preview.Wor
}
func (a *App) enqueueDriveGeneration(ctx context.Context, driveID string, worker *preview.Worker, thumbWorker *preview.ThumbWorker) {
// 封面 worker 始终入队(与早期"全局 preview.enabled=false 时仍然生成封面"
// 的行为一致);teaser worker 仅在该 drive 的 TeaserEnabled 为 true 时入队。
if thumbWorker != nil {
a.enqueueThumbnails(ctx, driveID, thumbWorker)
}
if !a.PreviewEnabled() || worker == nil {
if worker == nil || !a.teaserEnabledForDrive(ctx, driveID) {
return
}
if thumbWorker != nil && !a.waitForThumbnailsBeforePreview(ctx, driveID) {
@@ -986,13 +969,12 @@ func (a *App) enqueueUploadedVideo(ctx context.Context, v *catalog.Video) {
a.mu.Lock()
worker := a.workers[v.DriveID]
thumbWorker := a.thumbWorkers[v.DriveID]
previewEnabled := a.previewEnabled
a.mu.Unlock()
if thumbWorker != nil && v.ThumbnailURL == "" {
thumbWorker.Enqueue(v)
}
if previewEnabled && worker != nil {
if worker != nil && a.teaserEnabledForDrive(ctx, v.DriveID) {
worker.Enqueue(v)
}
}
+24 -6
View File
@@ -16,7 +16,7 @@ import (
"github.com/video-site/backend/internal/preview"
)
func TestRegisterPreviewWorkerBackfillsPendingWhenPreviewEnabled(t *testing.T) {
func TestRegisterPreviewWorkerBackfillsPendingWhenDriveTeaserEnabled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -30,6 +30,7 @@ func TestRegisterPreviewWorkerBackfillsPendingWhenPreviewEnabled(t *testing.T) {
}
})
seedDriveWithTeaser(t, cat, "drive-id", true)
video := &catalog.Video{
ID: "video-1",
DriveID: "drive-id",
@@ -48,7 +49,6 @@ func TestRegisterPreviewWorkerBackfillsPendingWhenPreviewEnabled(t *testing.T) {
cat: cat,
workers: make(map[string]*preview.Worker),
thumbWorkers: make(map[string]*preview.ThumbWorker),
previewEnabled: true,
}
worker := preview.NewWorker(&serverFakeTeaserGenerator{}, cat, &serverFakeDrive{})
go worker.Run(ctx)
@@ -91,6 +91,7 @@ func TestRegisterPreviewWorkersGenerateThumbnailsBeforePreviews(t *testing.T) {
}
})
seedDriveWithTeaser(t, cat, "drive-id", true)
now := time.Now()
for _, v := range []*catalog.Video{
{ID: "video-1", DriveID: "drive-id", FileID: "file-1", Title: "Clip 1", PreviewStatus: "pending"},
@@ -108,7 +109,6 @@ func TestRegisterPreviewWorkersGenerateThumbnailsBeforePreviews(t *testing.T) {
cat: cat,
workers: make(map[string]*preview.Worker),
thumbWorkers: make(map[string]*preview.ThumbWorker),
previewEnabled: true,
}
gen := &serverFakeTeaserGenerator{}
drv := &serverFakeDrive{}
@@ -167,6 +167,7 @@ func TestFailedThumbnailsDoNotBlockPreviewGeneration(t *testing.T) {
}
})
seedDriveWithTeaser(t, cat, "drive-id", true)
now := time.Now()
video := &catalog.Video{
ID: "video-failed-thumb",
@@ -196,7 +197,6 @@ func TestFailedThumbnailsDoNotBlockPreviewGeneration(t *testing.T) {
cat: cat,
workers: make(map[string]*preview.Worker),
thumbWorkers: make(map[string]*preview.ThumbWorker),
previewEnabled: true,
}
gen := &serverFakeTeaserGenerator{}
drv := &serverFakeDrive{}
@@ -244,6 +244,8 @@ func TestRegenFailedPreviewsQueuesOnlyFailedVideosForDrive(t *testing.T) {
}
})
seedDriveWithTeaser(t, cat, "drive-id", true)
seedDriveWithTeaser(t, cat, "other-drive", true)
now := time.Now()
for _, v := range []*catalog.Video{
{ID: "target-failed", DriveID: "drive-id", FileID: "file-1", Title: "Target Failed", PreviewStatus: "failed"},
@@ -262,7 +264,6 @@ func TestRegenFailedPreviewsQueuesOnlyFailedVideosForDrive(t *testing.T) {
cat: cat,
workers: make(map[string]*preview.Worker),
thumbWorkers: make(map[string]*preview.ThumbWorker),
previewEnabled: true,
}
worker := preview.NewWorker(&serverFakeTeaserGenerator{}, cat, &serverFakeDrive{})
go worker.Run(ctx)
@@ -324,6 +325,7 @@ func TestEnqueueUploadedVideoQueuesLocalPreviewWorker(t *testing.T) {
}
})
seedDriveWithTeaser(t, cat, "local-upload", true)
video := &catalog.Video{
ID: "local-upload-video",
DriveID: "local-upload",
@@ -342,7 +344,6 @@ func TestEnqueueUploadedVideoQueuesLocalPreviewWorker(t *testing.T) {
cat: cat,
workers: make(map[string]*preview.Worker),
thumbWorkers: make(map[string]*preview.ThumbWorker),
previewEnabled: true,
}
worker := preview.NewWorker(&serverFakeTeaserGenerator{}, cat, &serverLocalUploadFakeDrive{})
go worker.Run(ctx)
@@ -581,3 +582,20 @@ type serverLocalUploadFakeDrive struct {
}
func (d *serverLocalUploadFakeDrive) ID() string { return "local-upload" }
// seedDriveWithTeaser 在 catalog 里 upsert 一个测试用的 drive 行,把 TeaserEnabled
// 设为 enabled。teaser 入队判断现在按 per-drive 而不是全局 setting,所以涉及到
// teaser worker 的测试都要先把 drive 行写进 catalog。
func seedDriveWithTeaser(t *testing.T, cat *catalog.Catalog, driveID string, enabled bool) {
t.Helper()
if err := cat.UpsertDrive(context.Background(), &catalog.Drive{
ID: driveID,
Kind: "fake",
Name: driveID,
RootID: "0",
TeaserEnabled: enabled,
}); err != nil {
t.Fatalf("seed drive: %v", err)
}
}
+81 -31
View File
@@ -1,6 +1,7 @@
package api
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
@@ -25,9 +26,10 @@ type AdminServer struct {
OnRegenAllPreviews func()
OnRegenFailedPreviews func(driveID string)
GetDriveGenerationStatuses func() map[string]DriveGenerationStatuses
// Preview 开关读写
GetPreviewEnabled func() bool
SetPreviewEnabled func(enabled bool) error
// OnTeaserEnabledChanged 在 per-drive teaser 开关被切换后调用。
// enabled=true 时上层应该重新把 pending teaser 入队(类似旧的全局开关从关到开);
// enabled=false 时通常不用做事 —— worker 入队前会再次查 catalog,自然停止。
OnTeaserEnabledChanged func(driveID string, enabled bool)
// Theme 读写("dark" | "pink"
GetTheme func() string
SetTheme func(theme string) error
@@ -65,6 +67,7 @@ func (a *AdminServer) Register(r chi.Router) {
r.Post("/drives", a.handleUpsertDrive)
r.Delete("/drives/{id}", a.handleDeleteDrive)
r.Post("/drives/{id}/rescan", a.handleRescan)
r.Post("/drives/{id}/teaser-enabled", a.handleSetDriveTeaserEnabled)
r.Post("/drives/{id}/previews/failed/regenerate", a.handleRegenFailedPreviews)
// 视频
@@ -156,6 +159,8 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) {
Status string `json:"status"`
LastError string `json:"lastError,omitempty"`
HasCredential bool `json:"hasCredential"`
// TeaserEnabled 控制是否给本盘生成 teaser/封面。前端用它在网盘列表/编辑表单展示开关状态。
TeaserEnabled bool `json:"teaserEnabled"`
// LastCrawlAt 是 spider91 上次成功爬取的 unix 秒(来自 credentials.last_crawl_at)。
// 其它 kind 留 0;前端用它显示"上次抓取: N 小时前"。
LastCrawlAt int64 `json:"lastCrawlAt,omitempty"`
@@ -205,6 +210,7 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) {
RootID: d.RootID, ScanRootID: d.ScanRootID,
Status: d.Status, LastError: d.LastError,
HasCredential: hasCred,
TeaserEnabled: d.TeaserEnabled,
LastCrawlAt: lastCrawlAt,
ThumbnailGenerationStatus: generation.Thumbnail,
PreviewGenerationStatus: generation.Preview,
@@ -226,6 +232,10 @@ type upsertDriveReq struct {
RootID string `json:"rootId"`
ScanRootID string `json:"scanRootId"`
Credentials map[string]string `json:"credentials"`
// TeaserEnabled 是 per-drive teaser/封面生成开关。
// 用 *bool 区分 "未传" / "传了 false":未传时表示客户端不打算改这个字段,
// 沿用 catalog 现有值;新建时未传一律默认开启(true)。
TeaserEnabled *bool `json:"teaserEnabled,omitempty"`
}
func (a *AdminServer) handleUpsertDrive(w http.ResponseWriter, r *http.Request) {
@@ -238,16 +248,33 @@ func (a *AdminServer) handleUpsertDrive(w http.ResponseWriter, r *http.Request)
http.Error(w, "id and kind are required", http.StatusBadRequest)
return
}
if len(body.Credentials) == 0 {
if existing, err := a.Catalog.GetDrive(r.Context(), body.ID); err == nil && len(existing.Credentials) > 0 {
body.Credentials = existing.Credentials
}
// 凭证 / TeaserEnabled 都支持 "未传 = 沿用旧值":先把现存 drive 拉出来一次。
var existing *catalog.Drive
if existingDrive, err := a.Catalog.GetDrive(r.Context(), body.ID); err == nil {
existing = existingDrive
}
if len(body.Credentials) == 0 && existing != nil && len(existing.Credentials) > 0 {
body.Credentials = existing.Credentials
}
// teaserEnabled 解析顺序:
// 1. 请求显式带了 → 用请求值
// 2. 请求没带 + 编辑现有 drive → 沿用旧值
// 3. 请求没带 + 新建 drive → 默认 true(用户没特别说就生成)
teaserEnabled := true
switch {
case body.TeaserEnabled != nil:
teaserEnabled = *body.TeaserEnabled
case existing != nil:
teaserEnabled = existing.TeaserEnabled
}
d := &catalog.Drive{
ID: body.ID, Kind: body.Kind, Name: body.Name,
RootID: body.RootID, ScanRootID: body.ScanRootID,
Credentials: body.Credentials,
Status: "disconnected",
Credentials: body.Credentials,
Status: "disconnected",
TeaserEnabled: teaserEnabled,
}
if err := a.Catalog.UpsertDrive(r.Context(), d); err != nil {
writeErr(w, http.StatusInternalServerError, err)
@@ -282,6 +309,45 @@ func (a *AdminServer) handleRescan(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusAccepted, map[string]any{"ok": true})
}
// teaserEnabledReq 是 POST /admin/api/drives/{id}/teaser-enabled 的入参。
type teaserEnabledReq struct {
Enabled bool `json:"enabled"`
}
// handleSetDriveTeaserEnabled 切换某盘的 teaser 生成开关。
//
// 行为:
// - 写 catalog.drives.teaser_enabled
// - 调 OnTeaserEnabledChangedmain 注入;从关到开时会重新入队 pending teaser
// - 返回切换后的新值,方便前端乐观更新但又能以服务端为准
//
// 与 upsertDrive 的区别:那条接口要重传 kind / name / rootId 等,开关切换不该
// 牵连这些字段(顺手覆盖凭证或 rootID 容易出 bug)。所以单独走一条。
func (a *AdminServer) handleSetDriveTeaserEnabled(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "id is required", http.StatusBadRequest)
return
}
var body teaserEnabledReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
if err := a.Catalog.SetDriveTeaserEnabled(r.Context(), id, body.Enabled); err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "drive not found", http.StatusNotFound)
return
}
writeErr(w, http.StatusInternalServerError, err)
return
}
if a.OnTeaserEnabledChanged != nil {
a.OnTeaserEnabledChanged(id, body.Enabled)
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "teaserEnabled": body.Enabled})
}
func (a *AdminServer) handleAdminListVideos(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
page, _ := strconv.Atoi(q.Get("page"))
@@ -435,17 +501,17 @@ func (a *AdminServer) handleRegenFailedPreviews(w http.ResponseWriter, r *http.R
// ---------- Settings ----------
// settingsDTO 是 GET/PUT /admin/api/settings 的入参/出参。
//
// 注意:早期的全局 previewEnabled 字段已经下沉为每盘 teaser_enabled
// 不再出现在这里;前端要切换某个盘的 teaser 生成请用 POST /admin/api/drives 上传
// teaserEnabled 字段。保留 settings 用作主题、spider91 上传目标这类全局配置。
type settingsDTO struct {
PreviewEnabled bool `json:"previewEnabled"`
Theme string `json:"theme"`
Spider91UploadDriveID string `json:"spider91UploadDriveId"`
}
func (a *AdminServer) handleGetSettings(w http.ResponseWriter, r *http.Request) {
enabled := false
if a.GetPreviewEnabled != nil {
enabled = a.GetPreviewEnabled()
}
theme := "dark"
if a.GetTheme != nil {
if v := a.GetTheme(); v != "" {
@@ -457,14 +523,13 @@ func (a *AdminServer) handleGetSettings(w http.ResponseWriter, r *http.Request)
spider91UploadID = a.GetSpider91UploadDriveID()
}
writeJSON(w, http.StatusOK, settingsDTO{
PreviewEnabled: enabled,
Theme: theme,
Spider91UploadDriveID: spider91UploadID,
})
}
func (a *AdminServer) handlePutSettings(w http.ResponseWriter, r *http.Request) {
// 用 map 区分"没传"和"传了空字符串"两种语义;空 PikPak 上传 ID 表示
// 用 map 区分"没传"和"传了空字符串"两种语义;空 spider91 上传 ID 表示
// 清除显式设置(回退到自动模式)。
var raw map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
@@ -472,18 +537,6 @@ func (a *AdminServer) handlePutSettings(w http.ResponseWriter, r *http.Request)
return
}
if v, ok := raw["previewEnabled"]; ok && a.SetPreviewEnabled != nil {
var enabled bool
if err := json.Unmarshal(v, &enabled); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
if err := a.SetPreviewEnabled(enabled); err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
}
if v, ok := raw["theme"]; ok && a.SetTheme != nil {
var theme string
if err := json.Unmarshal(v, &theme); err != nil {
@@ -512,9 +565,6 @@ func (a *AdminServer) handlePutSettings(w http.ResponseWriter, r *http.Request)
// 回显当前值
resp := settingsDTO{}
if a.GetPreviewEnabled != nil {
resp.PreviewEnabled = a.GetPreviewEnabled()
}
if a.GetTheme != nil {
resp.Theme = a.GetTheme()
}
+47 -17
View File
@@ -894,8 +894,11 @@ type Drive struct {
Credentials map[string]string `json:"credentials,omitempty"`
Status string `json:"status"`
LastError string `json:"lastError,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// TeaserEnabled 控制是否给本盘生成 teaser/封面。
// 替代早期的全局 preview.enabled 开关;新建 drive 时 UpsertDrive 默认置 true。
TeaserEnabled bool `json:"teaserEnabled"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (c *Catalog) UpsertDrive(ctx context.Context, d *Drive) error {
@@ -906,24 +909,25 @@ func (c *Catalog) UpsertDrive(ctx context.Context, d *Drive) error {
}
d.UpdatedAt = time.UnixMilli(now)
_, err := c.db.ExecContext(ctx, `
INSERT INTO drives (id, kind, name, root_id, scan_root_id, credentials, status, last_error, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO drives (id, kind, name, root_id, scan_root_id, credentials, status, last_error, teaser_enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
kind = excluded.kind,
name = excluded.name,
root_id = excluded.root_id,
scan_root_id = excluded.scan_root_id,
credentials = excluded.credentials,
status = excluded.status,
last_error = excluded.last_error,
updated_at = excluded.updated_at
`, d.ID, d.Kind, d.Name, d.RootID, d.ScanRootID, string(cred), d.Status, d.LastError,
kind = excluded.kind,
name = excluded.name,
root_id = excluded.root_id,
scan_root_id = excluded.scan_root_id,
credentials = excluded.credentials,
status = excluded.status,
last_error = excluded.last_error,
teaser_enabled = excluded.teaser_enabled,
updated_at = excluded.updated_at
`, d.ID, d.Kind, d.Name, d.RootID, d.ScanRootID, string(cred), d.Status, d.LastError, boolToInt(d.TeaserEnabled),
d.CreatedAt.UnixMilli(), d.UpdatedAt.UnixMilli())
return err
}
func (c *Catalog) ListDrives(ctx context.Context) ([]*Drive, error) {
rows, err := c.db.QueryContext(ctx, `SELECT id, kind, name, root_id, COALESCE(scan_root_id, ''), COALESCE(credentials, '{}'), status, COALESCE(last_error, ''), created_at, updated_at FROM drives ORDER BY created_at ASC`)
rows, err := c.db.QueryContext(ctx, `SELECT id, kind, name, root_id, COALESCE(scan_root_id, ''), COALESCE(credentials, '{}'), status, COALESCE(last_error, ''), COALESCE(teaser_enabled, 1), created_at, updated_at FROM drives ORDER BY created_at ASC`)
if err != nil {
return nil, err
}
@@ -932,11 +936,13 @@ func (c *Catalog) ListDrives(ctx context.Context) ([]*Drive, error) {
for rows.Next() {
d := &Drive{}
var credsStr string
var teaserEnabled int
var createdAt, updatedAt int64
if err := rows.Scan(&d.ID, &d.Kind, &d.Name, &d.RootID, &d.ScanRootID, &credsStr, &d.Status, &d.LastError, &createdAt, &updatedAt); err != nil {
if err := rows.Scan(&d.ID, &d.Kind, &d.Name, &d.RootID, &d.ScanRootID, &credsStr, &d.Status, &d.LastError, &teaserEnabled, &createdAt, &updatedAt); err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(credsStr), &d.Credentials)
d.TeaserEnabled = teaserEnabled != 0
d.CreatedAt = time.UnixMilli(createdAt)
d.UpdatedAt = time.UnixMilli(updatedAt)
out = append(out, d)
@@ -945,14 +951,16 @@ func (c *Catalog) ListDrives(ctx context.Context) ([]*Drive, error) {
}
func (c *Catalog) GetDrive(ctx context.Context, id string) (*Drive, error) {
row := c.db.QueryRowContext(ctx, `SELECT id, kind, name, root_id, COALESCE(scan_root_id, ''), COALESCE(credentials, '{}'), status, COALESCE(last_error, ''), created_at, updated_at FROM drives WHERE id = ?`, id)
row := c.db.QueryRowContext(ctx, `SELECT id, kind, name, root_id, COALESCE(scan_root_id, ''), COALESCE(credentials, '{}'), status, COALESCE(last_error, ''), COALESCE(teaser_enabled, 1), created_at, updated_at FROM drives WHERE id = ?`, id)
d := &Drive{}
var credsStr string
var teaserEnabled int
var createdAt, updatedAt int64
if err := row.Scan(&d.ID, &d.Kind, &d.Name, &d.RootID, &d.ScanRootID, &credsStr, &d.Status, &d.LastError, &createdAt, &updatedAt); err != nil {
if err := row.Scan(&d.ID, &d.Kind, &d.Name, &d.RootID, &d.ScanRootID, &credsStr, &d.Status, &d.LastError, &teaserEnabled, &createdAt, &updatedAt); err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(credsStr), &d.Credentials)
d.TeaserEnabled = teaserEnabled != 0
d.CreatedAt = time.UnixMilli(createdAt)
d.UpdatedAt = time.UnixMilli(updatedAt)
return d, nil
@@ -963,6 +971,28 @@ func (c *Catalog) DeleteDrive(ctx context.Context, id string) error {
return err
}
// SetDriveTeaserEnabled 切换某盘的 teaser/封面生成开关。
//
// 与 UpsertDrive 的区别:只动 teaser_enabled + updated_at 一列,不要求调用方
// 重传 kind / name / credentials 等容易踩坑的字段。
//
// drive 不存在时返回 sql.ErrNoRows,调用方可以照此返回 404。
func (c *Catalog) SetDriveTeaserEnabled(ctx context.Context, id string, enabled bool) error {
if id == "" {
return fmt.Errorf("catalog: set drive teaser_enabled: empty id")
}
res, err := c.db.ExecContext(ctx,
`UPDATE drives SET teaser_enabled = ?, updated_at = ? WHERE id = ?`,
boolToInt(enabled), time.Now().UnixMilli(), id)
if err != nil {
return err
}
if rows, err := res.RowsAffected(); err == nil && rows == 0 {
return sql.ErrNoRows
}
return nil
}
// ---------- Admin session ----------
func (c *Catalog) CreateSession(ctx context.Context, token string, ttl time.Duration) error {
+4 -1
View File
@@ -61,13 +61,16 @@ CREATE INDEX IF NOT EXISTS idx_video_tags_video ON video_tags(video_id);
-- 网盘账户
CREATE TABLE IF NOT EXISTS drives (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL, -- quark / p115 / pikpak / wopan / onedrive
kind TEXT NOT NULL, -- quark / p115 / pikpak / wopan / onedrive / spider91
name TEXT NOT NULL,
root_id TEXT NOT NULL DEFAULT '0',
scan_root_id TEXT, -- 扫描起点(默认 root_id
credentials TEXT, -- JSON: cookie / refresh_token 等
status TEXT DEFAULT 'disconnected', -- disconnected / ok / error
last_error TEXT,
-- 是否给该盘生成 teaser/封面:1 开 / 0 关。
-- 替代了早期的全局 preview.enabled 设置(保留旧 setting 行不再读)。
teaser_enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
+58 -5
View File
@@ -51,6 +51,20 @@ func (c *Catalog) migrate(ctx context.Context) error {
if err := c.addColumnIfMissing(ctx, "videos", "thumbnail_status", "TEXT DEFAULT 'pending'"); err != nil {
return err
}
// drives.teaser_enabled:每盘 teaser 开关,替代旧的全局 preview.enabled。
// 升级路径:直接让 ALTER TABLE 的 DEFAULT 1 兜底 —— 每个现存 drive 都默认开启,
// 不读旧的 settings.preview.enabled 字段。这样老用户即便之前关过全局开关,
// 升级后所有盘也都恢复"默认生成 teaser",跟新建保持一致。
if _, err := c.addColumnIfMissingReportNew(ctx, "drives", "teaser_enabled", "INTEGER NOT NULL DEFAULT 1"); err != nil {
return err
}
// 一次性修正:早期版本(短暂存在过)会把现存 drive 的 teaser_enabled 同步成
// 旧的全局 preview.enabled 值,导致升级后所有 drive 都是关。"默认开启"约定下,
// 这里一次性把所有 drive 强制重置为 1,并用 marker setting 记号,避免之后
// 再覆盖用户后续在 UI 里 per-drive 改成关的设置。
if err := c.resetDriveTeaserEnabledToDefaultOnce(ctx); err != nil {
return err
}
if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_videos_content_hash ON videos(content_hash)`); err != nil {
return err
}
@@ -85,9 +99,19 @@ func (c *Catalog) migrate(ctx context.Context) error {
}
func (c *Catalog) addColumnIfMissing(ctx context.Context, table, column, definition string) error {
_, err := c.addColumnIfMissingReportNew(ctx, table, column, definition)
return err
}
// addColumnIfMissingReportNew 与 addColumnIfMissing 同步,但额外返回 added=true 表示
// 本次确实创建了新列(即旧 schema 缺这列),方便调用方仅在迁移路径里补做一次性
// 数据初始化(如把全局 setting 同步到新 per-drive 字段)。
//
// 已存在该列时返回 added=false,任何 ALTER TABLE 错误也直接透传。
func (c *Catalog) addColumnIfMissingReportNew(ctx context.Context, table, column, definition string) (bool, error) {
rows, err := c.db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
if err != nil {
return err
return false, err
}
defer rows.Close()
for rows.Next() {
@@ -97,14 +121,43 @@ func (c *Catalog) addColumnIfMissing(ctx context.Context, table, column, definit
var defaultValue any
var pk int
if err := rows.Scan(&cid, &name, &typ, &notNull, &defaultValue, &pk); err != nil {
return err
return false, err
}
if strings.EqualFold(name, column) {
return nil
return false, nil
}
}
_, err = c.db.ExecContext(ctx, `ALTER TABLE `+table+` ADD COLUMN `+column+` `+definition)
return err
if _, err := c.db.ExecContext(ctx, `ALTER TABLE `+table+` ADD COLUMN `+column+` `+definition); err != nil {
return false, err
}
return true, nil
}
// resetDriveTeaserEnabledToDefaultOnce 把所有现存 drive 的 teaser_enabled 强制
// 设为 1(开启),但仅在历史上没跑过这条迁移时执行(用 marker setting 记号)。
//
// 为什么需要:早期短暂存在过的版本会从旧的全局 preview.enabled = "0" 同步到
// 所有 drive 的 teaser_enabled = 0;用户报告升级后页面全显示"Teaser 关"。新版
// 约定 per-drive 默认开启,所以这里跑一次性修正。
//
// 幂等保证:marker setting 设过了就不再跑,确保用户在 UI 里把某盘关了不会被
// 重启时反复打开。
func (c *Catalog) resetDriveTeaserEnabledToDefaultOnce(ctx context.Context) error {
const markerKey = "drives.teaser_enabled.default_open_migrated"
marker, err := c.GetSetting(ctx, markerKey, "")
if err != nil {
return fmt.Errorf("read %s marker: %w", markerKey, err)
}
if strings.TrimSpace(marker) == "1" {
return nil
}
if _, err := c.db.ExecContext(ctx, `UPDATE drives SET teaser_enabled = 1, updated_at = ?`, time.Now().UnixMilli()); err != nil {
return fmt.Errorf("reset teaser_enabled to default: %w", err)
}
if err := c.SetSetting(ctx, markerKey, "1"); err != nil {
return fmt.Errorf("write %s marker: %w", markerKey, err)
}
return nil
}
func (c *Catalog) clearVolatileOneDriveThumbnails(ctx context.Context) error {
+176 -15
View File
@@ -2,11 +2,14 @@ package p115
import (
"context"
"crypto/sha1"
"encoding/hex"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path"
"strings"
"sync"
@@ -222,29 +225,187 @@ func (d *Driver) downloadInfo(pickCode string, ua string) (*sdk.DownloadInfo, st
}
func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) {
// 115 上传流程比较复杂:RapidUpload -> OSS 分片
// 第一版 teaser 文件小(<2MB),直接读全量写 seeker,走 RapidUploadOrByOSS
buf, err := io.ReadAll(r)
res, err := d.UploadAndReportSha1(ctx, parentID, name, r, size)
if err != nil {
return "", err
}
rs := strings.NewReader(string(buf))
if err := d.client.RapidUploadOrByOSS(parentID, name, size, rs); err != nil {
return "", fmt.Errorf("115 upload: %w", err)
return res.FileID, nil
}
// UploadResult 是 UploadAndReportSha1 的返回值。
//
// FileID: 上传后 115 给该文件分配的 ID(在父目录里能查到)。
// Sha1: 文件的 SHA1HEX 大写,与 115 的 sha1 格式一致),可直接写入 catalog.content_hash。
// Size: 实际上传的字节数(如调用时 size>0 应当与传入一致)。
type UploadResult struct {
FileID string
Sha1 string
Size int64
}
// UploadAndReportSha1 把 r 上传到 parentID 目录下的指定文件名,返回新文件元数据。
//
// 实现要点(参考 OpenList drivers/115):
//
// 1. 把 r 全量缓冲到本地临时文件并同时算 SHA1,避免在内存里堆 100MB 视频;
// 拿到 *os.File 后才能进 SDK 的 RapidUploadOrByMultipart 分片上传通道。
// 2. SDK 的 RapidUploadOrByMultipart 内部会:
// a. 调 /upload/init 走 ECDH 加密的秒传协议,命中即结束;
// b. 对 status=7 自动做范围 SHA1 二次校验后重试;
// c. 未命中且 size<=1KB 走 OSS PutObject;否则按 fileSize<i*GB → i*1000 片切分调 OSS multipart。
// 3. SDK 不返回 fileID。我们在上传完成后用 GetFiles 列父目录,按 SHA1 + 文件名匹配新文件。
// 列父目录时按时间倒序拉前 500 条,刚上传的文件会在最前面,几乎不会漏。
//
// 该方法不会按 SHA1 跨目录复用 fileID —— 同一文件如果父目录里已经有同名同 sha1 文件,
// 115 服务端会直接秒传成功并把已有文件视为本次结果,列目录时也仍然能找到,行为一致。
func (d *Driver) UploadAndReportSha1(ctx context.Context, parentID, name string, r io.Reader, size int64) (UploadResult, error) {
if d.client == nil {
return UploadResult{}, errors.New("p115 upload: driver not initialized")
}
// RapidUploadOrByOSS 目前没返回 fileID,需要回查
files, err := d.client.ListWithLimit(parentID, sdk.FileListLimit)
if r == nil {
return UploadResult{}, errors.New("p115 upload: nil reader")
}
if size < 0 {
return UploadResult{}, fmt.Errorf("p115 upload: invalid size %d", size)
}
name = strings.TrimSpace(name)
if name == "" {
return UploadResult{}, errors.New("p115 upload: empty file name")
}
parentID = strings.TrimSpace(parentID)
if parentID == "" {
parentID = d.rootID
}
tmp, sha1Hex, written, err := bufferAndHashSha1(r, size)
if err != nil {
return "", fmt.Errorf("115 upload verify: %w", err)
return UploadResult{}, err
}
if files != nil {
for _, f := range *files {
if !f.IsDirectory && f.Name == name {
return f.FileID, nil
}
defer func() {
_ = tmp.Close()
_ = os.Remove(tmp.Name())
}()
// 把流位置回到开头交给 SDK;SDK 会自己 Digest 一次(重复算一次 SHA1
// 但代码上可以避免侵入 SDK 内部状态机)。
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
return UploadResult{}, fmt.Errorf("p115 upload: seek tmp: %w", err)
}
if err := ctx.Err(); err != nil {
return UploadResult{}, err
}
if err := d.client.RapidUploadOrByMultipart(parentID, name, written, tmp); err != nil {
return UploadResult{}, fmt.Errorf("p115 upload: %w", err)
}
fileID, err := d.findUploadedFileID(ctx, parentID, name, sha1Hex)
if err != nil {
return UploadResult{}, err
}
return UploadResult{FileID: fileID, Sha1: sha1Hex, Size: written}, nil
}
// findUploadedFileID 列出 parentID 目录,按 (sha1, name) 找到新上传的文件并返回 fileID。
// 列目录用时间倒序 + Limit=500,新上传的文件几乎一定在前 500 条里。
//
// 失败时返回包装好的错误,由上层决定是否重试。
//
// 注:sdk.GetFiles 返回的 FileInfo 是 115 API 的原始结构,里面没有显式的 IsDirectory 字段;
// 文件的 FileID 非空、目录的 FileID 为空(目录是 CategoryID 自身)。
func (d *Driver) findUploadedFileID(ctx context.Context, parentID, name, sha1Hex string) (string, error) {
req := d.client.NewRequest().ForceContentType("application/json;charset=UTF-8")
resp, err := sdk.GetFiles(req, parentID,
sdk.WithOrder(sdk.FileOrderByTime),
sdk.WithShowDirEnable(false),
sdk.WithAsc(false),
sdk.WithLimit(500),
)
if err != nil {
return "", fmt.Errorf("p115 upload verify: %w", err)
}
if resp == nil {
return "", fmt.Errorf("p115 upload: empty list response for parent %q", parentID)
}
// 优先按 sha1 + name 双匹配;少数情况下名字含特殊字符被 115 服务端二次处理(比如折叠空白),
// 仍然以 sha1 命中即认可。
var sha1Hit string
for _, f := range resp.Files {
if f.FileID == "" {
continue // 目录
}
if !strings.EqualFold(f.Sha1, sha1Hex) {
continue
}
if f.Name == name {
return f.FileID, nil
}
if sha1Hit == "" {
sha1Hit = f.FileID
}
}
return "", errors.New("115 upload: file not found after upload")
if sha1Hit != "" {
return sha1Hit, nil
}
// 兜底:仅按 name 找
for _, f := range resp.Files {
if f.FileID != "" && f.Name == name {
return f.FileID, nil
}
}
return "", fmt.Errorf("p115 upload: uploaded file %q not found in parent %q", name, parentID)
}
// Rename 调用 115 SDK 把指定 fileID 重命名为 newName。
// 包装错误信息,方便日志定位是 115 端的失败。
func (d *Driver) Rename(ctx context.Context, fileID, newName string) error {
if d.client == nil {
return errors.New("p115 rename: driver not initialized")
}
if err := ctx.Err(); err != nil {
return err
}
fileID = strings.TrimSpace(fileID)
newName = strings.TrimSpace(newName)
if fileID == "" {
return errors.New("p115 rename: empty fileID")
}
if newName == "" {
return errors.New("p115 rename: empty newName")
}
if err := d.client.Rename(fileID, newName); err != nil {
return fmt.Errorf("p115 rename: %w", err)
}
return nil
}
// bufferAndHashSha1 把 r 全量复制到一个临时文件,同时计算 SHA1。
// 返回临时文件(位置在末尾,需调用方 Seek 回 0)、SHA1 hex 大写、实际字节数。
//
// 调用方负责 Close + Remove 临时文件。
func bufferAndHashSha1(r io.Reader, declaredSize int64) (*os.File, string, int64, error) {
tmp, err := os.CreateTemp("", "p115-upload-*.bin")
if err != nil {
return nil, "", 0, fmt.Errorf("p115 upload: create tmp: %w", err)
}
h := sha1.New()
mw := io.MultiWriter(tmp, h)
written, err := io.Copy(mw, r)
if err != nil {
_ = tmp.Close()
_ = os.Remove(tmp.Name())
return nil, "", 0, fmt.Errorf("p115 upload: buffer body: %w", err)
}
if declaredSize > 0 && written != declaredSize {
_ = tmp.Close()
_ = os.Remove(tmp.Name())
return nil, "", 0, fmt.Errorf("p115 upload: size mismatch: declared %d, copied %d", declaredSize, written)
}
sha1Hex := strings.ToUpper(hex.EncodeToString(h.Sum(nil)))
return tmp, sha1Hex, written, nil
}
func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) {
+102
View File
@@ -1,7 +1,14 @@
package p115
import (
"bytes"
"context"
"crypto/sha1"
"encoding/hex"
"errors"
"io"
"os"
"strings"
"testing"
)
@@ -26,3 +33,98 @@ func TestIsTransient115ListError(t *testing.T) {
})
}
}
// TestBufferAndHashSha1 验证 bufferAndHashSha1
//
// - 把 reader 的全部字节落到 tmp 文件
// - SHA1 与标准库一致(HEX 大写)
// - declaredSize=0 时不校验,>0 时严格校验
// - 调用方拿到的 *os.File 可以 Seek 回 0 重新读出原文(OSS SDK 上传需要)
func TestBufferAndHashSha1(t *testing.T) {
body := []byte("hello-115-upload-test")
want := sha1.Sum(body)
wantHex := strings.ToUpper(hex.EncodeToString(want[:]))
t.Run("declared size matches", func(t *testing.T) {
tmp, gotHex, n, err := bufferAndHashSha1(bytes.NewReader(body), int64(len(body)))
if err != nil {
t.Fatalf("bufferAndHashSha1 returned error: %v", err)
}
defer cleanup(tmp)
if gotHex != wantHex {
t.Errorf("sha1 = %s, want %s", gotHex, wantHex)
}
if n != int64(len(body)) {
t.Errorf("written = %d, want %d", n, len(body))
}
// Seek 回 0,应能读出原文
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
t.Fatalf("seek: %v", err)
}
got, err := io.ReadAll(tmp)
if err != nil {
t.Fatalf("read tmp: %v", err)
}
if !bytes.Equal(got, body) {
t.Errorf("tmp content mismatch: got %q want %q", string(got), string(body))
}
})
t.Run("declared size mismatch returns error", func(t *testing.T) {
_, _, _, err := bufferAndHashSha1(bytes.NewReader(body), int64(len(body))+1)
if err == nil {
t.Fatal("expected size mismatch error, got nil")
}
})
t.Run("declared size zero is unchecked", func(t *testing.T) {
tmp, gotHex, n, err := bufferAndHashSha1(bytes.NewReader(body), 0)
if err != nil {
t.Fatalf("bufferAndHashSha1 returned error: %v", err)
}
defer cleanup(tmp)
if gotHex != wantHex {
t.Errorf("sha1 = %s, want %s", gotHex, wantHex)
}
if n != int64(len(body)) {
t.Errorf("written = %d, want %d", n, len(body))
}
})
}
// TestUploadAndReportSha1RejectsInvalidArgs 检查空 reader / 空 name / 负 size 在
// 客户端未初始化前就被拒绝,避免下游 SDK 在错误参数下做异步初始化和真实网络调用。
func TestUploadAndReportSha1RejectsInvalidArgs(t *testing.T) {
d := New(Config{ID: "p115-test"})
// 注意:未调 Init,因此 d.client == nil,第一道防线就会拒绝。
cases := []struct {
name string
parentID string
fname string
body io.Reader
size int64
wantSubst string
}{
{name: "nil client", parentID: "0", fname: "x.mp4", body: bytes.NewReader([]byte("ok")), size: 2, wantSubst: "not initialized"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := d.UploadAndReportSha1(context.Background(), c.parentID, c.fname, c.body, c.size)
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), c.wantSubst) {
t.Fatalf("err = %v, want containing %q", err, c.wantSubst)
}
})
}
}
func cleanup(f *os.File) {
if f == nil {
return
}
_ = f.Close()
_ = os.Remove(f.Name())
}
+126 -31
View File
@@ -1,15 +1,15 @@
// Package spider91migrate 周期性把 spider91 drive 下载到本地的视频
// 上传到一个指定的 PikPak drive 目录,上传成功后:
// 上传到一个指定的目标 drive 目录(PikPak 或 115,上传成功后:
//
// - 改写 catalog 行:drive_id / file_id / content_hash 改成 PikPak 的;
// - 改写 catalog 行:drive_id / file_id / content_hash 改成目标盘的;
// 视频自身的 id 不变(仍是 spider91-<driveID>-<viewkey>),video_tags、
// 收藏、点赞、views 等关联数据全部保留
// - 删除本地 mp4spider91/<id>/videos/<viewkey>.<ext>)和 thumbspider91/<id>/thumbs/<viewkey>.jpg
//
// 之后回放时,videoSource() 自动落到 /p/stream/<pikpak>/<file_id>
// proxy 层走 302 直连 PikPak CDN
// 之后回放时,videoSource() 自动落到 /p/stream/<target>/<file_id>
// proxy 层走对应盘的直链 / 302 直连。
//
// 下次 PikPak 扫盘时,scanner 通过 (content_hash) / (file_name+size)
// 下次目标盘扫盘时,scanner 通过 (content_hash) / (file_name+size)
// 已有的 findDuplicate 兜底逻辑,不会为同一物理文件再建一行。
package spider91migrate
@@ -28,20 +28,97 @@ import (
"github.com/video-site/backend/internal/catalog"
"github.com/video-site/backend/internal/drives"
"github.com/video-site/backend/internal/drives/p115"
"github.com/video-site/backend/internal/drives/pikpak"
"github.com/video-site/backend/internal/drives/spider91"
)
// pikpakUploader 是 migrator 对 PikPak driver 的最小依赖接口。
// *pikpak.Driver 实现它;测试可以注入 fake
type pikpakUploader interface {
// uploadTarget 是 migrator 调用目标 drive 的最小接口。任何一种"接收 spider91 上传"的
// 网盘都要实现它;当前 PikPak 和 115 各自通过适配器满足
//
// 这一层抽象把"迁移调用方"和"具体盘的 SDK 协议"解耦:
// - PikPak 走 GCID + OSS PutObjectpikpak.UploadResult
// - 115 走 SHA1 + 秒传 / OSS / 分片(p115.UploadResult
//
// 两个返回值都被归一成本地的 UploadResult,并在 catalog 改写阶段统一处理。
type uploadTarget interface {
ID() string
Kind() string
RootID() string
UploadAndReportHash(ctx context.Context, parentID, name string, r io.Reader, size int64) (pikpak.UploadResult, error)
UploadAndReportHash(ctx context.Context, parentID, name string, r io.Reader, size int64) (UploadResult, error)
Rename(ctx context.Context, fileID, newName string) error
}
// UploadResult 是 uploadTarget.UploadAndReportHash 的归一返回。
//
// FileID 目标盘上的新文件 ID;
// Hash GCIDPikPak)或 SHA1 HEX 大写(115),写入 catalog.content_hash 用于跨盘去重;
// Size 实际上传字节数。
type UploadResult struct {
FileID string
Hash string
Size int64
}
// pikpakAdapter / p115Adapter 把具体 driver 包装成 uploadTarget。
//
// 之所以不让 driver 直接实现 uploadTarget
//
// 1. 各 driver 的 UploadAndReportXxx 返回的是各自包内的 UploadResult 类型,
// 直接共用同名同签名方法会引入循环依赖;
// 2. driver 包不应该感知 spider91migrate 这一层业务定义。
type pikpakAdapter struct {
d *pikpak.Driver
}
func (a *pikpakAdapter) ID() string { return a.d.ID() }
func (a *pikpakAdapter) Kind() string { return a.d.Kind() }
func (a *pikpakAdapter) RootID() string { return a.d.RootID() }
func (a *pikpakAdapter) UploadAndReportHash(ctx context.Context, parentID, name string, r io.Reader, size int64) (UploadResult, error) {
res, err := a.d.UploadAndReportHash(ctx, parentID, name, r, size)
if err != nil {
return UploadResult{}, err
}
return UploadResult{FileID: res.FileID, Hash: res.Hash, Size: res.Size}, nil
}
func (a *pikpakAdapter) Rename(ctx context.Context, fileID, newName string) error {
return a.d.Rename(ctx, fileID, newName)
}
type p115Adapter struct {
d *p115.Driver
}
func (a *p115Adapter) ID() string { return a.d.ID() }
func (a *p115Adapter) Kind() string { return a.d.Kind() }
func (a *p115Adapter) RootID() string { return a.d.RootID() }
func (a *p115Adapter) UploadAndReportHash(ctx context.Context, parentID, name string, r io.Reader, size int64) (UploadResult, error) {
res, err := a.d.UploadAndReportSha1(ctx, parentID, name, r, size)
if err != nil {
return UploadResult{}, err
}
return UploadResult{FileID: res.FileID, Hash: res.Sha1, Size: res.Size}, nil
}
func (a *p115Adapter) 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) {
switch v := d.(type) {
case *pikpak.Driver:
return &pikpakAdapter{d: v}, nil
case *p115.Driver:
return &p115Adapter{d: v}, nil
case uploadTarget:
// 测试或自定义实现可以直接传入;优先使用具体类型分支以拿到适配器。
return v, nil
default:
return nil, fmt.Errorf("drive %q kind=%s does not support spider91 upload", d.ID(), d.Kind())
}
}
// Registry 是 worker 用来按 driveID 取 driver 的最小依赖。
type Registry interface {
Get(id string) (drives.Drive, bool)
@@ -292,13 +369,30 @@ func (m *Migrator) runOnce(ctx context.Context) {
if renamed, err := m.backfillFileNames(ctx, target, pp); err != nil {
log.Printf("[spider91migrate] backfill names: %v", err)
} else if renamed > 0 {
log.Printf("[spider91migrate] backfilled %d PikPak file name(s) to desired format", renamed)
log.Printf("[spider91migrate] backfilled %d %s file name(s) to desired format", renamed, m.targetKindForLog())
}
}
// resolveTarget 返回 (PikPak drive ID, PikPak driver, err)
// 没设置或 drive 找不到时返回 err(调用方静默跳过)
func (m *Migrator) resolveTarget() (string, pikpakUploader, error) {
// targetKindForLog 把当前目标盘 kind 转成对人友好的简称,用于日志
// 解析失败时回退 "target"
func (m *Migrator) targetKindForLog() string {
if m.cfg.GetTargetDriveID == nil || m.cfg.Registry == nil {
return "target"
}
id := m.cfg.GetTargetDriveID()
if id == "" {
return "target"
}
d, ok := m.cfg.Registry.Get(id)
if !ok {
return "target"
}
return d.Kind()
}
// resolveTarget 返回 (target drive ID, target uploadTarget, err)。
// 没设置、drive 找不到,或 drive 类型不支持上传时返回 err(调用方静默跳过)。
func (m *Migrator) resolveTarget() (string, uploadTarget, error) {
if m.cfg.GetTargetDriveID == nil {
return "", nil, errors.New("no target getter")
}
@@ -310,11 +404,11 @@ func (m *Migrator) resolveTarget() (string, pikpakUploader, error) {
if !ok {
return "", nil, fmt.Errorf("target drive %q not in registry", id)
}
pp, ok := d.(pikpakUploader)
if !ok {
return "", nil, fmt.Errorf("target drive %q kind=%s, want pikpak", id, d.Kind())
t, err := adaptUploadTarget(d)
if err != nil {
return "", nil, err
}
return id, pp, nil
return id, t, nil
}
// spider91Drives 返回当前注册的所有 spider91 driver。
@@ -342,7 +436,7 @@ func (m *Migrator) spider91Drives() []*spider91.Driver {
// - 已经迁移过但本地还有残留 → 仅删本地(兜底)
//
// KeepLatestN < 0 时不保护任何本地文件,全部尝试迁移(旧行为,主要给测试用)。
func (m *Migrator) migrateDrive(ctx context.Context, src *spider91.Driver, targetDriveID string, pp pikpakUploader) (int, error) {
func (m *Migrator) migrateDrive(ctx context.Context, src *spider91.Driver, targetDriveID string, pp uploadTarget) (int, error) {
keepN := m.cfg.KeepLatestN
if keepN < 0 {
keepN = 0
@@ -443,7 +537,7 @@ func (m *Migrator) migrateDrive(ctx context.Context, src *spider91.Driver, targe
// migrateOne 把单条 spider91 视频上传到 PikPak 并改写 catalog。
// 返回 (true, nil) 表示真的迁了一条;(false, nil) 表示跳过(本地文件已不在等);
// (false, err) 表示真出错。
func (m *Migrator) migrateOne(ctx context.Context, v *catalog.Video, src *spider91.Driver, targetDriveID string, pp pikpakUploader) (bool, error) {
func (m *Migrator) migrateOne(ctx context.Context, v *catalog.Video, src *spider91.Driver, targetDriveID string, pp uploadTarget) (bool, error) {
path, err := src.VideoPath(v.FileID)
if err != nil {
return false, fmt.Errorf("resolve local path: %w", err)
@@ -467,28 +561,29 @@ func (m *Migrator) migrateOne(ctx context.Context, v *catalog.Video, src *spider
}
defer f.Close()
// 上传到 PikPak 的根目录(用户配置的 PikPak drive 的 rootID)。
// 上传到目标盘的根目录(用户配置的目标 drive 的 rootID)。
// 上传名走 desiredPikPakName 算出来的方案 B 格式:
//
// <sanitized title>-<viewkey 后 8 位>.<ext>
//
// 这样 PikPak Web 端列出来的文件名能直接看出是哪个视频,
// 又用 viewkey 后 8 位避免同标题撞名。
// 这样网盘 Web 端列出来的文件名能直接看出是哪个视频,
// 又用 viewkey 后 8 位避免同标题撞名。两个目标盘(PikPak / 115)共用同一格式,
// 简化前端 / catalog 的认知。
parent := pp.RootID()
uploadName := desiredPikPakName(v.Title, extractViewKey(v.ID), v.Ext)
res, err := pp.UploadAndReportHash(ctx, parent, uploadName, f, info.Size())
if err != nil {
return false, fmt.Errorf("pikpak upload: %w", err)
return false, fmt.Errorf("%s upload: %w", pp.Kind(), err)
}
if res.FileID == "" {
return false, errors.New("pikpak returned empty file id")
return false, fmt.Errorf("%s returned empty file id", pp.Kind())
}
// 事务性改写 catalog 行:drive_id / file_id / content_hash
if err := m.cfg.Catalog.MigrateVideoToDrive(ctx, v.ID, targetDriveID, res.FileID, res.Hash); err != nil {
return false, fmt.Errorf("catalog migrate: %w", err)
}
// 同步 catalog 里的 file_name,让下次 PikPak 扫盘时 (file_name, size) 也能匹配上
// 同步 catalog 里的 file_name,让下次目标盘扫盘时 (file_name, size) 也能匹配上
if err := m.cfg.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{FileName: uploadName}); err != nil {
log.Printf("[spider91migrate] %s update file_name after migrate: %v", v.ID, err)
}
@@ -496,7 +591,7 @@ func (m *Migrator) migrateOne(ctx context.Context, v *catalog.Video, src *spider
// 删除本地 mp4 和 thumbthumb 在 previews/thumbs/ 还有副本,不影响展示)
CleanupSpider91Local(src, v.FileID)
log.Printf("[spider91migrate] %s migrated to drive=%s file=%s name=%q", v.ID, targetDriveID, res.FileID, uploadName)
log.Printf("[spider91migrate] %s migrated to drive=%s(kind=%s) file=%s name=%q", v.ID, targetDriveID, pp.Kind(), res.FileID, uploadName)
return true, nil
}
@@ -594,14 +689,14 @@ func (m *Migrator) cleanupOldLocalVideos(ctx context.Context, src *spider91.Driv
return deleted, nil
}
// backfillFileNames 扫描 PikPak 目标 drive 下所有 spider91-* 起始 ID 的视频,
// 对文件名不是 desiredPikPakName(...) 期望格式的,调 PikPak.Rename 修正,
// backfillFileNames 扫描目标 drivePikPak 或 115下所有 spider91-* 起始 ID 的视频,
// 对文件名不是 desiredPikPakName(...) 期望格式的,调 target.Rename 修正,
// 并把 catalog.file_name 同步到新名字。
//
// 幂等:已经是期望格式的视频不会触发任何调用。
//
// 返回成功改名的条数。
func (m *Migrator) backfillFileNames(ctx context.Context, targetDriveID string, pp pikpakUploader) (int, error) {
func (m *Migrator) backfillFileNames(ctx context.Context, targetDriveID string, pp uploadTarget) (int, error) {
videos, err := m.cfg.Catalog.ListVideosByDriveID(ctx, targetDriveID, 10000)
if err != nil {
return 0, fmt.Errorf("list videos: %w", err)
@@ -627,9 +722,9 @@ func (m *Migrator) backfillFileNames(ctx context.Context, targetDriveID string,
}
if err := m.cfg.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{FileName: want}); err != nil {
log.Printf("[spider91migrate] %s update file_name after rename: %v", v.ID, err)
// PikPak 已经改名成功,但 catalog 更新失败 —— 下轮会重试。继续。
// 目标盘已经改名成功,但 catalog 更新失败 —— 下轮会重试。继续。
}
log.Printf("[spider91migrate] renamed %s on PikPak: %q -> %q", v.ID, v.FileName, want)
log.Printf("[spider91migrate] renamed %s on %s: %q -> %q", v.ID, pp.Kind(), v.FileName, want)
renamed++
}
return renamed, nil
+134 -17
View File
@@ -44,12 +44,13 @@ func (r *fakeRegistry) All() []drives.Drive {
return out
}
// fakePikPak 实现 drives.Drive + pikpakUploader 接口。
// fakePikPak 实现 drives.Drive + uploadTarget 接口(直接返回本包的 UploadResult
// 跳过 pikpakAdapter;这样测试不依赖真实 PikPak driver 的内部状态机)。
type fakePikPak struct {
id string
rootID string
uploadCalls int
uploadFunc func(ctx context.Context, parentID, name string, r io.Reader, size int64) (pikpak.UploadResult, error)
uploadFunc func(ctx context.Context, parentID, name string, r io.Reader, size int64) (UploadResult, error)
mu sync.Mutex
gotBodies map[string][]byte
// renameCalls 记录每次 Rename 的 fileID->newName 历史,用于 backfill 测试断言。
@@ -88,7 +89,7 @@ func (d *fakePikPak) Rename(_ context.Context, fileID, newName string) error {
d.mu.Unlock()
return nil
}
func (d *fakePikPak) UploadAndReportHash(ctx context.Context, parentID, name string, r io.Reader, size int64) (pikpak.UploadResult, error) {
func (d *fakePikPak) UploadAndReportHash(ctx context.Context, parentID, name string, r io.Reader, size int64) (UploadResult, error) {
d.mu.Lock()
d.uploadCalls++
d.mu.Unlock()
@@ -99,7 +100,7 @@ func (d *fakePikPak) UploadAndReportHash(ctx context.Context, parentID, name str
d.mu.Lock()
d.gotBodies[name] = body
d.mu.Unlock()
return pikpak.UploadResult{
return UploadResult{
FileID: "remote-" + name,
Hash: "FAKEHASH40CHARSXXXXXXXXXXXXXXXXXXXXXXXXX",
Size: int64(len(body)),
@@ -108,7 +109,23 @@ func (d *fakePikPak) UploadAndReportHash(ctx context.Context, parentID, name str
// 编译期断言:fakePikPak 同时满足两个接口。
var _ drives.Drive = (*fakePikPak)(nil)
var _ pikpakUploader = (*fakePikPak)(nil)
var _ uploadTarget = (*fakePikPak)(nil)
// fakeP115 与 fakePikPak 等价,但 Kind 是 "p115",用于验证 migrator 也能把视频
// 正确地路由到 115 目标盘(走 p115Adapter 的实际逻辑则需要真实 driver;
// 这里通过 adaptUploadTarget 的 uploadTarget 短路分支让 fakeP115 直接成为 target)。
type fakeP115 struct {
*fakePikPak
}
func newFakeP115(id, rootID string) *fakeP115 {
return &fakeP115{fakePikPak: newFakePikPak(id, rootID)}
}
func (d *fakeP115) Kind() string { return "p115" }
var _ drives.Drive = (*fakeP115)(nil)
var _ uploadTarget = (*fakeP115)(nil)
// TestBackfillFileNamesRenamesOnlyMismatchedSpider91Videos 验证回填逻辑:
//
@@ -384,9 +401,9 @@ func TestRunOncePreservesStateOnUploadError(t *testing.T) {
cat := setupCatalog(t)
src, _ := setupSpider91(t)
pp := newFakePikPak("pikpak-target", "pikpak-root-id")
pp.uploadFunc = func(ctx context.Context, parentID, name string, r io.Reader, size int64) (pikpak.UploadResult, error) {
pp.uploadFunc = func(ctx context.Context, parentID, name string, r io.Reader, size int64) (UploadResult, error) {
_, _ = io.Copy(io.Discard, r) // 把字节读完,模拟到一半失败
return pikpak.UploadResult{}, errors.New("simulated network failure")
return UploadResult{}, errors.New("simulated network failure")
}
reg := newFakeRegistry()
reg.Add(src)
@@ -657,12 +674,12 @@ func TestRunOnceCoolsDownOnCaptchaErrorAndAbortsBatch(t *testing.T) {
cat := setupCatalog(t)
src, _ := setupSpider91(t)
pp := newFakePikPak("pikpak-target", "pikpak-root-id")
pp.uploadFunc = func(ctx context.Context, parentID, name string, r io.Reader, size int64) (pikpak.UploadResult, error) {
pp.uploadFunc = func(ctx context.Context, parentID, name string, r io.Reader, size int64) (UploadResult, error) {
_, _ = io.Copy(io.Discard, r)
// 模拟真实 PikPak 4002 错误:通过包装 *pikpak.APIError
// pikpak.IsCaptchaError 应该能识别出来。
captcha := &pikpak.APIError{ErrorCode: 4002, ErrorMsg: "captcha_invalid", ErrorDescription: "Code(4002) - captcha_token expired"}
return pikpak.UploadResult{}, fmt.Errorf("pikpak upload: request session: %w", captcha)
return UploadResult{}, fmt.Errorf("pikpak upload: request session: %w", captcha)
}
reg := newFakeRegistry()
reg.Add(src)
@@ -729,18 +746,18 @@ func TestRunOnceResumesAfterCooldownExpires(t *testing.T) {
// 第一次:失败;第二次:成功。
var failOnce sync.Once
pp.uploadFunc = func(ctx context.Context, parentID, name string, r io.Reader, size int64) (pikpak.UploadResult, error) {
pp.uploadFunc = func(ctx context.Context, parentID, name string, r io.Reader, size int64) (UploadResult, error) {
body, _ := io.ReadAll(r)
var failed bool
failOnce.Do(func() { failed = true })
if failed {
captcha := &pikpak.APIError{ErrorCode: 4002, ErrorMsg: "captcha_invalid"}
return pikpak.UploadResult{}, fmt.Errorf("pikpak upload: request session: %w", captcha)
return UploadResult{}, fmt.Errorf("pikpak upload: request session: %w", captcha)
}
pp.mu.Lock()
pp.gotBodies[name] = body
pp.mu.Unlock()
return pikpak.UploadResult{
return UploadResult{
FileID: "remote-" + name,
Hash: "FAKEHASH40CHARSXXXXXXXXXXXXXXXXXXXXXXXXX",
Size: int64(len(body)),
@@ -797,18 +814,18 @@ func TestRunWakesWhenCooldownExpires(t *testing.T) {
migrated := make(chan struct{}, 1)
var failOnce sync.Once
pp.uploadFunc = func(ctx context.Context, parentID, name string, r io.Reader, size int64) (pikpak.UploadResult, error) {
pp.uploadFunc = func(ctx context.Context, parentID, name string, r io.Reader, size int64) (UploadResult, error) {
body, _ := io.ReadAll(r)
var failed bool
failOnce.Do(func() { failed = true })
if failed {
captcha := &pikpak.APIError{ErrorCode: 4002, ErrorMsg: "captcha_invalid"}
return pikpak.UploadResult{}, fmt.Errorf("pikpak upload: request session: %w", captcha)
return UploadResult{}, fmt.Errorf("pikpak upload: request session: %w", captcha)
}
pp.mu.Lock()
pp.gotBodies[name] = body
pp.mu.Unlock()
return pikpak.UploadResult{
return UploadResult{
FileID: "remote-" + name,
Hash: "FAKEHASH40CHARSXXXXXXXXXXXXXXXXXXXXXXXXX",
Size: int64(len(body)),
@@ -863,9 +880,9 @@ func TestNonCaptchaErrorDoesNotTriggerCooldown(t *testing.T) {
cat := setupCatalog(t)
src, _ := setupSpider91(t)
pp := newFakePikPak("pikpak-target", "pikpak-root-id")
pp.uploadFunc = func(ctx context.Context, parentID, name string, r io.Reader, size int64) (pikpak.UploadResult, error) {
pp.uploadFunc = func(ctx context.Context, parentID, name string, r io.Reader, size int64) (UploadResult, error) {
_, _ = io.Copy(io.Discard, r)
return pikpak.UploadResult{}, errors.New("simulated network failure")
return UploadResult{}, errors.New("simulated network failure")
}
reg := newFakeRegistry()
reg.Add(src)
@@ -893,3 +910,103 @@ func TestNonCaptchaErrorDoesNotTriggerCooldown(t *testing.T) {
t.Fatalf("non-captcha error should not trigger cooldown")
}
}
// TestRunOnceMigratesToP115Target 验证:当目标 drive 是 115kind="p115")时,
// migrator 也能正确把 spider91 视频上传过去并改写 catalog。
//
// 这条路径与 PikPak 的核心区别:
// - 适配器走 p115Adapter 而不是 pikpakAdapter(这里通过 fakeP115 实现 uploadTarget
// 直接短路 adaptUploadTarget 的 case *p115.Driver 分支,
// 避免依赖真实 SDK 客户端)
// - 上传错误不会被 pikpak.IsCaptchaError 识别,不应触发冷却
// - catalog 写入逻辑(drive_id / file_id / content_hash / file_name)与 PikPak 完全一致
func TestRunOnceMigratesToP115Target(t *testing.T) {
cat := setupCatalog(t)
src, _ := setupSpider91(t)
target := newFakeP115("p115-target", "p115-root-cid")
reg := newFakeRegistry()
reg.Add(src)
reg.Add(target)
now := time.Now()
id := writeSpider91Video(t, cat, src, "vk-115-001", ".mp4", []byte("video bytes 115"), now)
m := New(Config{
Catalog: cat,
Registry: reg,
GetTargetDriveID: func() string { return target.ID() },
Interval: time.Hour,
KeepLatestN: -1,
})
m.runOnce(context.Background())
if target.uploadCalls != 1 {
t.Fatalf("p115 upload calls = %d, want 1", target.uploadCalls)
}
got, err := cat.GetVideo(context.Background(), id)
if err != nil {
t.Fatalf("get video: %v", err)
}
if got.DriveID != target.ID() {
t.Fatalf("drive_id = %q, want %q", got.DriveID, target.ID())
}
wantName := "Sample vk-115-001-001.mp4"
if _, ok := target.gotBodies[wantName]; !ok {
t.Fatalf("p115 did not receive expected upload name %q (got names: %v)", wantName, keysOf(target.gotBodies))
}
if got.FileID != "remote-"+wantName {
t.Fatalf("file_id = %q, want %q", got.FileID, "remote-"+wantName)
}
if got.FileName != wantName {
t.Fatalf("file_name = %q, want %q", got.FileName, wantName)
}
if got.ContentHash == "" {
t.Fatal("content_hash should be set after p115 migration")
}
// 本地视频和 thumb 都应被删
videoPath, _ := src.VideoPath("vk-115-001.mp4")
if _, err := os.Stat(videoPath); !os.IsNotExist(err) {
t.Fatalf("local mp4 still exists after p115 migration or stat error: %v", err)
}
thumbPath, _ := src.ThumbPath("vk-115-001.jpg")
if _, err := os.Stat(thumbPath); !os.IsNotExist(err) {
t.Fatalf("local thumb still exists after p115 migration or stat error: %v", err)
}
}
// TestResolveTargetRejectsUnsupportedKind 验证当目标 drive 既不是 PikPak 也不是 115 时,
// resolveTarget 拒绝并返回 error,让 runOnce 静默跳过(不会做破坏性变更)。
func TestResolveTargetRejectsUnsupportedKind(t *testing.T) {
cat := setupCatalog(t)
src, _ := setupSpider91(t)
reg := newFakeRegistry()
reg.Add(src)
// spider91 自己也是 drives.Drive 但不是合法上传目标
other := src
m := New(Config{
Catalog: cat,
Registry: reg,
GetTargetDriveID: func() string { return other.ID() },
})
_, _, err := m.resolveTarget()
if err == nil {
t.Fatal("expected error for unsupported target kind, got nil")
}
if !strings.Contains(err.Error(), "does not support spider91 upload") {
t.Fatalf("err = %v, want a 'does not support spider91 upload' message", err)
}
// runOnce 应静默无害
now := time.Now()
_ = writeSpider91Video(t, cat, src, "vk-bad-target", ".mp4", []byte("data"), now)
m.runOnce(context.Background())
videoPath, _ := src.VideoPath("vk-bad-target.mp4")
if _, err := os.Stat(videoPath); err != nil {
t.Fatalf("local mp4 should remain when target unsupported: %v", err)
}
}
-2
View File
@@ -2,7 +2,6 @@ import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { HardDrive, Film, LogOut, Play, Home, Tags, Palette } from "lucide-react";
import { useAuth } from "./AuthContext";
import { useToast } from "./ToastContext";
import { PreviewToggle } from "./PreviewToggle";
export function AdminLayout() {
const { logout } = useAuth();
@@ -66,7 +65,6 @@ export function AdminLayout() {
</NavLink>
</nav>
<div className="admin-sidebar__footer">
<PreviewToggle />
<button className="admin-sidebar__logout" onClick={handleLogout}>
<LogOut size={14} style={{ verticalAlign: -2, marginRight: 4 }} />
退
+202 -4
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { Download, Plus, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import { Download, Plus, Power, PowerOff, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import * as api from "./api";
import { useToast } from "./ToastContext";
import { Modal } from "./Modal";
@@ -23,6 +23,15 @@ type FormState = {
rootId: string;
scanRootId: string;
creds: Record<string, string>;
/**
* spider91 drive ID
* creds POST /admin/api/drives handleSave
* PUT /admin/api/settings setting form state
* DriveForm
*
* = pikpak/p115 drive
*/
spider91UploadDriveId: string;
};
const emptyForm: FormState = {
@@ -32,27 +41,40 @@ const emptyForm: FormState = {
rootId: "0",
scanRootId: "0",
creds: {},
spider91UploadDriveId: "",
};
export function DrivesPage() {
const [list, setList] = useState<api.AdminDrive[]>([]);
const [storage, setStorage] = useState<api.AdminDriveStorage | null>(null);
const [settings, setSettings] = useState<api.Settings | null>(null);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [form, setForm] = useState<FormState>(emptyForm);
const [saving, setSaving] = useState(false);
const [regenFailedId, setRegenFailedId] = useState("");
// togglingTeaserId 在请求未返回前禁用按钮,避免连点导致两次切换互相覆盖。
const [togglingTeaserId, setTogglingTeaserId] = useState("");
const { show } = useToast();
// 当前系统中可作为 spider91 上传目标的 drive 列表(pikpak p115)。
// 用户保存 spider91 drive 时从这里挑一个;空表示走"自动"模式。
const uploadTargets = useMemo(
() => list.filter((d) => d.kind === "pikpak" || d.kind === "p115"),
[list]
);
async function refresh() {
setLoading(true);
try {
const [data, storageData] = await Promise.all([
const [data, storageData, settingsData] = await Promise.all([
api.listDrives(),
api.getDriveStorage(),
api.getSettings().catch(() => null),
]);
setList(data ?? []);
setStorage(storageData);
if (settingsData) setSettings(settingsData);
} catch (e) {
show(e instanceof Error ? e.message : "加载失败", "error");
} finally {
@@ -78,7 +100,12 @@ export function DrivesPage() {
}, []);
function openCreate() {
setForm(emptyForm);
// 创建时把全局 setting 当前值带进表单,方便用户在新建第一个 spider91 drive 时
// 直接看到当前的上传目标选择(一般是空 = 自动)。
setForm({
...emptyForm,
spider91UploadDriveId: settings?.spider91UploadDriveId ?? "",
});
setModalOpen(true);
}
@@ -90,6 +117,7 @@ export function DrivesPage() {
rootId: d.rootId,
scanRootId: d.scanRootId || d.rootId,
creds: {},
spider91UploadDriveId: settings?.spider91UploadDriveId ?? "",
});
setModalOpen(true);
}
@@ -110,6 +138,29 @@ export function DrivesPage() {
scanRootId: form.scanRootId || form.rootId || defaultRootId(form.kind),
credentials: form.creds,
});
// 仅当编辑/新建的是 spider91 drive 时,才同步全局上传目标 setting。
// 避免动其它类型 drive 的表单顺手覆盖了这个独立设置。
if (form.kind === "spider91" && form.spider91UploadDriveId !== (settings?.spider91UploadDriveId ?? "")) {
try {
const updated = await api.updateSettings({
spider91UploadDriveId: form.spider91UploadDriveId,
});
setSettings(updated);
} catch (settingsErr) {
// 不阻断主流程:drive 已经存了,setting 没存上,由 toast 提示用户手动重试
show(
settingsErr instanceof Error
? `Drive 已保存,但上传目标设置失败:${settingsErr.message}`
: "上传目标设置失败",
"error"
);
setModalOpen(false);
refresh();
return;
}
}
if (resp.warning) {
show(`已保存,但 driver 初始化失败:${resp.warning}`, "error");
} else {
@@ -161,6 +212,43 @@ export function DrivesPage() {
}
}
async function handleToggleTeaser(d: api.AdminDrive) {
const next = !d.teaserEnabled;
setTogglingTeaserId(d.id);
// 乐观更新本地状态,操作流畅;失败再回滚。
setList((prev) =>
prev.map((item) =>
item.id === d.id ? { ...item, teaserEnabled: next } : item
)
);
try {
const resp = await api.setDriveTeaserEnabled(d.id, next);
show(
resp.teaserEnabled
? `已开启「${d.name || d.id}」的 Teaser 生成`
: `已关闭「${d.name || d.id}」的 Teaser 生成`,
"success"
);
// 以服务端响应为准(防止极端竞态);并刷新计数等附属数据。
setList((prev) =>
prev.map((item) =>
item.id === d.id ? { ...item, teaserEnabled: resp.teaserEnabled } : item
)
);
refreshDriveList();
} catch (e) {
// 回滚乐观更新
setList((prev) =>
prev.map((item) =>
item.id === d.id ? { ...item, teaserEnabled: d.teaserEnabled } : item
)
);
show(e instanceof Error ? e.message : "切换失败", "error");
} finally {
setTogglingTeaserId("");
}
}
return (
<section>
<header className="admin-page__header">
@@ -246,6 +334,25 @@ export function DrivesPage() {
</>
)}
</button>{" "}
<button
className={`admin-btn ${d.teaserEnabled ? "is-success" : ""}`}
onClick={() => handleToggleTeaser(d)}
disabled={togglingTeaserId === d.id}
title={
d.teaserEnabled
? "本盘 Teaser 生成已开启,点击关闭"
: "本盘 Teaser 生成已关闭,点击开启"
}
>
{d.teaserEnabled ? <Power size={13} /> : <PowerOff size={13} />}
<span className="admin-btn__label">
{togglingTeaserId === d.id
? "切换中..."
: d.teaserEnabled
? "Teaser: 开"
: "Teaser: 关"}
</span>
</button>{" "}
<button
className="admin-btn"
disabled={(d.teaserFailedCount ?? 0) <= 0 || regenFailedId === d.id}
@@ -288,7 +395,12 @@ export function DrivesPage() {
</>
}
>
<DriveForm form={form} onChange={setForm} isEdit={!!list.find((x) => x.id === form.id)} />
<DriveForm
form={form}
onChange={setForm}
isEdit={!!list.find((x) => x.id === form.id)}
uploadTargets={uploadTargets}
/>
</Modal>
</section>
);
@@ -476,10 +588,12 @@ function DriveForm({
form,
onChange,
isEdit,
uploadTargets,
}: {
form: FormState;
onChange: (f: FormState) => void;
isEdit: boolean;
uploadTargets: api.AdminDrive[];
}) {
const fields = useMemo(() => credentialFields(form.kind), [form.kind]);
@@ -583,6 +697,90 @@ function DriveForm({
{f.help && <div className="admin-form__help">{f.help}</div>}
</div>
))}
{form.kind === "spider91" && (
<>
<hr className="admin-form__divider" />
<Spider91UploadTargetField
value={form.spider91UploadDriveId}
onChange={(v) => set("spider91UploadDriveId", v)}
uploadTargets={uploadTargets}
/>
</>
)}
</div>
);
}
/**
* Spider91UploadTargetField spider91 drive "上传目标"
*
*
* - = "(自动)" + pikpak/p115 drive
* - "自动" value="" worker
* pikpak p115 drive
* - pikpak/p115 drive
* - setting `spider91.upload_drive_id` drive
* credentials spider91 drive
*/
function Spider91UploadTargetField({
value,
onChange,
uploadTargets,
}: {
value: string;
onChange: (v: string) => void;
uploadTargets: api.AdminDrive[];
}) {
// 文案根据系统中实际挂载的目标盘 kind 自适应:
// - 只挂了 PikPak → 文案只讲 "PikPak"
// - 只挂了 115 → 文案只讲 "115 网盘"
// - 两类都挂 → 文案讲 "PikPak / 115 网盘"
// 这样在单一类型场景下用户不会被另一类的字样干扰。
const kindsPresent = new Set(uploadTargets.map((d) => d.kind));
const hasPikPak = kindsPresent.has("pikpak");
const has115 = kindsPresent.has("p115");
const presentLabel =
hasPikPak && has115
? "PikPak / 115 网盘"
: hasPikPak
? "PikPak"
: has115
? "115 网盘"
: "PikPak 或 115 网盘";
return (
<div className="admin-form__row">
<label></label>
{uploadTargets.length === 0 ? (
<>
<select value="" disabled>
<option value=""> {presentLabel}</option>
</select>
<div className="admin-form__help">
spider91 15
{presentLabel} drive spider91
</div>
</>
) : (
<>
<select value={value} onChange={(e) => onChange(e.target.value)}>
<option value=""> {presentLabel}</option>
{uploadTargets.map((d) => (
<option key={d.id} value={d.id}>
{kindLabel[d.kind] ?? d.kind} · {d.name || d.id}
</option>
))}
</select>
<div className="admin-form__help">
spider91
{uploadTargets.length > 1
? `如果同时挂着多个 ${presentLabel} drive"自动"模式不会工作,必须显式选定一个。`
: `当前只挂着 1 个 ${presentLabel},"自动"模式会直接选用它。`}
</div>
</>
)}
</div>
);
}
-75
View File
@@ -1,75 +0,0 @@
import { useEffect, useState } from "react";
import { Film } from "lucide-react";
import * as api from "./api";
import { useToast } from "./ToastContext";
// 预览生成开关。放在侧栏底部。
export function PreviewToggle() {
const [enabled, setEnabled] = useState<boolean | null>(null);
const [saving, setSaving] = useState(false);
const { show } = useToast();
useEffect(() => {
let active = true;
api
.getSettings()
.then((s) => {
if (active) setEnabled(s.previewEnabled);
})
.catch(() => {
if (active) setEnabled(false);
});
return () => {
active = false;
};
}, []);
async function handleToggle() {
if (enabled === null || saving) return;
const next = !enabled;
setSaving(true);
// 乐观更新
setEnabled(next);
try {
// 同 PUT 时也要把当前 theme 带上,避免被后端的"未设置就忽略"逻辑覆盖。
const cur = await api.getSettings();
const resp = await api.updateSettings({
previewEnabled: next,
theme: cur.theme,
});
setEnabled(resp.previewEnabled);
show(
next ? "已开启预览生成,正在补扫 pending" : "已关闭预览生成",
"success"
);
} catch (e) {
// 回滚
setEnabled(!next);
show(e instanceof Error ? e.message : "切换失败", "error");
} finally {
setSaving(false);
}
}
return (
<div className="preview-toggle">
<div className="preview-toggle__head">
<Film size={14} />
<span className="preview-toggle__label">Teaser </span>
</div>
<button
type="button"
role="switch"
aria-checked={enabled ?? false}
className={`toggle-switch ${enabled ? "is-on" : ""} ${
saving ? "is-saving" : ""
}`}
onClick={handleToggle}
disabled={enabled === null || saving}
title={enabled ? "点击关闭" : "点击开启"}
>
<span className="toggle-switch__dot" />
</button>
</div>
);
}
+1 -3
View File
@@ -78,10 +78,8 @@ export function ThemePage() {
applyTheme(next);
setSaving(next);
try {
// PUT 时需要把 previewEnabled 也带上(后端不区分部分更新和整体更新)
const cur = await api.getSettings();
// PUT 时只带 theme 即可:后端按字段存在与否判断是否变更,不会顺手改其它设置。
const resp = await api.updateSettings({
previewEnabled: cur.previewEnabled,
theme: next,
});
// 以服务端响应为准(但只在响应里返了合法值时才覆盖;旧后端不识别
+31 -2
View File
@@ -61,6 +61,8 @@ export type AdminDrive = {
status: string;
lastError?: string;
hasCredential: boolean;
/** 当前是否给该盘生成 teaser/封面(per-drive 开关,替代旧的全局 preview.enabled)。 */
teaserEnabled: boolean;
// spider91 上次成功爬取时间(unix 秒);其它 kind 留空。
lastCrawlAt?: number;
thumbnailGenerationStatus?: DriveGenerationStatus;
@@ -129,6 +131,22 @@ export function rescan(id: string) {
);
}
/**
* teaser toggle
*
* catalog.drives.teaser_enabled pending teaser
*
*/
export function setDriveTeaserEnabled(id: string, enabled: boolean) {
return request<{ ok: boolean; teaserEnabled: boolean }>(
`/drives/${encodeURIComponent(id)}/teaser-enabled`,
{
method: "POST",
body: JSON.stringify({ enabled }),
}
);
}
export function regenFailedPreviews(id: string) {
return request<{ ok: boolean }>(
`/drives/${encodeURIComponent(id)}/previews/failed/regenerate`,
@@ -230,15 +248,26 @@ export function createTag(label: string, aliases: string[]) {
export type Theme = "dark" | "pink";
export type Settings = {
previewEnabled: boolean;
theme: Theme;
/**
* spider91 drive ID pikpak p115 drive
* - pikpak/p115 drive
* - drive kind {pikpak, p115}
*/
spider91UploadDriveId: string;
};
export function getSettings() {
return request<Settings>("/settings");
}
export function updateSettings(body: Settings) {
/**
* Partial
*
*
* updateSettings({ theme: "pink" })
*/
export function updateSettings(body: Partial<Settings>) {
return request<Settings>("/settings", {
method: "PUT",
body: JSON.stringify(body),