7 Commits

Author SHA1 Message Date
nianzhibai e826c05d5c chore: release v0.1.4 2026-06-08 19:25:27 +08:00
nianzhibai 3465b9e837 Fix drive card icon fallback 2026-06-08 19:07:53 +08:00
nianzhibai d33c1b1b20 Support custom Google Drive OAuth credentials 2026-06-08 18:58:05 +08:00
nianzhibai 5fc8e9ebb7 Improve drive scan task coordination 2026-06-08 17:37:58 +08:00
nianzhibai dc7d2a5de3 Release v0.1.3 for ArtPlayer video detail updates 2026-06-07 15:24:57 +08:00
nianzhibai 2f2bfbfcdc Improve video detail player controls and layout 2026-06-07 15:17:08 +08:00
nianzhibai 9def08b0c5 Enhance video detail player experience
Add ArtPlayer/HLS playback, resume prompts, mobile gestures, orientation toggle, and theme-aware controls. Hide author metadata from video detail headers.
2026-06-07 00:15:32 +08:00
33 changed files with 3830 additions and 623 deletions
+7
View File
@@ -35,3 +35,10 @@ tmp/
91VideoSpider/__pycache__/
__pycache__/
*.pyc
# Local scratch images
/image.jpg
/image003.jpg
/image004.jpg
/image005.png
/image02.png
+2 -2
View File
@@ -109,7 +109,7 @@ go run ./cmd/server 后端 9192
| pikpak | `username`、`password`(token、验证码和设备 ID 由服务端自动处理并保存) |
| wopan | `access_token`、`refresh_token`,可选 `family_id` |
| onedrive | `refresh_token` |
| googledrive | `refresh_token` |
| googledrive | 默认只需 `refresh_token`;自建 OAuth 客户端模式还需 `use_online_api=false`、`client_id`、`client_secret` |
| localstorage | `path`(服务器上的已有视频目录,如 `/mnt/videos` |
### PikPak 速度说明
@@ -120,7 +120,7 @@ go run ./cmd/server 后端 9192
OneDrive 按 OpenList 默认应用方式调用 `https://api.oplist.org/onedrive/renewapi` 在线刷新 token,不需要配置 Azure 应用的 `client_id` / `client_secret` / `redirect_uri`。后台新建 OneDrive 时只需要填 OpenList 代刷得到的 `refresh_token`;服务端会默认挂载根目录并自动回写新 token。
Google Drive 按 OpenList 在线 API 调用 `https://api.oplist.org/googleui/renewapi` 刷新 token。后台新建 Google Drive 时只需要填 OpenList Google Drive 获取到的 `refresh_token`。Google Drive 下载地址必须携带 `Authorization` 头,浏览器不能直接 302 使用,所以本站会由后端代理 `/p/stream` 播放,不加入零带宽 302 白名单。
Google Drive 默认按 OpenList 在线 API 调用 `https://api.oplist.org/googleui/renewapi` 刷新 token。后台新建 Google Drive 时只需要填 OpenList Google Drive 获取到的 `refresh_token`。如果不想依赖 OpenList 在线 API,可以关闭“使用 OpenList 在线续期 API”,并填写同一个 Google OAuth 客户端授权得到的 `refresh_token`、`client_id`、`client_secret`,服务端会直接请求 Google OAuth token 接口续期。Google Drive 下载地址必须携带 `Authorization` 头,浏览器不能直接 302 使用,所以本站会由后端代理 `/p/stream` 播放,不加入零带宽 302 白名单。
## 文件名约定
+247 -68
View File
@@ -170,16 +170,15 @@ func main() {
OnDriveRemoved: func(driveID string) {
app.detachDrive(driveID)
},
OnScanRequested: func(driveID string) {
OnScanRequested: func(driveID string) bool {
// spider91 的"重扫"等同于手动触发一次爬取;其它 drive 走标准 scan
app.mu.Lock()
_, isSpider91 := app.spider91Crawlers[driveID]
app.mu.Unlock()
if isSpider91 {
app.scheduleSpider91Crawl(ctx, driveID)
return
return app.scheduleSpider91Crawl(ctx, driveID)
}
app.scheduleScan(ctx, driveID)
return app.scheduleScan(ctx, driveID)
},
OnStopDriveTasks: func(driveID string) bool {
return app.stopDriveTasks(ctx, driveID)
@@ -318,29 +317,20 @@ type App struct {
// 空字符串表示本地保存不上传,不再自动挑选 pikpak/p115/p123/onedrive drive。
spider91UploadDriveID string
// spider91Migrator 周期把 spider91 视频上传到目标 drivePikPak、115、123 或 OneDrive)。
spider91Migrator *spider91migrate.Migrator
// spider91Migrator 把 spider91 视频上传到目标 drivePikPak、115、123 或 OneDrive)。
spider91Migrator spider91MigrationRunner
// nightlyRunner 是凌晨流水线调度器:每天 cron_hour 串行跑扫盘 → 91 爬虫 → 迁移。
// 也响应 admin 「扫描所有网盘」按钮(TriggerNow)。
nightlyRunner *nightly.Runner
// scanGlobalMu 串行化所有云盘扫盘任务,确保同一时刻全系统只有一个扫盘
// 在跑(包括 admin 手动重扫和 nightly Phase 1)。即便用户同时点多个 drive
// 的"重扫"按钮,goroutine 也会排队等这把锁,逐个执行。
//
// 设计取舍:
// - 不同 drive 的扫盘技术上可以并行(互不干涉),但用户希望"线性来"以
// 避免带宽 / CPU 抢占,所以做全局串行。
// - nightly Phase 1 已经是 for 循环顺序调用 runScan,加了这把锁后行为
// 不变,只是顺手把 admin 异步触发的请求也接入同一条队列。
scanGlobalMu sync.Mutex
// scanQueueMu 保护 scanQueued。
// scanQueueMu 保护 scanQueued 和 scanProgress。
scanQueueMu sync.Mutex
// scanQueued 跟踪哪些 driveID 已经排队或正在跑扫盘/91 爬取,去重后续重复点击。
// 一个 drive 在 scheduleScan/scheduleSpider91Crawl 入队时被加入,后台 goroutine
// 结束时被移除。
// 不同 drive 互不等待,可以并行扫;同一个 drive 只能有一个扫盘/抓取任务。
scanQueued map[string]bool
// scanProgress 跟踪每个正在扫盘/抓取的 drive 当前进度。
scanProgress map[string]driveScanProgress
// taskCancelMu 保护 driveTaskCancels。这里登记的是可被"停止任务"按钮中断
// 的 drive 级任务上下文:扫盘、91 爬取、指纹补队列、失败生成重试等。
@@ -354,6 +344,15 @@ type App struct {
fingerprintQueueing map[string]bool
}
type driveScanProgress struct {
Scanned int
Added int
}
type spider91MigrationRunner interface {
RunOnce(ctx context.Context) error
}
// teaserEnabledForDrive 查询某个 drive 当前的 per-drive 预览视频开关。
//
// 预览视频生成不再由全局 setting 控制,而是由 catalog.drives.teaser_enabled
@@ -491,6 +490,17 @@ func (a *App) loadSpider91UploadDriveID(ctx context.Context) {
}
func (a *App) driveGenerationStatuses() map[string]api.DriveGenerationStatuses {
a.scanQueueMu.Lock()
scanningDrives := make(map[string]bool, len(a.scanQueued))
for id, running := range a.scanQueued {
scanningDrives[id] = running
}
scanProgresses := make(map[string]driveScanProgress, len(a.scanProgress))
for id, progress := range a.scanProgress {
scanProgresses[id] = progress
}
a.scanQueueMu.Unlock()
a.mu.Lock()
previewWorkers := make(map[string]*preview.Worker, len(a.workers))
for id, worker := range a.workers {
@@ -506,7 +516,20 @@ func (a *App) driveGenerationStatuses() map[string]api.DriveGenerationStatuses {
}
a.mu.Unlock()
out := make(map[string]api.DriveGenerationStatuses, len(previewWorkers)+len(thumbWorkers)+len(fingerprintWorkers))
out := make(map[string]api.DriveGenerationStatuses, len(scanningDrives)+len(previewWorkers)+len(thumbWorkers)+len(fingerprintWorkers))
for id, running := range scanningDrives {
if !running {
continue
}
progress := scanProgresses[id]
status := out[id]
status.Scan = api.GenerationStatus{
State: "scanning",
ScannedCount: progress.Scanned,
AddedCount: progress.Added,
}
out[id] = status
}
for id, worker := range previewWorkers {
status := out[id]
status.Preview = generationStatusFromPreview(worker.Status())
@@ -856,6 +879,12 @@ func (a *App) attachSpider91Crawler(d *catalog.Drive, drv *spider91.Driver) {
proxyURL := strings.TrimSpace(d.Credentials["proxy"])
driveID := d.ID
var progressMu sync.Mutex
checkedVideos := 0
expectedNewVideos := 0
updateProgress := func(scanned, added int) {
a.updateDriveScanProgress(driveID, scanned, added)
}
c := spider91.NewCrawler(spider91.CrawlerConfig{
Driver: drv,
Catalog: a.cat,
@@ -864,6 +893,35 @@ func (a *App) attachSpider91Crawler(d *catalog.Drive, drv *spider91.Driver) {
WorkDir: filepath.Dir(scriptPath),
CommonThumbDir: a.commonThumbsDir(),
ProxyURL: proxyURL,
OnProgress: func(progress spider91.CrawlProgress) {
progressMu.Lock()
if progress.TotalEntries == 0 && progress.NewVideos == 0 && progress.Skipped == 0 && progress.Failed == 0 {
checkedVideos = 0
expectedNewVideos = 0
} else if progress.TotalEntries > expectedNewVideos {
expectedNewVideos = progress.TotalEntries
}
scanned := checkedVideos
added := expectedNewVideos
progressMu.Unlock()
updateProgress(scanned, added)
},
OnCheckedVideo: func() {
progressMu.Lock()
checkedVideos++
scanned := checkedVideos
added := expectedNewVideos
progressMu.Unlock()
updateProgress(scanned, added)
},
OnExtractedVideo: func() {
progressMu.Lock()
expectedNewVideos++
scanned := checkedVideos
added := expectedNewVideos
progressMu.Unlock()
updateProgress(scanned, added)
},
// 新流程:预览视频不在每条视频入库时立即入队,而是 RunOnce 全部下完后由
// runSpider91Crawl 统一调 enqueueDriveGeneration 一次性入队。这样:
// - 下载阶段不和 ffmpeg 抢 CPU/IO
@@ -1008,6 +1066,7 @@ func (a *App) clearQueuedDriveTask(driveID string) bool {
a.scanQueueMu.Lock()
queued := a.scanQueued[driveID]
delete(a.scanQueued, driveID)
delete(a.scanProgress, driveID)
a.scanQueueMu.Unlock()
return queued
}
@@ -1019,6 +1078,7 @@ func (a *App) clearAllQueuedDriveTasks() []string {
ids = append(ids, id)
}
a.scanQueued = nil
a.scanProgress = nil
a.scanQueueMu.Unlock()
return ids
}
@@ -1042,6 +1102,102 @@ func (a *App) clearAllFingerprintQueueing() []string {
return ids
}
func (a *App) beginDriveScanOrCrawl(driveID string) bool {
driveID = strings.TrimSpace(driveID)
if driveID == "" {
return false
}
a.scanQueueMu.Lock()
defer a.scanQueueMu.Unlock()
if a.scanQueued == nil {
a.scanQueued = make(map[string]bool)
}
if a.scanQueued[driveID] {
return false
}
a.scanQueued[driveID] = true
if a.scanProgress == nil {
a.scanProgress = make(map[string]driveScanProgress)
}
a.scanProgress[driveID] = driveScanProgress{}
return true
}
func (a *App) endDriveScanOrCrawl(driveID string) {
a.scanQueueMu.Lock()
delete(a.scanQueued, driveID)
delete(a.scanProgress, driveID)
a.scanQueueMu.Unlock()
}
func (a *App) updateDriveScanProgress(driveID string, scanned, added int) {
driveID = strings.TrimSpace(driveID)
if driveID == "" {
return
}
a.scanQueueMu.Lock()
if a.scanQueued[driveID] {
if a.scanProgress == nil {
a.scanProgress = make(map[string]driveScanProgress)
}
a.scanProgress[driveID] = driveScanProgress{Scanned: scanned, Added: added}
}
a.scanQueueMu.Unlock()
}
func (a *App) driveHasActiveWork(driveID string) bool {
driveID = strings.TrimSpace(driveID)
if driveID == "" {
return true
}
a.scanQueueMu.Lock()
scanning := a.scanQueued[driveID]
a.scanQueueMu.Unlock()
if scanning {
return true
}
a.taskCancelMu.Lock()
taskContexts := len(a.driveTaskCancels[driveID])
a.taskCancelMu.Unlock()
if taskContexts > 0 {
return true
}
a.fingerprintQueueMu.Lock()
fingerprintQueueing := a.fingerprintQueueing[driveID]
a.fingerprintQueueMu.Unlock()
if fingerprintQueueing {
return true
}
a.mu.Lock()
previewWorker := a.workers[driveID]
thumbWorker := a.thumbWorkers[driveID]
fingerprintWorker := a.fingerprintWorkers[driveID]
a.mu.Unlock()
if previewTaskBusy(thumbWorker.Status()) {
return true
}
if previewTaskBusy(previewWorker.Status()) {
return true
}
if fingerprintTaskBusy(fingerprintWorker.Status()) {
return true
}
return false
}
func previewTaskBusy(status preview.TaskStatus) bool {
return status.State != "" && status.State != "idle"
}
func fingerprintTaskBusy(status fingerprint.TaskStatus) bool {
return status.State != "" && status.State != "idle"
}
func (a *App) resetDriveGenerationWorkers(ctx context.Context, driveID string) bool {
var drv drives.Drive
var attached bool
@@ -1355,52 +1511,41 @@ func (a *App) listDriveDirChildren(ctx context.Context, driveID, parentID string
// scheduleScan 异步触发某个 drive 的扫盘。
//
// 调用立即返回;扫盘任务在后台 goroutine 里排队执行 —— 系统中所有扫盘共享
// 一把 scanGlobalMu,按提交顺序串行跑
//
// 去重:如果该 drive 已经在排队或正在跑,重复请求会被丢弃并记日志。这样用户
// 反复点同一个 drive 的"重扫"按钮,也只会有一次实际工作。
//
// 用于 admin UI「重扫」、「立即抓取」这类异步触发;nightly Phase 1 应继续直接
// 调 runScan(同步、按 for 循环顺序),不需要走 scheduleScan。
func (a *App) scheduleScan(ctx context.Context, driveID string) {
taskCtx, done := a.registerDriveTaskContext(ctx, driveID)
a.scanQueueMu.Lock()
if a.scanQueued == nil {
a.scanQueued = make(map[string]bool)
// 调用立即返回。不同 drive 的扫盘可以并行;同一个 drive 如果已有扫盘、封面、
// 预览视频或指纹任务在跑,本次请求会被拒绝
func (a *App) scheduleScan(ctx context.Context, driveID string) bool {
if a.driveHasActiveWork(driveID) {
log.Printf("[scan] drive=%s has active work, skip duplicate request", driveID)
return false
}
if a.scanQueued[driveID] {
a.scanQueueMu.Unlock()
done()
if !a.beginDriveScanOrCrawl(driveID) {
log.Printf("[scan] drive=%s already queued or running, skip duplicate request", driveID)
return
return false
}
a.scanQueued[driveID] = true
a.scanQueueMu.Unlock()
taskCtx, done := a.registerDriveTaskContext(ctx, driveID)
go func() {
defer func() {
a.scanQueueMu.Lock()
delete(a.scanQueued, driveID)
a.scanQueueMu.Unlock()
a.endDriveScanOrCrawl(driveID)
done()
}()
a.runScanWithTaskContext(taskCtx, driveID)
}()
return true
}
func (a *App) runScan(ctx context.Context, driveID string) {
if !a.beginDriveScanOrCrawl(driveID) {
log.Printf("[scan] drive=%s already queued or running, skip direct scan", driveID)
return
}
defer a.endDriveScanOrCrawl(driveID)
taskCtx, done := a.registerDriveTaskContext(ctx, driveID)
defer done()
a.runScanWithTaskContext(taskCtx, driveID)
}
func (a *App) runScanWithTaskContext(ctx context.Context, driveID string) {
// 全局串行:同一时刻只有一个扫盘任务在跑(admin 重扫 + nightly Phase 1 共用)。
// 等待这把锁的 goroutine 在排队,按到达顺序逐个执行。
a.scanGlobalMu.Lock()
defer a.scanGlobalMu.Unlock()
if err := ctx.Err(); err != nil {
log.Printf("[scan] drive=%s canceled before start: %v", driveID, err)
return
@@ -1438,6 +1583,9 @@ func (a *App) runScanWithTaskContext(ctx context.Context, driveID string) {
return
}
sc := scanner.New(a.cat, drv, a.cfg.Scanner.VideoExtensions, d.SkipDirIDs, onNew)
sc.OnProgress = func(stats scanner.Stats) {
a.updateDriveScanProgress(driveID, stats.Scanned, stats.Added)
}
startID := d.RootID
@@ -2310,30 +2458,27 @@ func shouldScanDrive(d drives.Drive) bool {
// ---------- spider91 crawl ----------
func (a *App) scheduleSpider91Crawl(ctx context.Context, driveID string) {
taskCtx, done := a.registerDriveTaskContext(ctx, driveID)
a.scanQueueMu.Lock()
if a.scanQueued == nil {
a.scanQueued = make(map[string]bool)
func (a *App) scheduleSpider91Crawl(ctx context.Context, driveID string) bool {
if a.driveHasActiveWork(driveID) {
log.Printf("[spider91] drive=%s has active work, skip duplicate crawl request", driveID)
return false
}
if a.scanQueued[driveID] {
a.scanQueueMu.Unlock()
done()
if !a.beginDriveScanOrCrawl(driveID) {
log.Printf("[spider91] drive=%s already queued or running, skip duplicate crawl request", driveID)
return
return false
}
a.scanQueued[driveID] = true
a.scanQueueMu.Unlock()
taskCtx, done := a.registerDriveTaskContext(ctx, driveID)
go func() {
defer func() {
a.scanQueueMu.Lock()
delete(a.scanQueued, driveID)
a.scanQueueMu.Unlock()
a.endDriveScanOrCrawl(driveID)
done()
}()
a.runSpider91CrawlWithTaskContext(taskCtx, driveID)
if a.runSpider91CrawlWithTaskContext(taskCtx, driveID) {
a.runSpider91MigrationAfterManualCrawl(taskCtx, driveID)
}
}()
return true
}
// runSpider91Crawl 运行一次完整爬取流程并把 last_crawl_at 写回 drive.credentials。
@@ -2342,15 +2487,20 @@ func (a *App) scheduleSpider91Crawl(ctx context.Context, driveID string) {
// 流水线重跑时仍会重试。该方法是阻塞的,被 nightly Phase 2 串行调用,以及被
// admin "立即抓取" 单 drive 异步调用。
func (a *App) runSpider91Crawl(ctx context.Context, driveID string) {
if !a.beginDriveScanOrCrawl(driveID) {
log.Printf("[spider91] drive=%s already queued or running, skip direct crawl", driveID)
return
}
defer a.endDriveScanOrCrawl(driveID)
taskCtx, done := a.registerDriveTaskContext(ctx, driveID)
defer done()
a.runSpider91CrawlWithTaskContext(taskCtx, driveID)
}
func (a *App) runSpider91CrawlWithTaskContext(ctx context.Context, driveID string) {
func (a *App) runSpider91CrawlWithTaskContext(ctx context.Context, driveID string) bool {
if err := ctx.Err(); err != nil {
log.Printf("[spider91] drive=%s crawl canceled before start: %v", driveID, err)
return
return false
}
a.mu.Lock()
c := a.spider91Crawlers[driveID]
@@ -2358,21 +2508,21 @@ func (a *App) runSpider91CrawlWithTaskContext(ctx context.Context, driveID strin
if c == nil {
if err := a.ensureDriveAttached(ctx, driveID); err != nil {
log.Printf("[spider91] drive=%s attach failed: %v", driveID, err)
return
return false
}
a.mu.Lock()
c = a.spider91Crawlers[driveID]
a.mu.Unlock()
if c == nil {
log.Printf("[spider91] drive=%s crawler not attached", driveID)
return
return false
}
}
d, err := a.cat.GetDrive(ctx, driveID)
if err != nil || d == nil {
log.Printf("[spider91] drive=%s lookup failed: %v", driveID, err)
return
return false
}
targetNew := spider91IntCred(d, "target_new", spider91.DefaultTargetNew)
if targetNew <= 0 {
@@ -2406,7 +2556,7 @@ func (a *App) runSpider91CrawlWithTaskContext(ctx context.Context, driveID strin
}
if err := ctx.Err(); err != nil {
log.Printf("[spider91] drive=%s crawl canceled after run: %v", driveID, err)
return
return false
}
// 爬取全部完成后,统一把所有还 pending 的预览视频入队。
@@ -2421,6 +2571,35 @@ func (a *App) runSpider91CrawlWithTaskContext(ctx context.Context, driveID strin
a.mu.Unlock()
a.scheduleFingerprintBackfill(ctx, driveID, fingerprintWorker)
a.enqueueDriveGeneration(ctx, driveID, worker, thumbWorker)
return runErr == nil
}
func (a *App) runSpider91MigrationAfterManualCrawl(ctx context.Context, driveID string) {
if err := ctx.Err(); err != nil {
log.Printf("[spider91] drive=%s skip post-crawl migration: %v", driveID, err)
return
}
targetDriveID := a.Spider91UploadDriveID()
if targetDriveID == "" {
return
}
if a.spider91Migrator == nil {
log.Printf("[spider91] drive=%s skip post-crawl migration: migrator not configured", driveID)
return
}
log.Printf("[spider91] drive=%s waiting for generation queues before post-crawl migration target=%s", driveID, targetDriveID)
if err := a.waitAllPreviewQueuesIdle(ctx); err != nil {
log.Printf("[spider91] drive=%s post-crawl migration wait canceled: %v", driveID, err)
return
}
if err := ctx.Err(); err != nil {
log.Printf("[spider91] drive=%s skip post-crawl migration after wait: %v", driveID, err)
return
}
log.Printf("[spider91] drive=%s running post-crawl migration target=%s", driveID, targetDriveID)
if err := a.spider91Migrator.RunOnce(ctx); err != nil {
log.Printf("[spider91] drive=%s post-crawl migration: %v", driveID, err)
}
}
// spider91IntCred 解析 credentials 中的整数字段,缺省时返回 def。
+172
View File
@@ -260,6 +260,7 @@ func TestStopDriveTasksCancelsQueuedTasksAndReplacesWorkers(t *testing.T) {
"drive-id": func() { close(oldCanceled) },
},
scanQueued: map[string]bool{"drive-id": true},
scanProgress: map[string]driveScanProgress{"drive-id": {Scanned: 8, Added: 2}},
fingerprintQueueing: map[string]bool{"drive-id": true},
}
taskCtx, done := app.registerDriveTaskContext(ctx, "drive-id")
@@ -279,6 +280,9 @@ func TestStopDriveTasksCancelsQueuedTasksAndReplacesWorkers(t *testing.T) {
if app.scanQueued["drive-id"] {
t.Fatal("scan queue marker was not cleared")
}
if _, ok := app.scanProgress["drive-id"]; ok {
t.Fatal("scan progress marker was not cleared")
}
if app.fingerprintQueueing["drive-id"] {
t.Fatal("fingerprint queue marker was not cleared")
}
@@ -304,6 +308,117 @@ func TestStopDriveTasksCancelsQueuedTasksAndReplacesWorkers(t *testing.T) {
newCancel()
}
func TestScheduleScanRejectsDriveWithActiveGenerationWork(t *testing.T) {
ctx := context.Background()
thumbWorker := preview.NewThumbWorker(&serverFakeTeaserGenerator{}, nil, &serverFakeDrive{})
if !thumbWorker.Enqueue(&catalog.Video{ID: "busy-video", DriveID: "drive-id", Title: "Busy Video"}) {
t.Fatal("failed to enqueue busy thumbnail task")
}
app := &App{
thumbWorkers: map[string]*preview.ThumbWorker{"drive-id": thumbWorker},
}
if app.scheduleScan(ctx, "drive-id") {
t.Fatal("scheduleScan accepted a drive with active generation work")
}
}
func TestScheduleScanRunsDifferentDrivesConcurrently(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
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)
}
})
seedDriveWithTeaser(t, cat, "drive-a", true)
seedDriveWithTeaser(t, cat, "drive-b", true)
started := make(chan string, 2)
release := make(chan struct{})
registry := proxy.NewRegistry()
registry.Set("drive-a", &serverBlockingListDrive{id: "drive-a", started: started, release: release})
registry.Set("drive-b", &serverBlockingListDrive{id: "drive-b", started: started, release: release})
app := &App{
cfg: &config.Config{
Scanner: config.Scanner{VideoExtensions: []string{".mp4"}},
},
cat: cat,
registry: registry,
}
if !app.scheduleScan(ctx, "drive-a") {
t.Fatal("scheduleScan drive-a was rejected")
}
if !app.scheduleScan(ctx, "drive-b") {
t.Fatal("scheduleScan drive-b was rejected")
}
seen := map[string]struct{}{}
deadline := time.After(time.Second)
for len(seen) < 2 {
select {
case id := <-started:
seen[id] = struct{}{}
case <-deadline:
close(release)
t.Fatalf("started drives = %#v, want both drives before releasing List", seen)
}
}
close(release)
}
func TestDriveGenerationStatusIncludesScanState(t *testing.T) {
app := &App{
scanQueued: map[string]bool{"drive-id": true},
scanProgress: map[string]driveScanProgress{"drive-id": {Scanned: 12, Added: 3}},
}
status := app.driveGenerationStatuses()["drive-id"].Scan
if status.State != "scanning" {
t.Fatalf("scan status = %#v, want scanning", status)
}
if status.ScannedCount != 12 || status.AddedCount != 3 {
t.Fatalf("scan counts = scanned %d added %d, want 12 and 3", status.ScannedCount, status.AddedCount)
}
}
func TestRunSpider91MigrationAfterManualCrawlRequiresConfiguredUploadTarget(t *testing.T) {
ctx := context.Background()
registry := proxy.NewRegistry()
migrator := &serverFakeSpider91MigrationRunner{}
app := &App{
registry: registry,
spider91Migrator: migrator,
workers: map[string]*preview.Worker{},
thumbWorkers: map[string]*preview.ThumbWorker{},
fingerprintWorkers: map[string]*fingerprint.Worker{},
}
app.runSpider91MigrationAfterManualCrawl(ctx, "91spider")
if migrator.called != 0 {
t.Fatalf("migration called without upload target")
}
app.spider91UploadDriveID = "pikpak"
app.runSpider91MigrationAfterManualCrawl(ctx, "91spider")
if migrator.called != 0 {
t.Fatalf("migration called when upload target is not attached")
}
registry.Set("pikpak", &serverFakeKindDrive{id: "pikpak", kind: "pikpak"})
app.runSpider91MigrationAfterManualCrawl(ctx, "91spider")
if migrator.called != 1 {
t.Fatalf("migration calls = %d, want 1", migrator.called)
}
}
func TestDriveGenerationStatusUsesWorkerQueueNotPendingCatalogRows(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
@@ -1496,6 +1611,63 @@ func (d *serverFakeDrive) EnsureDir(context.Context, string) (string, error) {
}
func (d *serverFakeDrive) RootID() string { return "root" }
type serverFakeKindDrive struct {
serverFakeDrive
id string
kind string
}
func (d *serverFakeKindDrive) Kind() string { return d.kind }
func (d *serverFakeKindDrive) ID() string { return d.id }
type serverFakeSpider91MigrationRunner struct {
called int
}
func (r *serverFakeSpider91MigrationRunner) RunOnce(context.Context) error {
r.called++
return nil
}
type serverBlockingListDrive struct {
id string
started chan string
release chan struct{}
}
func (d *serverBlockingListDrive) Kind() string { return "fake" }
func (d *serverBlockingListDrive) ID() string { return d.id }
func (d *serverBlockingListDrive) Init(context.Context) error {
return nil
}
func (d *serverBlockingListDrive) List(ctx context.Context, _ string) ([]drives.Entry, error) {
if d.started != nil {
select {
case d.started <- d.id:
default:
}
}
select {
case <-d.release:
return nil, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (d *serverBlockingListDrive) Stat(context.Context, string) (*drives.Entry, error) {
return nil, drives.ErrNotSupported
}
func (d *serverBlockingListDrive) StreamURL(context.Context, string) (*drives.StreamLink, error) {
return &drives.StreamLink{URL: "https://video.example/clip.mp4"}, nil
}
func (d *serverBlockingListDrive) Upload(context.Context, string, string, io.Reader, int64) (string, error) {
return "", drives.ErrNotSupported
}
func (d *serverBlockingListDrive) EnsureDir(context.Context, string) (string, error) {
return "", drives.ErrNotSupported
}
func (d *serverBlockingListDrive) RootID() string { return "root" }
type serverFingerprintFakeDrive struct {
serverFakeDrive
path string
+5
View File
@@ -74,6 +74,11 @@ preview:
# root_id: "root"
# params:
# refresh_token: "..."
# # 默认 use_online_api=true,会使用 OpenList 在线续期 API。
# # 如需使用自己创建的 Google OAuth 客户端,取消下面三行注释:
# # use_online_api: "false"
# # client_id: "..."
# # client_secret: "..."
# 本地存储示例:
# - id: "local-media"
# kind: "localstorage"
+84 -6
View File
@@ -45,7 +45,7 @@ type AdminServer struct {
OnDriveSaved func(driveID string) error
OnDriveDeleteCleanup func(ctx context.Context, driveID string) (int, error)
OnDriveRemoved func(driveID string)
OnScanRequested func(driveID string)
OnScanRequested func(driveID string) bool
OnStopDriveTasks func(driveID string) bool
OnStopAllTasks func() int
OnRegenPreview func(videoID string)
@@ -81,6 +81,11 @@ type AdminServer struct {
P123HTTPClient *http.Client
}
const (
driveTaskBusyMessage = "当前存储有正在进行的任务,请稍后重试"
fullScanBusyMessage = "当前有全量扫描任务正在进行,请稍后重试"
)
// DriveDirEntry 是 dirtree 接口的一条返回项:网盘上的一个目录节点。
type DriveDirEntry struct {
ID string `json:"id"`
@@ -92,9 +97,12 @@ type GenerationStatus struct {
CurrentTitle string `json:"currentTitle,omitempty"`
QueueLength int `json:"queueLength"`
CooldownUntil string `json:"cooldownUntil,omitempty"`
ScannedCount int `json:"scannedCount"`
AddedCount int `json:"addedCount"`
}
type DriveGenerationStatuses struct {
Scan GenerationStatus `json:"scan"`
Thumbnail GenerationStatus `json:"thumbnail"`
Preview GenerationStatus `json:"preview"`
Fingerprint GenerationStatus `json:"fingerprint"`
@@ -417,6 +425,8 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) {
// 其它 kind 留 0;前端用它显示"上次抓取: N 小时前"。
Spider91Proxy string `json:"spider91Proxy,omitempty"`
LastCrawlAt int64 `json:"lastCrawlAt,omitempty"`
GoogleDriveUseOnlineAPI *bool `json:"googleDriveUseOnlineAPI,omitempty"`
ScanGenerationStatus GenerationStatus `json:"scanGenerationStatus"`
ThumbnailGenerationStatus GenerationStatus `json:"thumbnailGenerationStatus"`
PreviewGenerationStatus GenerationStatus `json:"previewGenerationStatus"`
FingerprintGenerationStatus GenerationStatus `json:"fingerprintGenerationStatus"`
@@ -437,6 +447,9 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) {
thumbCounts := thumbnailCounts[d.ID]
fingerprintCount := fingerprintCounts[d.ID]
generation := generationStatuses[d.ID]
if generation.Scan.State == "" {
generation.Scan.State = "idle"
}
if generation.Thumbnail.State == "" {
generation.Thumbnail.State = "idle"
}
@@ -476,6 +489,8 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) {
SkipDirIDs: append([]string{}, d.SkipDirIDs...),
Spider91Proxy: spider91ProxyForDrive(d),
LastCrawlAt: lastCrawlAt,
GoogleDriveUseOnlineAPI: googleDriveUseOnlineAPIForDrive(d),
ScanGenerationStatus: generation.Scan,
ThumbnailGenerationStatus: generation.Thumbnail,
PreviewGenerationStatus: generation.Preview,
FingerprintGenerationStatus: generation.Fingerprint,
@@ -534,6 +549,8 @@ func (a *AdminServer) handleUpsertDrive(w http.ResponseWriter, r *http.Request)
return
}
body.Credentials = credentials
} else if body.Kind == "googledrive" {
body.Credentials = mergeGoogleDriveCredentials(existing, body.Credentials)
} else if len(body.Credentials) == 0 && existing != nil && len(existing.Credentials) > 0 {
body.Credentials = existing.Credentials
}
@@ -590,6 +607,47 @@ func spider91ProxyForDrive(d *catalog.Drive) string {
return strings.TrimSpace(d.Credentials["proxy"])
}
func googleDriveUseOnlineAPIForDrive(d *catalog.Drive) *bool {
if d == nil || d.Kind != "googledrive" {
return nil
}
result := true
if d.Credentials == nil {
return &result
}
raw := strings.TrimSpace(d.Credentials["use_online_api"])
if raw == "" {
return &result
}
v, err := strconv.ParseBool(raw)
if err != nil {
return &result
}
result = v
return &result
}
func mergeGoogleDriveCredentials(existing *catalog.Drive, incoming map[string]string) map[string]string {
merged := map[string]string{}
if existing != nil {
for k, v := range existing.Credentials {
merged[k] = v
}
}
for k, v := range incoming {
key := strings.TrimSpace(k)
if key == "" {
continue
}
value := strings.TrimSpace(v)
if value == "" {
continue
}
merged[key] = value
}
return merged
}
func mergeSpider91Credentials(existing *catalog.Drive, incoming map[string]string) (map[string]string, error) {
merged := map[string]string{}
if existing != nil {
@@ -675,10 +733,26 @@ type deleteDriveReq struct {
func (a *AdminServer) handleRescan(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if a.OnScanRequested != nil {
a.OnScanRequested(id)
status := a.nightlyJobStatus()
if status.Running || status.Queued {
writeJSON(w, http.StatusAccepted, map[string]any{
"ok": true,
"accepted": false,
"message": fullScanBusyMessage,
"status": status,
})
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"ok": true})
accepted := true
if a.OnScanRequested != nil {
accepted = a.OnScanRequested(id)
}
resp := map[string]any{"ok": true, "accepted": accepted}
if !accepted {
resp["message"] = driveTaskBusyMessage
}
writeJSON(w, http.StatusAccepted, resp)
}
func (a *AdminServer) handleStopDriveTasks(w http.ResponseWriter, r *http.Request) {
@@ -734,11 +808,15 @@ func (a *AdminServer) handleRunNightlyJob(w http.ResponseWriter, r *http.Request
if a.OnRunNightlyJob != nil {
accepted = a.OnRunNightlyJob()
}
writeJSON(w, http.StatusAccepted, map[string]any{
resp := map[string]any{
"ok": true,
"accepted": accepted,
"status": a.nightlyJobStatus(),
})
}
if !accepted {
resp["message"] = fullScanBusyMessage
}
writeJSON(w, http.StatusAccepted, resp)
}
func (a *AdminServer) handleNightlyJobStatus(w http.ResponseWriter, r *http.Request) {
+243 -1
View File
@@ -278,6 +278,108 @@ func TestHandleRunNightlyJobReturnsAcceptedStatus(t *testing.T) {
}
}
func TestHandleRunNightlyJobReturnsBusyMessageWhenRejected(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/admin/api/jobs/nightly/run", nil)
rr := httptest.NewRecorder()
(&AdminServer{
OnRunNightlyJob: func() bool {
return false
},
GetNightlyJobStatus: func() NightlyJobStatus {
return NightlyJobStatus{State: "running", Running: true}
},
}).handleRunNightlyJob(rr, req)
if rr.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202; body = %s", rr.Code, rr.Body.String())
}
var got struct {
OK bool `json:"ok"`
Accepted bool `json:"accepted"`
Message string `json:"message"`
Status NightlyJobStatus `json:"status"`
}
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if !got.OK || got.Accepted || got.Message != fullScanBusyMessage || !got.Status.Running {
t.Fatalf("response = %#v, want rejected busy message", got)
}
}
func TestHandleRescanRejectsWhenNightlyBusy(t *testing.T) {
called := false
req := httptest.NewRequest(http.MethodPost, "/admin/api/drives/PikPak/rescan", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "PikPak")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rr := httptest.NewRecorder()
(&AdminServer{
OnScanRequested: func(driveID string) bool {
called = true
return true
},
GetNightlyJobStatus: func() NightlyJobStatus {
return NightlyJobStatus{State: "running", Running: true}
},
}).handleRescan(rr, req)
if rr.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202; body = %s", rr.Code, rr.Body.String())
}
if called {
t.Fatal("OnScanRequested was called while nightly job was busy")
}
var got struct {
OK bool `json:"ok"`
Accepted bool `json:"accepted"`
Message string `json:"message"`
Status NightlyJobStatus `json:"status"`
}
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if !got.OK || got.Accepted || got.Message != fullScanBusyMessage || !got.Status.Running {
t.Fatalf("response = %#v, want rejected full scan busy message", got)
}
}
func TestHandleRescanReturnsAcceptedFlagAndBusyMessage(t *testing.T) {
calledWith := ""
req := httptest.NewRequest(http.MethodPost, "/admin/api/drives/PikPak/rescan", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "PikPak")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rr := httptest.NewRecorder()
(&AdminServer{
OnScanRequested: func(driveID string) bool {
calledWith = driveID
return false
},
}).handleRescan(rr, req)
if rr.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202; body = %s", rr.Code, rr.Body.String())
}
var got struct {
OK bool `json:"ok"`
Accepted bool `json:"accepted"`
Message string `json:"message"`
}
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if calledWith != "PikPak" {
t.Fatalf("hook called with %q, want PikPak", calledWith)
}
if !got.OK || got.Accepted || got.Message != driveTaskBusyMessage {
t.Fatalf("response = %#v, want rejected busy message", got)
}
}
func TestHandleNightlyJobStatusDefaultsToIdle(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/admin/api/jobs/nightly/status", nil)
rr := httptest.NewRecorder()
@@ -509,6 +611,67 @@ func TestHandleUpsertDriveReplacesExistingCredentialsWhenProvided(t *testing.T)
}
}
func TestHandleUpsertGoogleDriveMergesOAuthCredentials(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: "google-main",
Kind: "googledrive",
Name: "Google Drive",
RootID: "root",
Credentials: map[string]string{
"refresh_token": "existing-refresh",
"access_token": "existing-access",
"use_online_api": "true",
"api_url_address": "https://api.oplist.org/googleui/renewapi",
},
Status: "ok",
}); err != nil {
t.Fatalf("seed drive: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/admin/api/drives", bytes.NewBufferString(`{
"id": "google-main",
"kind": "googledrive",
"name": "Google Drive",
"rootId": "root",
"credentials": {
"use_online_api": "false",
"client_id": "google-client-id",
"client_secret": "google-client-secret"
}
}`))
rr := httptest.NewRecorder()
(&AdminServer{Catalog: cat}).handleUpsertDrive(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
got, err := cat.GetDrive(ctx, "google-main")
if err != nil {
t.Fatalf("get drive: %v", err)
}
if got.Credentials["refresh_token"] != "existing-refresh" || got.Credentials["access_token"] != "existing-access" {
t.Fatalf("tokens were not preserved: %#v", got.Credentials)
}
if got.Credentials["use_online_api"] != "false" {
t.Fatalf("use_online_api = %q, want false", got.Credentials["use_online_api"])
}
if got.Credentials["client_id"] != "google-client-id" || got.Credentials["client_secret"] != "google-client-secret" {
t.Fatalf("oauth client credentials = %#v, want saved", got.Credentials)
}
}
func TestHandleUpsertSpider91ProxyPreservesRuntimeCredentials(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
@@ -803,6 +966,74 @@ func TestHandleListDrivesIncludesSpider91Proxy(t *testing.T) {
}
}
func TestHandleListDrivesIncludesGoogleDriveOnlineAPIMode(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)
}
})
for _, d := range []*catalog.Drive{
{
ID: "google-legacy",
Kind: "googledrive",
Name: "Google Legacy",
RootID: "root",
Credentials: map[string]string{
"refresh_token": "legacy-refresh",
},
Status: "ok",
},
{
ID: "google-oauth",
Kind: "googledrive",
Name: "Google OAuth",
RootID: "root",
Credentials: map[string]string{
"refresh_token": "oauth-refresh",
"use_online_api": "false",
"client_id": "client-id",
"client_secret": "client-secret",
},
Status: "ok",
},
} {
if err := cat.UpsertDrive(ctx, d); err != nil {
t.Fatalf("seed drive %s: %v", d.ID, err)
}
}
req := httptest.NewRequest(http.MethodGet, "/admin/api/drives", nil)
rr := httptest.NewRecorder()
(&AdminServer{Catalog: cat}).handleListDrives(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
var got []struct {
ID string `json:"id"`
GoogleDriveUseOnlineAPI bool `json:"googleDriveUseOnlineAPI"`
}
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
byID := map[string]bool{}
for _, d := range got {
byID[d.ID] = d.GoogleDriveUseOnlineAPI
}
if !byID["google-legacy"] {
t.Fatalf("legacy google drive use_online_api = false, want true")
}
if byID["google-oauth"] {
t.Fatalf("oauth google drive use_online_api = true, want false")
}
}
func TestHandleListDrivesIncludesTeaserCounts(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
@@ -854,6 +1085,7 @@ func TestHandleListDrivesIncludesTeaserCounts(t *testing.T) {
GetDriveGenerationStatuses: func() map[string]DriveGenerationStatuses {
return map[string]DriveGenerationStatuses{
"OneDrive": {
Scan: GenerationStatus{State: "scanning", ScannedCount: 12, AddedCount: 3},
Thumbnail: GenerationStatus{State: "cooling", QueueLength: 3, CooldownUntil: "2026-05-16T21:00:00+08:00"},
Preview: GenerationStatus{State: "generating", CurrentTitle: "OD Pending"},
Fingerprint: GenerationStatus{State: "generating", CurrentTitle: "OD Pending"},
@@ -867,6 +1099,7 @@ func TestHandleListDrivesIncludesTeaserCounts(t *testing.T) {
}
var got []struct {
ID string `json:"id"`
ScanGenerationStatus GenerationStatus `json:"scanGenerationStatus"`
ThumbnailGenerationStatus GenerationStatus `json:"thumbnailGenerationStatus"`
PreviewGenerationStatus GenerationStatus `json:"previewGenerationStatus"`
FingerprintGenerationStatus GenerationStatus `json:"fingerprintGenerationStatus"`
@@ -895,6 +1128,7 @@ func TestHandleListDrivesIncludesTeaserCounts(t *testing.T) {
FingerprintReady int
FingerprintPending int
FingerprintFailed int
Scan GenerationStatus
Thumbnail GenerationStatus
Preview GenerationStatus
Fingerprint GenerationStatus
@@ -911,6 +1145,7 @@ func TestHandleListDrivesIncludesTeaserCounts(t *testing.T) {
FingerprintReady int
FingerprintPending int
FingerprintFailed int
Scan GenerationStatus
Thumbnail GenerationStatus
Preview GenerationStatus
Fingerprint GenerationStatus
@@ -925,6 +1160,7 @@ func TestHandleListDrivesIncludesTeaserCounts(t *testing.T) {
FingerprintReady: d.FingerprintReadyCount,
FingerprintPending: d.FingerprintPendingCount,
FingerprintFailed: d.FingerprintFailedCount,
Scan: d.ScanGenerationStatus,
Thumbnail: d.ThumbnailGenerationStatus,
Preview: d.PreviewGenerationStatus,
Fingerprint: d.FingerprintGenerationStatus,
@@ -942,6 +1178,12 @@ func TestHandleListDrivesIncludesTeaserCounts(t *testing.T) {
if byID["OneDrive"].Thumbnail.State != "cooling" || byID["OneDrive"].Preview.State != "generating" {
t.Fatalf("OneDrive generation statuses = %#v, want thumbnail cooling and preview generating", byID["OneDrive"])
}
if byID["OneDrive"].Scan.State != "scanning" {
t.Fatalf("OneDrive scan status = %#v, want scanning", byID["OneDrive"].Scan)
}
if byID["OneDrive"].Scan.ScannedCount != 12 || byID["OneDrive"].Scan.AddedCount != 3 {
t.Fatalf("OneDrive scan counts = %#v, want scanned=12 added=3", byID["OneDrive"].Scan)
}
if byID["OneDrive"].FingerprintReady != 1 || byID["OneDrive"].FingerprintPending != 1 || byID["OneDrive"].FingerprintFailed != 1 {
t.Fatalf("OneDrive fingerprint counts = %#v, want ready=1 pending=1 failed=1", byID["OneDrive"])
}
@@ -957,7 +1199,7 @@ func TestHandleListDrivesIncludesTeaserCounts(t *testing.T) {
if byID["PikPak"].FingerprintPending != 2 {
t.Fatalf("PikPak fingerprint counts = %#v, want pending=2", byID["PikPak"])
}
if byID["PikPak"].Thumbnail.State != "idle" || byID["PikPak"].Preview.State != "idle" || byID["PikPak"].Fingerprint.State != "idle" {
if byID["PikPak"].Scan.State != "idle" || byID["PikPak"].Thumbnail.State != "idle" || byID["PikPak"].Preview.State != "idle" || byID["PikPak"].Fingerprint.State != "idle" {
t.Fatalf("PikPak generation statuses = %#v, want idle defaults", byID["PikPak"])
}
}
+58 -5
View File
@@ -64,6 +64,12 @@ type CrawlerConfig struct {
// OnNewVideo 是新视频成功入库后的回调,用于触发预览视频 worker。
OnNewVideo func(v *catalog.Video)
// OnProgress 在抓取统计变化时触发,用于后台管理页展示实时进度。
OnProgress func(progress CrawlProgress)
// OnCheckedVideo 在 Python 爬虫开始检查一个列表页视频时触发。
OnCheckedVideo func()
// OnExtractedVideo 在 Python 爬虫提取到一个新视频直链时触发。
OnExtractedVideo func()
}
// Crawler 把 Python 爬虫产出包装成 catalog 入库流程。
@@ -219,6 +225,16 @@ type CrawlResult struct {
SeenFile string
}
// CrawlProgress 是 RunOnce 过程中可安全对外发布的实时计数。
type CrawlProgress struct {
TargetNew int
TotalEntries int
NewVideos int
Skipped int
Failed int
SeenSnapshot int
}
// spiderVideoEntry 对应 spider_91porn.py 输出 JSON 中的单条视频。
type spiderVideoEntry struct {
Title string `json:"title"`
@@ -266,6 +282,20 @@ func (c *Crawler) RunOnce(ctx context.Context, targetNew int) (*CrawlResult, err
result := &CrawlResult{TargetNew: targetNew, StartedAt: time.Now()}
defer func() { result.FinishedAt = time.Now() }()
emitProgress := func() {
if c.cfg.OnProgress == nil {
return
}
c.cfg.OnProgress(CrawlProgress{
TargetNew: result.TargetNew,
TotalEntries: result.TotalEntries,
NewVideos: result.NewVideos,
Skipped: result.Skipped,
Failed: result.Failed,
SeenSnapshot: result.SeenSnapshot,
})
}
emitProgress()
// 1. 准备 .crawl/ 目录 + 已知源视频 ID 列表
//
@@ -291,6 +321,7 @@ func (c *Crawler) RunOnce(ctx context.Context, targetNew int) (*CrawlResult, err
return result, fmt.Errorf("spider91 crawler: build seen list: %w", err)
}
result.SeenSnapshot = seenCount
emitProgress()
// 2-3. 启动 Python 爬虫(流式 stdout 协议),并边读边处理。
//
@@ -321,9 +352,11 @@ func (c *Crawler) RunOnce(ctx context.Context, targetNew int) (*CrawlResult, err
continue
}
result.TotalEntries++
emitProgress()
sourceID := sourceIDForItem(item)
if sourceID == "" || strings.TrimSpace(item.VideoURL) == "" {
result.Failed++
emitProgress()
continue
}
if result.NewVideos >= targetNew {
@@ -335,22 +368,27 @@ func (c *Crawler) RunOnce(ctx context.Context, targetNew int) (*CrawlResult, err
if err != nil {
log.Printf("[spider91] drive=%s viewkey=%s source_id=%s check deleted: %v", c.cfg.Driver.ID(), item.Viewkey, sourceID, err)
result.Failed++
emitProgress()
continue
}
if deleted {
result.Skipped++
emitProgress()
continue
}
if existing, _ := c.cfg.Catalog.GetVideo(ctx, videoID); existing != nil {
result.Skipped++
emitProgress()
continue
}
if perr := c.processOne(ctx, videoID, item); perr != nil {
log.Printf("[spider91] drive=%s viewkey=%s source_id=%s failed: %v", c.cfg.Driver.ID(), item.Viewkey, sourceID, perr)
result.Failed++
emitProgress()
continue
}
result.NewVideos++
emitProgress()
}
if scerr := scanner.Err(); scerr != nil {
log.Printf("[spider91] drive=%s stdout scan: %v", c.cfg.Driver.ID(), scerr)
@@ -458,12 +496,12 @@ func (c *Crawler) startSpiderTargetNew(ctx context.Context, targetNew int, seenP
return nil, nil, fmt.Errorf("start: %w", err)
}
// stderr 转发到 backend log。子进程退出时 reader 自动 EOFgoroutine 自然结束。
go forwardSpiderLog(c.cfg.Driver.ID(), stderr)
go forwardSpiderLog(c.cfg.Driver.ID(), stderr, c.cfg.OnCheckedVideo, c.cfg.OnExtractedVideo)
return cmd, stdout, nil
}
// forwardSpiderLog 把 Python stderr 逐行转发到 backend log,便于调试。
func forwardSpiderLog(driveID string, r io.Reader) {
func forwardSpiderLog(driveID string, r io.Reader, onCheckedVideo func(), onExtractedVideo func()) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
@@ -472,9 +510,23 @@ func forwardSpiderLog(driveID string, r io.Reader) {
continue
}
log.Printf("[spider91:py] drive=%s %s", driveID, line)
if onCheckedVideo != nil && isSpider91CheckedVideoLogLine(line) {
onCheckedVideo()
}
if onExtractedVideo != nil && isSpider91ExtractedVideoLogLine(line) {
onExtractedVideo()
}
}
}
func isSpider91CheckedVideoLogLine(line string) bool {
return checkedVideoLogRE.MatchString(line)
}
func isSpider91ExtractedVideoLogLine(line string) bool {
return strings.Contains(line, "[OK] 成功提取视频直链")
}
// processOne 处理单个 91 源视频:下载视频 + 封面 + 复制封面 + 入库。
// 任一步失败会清理已写入的临时文件,不留半成品。
func (c *Crawler) processOne(ctx context.Context, videoID string, item spiderVideoEntry) error {
@@ -847,9 +899,10 @@ func spider91CookieHeader(cookies []*http.Cookie) string {
}
var (
strencode2RE = regexp.MustCompile(`strencode2\(["']([^"']+)["']\)`)
srcAttrRE = regexp.MustCompile(`src=['"]([^'"]+)['"]`)
mp4URLRE = regexp.MustCompile(`https?://[^\s"'<>]+\.mp4[^\s"'<>]*`)
checkedVideoLogRE = regexp.MustCompile(`处理视频\s+\d+/\d+:`)
strencode2RE = regexp.MustCompile(`strencode2\(["']([^"']+)["']\)`)
srcAttrRE = regexp.MustCompile(`src=['"]([^'"]+)['"]`)
mp4URLRE = regexp.MustCompile(`https?://[^\s"'<>]+\.mp4[^\s"'<>]*`)
)
func parseSpider91VideoURL(html string) string {
@@ -707,6 +707,18 @@ func TestSpider91CookieHeader(t *testing.T) {
}
}
func TestSpider91ProgressLogLineClassifiers(t *testing.T) {
if !isSpider91CheckedVideoLogLine("[2026-06-08 16:49:17] 处理视频 3/24: 标题") {
t.Fatal("checked video log line was not recognized")
}
if isSpider91CheckedVideoLogLine("[2026-06-08 16:49:17] [页 2] 发现 24 个视频") {
t.Fatal("page summary log line should not count as checked video")
}
if !isSpider91ExtractedVideoLogLine("[2026-06-08 16:49:39] [OK] 成功提取视频直链") {
t.Fatal("extracted video log line was not recognized")
}
}
func spider91DetailHTML(videoURL string) string {
fragment := `<video><source src="` + videoURL + `" type="video/mp4"></video>`
return `document.write(strencode2("` + url.PathEscape(fragment) + `"));`
+8 -1
View File
@@ -25,6 +25,8 @@ type Scanner struct {
SkipDirIDs map[string]struct{}
// 回调:新视频被加入后触发预览视频生成
OnNewVideo func(v *catalog.Video)
// OnProgress 在扫描进度变化时触发。回调只应读取 Stats 里的计数,不应修改 map 字段。
OnProgress func(stats Stats)
// ProgressInterval 控制扫描内部 heartbeat 的最小输出间隔。
// 0 → 默认 30s< 0 → 关闭 heartbeat(仅留外层 start / done 两行)。
// heartbeat 单行格式:
@@ -91,6 +93,9 @@ func (s *Scanner) Run(ctx context.Context, startDirID string) (Stats, error) {
driveID = s.Drive.ID()
}
progress := func(currentDir string) {
if s.OnProgress != nil {
s.OnProgress(stats)
}
if interval < 0 {
return
}
@@ -149,7 +154,6 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
continue
}
stats.Scanned++
ext := strings.ToLower(path.Ext(e.Name))
if !s.Exts[ext] {
continue
@@ -157,6 +161,8 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
if e.Size <= 0 {
continue
}
stats.Scanned++
progress(dirName)
stats.SeenFileIDs[e.ID] = struct{}{}
id := s.Drive.Kind() + "-" + s.Drive.ID() + "-" + e.ID
@@ -266,6 +272,7 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
return err
}
stats.Added++
progress(dirName)
if s.OnNewVideo != nil {
s.OnNewVideo(v)
}
+33
View File
@@ -91,6 +91,39 @@ func TestRunIgnoresZeroSizeVideoFiles(t *testing.T) {
}
}
func TestRunScannedCountsOnlyVideoCandidates(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
if err != nil {
t.Fatalf("open catalog: %v", err)
}
t.Cleanup(func() {
if err := cat.Close(); err != nil {
t.Fatalf("close catalog: %v", err)
}
})
drv := &scannerFakeDrive{
entries: []drives.Entry{
{ID: "file-1", Name: "clip.mp4", Size: 123},
{ID: "file-2", Name: "notes.txt", Size: 123},
{ID: "file-3", Name: "empty.mp4", Size: 0},
},
}
sc := New(cat, drv, []string{".mp4"}, nil, nil)
stats, err := sc.Run(ctx, "")
if err != nil {
t.Fatalf("scan: %v", err)
}
if stats.Scanned != 1 {
t.Fatalf("scanned = %d, want one non-empty video candidate", stats.Scanned)
}
if stats.Added != 1 {
t.Fatalf("added = %d, want one added video", stats.Added)
}
}
func TestRunStopsWhenContextCanceledDuringFileLoop(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
+37 -2
View File
@@ -1,14 +1,16 @@
{
"name": "video-site",
"version": "0.1.0",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "video-site",
"version": "0.1.0",
"version": "0.1.4",
"license": "MIT",
"dependencies": {
"artplayer": "^5.4.0",
"hls.js": "^1.6.16",
"lucide-react": "0.453.0",
"react": "18.3.1",
"react-dom": "18.3.1",
@@ -475,6 +477,15 @@
}
}
},
"node_modules/artplayer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/artplayer/-/artplayer-5.4.0.tgz",
"integrity": "sha512-2B+plbx8N2yNsjK4nJU3+EOG8TULm1LRZk/QPkWRAMEX2Ee/MSnZG/WJYz8kcoZxZuLKcQ3uXifqLuPxZOH29A==",
"license": "MIT",
"dependencies": {
"option-validator": "^2.0.6"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -525,12 +536,27 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/hls.js": {
"version": "1.6.16",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
"license": "Apache-2.0"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -832,6 +858,15 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/option-validator": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/option-validator/-/option-validator-2.0.6.tgz",
"integrity": "sha512-tmZDan2LRIRQyhUGvkff68/O0R8UmF+Btmiiz0SmSw2ng3CfPZB9wJlIjHpe/MKUZqyIZkVIXCrwr1tIN+0Dzg==",
"license": "MIT",
"dependencies": {
"kind-of": "^6.0.3"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+3 -1
View File
@@ -2,7 +2,7 @@
"name": "video-site",
"private": true,
"license": "MIT",
"version": "0.1.0",
"version": "0.1.4",
"type": "module",
"scripts": {
"dev": "vite",
@@ -13,6 +13,8 @@
"test": "node --import tsx --test tests/*.test.ts"
},
"dependencies": {
"artplayer": "^5.4.0",
"hls.js": "^1.6.16",
"lucide-react": "0.453.0",
"react": "18.3.1",
"react-dom": "18.3.1",
+65 -14
View File
@@ -20,6 +20,7 @@ import { formatBytes } from "./storageFormat";
import { makeUniqueDriveId } from "./driveId";
import {
FormState,
driveKindAbbr,
kindLabel,
emptyForm,
idleNightlyStatus,
@@ -38,6 +39,21 @@ import { DriveForm } from "./drive/DriveForm";
import { DeleteDriveModal } from "./drive/DeleteDriveModal";
import { SkipDirsPanel } from "./drive/SkipDirsPanel";
const DRIVE_BUSY_MESSAGE = "当前存储有正在进行的任务,请稍后重试";
const NIGHTLY_BUSY_MESSAGE = "当前有全量扫描任务正在进行,请稍后重试";
function isDriveBusy(d: api.AdminDrive) {
return [
d.scanGenerationStatus,
d.thumbnailGenerationStatus,
d.previewGenerationStatus,
d.fingerprintGenerationStatus,
].some((status) => {
const state = status?.state || "idle";
return state !== "idle";
});
}
export function DrivesPage() {
const [list, setList] = useState<api.AdminDrive[]>([]);
const [storage, setStorage] = useState<api.AdminDriveStorage | null>(null);
@@ -61,7 +77,8 @@ export function DrivesPage() {
const [scanningAll, setScanningAll] = useState(false);
const [stoppingAll, setStoppingAll] = useState(false);
const [trackingNightly, setTrackingNightly] = useState(false);
const [scanningDriveId, setScanningDriveId] = useState("");
const [scanningDriveIds, setScanningDriveIds] = useState<Record<string, boolean>>({});
const scanningDriveIdsRef = useRef(new Set<string>());
const [stoppingDriveId, setStoppingDriveId] = useState("");
const [searchParams, setSearchParams] = useSearchParams();
const selectedDriveId = searchParams.get("drive") || null;
@@ -184,7 +201,12 @@ export function DrivesPage() {
kind: d.kind,
name: d.name,
rootId: d.rootId,
creds: d.kind === "spider91" ? { proxy: d.spider91Proxy ?? "" } : {},
creds:
d.kind === "spider91"
? { proxy: d.spider91Proxy ?? "" }
: d.kind === "googledrive"
? { use_online_api: (d.googleDriveUseOnlineAPI ?? true) ? "true" : "false" }
: {},
spider91UploadDriveId: settings?.spider91UploadDriveId ?? "",
};
setForm(nextForm);
@@ -295,25 +317,47 @@ export function DrivesPage() {
}
async function handleRescan(d: api.AdminDrive) {
if (scanningDriveId) return;
setScanningDriveId(d.id);
if (nightlyBusy) {
show(nightlyBusyText(nightlyStatus) || NIGHTLY_BUSY_MESSAGE, "info");
return;
}
if (isDriveBusy(d) || scanningDriveIdsRef.current.has(d.id)) {
show(DRIVE_BUSY_MESSAGE, "info");
return;
}
scanningDriveIdsRef.current.add(d.id);
setScanningDriveIds((prev) => ({ ...prev, [d.id]: true }));
try {
await api.rescan(d.id);
const resp = await api.rescan(d.id);
if (!resp.accepted) {
if (resp.status) {
setNightlyStatus(resp.status);
}
show(resp.message || DRIVE_BUSY_MESSAGE, "info");
refreshDriveList();
return;
}
if (d.kind === "spider91") {
show("已触发抓取任务,需要 2-4 分钟,可稍后刷新视频列表查看", "success");
} else {
show("已触发扫描,可稍后刷新视频列表查看", "success");
}
refreshDriveList();
} catch (e) {
show(e instanceof Error ? e.message : "触发失败", "error");
} finally {
setScanningDriveId("");
scanningDriveIdsRef.current.delete(d.id);
setScanningDriveIds((prev) => {
const next = { ...prev };
delete next[d.id];
return next;
});
}
}
async function handleRunNightly() {
if (nightlyBusy) {
show(nightlyBusyText(nightlyStatus) || "当前已有扫描所有网盘任务", "info");
show(nightlyBusyText(nightlyStatus) || NIGHTLY_BUSY_MESSAGE, "info");
return;
}
setScanningAll(true);
@@ -324,7 +368,7 @@ export function DrivesPage() {
setTrackingNightly(!resp.status.running);
show("已触发扫描所有网盘,耗时较长,可在任务状态和 backend 日志观察进度", "success");
} else {
show("当前已有扫描所有网盘任务", "info");
show(resp.message || NIGHTLY_BUSY_MESSAGE, "info");
}
} catch (e) {
show(e instanceof Error ? e.message : "触发失败", "error");
@@ -515,17 +559,24 @@ export function DrivesPage() {
type="button"
className="admin-btn is-primary"
onClick={() => handleRescan(d)}
disabled={!!scanningDriveId}
aria-disabled={nightlyBusy || isDriveBusy(d) || !!scanningDriveIds[d.id]}
title={
nightlyBusy
? nightlyBusyText(nightlyStatus) || NIGHTLY_BUSY_MESSAGE
: isDriveBusy(d) || scanningDriveIds[d.id]
? DRIVE_BUSY_MESSAGE
: undefined
}
>
{d.kind === "spider91" ? (
<>
<Download size={13} className={scanningDriveId === d.id ? "admin-spin" : undefined} />
{scanningDriveId === d.id ? "触发中..." : "立即抓取"}
<Download size={13} className={scanningDriveIds[d.id] ? "admin-spin" : undefined} />
{scanningDriveIds[d.id] ? "触发中..." : "立即抓取"}
</>
) : (
<>
<RefreshCw size={13} className={scanningDriveId === d.id ? "admin-spin" : undefined} />
{scanningDriveId === d.id ? "触发中..." : "立即重扫"}
<RefreshCw size={13} className={scanningDriveIds[d.id] ? "admin-spin" : undefined} />
{scanningDriveIds[d.id] ? "触发中..." : "立即重扫"}
</>
)}
</button>
@@ -720,7 +771,7 @@ export function DrivesPage() {
<div className="admin-drive-card__header">
<div className="admin-drive-card__title">
<span className="admin-drive-card__brand-icon" data-kind={d.kind}>
{d.kind.substring(0, 2)}
{driveKindAbbr(d.kind)}
</span>
<span>{d.name || d.id}</span>
</div>
+7 -2
View File
@@ -95,6 +95,9 @@ export type AdminDrive = {
lastCrawlAt?: number;
// spider91 专用代理地址;仅后台管理接口返回,用于编辑表单回显。
spider91Proxy?: string;
// Google Drive 是否使用 OpenList 在线续期 API;未配置时后端按 true 返回。
googleDriveUseOnlineAPI?: boolean;
scanGenerationStatus?: DriveGenerationStatus;
thumbnailGenerationStatus?: DriveGenerationStatus;
previewGenerationStatus?: DriveGenerationStatus;
fingerprintGenerationStatus?: DriveGenerationStatus;
@@ -115,6 +118,8 @@ export type DriveGenerationStatus = {
currentTitle?: string;
queueLength: number;
cooldownUntil?: string;
scannedCount: number;
addedCount: number;
};
export function listDrives() {
@@ -170,7 +175,7 @@ export function deleteDrive(id: string, body: DeleteDriveInput) {
}
export function rescan(id: string) {
return request<{ ok: boolean }>(
return request<{ ok: boolean; accepted: boolean; message?: string; status?: NightlyJobStatus }>(
`/drives/${encodeURIComponent(id)}/rescan`,
{ method: "POST" }
);
@@ -448,7 +453,7 @@ export function getNightlyJobStatus() {
}
export function runNightlyJob() {
return request<{ ok: boolean; accepted: boolean; status: NightlyJobStatus }>(
return request<{ ok: boolean; accepted: boolean; status: NightlyJobStatus; message?: string }>(
"/jobs/nightly/run",
{ method: "POST" }
);
+27 -9
View File
@@ -204,6 +204,11 @@ export function DriveGenerationPanel({
</header>
<div className="admin-gen-columns">
<DriveGenCol
label={d.kind === "spider91" ? "抓取" : "扫盘"}
status={d.scanGenerationStatus}
showCounts={false}
/>
<DriveGenCol
label="封面"
status={d.thumbnailGenerationStatus}
@@ -265,6 +270,7 @@ function DriveGenCol({
pending,
failed,
extra,
showCounts = true,
}: {
label: string;
status?: api.DriveGenerationStatus;
@@ -272,10 +278,14 @@ function DriveGenCol({
pending?: number;
failed?: number;
extra?: number;
showCounts?: boolean;
}) {
const state = status?.state || "idle";
const detail = generationDetail(status);
const title = generationTitle(status, detail);
const stateLabel = label === "抓取" && state === "scanning" ? "抓取中" : generationStateLabel(state);
const showScanProgress = !showCounts && (state === "scanning" || (status?.scannedCount ?? 0) > 0 || (status?.addedCount ?? 0) > 0);
const scannedLabel = label === "抓取" ? "已抓取" : "已扫描";
return (
<div className="admin-gen-col">
<div className="admin-gen-col__head">
@@ -284,18 +294,26 @@ function DriveGenCol({
className={`admin-status admin-generation-state is-${generationStateClass(state)}`}
title={title || undefined}
>
{generationStateLabel(state)}
{stateLabel}
</span>
</div>
{detail && <div className="admin-gen-col__detail">{detail}</div>}
<div className="admin-gen-col__counts">
<div className="admin-gen-col__count"><span></span><strong>{ready ?? 0}</strong></div>
<div className="admin-gen-col__count"><span></span><strong>{pending ?? 0}</strong></div>
<div className="admin-gen-col__count"><span></span><strong>{failed ?? 0}</strong></div>
{(extra ?? 0) > 0 && (
<div className="admin-gen-col__count"><span></span><strong>{extra}</strong></div>
)}
</div>
{showScanProgress && (
<div className="admin-gen-col__counts admin-gen-col__counts--scan">
<div className="admin-gen-col__count"><span>{scannedLabel}</span><strong>{status?.scannedCount ?? 0}</strong></div>
<div className="admin-gen-col__count"><span></span><strong>{status?.addedCount ?? 0}</strong></div>
</div>
)}
{showCounts && (
<div className="admin-gen-col__counts">
<div className="admin-gen-col__count"><span></span><strong>{ready ?? 0}</strong></div>
<div className="admin-gen-col__count"><span></span><strong>{pending ?? 0}</strong></div>
<div className="admin-gen-col__count"><span></span><strong>{failed ?? 0}</strong></div>
{(extra ?? 0) > 0 && (
<div className="admin-gen-col__count"><span></span><strong>{extra}</strong></div>
)}
</div>
)}
</div>
);
}
+48 -20
View File
@@ -1,5 +1,5 @@
import { useId, useMemo, useState } from "react";
import { ArrowLeft } from "lucide-react";
import { ArrowLeft, ChevronDown } from "lucide-react";
import { P123QRCodeLogin } from "./P123QRCodeLogin";
import { Spider91UploadTargetField } from "./Spider91UploadTargetField";
import {
@@ -49,7 +49,7 @@ export function DriveForm({
onBack?: () => void;
}) {
const idPrefix = useId();
const fields = useMemo(() => credentialFields(form.kind), [form.kind]);
const fields = useMemo(() => credentialFields(form.kind, form.creds), [form.kind, form.creds]);
const help = credentialHelp(form.kind, isEdit);
const [step, setStep] = useState<"type" | "form">(isEdit ? "form" : "type");
const nameId = `${idPrefix}-drive-name`;
@@ -180,25 +180,53 @@ export function DriveForm({
{fields.map((f) => (
<div key={f.key} className="admin-form__row">
<label htmlFor={`${idPrefix}-credential-${f.key}`}>
{f.label}
{f.required && " *"}
</label>
{f.multiline ? (
<textarea
id={`${idPrefix}-credential-${f.key}`}
value={form.creds[f.key] ?? ""}
onChange={(e) => setCred(f.key, e.target.value)}
placeholder={f.placeholder}
/>
{f.type === "select" ? (
<>
<label htmlFor={`${idPrefix}-credential-${f.key}`}>
{f.label}
{f.required && " *"}
</label>
<div className="admin-form-select-wrap">
<select
id={`${idPrefix}-credential-${f.key}`}
className="admin-form-select"
value={form.creds[f.key] ?? f.defaultValue ?? ""}
onChange={(e) => setCred(f.key, e.target.value)}
>
{(f.options ?? []).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<ChevronDown size={15} className="admin-form-select__icon" aria-hidden="true" />
</div>
</>
) : (
<input
id={`${idPrefix}-credential-${f.key}`}
type={credentialInputType(f.key)}
value={form.creds[f.key] ?? ""}
onChange={(e) => setCred(f.key, e.target.value)}
placeholder={f.placeholder}
/>
<>
<label htmlFor={`${idPrefix}-credential-${f.key}`}>
{f.label}
{f.required && " *"}
</label>
{f.multiline ? (
<textarea
id={`${idPrefix}-credential-${f.key}`}
value={form.creds[f.key] ?? ""}
onChange={(e) => setCred(f.key, e.target.value)}
placeholder={f.placeholder}
required={f.required && !isEdit}
/>
) : (
<input
id={`${idPrefix}-credential-${f.key}`}
type={credentialInputType(f.key)}
value={form.creds[f.key] ?? ""}
onChange={(e) => setCred(f.key, e.target.value)}
placeholder={f.placeholder}
required={f.required && !isEdit}
/>
)}
</>
)}
{f.help && <div className="admin-form__help">{f.help}</div>}
</div>
+65 -6
View File
@@ -12,6 +12,16 @@ export const kindAbbr: Record<string, string> = {
spider91: "91",
};
export function driveKindAbbr(kind: string): string {
const explicit = kindAbbr[kind];
if (explicit) return explicit;
const trimmed = kind.trim();
if (!trimmed) return "??";
const compact = trimmed.replace(/[^a-zA-Z0-9]+/g, "");
return (compact || trimmed).slice(0, 2).toUpperCase();
}
export const kindLabel: Record<string, string> = {
quark: "夸克网盘",
p115: "115 网盘",
@@ -56,12 +66,12 @@ export function nightlyButtonText(status: { running: boolean; queued: boolean },
}
export function nightlyBusyText(status: { running: boolean; queued: boolean }) {
if (status.running) return "扫描任务正在运行";
if (status.queued) return "扫描任务已排队";
if (status.running || status.queued) return "当前有全量扫描任务正在进行,请稍后重试";
return "";
}
export function generationStateLabel(state: string): string {
if (state === "scanning") return "扫盘中";
if (state === "generating") return "生成中";
if (state === "cooling") return "冷却中";
if (state === "queued") return "排队中";
@@ -69,7 +79,8 @@ export function generationStateLabel(state: string): string {
}
export function generationStateClass(state: string): string {
if (state === "generating" || state === "cooling" || state === "queued") {
if (state === "scanning" || state === "generating" || state === "cooling" || state === "queued") {
if (state === "scanning") return "generating";
return state;
}
return "idle";
@@ -146,7 +157,9 @@ export function credentialHelp(kind: Kind, isEdit: boolean): string {
case "onedrive":
return `按 OpenList 默认应用在线挂载,只需要 refresh_token;保存时会自动刷新并保存 token。${note}`;
case "googledrive":
return `按 OpenList 在线 API 挂载,只需要 Google Drive refresh_token;保存时会自动刷新并保存 token。播放不走 302,会由后端带 Authorization 代理转发。${note}`;
return isEdit
? "请参考OpenList文档中关于谷歌云盘的配置方法;如不修改凭证,留空即可,保存时会沿用旧值"
: "请参考OpenList文档中关于谷歌云盘的配置方法";
case "localstorage":
return `填写服务器可访问的本地目录绝对路径,例如 /mnt/videos。系统会扫描该目录及子目录中的视频文件和 .strm 文件;.strm 可指向 HTTP/HTTPS 直链,或指向本地存储根目录内的真实视频路径。Docker 部署时请填写容器内路径。${note}`;
case "spider91":
@@ -156,14 +169,31 @@ export function credentialHelp(kind: Kind, isEdit: boolean): string {
}
}
export function credentialFields(kind: Kind): Array<{
export type CredentialField = {
key: string;
label: string;
placeholder: string;
type?: "text" | "select";
options?: Array<{ value: string; label: string }>;
multiline?: boolean;
required?: boolean;
defaultValue?: string;
help?: string;
}> {
};
export function credentialBoolValue(value: string | undefined, defaultValue: boolean): boolean {
const normalized = (value ?? "").trim().toLowerCase();
if (normalized === "") return defaultValue;
if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") return true;
if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") return false;
return defaultValue;
}
export function googleDriveUsesOnlineAPI(creds: Record<string, string> = {}): boolean {
return credentialBoolValue(creds.use_online_api, true);
}
export function credentialFields(kind: Kind, creds: Record<string, string> = {}): CredentialField[] {
switch (kind) {
case "quark":
return [
@@ -252,6 +282,17 @@ export function credentialFields(kind: Kind): Array<{
];
case "googledrive":
return [
{
key: "use_online_api",
label: "认证方式",
placeholder: "",
type: "select",
defaultValue: "true",
options: [
{ value: "true", label: "OpenList 在线 API" },
{ value: "false", label: "自建 Google OAuth 客户端" },
],
},
{
key: "refresh_token",
label: "refresh_token",
@@ -259,6 +300,24 @@ export function credentialFields(kind: Kind): Array<{
multiline: true,
required: true,
},
...(googleDriveUsesOnlineAPI(creds)
? []
: [
{
key: "client_id",
label: "客户端 ID",
placeholder: "xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com",
required: true,
help: "Google Cloud Console 中 OAuth 2.0 客户端的 Client ID",
},
{
key: "client_secret",
label: "客户端密钥",
placeholder: "Google OAuth client secret",
required: true,
help: "Google Cloud Console 中同一个 OAuth 客户端的 Client Secret",
},
]),
];
case "localstorage":
return [
+76 -6
View File
@@ -1,4 +1,4 @@
import { ReactNode } from "react";
import { ReactNode, useEffect, useState } from "react";
import { TopBar } from "./TopBar";
import { MainNav } from "./MainNav";
import { SubNav } from "./SubNav";
@@ -7,14 +7,84 @@ import { BackToTop } from "./BackToTop";
type Props = {
children: ReactNode;
mobileAutoHideNav?: boolean;
};
export function AppShell({ children }: Props) {
const MOBILE_NAV_QUERY = "(max-width: 768px)";
const SCROLL_DELTA_THRESHOLD = 6;
const HIDE_AFTER_SCROLL_Y = 56;
export function AppShell({ children, mobileAutoHideNav = false }: Props) {
const [mobileNavHidden, setMobileNavHidden] = useState(false);
useEffect(() => {
if (!mobileAutoHideNav) {
setMobileNavHidden(false);
return;
}
const mediaQuery = window.matchMedia(MOBILE_NAV_QUERY);
let lastScrollY = Math.max(window.scrollY, 0);
let ticking = false;
const showNav = () => setMobileNavHidden(false);
const updateNavVisibility = () => {
ticking = false;
const currentScrollY = Math.max(window.scrollY, 0);
if (!mediaQuery.matches || currentScrollY <= 0) {
showNav();
lastScrollY = currentScrollY;
return;
}
const delta = currentScrollY - lastScrollY;
if (Math.abs(delta) < SCROLL_DELTA_THRESHOLD) return;
if (delta > 0 && currentScrollY > HIDE_AFTER_SCROLL_Y) {
setMobileNavHidden(true);
} else if (delta < 0) {
showNav();
}
lastScrollY = currentScrollY;
};
const handleScroll = () => {
if (ticking) return;
ticking = true;
window.requestAnimationFrame(updateNavVisibility);
};
const handleMediaChange = () => {
lastScrollY = Math.max(window.scrollY, 0);
showNav();
};
handleMediaChange();
window.addEventListener("scroll", handleScroll, { passive: true });
mediaQuery.addEventListener("change", handleMediaChange);
return () => {
window.removeEventListener("scroll", handleScroll);
mediaQuery.removeEventListener("change", handleMediaChange);
};
}, [mobileAutoHideNav]);
const className = [
"app-shell",
mobileAutoHideNav ? "app-shell--mobile-auto-hide-nav" : "",
mobileNavHidden ? "is-mobile-nav-hidden" : "",
].filter(Boolean).join(" ");
return (
<div className="app-shell">
<TopBar />
<MainNav />
<SubNav />
<div className={className}>
<div className="app-shell__nav-stack">
<TopBar />
<MainNav />
<SubNav />
</div>
<main className="app-shell__main">{children}</main>
<Footer />
<BackToTop />
+5 -7
View File
@@ -38,13 +38,11 @@ export function RecommendedRail({ videos }: Props) {
return (
<aside className="vd-rail" aria-label="推荐视频">
<header className="vd-rail__head">
<span className="vd-rail__head-bar" aria-hidden="true" />
<div className="vd-rail__head-text">
<h2 className="vd-rail__head-title"></h2>
<span className="vd-rail__head-sub">
· {videos.length}
</span>
</div>
<span className="vd-rail__head-icon" aria-hidden="true">
<span />
<span />
</span>
<h2 className="vd-rail__head-title"></h2>
</header>
<ul className="vd-rail__list">
{videos.map((v) => (
+19 -6
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { EyeOff, ThumbsDown, ThumbsUp } from "lucide-react";
import type { VideoDetail } from "@/types";
import { formatCount } from "@/lib/format";
@@ -12,11 +12,11 @@ type Props = {
/**
*
* - 线"成体"
* - + 线
* - +
* - "不再显示" hover danger
*
*
* - POST /api/video/:id/like state
* - state
* -
*/
export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
@@ -25,11 +25,20 @@ export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
const [bursting, setBursting] = useState(false);
const [liked, setLiked] = useState(false);
const [disliked, setDisliked] = useState(false);
const [likeSubmitted, setLikeSubmitted] = useState(false);
useEffect(() => {
setLikes(video.likes ?? 0);
setDislikes(video.dislikes ?? 0);
setBursting(false);
setLiked(false);
setDisliked(false);
setLikeSubmitted(false);
}, [video.id, video.likes, video.dislikes]);
async function handleLike() {
if (liked) return;
setLiked(true);
setLikes((n) => n + 1);
setBursting(true);
window.setTimeout(() => setBursting(false), 320);
@@ -38,6 +47,11 @@ export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
setDislikes((n) => Math.max(0, n - 1));
}
if (likeSubmitted) return;
setLikeSubmitted(true);
setLikes((n) => n + 1);
try {
const res = await fetch(
`/api/video/${encodeURIComponent(video.id)}/like`,
@@ -51,6 +65,7 @@ export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
} catch {
setLikes((n) => Math.max(0, n - 1));
setLiked(false);
setLikeSubmitted(false);
}
}
@@ -64,7 +79,6 @@ export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
setDislikes((n) => n + 1);
if (liked) {
setLiked(false);
setLikes((n) => Math.max(0, n - 1));
}
}
@@ -83,7 +97,6 @@ export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
<ThumbsUp size={18} fill={liked ? "currentColor" : "none"} />
<span className="vd-actions__count">{formatCount(likes)}</span>
</button>
<span className="vd-actions__divider" aria-hidden="true" />
<button
type="button"
className={`vd-actions__pill vd-actions__dislike${
+3 -3
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { Hash, Pencil, X } from "lucide-react";
import { Pencil, Tag, X } from "lucide-react";
import type { TagItem, VideoDetail } from "@/types";
type Props = {
@@ -17,7 +17,7 @@ type Props = {
* "两张分离卡"
* - 线
* - "简介"
* - #
* -
*/
export function VideoInfoPanel({
video,
@@ -99,7 +99,7 @@ export function VideoInfoPanel({
<div className="vd-info__tags">
<div className="vd-info__section-head">
<span className="vd-info__section-title">
<Hash size={14} aria-hidden="true" />
<Tag size={15} strokeWidth={2} aria-hidden="true" />
</span>
{onTagsChange && (
+21 -19
View File
@@ -1,3 +1,4 @@
import { CalendarDays, Clock3, Eye } from "lucide-react";
import type { VideoDetail } from "@/types";
import { formatCount } from "@/lib/format";
@@ -9,12 +10,11 @@ type Props = {
*
*
*
* -
* - meta + +
* - meta
* "·"
* - meta
*/
export function VideoMetaHeader({ video }: Props) {
const author = (video.author ?? "").trim();
const source = (video.sourceLabel ?? "").trim();
const quality = (video.quality ?? "").trim();
const duration = (video.duration ?? "").trim();
@@ -23,20 +23,7 @@ export function VideoMetaHeader({ video }: Props) {
return (
<header className="vd-header">
<h1 className="vd-header__title" title={video.title}>
{video.title}
</h1>
<div className="vd-header__row">
{author && (
<div className="vd-author" aria-label={`作者 ${author}`}>
<span className="vd-author__avatar" aria-hidden="true">
{author.slice(0, 1)}
</span>
<span className="vd-author__name">{author}</span>
</div>
)}
<ul className="vd-meta" aria-label="视频信息">
{source && (
<li className="vd-meta__chip" data-tone={sourceKind || "neutral"}>
@@ -52,13 +39,28 @@ export function VideoMetaHeader({ video }: Props) {
{quality}
</li>
)}
{duration && <li className="vd-meta__chip">{duration}</li>}
<li className="vd-meta__chip">
{duration && (
<li className="vd-meta__chip vd-meta__chip--plain">
<Clock3 size={14} aria-hidden="true" />
{duration}
</li>
)}
<li className="vd-meta__chip vd-meta__chip--plain">
<Eye size={14} aria-hidden="true" />
<strong>{formatCount(video.views)}</strong>
</li>
{published && <li className="vd-meta__chip">{published}</li>}
{published && (
<li className="vd-meta__chip vd-meta__chip--plain">
<CalendarDays size={14} aria-hidden="true" />
{published}
</li>
)}
</ul>
</div>
<h1 className="vd-header__title" title={video.title}>
{video.title}
</h1>
</header>
);
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -105,7 +105,7 @@ export default function HomePage() {
const latest = latestVideos.slice(0, displayCount);
return (
<AppShell>
<AppShell mobileAutoHideNav>
<div className="container page-section">
<PromoStrip />
<SearchPanel />
+69 -13
View File
@@ -84,14 +84,66 @@ export default function VideoDetailPage() {
if (loading) {
return (
<AppShell>
<AppShell mobileAutoHideNav>
<div className="vd-page">
<div className="vd-ambient" aria-hidden="true" />
<div className="container vd-page__inner">
<div className="vd-skeleton">
<div className="vd-skeleton__player" />
<div className="vd-skeleton__title" />
<div className="vd-skeleton__meta" />
<div
className="vd-layout vd-skeleton"
aria-busy="true"
aria-label="视频详情加载中"
>
<div className="vd-main">
<div className="vd-skeleton__player" />
<div className="vd-skeleton__summary">
<div className="vd-skeleton__chips">
<span className="vd-skeleton__chip vd-skeleton__chip--source" />
<span className="vd-skeleton__chip" />
<span className="vd-skeleton__chip vd-skeleton__chip--plain" />
<span className="vd-skeleton__chip vd-skeleton__chip--plain" />
</div>
<div className="vd-skeleton__title" />
<div className="vd-skeleton__actions">
<span />
<span />
<span />
</div>
</div>
<div className="vd-skeleton__info">
<span className="vd-skeleton__section-head" />
<span className="vd-skeleton__line" />
<span className="vd-skeleton__line vd-skeleton__line--short" />
<div className="vd-skeleton__tag-row">
<span />
<span />
<span />
</div>
</div>
</div>
<aside className="vd-rail vd-skeleton__rail">
<div className="vd-rail__head">
<span className="vd-rail__head-icon" aria-hidden="true">
<span />
<span />
</span>
<span className="vd-skeleton__rail-head" />
</div>
<ul className="vd-rail__list vd-skeleton__rail-list">
{Array.from({ length: 6 }).map((_, index) => (
<li key={index} className="vd-skeleton__rail-item">
<span className="vd-skeleton__rail-thumb" />
<span className="vd-skeleton__rail-body">
<span className="vd-skeleton__rail-title" />
<span className="vd-skeleton__rail-title vd-skeleton__rail-title--short" />
<span className="vd-skeleton__rail-meta" />
</span>
</li>
))}
</ul>
</aside>
</div>
</div>
</div>
@@ -101,7 +153,7 @@ export default function VideoDetailPage() {
if (!detail) {
return (
<AppShell>
<AppShell mobileAutoHideNav>
<div className="vd-page">
<div className="container vd-page__inner">
<div className="vd-empty"></div>
@@ -112,7 +164,7 @@ export default function VideoDetailPage() {
}
return (
<AppShell>
<AppShell mobileAutoHideNav>
<div className="vd-page">
{/* Ambient 背景层:用海报作模糊底色,叠加渐变过渡到页面背景 */}
<div
@@ -131,21 +183,25 @@ export default function VideoDetailPage() {
<div className="vd-player-wrap">
<div className="vd-player">
<VideoPlayer
id={detail.id}
src={detail.videoSrc}
poster={detail.poster}
previewSrc={detail.previewSrc}
title={detail.title}
onFirstPlay={handleFirstPlay}
/>
</div>
</div>
<VideoMetaHeader video={detail} />
<section className="vd-summary" aria-label="当前视频">
<VideoMetaHeader video={detail} />
<VideoActions
video={detail}
onHideVideo={handleHideVideo}
hideSaving={hideSaving}
/>
<VideoActions
video={detail}
onHideVideo={handleHideVideo}
hideSaving={hideSaving}
/>
</section>
<VideoInfoPanel
video={detail}
+4 -2
View File
@@ -2971,6 +2971,7 @@
display: grid;
place-items: center;
color: #fff;
background: var(--accent);
font-size: 13px;
font-weight: var(--weight-bold);
text-transform: capitalize;
@@ -2983,6 +2984,7 @@
.admin-drive-card__brand-icon[data-kind="pikpak"] { background: var(--drive-pikpak); }
.admin-drive-card__brand-icon[data-kind="wopan"] { background: var(--drive-wopan); }
.admin-drive-card__brand-icon[data-kind="onedrive"] { background: var(--drive-onedrive); }
.admin-drive-card__brand-icon[data-kind="googledrive"] { background: #4285f4; }
.admin-drive-card__brand-icon[data-kind="localstorage"] { background: var(--drive-localstorage); }
.admin-drive-card__brand-icon[data-kind="spider91"] { background: var(--accent); }
@@ -3107,11 +3109,11 @@
}
/* =========================================================
* Drive Generation 3-Column Layout
* Drive Generation
* ========================================================= */
.admin-gen-columns {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--space-3);
margin-bottom: var(--space-4);
}
+4
View File
@@ -10,6 +10,10 @@
font-size: var(--font-base);
}
.app-shell__nav-stack {
flex: 0 0 auto;
}
.app-shell__main {
flex: 1;
width: 100%;
+18
View File
@@ -179,6 +179,24 @@
/* ----- 响应式 ----- */
@media (max-width: 768px) {
.app-shell--mobile-auto-hide-nav .app-shell__nav-stack {
position: sticky;
top: 0;
z-index: var(--z-nav);
transform: translateY(0);
transition: transform 220ms var(--ease-out);
will-change: transform;
}
.app-shell--mobile-auto-hide-nav.is-mobile-nav-hidden .app-shell__nav-stack {
transform: translateY(-100%);
}
.app-shell--mobile-auto-hide-nav .main-nav {
position: relative;
z-index: auto;
}
.main-nav__inner {
height: 56px;
gap: var(--space-3);
File diff suppressed because it is too large Load Diff
+59 -4
View File
@@ -112,7 +112,7 @@ test("onedrive drive form only exposes required default-app fields", () => {
assert.doesNotMatch(fields, /key: "site_id"/);
});
test("googledrive drive form only exposes refresh token", () => {
test("googledrive drive form supports online API and custom OAuth client modes", () => {
assertDriveTypeOption("googledrive", "Google Drive");
const match =
@@ -123,10 +123,25 @@ test("googledrive drive form only exposes refresh token", () => {
const fields = match[1];
assert.match(fields, /key: "refresh_token"/);
assert.doesNotMatch(fields, /key: "access_token"/);
assert.match(fields, /key: "use_online_api"/);
assert.match(fields, /type: "select"/);
assert.match(fields, /defaultValue: "true"/);
assert.match(fields, /OpenList 在线 API/);
assert.match(fields, /自建 Google OAuth 客户端/);
assert.match(fields, /key: "client_id"/);
assert.match(fields, /key: "client_secret"/);
assert.match(fields, /googleDriveUsesOnlineAPI\(creds\)/);
assert.doesNotMatch(fields, /key: "api_url_address"/);
assert.doesNotMatch(fields, /key: "client_id"/);
assert.doesNotMatch(fields, /key: "client_secret"/);
assert.doesNotMatch(fields, /在线 API 模式填写 OpenList 获取的 refresh_token/);
assert.doesNotMatch(constantsSource, /请参考OpenList文档中关于谷歌云盘的配置方法。/);
assert.doesNotMatch(constantsSource, /选择自建 Google OAuth 客户端后,服务端会直接请求 Google OAuth token 接口续期。/);
assert.match(driveFormSource, /<select/);
assert.match(driveFormSource, /value=\{form\.creds\[f\.key\] \?\? f\.defaultValue \?\? ""\}/);
assert.match(driveFormSource, /className="admin-form-select"/);
assert.match(driveFormSource, /ChevronDown/);
assert.match(drivesPageSource, /googleDriveUseOnlineAPI/);
assert.match(apiSource, /googleDriveUseOnlineAPI\?: boolean/);
assert.doesNotMatch(fields, /key: "access_token"/);
});
test("pikpak drive form only exposes account login fields", () => {
@@ -176,6 +191,15 @@ test("drive type selector keeps primary source order", () => {
]);
});
test("drive cards use configured abbreviations and visible fallback icon colors", () => {
assert.match(constantsSource, /googledrive:\s*"GD"/);
assert.match(constantsSource, /function driveKindAbbr\(kind: string\)/);
assert.match(constantsSource, /\.slice\(0, 2\)\.toUpperCase\(\)/);
assert.match(drivesPageSource, /driveKindAbbr\(d\.kind\)/);
assert.match(adminCss, /\.admin-drive-card__brand-icon\s*\{[^}]*background:\s*var\(--accent\);/s);
assert.match(adminCss, /\.admin-drive-card__brand-icon\[data-kind="googledrive"\]\s*\{\s*background:\s*#4285f4;\s*\}/);
});
test("drive management exposes stop task controls", () => {
assert.match(apiSource, /stopDriveTasks/);
assert.match(apiSource, /\/drives\/\$\{encodeURIComponent\(id\)\}\/tasks\/stop/);
@@ -186,6 +210,37 @@ test("drive management exposes stop task controls", () => {
assert.match(drivesPageSource, /停止所有网盘任务/);
});
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/);
assert.match(drivesPageSource, /当前存储有正在进行的任务,请稍后重试/);
assert.match(drivesPageSource, /function isDriveBusy\(d: api\.AdminDrive\)/);
assert.match(drivesPageSource, /d\.scanGenerationStatus/);
assert.match(drivesPageSource, /status\?\.state \|\| "idle"/);
assert.match(drivesPageSource, /scanningDriveIdsRef\.current\.has\(d\.id\)/);
assert.match(drivesPageSource, /if \(!resp\.accepted\)/);
assert.doesNotMatch(drivesPageSource, /disabled=\{!!scanningDriveId\}/);
});
test("nightly scan duplicate trigger uses full-scan busy message", () => {
assert.match(apiSource, /status:\s*NightlyJobStatus;\s*message\?:\s*string/);
assert.match(drivesPageSource, /当前有全量扫描任务正在进行,请稍后重试/);
assert.match(drivesPageSource, /resp\.message \|\| NIGHTLY_BUSY_MESSAGE/);
assert.match(constantsSource, /当前有全量扫描任务正在进行,请稍后重试/);
});
test("drive generation panel shows scan or crawler status first", () => {
assert.match(driveComponentsSource, /label=\{d\.kind === "spider91" \? "抓取" : "扫盘"\}/);
assert.match(driveComponentsSource, /status=\{d\.scanGenerationStatus\}/);
assert.match(driveComponentsSource, /showCounts=\{false\}/);
assert.match(driveComponentsSource, /label === "抓取" && state === "scanning" \? "抓取中"/);
assert.match(driveComponentsSource, /status\?\.scannedCount/);
assert.match(driveComponentsSource, /预计新增/);
assert.match(apiSource, /scannedCount:\s*number/);
assert.match(apiSource, /addedCount:\s*number/);
assert.match(constantsSource, /if \(state === "scanning"\) return "扫盘中"/);
});
test("drive detail selection is stored in the URL history", () => {
assert.match(drivesPageSource, /useSearchParams/);
assert.match(drivesPageSource, /searchParams\.get\("drive"\)/);
+33
View File
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const actionsSource = readFileSync(
new URL("../src/components/VideoActions.tsx", import.meta.url),
"utf8"
);
const detailCss = readFileSync(
new URL("../src/styles/video-detail.css", import.meta.url),
"utf8"
);
test("detail dislike does not locally decrement persisted likes", () => {
const match = /function handleDislike\(\) \{([\s\S]*?)\n return \(/.exec(
actionsSource
);
assert.ok(match, "handleDislike block should be present");
assert.match(match[1], /setDisliked\(true\)/);
assert.doesNotMatch(match[1], /setLikes/);
});
test("detail like and dislike buttons are visually separated", () => {
assert.doesNotMatch(actionsSource, /vd-actions__divider/);
assert.match(
detailCss,
/\.vd-actions__group\s*\{[^}]*gap:\s*var\(--space-2\)/s
);
assert.match(
detailCss,
/\.vd-actions__pill\s*\{[^}]*border:\s*1px solid var\(--border-subtle\)[^}]*border-radius:\s*var\(--radius-sm\)/s
);
});
+155
View File
@@ -0,0 +1,155 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const detailCss = readFileSync(
new URL("../src/styles/video-detail.css", import.meta.url),
"utf8"
);
const playerSource = readFileSync(
new URL("../src/components/VideoPlayer.tsx", import.meta.url),
"utf8"
);
const detailPageSource = readFileSync(
new URL("../src/pages/VideoDetailPage.tsx", import.meta.url),
"utf8"
);
test("detail player poster uses full-frame contain scaling", () => {
assert.match(
detailCss,
/\.video-player \.art-poster\s*\{[^}]*background-position:\s*center[^}]*background-repeat:\s*no-repeat[^}]*background-size:\s*contain/s
);
});
test("detail player does not keep playback resume state", () => {
assert.doesNotMatch(playerSource, /ResumePrompt/);
assert.doesNotMatch(playerSource, /PlaybackRecord/);
assert.doesNotMatch(playerSource, /PLAYBACK_KEY_PREFIX/);
assert.doesNotMatch(playerSource, /maybeOfferResume/);
assert.doesNotMatch(playerSource, /savePlaybackRecord/);
assert.doesNotMatch(playerSource, /clearPlaybackRecord/);
assert.doesNotMatch(playerSource, /video-player__resume/);
assert.doesNotMatch(detailCss, /video-player__resume/);
});
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"/);
assert.match(detailPageSource, /className="vd-skeleton__info"/);
assert.match(detailPageSource, /className="vd-rail vd-skeleton__rail"/);
assert.match(detailPageSource, /Array\.from\(\{ length: 6 \}\)/);
assert.doesNotMatch(detailPageSource, /className="vd-skeleton__meta"/);
assert.match(
detailCss,
/\.vd-skeleton__player\s*\{[^}]*aspect-ratio:\s*16 \/ 9[^}]*border-radius:\s*0/s
);
assert.match(
detailCss,
/\.vd-skeleton__summary,\s*\.vd-skeleton__info\s*\{[^}]*border:\s*1px solid var\(--border-default\)[^}]*border-radius:\s*var\(--radius-md\)/s
);
assert.match(
detailCss,
/\.vd-skeleton__rail-item\s*\{[^}]*grid-template-columns:\s*150px minmax\(0,\s*1fr\)/s
);
assert.doesNotMatch(
detailCss,
/\.vd-skeleton__player\s*\{[^}]*box-shadow:\s*var\(--shadow-lg\)/s
);
});
test("detail video title uses a restrained size", () => {
assert.match(
detailCss,
/\.vd-header__title\s*\{[^}]*font-size:\s*var\(--font-xl\)[^}]*line-height:\s*1\.34/s
);
assert.doesNotMatch(
detailCss,
/\.vd-header__title\s*\{[^}]*font-size:\s*var\(--font-2xl\)/s
);
assert.match(
detailCss,
/@media \(max-width:\s*480px\)\s*\{[\s\S]*\.vd-header__title\s*\{[^}]*font-size:\s*var\(--font-base\)/s
);
});
test("detail player uses custom mobile gestures instead of ArtPlayer native gestures", () => {
assert.match(playerSource, /gesture:\s*false/);
assert.match(playerSource, /fastForward:\s*false/);
assert.match(playerSource, /function bindMobilePlayerGestures/);
assert.match(playerSource, /let suppressNextClick = false/);
assert.match(playerSource, /endPress\(true\)/);
assert.match(playerSource, /event\.stopImmediatePropagation\(\)/);
assert.match(playerSource, /addEventListener\("click", handleClick, true\)/);
assert.match(playerSource, /state\.mode = "seek"/);
assert.match(playerSource, /state\.side === "right" \? "volume" : "brightness"/);
assert.doesNotMatch(playerSource, /function isPlayerLandscapeExpanded/);
assert.doesNotMatch(playerSource, /getEffectivePlayerOrientation\(art\) === "landscape"/);
assert.match(playerSource, /if \(!isPlayerExpanded\(art\)\) \{\s*resetGesture\(\);/);
assert.match(playerSource, /if \(!isPlayerExpanded\(art\)\) return;\s*onGestureHud\(seekGestureLabel/);
assert.match(playerSource, /const FAST_RATE_CLASS = "art-fast-rate-active"/);
assert.match(playerSource, /const FAST_RATE_HINT_CLASS = "video-player__art-rate-hint"/);
assert.match(playerSource, /const PLAYER_GESTURE_HUD_CLASS = "video-player__art-gesture-hud"/);
assert.match(playerSource, /setPlayerFastRateHint\(art, active\)/);
assert.match(playerSource, /player\.appendChild\(hint\)/);
assert.match(playerSource, /showPlayerGestureHud\(art, "volume", formatPercent\(normalized\)\)/);
assert.match(playerSource, /showPlayerGestureHud\(art, "brightness", formatBrightnessPercent\(nextBrightness\)\)/);
assert.match(playerSource, /stroke-width="1\.7"/);
assert.match(playerSource, /M15\.4 9\.2a4\.2 4\.2 0 0 1 0 5\.6/);
assert.match(playerSource, /M4\.8 9\.7h3l4\.3-3\.6v11\.8l-4\.3-3\.6h-3/);
assert.doesNotMatch(playerSource, /stroke-width="2\.2"/);
assert.doesNotMatch(playerSource, /onGestureHud\(`音量 /);
assert.doesNotMatch(playerSource, /onGestureHud\(`亮度 /);
assert.match(playerSource, /fullscreen:\s*true/);
assert.match(playerSource, /fullscreenWeb:\s*!enableOrientationControl/);
assert.doesNotMatch(playerSource, /addTextTrack\("captions", "Playback rate"/);
assert.doesNotMatch(playerSource, /new VTTCue\(/);
assert.doesNotMatch(playerSource, /onGestureHud\(`\$\{FAST_RATE\}x`/);
assert.match(playerSource, /addEventListener\("touchmove", handleTouchMove, \{ passive: false \}\)/);
});
test("detail player fullscreen long-press rate hint lives inside ArtPlayer", () => {
assert.match(
detailCss,
/\.video-player__rate-hint,\s*\.video-player__art-rate-hint\s*\{[\s\S]*position:\s*absolute[\s\S]*top:\s*12px/s
);
assert.match(
detailCss,
/\.video-player__art-rate-hint\s*\{[^}]*z-index:\s*130/s
);
assert.match(
detailCss,
/\.art-video-player\.art-fullscreen \.video-player__art-rate-hint,[\s\S]*\.art-video-player\.art-fullscreen-web \.video-player__art-rate-hint,[\s\S]*position:\s*fixed/s
);
});
test("detail player mobile brightness gesture only filters the video surface", () => {
assert.match(
detailCss,
/\.video-player \.art-video,\s*\.video-player \.art-poster\s*\{[^}]*filter:\s*brightness\(var\(--video-player-brightness, 1\)\)/s
);
assert.match(
detailCss,
/@media \(hover: none\) and \(pointer: coarse\)\s*\{[\s\S]*\.video-player \.art-video-player,[\s\S]*touch-action:\s*pan-y/s
);
assert.match(
detailCss,
/\.video-player \.art-video-player\.art-fullscreen,[\s\S]*\.video-player \.art-video-player\.art-fullscreen-web,[\s\S]*touch-action:\s*none/s
);
assert.match(
detailCss,
/\.video-player__art-gesture-hud\s*\{[^}]*top:\s*16%[^}]*background:\s*rgba\(18,\s*18,\s*20,\s*0\.8\)[^}]*font-size:\s*18px/s
);
assert.match(
detailCss,
/\.video-player__art-gesture-hud-icon\s*\{[^}]*width:\s*18px[^}]*height:\s*18px[^}]*transform:\s*translateY\(-1px\)/s
);
assert.match(
detailCss,
/\.video-player__art-gesture-hud-icon svg\s*\{[^}]*width:\s*18px[^}]*height:\s*18px/s
);
assert.match(
detailCss,
/\.art-video-player\.art-fullscreen \.video-player__art-gesture-hud,[\s\S]*\.art-video-player\.art-manual-orientation \.video-player__art-gesture-hud\s*\{[^}]*position:\s*fixed/s
);
});