mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 08:45:41 +08:00
refactor: rename teaser UI copy to preview video
This commit is contained in:
@@ -100,7 +100,7 @@ Important backend packages:
|
||||
- `internal/catalog`: SQLite catalog, schema migration, video metadata, settings, tags, drive records, generation status, and deduplication state. It opens SQLite with WAL and a busy timeout.
|
||||
- `internal/drives`: provider abstraction. Implementations include `quark`, `p115`, `pikpak`, `wopan`, `onedrive`, `localstorage`, `localupload`, and `spider91`.
|
||||
- `internal/scanner`: recursively lists drive directories, parses filenames/tags, upserts catalog videos, applies skip-directory rules, and enqueues newly discovered videos.
|
||||
- `internal/preview`: ffprobe/ffmpeg thumbnail and teaser generation workers. Generated assets are local files under the configured preview directory.
|
||||
- `internal/preview`: ffprobe/ffmpeg thumbnail and preview-video generation workers. Generated assets are local files under the configured preview directory.
|
||||
- `internal/fingerprint`: asynchronous sampled SHA-256 worker used for cross-drive duplicate detection.
|
||||
- `internal/proxy`: `/p/*` media serving. Some providers redirect with `302` to signed CDN URLs, while providers requiring backend-held headers are reverse-proxied with Range support.
|
||||
- `internal/api`: main API and admin API route handlers.
|
||||
@@ -131,5 +131,5 @@ Docker and installer deployments rewrite config paths so data lives under `/opt/
|
||||
|
||||
- Main-site API routes and proxy routes require authentication; only login/setup and `/api/settings/theme` are intentionally public.
|
||||
- When adding a new drive provider, implement `internal/drives.Drive`, persist any needed config through catalog/admin APIs, attach it in `cmd/server`, and decide whether `/p/stream` should redirect or reverse-proxy in `internal/proxy`.
|
||||
- Generated thumbnails and teasers are local runtime assets; do not treat them as source files.
|
||||
- Generated thumbnails and preview videos are local runtime assets; do not treat them as source files.
|
||||
- Frontend tests use Node's built-in test runner with `tsx`; TypeScript linting only checks `src` through the root `tsconfig.json`.
|
||||
|
||||
+9
-9
@@ -3,7 +3,7 @@
|
||||
视频聚合站的 Go 后端。提供三件事:
|
||||
|
||||
1. 多家网盘统一抽象(夸克 / 115 / PikPak / 联通沃盘 / OneDrive / Google Drive / 本地存储)
|
||||
2. 视频元数据目录(SQLite)+ 扫描 + teaser 预生成
|
||||
2. 视频元数据目录(SQLite)+ 扫描 + 预览视频预生成
|
||||
3. REST API(前台)+ 管理后台 + 直链代理
|
||||
4. 标签池、视频隐藏、按网盘统计和详情页来源网盘类型展示能力
|
||||
|
||||
@@ -24,7 +24,7 @@ internal/
|
||||
googledrive/ Google Drive(OpenList 在线续期 + Google Drive API;播放走后端代理)
|
||||
localstorage/ 本地目录扫描(服务器已有视频目录)
|
||||
scanner/ 扫目录 → 落库
|
||||
preview/ ffmpeg 抽封面和生成多段 teaser
|
||||
preview/ ffmpeg 抽封面和生成多段预览视频
|
||||
proxy/ /p/stream/*、/p/preview/* 代理
|
||||
auth/ 管理员 session
|
||||
api/ REST 路由
|
||||
@@ -81,7 +81,7 @@ go run ./cmd/server 后端 9192
|
||||
|
||||
## 添加一个盘
|
||||
|
||||
推荐在前端管理后台 `/admin/drives` 新增网盘。保存后会立即挂载并触发扫描;视频结果可在 `/admin/videos` 按网盘查看,每页 100 条,页面会同时显示各网盘 Teaser 已生成、待生成、失败数量。
|
||||
推荐在前端管理后台 `/admin/drives` 新增网盘。保存后会立即挂载并触发扫描;视频结果可在 `/admin/videos` 按网盘查看,每页 100 条,页面会同时显示各网盘预览视频已生成、待生成、失败数量。
|
||||
|
||||
也可以直接调用后端接口:
|
||||
|
||||
@@ -149,18 +149,18 @@ Google Drive 按 OpenList 在线 API 调用 `https://api.oplist.org/googleui/ren
|
||||
|
||||
`sampled_sha256` 是文件级去重:适合识别同一个视频文件被复制到 115 / PikPak / OneDrive 等不同网盘的情况。它不会删除任何网盘文件,也不用于识别转码、裁剪、加水印后的同源视频。
|
||||
|
||||
封面和 teaser 仍然优先生成,不等待指纹完成。夜间流水线最后会做一次重复资产清理:对 `size_bytes + sampled_sha256` 命中的非 canonical 视频,只删除本机生成的重复封面和 teaser,并把对应字段重置为 `pending`。网盘原文件和视频元数据记录不会被删除;如果 canonical 视频以后被移除,这些重复项会重新进入生成队列。
|
||||
封面和预览视频仍然优先生成,不等待指纹完成。夜间流水线最后会做一次重复资产清理:对 `size_bytes + sampled_sha256` 命中的非 canonical 视频,只删除本机生成的重复封面和预览视频,并把对应字段重置为 `pending`。网盘原文件和视频元数据记录不会被删除;如果 canonical 视频以后被移除,这些重复项会重新进入生成队列。
|
||||
|
||||
## 管理能力
|
||||
|
||||
- `/admin/drives`:新增、编辑、删除网盘,触发扫描。
|
||||
- `/admin/videos`:按网盘筛选视频,每页 100 条分页,查看各网盘 Teaser 统计,编辑标题/作者/分类/标签,单条或全量重生 teaser。
|
||||
- `/admin/videos`:按网盘筛选视频,每页 100 条分页,查看各网盘预览视频统计,编辑标题/作者/分类/标签,单条或全量重生预览视频。
|
||||
- `/admin/tags`:新增标签并用内置规则自动匹配已有视频;删除非系统标签时会从所有视频上同步移除该标签。
|
||||
- 播放页视频信息会展示来源网盘类型;同时提供“不再展示”,点击后会把视频标记为全局隐藏。隐藏视频不会再出现在首页、列表、搜索、相关推荐和详情接口中。目前没有管理后台恢复入口,如需恢复可把数据库里对应视频的 `hidden` 字段改回 `0`。
|
||||
|
||||
## Teaser 生成
|
||||
## 预览视频生成
|
||||
|
||||
scanner 扫到新视频会把 `(driveID, videoID)` 丢进 worker 队列。worker 会先用 `ffprobe` 探测时长,再用 `ffmpeg` 抽封面和生成无声 teaser:
|
||||
scanner 扫到新视频会把 `(driveID, videoID)` 丢进 worker 队列。worker 会先用 `ffprobe` 探测时长,再用 `ffmpeg` 抽封面和生成无声预览视频:
|
||||
|
||||
```
|
||||
ffmpeg -ss <起点> -headers "UA/Cookie/Referer" -i <直链> \
|
||||
@@ -168,9 +168,9 @@ ffmpeg -ss <起点> -headers "UA/Cookie/Referer" -i <直链> \
|
||||
-movflags +faststart -y <local>.mp4
|
||||
```
|
||||
|
||||
当前策略是每段固定 3 秒;30 秒以下最多 3 段,30 秒及以上固定 4 段;长视频在 20% 到 80% 区间均匀取段。生成的 teaser 和封面都只保存在本地 `data/previews/`,不会回写到网盘;旧数据中的 `preview_file_id` 会被忽略。
|
||||
当前策略是每段固定 3 秒;30 秒以下最多 3 段,30 秒及以上固定 4 段;长视频在 20% 到 80% 区间均匀取段。生成的预览视频和封面都只保存在本地 `data/previews/`,不会回写到网盘;旧数据中的 `preview_file_id` 会被忽略。
|
||||
|
||||
服务启动或网盘重新挂载时,如果 Teaser 开关已开启,后端会把历史 `pending` 任务重新入队,避免重启后长期停在“待生成”。OneDrive 扫盘和直链生成 teaser / 封面时可能触发 Microsoft Graph 429、`TooManyRequests`、`activityLimitReached` 或 throttled 文本;后端会识别这类错误并让当前网盘进入冷却期,保留任务为 `pending`,避免连续请求触发更严重限流。扫盘阶段会按 `Retry-After` 或默认冷却时间等待后继续当前目录。
|
||||
服务启动或网盘重新挂载时,如果预览视频开关已开启,后端会把历史 `pending` 任务重新入队,避免重启后长期停在“待生成”。OneDrive 扫盘和直链生成预览视频 / 封面时可能触发 Microsoft Graph 429、`TooManyRequests`、`activityLimitReached` 或 throttled 文本;后端会识别这类错误并让当前网盘进入冷却期,保留任务为 `pending`,避免连续请求触发更严重限流。扫盘阶段会按 `Retry-After` 或默认冷却时间等待后继续当前目录。
|
||||
|
||||
前端卡片的 `previewSrc` 统一指向 `/p/preview/<videoID>`,后端只从本地 `preview_local` 文件读取。
|
||||
|
||||
|
||||
+15
-15
@@ -199,7 +199,7 @@ func main() {
|
||||
return app.driveGenerationStatuses()
|
||||
},
|
||||
OnTeaserEnabledChanged: func(driveID string, enabled bool) {
|
||||
// 从关到开时立刻补扫该盘 pending teaser,行为对齐旧的"全局开关从关到开"。
|
||||
// 从关到开时立刻补扫该盘 pending 预览视频,行为对齐旧的"全局开关从关到开"。
|
||||
// 关闭分支不需要做事 —— 入队前会重新查 catalog,新的 enqueue 自然停。
|
||||
if !enabled {
|
||||
return
|
||||
@@ -242,8 +242,8 @@ func main() {
|
||||
mountFrontend(r)
|
||||
|
||||
// 凌晨流水线:每天 cron_hour 触发一次,串行跑
|
||||
// Phase 1 扫所有非 spider91 / localupload 网盘 + 删除检测 + 入队封面/teaser
|
||||
// Phase 2 spider91 爬虫 + 入队 teaser
|
||||
// Phase 1 扫所有非 spider91 / localupload 网盘 + 删除检测 + 入队封面/预览视频
|
||||
// Phase 2 spider91 爬虫 + 入队预览视频
|
||||
// Phase 3 spider91 → 云盘迁移
|
||||
// 也响应 admin "扫描所有网盘" 按钮(POST /admin/api/jobs/nightly/run → TriggerNow)。
|
||||
app.nightlyRunner = nightly.New(nightly.Config{
|
||||
@@ -337,9 +337,9 @@ type App struct {
|
||||
fingerprintQueueing map[string]bool
|
||||
}
|
||||
|
||||
// teaserEnabledForDrive 查询某个 drive 当前的 per-drive teaser 开关。
|
||||
// teaserEnabledForDrive 查询某个 drive 当前的 per-drive 预览视频开关。
|
||||
//
|
||||
// teaser 生成不再由全局 setting 控制,而是由 catalog.drives.teaser_enabled
|
||||
// 预览视频生成不再由全局 setting 控制,而是由 catalog.drives.teaser_enabled
|
||||
// 决定。任何"是否入队 preview worker"的判断都应通过这个方法读,避免把状态
|
||||
// 散落到 App 内存里和 DB 不一致。
|
||||
//
|
||||
@@ -872,10 +872,10 @@ func (a *App) attachSpider91Crawler(d *catalog.Drive, drv *spider91.Driver) {
|
||||
WorkDir: filepath.Dir(scriptPath),
|
||||
CommonThumbDir: a.commonThumbsDir(),
|
||||
ProxyURL: proxyURL,
|
||||
// 新流程:teaser 不在每条视频入库时立即入队,而是 RunOnce 全部下完后由
|
||||
// 新流程:预览视频不在每条视频入库时立即入队,而是 RunOnce 全部下完后由
|
||||
// runSpider91Crawl 统一调 enqueueDriveGeneration 一次性入队。这样:
|
||||
// - 下载阶段不和 ffmpeg 抢 CPU/IO
|
||||
// - "等待 teaser 队列 idle" 在 nightly Phase 2 的语义上更直观
|
||||
// - "等待预览视频队列 idle" 在 nightly Phase 2 的语义上更直观
|
||||
// 不再传 OnNewVideo(crawler 内部的回调字段保留,仅为单测计数器之用)。
|
||||
})
|
||||
|
||||
@@ -961,7 +961,7 @@ func (a *App) enqueuePending(ctx context.Context, driveID string, w *preview.Wor
|
||||
|
||||
func (a *App) enqueueDriveGeneration(ctx context.Context, driveID string, worker *preview.Worker, thumbWorker *preview.ThumbWorker) {
|
||||
// 封面 worker 始终入队(与早期"全局 preview.enabled=false 时仍然生成封面"
|
||||
// 的行为一致);teaser worker 仅在该 drive 的 TeaserEnabled 为 true 时入队。
|
||||
// 的行为一致);预览视频 worker 仅在该 drive 的 TeaserEnabled 为 true 时入队。
|
||||
// 两条队列互不等待,避免封面批量生成拖住预览视频生成。
|
||||
if thumbWorker != nil {
|
||||
a.enqueueThumbnails(ctx, driveID, thumbWorker)
|
||||
@@ -1689,7 +1689,7 @@ func (a *App) regenFailedPreviews(ctx context.Context, driveID string) {
|
||||
}
|
||||
|
||||
// regenFailedThumbnails 把某 drive 下 thumbnail_status=failed 的视频全部重置为
|
||||
// pending 并重新入队封面 worker。与 regenFailedPreviews 行为对称:那条管 teaser,
|
||||
// pending 并重新入队封面 worker。与 regenFailedPreviews 行为对称:那条管预览视频,
|
||||
// 这条管封面图(两个 worker 是独立队列)。
|
||||
//
|
||||
// 操作不会触发已生成失败的视频重新去网盘取流 —— 只是把 catalog 的状态翻到 pending
|
||||
@@ -1806,10 +1806,10 @@ func (a *App) listSpider91DriveIDs(ctx context.Context) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// waitAllPreviewQueuesIdle 阻塞直到所有 drive 的封面 worker 和 teaser worker
|
||||
// waitAllPreviewQueuesIdle 阻塞直到所有 drive 的封面 worker 和预览视频 worker
|
||||
// 队列都为空且无 in-flight 任务。
|
||||
//
|
||||
// 顺序:先等所有 thumb worker,再等所有 teaser。两个队列生成时互不等待;
|
||||
// 顺序:先等所有 thumb worker,再等所有预览视频。两个队列生成时互不等待;
|
||||
// nightly 只在 phase 边界统一等待它们都 drain。
|
||||
// 若 ctx 在等待中被取消(软超时 / shutdown),立即返回 ctx.Err。
|
||||
func (a *App) waitAllPreviewQueuesIdle(ctx context.Context) error {
|
||||
@@ -1909,10 +1909,10 @@ func (a *App) runSpider91Crawl(ctx context.Context, driveID string) {
|
||||
log.Printf("[spider91] drive=%s update last_crawl_at: %v", driveID, err)
|
||||
}
|
||||
|
||||
// 爬取全部完成后,统一把所有还 pending 的 teaser 入队。
|
||||
// 这是新流水线设计:crawler 自身不再每条入库就立即触发 teaser 生成,
|
||||
// 让"下载阶段"和"teaser 阶段"在时间上分清楚(也跟 nightly Phase 2
|
||||
// 的"等 teaser 队列 idle"语义对齐)。enqueueDriveGeneration 内部会读
|
||||
// 爬取全部完成后,统一把所有还 pending 的预览视频入队。
|
||||
// 这是新流水线设计:crawler 自身不再每条入库就立即触发预览视频生成,
|
||||
// 让"下载阶段"和"预览视频阶段"在时间上分清楚(也跟 nightly Phase 2
|
||||
// 的"等预览视频队列 idle"语义对齐)。enqueueDriveGeneration 内部会读
|
||||
// 该 drive 当前的 teaser_enabled,关闭时是 noop。
|
||||
a.mu.Lock()
|
||||
worker := a.workers[driveID]
|
||||
|
||||
@@ -22,7 +22,7 @@ server:
|
||||
storage:
|
||||
# SQLite 数据库文件路径
|
||||
db_path: "./data/video-site.db"
|
||||
# 本地 teaser 和封面目录
|
||||
# 本地预览视频和封面目录
|
||||
local_preview_dir: "./data/previews"
|
||||
|
||||
scanner:
|
||||
@@ -38,24 +38,24 @@ scanner:
|
||||
nightly:
|
||||
# 凌晨流水线触发整点(0-23),默认 1 即每天 01:00。流程:
|
||||
# Phase 1 扫所有非 spider91 / 非 localupload 网盘 → 检测新增 / 删除
|
||||
# → 入队封面和 teaser → 等所有队列 idle
|
||||
# Phase 2 spider91 爬虫(如配置)→ 入队 teaser → 等队列 idle
|
||||
# → 入队封面和预览视频 → 等所有队列 idle
|
||||
# Phase 2 spider91 爬虫(如配置)→ 入队预览视频 → 等队列 idle
|
||||
# Phase 3 spider91 → 云盘迁移(一次性 sweep)
|
||||
cron_hour: 1
|
||||
# 单次流水线总耗时上限(软超时);超过后当前 phase 跑完不启动后续 phase。
|
||||
max_duration: 6h
|
||||
|
||||
preview:
|
||||
# 是否启用 ffmpeg 抽帧生成 teaser
|
||||
# 是否启用 ffmpeg 抽帧生成预览视频
|
||||
enabled: true
|
||||
# ffmpeg / ffprobe 可执行文件名或绝对路径
|
||||
ffmpeg_path: "ffmpeg"
|
||||
ffprobe_path: "ffprobe"
|
||||
# teaser 每段时长(秒),实际生成时每段最多 3 秒
|
||||
# 预览视频每段时长(秒),实际生成时每段最多 3 秒
|
||||
duration_seconds: 3
|
||||
# 兼容旧配置;当前 30 秒以下最多 3 段,30 秒及以上固定 4 段
|
||||
segments: 3
|
||||
# teaser 视频宽度
|
||||
# 预览视频宽度
|
||||
width: 480
|
||||
|
||||
# 盘列表。上线后请通过管理后台添加,本文件可留空。
|
||||
|
||||
@@ -39,7 +39,7 @@ type AdminServer struct {
|
||||
SetupRequired func() bool
|
||||
// OnSetup 持久化首次部署时设置的管理员账号密码,并更新运行中认证器。
|
||||
OnSetup func(username, password string) error
|
||||
// LocalPreviewDir is the local directory that stores generated teasers and thumbs.
|
||||
// LocalPreviewDir is the local directory that stores generated preview videos and thumbs.
|
||||
LocalPreviewDir string
|
||||
// Hooks:外层注入实际执行者
|
||||
OnDriveSaved func(driveID string) error
|
||||
@@ -52,8 +52,8 @@ type AdminServer struct {
|
||||
OnRegenFailedThumbnails func(driveID string)
|
||||
OnRegenFailedFingerprints func(driveID string)
|
||||
GetDriveGenerationStatuses func() map[string]DriveGenerationStatuses
|
||||
// OnTeaserEnabledChanged 在 per-drive teaser 开关被切换后调用。
|
||||
// enabled=true 时上层应该重新把 pending teaser 入队(类似旧的全局开关从关到开);
|
||||
// OnTeaserEnabledChanged 在 per-drive 预览视频开关被切换后调用。
|
||||
// enabled=true 时上层应该重新把 pending 预览视频入队(类似旧的全局开关从关到开);
|
||||
// enabled=false 时通常不用做事 —— worker 入队前会再次查 catalog,自然停止。
|
||||
OnTeaserEnabledChanged func(driveID string, enabled bool)
|
||||
// Theme 读写("dark" | "pink")
|
||||
@@ -396,7 +396,7 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) {
|
||||
Status string `json:"status"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
HasCredential bool `json:"hasCredential"`
|
||||
// TeaserEnabled 控制是否给本盘生成 teaser/封面。前端用它在网盘列表/编辑表单展示开关状态。
|
||||
// TeaserEnabled 控制是否给本盘生成预览视频/封面。前端用它在网盘列表/编辑表单展示开关状态。
|
||||
TeaserEnabled bool `json:"teaserEnabled"`
|
||||
// SkipDirIDs 是用户在 admin 配置的"扫描跳过目录"集合(drive 侧目录 fileID)。
|
||||
// 前端用它在"设置跳过目录"弹窗里回显已选项;JSON 字段名 camelCase 与
|
||||
@@ -491,7 +491,7 @@ type upsertDriveReq struct {
|
||||
// Deprecated: 扫描起点已固定为 rootId;保留字段只为兼容旧客户端请求体。
|
||||
ScanRootID string `json:"scanRootId"`
|
||||
Credentials map[string]string `json:"credentials"`
|
||||
// TeaserEnabled 是 per-drive teaser/封面生成开关。
|
||||
// TeaserEnabled 是 per-drive 预览视频/封面生成开关。
|
||||
// 用 *bool 区分 "未传" / "传了 false":未传时表示客户端不打算改这个字段,
|
||||
// 沿用 catalog 现有值;新建时未传一律默认开启(true)。
|
||||
TeaserEnabled *bool `json:"teaserEnabled,omitempty"`
|
||||
@@ -738,11 +738,11 @@ type teaserEnabledReq struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// handleSetDriveTeaserEnabled 切换某盘的 teaser 生成开关。
|
||||
// handleSetDriveTeaserEnabled 切换某盘的预览视频生成开关。
|
||||
//
|
||||
// 行为:
|
||||
// - 写 catalog.drives.teaser_enabled
|
||||
// - 调 OnTeaserEnabledChanged(main 注入;从关到开时会重新入队 pending teaser)
|
||||
// - 调 OnTeaserEnabledChanged(main 注入;从关到开时会重新入队 pending 预览视频)
|
||||
// - 返回切换后的新值,方便前端乐观更新但又能以服务端为准
|
||||
//
|
||||
// 与 upsertDrive 的区别:那条接口要重传 kind / name / rootId 等,开关切换不该
|
||||
@@ -1027,7 +1027,7 @@ func (a *AdminServer) handleRegenFailedPreviews(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
|
||||
// handleRegenFailedThumbnails 触发某 drive 下所有 thumbnail_status=failed 的封面
|
||||
// 重新入队生成。和 handleRegenFailedPreviews 行为对称(一个管 teaser,一个管封面)。
|
||||
// 重新入队生成。和 handleRegenFailedPreviews 行为对称(一个管预览视频,一个管封面)。
|
||||
//
|
||||
// 立即返回 202;实际执行在后台 goroutine 跑,状态可在下次 GET /admin/api/drives
|
||||
// 的 thumbnailFailedCount / thumbnailGenerationStatus 看变化。
|
||||
@@ -1040,7 +1040,7 @@ func (a *AdminServer) handleRegenFailedThumbnails(w http.ResponseWriter, r *http
|
||||
}
|
||||
|
||||
// handleRegenFailedFingerprints triggers regeneration for all failed sampled
|
||||
// fingerprints on a drive. It mirrors the failed teaser/thumbnail retry endpoints.
|
||||
// fingerprints on a drive. It mirrors the failed preview-video/thumbnail retry endpoints.
|
||||
func (a *AdminServer) handleRegenFailedFingerprints(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if a.OnRegenFailedFingerprints != nil {
|
||||
@@ -1054,7 +1054,7 @@ func (a *AdminServer) handleRegenFailedFingerprints(w http.ResponseWriter, r *ht
|
||||
// settingsDTO 是 GET/PUT /admin/api/settings 的入参/出参。
|
||||
//
|
||||
// 注意:早期的全局 previewEnabled 字段已经下沉为每盘 teaser_enabled,
|
||||
// 不再出现在这里;前端要切换某个盘的 teaser 生成请用 POST /admin/api/drives 上传
|
||||
// 不再出现在这里;前端要切换某个盘的预览视频生成请用 POST /admin/api/drives 上传
|
||||
// teaserEnabled 字段。保留 settings 用作主题、spider91 上传目标这类全局配置。
|
||||
type settingsDTO struct {
|
||||
Theme string `json:"theme"`
|
||||
|
||||
@@ -546,7 +546,7 @@ func (c *Catalog) ListVideosByThumbnailStatus(ctx context.Context, driveID, stat
|
||||
// Besides missing thumbnails, this includes videos with an existing thumbnail but
|
||||
// missing duration metadata, because the thumbnail worker probes duration while
|
||||
// it already has a stream link.
|
||||
// Failed thumbnails are reported separately and should not block teaser generation.
|
||||
// Failed thumbnails are reported separately and should not block preview-video generation.
|
||||
// Videos whose local assets were cleared because they are fingerprint duplicates
|
||||
// stay pending in the DB, but uniqueVideoWhereSQL keeps them out of this queue
|
||||
// while their canonical sibling still exists.
|
||||
@@ -1372,7 +1372,7 @@ func (c *Catalog) ListLocalMediaRefs(ctx context.Context) ([]LocalMediaRef, erro
|
||||
|
||||
// DuplicateAssetCleanupCandidate points at a non-canonical video in a
|
||||
// size+sampled_sha256 duplicate group that still owns generated local assets.
|
||||
// The cleanup job uses this to remove duplicate thumbnails/teasers without
|
||||
// The cleanup job uses this to remove duplicate thumbnails/preview videos without
|
||||
// touching the original cloud file or deleting the catalog row.
|
||||
type DuplicateAssetCleanupCandidate struct {
|
||||
VideoID string
|
||||
@@ -1500,7 +1500,7 @@ type Drive struct {
|
||||
Credentials map[string]string `json:"credentials,omitempty"`
|
||||
Status string `json:"status"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
// TeaserEnabled 控制是否给本盘生成 teaser/封面。
|
||||
// TeaserEnabled 控制是否给本盘生成预览视频/封面。
|
||||
// 替代早期的全局 preview.enabled 开关;新建 drive 时 UpsertDrive 默认置 true。
|
||||
TeaserEnabled bool `json:"teaserEnabled"`
|
||||
// SkipDirIDs 是用户在管理后台为该盘选定的"扫描跳过目录"集合(网盘侧的目录 fileID)。
|
||||
@@ -1633,7 +1633,7 @@ func (c *Catalog) DeleteDrive(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// SetDriveTeaserEnabled 切换某盘的 teaser/封面生成开关。
|
||||
// SetDriveTeaserEnabled 切换某盘的预览视频/封面生成开关。
|
||||
//
|
||||
// 与 UpsertDrive 的区别:只动 teaser_enabled + updated_at 一列,不要求调用方
|
||||
// 重传 kind / name / credentials 等容易踩坑的字段。
|
||||
|
||||
@@ -19,8 +19,8 @@ CREATE TABLE IF NOT EXISTS videos (
|
||||
thumbnail_url TEXT,
|
||||
thumbnail_status TEXT DEFAULT 'pending', -- pending / ready / failed / skipped
|
||||
thumbnail_failures INTEGER DEFAULT 0, -- consecutive transient thumbnail generation failures
|
||||
preview_file_id TEXT, -- deprecated: 旧版回写网盘后的 teaser file id
|
||||
preview_local TEXT, -- 本地 teaser 路径(兜底)
|
||||
preview_file_id TEXT, -- deprecated: 旧版回写网盘后的预览视频 file id
|
||||
preview_local TEXT, -- 本地预览视频路径(兜底)
|
||||
preview_status TEXT DEFAULT 'pending', -- pending / ready / failed
|
||||
views INTEGER DEFAULT 0,
|
||||
favorites INTEGER DEFAULT 0,
|
||||
@@ -80,7 +80,7 @@ CREATE TABLE IF NOT EXISTS drives (
|
||||
credentials TEXT, -- JSON: cookie / refresh_token 等
|
||||
status TEXT DEFAULT 'disconnected', -- disconnected / ok / error
|
||||
last_error TEXT,
|
||||
-- 是否给该盘生成 teaser/封面:1 开 / 0 关。
|
||||
-- 是否给该盘生成预览视频/封面:1 开 / 0 关。
|
||||
-- 替代了早期的全局 preview.enabled 设置(保留旧 setting 行不再读)。
|
||||
teaser_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
-- 扫描时要跳过的目录 ID 集合(JSON array of string)。命中其中任意一个的目录及其
|
||||
|
||||
@@ -66,10 +66,10 @@ func (c *Catalog) migrate(ctx context.Context) error {
|
||||
if err := c.addColumnIfMissing(ctx, "videos", "thumbnail_failures", "INTEGER DEFAULT 0"); err != nil {
|
||||
return err
|
||||
}
|
||||
// drives.teaser_enabled:每盘 teaser 开关,替代旧的全局 preview.enabled。
|
||||
// drives.teaser_enabled:每盘预览视频开关,替代旧的全局 preview.enabled。
|
||||
// 升级路径:直接让 ALTER TABLE 的 DEFAULT 1 兜底 —— 每个现存 drive 都默认开启,
|
||||
// 不读旧的 settings.preview.enabled 字段。这样老用户即便之前关过全局开关,
|
||||
// 升级后所有盘也都恢复"默认生成 teaser",跟新建保持一致。
|
||||
// 升级后所有盘也都恢复"默认生成预览视频",跟新建保持一致。
|
||||
if _, err := c.addColumnIfMissingReportNew(ctx, "drives", "teaser_enabled", "INTEGER NOT NULL DEFAULT 1"); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -193,7 +193,7 @@ func (c *Catalog) addColumnIfMissingReportNew(ctx context.Context, table, column
|
||||
// 设为 1(开启),但仅在历史上没跑过这条迁移时执行(用 marker setting 记号)。
|
||||
//
|
||||
// 为什么需要:早期短暂存在过的版本会从旧的全局 preview.enabled = "0" 同步到
|
||||
// 所有 drive 的 teaser_enabled = 0;用户报告升级后页面全显示"Teaser 关"。新版
|
||||
// 所有 drive 的 teaser_enabled = 0;用户报告升级后页面全显示"预览视频关"。新版
|
||||
// 约定 per-drive 默认开启,所以这里跑一次性修正。
|
||||
//
|
||||
// 幂等保证:marker setting 设过了就不再跑,确保用户在 UI 里把某盘关了不会被
|
||||
|
||||
@@ -30,7 +30,7 @@ type Drive interface {
|
||||
StreamURL(ctx context.Context, fileID string) (*StreamLink, error)
|
||||
|
||||
// Upload 把本地流写入指定目录,返回新文件 fileID。
|
||||
// 当前 teaser 和封面只保存在本地,不再通过该方法写回网盘。
|
||||
// 当前预览视频和封面只保存在本地,不再通过该方法写回网盘。
|
||||
Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error)
|
||||
|
||||
// EnsureDir 保证指定路径存在(相对根目录),返回最终目录 fileID。
|
||||
|
||||
@@ -263,7 +263,7 @@ func (d *Driver) findChildDir(ctx context.Context, parent, name string) (string,
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// ---------- 上传(第一版不实现,走本地 teaser 兜底) ----------
|
||||
// ---------- 上传(第一版不实现,走本地预览视频兜底) ----------
|
||||
|
||||
func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) {
|
||||
return "", drives.ErrNotSupported
|
||||
|
||||
@@ -61,7 +61,7 @@ type CrawlerConfig struct {
|
||||
// DownloadTimeout 限制单条视频/封面下载的耗时。
|
||||
DownloadTimeout time.Duration
|
||||
|
||||
// OnNewVideo 是新视频成功入库后的回调,用于触发 teaser worker。
|
||||
// OnNewVideo 是新视频成功入库后的回调,用于触发预览视频 worker。
|
||||
OnNewVideo func(v *catalog.Video)
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ type spiderVideoEntry struct {
|
||||
// 3. Go 端 bufio.Scanner 按行读:每行立即下载视频和封面、入库。
|
||||
// 这样 "Python 翻页找下一个" 与 "Go 下载当前一个" 在时间上重叠,缩短整轮耗时;
|
||||
// 更重要的是不会让前几个下载耽误后面签名链接 e= 过期。
|
||||
// 4. 全部消费完 + 子进程退出 → 返回 CrawlResult。teaser 不在此处入队,
|
||||
// 4. 全部消费完 + 子进程退出 → 返回 CrawlResult。预览视频不在此处入队,
|
||||
// 由调用方 (App.runSpider91Crawl) 在 RunOnce 后统一调 enqueueDriveGeneration。
|
||||
//
|
||||
// targetNew <= 0 会被规范化成 spider91DefaultTargetNew(15)。
|
||||
|
||||
@@ -138,7 +138,7 @@ func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error)
|
||||
|
||||
// StreamURL 返回本地视频文件路径,给 ffmpeg / 上层服务使用。
|
||||
// 注意:proxy.serve 不能直接处理本地路径,回放要走 api.handleSpider91Video。
|
||||
// teaser/封面 worker 通过 localPreviewLink 兜底走本地文件,刚好兼容 path 形式的 URL。
|
||||
// 预览视频/封面 worker 通过 localPreviewLink 兜底走本地文件,刚好兼容 path 形式的 URL。
|
||||
func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) {
|
||||
path, err := d.VideoPath(fileID)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
// "扫描所有网盘"):
|
||||
//
|
||||
// Phase 1: for each non-spider91 cloud drive
|
||||
// scan + delete-detection + enqueue thumb + enqueue teaser
|
||||
// wait until all thumb / teaser queues are idle
|
||||
// scan + delete-detection + enqueue thumb + enqueue preview video
|
||||
// wait until all thumb / preview-video queues are idle
|
||||
// Phase 2: if any spider91 drive configured
|
||||
// crawl + enqueue teaser for new videos
|
||||
// wait until teaser queues are idle
|
||||
// crawl + enqueue preview video for new videos
|
||||
// wait until preview-video queues are idle
|
||||
// Phase 3: spider91 → cloud migration (single sweep, captcha cooldown still
|
||||
// honored within this call)
|
||||
// Phase 4: cleanup duplicate local preview/thumbnail assets after sampled
|
||||
@@ -76,10 +76,10 @@ type Config struct {
|
||||
ListSpider91Drives func(ctx context.Context) []string
|
||||
|
||||
// RunSpider91Crawl synchronously runs one crawl cycle (downloads + thumbs +
|
||||
// teaser enqueue) for a single spider91 drive.
|
||||
// preview-video enqueue) for a single spider91 drive.
|
||||
RunSpider91Crawl func(ctx context.Context, driveID string)
|
||||
|
||||
// WaitPreviewQueuesIdle blocks until both the thumbnail and teaser queues
|
||||
// WaitPreviewQueuesIdle blocks until both the thumbnail and preview-video queues
|
||||
// across all drives are drained (queue empty + no in-flight task). It must
|
||||
// honor ctx cancellation.
|
||||
WaitPreviewQueuesIdle func(ctx context.Context) error
|
||||
|
||||
@@ -26,10 +26,10 @@ import (
|
||||
type Config struct {
|
||||
FFmpegPath string
|
||||
FFprobePath string
|
||||
DurationSeconds int // 兼容旧配置;当前 teaser 每段固定 3 秒
|
||||
DurationSeconds int // 兼容旧配置;当前预览视频每段固定 3 秒
|
||||
Width int
|
||||
Segments int // 兼容旧配置;当前 30 秒及以上视频固定使用 4 段
|
||||
LocalDir string // 本地 teaser 和封面目录
|
||||
LocalDir string // 本地预览视频和封面目录
|
||||
}
|
||||
|
||||
type Generator struct {
|
||||
@@ -236,7 +236,7 @@ func appendUniqueStart(starts []float64, start, eachSec float64) []float64 {
|
||||
return append(starts, start)
|
||||
}
|
||||
|
||||
// thumbnailOffsets 选封面抽帧的时间点(秒)。独立于 teaser。
|
||||
// thumbnailOffsets 选封面抽帧的时间点(秒)。独立于预览视频。
|
||||
// 默认取视频中间帧;时长未知时退回早期帧。
|
||||
func thumbnailOffsets(duration float64) []float64 {
|
||||
if duration <= 0 {
|
||||
@@ -383,9 +383,9 @@ func (g *Generator) Probe(ctx context.Context, link *drives.StreamLink) (float64
|
||||
return strconv.ParseFloat(raw, 64)
|
||||
}
|
||||
|
||||
// --- Teaser ---
|
||||
// --- 预览视频 ---
|
||||
|
||||
// Generate 拉取 teaser 到本地临时文件,返回路径。
|
||||
// Generate 拉取预览视频到本地临时文件,返回路径。
|
||||
// 根据 Config.Segments 和视频时长决定是单段还是多段拼接。
|
||||
func (g *Generator) Generate(ctx context.Context, link *drives.StreamLink, duration float64) (string, error) {
|
||||
return g.generate(ctx, duration, func(int) (*drives.StreamLink, error) {
|
||||
@@ -1506,7 +1506,7 @@ func driveErrorShouldCooldown(d drives.Drive, err error) bool {
|
||||
strings.Contains(text, "request has been blocked") ||
|
||||
strings.Contains(text, "访问被阻断")
|
||||
case "pikpak":
|
||||
// PikPak 在 teaser / 封面生成阶段(取链或拉直链字节)可能命中:
|
||||
// PikPak 在预览视频 / 封面生成阶段(取链或拉直链字节)可能命中:
|
||||
// - error_code=10 操作频繁
|
||||
// - HTTP 429 / 5xx / 509 限流和服务端不可用
|
||||
// - 通用文本:rate limit / too many requests / blocked
|
||||
@@ -1729,7 +1729,7 @@ func (w *Worker) process(ctx context.Context, v *catalog.Video) {
|
||||
}
|
||||
}
|
||||
|
||||
// 2) teaser
|
||||
// 2) 预览视频
|
||||
tmp, err := w.generateTeaser(ctx, v, link, duration)
|
||||
if err != nil {
|
||||
if w.pauseForRecoverableError(err, "generate", v.Title) {
|
||||
|
||||
@@ -216,7 +216,7 @@ func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, link *drives.Strea
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
// ServeLocal 服务本地 teaser 文件
|
||||
// ServeLocal 服务本地预览视频文件
|
||||
func (p *Proxy) ServeLocal(w http.ResponseWriter, r *http.Request, path string) {
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ type Scanner struct {
|
||||
//
|
||||
// nil / 空集合 → 行为等同于不跳过任何目录。
|
||||
SkipDirIDs map[string]struct{}
|
||||
// 回调:新视频被加入后触发 teaser 生成
|
||||
// 回调:新视频被加入后触发预览视频生成
|
||||
OnNewVideo func(v *catalog.Video)
|
||||
// ProgressInterval 控制扫描内部 heartbeat 的最小输出间隔。
|
||||
// 0 → 默认 30s;< 0 → 关闭 heartbeat(仅留外层 start / done 两行)。
|
||||
@@ -128,7 +128,7 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir {
|
||||
// 跳过 previews 目录,避免扫到自己生成的 teaser
|
||||
// 跳过 previews 目录,避免扫到自己生成的预览视频
|
||||
if strings.EqualFold(e.Name, "previews") {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -307,7 +307,7 @@ export function DrivesPage() {
|
||||
setRegenFailedId(d.id);
|
||||
try {
|
||||
await api.regenFailedPreviews(d.id);
|
||||
show("已触发失败 teaser 重新生成", "success");
|
||||
show("已触发失败预览视频重新生成", "success");
|
||||
refresh();
|
||||
} catch (e) {
|
||||
show(e instanceof Error ? e.message : "触发失败", "error");
|
||||
@@ -354,8 +354,8 @@ export function DrivesPage() {
|
||||
const resp = await api.setDriveTeaserEnabled(d.id, next);
|
||||
show(
|
||||
resp.teaserEnabled
|
||||
? `已开启「${d.name || d.id}」的 Teaser 生成`
|
||||
: `已关闭「${d.name || d.id}」的 Teaser 生成`,
|
||||
? `已开启「${d.name || d.id}」的预览视频生成`
|
||||
: `已关闭「${d.name || d.id}」的预览视频生成`,
|
||||
"success"
|
||||
);
|
||||
setList((prev) =>
|
||||
|
||||
+23
-21
@@ -69,11 +69,14 @@ export function VideosPage() {
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const pageStart = total === 0 ? 0 : (page - 1) * PAGE_SIZE + 1;
|
||||
const pageEnd = Math.min(total, page * PAGE_SIZE);
|
||||
const listSummary = driveId
|
||||
? `${driveNameMap.get(driveId) ?? driveId}:共 ${total} 个视频,第 ${page} / ${totalPages} 页,显示 ${pageStart}-${pageEnd}`
|
||||
: `全部网盘:共 ${total} 个视频,第 ${page} / ${totalPages} 页,显示 ${pageStart}-${pageEnd}`;
|
||||
|
||||
async function handleRegen(v: api.AdminVideo) {
|
||||
try {
|
||||
await api.regenPreview(v.id);
|
||||
show("已触发 teaser 重生", "success");
|
||||
show("已触发预览视频重生", "success");
|
||||
} catch (e) {
|
||||
show(e instanceof Error ? e.message : "触发失败", "error");
|
||||
}
|
||||
@@ -161,7 +164,7 @@ export function VideosPage() {
|
||||
</header>
|
||||
|
||||
{drives.length > 0 && (
|
||||
<div className="admin-drive-teasers" aria-label="网盘 Teaser 统计">
|
||||
<div className="admin-drive-teasers" aria-label="网盘预览视频统计">
|
||||
{drives.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
@@ -191,20 +194,19 @@ export function VideosPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="admin-batch-actions admin-card" style={{ marginBottom: 16, padding: "8px 16px", display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<span className="admin-text-faint">已选择 {selectedIds.size} 项(当前页)</span>
|
||||
<button type="button" className="admin-btn is-primary" onClick={handleBatchRegen}>
|
||||
<RefreshCw size={13} /> 批量重生 Teaser
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<div className="admin-videos-summary">
|
||||
{driveId
|
||||
? `${driveNameMap.get(driveId) ?? driveId}:共 ${total} 个视频,第 ${page} / ${totalPages} 页,显示 ${pageStart}-${pageEnd}`
|
||||
: `全部网盘:共 ${total} 个视频,第 ${page} / ${totalPages} 页,显示 ${pageStart}-${pageEnd}`}
|
||||
<div className="admin-videos-list-toolbar">
|
||||
<div className="admin-videos-summary">{listSummary}</div>
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="admin-videos-bulk-actions">
|
||||
<span className="admin-videos-bulk-actions__count">
|
||||
已选择 {selectedIds.size} 项
|
||||
</span>
|
||||
<button type="button" className="admin-btn is-primary" onClick={handleBatchRegen}>
|
||||
<RefreshCw size={13} /> 批量重生预览视频
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -251,7 +253,7 @@ export function VideosPage() {
|
||||
<th>作者</th>
|
||||
<th>标签</th>
|
||||
<th>时长</th>
|
||||
<th>Teaser</th>
|
||||
<th>预览视频</th>
|
||||
<th>来源</th>
|
||||
<th className="is-actions">操作</th>
|
||||
</tr>
|
||||
@@ -288,7 +290,7 @@ export function VideosPage() {
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="时长">{formatDur(v.durationSeconds)}</td>
|
||||
<td data-label="Teaser">
|
||||
<td data-label="预览视频">
|
||||
<PreviewStatus s={v.previewStatus} />
|
||||
</td>
|
||||
<td data-label="来源" className="admin-mono-cell">
|
||||
@@ -298,7 +300,7 @@ export function VideosPage() {
|
||||
<button type="button" className="admin-btn" onClick={() => setEditing(v)}>
|
||||
<Edit size={13} /> 编辑
|
||||
</button>{" "}
|
||||
<button type="button" className="admin-btn" onClick={() => handleRegen(v)} title="重生 teaser">
|
||||
<button type="button" className="admin-btn" onClick={() => handleRegen(v)} title="重生预览视频">
|
||||
<RefreshCw size={13} />
|
||||
</button>
|
||||
</td>
|
||||
@@ -359,8 +361,8 @@ export function VideosPage() {
|
||||
)}
|
||||
<ConfirmModal
|
||||
open={batchRegenOpen}
|
||||
title="批量重生 Teaser"
|
||||
message={`确定要为当前页选中的 ${selectedIds.size} 个视频重新生成 teaser 吗?`}
|
||||
title="批量重生预览视频"
|
||||
message={`确定要为当前页选中的 ${selectedIds.size} 个视频重新生成预览视频吗?`}
|
||||
confirmText="确认重生"
|
||||
loading={batchRegening}
|
||||
onCancel={() => {
|
||||
@@ -527,7 +529,7 @@ function EditVideoModal({
|
||||
<dd>{video.driveId}</dd>
|
||||
<dt>文件信息</dt>
|
||||
<dd>{fileMeta(video) || "—"}</dd>
|
||||
<dt>Teaser</dt>
|
||||
<dt>预览视频</dt>
|
||||
<dd>
|
||||
<PreviewStatus s={video.previewStatus} />
|
||||
</dd>
|
||||
|
||||
+4
-4
@@ -83,7 +83,7 @@ export type AdminDrive = {
|
||||
status: string;
|
||||
lastError?: string;
|
||||
hasCredential: boolean;
|
||||
/** 当前是否给该盘生成 teaser/封面(per-drive 开关,替代旧的全局 preview.enabled)。 */
|
||||
/** 当前是否给该盘生成预览视频/封面(per-drive 开关,替代旧的全局 preview.enabled)。 */
|
||||
teaserEnabled: boolean;
|
||||
/**
|
||||
* 用户在 admin 配置的"扫描跳过目录"集合(drive 侧目录 fileID 列表)。
|
||||
@@ -204,9 +204,9 @@ export function getP123QRStatus(uniID: string, loginUuid: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换某个云盘的 teaser 生成开关。点击网盘列表里行内的 toggle 按钮时调用。
|
||||
* 切换某个云盘的预览视频生成开关。点击网盘列表里行内的 toggle 按钮时调用。
|
||||
*
|
||||
* 后端会写 catalog.drives.teaser_enabled,并在从关到开时立刻补扫该盘 pending teaser;
|
||||
* 后端会写 catalog.drives.teaser_enabled,并在从关到开时立刻补扫该盘 pending 预览视频;
|
||||
* 关闭分支不补做任何事,新的入队判断会自动停。
|
||||
*/
|
||||
export function setDriveTeaserEnabled(id: string, enabled: boolean) {
|
||||
@@ -265,7 +265,7 @@ export function regenFailedPreviews(id: string) {
|
||||
|
||||
/**
|
||||
* 触发某 drive 下所有 thumbnail_status=failed 的封面重新入队生成。
|
||||
* 与 regenFailedPreviews 行为对称(一个管 teaser,一个管封面)。
|
||||
* 与 regenFailedPreviews 行为对称(一个管预览视频,一个管封面)。
|
||||
*
|
||||
* 后端立即返回 202;实际状态变化在下次 listDrives 拉到的 thumbnailFailedCount /
|
||||
* thumbnailGenerationStatus 字段里观察。
|
||||
|
||||
@@ -132,7 +132,7 @@ export function DriveCardMetrics({ d }: { d: api.AdminDrive }) {
|
||||
</strong>
|
||||
</div>
|
||||
<div className="admin-drive-card__metric">
|
||||
<span>Teaser 数 (就绪/失败)</span>
|
||||
<span>预览视频数 (就绪/失败)</span>
|
||||
<strong>
|
||||
{d.teaserReadyCount ?? 0}
|
||||
<span style={{ fontSize: "11px", fontWeight: "normal", color: "var(--text-faint)" }}>
|
||||
@@ -277,4 +277,4 @@ export function DriveGenerationPanel({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ export function credentialHelp(kind: Kind, isEdit: boolean): string {
|
||||
case "googledrive":
|
||||
return `按 OpenList 在线 API 挂载,只需要 Google Drive refresh_token;保存时会自动刷新并保存 token。播放不走 302,会由后端带 Authorization 代理转发。${note}`;
|
||||
case "localstorage":
|
||||
return `把服务器上的一个已有目录作为视频来源扫描。填写绝对路径,例如 /mnt/videos;系统会读取该目录及子目录中的视频,并生成封面、Teaser 和指纹。${note}`;
|
||||
return `把服务器上的一个已有目录作为视频来源扫描。填写绝对路径,例如 /mnt/videos;系统会读取该目录及子目录中的视频,并生成封面、预览视频和指纹。${note}`;
|
||||
case "spider91":
|
||||
return "91 爬虫会把定时抓取到的视频和封面先保存到本机,并作为一个视频来源接入站点;可按服务器网络情况单独配置代理。后续流水线会把较早的视频上传到你选择的 115 / PikPak / OneDrive 目标盘。";
|
||||
default:
|
||||
@@ -268,4 +268,4 @@ export function credentialFields(kind: Kind): Array<{
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-1
@@ -1624,10 +1624,32 @@
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
.admin-videos-summary {
|
||||
.admin-videos-list-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin: -8px 0 var(--space-4);
|
||||
}
|
||||
|
||||
.admin-videos-summary {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-faint);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-videos-bulk-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.admin-videos-bulk-actions__count {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-medium);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-table-pagination {
|
||||
@@ -1671,6 +1693,16 @@
|
||||
.admin-table-pagination {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.admin-videos-list-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-videos-bulk-actions {
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
- 视频卡片不是大卡片,而是紧凑信息块:封面、徽标、时长、单行标题、作者、观看量、收藏数、评论数、点赞/点踩。
|
||||
- 视觉系统主要由深色导航、黑色卡片、橙色强调色、白色文字和灰色辅助信息组成。
|
||||
- 自动预览是站内所有视频卡片的基础能力,不只是首页特效。
|
||||
- 原站同时存在两种预览机制:独立 `mp4` teaser 预览,以及旧式多张缩略图轮播。
|
||||
- 原站同时存在两种预览机制:独立 `mp4` 预览视频,以及旧式多张缩略图轮播。
|
||||
- 页面使用懒加载、返回顶部按钮、分页组件、Video.js 播放器和右侧推荐列。
|
||||
|
||||
## 3. 页面结构
|
||||
@@ -466,14 +466,14 @@ export type CommentItem = {
|
||||
- 参考站前端不是根据完整视频时长实时裁剪预览片段。
|
||||
- 它在 hover 时直接请求一条独立的预览资源:`https://vthumb.killcovid2021.com/thumb/{videoId}.mp4`。
|
||||
- 已抽查多个预览文件,时长均为固定 `10 秒`。
|
||||
- 前端代码里没有“从第几秒开始截取”的参数,所以更像是后端预先生成好的 teaser clip。
|
||||
- 前端代码里没有“从第几秒开始截取”的参数,所以更像是后端预先生成好的预览视频片段。
|
||||
- 仅从前端代码无法百分百确认这 `10 秒` 对应完整视频的开头、中段还是后台挑选片段。
|
||||
|
||||
我们实现时不建议照搬原脚本,而是用 React 状态和更稳的资源管理来做。
|
||||
|
||||
### 5.2 预览资源生成策略
|
||||
|
||||
推荐采用“独立 teaser 文件”的方式,而不是 hover 时裁剪完整视频。
|
||||
推荐采用“独立预览视频文件”的方式,而不是 hover 时裁剪完整视频。
|
||||
|
||||
资源规则:
|
||||
|
||||
@@ -1275,9 +1275,9 @@ src/
|
||||
|
||||
#### 14.3.1 预览视频复用完整视频
|
||||
|
||||
- plan 5.2 节强调预览应为独立的 10 秒 teaser 文件。
|
||||
- plan 5.2 节强调预览应为独立的 10 秒预览视频文件。
|
||||
- 当前 mock:`previewSrc === videoSrc`,都指向 Google 公开演示视频(`commondatastorage.googleapis.com/gtv-videos-bucket`)。
|
||||
- 影响:只影响 mock 数据,组件按"只加载预览 URL"工作,后端生成好独立 teaser 后,只改 `data/videos.ts` 中 `previewSrc` 即可。
|
||||
- 影响:只影响 mock 数据,组件按"只加载预览 URL"工作,后端生成好独立预览视频后,只改 `data/videos.ts` 中 `previewSrc` 即可。
|
||||
|
||||
#### 14.3.2 "今日排行"和"最新视频"使用同一批数据
|
||||
|
||||
@@ -1411,8 +1411,8 @@ VideoProject/
|
||||
│ │ │ ├─ pikpak/ 自己实现(参考 OpenList pikpak)
|
||||
│ │ │ └─ wopan/ 壳 + OpenListTeam/wopan-sdk-go
|
||||
│ │ ├─ catalog/ SQLite + VideoItem 增删改查
|
||||
│ │ ├─ scanner/ 扫目录 → 落库 + 异步抽 teaser
|
||||
│ │ ├─ preview/ ffmpeg 抽 10s teaser
|
||||
│ │ ├─ scanner/ 扫目录 → 落库 + 异步生成预览视频
|
||||
│ │ ├─ preview/ ffmpeg 生成 10s 预览视频
|
||||
│ │ ├─ proxy/ /p/<drive>/<id> 代理下载,注入 UA/Referer/Cookie
|
||||
│ │ ├─ auth/ 管理后台鉴权
|
||||
│ │ └─ api/ REST 路由
|
||||
@@ -1431,7 +1431,7 @@ VideoProject/
|
||||
- **SDK**:
|
||||
- 夸克:移植 OpenList `drivers/quark_uc` 的 HTTP 逻辑(纯 Cookie + resty)。
|
||||
- 115:`github.com/SheltonZhu/115driver`,通过 `replace` 指令指向 `../115driver-1.3.2`。
|
||||
- PikPak:移植 OpenList `drivers/pikpak` 的 HTTP 逻辑(用户名密码 / refresh_token + captcha_token + resty);支持扫描和播放,teaser/封面生成产物只写本地。
|
||||
- PikPak:移植 OpenList `drivers/pikpak` 的 HTTP 逻辑(用户名密码 / refresh_token + captcha_token + resty);支持扫描和播放,预览视频/封面生成产物只写本地。
|
||||
- 沃盘:`github.com/OpenListTeam/wopan-sdk-go`,`replace` 指向 `../wopan-sdk-go-0.2.0`。
|
||||
- **视频处理**:ffmpeg / ffprobe,作为外部子进程调用。
|
||||
- **部署**:本地 Windows 开发,最终部署到 Linux 服务器(二进制 + systemd + nginx 反代)。
|
||||
@@ -1442,7 +1442,7 @@ VideoProject/
|
||||
|---|---|
|
||||
| 登录方式 | **B**:管理后台做完整登录流程。115 扫码、夸克扫码或 Cookie 导入、沃盘手机号 + 短信验证。Token 持久化到 SQLite 并自动刷新。 |
|
||||
| 元数据来源 | **默认文件名解析**:`标题.mp4`、`标题 - 作者.mp4`,或带前缀的 `[前缀] 标题 - 作者.mp4`;前缀只用于标题清理,不作为任意标签列表入库。标签来自系统 / 用户标签匹配和目录合集规则;同时提供后台录入 API 覆盖字段 |
|
||||
| Hover teaser | **C 预生成**:scanner 发现新视频时异步生成 10s teaser 并存回网盘的 `previews/` 目录,详情页和列表页 hover 都秒开 |
|
||||
| Hover 预览视频 | **C 预生成**:scanner 发现新视频时异步生成 10s 预览视频并存回网盘的 `previews/` 目录,详情页和列表页 hover 都秒开 |
|
||||
| 部署目标 | Linux 服务器;本地 Windows 开发 |
|
||||
| 扫描策略 | 启动时全量 + 每 6 小时增量 + 支持手动触发 |
|
||||
|
||||
@@ -1460,7 +1460,7 @@ type Drive interface {
|
||||
// 返回一次性直链 + 必要的请求头。proxy 层据此回源。
|
||||
StreamURL(ctx context.Context, fileID string) (*StreamLink, error)
|
||||
|
||||
// 上传用于 scanner 写回 teaser 文件
|
||||
// 上传用于 scanner 写回预览视频文件
|
||||
Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error)
|
||||
}
|
||||
|
||||
@@ -1501,7 +1501,7 @@ POST /admin/api/videos/:id # 更新元数据
|
||||
PUT /admin/api/videos # 新建视频(跳过扫描)
|
||||
```
|
||||
|
||||
### 15.6 Teaser 生成流程
|
||||
### 15.6 预览视频生成流程
|
||||
|
||||
scanner 每次发现新视频(catalog 里没有的 fileID)时:
|
||||
|
||||
@@ -1516,7 +1516,7 @@ scanner 每次发现新视频(catalog 里没有的 fileID)时:
|
||||
- `scale=480:-2`:目标宽 480,缩减体积到 300KB-1.5MB
|
||||
- `-movflags +faststart`:让 moov atom 在文件头部,支持边下边播
|
||||
3. ffmpeg 需要带上 Drive 提供的 UA/Referer/Cookie(用 `-headers` 参数传递)
|
||||
4. teaser 写入本地 `data/previews/<videoID>.mp4`
|
||||
4. 预览视频写入本地 `data/previews/<videoID>.mp4`
|
||||
5. catalog 记录 `preview_local`,详情页/卡片返回 `previewSrc` 指向 `/p/preview/<videoID>`;旧版 `preview_file_id` 字段保留但不再用于读取
|
||||
|
||||
失败重试 3 次,间隔指数退避。失败的记录标记 `preview_status = failed`,不再自动重试,需要后台手动重扫。
|
||||
@@ -1627,11 +1627,11 @@ Linux 服务器:
|
||||
|
||||
- **三家协议变动风险**:协议是逆向出来的,网盘方改就得跟着改。SDK 社区更新到了就 `go get` 新版本。
|
||||
- **网盘风控**:扫描频率太高、直链请求太密集可能被封。scanner 默认 QPS 限制 + 单次扫描目录数量上限。
|
||||
- **teaser/封面本地存储**:生成产物只写入本地 `data/previews/`,不再依赖网盘写权限;部署时需要把该目录纳入持久化和备份策略。
|
||||
- **预览视频/封面本地存储**:生成产物只写入本地 `data/previews/`,不再依赖网盘写权限;部署时需要把该目录纳入持久化和备份策略。
|
||||
|
||||
### 15.12 Teaser 生成策略(已落地)
|
||||
### 15.12 预览视频生成策略(已落地)
|
||||
|
||||
Teaser 不再是"固定从第 10 秒抽 10 秒",改为按视频时长分段挑起点 + 三段拼接:
|
||||
预览视频不再是"固定从第 10 秒抽 10 秒",改为按视频时长分段挑起点 + 三段拼接:
|
||||
|
||||
- **段数**:`Config.Segments`,默认 3。视频 `< 30s` 自动降级为单段。
|
||||
- **每段时长**:`DurationSeconds / Segments`,下限 2 秒,默认 9 / 3 = 3 秒。
|
||||
@@ -1641,11 +1641,11 @@ Teaser 不再是"固定从第 10 秒抽 10 秒",改为按视频时长分段挑
|
||||
- `duration ≥ 10min` → 在 `[20%, 80%]` 区间均匀分布 N 段
|
||||
- **拼接**:每段 `scale=480:-2` 缩放,`fade-in 0.2s` + `fade-out 0.2s`,`concat` 滤镜合成单个 mp4,`libx264 crf 28 preset veryfast`,体积 500 KB - 1.5 MB。
|
||||
|
||||
封面独立于 teaser:
|
||||
封面独立于预览视频:
|
||||
- `pickThumbnailOffset(duration)`:
|
||||
- `duration < 60s` → `duration * 0.3`
|
||||
- `duration ≥ 60s` → `clamp(duration * 0.2, 5, 120)` 秒
|
||||
- 抽帧单独走 `ffmpeg -frames:v 1`,和 teaser 起点解耦。
|
||||
- 抽帧单独走 `ffmpeg -frames:v 1`,和预览视频起点解耦。
|
||||
- 输出 `data/previews/thumbs/<videoID>.jpg`,前端走 `/p/thumb/<videoID>` 路由。
|
||||
|
||||
前端展示(`VideoCard.tsx`):
|
||||
@@ -1906,7 +1906,7 @@ ac3 / dts / flac / opus / vorbis 一律重编 aac(音频码率小,1-2 分钟
|
||||
|
||||
## 16. 91 爬虫源接入(spider91,已落地,2026-05-22)
|
||||
|
||||
把 `91VideoSpider/spider_91porn.py` 包装成一种新的 drive 类型 `spider91`,每天凌晨自动跑一次爬虫"凑够 N 个新视频",下载视频和封面到本地,作为视频源接入现有的列表/详情/标签/teaser 流水线。
|
||||
把 `91VideoSpider/spider_91porn.py` 包装成一种新的 drive 类型 `spider91`,每天凌晨自动跑一次爬虫"凑够 N 个新视频",下载视频和封面到本地,作为视频源接入现有的列表/详情/标签/预览视频流水线。
|
||||
|
||||
### 16.1 设计取舍
|
||||
|
||||
@@ -1915,10 +1915,10 @@ ac3 / dts / flac / opus / vorbis 一律重编 aac(音频码率小,1-2 分钟
|
||||
- **viewkey 做主键**:91porn 网站对每个视频的稳定标识,列表页/详情页 URL 都能拿到。`videos.id = "spider91-<driveID>-<viewkey>"`,`videos.file_id = "<viewkey>.<ext>"`,和 localupload 的 ID/FileID 解耦风格一致。
|
||||
- **视频文件后缀按 URL 真实后缀**:原本 hardcode 写 `.mp4`,但 91porn 直链的格式不固定(`.mp4` / `.flv` / 个别 `.m3u8`),盲存 `.mp4` 会让 ffmpeg 拿到错的容器结构。`detectVideoExt(url)` 解析路径扩展名,命中白名单(mp4/webm/mkv/mov/m4v/flv/avi)就用真实后缀,`.m3u8` 等流媒体清单回退 `.mp4`。`videos.ext` 字段也跟实际后缀保持一致。
|
||||
- **封面直接复用网站封面**:crawler 下载完封面后复制一份到 `data/previews/thumbs/<videoID>.jpg`,让 `/p/thumb/{videoID}` 路由不需要任何特例就能命中。同时 `videos.thumbnail_url` 设为 `/p/thumb/<videoID>`、`thumbnail_status = 'ready'`,让 thumb worker 自动跳过 spider91 视频。
|
||||
- **teaser 仍走 ffmpeg 流水线**:crawler 调 `OnNewVideo` 回调把新视频塞进当前 drive 的 preview worker 队列。
|
||||
- **预览视频仍走 ffmpeg 流水线**:crawler 调 `OnNewVideo` 回调把新视频塞进当前 drive 的 preview worker 队列。
|
||||
- **走代理下载**:91porn CDN 节点在海外,国内直连只有几 KB/s。crawler 的 `http.Client` 用 `http.ProxyFromEnvironment`(读 `HTTPS_PROXY` 环境变量),并允许在 drive credentials 里通过 `proxy` 字段显式覆盖。**这是个重要修正**:自定义 `http.Transport` 默认不带 `Proxy: http.ProxyFromEnvironment`,必须显式加,否则会忽略 `HTTPS_PROXY` 环境变量直连——这是排查中花了最多时间的坑。
|
||||
- **统一 `91porn` 标签**:所有 spider91 视频在入库时打上 `91porn` 标签。`attachSpider91Crawler` 启动时调 `Catalog.CreateTagAndClassify("91porn", nil, "system")` 同时建标签 + 给已入库的视频按 author 字段补打;新视频入库时 crawler 直接设置 `Tags: []string{"91porn"}` 让 `UpsertVideo` 自动同步 `video_tags` 表。
|
||||
- **管理后台 UI 适配**:spider91 不属于"网盘",但复用 `/admin/drives` 页有意义(视频源、teaser、本地占用等列都通用)。做了几处 surgical 修补:状态列对 spider91 直接看 `status` 字段不要求凭证("已就绪"/"错误",不会显示"未配置凭证");操作按钮对 spider91 显示 "立即抓取"(图标 Download)而非"重扫";表单隐藏"根目录 ID" / "扫描起点目录 ID"两行;"扫描根"列对 spider91 显示 "上次抓取 N 小时前"(`lastCrawlAt` 字段从 `drive.credentials.last_crawl_at` 提取)。
|
||||
- **管理后台 UI 适配**:spider91 不属于"网盘",但复用 `/admin/drives` 页有意义(视频源、预览视频、本地占用等列都通用)。做了几处 surgical 修补:状态列对 spider91 直接看 `status` 字段不要求凭证("已就绪"/"错误",不会显示"未配置凭证");操作按钮对 spider91 显示 "立即抓取"(图标 Download)而非"重扫";表单隐藏"根目录 ID" / "扫描起点目录 ID"两行;"扫描根"列对 spider91 显示 "上次抓取 N 小时前"(`lastCrawlAt` 字段从 `drive.credentials.last_crawl_at` 提取)。
|
||||
|
||||
### 16.2 文件改动
|
||||
|
||||
@@ -1954,7 +1954,7 @@ ac3 / dts / flac / opus / vorbis 一律重编 aac(音频码率小,1-2 分钟
|
||||
|
||||
### 16.5 GC 和清理
|
||||
|
||||
- 当前**不主动清理旧视频文件**。删 spider91 drive 不会删 `data/spider91/<driveID>/` 下的文件(和云盘 drive 删除时不动 teaser 一致)
|
||||
- 当前**不主动清理旧视频文件**。删 spider91 drive 不会删 `data/spider91/<driveID>/` 下的文件(和云盘 drive 删除时不动预览视频一致)
|
||||
- 已存在 viewkey 在 Python 端通过 `--seen-viewkeys-file` 跳过(不发详情页请求),Go 端再做 `Catalog.GetVideo` 二次去重防御
|
||||
- 每次爬虫输出的 JSON 留在 `<driveDir>/.crawl/target-<N>-<UTC>.json`,对应的已知 viewkey 列表在 `<driveDir>/.crawl/seen-<UTC>.txt`,方便事后排查;磁盘吃紧可手动清理
|
||||
|
||||
|
||||
Reference in New Issue
Block a user