refactor: rename teaser UI copy to preview video

This commit is contained in:
nianzhibai
2026-06-03 19:45:15 +08:00
parent 397823bb8d
commit 869c0d5f78
23 changed files with 161 additions and 127 deletions
+2 -2
View File
@@ -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
View File
@@ -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 DriveOpenList 在线续期 + 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
View File
@@ -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 的语义上更直观
// 不再传 OnNewVideocrawler 内部的回调字段保留,仅为单测计数器之用)。
})
@@ -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]
+6 -6
View File
@@ -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
# 盘列表。上线后请通过管理后台添加,本文件可留空。
+10 -10
View File
@@ -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
// - 调 OnTeaserEnabledChangedmain 注入;从关到开时会重新入队 pending teaser
// - 调 OnTeaserEnabledChangedmain 注入;从关到开时会重新入队 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"`
+4 -4
View File
@@ -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 等容易踩坑的字段。
+3 -3
View File
@@ -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)。命中其中任意一个的目录及其
+3 -3
View File
@@ -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 里把某盘关了不会被
+1 -1
View File
@@ -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。
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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 会被规范化成 spider91DefaultTargetNew15)。
+1 -1
View File
@@ -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 {
+6 -6
View File
@@ -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
+7 -7
View File
@@ -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) {
+1 -1
View File
@@ -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)
}
+2 -2
View File
@@ -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
}
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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 字段里观察。
+2 -2
View File
@@ -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>
);
}
}
+2 -2
View File
@@ -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
View File
@@ -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%;
}
}
/* =========================================================
+21 -21
View File
@@ -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`,方便事后排查;磁盘吃紧可手动清理