4 Commits

Author SHA1 Message Date
nianzhibai 052e142520 chore: bump version to 0.1.8 2026-06-14 18:25:19 +08:00
nianzhibai f9351324c6 fix: show active preview generation status 2026-06-14 18:22:04 +08:00
nianzhibai bb83277d62 feat: add crawler preview generation toggle
Expose per-crawler teaser settings on crawler cards and persist them through the admin API.\n\nWhen preview generation is disabled, crawler imports still create thumbnails and fingerprints while marking previews disabled and allowing migration without waiting for teaser files.\n\nPreserve the latest teaser setting after crawler runs so stale crawl state cannot overwrite a user toggle.
2026-06-14 17:52:29 +08:00
nianzhibai aa856db1f6 fix: refine video UI loading and playback behavior 2026-06-14 16:59:41 +08:00
24 changed files with 760 additions and 83 deletions
+41 -12
View File
@@ -247,6 +247,9 @@ func main() {
GetDriveGenerationStatuses: func() map[string]api.DriveGenerationStatuses { GetDriveGenerationStatuses: func() map[string]api.DriveGenerationStatuses {
return app.driveGenerationStatuses() return app.driveGenerationStatuses()
}, },
GetPreviewGenerationVideoIDs: func() map[string]bool {
return app.previewGenerationVideoIDs()
},
OnTeaserEnabledChanged: func(driveID string, enabled bool) { OnTeaserEnabledChanged: func(driveID string, enabled bool) {
// 从关到开时立刻补扫该盘 pending 预览视频,行为对齐旧的"全局开关从关到开"。 // 从关到开时立刻补扫该盘 pending 预览视频,行为对齐旧的"全局开关从关到开"。
// 关闭分支不需要做事 —— 入队前会重新查 catalog,新的 enqueue 自然停。 // 关闭分支不需要做事 —— 入队前会重新查 catalog,新的 enqueue 自然停。
@@ -656,6 +659,23 @@ func (a *App) driveGenerationStatuses() map[string]api.DriveGenerationStatuses {
return out return out
} }
func (a *App) previewGenerationVideoIDs() map[string]bool {
a.mu.Lock()
previewWorkers := make([]*preview.Worker, 0, len(a.workers))
for _, worker := range a.workers {
previewWorkers = append(previewWorkers, worker)
}
a.mu.Unlock()
out := make(map[string]bool)
for _, worker := range previewWorkers {
for _, id := range worker.ActiveVideoIDs() {
out[id] = true
}
}
return out
}
func (a *App) updateCrawlerUploadProgress(progress spider91migrate.UploadProgress) { func (a *App) updateCrawlerUploadProgress(progress spider91migrate.UploadProgress) {
driveID := strings.TrimSpace(progress.DriveID) driveID := strings.TrimSpace(progress.DriveID)
if driveID == "" { if driveID == "" {
@@ -1216,6 +1236,7 @@ func (a *App) attachScriptCrawler(d *catalog.Drive, drv *scriptcrawler.Driver) {
CommonThumbDir: a.commonThumbsDir(), CommonThumbDir: a.commonThumbsDir(),
ProxyURL: proxyURL, ProxyURL: proxyURL,
ConfigJSON: configJSON, ConfigJSON: configJSON,
DisablePreview: !d.TeaserEnabled,
OnProgress: func(progress scriptcrawler.CrawlProgress) { OnProgress: func(progress scriptcrawler.CrawlProgress) {
scanned := progress.Checked scanned := progress.Checked
if scanned < progress.TotalEntries { if scanned < progress.TotalEntries {
@@ -3143,18 +3164,7 @@ func (a *App) runScriptCrawlerCrawlWithTaskContext(ctx context.Context, driveID
driveID, res.TargetNew, res.CandidateBudget, res.TotalEntries, res.NewVideos, res.Skipped, res.Failed, res.SeenSnapshot) driveID, res.TargetNew, res.CandidateBudget, res.TotalEntries, res.NewVideos, res.Skipped, res.Failed, res.SeenSnapshot)
} }
if d.Credentials == nil { if err := a.updateScriptCrawlerRunState(ctx, driveID, runErr); err != nil {
d.Credentials = make(map[string]string)
}
d.Credentials["last_crawl_at"] = strconv.FormatInt(time.Now().Unix(), 10)
if runErr != nil {
d.Status = "error"
d.LastError = runErr.Error()
} else {
d.Status = "ok"
d.LastError = ""
}
if err := a.cat.UpsertDrive(ctx, d); err != nil {
log.Printf("[scriptcrawler] drive=%s update last_crawl_at: %v", driveID, err) log.Printf("[scriptcrawler] drive=%s update last_crawl_at: %v", driveID, err)
} }
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
@@ -3172,6 +3182,25 @@ func (a *App) runScriptCrawlerCrawlWithTaskContext(ctx context.Context, driveID
return runErr == nil return runErr == nil
} }
func (a *App) updateScriptCrawlerRunState(ctx context.Context, driveID string, runErr error) error {
d, err := a.cat.GetDrive(ctx, driveID)
if err != nil {
return err
}
if d.Credentials == nil {
d.Credentials = make(map[string]string)
}
d.Credentials["last_crawl_at"] = strconv.FormatInt(time.Now().Unix(), 10)
if runErr != nil {
d.Status = "error"
d.LastError = runErr.Error()
} else {
d.Status = "ok"
d.LastError = ""
}
return a.cat.UpsertDrive(ctx, d)
}
func (a *App) runSpider91MigrationAfterManualCrawl(ctx context.Context, driveID string) { func (a *App) runSpider91MigrationAfterManualCrawl(ctx context.Context, driveID string) {
a.runCrawlerMigrationAfterManualCrawl(ctx, driveID) a.runCrawlerMigrationAfterManualCrawl(ctx, driveID)
} }
+47
View File
@@ -227,6 +227,53 @@ func TestRegisterPreviewWorkersBackfillsHistoricalFingerprints(t *testing.T) {
t.Fatalf("fingerprint status=%q sampled=%q, want ready with hash", got.FingerprintStatus, got.SampledSHA256) t.Fatalf("fingerprint status=%q sampled=%q, want ready with hash", got.FingerprintStatus, got.SampledSHA256)
} }
func TestUpdateScriptCrawlerRunStatePreservesCurrentTeaserSwitch(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
if err != nil {
t.Fatalf("open catalog: %v", err)
}
t.Cleanup(func() {
if err := cat.Close(); err != nil {
t.Fatalf("close catalog: %v", err)
}
})
if err := cat.UpsertDrive(ctx, &catalog.Drive{
ID: "crawler-id",
Kind: scriptcrawler.Kind,
Name: "Crawler",
RootID: "/",
Credentials: map[string]string{
"script_path": "/tmp/crawler.py",
"target_new": "10",
},
TeaserEnabled: false,
}); err != nil {
t.Fatalf("seed crawler drive: %v", err)
}
if err := cat.SetDriveTeaserEnabled(ctx, "crawler-id", true); err != nil {
t.Fatalf("toggle teaser: %v", err)
}
app := &App{cat: cat}
if err := app.updateScriptCrawlerRunState(ctx, "crawler-id", nil); err != nil {
t.Fatalf("update run state: %v", err)
}
got, err := cat.GetDrive(ctx, "crawler-id")
if err != nil {
t.Fatalf("get crawler drive: %v", err)
}
if !got.TeaserEnabled {
t.Fatal("teaserEnabled = false after run state update, want preserved true")
}
if got.Status != "ok" || got.LastError != "" {
t.Fatalf("status=%q lastError=%q, want ok with no error", got.Status, got.LastError)
}
if got.Credentials["last_crawl_at"] == "" || got.Credentials["target_new"] != "10" {
t.Fatalf("credentials after run state update = %#v", got.Credentials)
}
}
func TestStopDriveTasksCancelsQueuedTasksAndReplacesWorkers(t *testing.T) { func TestStopDriveTasksCancelsQueuedTasksAndReplacesWorkers(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
+29 -9
View File
@@ -65,9 +65,10 @@ type AdminServer struct {
// 处理完候选列表后任务自然结束。 // 处理完候选列表后任务自然结束。
OnStartDriveTranscode func(driveID string) (bool, string) OnStartDriveTranscode func(driveID string) (bool, string)
// OnStopDriveTranscode 手动停止某盘正在进行的转码任务。返回是否有任务被停。 // OnStopDriveTranscode 手动停止某盘正在进行的转码任务。返回是否有任务被停。
OnStopDriveTranscode func(driveID string) bool OnStopDriveTranscode func(driveID string) bool
OnDeleteVideo func(ctx context.Context, videoID string, deleteSource bool) (DeleteVideoResult, error) OnDeleteVideo func(ctx context.Context, videoID string, deleteSource bool) (DeleteVideoResult, error)
GetDriveGenerationStatuses func() map[string]DriveGenerationStatuses GetDriveGenerationStatuses func() map[string]DriveGenerationStatuses
GetPreviewGenerationVideoIDs func() map[string]bool
// OnTeaserEnabledChanged 在 per-drive 预览视频开关被切换后调用。 // OnTeaserEnabledChanged 在 per-drive 预览视频开关被切换后调用。
// enabled=true 时上层应该重新把 pending 预览视频入队(类似旧的全局开关从关到开); // enabled=true 时上层应该重新把 pending 预览视频入队(类似旧的全局开关从关到开);
// enabled=false 时通常不用做事 —— worker 入队前会再次查 catalog,自然停止。 // enabled=false 时通常不用做事 —— worker 入队前会再次查 catalog,自然停止。
@@ -469,7 +470,8 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) {
Status string `json:"status"` Status string `json:"status"`
LastError string `json:"lastError,omitempty"` LastError string `json:"lastError,omitempty"`
HasCredential bool `json:"hasCredential"` HasCredential bool `json:"hasCredential"`
// TeaserEnabled 控制是否给本盘生成预览视频/封面。前端用它在网盘列表/编辑表单展示开关状态 // TeaserEnabled 控制是否给本盘生成预览视频封面生成不受影响
// 前端用它在网盘列表/编辑表单展示开关状态。
TeaserEnabled bool `json:"teaserEnabled"` TeaserEnabled bool `json:"teaserEnabled"`
// SkipDirIDs 是用户在 admin 配置的"扫描跳过目录"集合(drive 侧目录 fileID)。 // SkipDirIDs 是用户在 admin 配置的"扫描跳过目录"集合(drive 侧目录 fileID)。
// 前端用它在"设置跳过目录"弹窗里回显已选项;JSON 字段名 camelCase 与 // 前端用它在"设置跳过目录"弹窗里回显已选项;JSON 字段名 camelCase 与
@@ -591,7 +593,7 @@ type upsertDriveReq struct {
// Deprecated: 扫描起点已固定为 rootId;保留字段只为兼容旧客户端请求体。 // Deprecated: 扫描起点已固定为 rootId;保留字段只为兼容旧客户端请求体。
ScanRootID string `json:"scanRootId"` ScanRootID string `json:"scanRootId"`
Credentials map[string]string `json:"credentials"` Credentials map[string]string `json:"credentials"`
// TeaserEnabled 是 per-drive 预览视频/封面生成开关。 // TeaserEnabled 是 per-drive 预览视频生成开关;封面生成不受影响
// 用 *bool 区分 "未传" / "传了 false":未传时表示客户端不打算改这个字段, // 用 *bool 区分 "未传" / "传了 false":未传时表示客户端不打算改这个字段,
// 沿用 catalog 现有值;新建时未传一律默认开启(true)。 // 沿用 catalog 现有值;新建时未传一律默认开启(true)。
TeaserEnabled *bool `json:"teaserEnabled,omitempty"` TeaserEnabled *bool `json:"teaserEnabled,omitempty"`
@@ -690,6 +692,7 @@ type crawlerDTO struct {
Proxy string `json:"proxy,omitempty"` Proxy string `json:"proxy,omitempty"`
TargetNew string `json:"targetNew,omitempty"` TargetNew string `json:"targetNew,omitempty"`
UploadDriveID string `json:"uploadDriveId,omitempty"` UploadDriveID string `json:"uploadDriveId,omitempty"`
TeaserEnabled bool `json:"teaserEnabled"`
LastCrawlAt int64 `json:"lastCrawlAt,omitempty"` LastCrawlAt int64 `json:"lastCrawlAt,omitempty"`
ScanGenerationStatus GenerationStatus `json:"scanGenerationStatus"` ScanGenerationStatus GenerationStatus `json:"scanGenerationStatus"`
ThumbnailGenerationStatus GenerationStatus `json:"thumbnailGenerationStatus"` ThumbnailGenerationStatus GenerationStatus `json:"thumbnailGenerationStatus"`
@@ -717,6 +720,7 @@ type upsertCrawlerReq struct {
Proxy string `json:"proxy"` Proxy string `json:"proxy"`
TargetNew string `json:"targetNew"` TargetNew string `json:"targetNew"`
UploadDriveID string `json:"uploadDriveId"` UploadDriveID string `json:"uploadDriveId"`
TeaserEnabled *bool `json:"teaserEnabled,omitempty"`
} }
func (a *AdminServer) handleListCrawlers(w http.ResponseWriter, r *http.Request) { func (a *AdminServer) handleListCrawlers(w http.ResponseWriter, r *http.Request) {
@@ -778,6 +782,7 @@ func (a *AdminServer) crawlerDTOForDrive(d *catalog.Drive, assets catalog.Crawle
Proxy: strings.TrimSpace(d.Credentials["proxy"]), Proxy: strings.TrimSpace(d.Credentials["proxy"]),
TargetNew: strings.TrimSpace(d.Credentials["target_new"]), TargetNew: strings.TrimSpace(d.Credentials["target_new"]),
UploadDriveID: strings.TrimSpace(d.Credentials["upload_drive_id"]), UploadDriveID: strings.TrimSpace(d.Credentials["upload_drive_id"]),
TeaserEnabled: d.TeaserEnabled,
LastCrawlAt: lastCrawlAt, LastCrawlAt: lastCrawlAt,
ScanGenerationStatus: generation.Scan, ScanGenerationStatus: generation.Scan,
ThumbnailGenerationStatus: generation.Thumbnail, ThumbnailGenerationStatus: generation.Thumbnail,
@@ -864,6 +869,13 @@ func (a *AdminServer) handleUpsertCrawler(w http.ResponseWriter, r *http.Request
return return
} }
name := meta.Name name := meta.Name
teaserEnabled := true
if existing != nil {
teaserEnabled = existing.TeaserEnabled
}
if body.TeaserEnabled != nil {
teaserEnabled = *body.TeaserEnabled
}
if id == "" { if id == "" {
generatedID, err := a.generateCrawlerID(r.Context(), name) generatedID, err := a.generateCrawlerID(r.Context(), name)
if err != nil { if err != nil {
@@ -879,15 +891,15 @@ func (a *AdminServer) handleUpsertCrawler(w http.ResponseWriter, r *http.Request
RootID: "/", RootID: "/",
Credentials: merged, Credentials: merged,
Status: "disconnected", Status: "disconnected",
TeaserEnabled: true, TeaserEnabled: teaserEnabled,
}
if existing != nil {
d.TeaserEnabled = existing.TeaserEnabled
} }
if err := a.Catalog.UpsertDrive(r.Context(), d); err != nil { if err := a.Catalog.UpsertDrive(r.Context(), d); err != nil {
writeErr(w, http.StatusInternalServerError, err) writeErr(w, http.StatusInternalServerError, err)
return return
} }
if existing != nil && existing.TeaserEnabled != teaserEnabled && a.OnTeaserEnabledChanged != nil {
a.OnTeaserEnabledChanged(id, teaserEnabled)
}
if a.OnDriveSaved != nil { if a.OnDriveSaved != nil {
if err := a.OnDriveSaved(id); err != nil { if err := a.OnDriveSaved(id); err != nil {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id, "warning": err.Error()}) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id, "warning": err.Error()})
@@ -1920,6 +1932,14 @@ func (a *AdminServer) handleAdminListVideos(w http.ResponseWriter, r *http.Reque
writeErr(w, http.StatusInternalServerError, err) writeErr(w, http.StatusInternalServerError, err)
return return
} }
if a.GetPreviewGenerationVideoIDs != nil {
generating := a.GetPreviewGenerationVideoIDs()
for _, item := range items {
if item != nil && generating[item.ID] {
item.PreviewStatus = "generating"
}
}
}
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"items": items, "items": items,
"total": total, "total": total,
+136 -19
View File
@@ -944,7 +944,8 @@ func TestHandleListCrawlersOnlyIncludesCrawlerPageScripts(t *testing.T) {
"script_path": scriptPath, "script_path": scriptPath,
"upload_drive_id": "p115-target", "upload_drive_id": "p115-target",
}, },
Status: "ok", Status: "ok",
TeaserEnabled: false,
}, },
{ {
ID: "p115-target", ID: "p115-target",
@@ -1027,6 +1028,7 @@ func TestHandleListCrawlersOnlyIncludesCrawlerPageScripts(t *testing.T) {
Kind string `json:"kind"` Kind string `json:"kind"`
Proxy string `json:"proxy"` Proxy string `json:"proxy"`
UploadDriveID string `json:"uploadDriveId"` UploadDriveID string `json:"uploadDriveId"`
TeaserEnabled bool `json:"teaserEnabled"`
LastCrawlAt int64 `json:"lastCrawlAt"` LastCrawlAt int64 `json:"lastCrawlAt"`
TotalCrawled int `json:"totalCrawledCount"` TotalCrawled int `json:"totalCrawledCount"`
LocalVideos int `json:"localVideoCount"` LocalVideos int `json:"localVideoCount"`
@@ -1038,11 +1040,12 @@ func TestHandleListCrawlersOnlyIncludesCrawlerPageScripts(t *testing.T) {
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil { if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err) t.Fatalf("decode: %v", err)
} }
byID := map[string]struct { type crawlerListRow struct {
Name string Name string
Kind string Kind string
Proxy string Proxy string
UploadDriveID string UploadDriveID string
TeaserEnabled bool
LastCrawlAt int64 LastCrawlAt int64
TotalCrawled int TotalCrawled int
LocalVideos int LocalVideos int
@@ -1050,25 +1053,15 @@ func TestHandleListCrawlersOnlyIncludesCrawlerPageScripts(t *testing.T) {
ThumbnailReady int ThumbnailReady int
TeaserReady int TeaserReady int
FingerprintReady int FingerprintReady int
}{} }
byID := map[string]crawlerListRow{}
for _, d := range got { for _, d := range got {
byID[d.ID] = struct { byID[d.ID] = crawlerListRow{
Name string
Kind string
Proxy string
UploadDriveID string
LastCrawlAt int64
TotalCrawled int
LocalVideos int
MigratedVideo int
ThumbnailReady int
TeaserReady int
FingerprintReady int
}{
Name: d.Name, Name: d.Name,
Kind: d.Kind, Kind: d.Kind,
Proxy: d.Proxy, Proxy: d.Proxy,
UploadDriveID: d.UploadDriveID, UploadDriveID: d.UploadDriveID,
TeaserEnabled: d.TeaserEnabled,
LastCrawlAt: d.LastCrawlAt, LastCrawlAt: d.LastCrawlAt,
TotalCrawled: d.TotalCrawled, TotalCrawled: d.TotalCrawled,
LocalVideos: d.LocalVideos, LocalVideos: d.LocalVideos,
@@ -1096,6 +1089,9 @@ func TestHandleListCrawlersOnlyIncludesCrawlerPageScripts(t *testing.T) {
if byID["crawler-spider91"].UploadDriveID != "p115-target" { if byID["crawler-spider91"].UploadDriveID != "p115-target" {
t.Fatalf("uploadDriveId = %q, want p115-target", byID["crawler-spider91"].UploadDriveID) t.Fatalf("uploadDriveId = %q, want p115-target", byID["crawler-spider91"].UploadDriveID)
} }
if byID["crawler-spider91"].TeaserEnabled {
t.Fatal("teaserEnabled = true, want false from crawler drive")
}
if byID["crawler-spider91"].LastCrawlAt != 1800000000 { if byID["crawler-spider91"].LastCrawlAt != 1800000000 {
t.Fatalf("lastCrawlAt = %d, want 1800000000", byID["crawler-spider91"].LastCrawlAt) t.Fatalf("lastCrawlAt = %d, want 1800000000", byID["crawler-spider91"].LastCrawlAt)
} }
@@ -1171,7 +1167,8 @@ func TestHandleUpsertCrawlerRequiresScriptPath(t *testing.T) {
"id": "spider91-main", "id": "spider91-main",
"builtin": "spider91", "builtin": "spider91",
"scriptPath": "`+scriptPath+`", "scriptPath": "`+scriptPath+`",
"targetNew": "15" "targetNew": "15",
"teaserEnabled": false
}`)) }`))
rr = httptest.NewRecorder() rr = httptest.NewRecorder()
srv.handleUpsertCrawler(rr, req) srv.handleUpsertCrawler(rr, req)
@@ -1195,6 +1192,9 @@ func TestHandleUpsertCrawlerRequiresScriptPath(t *testing.T) {
if got.Credentials["script_path"] != scriptPath { if got.Credentials["script_path"] != scriptPath {
t.Fatalf("script_path = %q, want %q", got.Credentials["script_path"], scriptPath) t.Fatalf("script_path = %q, want %q", got.Credentials["script_path"], scriptPath)
} }
if got.TeaserEnabled {
t.Fatal("teaserEnabled = true, want false from request")
}
} }
func TestHandleUpsertCrawlerGeneratesIDFromScriptName(t *testing.T) { func TestHandleUpsertCrawlerGeneratesIDFromScriptName(t *testing.T) {
@@ -1277,12 +1277,21 @@ func TestHandleUpsertCrawlerPersistsAndValidatesUploadDrive(t *testing.T) {
t.Fatalf("seed drive %s: %v", d.ID, err) t.Fatalf("seed drive %s: %v", d.ID, err)
} }
} }
srv := &AdminServer{Catalog: cat} var teaserCallbackID string
var teaserCallbackEnabled bool
srv := &AdminServer{
Catalog: cat,
OnTeaserEnabledChanged: func(id string, enabled bool) {
teaserCallbackID = id
teaserCallbackEnabled = enabled
},
}
req := httptest.NewRequest(http.MethodPost, "/admin/api/crawlers", strings.NewReader(`{ req := httptest.NewRequest(http.MethodPost, "/admin/api/crawlers", strings.NewReader(`{
"id": "crawler-upload", "id": "crawler-upload",
"scriptPath": "`+scriptPath+`", "scriptPath": "`+scriptPath+`",
"uploadDriveId": "p115-target" "uploadDriveId": "p115-target",
"teaserEnabled": false
}`)) }`))
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
srv.handleUpsertCrawler(rr, req) srv.handleUpsertCrawler(rr, req)
@@ -1296,6 +1305,12 @@ func TestHandleUpsertCrawlerPersistsAndValidatesUploadDrive(t *testing.T) {
if got.Credentials["upload_drive_id"] != "p115-target" { if got.Credentials["upload_drive_id"] != "p115-target" {
t.Fatalf("upload_drive_id = %q, want p115-target", got.Credentials["upload_drive_id"]) t.Fatalf("upload_drive_id = %q, want p115-target", got.Credentials["upload_drive_id"])
} }
if got.TeaserEnabled {
t.Fatal("teaserEnabled = true, want false")
}
if teaserCallbackID != "" {
t.Fatalf("teaser callback on create = %q, want none", teaserCallbackID)
}
req = httptest.NewRequest(http.MethodPost, "/admin/api/crawlers", strings.NewReader(`{ req = httptest.NewRequest(http.MethodPost, "/admin/api/crawlers", strings.NewReader(`{
"id": "crawler-upload", "id": "crawler-upload",
@@ -1314,6 +1329,34 @@ func TestHandleUpsertCrawlerPersistsAndValidatesUploadDrive(t *testing.T) {
if got.Credentials["upload_drive_id"] != "wopan-target" { if got.Credentials["upload_drive_id"] != "wopan-target" {
t.Fatalf("upload_drive_id = %q, want wopan-target", got.Credentials["upload_drive_id"]) t.Fatalf("upload_drive_id = %q, want wopan-target", got.Credentials["upload_drive_id"])
} }
if got.TeaserEnabled {
t.Fatal("teaserEnabled after edit without field = true, want preserved false")
}
if teaserCallbackID != "" {
t.Fatalf("teaser callback after preserved edit = %q, want none", teaserCallbackID)
}
req = httptest.NewRequest(http.MethodPost, "/admin/api/crawlers", strings.NewReader(`{
"id": "crawler-upload",
"scriptPath": "`+scriptPath+`",
"uploadDriveId": "wopan-target",
"teaserEnabled": true
}`))
rr = httptest.NewRecorder()
srv.handleUpsertCrawler(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("enable teaser status = %d, body = %s", rr.Code, rr.Body.String())
}
got, err = cat.GetDrive(ctx, "crawler-upload")
if err != nil {
t.Fatalf("get crawler after teaser enable: %v", err)
}
if !got.TeaserEnabled {
t.Fatal("teaserEnabled after explicit enable = false, want true")
}
if teaserCallbackID != "crawler-upload" || !teaserCallbackEnabled {
t.Fatalf("teaser callback = %q/%v, want crawler-upload/true", teaserCallbackID, teaserCallbackEnabled)
}
req = httptest.NewRequest(http.MethodPost, "/admin/api/crawlers", strings.NewReader(`{ req = httptest.NewRequest(http.MethodPost, "/admin/api/crawlers", strings.NewReader(`{
"id": "crawler-upload", "id": "crawler-upload",
@@ -2461,6 +2504,80 @@ func TestHandleAdminListVideosPaginates(t *testing.T) {
} }
} }
func TestHandleAdminListVideosMarksActivePreviewGeneration(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
if err != nil {
t.Fatalf("open catalog: %v", err)
}
t.Cleanup(func() {
if err := cat.Close(); err != nil {
t.Fatalf("close catalog: %v", err)
}
})
now := time.Now()
for _, v := range []*catalog.Video{
{
ID: "active-video",
DriveID: "OneDrive",
FileID: "active-file",
Title: "Active video",
PreviewStatus: "ready",
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
},
{
ID: "idle-video",
DriveID: "OneDrive",
FileID: "idle-file",
Title: "Idle video",
PreviewStatus: "ready",
PublishedAt: now.Add(-time.Hour),
CreatedAt: now,
UpdatedAt: now,
},
} {
if err := cat.UpsertVideo(ctx, v); err != nil {
t.Fatalf("seed video %s: %v", v.ID, err)
}
}
req := httptest.NewRequest(http.MethodGet, "/admin/api/videos?driveId=OneDrive", nil)
rr := httptest.NewRecorder()
(&AdminServer{
Catalog: cat,
GetPreviewGenerationVideoIDs: func() map[string]bool {
return map[string]bool{"active-video": true}
},
}).handleAdminListVideos(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
var got struct {
Items []catalog.Video `json:"items"`
Total int `json:"total"`
}
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.Total != 2 || len(got.Items) != 2 {
t.Fatalf("response total/items = %d/%d, want 2/2", got.Total, len(got.Items))
}
statusByID := map[string]string{}
for _, item := range got.Items {
statusByID[item.ID] = item.PreviewStatus
}
if statusByID["active-video"] != "generating" {
t.Fatalf("active status = %q, want generating", statusByID["active-video"])
}
if statusByID["idle-video"] != "ready" {
t.Fatalf("idle status = %q, want ready", statusByID["idle-video"])
}
}
func TestHandleRegenAllPreviewsInvokesHook(t *testing.T) { func TestHandleRegenAllPreviewsInvokesHook(t *testing.T) {
called := false called := false
server := &AdminServer{ server := &AdminServer{
+2 -2
View File
@@ -1937,7 +1937,7 @@ type Drive struct {
Credentials map[string]string `json:"credentials,omitempty"` Credentials map[string]string `json:"credentials,omitempty"`
Status string `json:"status"` Status string `json:"status"`
LastError string `json:"lastError,omitempty"` LastError string `json:"lastError,omitempty"`
// TeaserEnabled 控制是否给本盘生成预览视频/封面。 // TeaserEnabled 控制是否给本盘生成预览视频封面生成不受影响
// 替代早期的全局 preview.enabled 开关;新建 drive 时 UpsertDrive 默认置 true。 // 替代早期的全局 preview.enabled 开关;新建 drive 时 UpsertDrive 默认置 true。
TeaserEnabled bool `json:"teaserEnabled"` TeaserEnabled bool `json:"teaserEnabled"`
// SkipDirIDs 是用户在管理后台为该盘选定的"扫描跳过目录"集合(网盘侧的目录 fileID)。 // SkipDirIDs 是用户在管理后台为该盘选定的"扫描跳过目录"集合(网盘侧的目录 fileID)。
@@ -2070,7 +2070,7 @@ func (c *Catalog) DeleteDrive(ctx context.Context, id string) error {
return err return err
} }
// SetDriveTeaserEnabled 切换某盘的预览视频/封面生成开关。 // SetDriveTeaserEnabled 切换某盘的预览视频生成开关。
// //
// 与 UpsertDrive 的区别:只动 teaser_enabled + updated_at 一列,不要求调用方 // 与 UpsertDrive 的区别:只动 teaser_enabled + updated_at 一列,不要求调用方
// 重传 kind / name / credentials 等容易踩坑的字段。 // 重传 kind / name / credentials 等容易踩坑的字段。
+2 -2
View File
@@ -21,7 +21,7 @@ CREATE TABLE IF NOT EXISTS videos (
thumbnail_failures INTEGER DEFAULT 0, -- consecutive transient thumbnail generation failures thumbnail_failures INTEGER DEFAULT 0, -- consecutive transient thumbnail generation failures
preview_file_id TEXT, -- deprecated: 旧版回写网盘后的预览视频 file id preview_file_id TEXT, -- deprecated: 旧版回写网盘后的预览视频 file id
preview_local TEXT, -- 本地预览视频路径(兜底) preview_local TEXT, -- 本地预览视频路径(兜底)
preview_status TEXT DEFAULT 'pending', -- pending / ready / failed preview_status TEXT DEFAULT 'pending', -- pending / ready / failed / disabled
transcode_status TEXT DEFAULT '', -- '' / pending / ready / skipped / failed(浏览器兼容性转码) transcode_status TEXT DEFAULT '', -- '' / pending / ready / skipped / failed(浏览器兼容性转码)
transcode_error TEXT DEFAULT '', transcode_error TEXT DEFAULT '',
transcoded_file_id TEXT DEFAULT '', -- 转码产物在同一 drive 上的 fileID,播放源优先用它 transcoded_file_id TEXT DEFAULT '', -- 转码产物在同一 drive 上的 fileID,播放源优先用它
@@ -121,7 +121,7 @@ CREATE TABLE IF NOT EXISTS drives (
credentials TEXT, -- JSON: cookie / refresh_token 等 credentials TEXT, -- JSON: cookie / refresh_token 等
status TEXT DEFAULT 'disconnected', -- disconnected / ok / error status TEXT DEFAULT 'disconnected', -- disconnected / ok / error
last_error TEXT, last_error TEXT,
-- 是否给该盘生成预览视频/封面1 开 / 0 关。 -- 是否给该盘生成预览视频:1 开 / 0 关。封面生成不受影响。
-- 替代了早期的全局 preview.enabled 设置(保留旧 setting 行不再读)。 -- 替代了早期的全局 preview.enabled 设置(保留旧 setting 行不再读)。
teaser_enabled INTEGER NOT NULL DEFAULT 1, teaser_enabled INTEGER NOT NULL DEFAULT 1,
-- 扫描时要跳过的目录 ID 集合(JSON array of string)。命中其中任意一个的目录及其 -- 扫描时要跳过的目录 ID 集合(JSON array of string)。命中其中任意一个的目录及其
@@ -50,6 +50,7 @@ type CrawlerConfig struct {
CommonThumbDir string CommonThumbDir string
ProxyURL string ProxyURL string
ConfigJSON string ConfigJSON string
DisablePreview bool
HTTPClient *http.Client HTTPClient *http.Client
DownloadTimeout time.Duration DownloadTimeout time.Duration
OnProgress func(CrawlProgress) OnProgress func(CrawlProgress)
@@ -562,6 +563,10 @@ func (c *Crawler) processItem(ctx context.Context, item Item) (bool, error) {
if quality == "" { if quality == "" {
quality = "HD" quality = "HD"
} }
previewStatus := "pending"
if c.previewDisabled(ctx) {
previewStatus = "disabled"
}
v := &catalog.Video{ v := &catalog.Video{
ID: videoID, ID: videoID,
DriveID: c.cfg.Driver.ID(), DriveID: c.cfg.Driver.ID(),
@@ -576,7 +581,7 @@ func (c *Crawler) processItem(ctx context.Context, item Item) (bool, error) {
Quality: quality, Quality: quality,
Category: strings.TrimSpace(item.Category), Category: strings.TrimSpace(item.Category),
Description: strings.TrimSpace(item.Description), Description: strings.TrimSpace(item.Description),
PreviewStatus: "pending", PreviewStatus: previewStatus,
PublishedAt: publishedAt, PublishedAt: publishedAt,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
@@ -632,6 +637,18 @@ func (c *Crawler) processItem(ctx context.Context, item Item) (bool, error) {
return true, nil return true, nil
} }
func (c *Crawler) previewDisabled(ctx context.Context) bool {
if c == nil {
return false
}
if c.cfg.Catalog != nil && c.cfg.Driver != nil {
if d, err := c.cfg.Catalog.GetDrive(ctx, c.cfg.Driver.ID()); err == nil && d != nil {
return !d.TeaserEnabled
}
}
return c.cfg.DisablePreview
}
func (c *Crawler) materializeMedia(ctx context.Context, ref MediaRef, dst, referer string, required bool) (int64, error) { func (c *Crawler) materializeMedia(ctx context.Context, ref MediaRef, dst, referer string, required bool) (int64, error) {
if local := strings.TrimSpace(ref.LocalFile); local != "" { if local := strings.TrimSpace(ref.LocalFile); local != "" {
return c.copyLocalOutput(local, dst) return c.copyLocalOutput(local, dst)
@@ -114,6 +114,128 @@ func TestCrawlerRunOnceImportsLocalFileAndSkipsExisting(t *testing.T) {
} }
} }
func TestCrawlerRunOnceMarksPreviewDisabledWhenConfigured(t *testing.T) {
ctx := context.Background()
tmp := t.TempDir()
cat, err := catalog.Open(filepath.Join(tmp, "catalog.db"))
if err != nil {
t.Fatalf("open catalog: %v", err)
}
t.Cleanup(func() {
if err := cat.Close(); err != nil {
t.Fatalf("close catalog: %v", err)
}
})
drv := New(Config{ID: "demo", RootDir: filepath.Join(tmp, "crawler")})
if err := drv.Init(ctx); err != nil {
t.Fatalf("driver init: %v", err)
}
dummyScript := filepath.Join(tmp, "helper-script")
if err := os.WriteFile(dummyScript, []byte("helper"), 0o755); err != nil {
t.Fatalf("write dummy script: %v", err)
}
wrapper := filepath.Join(tmp, "helper-wrapper.sh")
wrapperScript := fmt.Sprintf("#!/bin/sh\nexec %q -test.run=TestScriptCrawlerHelperProcess \"$@\"\n", os.Args[0])
if err := os.WriteFile(wrapper, []byte(wrapperScript), 0o755); err != nil {
t.Fatalf("write helper wrapper: %v", err)
}
t.Setenv("GO_WANT_SCRIPTCRAWLER_HELPER", "1")
c := NewCrawler(CrawlerConfig{
Driver: drv,
Catalog: cat,
PythonPath: wrapper,
FFprobePath: writeScriptCrawlerFFprobeStub(t, tmp, true),
ScriptPath: dummyScript,
DisablePreview: true,
})
res, err := c.RunOnce(ctx, 1)
if err != nil {
t.Fatalf("run once: %v", err)
}
if res.NewVideos != 1 || res.Failed != 0 {
t.Fatalf("result = new:%d failed:%d, want 1/0", res.NewVideos, res.Failed)
}
v, err := cat.GetVideo(ctx, BuildVideoID("demo", "abc-123"))
if err != nil {
t.Fatalf("get video: %v", err)
}
if v.PreviewStatus != "disabled" {
t.Fatalf("preview status = %q, want disabled", v.PreviewStatus)
}
if v.FingerprintStatus != "ready" || v.SampledSHA256 == "" {
t.Fatalf("fingerprint status=%q sampled=%q, want ready and sampled hash", v.FingerprintStatus, v.SampledSHA256)
}
pending, err := cat.ListVideosByPreviewStatus(ctx, "demo", "pending", 0)
if err != nil {
t.Fatalf("list pending previews: %v", err)
}
if len(pending) != 0 {
t.Fatalf("pending previews = %d, want 0", len(pending))
}
}
func TestCrawlerRunOnceUsesCurrentDrivePreviewSwitch(t *testing.T) {
ctx := context.Background()
tmp := t.TempDir()
cat, err := catalog.Open(filepath.Join(tmp, "catalog.db"))
if err != nil {
t.Fatalf("open catalog: %v", err)
}
t.Cleanup(func() {
if err := cat.Close(); err != nil {
t.Fatalf("close catalog: %v", err)
}
})
drv := New(Config{ID: "demo", RootDir: filepath.Join(tmp, "crawler")})
if err := drv.Init(ctx); err != nil {
t.Fatalf("driver init: %v", err)
}
if err := cat.UpsertDrive(ctx, &catalog.Drive{
ID: drv.ID(),
Kind: Kind,
Name: "Demo",
RootID: "/",
Credentials: map[string]string{"script_path": "/tmp/crawler.py"},
TeaserEnabled: true,
}); err != nil {
t.Fatalf("seed drive: %v", err)
}
dummyScript := filepath.Join(tmp, "helper-script")
if err := os.WriteFile(dummyScript, []byte("helper"), 0o755); err != nil {
t.Fatalf("write dummy script: %v", err)
}
wrapper := filepath.Join(tmp, "helper-wrapper.sh")
wrapperScript := fmt.Sprintf("#!/bin/sh\nexec %q -test.run=TestScriptCrawlerHelperProcess \"$@\"\n", os.Args[0])
if err := os.WriteFile(wrapper, []byte(wrapperScript), 0o755); err != nil {
t.Fatalf("write helper wrapper: %v", err)
}
t.Setenv("GO_WANT_SCRIPTCRAWLER_HELPER", "1")
c := NewCrawler(CrawlerConfig{
Driver: drv,
Catalog: cat,
PythonPath: wrapper,
FFprobePath: writeScriptCrawlerFFprobeStub(t, tmp, true),
ScriptPath: dummyScript,
DisablePreview: true,
})
res, err := c.RunOnce(ctx, 1)
if err != nil {
t.Fatalf("run once: %v", err)
}
if res.NewVideos != 1 || res.Failed != 0 {
t.Fatalf("result = new:%d failed:%d, want 1/0", res.NewVideos, res.Failed)
}
v, err := cat.GetVideo(ctx, BuildVideoID("demo", "abc-123"))
if err != nil {
t.Fatalf("get video: %v", err)
}
if v.PreviewStatus != "pending" {
t.Fatalf("preview status = %q, want pending from current drive switch", v.PreviewStatus)
}
}
func TestCrawlerRunOnceUsesSourceKindNamespace(t *testing.T) { func TestCrawlerRunOnceUsesSourceKindNamespace(t *testing.T) {
ctx := context.Background() ctx := context.Background()
tmp := t.TempDir() tmp := t.TempDir()
+20
View File
@@ -1114,6 +1114,19 @@ func (q *videoQueue) release(v *catalog.Video) {
q.mu.Unlock() q.mu.Unlock()
} }
func (q *videoQueue) idsSnapshot() []string {
q.mu.Lock()
defer q.mu.Unlock()
if len(q.ids) == 0 {
return nil
}
out := make([]string, 0, len(q.ids))
for id := range q.ids {
out = append(out, id)
}
return out
}
func (q *videoQueue) lengthExcluding(currentID string) int { func (q *videoQueue) lengthExcluding(currentID string) int {
q.mu.Lock() q.mu.Lock()
defer q.mu.Unlock() defer q.mu.Unlock()
@@ -1241,6 +1254,13 @@ func (w *Worker) Status() TaskStatus {
return taskStatus(&w.activity, &w.rateLimit, w.queue.lengthExcluding(currentID)) return taskStatus(&w.activity, &w.rateLimit, w.queue.lengthExcluding(currentID))
} }
func (w *Worker) ActiveVideoIDs() []string {
if w == nil {
return nil
}
return w.queue.idsSnapshot()
}
func (w *ThumbWorker) Status() TaskStatus { func (w *ThumbWorker) Status() TaskStatus {
if w == nil { if w == nil {
return TaskStatus{State: "idle"} return TaskStatus{State: "idle"}
+24 -19
View File
@@ -101,15 +101,16 @@ const (
) )
type migrationPlan struct { type migrationPlan struct {
source Spider91LocalSource source Spider91LocalSource
row *catalog.Drive row *catalog.Drive
sourceKinds []string sourceKinds []string
targetDriveID string targetDriveID string
target uploadTarget target uploadTarget
uploadDir string uploadDir string
keepLatestN int keepLatestN int
requireAssetsReady bool requireAssetsReady bool
legacyBackfill bool requirePreviewReady bool
legacyBackfill bool
} }
// pikpakAdapter / p115Adapter / p123Adapter / onedriveAdapter / googledriveAdapter / wopanAdapter / guangyapanAdapter 把具体 driver 包装成 uploadTarget。 // pikpakAdapter / p115Adapter / p123Adapter / onedriveAdapter / googledriveAdapter / wopanAdapter / guangyapanAdapter 把具体 driver 包装成 uploadTarget。
@@ -597,14 +598,15 @@ func (m *Migrator) migrationPlans(ctx context.Context) []migrationPlan {
continue continue
} }
out = append(out, migrationPlan{ out = append(out, migrationPlan{
source: src, source: src,
row: row, row: row,
sourceKinds: crawlerSourceKindsForRow(row), sourceKinds: crawlerSourceKindsForRow(row),
targetDriveID: resolvedID, targetDriveID: resolvedID,
target: target, target: target,
uploadDir: scriptCrawlerUploadDir(row.ID), uploadDir: scriptCrawlerUploadDir(row.ID),
keepLatestN: 0, keepLatestN: 0,
requireAssetsReady: true, requireAssetsReady: true,
requirePreviewReady: row.TeaserEnabled,
}) })
case spider91.Kind: case spider91.Kind:
if m.cfg.GetTargetDriveID == nil { if m.cfg.GetTargetDriveID == nil {
@@ -838,7 +840,7 @@ func (m *Migrator) migrateDrive(ctx context.Context, plan migrationPlan) (int, e
} }
if plan.requireAssetsReady { if plan.requireAssetsReady {
ready, err := m.crawlerVideoAssetsReady(ctx, v) ready, err := m.crawlerVideoAssetsReady(ctx, v, plan.requirePreviewReady)
if err != nil { if err != nil {
log.Printf("[spider91migrate] %s check generated assets: %v", v.ID, err) log.Printf("[spider91migrate] %s check generated assets: %v", v.ID, err)
continue continue
@@ -914,7 +916,7 @@ func (m *Migrator) findVideoForLocalFile(ctx context.Context, plan migrationPlan
return nil return nil
} }
func (m *Migrator) crawlerVideoAssetsReady(ctx context.Context, v *catalog.Video) (bool, error) { func (m *Migrator) crawlerVideoAssetsReady(ctx context.Context, v *catalog.Video, requirePreview bool) (bool, error) {
if v == nil { if v == nil {
return false, nil return false, nil
} }
@@ -922,6 +924,9 @@ func (m *Migrator) crawlerVideoAssetsReady(ctx context.Context, v *catalog.Video
if !fingerprintReady { if !fingerprintReady {
return false, nil return false, nil
} }
if !requirePreview {
return true, nil
}
if strings.EqualFold(strings.TrimSpace(v.PreviewStatus), "ready") { if strings.EqualFold(strings.TrimSpace(v.PreviewStatus), "ready") {
return true, nil return true, nil
} }
@@ -365,11 +365,19 @@ func seedScriptCrawlerDrive(t *testing.T, cat *catalog.Catalog, d *scriptcrawler
"script_path": "/tmp/crawler.py", "script_path": "/tmp/crawler.py",
"upload_drive_id": uploadDriveID, "upload_drive_id": uploadDriveID,
}, },
TeaserEnabled: true,
}); err != nil { }); err != nil {
t.Fatalf("seed scriptcrawler drive: %v", err) t.Fatalf("seed scriptcrawler drive: %v", err)
} }
} }
func setScriptCrawlerTeaserEnabled(t *testing.T, cat *catalog.Catalog, driveID string, enabled bool) {
t.Helper()
if err := cat.SetDriveTeaserEnabled(context.Background(), driveID, enabled); err != nil {
t.Fatalf("set scriptcrawler teaser enabled: %v", err)
}
}
func writeScriptCrawlerVideo(t *testing.T, cat *catalog.Catalog, d *scriptcrawler.Driver, sourceID, ext string, content []byte, readyAssets bool) string { func writeScriptCrawlerVideo(t *testing.T, cat *catalog.Catalog, d *scriptcrawler.Driver, sourceID, ext string, content []byte, readyAssets bool) string {
t.Helper() t.Helper()
fileID := sourceID + ext fileID := sourceID + ext
@@ -587,6 +595,47 @@ func TestRunOnceSkipsScriptCrawlerVideoUntilPreviewAndFingerprintReady(t *testin
} }
} }
func TestRunOnceMigratesScriptCrawlerVideoWithoutPreviewWhenTeaserDisabled(t *testing.T) {
cat := setupCatalog(t)
src := setupScriptCrawler(t, "crawler-no-preview")
pp := newFakePikPak("pikpak-target", "pikpak-root-id")
seedScriptCrawlerDrive(t, cat, src, pp.ID())
setScriptCrawlerTeaserEnabled(t, cat, src.ID(), false)
reg := newFakeRegistry()
reg.Add(src)
reg.Add(pp)
id := writeScriptCrawlerVideo(t, cat, src, "fingerprint-ready", ".mp4", []byte("script video bytes"), false)
if err := cat.UpdateVideoFingerprint(context.Background(), id, "sampled-fingerprint-ready", "ready", ""); err != nil {
t.Fatalf("mark fingerprint ready: %v", err)
}
if err := cat.UpdatePreview(context.Background(), id, "", "disabled"); err != nil {
t.Fatalf("mark preview disabled: %v", err)
}
m := New(Config{Catalog: cat, Registry: reg})
m.runOnce(context.Background())
if pp.uploadCalls != 1 {
t.Fatalf("upload calls = %d, want 1 when preview generation is disabled", pp.uploadCalls)
}
got, err := cat.GetVideo(context.Background(), id)
if err != nil {
t.Fatalf("get migrated video: %v", err)
}
if got.DriveID != pp.ID() {
t.Fatalf("drive_id = %q, want %q", got.DriveID, pp.ID())
}
if got.PreviewStatus != "disabled" || got.FingerprintStatus != "ready" || got.SampledSHA256 == "" {
t.Fatalf("asset status after migration = preview %q fingerprint %q sampled %q, want disabled/ready/non-empty", got.PreviewStatus, got.FingerprintStatus, got.SampledSHA256)
}
videoPath, _ := src.VideoPath("fingerprint-ready.mp4")
if _, err := os.Stat(videoPath); !os.IsNotExist(err) {
t.Fatalf("local scriptcrawler video still exists or stat error %v", err)
}
}
func TestRunOnceBindsScriptCrawlerDuplicateToExistingTargetWithoutUpload(t *testing.T) { func TestRunOnceBindsScriptCrawlerDuplicateToExistingTargetWithoutUpload(t *testing.T) {
cat := setupCatalog(t) cat := setupCatalog(t)
src := setupScriptCrawler(t, "crawler-duplicate") src := setupScriptCrawler(t, "crawler-duplicate")
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "video-site", "name": "video-site",
"version": "0.1.7", "version": "0.1.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "video-site", "name": "video-site",
"version": "0.1.7", "version": "0.1.8",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"artplayer": "^5.4.0", "artplayer": "^5.4.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "video-site", "name": "video-site",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"version": "0.1.7", "version": "0.1.8",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+37
View File
@@ -18,6 +18,8 @@ import {
Link as LinkIcon, Link as LinkIcon,
Pencil, Pencil,
Plus, Plus,
Power,
PowerOff,
RefreshCw, RefreshCw,
TestTube, TestTube,
Trash2, Trash2,
@@ -56,6 +58,7 @@ export function CrawlersPage() {
const [expandedId, setExpandedId] = useState(""); const [expandedId, setExpandedId] = useState("");
const [runningId, setRunningId] = useState(""); const [runningId, setRunningId] = useState("");
const [stoppingId, setStoppingId] = useState(""); const [stoppingId, setStoppingId] = useState("");
const [togglingTeaserId, setTogglingTeaserId] = useState("");
// undefined = 编辑器关闭;null = 新建;其余 = 编辑已有爬虫 // undefined = 编辑器关闭;null = 新建;其余 = 编辑已有爬虫
const [editorTarget, setEditorTarget] = useState<api.AdminCrawler | null | undefined>(undefined); const [editorTarget, setEditorTarget] = useState<api.AdminCrawler | null | undefined>(undefined);
const [deleteTarget, setDeleteTarget] = useState<api.AdminCrawler | null>(null); const [deleteTarget, setDeleteTarget] = useState<api.AdminCrawler | null>(null);
@@ -136,6 +139,23 @@ export function CrawlersPage() {
} }
} }
async function toggleTeaser(crawler: api.AdminCrawler) {
const next = !crawler.teaserEnabled;
setTogglingTeaserId(crawler.id);
setList((prev) => prev.map((item) => (item.id === crawler.id ? { ...item, teaserEnabled: next } : item)));
try {
const resp = await api.setDriveTeaserEnabled(crawler.id, next);
setList((prev) => prev.map((item) => (item.id === crawler.id ? { ...item, teaserEnabled: resp.teaserEnabled } : item)));
show(resp.teaserEnabled ? `已开启「${crawler.name}」预览视频生成` : `已关闭「${crawler.name}」预览视频生成`, "success");
await refresh(true);
} catch (e) {
setList((prev) => prev.map((item) => (item.id === crawler.id ? { ...item, teaserEnabled: crawler.teaserEnabled } : item)));
show(e instanceof Error ? e.message : "切换预览视频失败", "error");
} finally {
setTogglingTeaserId("");
}
}
async function confirmDelete() { async function confirmDelete() {
if (!deleteTarget) return; if (!deleteTarget) return;
setDeleting(true); setDeleting(true);
@@ -214,9 +234,11 @@ export function CrawlersPage() {
expanded={expandedId === crawler.id} expanded={expandedId === crawler.id}
running={runningId === crawler.id} running={runningId === crawler.id}
stopping={stoppingId === crawler.id} stopping={stoppingId === crawler.id}
togglingTeaser={togglingTeaserId === crawler.id}
onToggle={() => setExpandedId(expandedId === crawler.id ? "" : crawler.id)} onToggle={() => setExpandedId(expandedId === crawler.id ? "" : crawler.id)}
onRun={() => run(crawler)} onRun={() => run(crawler)}
onStop={() => stop(crawler)} onStop={() => stop(crawler)}
onToggleTeaser={() => toggleTeaser(crawler)}
onEdit={() => setEditorTarget(crawler)} onEdit={() => setEditorTarget(crawler)}
onDelete={() => setDeleteTarget(crawler)} onDelete={() => setDeleteTarget(crawler)}
/> />
@@ -290,9 +312,11 @@ function CrawlerRow({
expanded, expanded,
running, running,
stopping, stopping,
togglingTeaser,
onToggle, onToggle,
onRun, onRun,
onStop, onStop,
onToggleTeaser,
onEdit, onEdit,
onDelete, onDelete,
}: { }: {
@@ -300,9 +324,11 @@ function CrawlerRow({
expanded: boolean; expanded: boolean;
running: boolean; running: boolean;
stopping: boolean; stopping: boolean;
togglingTeaser: boolean;
onToggle: () => void; onToggle: () => void;
onRun: () => void; onRun: () => void;
onStop: () => void; onStop: () => void;
onToggleTeaser: () => void;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
}) { }) {
@@ -343,6 +369,17 @@ function CrawlerRow({
<ChevronDown size={16} className="admin-crawler-row__chevron" /> <ChevronDown size={16} className="admin-crawler-row__chevron" />
</button> </button>
<div className="admin-crawler-row__actions"> <div className="admin-crawler-row__actions">
<button
className={`admin-btn admin-crawler-preview-card-toggle ${crawler.teaserEnabled ? "is-on" : ""}`}
type="button"
onClick={onToggleTeaser}
disabled={togglingTeaser}
aria-pressed={crawler.teaserEnabled}
title={crawler.teaserEnabled ? "关闭后,该爬虫新爬取的视频不再生成预览视频" : "开启后,该爬虫新爬取的视频会生成预览视频"}
>
{crawler.teaserEnabled ? <Power size={13} /> : <PowerOff size={13} />}
<span>{crawler.teaserEnabled ? "预览:开" : "预览:关"}</span>
</button>
{busy ? ( {busy ? (
<button className="admin-btn is-stop" type="button" onClick={onStop} disabled={stopping}> <button className="admin-btn is-stop" type="button" onClick={onStop} disabled={stopping}>
<CircleStop size={13} /> {stopping ? "停止中..." : "停止"} <CircleStop size={13} /> {stopping ? "停止中..." : "停止"}
+2 -2
View File
@@ -632,7 +632,7 @@ export function DrivesPage() {
</button> </button>
<button <button
type="button" type="button"
className="admin-btn is-stop" className="admin-btn is-primary"
onClick={() => handleStopDriveTasks(d)} onClick={() => handleStopDriveTasks(d)}
disabled={!!stoppingDriveId} disabled={!!stoppingDriveId}
title="停止此网盘当前的扫描、封面、预览视频和视频指纹生成任务。" title="停止此网盘当前的扫描、封面、预览视频和视频指纹生成任务。"
@@ -642,7 +642,7 @@ export function DrivesPage() {
</button> </button>
</div> </div>
{d.kind !== "spider91" && ( {d.kind !== "spider91" && (
<button type="button" className="admin-btn" onClick={() => openEdit(d)}> <button type="button" className="admin-btn is-primary" onClick={() => openEdit(d)}>
</button> </button>
)} )}
+96 -6
View File
@@ -21,9 +21,17 @@ import { formatBytes } from "./storageFormat";
const DESKTOP_VIDEOS_PAGE_SIZE = 50; const DESKTOP_VIDEOS_PAGE_SIZE = 50;
const MOBILE_VIDEOS_PAGE_SIZE = 20; const MOBILE_VIDEOS_PAGE_SIZE = 20;
const VIDEOS_MOBILE_QUERY = "(max-width: 640px)"; const VIDEOS_MOBILE_QUERY = "(max-width: 640px)";
const REGEN_PREVIEW_STATUS = "generating";
const REGEN_PREVIEW_POLL_INTERVAL_MS = 2000;
const REGEN_PREVIEW_TRACK_TIMEOUT_MS = 30 * 60 * 1000;
type TabKey = "current" | "blacklist"; type TabKey = "current" | "blacklist";
type RegenPreviewState = {
expiresAt: number;
originalUpdatedAt: number;
};
const TABS: { key: TabKey; label: string }[] = [ const TABS: { key: TabKey; label: string }[] = [
{ key: "current", label: "当前视频" }, { key: "current", label: "当前视频" },
{ key: "blacklist", label: "拉黑视频" }, { key: "blacklist", label: "拉黑视频" },
@@ -121,6 +129,7 @@ function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) {
const [deleteTarget, setDeleteTarget] = useState<api.AdminVideo | null>(null); const [deleteTarget, setDeleteTarget] = useState<api.AdminVideo | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [deleteSource, setDeleteSource] = useState(false); const [deleteSource, setDeleteSource] = useState(false);
const [regenPreviewById, setRegenPreviewById] = useState<Record<string, RegenPreviewState>>({});
const pageSize = useVideosPageSize(); const pageSize = useVideosPageSize();
const { show } = useToast(); const { show } = useToast();
@@ -147,6 +156,19 @@ function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) {
} }
} }
async function refreshListOnly() {
try {
const r = await api.listVideos({ driveId, page, size: pageSize, keyword: searchKeyword });
setList(r.items ?? []);
setTotal(r.total ?? 0);
} catch {
// Polling is only used to clear optimistic preview-generation state.
}
}
const trackedRegenCount = Object.keys(regenPreviewById).length;
const hasGeneratingPreview = list.some((v) => v.previewStatus === REGEN_PREVIEW_STATUS);
useEffect(() => { useEffect(() => {
refresh(); refresh();
}, [driveId, page, searchKeyword, pageSize]); }, [driveId, page, searchKeyword, pageSize]);
@@ -164,6 +186,33 @@ function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) {
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [keyword]); }, [keyword]);
useEffect(() => {
if (trackedRegenCount === 0 && !hasGeneratingPreview) return;
const timer = window.setInterval(() => {
refreshListOnly();
}, REGEN_PREVIEW_POLL_INTERVAL_MS);
return () => window.clearInterval(timer);
}, [trackedRegenCount, hasGeneratingPreview, driveId, page, pageSize, searchKeyword]);
useEffect(() => {
if (trackedRegenCount === 0) return;
const now = Date.now();
setRegenPreviewById((current) => {
const next = { ...current };
let changed = false;
const byId = new Map(list.map((v) => [v.id, v]));
for (const [id, state] of Object.entries(current)) {
const video = byId.get(id);
const updatedAt = videoUpdatedAtMs(video);
if (!video || now >= state.expiresAt || updatedAt > state.originalUpdatedAt) {
delete next[id];
changed = true;
}
}
return changed ? next : current;
});
}, [list, trackedRegenCount]);
const driveNameMap = new Map(drives.map((d) => [d.id, d.name || d.id])); const driveNameMap = new Map(drives.map((d) => [d.id, d.name || d.id]));
const listItems = list; const listItems = list;
@@ -177,6 +226,7 @@ function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) {
async function handleRegen(v: api.AdminVideo) { async function handleRegen(v: api.AdminVideo) {
try { try {
await api.regenPreview(v.id); await api.regenPreview(v.id);
trackRegeneratingPreview([v]);
show("已触发预览视频重生", "success"); show("已触发预览视频重生", "success");
} catch (e) { } catch (e) {
show(e instanceof Error ? e.message : "触发失败", "error"); show(e instanceof Error ? e.message : "触发失败", "error");
@@ -196,13 +246,20 @@ function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) {
async function confirmBatchRegen() { async function confirmBatchRegen() {
const ids = [...selectedIds]; const ids = [...selectedIds];
const videoById = new Map(listItems.map((v) => [v.id, v]));
setBatchRegening(true); setBatchRegening(true);
let success = 0; let success = 0;
try { try {
const results = await Promise.allSettled(ids.map((id) => api.regenPreview(id))); const results = await Promise.allSettled(ids.map((id) => api.regenPreview(id)));
for (const r of results) { const acceptedVideos: api.AdminVideo[] = [];
if (r.status === "fulfilled") success++; results.forEach((r, index) => {
} if (r.status === "fulfilled") {
const video = videoById.get(ids[index]);
if (video) acceptedVideos.push(video);
success++;
}
});
trackRegeneratingPreview(acceptedVideos);
show( show(
`批量触发完成,成功 ${success} / ${ids.length}`, `批量触发完成,成功 ${success} / ${ids.length}`,
success === ids.length ? "success" : "info" success === ids.length ? "success" : "info"
@@ -214,6 +271,25 @@ function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) {
} }
} }
function trackRegeneratingPreview(videos: api.AdminVideo[]) {
if (videos.length === 0) return;
const startedAt = Date.now();
setRegenPreviewById((current) => {
const next = { ...current };
for (const v of videos) {
next[v.id] = {
expiresAt: startedAt + REGEN_PREVIEW_TRACK_TIMEOUT_MS,
originalUpdatedAt: videoUpdatedAtMs(v),
};
}
return next;
});
}
function isPreviewGenerating(v: api.AdminVideo) {
return !!regenPreviewById[v.id] || v.previewStatus === REGEN_PREVIEW_STATUS;
}
async function confirmDeleteVideo() { async function confirmDeleteVideo() {
if (!deleteTarget) return; if (!deleteTarget) return;
const target = deleteTarget; const target = deleteTarget;
@@ -398,7 +474,7 @@ function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) {
<td data-label="作者">{v.author || <span className="admin-text-faint"></span>}</td> <td data-label="作者">{v.author || <span className="admin-text-faint"></span>}</td>
<td data-label="时长">{formatDur(v.durationSeconds)}</td> <td data-label="时长">{formatDur(v.durationSeconds)}</td>
<td data-label="预览视频"> <td data-label="预览视频">
<PreviewStatus s={v.previewStatus} /> <PreviewStatus s={isPreviewGenerating(v) ? REGEN_PREVIEW_STATUS : v.previewStatus} />
</td> </td>
<td data-label="来源" className="admin-mono-cell"> <td data-label="来源" className="admin-mono-cell">
{driveNameMap.get(v.driveId) ?? v.driveId} {driveNameMap.get(v.driveId) ?? v.driveId}
@@ -407,8 +483,14 @@ function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) {
<button type="button" className="admin-btn" onClick={() => setEditing(v)} title="编辑视频"> <button type="button" className="admin-btn" onClick={() => setEditing(v)} title="编辑视频">
<Edit size={13} /> <Edit size={13} />
</button>{" "} </button>{" "}
<button type="button" className="admin-btn" onClick={() => handleRegen(v)} title="重生预览视频"> <button
<RefreshCw size={13} /> type="button"
className="admin-btn"
onClick={() => handleRegen(v)}
disabled={isPreviewGenerating(v)}
title={isPreviewGenerating(v) ? "预览视频正在生成" : "重生预览视频"}
>
<RefreshCw size={13} className={isPreviewGenerating(v) ? "admin-spin" : undefined} />
</button>{" "} </button>{" "}
<button <button
type="button" type="button"
@@ -832,8 +914,10 @@ function VideoTitleCell({ video: v }: { video: api.AdminVideo }) {
} }
function PreviewStatus({ s }: { s: string }) { function PreviewStatus({ s }: { s: string }) {
if (s === REGEN_PREVIEW_STATUS) return <span className="admin-status is-generating"></span>;
if (s === "ready") return <span className="admin-status is-ok"></span>; if (s === "ready") return <span className="admin-status is-ok"></span>;
if (s === "failed") return <span className="admin-status is-error"></span>; if (s === "failed") return <span className="admin-status is-error"></span>;
if (s === "disabled") return <span className="admin-status"></span>;
if (s === "skipped") return <span className="admin-status"></span>; if (s === "skipped") return <span className="admin-status"></span>;
return <span className="admin-status is-pending"></span>; return <span className="admin-status is-pending"></span>;
} }
@@ -870,6 +954,12 @@ function formatDateTime(ms: number): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
} }
function videoUpdatedAtMs(video?: api.AdminVideo): number {
if (!video?.updatedAt) return 0;
const value = Date.parse(video.updatedAt);
return Number.isFinite(value) ? value : 0;
}
function useVideosPageSize() { function useVideosPageSize() {
const [pageSize, setPageSize] = useState(() => const [pageSize, setPageSize] = useState(() =>
window.matchMedia(VIDEOS_MOBILE_QUERY).matches ? MOBILE_VIDEOS_PAGE_SIZE : DESKTOP_VIDEOS_PAGE_SIZE window.matchMedia(VIDEOS_MOBILE_QUERY).matches ? MOBILE_VIDEOS_PAGE_SIZE : DESKTOP_VIDEOS_PAGE_SIZE
+2 -1
View File
@@ -84,7 +84,7 @@ export type AdminDrive = {
status: string; status: string;
lastError?: string; lastError?: string;
hasCredential: boolean; hasCredential: boolean;
/** 当前是否给该盘生成预览视频/封面(per-drive 开关,替代旧的全局 preview.enabled)。 */ /** 当前是否给该盘生成预览视频(per-drive 开关,替代旧的全局 preview.enabled;封面不受影响)。 */
teaserEnabled: boolean; teaserEnabled: boolean;
/** /**
* admin "扫描跳过目录"drive fileID * admin "扫描跳过目录"drive fileID
@@ -212,6 +212,7 @@ export type AdminCrawler = {
proxy?: string; proxy?: string;
targetNew?: string; targetNew?: string;
uploadDriveId?: string; uploadDriveId?: string;
teaserEnabled: boolean;
lastCrawlAt?: number; lastCrawlAt?: number;
scanGenerationStatus?: DriveGenerationStatus; scanGenerationStatus?: DriveGenerationStatus;
thumbnailGenerationStatus?: DriveGenerationStatus; thumbnailGenerationStatus?: DriveGenerationStatus;
+3
View File
@@ -92,8 +92,11 @@ const LONG_PRESS_MS = 400;
const FAST_RATE = 2; const FAST_RATE = 2;
/** 默认倍速。 */ /** 默认倍速。 */
const NORMAL_RATE = 1; const NORMAL_RATE = 1;
/** ArtPlayer 内部播放失败自动重连次数。 */
const ARTPLAYER_RECONNECT_TIME_MAX = 3;
Artplayer.FAST_FORWARD_VALUE = FAST_RATE; Artplayer.FAST_FORWARD_VALUE = FAST_RATE;
Artplayer.RECONNECT_TIME_MAX = ARTPLAYER_RECONNECT_TIME_MAX;
const DEFAULT_SETTINGS: PlayerSettings = { const DEFAULT_SETTINGS: PlayerSettings = {
volume: 0.7, volume: 0.7,
+20
View File
@@ -768,6 +768,25 @@
gap: var(--space-2); gap: var(--space-2);
} }
.admin-crawler-preview-card-toggle {
min-width: 96px;
padding-inline: 10px;
}
.admin-crawler-preview-card-toggle.is-on {
border-color: var(--accent);
background: var(--accent);
color: var(--text-on-accent);
box-shadow: none;
}
.admin-crawler-preview-card-toggle.is-on:hover:not(:disabled) {
border-color: var(--accent-hover);
background: var(--accent-hover);
color: var(--text-on-accent);
box-shadow: none;
}
.admin-crawler-row__delete { .admin-crawler-row__delete {
padding-inline: 10px; padding-inline: 10px;
} }
@@ -1879,6 +1898,7 @@
.admin-status.is-ok { background: var(--success-soft); color: var(--success); } .admin-status.is-ok { background: var(--success-soft); color: var(--success); }
.admin-status.is-error { background: var(--danger-soft); color: var(--danger); } .admin-status.is-error { background: var(--danger-soft); color: var(--danger); }
.admin-status.is-pending { background: var(--warning-soft); color: var(--warning); } .admin-status.is-pending { background: var(--warning-soft); color: var(--warning); }
.admin-status.is-generating { background: var(--info-soft); color: var(--info); }
.admin-generation-state.is-generating { background: var(--info-soft); color: var(--info); } .admin-generation-state.is-generating { background: var(--info-soft); color: var(--info); }
.admin-generation-state.is-cooling { background: var(--warning-soft); color: var(--warning); } .admin-generation-state.is-cooling { background: var(--warning-soft); color: var(--warning); }
+24 -7
View File
@@ -492,10 +492,15 @@
} }
.skeleton-card { .skeleton-card {
--skeleton-card-bg: var(--bg-surface);
--skeleton-card-border: var(--border-subtle);
--skeleton-shimmer-base: rgba(255, 255, 255, 0.03);
--skeleton-shimmer-highlight: rgba(255, 255, 255, 0.08);
position: relative; position: relative;
aspect-ratio: 16 / 12.5; aspect-ratio: 16 / 12.5;
background: var(--bg-surface); background: var(--skeleton-card-bg);
border: 1px solid var(--border-subtle); border: 1px solid var(--skeleton-card-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: 8px; padding: 8px;
box-sizing: border-box; box-sizing: border-box;
@@ -504,6 +509,18 @@
flex-direction: column; flex-direction: column;
} }
:root[data-theme="pink"] .skeleton-card {
--skeleton-card-border: rgba(255, 91, 138, 0.18);
--skeleton-shimmer-base: rgba(255, 91, 138, 0.12);
--skeleton-shimmer-highlight: rgba(255, 91, 138, 0.26);
}
:root[data-theme="sky"] .skeleton-card {
--skeleton-card-border: rgba(60, 100, 170, 0.18);
--skeleton-shimmer-base: rgba(60, 100, 170, 0.13);
--skeleton-shimmer-highlight: rgba(60, 100, 170, 0.26);
}
/* Skeleton image thumbnail area */ /* Skeleton image thumbnail area */
.skeleton-card::before { .skeleton-card::before {
content: ""; content: "";
@@ -513,9 +530,9 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
rgba(255, 255, 255, 0.03) 25%, var(--skeleton-shimmer-base) 25%,
rgba(255, 255, 255, 0.08) 50%, var(--skeleton-shimmer-highlight) 50%,
rgba(255, 255, 255, 0.03) 75% var(--skeleton-shimmer-base) 75%
); );
background-size: 200% 100%; background-size: 200% 100%;
animation: skeleton-shimmer 1.6s ease-in-out infinite; animation: skeleton-shimmer 1.6s ease-in-out infinite;
@@ -529,8 +546,8 @@
width: 100%; width: 100%;
height: 28px; height: 28px;
background: background:
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 25%, rgba(255, 255, 255, 0.08) 50%, rgba(255, 255, 255, 0.03) 75%) 0 0 / 70% 12px no-repeat, linear-gradient(90deg, var(--skeleton-shimmer-base) 25%, var(--skeleton-shimmer-highlight) 50%, var(--skeleton-shimmer-base) 75%) 0 0 / 70% 12px no-repeat,
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 25%, rgba(255, 255, 255, 0.08) 50%, rgba(255, 255, 255, 0.03) 75%) 0 20px / 42% 8px no-repeat; linear-gradient(90deg, var(--skeleton-shimmer-base) 25%, var(--skeleton-shimmer-highlight) 50%, var(--skeleton-shimmer-base) 75%) 0 20px / 42% 8px no-repeat;
background-size: 200% 100%; background-size: 200% 100%;
animation: skeleton-shimmer 1.6s ease-in-out infinite; animation: skeleton-shimmer 1.6s ease-in-out infinite;
} }
+24
View File
@@ -259,6 +259,13 @@ test("crawler management is a separate admin section", () => {
assert.match(crawlerPageSource, /测试通过/); assert.match(crawlerPageSource, /测试通过/);
assert.match(crawlerPageSource, /Spider91UploadTargetField/); assert.match(crawlerPageSource, /Spider91UploadTargetField/);
assert.match(crawlerPageSource, /uploadDriveId/); assert.match(crawlerPageSource, /uploadDriveId/);
assert.match(crawlerPageSource, /api\.setDriveTeaserEnabled/);
assert.match(crawlerPageSource, /admin-crawler-preview-card-toggle/);
assert.match(crawlerPageSource, /预览:开/);
assert.match(crawlerPageSource, /预览:关/);
assert.match(crawlerPageSource, /aria-pressed=\{crawler\.teaserEnabled\}/);
assert.doesNotMatch(crawlerPageSource, /teaserEnabled: form\.teaserEnabled/);
assert.doesNotMatch(crawlerPageSource, /aria-pressed=\{form\.teaserEnabled\}/);
assert.match(crawlerPageSource, /UPLOAD_TARGET_KINDS/); assert.match(crawlerPageSource, /UPLOAD_TARGET_KINDS/);
assert.doesNotMatch(crawlerPageSource, /新建脚本/); assert.doesNotMatch(crawlerPageSource, /新建脚本/);
assert.doesNotMatch(crawlerPageSource, /爬虫 ID/); assert.doesNotMatch(crawlerPageSource, /爬虫 ID/);
@@ -274,6 +281,8 @@ test("crawler management is a separate admin section", () => {
assert.doesNotMatch(crawlerPageSource, /内置 91/); assert.doesNotMatch(crawlerPageSource, /内置 91/);
assert.match(apiSource, /type AdminCrawler/); assert.match(apiSource, /type AdminCrawler/);
assert.match(apiSource, /uploadDriveId\?: string/); assert.match(apiSource, /uploadDriveId\?: string/);
assert.match(apiSource, /teaserEnabled: boolean/);
assert.doesNotMatch(apiSource, /teaserEnabled\?: boolean/);
assert.match(apiSource, /"\/crawlers"/); assert.match(apiSource, /"\/crawlers"/);
assert.match(apiSource, /"\/crawlers\/import-file"/); assert.match(apiSource, /"\/crawlers\/import-file"/);
assert.match(apiSource, /"\/crawlers\/import-url"/); assert.match(apiSource, /"\/crawlers\/import-url"/);
@@ -304,6 +313,21 @@ test("drive management exposes stop task controls", () => {
assert.match(drivesPageSource, /停止所有网盘任务/); assert.match(drivesPageSource, /停止所有网盘任务/);
}); });
test("drive detail primary actions use the rescan button color", () => {
assert.match(
drivesPageSource,
/className="admin-btn is-primary"\s+onClick=\{\(\) => handleRescan\(d\)\}/
);
assert.match(
drivesPageSource,
/className="admin-btn is-primary"\s+onClick=\{\(\) => handleStopDriveTasks\(d\)\}/
);
assert.match(
drivesPageSource,
/className="admin-btn is-primary"\s+onClick=\{\(\) => openEdit\(d\)\}/
);
});
test("drive rescan reports busy storage tasks instead of queueing duplicates", () => { test("drive rescan reports busy storage tasks instead of queueing duplicates", () => {
assert.match(apiSource, /accepted:\s*boolean;\s*message\?:\s*string/); assert.match(apiSource, /accepted:\s*boolean;\s*message\?:\s*string/);
assert.match(apiSource, /scanGenerationStatus\?: DriveGenerationStatus/); assert.match(apiSource, /scanGenerationStatus\?: DriveGenerationStatus/);
+16
View File
@@ -20,3 +20,19 @@ test("admin videos batch delete runs deletions sequentially", () => {
/Promise\.allSettled\(\s*ids\.map\(\(id\) => api\.deleteVideo\(id(?:, [^)]+)?\)\)\s*\)/ /Promise\.allSettled\(\s*ids\.map\(\(id\) => api\.deleteVideo\(id(?:, [^)]+)?\)\)\s*\)/
); );
}); });
test("admin videos show generating status after preview regeneration is accepted", () => {
assert.match(videosPageSource, /const REGEN_PREVIEW_STATUS = "generating";/);
assert.match(videosPageSource, /const \[regenPreviewById, setRegenPreviewById\]/);
assert.match(videosPageSource, /trackRegeneratingPreview\(\[v\]\)/);
assert.match(videosPageSource, /<PreviewStatus s=\{isPreviewGenerating\(v\) \? REGEN_PREVIEW_STATUS : v\.previewStatus\} \/>/);
assert.match(videosPageSource, /refreshListOnly\(\)/);
});
test("admin videos keep generating status after page refresh", () => {
assert.match(videosPageSource, /const hasGeneratingPreview = list\.some\(\(v\) => v\.previewStatus === REGEN_PREVIEW_STATUS\);/);
assert.match(videosPageSource, /if \(trackedRegenCount === 0 && !hasGeneratingPreview\) return;/);
assert.match(videosPageSource, /function isPreviewGenerating\(v: api\.AdminVideo\)/);
assert.match(videosPageSource, /return !!regenPreviewById\[v\.id\] \|\| v\.previewStatus === REGEN_PREVIEW_STATUS;/);
assert.match(videosPageSource, /disabled=\{isPreviewGenerating\(v\)\}/);
});
+35
View File
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const videoCardCss = readFileSync(
new URL("../src/styles/video-card.css", import.meta.url),
"utf8"
);
function ruleBody(css: string, selector: string): string {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`));
assert.ok(match, `Expected CSS rule for ${selector}`);
return match[1];
}
test("home video skeleton uses theme-aware non-white shimmer colors", () => {
const skeleton = ruleBody(videoCardCss, ".skeleton-card");
const pink = ruleBody(videoCardCss, ':root[data-theme="pink"] .skeleton-card');
const sky = ruleBody(videoCardCss, ':root[data-theme="sky"] .skeleton-card');
const thumb = ruleBody(videoCardCss, ".skeleton-card::before");
const text = ruleBody(videoCardCss, ".skeleton-card::after");
assert.match(skeleton, /--skeleton-shimmer-base\s*:/);
assert.match(skeleton, /--skeleton-shimmer-highlight\s*:/);
assert.match(thumb, /var\(--skeleton-shimmer-base\)/);
assert.match(thumb, /var\(--skeleton-shimmer-highlight\)/);
assert.match(text, /var\(--skeleton-shimmer-base\)/);
assert.match(text, /var\(--skeleton-shimmer-highlight\)/);
assert.match(pink, /--skeleton-shimmer-base\s*:\s*rgba\(255,\s*91,\s*138,\s*0\.12\)/);
assert.match(pink, /--skeleton-shimmer-highlight\s*:\s*rgba\(255,\s*91,\s*138,\s*0\.26\)/);
assert.match(sky, /--skeleton-shimmer-base\s*:\s*rgba\(60,\s*100,\s*170,\s*0\.13\)/);
assert.match(sky, /--skeleton-shimmer-highlight\s*:\s*rgba\(60,\s*100,\s*170,\s*0\.26\)/);
});
+8
View File
@@ -74,6 +74,14 @@ test("detail player exposes a non-persistent loop switch in ArtPlayer settings",
assert.match(playerSource, /item\.tooltip = next \? "开" : "关"/); assert.match(playerSource, /item\.tooltip = next \? "开" : "关"/);
}); });
test("detail player limits ArtPlayer automatic reconnect attempts", () => {
assert.match(playerSource, /const ARTPLAYER_RECONNECT_TIME_MAX = 3;/);
assert.match(
playerSource,
/Artplayer\.RECONNECT_TIME_MAX = ARTPLAYER_RECONNECT_TIME_MAX;/
);
});
test("detail loading skeleton matches current desktop video page layout", () => { test("detail loading skeleton matches current desktop video page layout", () => {
assert.match(detailPageSource, /className="vd-layout vd-skeleton"/); assert.match(detailPageSource, /className="vd-layout vd-skeleton"/);
assert.match(detailPageSource, /className="vd-skeleton__summary"/); assert.match(detailPageSource, /className="vd-skeleton__summary"/);