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 {
return app.driveGenerationStatuses()
},
GetPreviewGenerationVideoIDs: func() map[string]bool {
return app.previewGenerationVideoIDs()
},
OnTeaserEnabledChanged: func(driveID string, enabled bool) {
// 从关到开时立刻补扫该盘 pending 预览视频,行为对齐旧的"全局开关从关到开"。
// 关闭分支不需要做事 —— 入队前会重新查 catalog,新的 enqueue 自然停。
@@ -656,6 +659,23 @@ func (a *App) driveGenerationStatuses() map[string]api.DriveGenerationStatuses {
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) {
driveID := strings.TrimSpace(progress.DriveID)
if driveID == "" {
@@ -1216,6 +1236,7 @@ func (a *App) attachScriptCrawler(d *catalog.Drive, drv *scriptcrawler.Driver) {
CommonThumbDir: a.commonThumbsDir(),
ProxyURL: proxyURL,
ConfigJSON: configJSON,
DisablePreview: !d.TeaserEnabled,
OnProgress: func(progress scriptcrawler.CrawlProgress) {
scanned := progress.Checked
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)
}
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 = ""
}
if err := a.cat.UpsertDrive(ctx, d); err != nil {
if err := a.updateScriptCrawlerRunState(ctx, driveID, runErr); err != nil {
log.Printf("[scriptcrawler] drive=%s update last_crawl_at: %v", driveID, err)
}
if err := ctx.Err(); err != nil {
@@ -3172,6 +3182,25 @@ func (a *App) runScriptCrawlerCrawlWithTaskContext(ctx context.Context, driveID
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) {
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)
}
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) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
+29 -9
View File
@@ -65,9 +65,10 @@ type AdminServer struct {
// 处理完候选列表后任务自然结束。
OnStartDriveTranscode func(driveID string) (bool, string)
// OnStopDriveTranscode 手动停止某盘正在进行的转码任务。返回是否有任务被停。
OnStopDriveTranscode func(driveID string) bool
OnDeleteVideo func(ctx context.Context, videoID string, deleteSource bool) (DeleteVideoResult, error)
GetDriveGenerationStatuses func() map[string]DriveGenerationStatuses
OnStopDriveTranscode func(driveID string) bool
OnDeleteVideo func(ctx context.Context, videoID string, deleteSource bool) (DeleteVideoResult, error)
GetDriveGenerationStatuses func() map[string]DriveGenerationStatuses
GetPreviewGenerationVideoIDs func() map[string]bool
// OnTeaserEnabledChanged 在 per-drive 预览视频开关被切换后调用。
// enabled=true 时上层应该重新把 pending 预览视频入队(类似旧的全局开关从关到开);
// enabled=false 时通常不用做事 —— worker 入队前会再次查 catalog,自然停止。
@@ -469,7 +470,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 控制是否给本盘生成预览视频/封面。前端用它在网盘列表/编辑表单展示开关状态
// TeaserEnabled 控制是否给本盘生成预览视频封面生成不受影响
// 前端用它在网盘列表/编辑表单展示开关状态。
TeaserEnabled bool `json:"teaserEnabled"`
// SkipDirIDs 是用户在 admin 配置的"扫描跳过目录"集合(drive 侧目录 fileID)。
// 前端用它在"设置跳过目录"弹窗里回显已选项;JSON 字段名 camelCase 与
@@ -591,7 +593,7 @@ type upsertDriveReq struct {
// Deprecated: 扫描起点已固定为 rootId;保留字段只为兼容旧客户端请求体。
ScanRootID string `json:"scanRootId"`
Credentials map[string]string `json:"credentials"`
// TeaserEnabled 是 per-drive 预览视频/封面生成开关。
// TeaserEnabled 是 per-drive 预览视频生成开关;封面生成不受影响
// 用 *bool 区分 "未传" / "传了 false":未传时表示客户端不打算改这个字段,
// 沿用 catalog 现有值;新建时未传一律默认开启(true)。
TeaserEnabled *bool `json:"teaserEnabled,omitempty"`
@@ -690,6 +692,7 @@ type crawlerDTO struct {
Proxy string `json:"proxy,omitempty"`
TargetNew string `json:"targetNew,omitempty"`
UploadDriveID string `json:"uploadDriveId,omitempty"`
TeaserEnabled bool `json:"teaserEnabled"`
LastCrawlAt int64 `json:"lastCrawlAt,omitempty"`
ScanGenerationStatus GenerationStatus `json:"scanGenerationStatus"`
ThumbnailGenerationStatus GenerationStatus `json:"thumbnailGenerationStatus"`
@@ -717,6 +720,7 @@ type upsertCrawlerReq struct {
Proxy string `json:"proxy"`
TargetNew string `json:"targetNew"`
UploadDriveID string `json:"uploadDriveId"`
TeaserEnabled *bool `json:"teaserEnabled,omitempty"`
}
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"]),
TargetNew: strings.TrimSpace(d.Credentials["target_new"]),
UploadDriveID: strings.TrimSpace(d.Credentials["upload_drive_id"]),
TeaserEnabled: d.TeaserEnabled,
LastCrawlAt: lastCrawlAt,
ScanGenerationStatus: generation.Scan,
ThumbnailGenerationStatus: generation.Thumbnail,
@@ -864,6 +869,13 @@ func (a *AdminServer) handleUpsertCrawler(w http.ResponseWriter, r *http.Request
return
}
name := meta.Name
teaserEnabled := true
if existing != nil {
teaserEnabled = existing.TeaserEnabled
}
if body.TeaserEnabled != nil {
teaserEnabled = *body.TeaserEnabled
}
if id == "" {
generatedID, err := a.generateCrawlerID(r.Context(), name)
if err != nil {
@@ -879,15 +891,15 @@ func (a *AdminServer) handleUpsertCrawler(w http.ResponseWriter, r *http.Request
RootID: "/",
Credentials: merged,
Status: "disconnected",
TeaserEnabled: true,
}
if existing != nil {
d.TeaserEnabled = existing.TeaserEnabled
TeaserEnabled: teaserEnabled,
}
if err := a.Catalog.UpsertDrive(r.Context(), d); err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
if existing != nil && existing.TeaserEnabled != teaserEnabled && a.OnTeaserEnabledChanged != nil {
a.OnTeaserEnabledChanged(id, teaserEnabled)
}
if a.OnDriveSaved != nil {
if err := a.OnDriveSaved(id); err != nil {
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)
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{
"items": items,
"total": total,
+136 -19
View File
@@ -944,7 +944,8 @@ func TestHandleListCrawlersOnlyIncludesCrawlerPageScripts(t *testing.T) {
"script_path": scriptPath,
"upload_drive_id": "p115-target",
},
Status: "ok",
Status: "ok",
TeaserEnabled: false,
},
{
ID: "p115-target",
@@ -1027,6 +1028,7 @@ func TestHandleListCrawlersOnlyIncludesCrawlerPageScripts(t *testing.T) {
Kind string `json:"kind"`
Proxy string `json:"proxy"`
UploadDriveID string `json:"uploadDriveId"`
TeaserEnabled bool `json:"teaserEnabled"`
LastCrawlAt int64 `json:"lastCrawlAt"`
TotalCrawled int `json:"totalCrawledCount"`
LocalVideos int `json:"localVideoCount"`
@@ -1038,11 +1040,12 @@ func TestHandleListCrawlersOnlyIncludesCrawlerPageScripts(t *testing.T) {
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
byID := map[string]struct {
type crawlerListRow struct {
Name string
Kind string
Proxy string
UploadDriveID string
TeaserEnabled bool
LastCrawlAt int64
TotalCrawled int
LocalVideos int
@@ -1050,25 +1053,15 @@ func TestHandleListCrawlersOnlyIncludesCrawlerPageScripts(t *testing.T) {
ThumbnailReady int
TeaserReady int
FingerprintReady int
}{}
}
byID := map[string]crawlerListRow{}
for _, d := range got {
byID[d.ID] = struct {
Name string
Kind string
Proxy string
UploadDriveID string
LastCrawlAt int64
TotalCrawled int
LocalVideos int
MigratedVideo int
ThumbnailReady int
TeaserReady int
FingerprintReady int
}{
byID[d.ID] = crawlerListRow{
Name: d.Name,
Kind: d.Kind,
Proxy: d.Proxy,
UploadDriveID: d.UploadDriveID,
TeaserEnabled: d.TeaserEnabled,
LastCrawlAt: d.LastCrawlAt,
TotalCrawled: d.TotalCrawled,
LocalVideos: d.LocalVideos,
@@ -1096,6 +1089,9 @@ func TestHandleListCrawlersOnlyIncludesCrawlerPageScripts(t *testing.T) {
if byID["crawler-spider91"].UploadDriveID != "p115-target" {
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 {
t.Fatalf("lastCrawlAt = %d, want 1800000000", byID["crawler-spider91"].LastCrawlAt)
}
@@ -1171,7 +1167,8 @@ func TestHandleUpsertCrawlerRequiresScriptPath(t *testing.T) {
"id": "spider91-main",
"builtin": "spider91",
"scriptPath": "`+scriptPath+`",
"targetNew": "15"
"targetNew": "15",
"teaserEnabled": false
}`))
rr = httptest.NewRecorder()
srv.handleUpsertCrawler(rr, req)
@@ -1195,6 +1192,9 @@ func TestHandleUpsertCrawlerRequiresScriptPath(t *testing.T) {
if 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) {
@@ -1277,12 +1277,21 @@ func TestHandleUpsertCrawlerPersistsAndValidatesUploadDrive(t *testing.T) {
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(`{
"id": "crawler-upload",
"scriptPath": "`+scriptPath+`",
"uploadDriveId": "p115-target"
"uploadDriveId": "p115-target",
"teaserEnabled": false
}`))
rr := httptest.NewRecorder()
srv.handleUpsertCrawler(rr, req)
@@ -1296,6 +1305,12 @@ func TestHandleUpsertCrawlerPersistsAndValidatesUploadDrive(t *testing.T) {
if got.Credentials["upload_drive_id"] != "p115-target" {
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(`{
"id": "crawler-upload",
@@ -1314,6 +1329,34 @@ func TestHandleUpsertCrawlerPersistsAndValidatesUploadDrive(t *testing.T) {
if got.Credentials["upload_drive_id"] != "wopan-target" {
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(`{
"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) {
called := false
server := &AdminServer{
+2 -2
View File
@@ -1937,7 +1937,7 @@ type Drive struct {
Credentials map[string]string `json:"credentials,omitempty"`
Status string `json:"status"`
LastError string `json:"lastError,omitempty"`
// TeaserEnabled 控制是否给本盘生成预览视频/封面。
// TeaserEnabled 控制是否给本盘生成预览视频封面生成不受影响
// 替代早期的全局 preview.enabled 开关;新建 drive 时 UpsertDrive 默认置 true。
TeaserEnabled bool `json:"teaserEnabled"`
// SkipDirIDs 是用户在管理后台为该盘选定的"扫描跳过目录"集合(网盘侧的目录 fileID)。
@@ -2070,7 +2070,7 @@ func (c *Catalog) DeleteDrive(ctx context.Context, id string) error {
return err
}
// SetDriveTeaserEnabled 切换某盘的预览视频/封面生成开关。
// SetDriveTeaserEnabled 切换某盘的预览视频生成开关。
//
// 与 UpsertDrive 的区别:只动 teaser_enabled + updated_at 一列,不要求调用方
// 重传 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
preview_file_id TEXT, -- deprecated: 旧版回写网盘后的预览视频 file id
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_error TEXT DEFAULT '',
transcoded_file_id TEXT DEFAULT '', -- 转码产物在同一 drive 上的 fileID,播放源优先用它
@@ -121,7 +121,7 @@ CREATE TABLE IF NOT EXISTS drives (
credentials TEXT, -- JSON: cookie / refresh_token 等
status TEXT DEFAULT 'disconnected', -- disconnected / ok / error
last_error TEXT,
-- 是否给该盘生成预览视频/封面1 开 / 0 关。
-- 是否给该盘生成预览视频:1 开 / 0 关。封面生成不受影响。
-- 替代了早期的全局 preview.enabled 设置(保留旧 setting 行不再读)。
teaser_enabled INTEGER NOT NULL DEFAULT 1,
-- 扫描时要跳过的目录 ID 集合(JSON array of string)。命中其中任意一个的目录及其
@@ -50,6 +50,7 @@ type CrawlerConfig struct {
CommonThumbDir string
ProxyURL string
ConfigJSON string
DisablePreview bool
HTTPClient *http.Client
DownloadTimeout time.Duration
OnProgress func(CrawlProgress)
@@ -562,6 +563,10 @@ func (c *Crawler) processItem(ctx context.Context, item Item) (bool, error) {
if quality == "" {
quality = "HD"
}
previewStatus := "pending"
if c.previewDisabled(ctx) {
previewStatus = "disabled"
}
v := &catalog.Video{
ID: videoID,
DriveID: c.cfg.Driver.ID(),
@@ -576,7 +581,7 @@ func (c *Crawler) processItem(ctx context.Context, item Item) (bool, error) {
Quality: quality,
Category: strings.TrimSpace(item.Category),
Description: strings.TrimSpace(item.Description),
PreviewStatus: "pending",
PreviewStatus: previewStatus,
PublishedAt: publishedAt,
CreatedAt: now,
UpdatedAt: now,
@@ -632,6 +637,18 @@ func (c *Crawler) processItem(ctx context.Context, item Item) (bool, error) {
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) {
if local := strings.TrimSpace(ref.LocalFile); local != "" {
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) {
ctx := context.Background()
tmp := t.TempDir()
+20
View File
@@ -1114,6 +1114,19 @@ func (q *videoQueue) release(v *catalog.Video) {
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 {
q.mu.Lock()
defer q.mu.Unlock()
@@ -1241,6 +1254,13 @@ func (w *Worker) Status() TaskStatus {
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 {
if w == nil {
return TaskStatus{State: "idle"}
+24 -19
View File
@@ -101,15 +101,16 @@ const (
)
type migrationPlan struct {
source Spider91LocalSource
row *catalog.Drive
sourceKinds []string
targetDriveID string
target uploadTarget
uploadDir string
keepLatestN int
requireAssetsReady bool
legacyBackfill bool
source Spider91LocalSource
row *catalog.Drive
sourceKinds []string
targetDriveID string
target uploadTarget
uploadDir string
keepLatestN int
requireAssetsReady bool
requirePreviewReady bool
legacyBackfill bool
}
// pikpakAdapter / p115Adapter / p123Adapter / onedriveAdapter / googledriveAdapter / wopanAdapter / guangyapanAdapter 把具体 driver 包装成 uploadTarget。
@@ -597,14 +598,15 @@ func (m *Migrator) migrationPlans(ctx context.Context) []migrationPlan {
continue
}
out = append(out, migrationPlan{
source: src,
row: row,
sourceKinds: crawlerSourceKindsForRow(row),
targetDriveID: resolvedID,
target: target,
uploadDir: scriptCrawlerUploadDir(row.ID),
keepLatestN: 0,
requireAssetsReady: true,
source: src,
row: row,
sourceKinds: crawlerSourceKindsForRow(row),
targetDriveID: resolvedID,
target: target,
uploadDir: scriptCrawlerUploadDir(row.ID),
keepLatestN: 0,
requireAssetsReady: true,
requirePreviewReady: row.TeaserEnabled,
})
case spider91.Kind:
if m.cfg.GetTargetDriveID == nil {
@@ -838,7 +840,7 @@ func (m *Migrator) migrateDrive(ctx context.Context, plan migrationPlan) (int, e
}
if plan.requireAssetsReady {
ready, err := m.crawlerVideoAssetsReady(ctx, v)
ready, err := m.crawlerVideoAssetsReady(ctx, v, plan.requirePreviewReady)
if err != nil {
log.Printf("[spider91migrate] %s check generated assets: %v", v.ID, err)
continue
@@ -914,7 +916,7 @@ func (m *Migrator) findVideoForLocalFile(ctx context.Context, plan migrationPlan
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 {
return false, nil
}
@@ -922,6 +924,9 @@ func (m *Migrator) crawlerVideoAssetsReady(ctx context.Context, v *catalog.Video
if !fingerprintReady {
return false, nil
}
if !requirePreview {
return true, nil
}
if strings.EqualFold(strings.TrimSpace(v.PreviewStatus), "ready") {
return true, nil
}
@@ -365,11 +365,19 @@ func seedScriptCrawlerDrive(t *testing.T, cat *catalog.Catalog, d *scriptcrawler
"script_path": "/tmp/crawler.py",
"upload_drive_id": uploadDriveID,
},
TeaserEnabled: true,
}); err != nil {
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 {
t.Helper()
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) {
cat := setupCatalog(t)
src := setupScriptCrawler(t, "crawler-duplicate")
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "video-site",
"version": "0.1.7",
"version": "0.1.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "video-site",
"version": "0.1.7",
"version": "0.1.8",
"license": "MIT",
"dependencies": {
"artplayer": "^5.4.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "video-site",
"private": true,
"license": "MIT",
"version": "0.1.7",
"version": "0.1.8",
"type": "module",
"scripts": {
"dev": "vite",
+37
View File
@@ -18,6 +18,8 @@ import {
Link as LinkIcon,
Pencil,
Plus,
Power,
PowerOff,
RefreshCw,
TestTube,
Trash2,
@@ -56,6 +58,7 @@ export function CrawlersPage() {
const [expandedId, setExpandedId] = useState("");
const [runningId, setRunningId] = useState("");
const [stoppingId, setStoppingId] = useState("");
const [togglingTeaserId, setTogglingTeaserId] = useState("");
// undefined = 编辑器关闭;null = 新建;其余 = 编辑已有爬虫
const [editorTarget, setEditorTarget] = useState<api.AdminCrawler | null | undefined>(undefined);
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() {
if (!deleteTarget) return;
setDeleting(true);
@@ -214,9 +234,11 @@ export function CrawlersPage() {
expanded={expandedId === crawler.id}
running={runningId === crawler.id}
stopping={stoppingId === crawler.id}
togglingTeaser={togglingTeaserId === crawler.id}
onToggle={() => setExpandedId(expandedId === crawler.id ? "" : crawler.id)}
onRun={() => run(crawler)}
onStop={() => stop(crawler)}
onToggleTeaser={() => toggleTeaser(crawler)}
onEdit={() => setEditorTarget(crawler)}
onDelete={() => setDeleteTarget(crawler)}
/>
@@ -290,9 +312,11 @@ function CrawlerRow({
expanded,
running,
stopping,
togglingTeaser,
onToggle,
onRun,
onStop,
onToggleTeaser,
onEdit,
onDelete,
}: {
@@ -300,9 +324,11 @@ function CrawlerRow({
expanded: boolean;
running: boolean;
stopping: boolean;
togglingTeaser: boolean;
onToggle: () => void;
onRun: () => void;
onStop: () => void;
onToggleTeaser: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
@@ -343,6 +369,17 @@ function CrawlerRow({
<ChevronDown size={16} className="admin-crawler-row__chevron" />
</button>
<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 ? (
<button className="admin-btn is-stop" type="button" onClick={onStop} disabled={stopping}>
<CircleStop size={13} /> {stopping ? "停止中..." : "停止"}
+2 -2
View File
@@ -632,7 +632,7 @@ export function DrivesPage() {
</button>
<button
type="button"
className="admin-btn is-stop"
className="admin-btn is-primary"
onClick={() => handleStopDriveTasks(d)}
disabled={!!stoppingDriveId}
title="停止此网盘当前的扫描、封面、预览视频和视频指纹生成任务。"
@@ -642,7 +642,7 @@ export function DrivesPage() {
</button>
</div>
{d.kind !== "spider91" && (
<button type="button" className="admin-btn" onClick={() => openEdit(d)}>
<button type="button" className="admin-btn is-primary" onClick={() => openEdit(d)}>
</button>
)}
+96 -6
View File
@@ -21,9 +21,17 @@ import { formatBytes } from "./storageFormat";
const DESKTOP_VIDEOS_PAGE_SIZE = 50;
const MOBILE_VIDEOS_PAGE_SIZE = 20;
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 RegenPreviewState = {
expiresAt: number;
originalUpdatedAt: number;
};
const TABS: { key: TabKey; label: string }[] = [
{ key: "current", label: "当前视频" },
{ key: "blacklist", label: "拉黑视频" },
@@ -121,6 +129,7 @@ function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) {
const [deleteTarget, setDeleteTarget] = useState<api.AdminVideo | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteSource, setDeleteSource] = useState(false);
const [regenPreviewById, setRegenPreviewById] = useState<Record<string, RegenPreviewState>>({});
const pageSize = useVideosPageSize();
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(() => {
refresh();
}, [driveId, page, searchKeyword, pageSize]);
@@ -164,6 +186,33 @@ function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) {
return () => window.clearTimeout(timer);
}, [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 listItems = list;
@@ -177,6 +226,7 @@ function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) {
async function handleRegen(v: api.AdminVideo) {
try {
await api.regenPreview(v.id);
trackRegeneratingPreview([v]);
show("已触发预览视频重生", "success");
} catch (e) {
show(e instanceof Error ? e.message : "触发失败", "error");
@@ -196,13 +246,20 @@ function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) {
async function confirmBatchRegen() {
const ids = [...selectedIds];
const videoById = new Map(listItems.map((v) => [v.id, v]));
setBatchRegening(true);
let success = 0;
try {
const results = await Promise.allSettled(ids.map((id) => api.regenPreview(id)));
for (const r of results) {
if (r.status === "fulfilled") success++;
}
const acceptedVideos: api.AdminVideo[] = [];
results.forEach((r, index) => {
if (r.status === "fulfilled") {
const video = videoById.get(ids[index]);
if (video) acceptedVideos.push(video);
success++;
}
});
trackRegeneratingPreview(acceptedVideos);
show(
`批量触发完成,成功 ${success} / ${ids.length}`,
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() {
if (!deleteTarget) return;
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="时长">{formatDur(v.durationSeconds)}</td>
<td data-label="预览视频">
<PreviewStatus s={v.previewStatus} />
<PreviewStatus s={isPreviewGenerating(v) ? REGEN_PREVIEW_STATUS : v.previewStatus} />
</td>
<td data-label="来源" className="admin-mono-cell">
{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="编辑视频">
<Edit size={13} />
</button>{" "}
<button type="button" className="admin-btn" onClick={() => handleRegen(v)} title="重生预览视频">
<RefreshCw size={13} />
<button
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
type="button"
@@ -832,8 +914,10 @@ function VideoTitleCell({ video: v }: { video: api.AdminVideo }) {
}
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 === "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>;
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())}`;
}
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() {
const [pageSize, setPageSize] = useState(() =>
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;
lastError?: string;
hasCredential: boolean;
/** 当前是否给该盘生成预览视频/封面(per-drive 开关,替代旧的全局 preview.enabled)。 */
/** 当前是否给该盘生成预览视频(per-drive 开关,替代旧的全局 preview.enabled;封面不受影响)。 */
teaserEnabled: boolean;
/**
* admin "扫描跳过目录"drive fileID
@@ -212,6 +212,7 @@ export type AdminCrawler = {
proxy?: string;
targetNew?: string;
uploadDriveId?: string;
teaserEnabled: boolean;
lastCrawlAt?: number;
scanGenerationStatus?: DriveGenerationStatus;
thumbnailGenerationStatus?: DriveGenerationStatus;
+3
View File
@@ -92,8 +92,11 @@ const LONG_PRESS_MS = 400;
const FAST_RATE = 2;
/** 默认倍速。 */
const NORMAL_RATE = 1;
/** ArtPlayer 内部播放失败自动重连次数。 */
const ARTPLAYER_RECONNECT_TIME_MAX = 3;
Artplayer.FAST_FORWARD_VALUE = FAST_RATE;
Artplayer.RECONNECT_TIME_MAX = ARTPLAYER_RECONNECT_TIME_MAX;
const DEFAULT_SETTINGS: PlayerSettings = {
volume: 0.7,
+20
View File
@@ -768,6 +768,25 @@
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 {
padding-inline: 10px;
}
@@ -1879,6 +1898,7 @@
.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-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-cooling { background: var(--warning-soft); color: var(--warning); }
+24 -7
View File
@@ -492,10 +492,15 @@
}
.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;
aspect-ratio: 16 / 12.5;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
background: var(--skeleton-card-bg);
border: 1px solid var(--skeleton-card-border);
border-radius: var(--radius-md);
padding: 8px;
box-sizing: border-box;
@@ -504,6 +509,18 @@
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-card::before {
content: "";
@@ -513,9 +530,9 @@
border-radius: var(--radius-sm);
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%
var(--skeleton-shimmer-base) 25%,
var(--skeleton-shimmer-highlight) 50%,
var(--skeleton-shimmer-base) 75%
);
background-size: 200% 100%;
animation: skeleton-shimmer 1.6s ease-in-out infinite;
@@ -529,8 +546,8 @@
width: 100%;
height: 28px;
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, 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 0 / 70% 12px 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%;
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, /Spider91UploadTargetField/);
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.doesNotMatch(crawlerPageSource, /新建脚本/);
assert.doesNotMatch(crawlerPageSource, /爬虫 ID/);
@@ -274,6 +281,8 @@ test("crawler management is a separate admin section", () => {
assert.doesNotMatch(crawlerPageSource, /内置 91/);
assert.match(apiSource, /type AdminCrawler/);
assert.match(apiSource, /uploadDriveId\?: string/);
assert.match(apiSource, /teaserEnabled: boolean/);
assert.doesNotMatch(apiSource, /teaserEnabled\?: boolean/);
assert.match(apiSource, /"\/crawlers"/);
assert.match(apiSource, /"\/crawlers\/import-file"/);
assert.match(apiSource, /"\/crawlers\/import-url"/);
@@ -304,6 +313,21 @@ test("drive management exposes stop task controls", () => {
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", () => {
assert.match(apiSource, /accepted:\s*boolean;\s*message\?:\s*string/);
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*\)/
);
});
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 \? "开" : "关"/);
});
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", () => {
assert.match(detailPageSource, /className="vd-layout vd-skeleton"/);
assert.match(detailPageSource, /className="vd-skeleton__summary"/);