mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 08:45:41 +08:00
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:
+58
-76
@@ -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 视频上传到目标 drive(PikPak 或 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 是后台 worker,OnNewVideo 是 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// - 调 OnTeaserEnabledChanged(main 注入;从关到开时会重新入队 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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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, ¬Null, &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 {
|
||||
|
||||
@@ -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: 文件的 SHA1(HEX 大写,与 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) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 等关联数据全部保留
|
||||
// - 删除本地 mp4(spider91/<id>/videos/<viewkey>.<ext>)和 thumb(spider91/<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 PutObject(pikpak.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 GCID(PikPak)或 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 和 thumb(thumb 在 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 扫描目标 drive(PikPak 或 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
|
||||
|
||||
@@ -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 是 115(kind="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,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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user