7 Commits

Author SHA1 Message Date
nianzhibai b8388eba59 Prepare v0.1.7 release 2026-06-13 16:32:20 +08:00
nianzhibai 76782f3801 Refine shorts playback caching
Remove shorts recommendation preference, keep a six-video viewed cache window, preload the next two videos after healthy buffering, and avoid premature seen-list resets between sessions.
2026-06-13 16:26:36 +08:00
nianzhibai 1ae1408fb6 feat: refine video detail player controls
Remove the hide action from the video detail page and keep delete as the only management action.

Adjust mobile delete dialog and ArtPlayer settings UI, disable persisted player settings, and add a temporary loop option.
2026-06-13 15:18:20 +08:00
nianzhibai 738406162a feat: add video blacklist management
Add backend blacklist tombstone APIs and hidden-video migration support.

Update the admin video management UI with blacklist tabs, restore actions, alignment fixes, responsive layout polish, and regression coverage.
2026-06-13 14:34:00 +08:00
nianzhibai 0f111b846d feat: add opt-in toggle for local STRM targets outside the storage root
Local .strm files that pointed to a path outside the configured storage
root previously failed cover/preview/fingerprint generation and playback
with "strm target escapes root", breaking the common layout where the
strm library and the real media files (e.g. an rclone mount) live in
separate directories (issue #22 follow-up).

- localstorage driver gains STRMAllowOutsideRoot; when on, strm targets
  outside the root are allowed (still resolves symlinks and still rejects
  nested strm, so no new escape vector). Default off preserves the
  existing security boundary
- Toggle persisted as the strm_allow_outside_root credential; editing a
  localstorage drive now merges credentials per-key so leaving the path
  blank keeps the old value while flipping the toggle
- Saving a localstorage drive with the toggle on auto-re-enqueues
  previously-failed thumbnails/previews/fingerprints, so enabling it
  recovers without manually clicking the three retry buttons
- Drives API exposes strmAllowOutsideRoot for form echo-back; admin
  drive form adds a "允许指向目录外" select with a security warning
- Tests cover allow-outside-root on/off and that nested strm stays
  rejected even when the toggle is on

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 10:34:54 +08:00
nianzhibai 4dd9015bd7 feat: add per-storage manual transcode for browser-incompatible videos
Add a transcode control to each storage in the admin drives page,
modeled after the cover/preview generation controls:

- Manual start/stop button per storage; transcoding is off by default
  and never runs automatically (not triggered by scans or the nightly
  pipeline)
- New transcode worker probes candidates (non mp4/webm extensions)
  with ffprobe: already-compatible files are marked skipped; AVI with
  H.264 is remuxed losslessly; incompatible codecs (MPEG-4 Part 2,
  WMV, RMVB, HEVC...) are transcoded to H.264/AAC MP4 with +faststart
- Transcoded output is uploaded back to the same storage under a
  "91转码" directory which is auto-added to the drive's scan skip list
  so the scanner never re-imports the artifacts
- Playback source automatically prefers the transcoded file once
  ready, keeping the 302 direct-link mode for cloud drives
- videos table gains transcode_status/error/file_id/size columns via
  startup migration; counts and live task status surface in the
  admin drives API and generation panel UI
- Stop semantics: per-drive stop button, drive-level "stop all tasks"
  and global stop all include the transcode task; interrupted videos
  keep their candidate status and resume on next start

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 09:41:08 +08:00
nianzhibai 84fbb6f51c feat: improve shorts playback with buffer-aware preload and back-swipe cache
- Active video loads with priority; next video preloads only after the
  active one has 12s of forward buffer or is buffered to the end
- Add high/low watermark hysteresis (12s grant / 4s revoke) so the
  preload grant no longer thrashes around the threshold, discarding
  already-preloaded data
- Treat buffered-to-end as healthy to avoid revoking preload near the
  end of short clips
- Mark sources as cacheable on first canplay and keep src bound for
  cached adjacent slides in both directions, so swiping back (and
  forward again) reuses the browser buffer instead of reloading

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 09:00:33 +08:00
35 changed files with 2883 additions and 784 deletions
+7
View File
@@ -37,6 +37,13 @@ __pycache__/
*.pyc
# Local scratch images
/*.png
/*.jpg
/*.jpeg
/*.gif
/*.webp
/*.bmp
/*.ico
/image.jpg
/image003.jpg
/image004.jpg
+11
View File
@@ -25,6 +25,8 @@
- **封面 & 预览片段** — 自动为每个视频生成封面图和预览片段,首页快速选片
- **爬虫脚本** — 项目支持导入自定义脚本,但是有一些规范,具体可以参考 [SpiderFor91](https://github.com/Just-Spider/SpiderFor91),项目不再内置任何爬虫脚本
- **短视频模式** — 一键切换抖音风格,沉浸刷片
- **兼容性转码** — 对 AVI、WMV、RMVB 等浏览器无法直接播放的视频,可在后台手动转码为 H.264 MP4
- **视频管理** — 删除或隐藏的视频进入黑名单,支持在后台移出黑名单后重新扫盘入库
---
@@ -82,6 +84,14 @@ sudo bash install.sh
> `video-site-91` 为等效别名,两者可互换使用。
**已部署用户升级:**
```bash
91 update
```
升级会保留现有 `config.yaml`、数据库、封面、预览、上传文件和爬虫数据。脚本会自动安装或检查 `ffmpeg` / `ffprobe` 等运行依赖,并在新版本启动失败时回滚到升级前文件。
**自定义端口:**
```bash
@@ -153,6 +163,7 @@ docker compose up -d # 更新并重启
```
> 所有配置、数据库、封面、预览及上传文件均保存在 `./data/` 目录下。
> 从旧版本升级 Docker 部署时,执行 `docker compose pull && docker compose up -d` 即可;`./data/` 不会被镜像更新覆盖。
---
+2 -2
View File
@@ -154,9 +154,9 @@ Google Drive 默认按 OpenList 在线 API 调用 `https://api.oplist.org/google
## 管理能力
- `/admin/drives`:新增、编辑、删除网盘,触发扫描。
- `/admin/videos`:按网盘筛选视频,每页 100 条分页,查看各网盘预览视频统计,编辑标题/作者/分类/标签,单条或全量重生预览视频。
- `/admin/videos`:按网盘筛选视频,每页 100 条分页,查看各网盘预览视频统计,编辑标题/作者/分类/标签,单条或全量重生预览视频;拉黑视频页可查看被删除或被隐藏的视频,并支持移出黑名单后在下次扫盘重新入库
- `/admin/tags`:新增标签并用内置规则自动匹配已有视频;删除非系统标签时会从所有视频上同步移除该标签。
- 播放页视频信息会展示来源网盘类型;同时提供“不再展示”,点击后会把视频标记为全局隐藏。隐藏视频不会再出现在首页、列表、搜索、相关推荐和详情接口中。目前没有管理后台恢复入口,如需恢复可把数据库里对应视频的 `hidden` 字段改回 `0`
- 播放页视频信息会展示来源网盘类型,并提供删除入口。被删除或被隐藏视频会进入黑名单,不会再出现在首页、列表、搜索和详情接口中;在后台移出黑名单后,会在下次扫盘时重新发现并入库
## 预览视频生成
+200 -6
View File
@@ -43,6 +43,7 @@ import (
"github.com/video-site/backend/internal/proxy"
"github.com/video-site/backend/internal/scanner"
"github.com/video-site/backend/internal/spider91migrate"
"github.com/video-site/backend/internal/transcode"
)
const fingerprintReconcileInterval = time.Minute
@@ -130,6 +131,13 @@ func main() {
OnVideoUploaded: func(v *catalog.Video) {
app.enqueueUploadedVideo(ctx, v)
},
// 前台「不再展示」走拉黑逻辑:删记录 + 删本地封面/预览 + 写墓碑,
// 保留网盘源文件(deleteSource=false)。下次扫盘不再入库;如需恢复,
// 在后台「拉黑视频」移出黑名单即可,扫盘时会重新添加回来。
OnHideVideo: func(reqCtx context.Context, videoID string) error {
_, err := app.deleteVideo(reqCtx, videoID, false)
return err
},
GetTheme: func() string { return app.Theme() },
}
@@ -169,6 +177,14 @@ func main() {
return err
}
app.scheduleCrawlerUploadMigration(ctx, driveID)
// 本地存储开启 .strm 越root后,之前因 strm 指向目录外而失败的封面/
// 预览/指纹应自动重试,省得用户再手动点三个"重试失败"按钮。
if d.Kind == localstorage.Kind &&
parseBoolDefault(strings.TrimSpace(d.Credentials["strm_allow_outside_root"]), false) {
go app.regenFailedThumbnails(ctx, driveID)
go app.regenFailedPreviews(ctx, driveID)
go app.regenFailedFingerprints(ctx, driveID)
}
return nil
},
OnDriveDeleteCleanup: func(cleanupCtx context.Context, driveID string) (int, error) {
@@ -218,6 +234,12 @@ func main() {
OnRegenFailedFingerprints: func(driveID string) {
go app.regenFailedFingerprints(ctx, driveID)
},
OnStartDriveTranscode: func(driveID string) (bool, string) {
return app.startDriveTranscode(ctx, driveID)
},
OnStopDriveTranscode: func(driveID string) bool {
return app.stopDriveTranscode(driveID)
},
OnDeleteVideo: func(reqCtx context.Context, videoID string, deleteSource bool) (api.DeleteVideoResult, error) {
return app.deleteVideo(reqCtx, videoID, deleteSource)
},
@@ -297,6 +319,7 @@ func main() {
}
}()
go app.attachExistingDrives(ctx)
go app.migrateHiddenVideosToTombstone(ctx)
// 等待退出信号
sigs := make(chan os.Signal, 1)
@@ -368,6 +391,13 @@ type App struct {
// uploadProgress 跟踪脚本爬虫迁移到云盘时的实时上传状态。
uploadProgressMu sync.Mutex
uploadProgress map[string]driveUploadProgress
// transcodeMu 保护 transcodeWorkers / transcodeCancels。
// 浏览器兼容性转码每盘最多一个任务,且只能由管理员手动开启
// (不随扫盘/夜间流水线自动运行),手动停止或处理完即从 map 清除。
transcodeMu sync.Mutex
transcodeWorkers map[string]*transcode.Worker
transcodeCancels map[string]context.CancelFunc
}
type driveScanProgress struct {
@@ -557,7 +587,14 @@ func (a *App) driveGenerationStatuses() map[string]api.DriveGenerationStatuses {
}
a.mu.Unlock()
out := make(map[string]api.DriveGenerationStatuses, len(scanningDrives)+len(previewWorkers)+len(thumbWorkers)+len(fingerprintWorkers)+len(uploadProgresses))
a.transcodeMu.Lock()
transcodeWorkers := make(map[string]*transcode.Worker, len(a.transcodeWorkers))
for id, worker := range a.transcodeWorkers {
transcodeWorkers[id] = worker
}
a.transcodeMu.Unlock()
out := make(map[string]api.DriveGenerationStatuses, len(scanningDrives)+len(previewWorkers)+len(thumbWorkers)+len(fingerprintWorkers)+len(uploadProgresses)+len(transcodeWorkers))
for id, running := range scanningDrives {
if !running {
continue
@@ -601,6 +638,11 @@ func (a *App) driveGenerationStatuses() map[string]api.DriveGenerationStatuses {
}
out[id] = status
}
for id, worker := range transcodeWorkers {
status := out[id]
status.Transcode = generationStatusFromTranscode(worker.Status())
out[id] = status
}
return out
}
@@ -687,6 +729,126 @@ func generationStatusFromFingerprint(status fingerprint.TaskStatus) api.Generati
return out
}
func generationStatusFromTranscode(status transcode.TaskStatus) api.GenerationStatus {
state := status.State
if state == "" {
state = "idle"
}
return api.GenerationStatus{
State: state,
CurrentTitle: status.CurrentTitle,
QueueLength: status.QueueLength,
DoneCount: status.DoneCount,
TotalCount: status.TotalCount,
}
}
// transcodeWorkDir 返回转码用的本地临时目录(下载原片 / 写产物),与
// localUploadDir 一样挂在数据目录下,避免 /tmp 空间不足。
func (a *App) transcodeWorkDir() string {
return filepath.Join(filepath.Dir(a.cfg.Storage.LocalPreviewDir), "transcode-tmp")
}
// startDriveTranscode 手动开启某盘的浏览器兼容性转码。
// 转码从不自动运行:扫盘、夜间流水线都不会触发,这里是唯一入口。
// 任务跑完候选列表后自然结束;中途可用 stopDriveTranscode / 停止所有任务中断。
func (a *App) startDriveTranscode(ctx context.Context, driveID string) (bool, string) {
driveID = strings.TrimSpace(driveID)
if driveID == "" {
return false, "缺少存储 ID"
}
drv, ok := a.registry.Get(driveID)
if !ok {
return false, "存储未挂载或不可用"
}
switch drv.Kind() {
case spider91.Kind, scriptcrawler.Kind:
return false, "爬虫存储不支持转码"
}
workDir := a.transcodeWorkDir()
if err := os.MkdirAll(workDir, 0o755); err != nil {
return false, "创建转码临时目录失败: " + err.Error()
}
a.transcodeMu.Lock()
if a.transcodeWorkers == nil {
a.transcodeWorkers = make(map[string]*transcode.Worker)
a.transcodeCancels = make(map[string]context.CancelFunc)
}
if existing := a.transcodeWorkers[driveID]; existing != nil {
a.transcodeMu.Unlock()
return false, "该存储的转码任务已在运行"
}
worker := transcode.NewWorker(transcode.Config{
FFmpegPath: a.cfg.Preview.FFmpegPath,
FFprobePath: a.cfg.Preview.FFprobePath,
WorkDir: workDir,
}, a.cat, drv)
taskCtx, done := a.registerDriveTaskContext(ctx, driveID)
runCtx, cancel := context.WithCancel(taskCtx)
a.transcodeWorkers[driveID] = worker
a.transcodeCancels[driveID] = cancel
a.transcodeMu.Unlock()
go func() {
defer func() {
cancel()
done()
a.transcodeMu.Lock()
if a.transcodeWorkers[driveID] == worker {
delete(a.transcodeWorkers, driveID)
delete(a.transcodeCancels, driveID)
}
a.transcodeMu.Unlock()
}()
candidates, err := a.cat.ListTranscodeCandidates(runCtx, driveID, 0)
if err != nil {
log.Printf("[transcode] list candidates drive=%s: %v", driveID, err)
return
}
if len(candidates) == 0 {
log.Printf("[transcode] drive=%s no candidates", driveID)
return
}
log.Printf("[transcode] drive=%s start, %d candidates", driveID, len(candidates))
worker.Run(runCtx, candidates)
}()
return true, ""
}
// stopAllDriveTranscodes 停掉所有盘的转码任务,返回被停的 driveID 列表。
func (a *App) stopAllDriveTranscodes() []string {
a.transcodeMu.Lock()
cancels := a.transcodeCancels
a.transcodeCancels = nil
a.transcodeWorkers = nil
a.transcodeMu.Unlock()
ids := make([]string, 0, len(cancels))
for id, cancel := range cancels {
if cancel != nil {
cancel()
}
ids = append(ids, id)
}
return ids
}
// stopDriveTranscode 手动停止某盘的转码任务。返回是否有任务被停。
func (a *App) stopDriveTranscode(driveID string) bool {
driveID = strings.TrimSpace(driveID)
a.transcodeMu.Lock()
cancel := a.transcodeCancels[driveID]
delete(a.transcodeCancels, driveID)
delete(a.transcodeWorkers, driveID)
a.transcodeMu.Unlock()
if cancel == nil {
return false
}
cancel()
log.Printf("[transcode] stop drive=%s", driveID)
return true
}
func (a *App) attachDrive(ctx context.Context, d *catalog.Drive) error {
a.driveAttachMu.Lock()
defer a.driveAttachMu.Unlock()
@@ -841,8 +1003,9 @@ func (a *App) attachDriveUnlocked(ctx context.Context, d *catalog.Drive) error {
})
case localstorage.Kind:
drv = localstorage.New(localstorage.Config{
ID: d.ID,
RootPath: d.Credentials["path"],
ID: d.ID,
RootPath: d.Credentials["path"],
STRMAllowOutsideRoot: parseBoolDefault(strings.TrimSpace(d.Credentials["strm_allow_outside_root"]), false),
})
case scriptcrawler.Kind:
drv = scriptcrawler.New(scriptcrawler.Config{
@@ -1435,10 +1598,11 @@ func (a *App) stopDriveTasks(ctx context.Context, driveID string) bool {
queued := a.clearQueuedDriveTask(driveID)
fingerprintQueued := a.clearFingerprintQueueing(driveID)
uploading := a.clearCrawlerUploadProgress(driveID)
transcoding := a.stopDriveTranscode(driveID)
hadWorkers := a.resetDriveGenerationWorkers(ctx, driveID)
stopped := canceled > 0 || queued || fingerprintQueued || uploading || hadWorkers
log.Printf("[tasks] stop drive=%s stopped=%v canceled_tasks=%d queued=%v fingerprint_queue=%v uploading=%v workers=%v",
driveID, stopped, canceled, queued, fingerprintQueued, uploading, hadWorkers)
stopped := canceled > 0 || queued || fingerprintQueued || uploading || transcoding || hadWorkers
log.Printf("[tasks] stop drive=%s stopped=%v canceled_tasks=%d queued=%v fingerprint_queue=%v uploading=%v transcoding=%v workers=%v",
driveID, stopped, canceled, queued, fingerprintQueued, uploading, transcoding, hadWorkers)
return stopped
}
@@ -1459,6 +1623,9 @@ func (a *App) stopAllDriveTasks(ctx context.Context) int {
for _, id := range a.clearAllCrawlerUploadProgress() {
stoppedIDs[id] = struct{}{}
}
for _, id := range a.stopAllDriveTranscodes() {
stoppedIDs[id] = struct{}{}
}
for _, id := range a.resetAllDriveGenerationWorkers(ctx) {
stoppedIDs[id] = struct{}{}
}
@@ -1813,6 +1980,33 @@ func (a *App) cleanupMissingDriveVideos(ctx context.Context, driveID string, liv
return removed, nil
}
// migrateHiddenVideosToTombstone 把历史「隐藏」视频一次性迁移为黑名单墓碑。
// 隐藏机制已废弃——前台「不再展示」改走拉黑逻辑。迁移=删库记录 + 删本地
// 封面/预览 + 写墓碑,保留网盘源文件。迁移后无 hidden=1 记录,重复执行为空操作。
func (a *App) migrateHiddenVideosToTombstone(ctx context.Context) {
if a == nil || a.cat == nil {
return
}
hidden, err := a.cat.ListHiddenVideos(ctx)
if err != nil {
log.Printf("[migrate] list hidden videos: %v", err)
return
}
if len(hidden) == 0 {
return
}
log.Printf("[migrate] converting %d hidden video(s) to blacklist tombstones", len(hidden))
migrated := 0
for _, v := range hidden {
if _, err := a.deleteVideo(ctx, v.ID, false); err != nil {
log.Printf("[migrate] hidden->tombstone %s: %v", v.ID, err)
continue
}
migrated++
}
log.Printf("[migrate] hidden->tombstone done: %d/%d", migrated, len(hidden))
}
func (a *App) deleteVideo(ctx context.Context, videoID string, deleteSource bool) (api.DeleteVideoResult, error) {
if a == nil || a.cat == nil {
return api.DeleteVideoResult{}, sql.ErrNoRows
+139 -4
View File
@@ -59,7 +59,13 @@ type AdminServer struct {
OnRegenFailedPreviews func(driveID string)
OnRegenFailedThumbnails func(driveID string)
OnRegenFailedFingerprints func(driveID string)
OnDeleteVideo func(ctx context.Context, videoID string, deleteSource bool) (DeleteVideoResult, error)
// OnStartDriveTranscode 手动开启某盘的浏览器兼容性转码任务。
// 返回 (是否接受, 拒绝原因)。转码从不自动运行,只能在这里手动触发;
// 处理完候选列表后任务自然结束。
OnStartDriveTranscode func(driveID string) (bool, string)
// OnStopDriveTranscode 手动停止某盘正在进行的转码任务。返回是否有任务被停。
OnStopDriveTranscode func(driveID string) bool
OnDeleteVideo func(ctx context.Context, videoID string, deleteSource bool) (DeleteVideoResult, error)
GetDriveGenerationStatuses func() map[string]DriveGenerationStatuses
// OnTeaserEnabledChanged 在 per-drive 预览视频开关被切换后调用。
// enabled=true 时上层应该重新把 pending 预览视频入队(类似旧的全局开关从关到开);
@@ -118,6 +124,7 @@ type DriveGenerationStatuses struct {
Preview GenerationStatus `json:"preview"`
Fingerprint GenerationStatus `json:"fingerprint"`
Upload GenerationStatus `json:"upload"`
Transcode GenerationStatus `json:"transcode"`
}
type NightlyJobStatus struct {
@@ -169,6 +176,8 @@ func (a *AdminServer) Register(r chi.Router) {
r.Post("/drives/{id}/previews/failed/regenerate", a.handleRegenFailedPreviews)
r.Post("/drives/{id}/thumbnails/failed/regenerate", a.handleRegenFailedThumbnails)
r.Post("/drives/{id}/fingerprints/failed/regenerate", a.handleRegenFailedFingerprints)
r.Post("/drives/{id}/transcode/start", a.handleStartDriveTranscode)
r.Post("/drives/{id}/transcode/stop", a.handleStopDriveTranscode)
// 爬虫
r.Get("/crawlers", a.handleListCrawlers)
@@ -182,10 +191,14 @@ func (a *AdminServer) Register(r chi.Router) {
// 视频
r.Get("/videos", a.handleAdminListVideos)
r.Get("/videos/stats", a.handleVideoStats)
r.Put("/videos/{id}", a.handleUpdateVideo)
r.Delete("/videos/{id}", a.handleDeleteVideo)
r.Post("/videos/regen-preview", a.handleRegenAllPreviews)
r.Post("/videos/{id}/regen-preview", a.handleRegenPreview)
// 黑名单(被拉黑/手动删除、扫盘不再入库的视频)
r.Get("/blacklist", a.handleListBlacklist)
r.Delete("/blacklist/{id}", a.handleRemoveBlacklist)
// 标签
r.Get("/tags", a.handleListTags)
@@ -431,6 +444,11 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusInternalServerError, err)
return
}
transcodeCounts, err := a.Catalog.CountTranscodesByDrive(r.Context())
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
generationStatuses := map[string]DriveGenerationStatuses{}
if a.GetDriveGenerationStatuses != nil {
generationStatuses = a.GetDriveGenerationStatuses()
@@ -456,6 +474,8 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) {
Spider91Proxy string `json:"spider91Proxy,omitempty"`
LastCrawlAt int64 `json:"lastCrawlAt,omitempty"`
GoogleDriveUseOnlineAPI *bool `json:"googleDriveUseOnlineAPI,omitempty"`
// STRMAllowOutsideRoot 是 localstorage 的 .strm 越root开关;其它 kind 省略。
STRMAllowOutsideRoot *bool `json:"strmAllowOutsideRoot,omitempty"`
ScanGenerationStatus GenerationStatus `json:"scanGenerationStatus"`
ThumbnailGenerationStatus GenerationStatus `json:"thumbnailGenerationStatus"`
PreviewGenerationStatus GenerationStatus `json:"previewGenerationStatus"`
@@ -470,6 +490,11 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) {
FingerprintReadyCount int `json:"fingerprintReadyCount"`
FingerprintPendingCount int `json:"fingerprintPendingCount"`
FingerprintFailedCount int `json:"fingerprintFailedCount"`
TranscodeGenerationStatus GenerationStatus `json:"transcodeGenerationStatus"`
TranscodePendingCount int `json:"transcodePendingCount"`
TranscodeReadyCount int `json:"transcodeReadyCount"`
TranscodeFailedCount int `json:"transcodeFailedCount"`
TranscodeSkippedCount int `json:"transcodeSkippedCount"`
}
list := make([]out, 0, len(drives))
for _, d := range drives {
@@ -479,6 +504,7 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) {
counts := teaserCounts[d.ID]
thumbCounts := thumbnailCounts[d.ID]
fingerprintCount := fingerprintCounts[d.ID]
transcodeCount := transcodeCounts[d.ID]
generation := generationStatuses[d.ID]
if generation.Scan.State == "" {
generation.Scan.State = "idle"
@@ -492,6 +518,9 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) {
if generation.Fingerprint.State == "" {
generation.Fingerprint.State = "idle"
}
if generation.Transcode.State == "" {
generation.Transcode.State = "idle"
}
// spider91 没有用户凭证概念;只要存在 drive 行就视为"已配置"。
// last_crawl_at 是后端自动写入的运行状态字段,不计入 hasCredential 判定。
hasCred := false
@@ -523,6 +552,7 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) {
Spider91Proxy: spider91ProxyForDrive(d),
LastCrawlAt: lastCrawlAt,
GoogleDriveUseOnlineAPI: googleDriveUseOnlineAPIForDrive(d),
STRMAllowOutsideRoot: strmAllowOutsideRootForDrive(d),
ScanGenerationStatus: generation.Scan,
ThumbnailGenerationStatus: generation.Thumbnail,
PreviewGenerationStatus: generation.Preview,
@@ -537,6 +567,11 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) {
FingerprintReadyCount: fingerprintCount.Ready,
FingerprintPendingCount: fingerprintCount.Pending,
FingerprintFailedCount: fingerprintCount.Failed,
TranscodeGenerationStatus: generation.Transcode,
TranscodePendingCount: transcodeCount.Pending,
TranscodeReadyCount: transcodeCount.Ready,
TranscodeFailedCount: transcodeCount.Failed,
TranscodeSkippedCount: transcodeCount.Skipped,
})
}
writeJSON(w, http.StatusOK, list)
@@ -585,8 +620,10 @@ 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 body.Kind == "googledrive" || body.Kind == "localstorage" {
// 按键合并、空值沿用旧值:localstorage 编辑表单里 path 留空表示不改,
// 但 strm_allow_outside_root 开关每次都会带值,必须逐键合并而不是整体替换。
body.Credentials = mergeNonEmptyCredentials(existing, body.Credentials)
} else if len(body.Credentials) == 0 && existing != nil && len(existing.Credentials) > 0 {
body.Credentials = existing.Credentials
}
@@ -1322,6 +1359,21 @@ func spider91ProxyForDrive(d *catalog.Drive) string {
return strings.TrimSpace(d.Credentials["proxy"])
}
// strmAllowOutsideRootForDrive 返回 localstorage 的 .strm 越root开关;
// 其它 kind 返回 nilJSON 省略)。未配置时默认 false。
func strmAllowOutsideRootForDrive(d *catalog.Drive) *bool {
if d == nil || d.Kind != "localstorage" {
return nil
}
result := false
if d.Credentials != nil {
if v, err := strconv.ParseBool(strings.TrimSpace(d.Credentials["strm_allow_outside_root"])); err == nil {
result = v
}
}
return &result
}
func googleDriveUseOnlineAPIForDrive(d *catalog.Drive) *bool {
if d == nil || d.Kind != "googledrive" {
return nil
@@ -1342,7 +1394,10 @@ func googleDriveUseOnlineAPIForDrive(d *catalog.Drive) *bool {
return &result
}
func mergeGoogleDriveCredentials(existing *catalog.Drive, incoming map[string]string) map[string]string {
// mergeNonEmptyCredentials 逐键合并凭证:incoming 里非空的键覆盖旧值,
// 空值/缺失的键沿用旧值。googledrive 和 localstorage 的编辑表单都依赖
// 这个语义(留空 = 不修改)。
func mergeNonEmptyCredentials(existing *catalog.Drive, incoming map[string]string) map[string]string {
merged := map[string]string{}
if existing != nil {
for k, v := range existing.Credentials {
@@ -1547,6 +1602,35 @@ func (a *AdminServer) handleStopDriveTasks(w http.ResponseWriter, r *http.Reques
})
}
// handleStartDriveTranscode 手动开启某盘的浏览器兼容性转码。
// 转码默认不开启、从不自动运行;本接口是唯一入口。
func (a *AdminServer) handleStartDriveTranscode(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if a.OnStartDriveTranscode == nil {
writeErr(w, http.StatusNotImplemented, errors.New("transcode not supported"))
return
}
accepted, message := a.OnStartDriveTranscode(id)
writeJSON(w, http.StatusAccepted, map[string]any{
"ok": true,
"accepted": accepted,
"message": message,
})
}
// handleStopDriveTranscode 手动停止某盘正在进行的转码任务。
func (a *AdminServer) handleStopDriveTranscode(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
stopped := false
if a.OnStopDriveTranscode != nil {
stopped = a.OnStopDriveTranscode(id)
}
writeJSON(w, http.StatusAccepted, map[string]any{
"ok": true,
"stopped": stopped,
})
}
func (a *AdminServer) p123QRClient() *p123.QRClient {
return p123.NewQRClient(p123.QRConfig{
UserAPIBaseURL: a.P123UserAPIBaseURL,
@@ -1806,6 +1890,57 @@ func (a *AdminServer) handleAdminListVideos(w http.ResponseWriter, r *http.Reque
})
}
// handleVideoStats 返回后台视频管理两个标签页的计数(当前/拉黑)。
func (a *AdminServer) handleVideoStats(w http.ResponseWriter, r *http.Request) {
current, blacklisted, err := a.Catalog.VideoManagementCounts(r.Context())
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"current": current,
"blacklisted": blacklisted,
})
}
// handleListBlacklist 分页返回黑名单(墓碑)视频。
func (a *AdminServer) handleListBlacklist(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
page, _ := strconv.Atoi(q.Get("page"))
size, _ := strconv.Atoi(q.Get("size"))
if page <= 0 {
page = 1
}
if size <= 0 || size > 100 {
size = 100
}
items, total, err := a.Catalog.ListDeletedVideos(r.Context(), q.Get("keyword"), page, size)
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items,
"total": total,
"page": page,
"size": size,
})
}
// handleRemoveBlacklist 把视频移出黑名单(删除墓碑),下次扫盘会重新入库。
func (a *AdminServer) handleRemoveBlacklist(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := a.Catalog.RemoveDeletedVideo(r.Context(), id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeErr(w, http.StatusNotFound, err)
return
}
writeErr(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *AdminServer) handleListTags(w http.ResponseWriter, r *http.Request) {
tags, err := a.Catalog.ListTags(r.Context())
if err != nil {
+39 -19
View File
@@ -55,6 +55,10 @@ type Server struct {
LocalDir string
UploadDir string
OnVideoUploaded func(*catalog.Video)
// OnHideVideo 处理前台「不再展示」。隐藏机制已废弃,改走拉黑逻辑:
// 删除库中记录 + 本地封面/预览,保留网盘源文件,并写黑名单墓碑
// (扫盘不再入库)。未注入时回退为旧的 hidden 标记。
OnHideVideo func(ctx context.Context, videoID string) error
tagCacheMu sync.Mutex
tagCacheUntil time.Time
@@ -526,11 +530,9 @@ func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) {
}
// shortsNextReq 客户端把当前轮已看过的 video id 列表传上来。
// PreferredFromVideoID 来自短视频页最近一次点赞成功的视频,用于优先推荐相似标签。
type shortsNextReq struct {
SeenIDs []string `json:"seenIds"`
Count int `json:"count"`
PreferredFromVideoID string `json:"preferredFromVideoId"`
SeenIDs []string `json:"seenIds"`
Count int `json:"count"`
}
// ShortsItemDTO 是短视频流单条的精简结构。比 VideoDTO 多 videoSrc / poster
@@ -548,8 +550,8 @@ type ShortsItemDTO struct {
// - 服务器从未在 seenIds 中的可见视频里随机抽至多 count 条返回
// - 当返回数量 < count 且小于全库可见总数时,说明本轮即将结束,
// 返回 roundComplete=true,前端应在用户看完返回的这些后清空本地已看记录开新一轮
// - 当 seenIds 已经覆盖全库时,本接口直接返回新一轮的随机一批
// seenIds=[] 即可让客户端在轮次完成后重新开始
// - 当 seenIds 真实覆盖当前全部可见视频时,本接口直接返回新一轮的随机一批
// 不能仅看 seenIds 长度,里面可能有隐藏、删除或历史脏 ID
func (s *Server) handleShortsNext(w http.ResponseWriter, r *http.Request) {
var body shortsNextReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
@@ -570,22 +572,18 @@ func (s *Server) handleShortsNext(w http.ResponseWriter, r *http.Request) {
return
}
// 如果客户端已看记录已经 ≥ 全库,则视为新一轮,直接忽略 seenIds
exclude := body.SeenIDs
if total > 0 && len(exclude) >= total {
exclude = nil
}
var items []*catalog.Video
if strings.TrimSpace(body.PreferredFromVideoID) != "" {
items, err = s.Catalog.RandomVideosForPreferredVideoExcluding(r.Context(), body.PreferredFromVideoID, exclude, count)
} else {
items, err = s.Catalog.RandomVideosExcluding(r.Context(), exclude, count)
}
items, err := s.Catalog.RandomVideosExcluding(r.Context(), body.SeenIDs, count)
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
if total > 0 && len(items) == 0 && len(body.SeenIDs) > 0 {
items, err = s.Catalog.RandomVideosExcluding(r.Context(), nil, count)
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
}
// 注入 sourceLabel 以便前端展示来源网盘
driveLabels := make(map[string]string)
@@ -687,7 +685,14 @@ func (s *Server) handleView(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleHideVideo(w http.ResponseWriter, r *http.Request) {
id := routeParam(r, "id")
if err := s.Catalog.HideVideo(r.Context(), id); err != nil {
var err error
if s.OnHideVideo != nil {
// 走拉黑逻辑:删记录 + 删本地封面/预览 + 写墓碑,保留网盘源文件。
err = s.OnHideVideo(r.Context(), id)
} else {
err = s.Catalog.HideVideo(r.Context(), id)
}
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeErr(w, http.StatusNotFound, err)
return
@@ -970,6 +975,15 @@ func thumbnailURL(v *catalog.Video) string {
return base + "?v=" + strconv.FormatInt(v.UpdatedAt.UnixMilli(), 10)
}
// transcodedSource 在视频有就绪的浏览器兼容性转码产物时返回产物的播放地址。
// 产物和原始文件在同一个 drive 上,走同一条 /p/stream 代理/302 链路。
func transcodedSource(v *catalog.Video) (string, bool) {
if v.TranscodeStatus == "ready" && v.TranscodedFileID != "" && v.DriveID != localUploadDriveID {
return fmt.Sprintf("/p/stream/%s/%s", pathSegment(v.DriveID), pathSegment(v.TranscodedFileID)), true
}
return "", false
}
func (s *Server) videoSource(v *catalog.Video) string {
if v.DriveID == localUploadDriveID {
return "/p/upload/" + pathSegment(v.ID)
@@ -982,6 +996,9 @@ func (s *Server) videoSource(v *catalog.Video) string {
}
}
}
if src, ok := transcodedSource(v); ok {
return src
}
return fmt.Sprintf("/p/stream/%s/%s", pathSegment(v.DriveID), pathSegment(v.FileID))
}
@@ -991,6 +1008,9 @@ func videoSource(v *catalog.Video) string {
if v.DriveID == localUploadDriveID {
return "/p/upload/" + pathSegment(v.ID)
}
if src, ok := transcodedSource(v); ok {
return src
}
return fmt.Sprintf("/p/stream/%s/%s", pathSegment(v.DriveID), pathSegment(v.FileID))
}
+73 -6
View File
@@ -810,7 +810,7 @@ func TestHandleTagsReturnsUnifiedTagPool(t *testing.T) {
}
}
func TestHandleShortsNextUsesPreferredVideoLeastPopulatedTag(t *testing.T) {
func TestHandleShortsNextReturnsRandomBatchExcludingSeen(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
if err != nil {
@@ -834,7 +834,7 @@ func TestHandleShortsNextUsesPreferredVideoLeastPopulatedTag(t *testing.T) {
}
}
req := httptest.NewRequest(http.MethodPost, "/api/shorts/next", strings.NewReader(`{"seenIds":["current"],"count":3,"preferredFromVideoId":"current"}`))
req := httptest.NewRequest(http.MethodPost, "/api/shorts/next", strings.NewReader(`{"seenIds":["current"],"count":3}`))
rr := httptest.NewRecorder()
(&Server{Catalog: cat}).handleShortsNext(rr, req)
@@ -857,10 +857,7 @@ func TestHandleShortsNextUsesPreferredVideoLeastPopulatedTag(t *testing.T) {
t.Fatalf("total = %d, want 4", got.Total)
}
if got.RoundComplete {
t.Fatalf("roundComplete = true, want false with fallback-filled batch")
}
if !containsString(ids, "rare-1") {
t.Fatalf("ids = %#v, want rare-1 from least populated tag", ids)
t.Fatalf("roundComplete = true, want false with a full remaining batch")
}
if containsString(ids, "current") {
t.Fatalf("ids = %#v, should exclude current", ids)
@@ -868,6 +865,76 @@ func TestHandleShortsNextUsesPreferredVideoLeastPopulatedTag(t *testing.T) {
if len(ids) != 3 {
t.Fatalf("ids = %#v, want 3 items", ids)
}
for _, want := range []string{"common-1", "common-2", "rare-1"} {
if !containsString(ids, want) {
t.Fatalf("ids = %#v, want remaining id %s", ids, want)
}
}
}
func TestHandleShortsNextDoesNotResetForStaleSeenIDs(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
if err != nil {
t.Fatalf("open catalog: %v", err)
}
t.Cleanup(func() {
if err := cat.Close(); err != nil {
t.Fatalf("close catalog: %v", err)
}
})
now := time.Now()
for _, v := range []*catalog.Video{
{ID: "seen-1", DriveID: "drive", FileID: "f-seen-1", Title: "seen 1", PublishedAt: now, CreatedAt: now, UpdatedAt: now},
{ID: "fresh-1", DriveID: "drive", FileID: "f-fresh-1", Title: "fresh 1", PublishedAt: now, CreatedAt: now, UpdatedAt: now},
{ID: "fresh-2", DriveID: "drive", FileID: "f-fresh-2", Title: "fresh 2", PublishedAt: now, CreatedAt: now, UpdatedAt: now},
{ID: "hidden-1", DriveID: "drive", FileID: "f-hidden-1", Title: "hidden 1", PublishedAt: now, CreatedAt: now, UpdatedAt: now},
} {
if err := cat.UpsertVideo(ctx, v); err != nil {
t.Fatalf("seed %s: %v", v.ID, err)
}
}
if err := cat.HideVideo(ctx, "hidden-1"); err != nil {
t.Fatalf("hide hidden-1: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/shorts/next", strings.NewReader(`{"seenIds":["seen-1","hidden-1","deleted-stale"],"count":3}`))
rr := httptest.NewRecorder()
(&Server{Catalog: cat}).handleShortsNext(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
var got struct {
Items []ShortsItemDTO `json:"items"`
Total int `json:"total"`
RoundComplete bool `json:"roundComplete"`
}
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
ids := make([]string, 0, len(got.Items))
for _, item := range got.Items {
ids = append(ids, item.ID)
}
if got.Total != 3 {
t.Fatalf("total = %d, want 3", got.Total)
}
if !got.RoundComplete {
t.Fatalf("roundComplete = false, want true after returning all unviewed visible videos")
}
if containsString(ids, "seen-1") || containsString(ids, "hidden-1") {
t.Fatalf("ids = %#v, should not reset and return seen or hidden videos", ids)
}
for _, want := range []string{"fresh-1", "fresh-2"} {
if !containsString(ids, want) {
t.Fatalf("ids = %#v, want %s", ids, want)
}
}
if len(ids) != 2 {
t.Fatalf("ids = %#v, want exactly the two unviewed visible videos", ids)
}
}
func TestHandleUpdateVideoTagsRejectsUnknownTags(t *testing.T) {
+225 -186
View File
@@ -51,38 +51,44 @@ func (c *Catalog) Close() error { return c.db.Close() }
// ---------- Video ----------
type Video struct {
ID string `json:"id"`
DriveID string `json:"driveId"`
FileID string `json:"fileId"`
FileName string `json:"fileName"`
ContentHash string `json:"contentHash"`
SampledSHA256 string `json:"sampledSha256"`
FingerprintStatus string `json:"fingerprintStatus"`
FingerprintError string `json:"fingerprintError"`
ParentID string `json:"parentId"`
Title string `json:"title"`
Author string `json:"author"`
Tags []string `json:"tags"`
DurationSeconds int `json:"durationSeconds"`
Size int64 `json:"size"`
Ext string `json:"ext"`
Quality string `json:"quality"`
ThumbnailURL string `json:"thumbnailUrl"`
PreviewFileID string `json:"previewFileId"`
PreviewLocal string `json:"previewLocal"`
PreviewStatus string `json:"previewStatus"`
Views int `json:"views"`
Favorites int `json:"favorites"`
Comments int `json:"comments"`
Likes int `json:"likes"`
Dislikes int `json:"dislikes"`
Category string `json:"category"`
Hidden bool `json:"hidden"`
Badges []string `json:"badges"`
Description string `json:"description"`
PublishedAt time.Time `json:"publishedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID string `json:"id"`
DriveID string `json:"driveId"`
FileID string `json:"fileId"`
FileName string `json:"fileName"`
ContentHash string `json:"contentHash"`
SampledSHA256 string `json:"sampledSha256"`
FingerprintStatus string `json:"fingerprintStatus"`
FingerprintError string `json:"fingerprintError"`
ParentID string `json:"parentId"`
Title string `json:"title"`
Author string `json:"author"`
Tags []string `json:"tags"`
DurationSeconds int `json:"durationSeconds"`
Size int64 `json:"size"`
Ext string `json:"ext"`
Quality string `json:"quality"`
ThumbnailURL string `json:"thumbnailUrl"`
PreviewFileID string `json:"previewFileId"`
PreviewLocal string `json:"previewLocal"`
PreviewStatus string `json:"previewStatus"`
// TranscodeStatus:浏览器兼容性转码状态。
// ''=未检测 / pending=已入队 / ready=已转码 / skipped=无需转码 / failed=失败。
TranscodeStatus string `json:"transcodeStatus"`
TranscodeError string `json:"transcodeError"`
TranscodedFileID string `json:"transcodedFileId"`
TranscodedSize int64 `json:"transcodedSize"`
Views int `json:"views"`
Favorites int `json:"favorites"`
Comments int `json:"comments"`
Likes int `json:"likes"`
Dislikes int `json:"dislikes"`
Category string `json:"category"`
Hidden bool `json:"hidden"`
Badges []string `json:"badges"`
Description string `json:"description"`
PublishedAt time.Time `json:"publishedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (c *Catalog) UpsertVideo(ctx context.Context, v *Video) error {
@@ -190,6 +196,84 @@ func (c *Catalog) UpdatePreview(ctx context.Context, id, previewLocal, status st
return err
}
// transcodeCandidateWhereSQL 圈定"可能需要浏览器兼容性转码"的视频:
// mp4/webm/m4v 默认浏览器可播不进候选;strm 是远程引用没有本体。
// 其余扩展名都先入候选,由转码 worker probe 实际编码后决定转码还是跳过
// skipped)。failed 也保留在候选里,重新点开始转码时会自动重试。
const transcodeCandidateWhereSQL = `COALESCE(ext, '') NOT IN ('mp4', 'webm', 'm4v', 'strm')
AND COALESCE(transcode_status, '') IN ('', 'pending', 'failed')`
// ListTranscodeCandidates 列出某盘所有转码候选视频。limit<=0 表示不限制。
func (c *Catalog) ListTranscodeCandidates(ctx context.Context, driveID string, limit int) ([]*Video, error) {
query := `SELECT ` + allVideoCols + ` FROM videos
WHERE drive_id = ? AND ` + transcodeCandidateWhereSQL + `
ORDER BY created_at ASC, id ASC`
args := []any{driveID}
if limit > 0 {
query += ` LIMIT ?`
args = append(args, limit)
}
rows, err := c.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Video
for rows.Next() {
v, err := scanVideo(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
// UpdateVideoTranscode 写回单条视频的转码结果。
// status=ready 时 transcodedFileID/transcodedSize 指向转码产物;
// 其它 status 调用方应传空值,本函数会按传入值原样覆盖。
func (c *Catalog) UpdateVideoTranscode(ctx context.Context, id, status, errMsg, transcodedFileID string, transcodedSize int64) error {
_, err := c.db.ExecContext(ctx,
`UPDATE videos SET transcode_status = ?, transcode_error = ?, transcoded_file_id = ?, transcoded_size = ?, updated_at = ? WHERE id = ?`,
status, errMsg, transcodedFileID, transcodedSize, time.Now().UnixMilli(), id)
return err
}
// DriveTranscodeCounts 是单盘的转码进度统计。
type DriveTranscodeCounts struct {
// Pending 是仍在候选集合里、还没有出结果的数量(含从未检测过的)。
Pending int
Ready int
Failed int
Skipped int
}
func (c *Catalog) CountTranscodesByDrive(ctx context.Context) (map[string]DriveTranscodeCounts, error) {
rows, err := c.db.QueryContext(ctx, `
SELECT drive_id,
COUNT(CASE WHEN COALESCE(ext, '') NOT IN ('mp4', 'webm', 'm4v', 'strm')
AND COALESCE(transcode_status, '') IN ('', 'pending') THEN 1 END) AS pending_count,
COUNT(CASE WHEN COALESCE(transcode_status, '') = 'ready' THEN 1 END) AS ready_count,
COUNT(CASE WHEN COALESCE(transcode_status, '') = 'failed' THEN 1 END) AS failed_count,
COUNT(CASE WHEN COALESCE(transcode_status, '') = 'skipped' THEN 1 END) AS skipped_count
FROM videos
GROUP BY drive_id`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]DriveTranscodeCounts)
for rows.Next() {
var driveID string
var counts DriveTranscodeCounts
if err := rows.Scan(&driveID, &counts.Pending, &counts.Ready, &counts.Failed, &counts.Skipped); err != nil {
return nil, err
}
out[driveID] = counts
}
return out, rows.Err()
}
func (c *Catalog) HideVideo(ctx context.Context, id string) error {
res, err := c.db.ExecContext(ctx,
`UPDATE videos SET hidden = 1, updated_at = ? WHERE id = ?`,
@@ -203,6 +287,27 @@ func (c *Catalog) HideVideo(ctx context.Context, id string) error {
return nil
}
// ListHiddenVideos 返回所有被标记隐藏(hidden=1)的视频。
// 仅用于一次性把历史「隐藏」视频迁移为黑名单墓碑——隐藏机制已废弃,
// 前台「不再展示」改走拉黑逻辑。
func (c *Catalog) ListHiddenVideos(ctx context.Context) ([]*Video, error) {
rows, err := c.db.QueryContext(ctx,
`SELECT `+allVideoCols+` FROM videos WHERE COALESCE(hidden, 0) = 1`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Video
for rows.Next() {
v, err := scanVideo(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
// MigrateVideoToDrive 把 catalog 里 id=videoID 这条视频迁移到另一个 drive。
// 用于 spider91 → PikPak 的迁移:上传成功后改写 drive_id / file_id /
// content_hash,保留视频自身的 idspider91-<driveID>-<sourceID>),这样
@@ -898,6 +1003,92 @@ func (c *Catalog) DeleteVideo(ctx context.Context, id string) error {
return tx.Commit()
}
// DeletedVideo 是黑名单(墓碑)表里的一条记录。原始视频行已删除,
// 这里只保留扫盘去重和后台展示需要的最小字段;没有 title/封面/作者。
type DeletedVideo struct {
ID string `json:"id"`
DriveID string `json:"driveId"`
FileID string `json:"fileId"`
FileName string `json:"fileName"`
Size int64 `json:"size"`
DeletedAt int64 `json:"deletedAt"` // unix 毫秒
}
// ListDeletedVideos 分页列出黑名单视频,按拉黑时间倒序。
// keyword 非空时按文件名模糊匹配。
func (c *Catalog) ListDeletedVideos(ctx context.Context, keyword string, page, size int) ([]*DeletedVideo, int, error) {
if size <= 0 {
size = 50
}
if page <= 0 {
page = 1
}
var where []string
var args []any
if kw := strings.TrimSpace(keyword); kw != "" {
where = append(where, "file_name LIKE ?")
args = append(args, "%"+kw+"%")
}
whereSQL := ""
if len(where) > 0 {
whereSQL = " WHERE " + strings.Join(where, " AND ")
}
var total int
if err := c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM deleted_videos`+whereSQL, args...).Scan(&total); err != nil {
return nil, 0, err
}
offset := (page - 1) * size
rows, err := c.db.QueryContext(ctx,
`SELECT id, COALESCE(drive_id, ''), COALESCE(file_id, ''), COALESCE(file_name, ''), COALESCE(size_bytes, 0), deleted_at
FROM deleted_videos`+whereSQL+`
ORDER BY deleted_at DESC
LIMIT ? OFFSET ?`,
append(args, size, offset)...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []*DeletedVideo
for rows.Next() {
v := &DeletedVideo{}
if err := rows.Scan(&v.ID, &v.DriveID, &v.FileID, &v.FileName, &v.Size, &v.DeletedAt); err != nil {
return nil, 0, err
}
out = append(out, v)
}
return out, total, rows.Err()
}
// RemoveDeletedVideo 把视频移出黑名单(删除墓碑)。移除后该视频会在
// 下次扫盘/凌晨流水线时被重新发现并入库,本函数不主动触发扫描。
func (c *Catalog) RemoveDeletedVideo(ctx context.Context, id string) error {
res, err := c.db.ExecContext(ctx, `DELETE FROM deleted_videos WHERE id = ?`, id)
if err != nil {
return err
}
if rows, err := res.RowsAffected(); err == nil && rows == 0 {
return sql.ErrNoRows
}
return nil
}
// VideoManagementCounts 返回后台视频管理两个标签的计数:
// current=当前可见(与「当前视频」页一致的去重+在线盘+hidden=0 口径),
// blacklisted=黑名单墓碑总数。
func (c *Catalog) VideoManagementCounts(ctx context.Context) (current, blacklisted int, err error) {
currentSQL := `SELECT COUNT(*) FROM videos WHERE COALESCE(hidden, 0) = 0 AND ` + activeDriveWhereSQL + ` AND ` + uniqueVideoWhereSQL
if err = c.db.QueryRowContext(ctx, currentSQL).Scan(&current); err != nil {
return 0, 0, err
}
if err = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM deleted_videos`).Scan(&blacklisted); err != nil {
return 0, 0, err
}
return current, blacklisted, nil
}
func (c *Catalog) IsVideoDeleted(ctx context.Context, id string) (bool, error) {
id = strings.TrimSpace(id)
if id == "" {
@@ -1342,160 +1533,6 @@ func cleanVideoIDs(ids []string) []string {
return cleaned
}
func cleanTagLabels(labels []string) []string {
seen := make(map[string]struct{}, len(labels))
cleaned := make([]string, 0, len(labels))
for _, label := range labels {
label = strings.TrimSpace(label)
if label == "" {
continue
}
key := strings.ToLower(label)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
cleaned = append(cleaned, label)
}
return cleaned
}
func (c *Catalog) LeastPopulatedVisibleUniqueTag(ctx context.Context, labels []string) (string, error) {
cleaned := cleanTagLabels(labels)
bestLabel := ""
bestCount := 0
for _, label := range cleaned {
var count int
if err := c.db.QueryRowContext(ctx,
`SELECT COUNT(*)
FROM videos
WHERE COALESCE(hidden, 0) = 0
AND `+activeDriveWhereSQL+`
AND `+uniqueVideoWhereSQL+`
AND EXISTS (
SELECT 1
FROM video_tags vt
JOIN tags t ON t.id = vt.tag_id
WHERE vt.video_id = videos.id
AND t.label = ? COLLATE NOCASE
)`,
label,
).Scan(&count); err != nil {
return "", err
}
if count == 0 {
continue
}
if bestLabel == "" || count < bestCount {
bestLabel = label
bestCount = count
}
}
return bestLabel, nil
}
func (c *Catalog) RandomVideosByTagExcluding(ctx context.Context, tag string, excludeIDs []string, limit int) ([]*Video, error) {
if limit <= 0 {
return nil, nil
}
tag = strings.TrimSpace(tag)
if tag == "" {
return nil, nil
}
cleaned := cleanVideoIDs(excludeIDs)
args := make([]any, 0, len(cleaned)+2)
args = append(args, tag)
whereSQL := `WHERE COALESCE(hidden, 0) = 0
AND ` + activeDriveWhereSQL + `
AND ` + uniqueVideoWhereSQL + `
AND EXISTS (
SELECT 1
FROM video_tags vt
JOIN tags t ON t.id = vt.tag_id
WHERE vt.video_id = videos.id
AND t.label = ? COLLATE NOCASE
)`
if len(cleaned) > 0 {
placeholders := strings.Repeat("?,", len(cleaned))
placeholders = placeholders[:len(placeholders)-1]
whereSQL += " AND id NOT IN (" + placeholders + ")"
for _, id := range cleaned {
args = append(args, id)
}
}
args = append(args, limit)
rows, err := c.db.QueryContext(ctx,
`SELECT `+allVideoCols+` FROM videos `+whereSQL+`
ORDER BY RANDOM() LIMIT ?`,
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Video
for rows.Next() {
v, err := scanVideo(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func (c *Catalog) RandomVideosForPreferredVideoExcluding(ctx context.Context, preferredVideoID string, excludeIDs []string, limit int) ([]*Video, error) {
if limit <= 0 {
return nil, nil
}
preferredVideoID = strings.TrimSpace(preferredVideoID)
if preferredVideoID == "" {
return c.RandomVideosExcluding(ctx, excludeIDs, limit)
}
preferredExclude := append([]string{}, excludeIDs...)
preferredExclude = append(preferredExclude, preferredVideoID)
preferred, err := c.GetVideo(ctx, preferredVideoID)
if err != nil || preferred == nil || preferred.Hidden || len(preferred.Tags) == 0 {
return c.RandomVideosExcluding(ctx, preferredExclude, limit)
}
tag, err := c.LeastPopulatedVisibleUniqueTag(ctx, preferred.Tags)
if err != nil {
return nil, err
}
if tag == "" {
return c.RandomVideosExcluding(ctx, preferredExclude, limit)
}
items, err := c.RandomVideosByTagExcluding(ctx, tag, preferredExclude, limit)
if err != nil {
return nil, err
}
if len(items) >= limit {
return items, nil
}
mergedExclude := make([]string, 0, len(preferredExclude)+len(items))
mergedExclude = append(mergedExclude, preferredExclude...)
for _, item := range items {
if item != nil {
mergedExclude = append(mergedExclude, item.ID)
}
}
fallback, err := c.RandomVideosExcluding(ctx, mergedExclude, limit-len(items))
if err != nil {
return nil, err
}
return append(items, fallback...), nil
}
type DriveTeaserCounts struct {
Ready int
Pending int
@@ -2165,6 +2202,7 @@ COALESCE(sampled_sha256, ''), COALESCE(fingerprint_status, 'pending'), COALESCE(
COALESCE(parent_id, ''), title, COALESCE(author, ''), COALESCE(tags, '[]'),
duration_seconds, size_bytes, COALESCE(ext, ''), COALESCE(quality, ''), COALESCE(thumbnail_url, ''),
COALESCE(preview_file_id, ''), COALESCE(preview_local, ''), COALESCE(preview_status, 'pending'),
COALESCE(transcode_status, ''), COALESCE(transcode_error, ''), COALESCE(transcoded_file_id, ''), COALESCE(transcoded_size, 0),
views, favorites, comments, likes, dislikes,
COALESCE(category, ''), COALESCE(hidden, 0), COALESCE(badges, '[]'), COALESCE(description, ''),
published_at, created_at, updated_at
@@ -2236,6 +2274,7 @@ func scanVideo(row rowScanner) (*Video, error) {
&v.ParentID, &v.Title, &v.Author, &tagsJSON,
&v.DurationSeconds, &v.Size, &v.Ext, &v.Quality, &v.ThumbnailURL,
&v.PreviewFileID, &v.PreviewLocal, &v.PreviewStatus,
&v.TranscodeStatus, &v.TranscodeError, &v.TranscodedFileID, &v.TranscodedSize,
&v.Views, &v.Favorites, &v.Comments, &v.Likes, &v.Dislikes,
&v.Category, &hidden, &badgesJSON, &v.Description,
&publishedAt, &createdAt, &updatedAt,
+4
View File
@@ -22,6 +22,10 @@ CREATE TABLE IF NOT EXISTS videos (
preview_file_id TEXT, -- deprecated: 旧版回写网盘后的预览视频 file id
preview_local TEXT, -- 本地预览视频路径(兜底)
preview_status TEXT DEFAULT 'pending', -- pending / ready / failed
transcode_status TEXT DEFAULT '', -- '' / pending / ready / skipped / failed(浏览器兼容性转码)
transcode_error TEXT DEFAULT '',
transcoded_file_id TEXT DEFAULT '', -- 转码产物在同一 drive 上的 fileID,播放源优先用它
transcoded_size INTEGER DEFAULT 0,
views INTEGER DEFAULT 0,
favorites INTEGER DEFAULT 0,
comments INTEGER DEFAULT 0,
-168
View File
@@ -165,171 +165,3 @@ func TestRandomVideosWithReadyThumbnailsExcluding(t *testing.T) {
}
}
}
func TestRandomVideosForPreferredVideoChoosesLeastPopulatedTag(t *testing.T) {
ctx := context.Background()
cat, err := Open(t.TempDir() + "/catalog.db")
if err != nil {
t.Fatalf("open catalog: %v", err)
}
t.Cleanup(func() { _ = cat.Close() })
now := time.Now()
for _, v := range []*Video{
{ID: "current", DriveID: "drive", FileID: "f-current", Title: "current", Tags: []string{"common", "rare"}, PublishedAt: now, CreatedAt: now, UpdatedAt: now},
{ID: "common-1", DriveID: "drive", FileID: "f-common-1", Title: "common 1", Tags: []string{"common"}, PublishedAt: now, CreatedAt: now, UpdatedAt: now},
{ID: "common-2", DriveID: "drive", FileID: "f-common-2", Title: "common 2", Tags: []string{"common"}, PublishedAt: now, CreatedAt: now, UpdatedAt: now},
{ID: "rare-1", DriveID: "drive", FileID: "f-rare-1", Title: "rare 1", Tags: []string{"rare"}, PublishedAt: now, CreatedAt: now, UpdatedAt: now},
} {
if err := cat.UpsertVideo(ctx, v); err != nil {
t.Fatalf("seed %s: %v", v.ID, err)
}
}
tag, err := cat.LeastPopulatedVisibleUniqueTag(ctx, []string{"common", "rare"})
if err != nil {
t.Fatalf("least populated tag: %v", err)
}
if tag != "rare" {
t.Fatalf("least populated tag = %q, want rare", tag)
}
got, err := cat.RandomVideosForPreferredVideoExcluding(ctx, "current", []string{"current"}, 1)
if err != nil {
t.Fatalf("random preferred: %v", err)
}
if len(got) != 1 || got[0].ID != "rare-1" {
t.Fatalf("preferred result = %#v, want rare-1", videoIDs(got))
}
got, err = cat.RandomVideosForPreferredVideoExcluding(ctx, "current", nil, 1)
if err != nil {
t.Fatalf("random preferred without explicit exclude: %v", err)
}
if len(got) != 1 || got[0].ID == "current" {
t.Fatalf("preferred result without explicit exclude = %#v, should not return current", videoIDs(got))
}
}
func TestRandomVideosForPreferredVideoFallsBackToFillBatch(t *testing.T) {
ctx := context.Background()
cat, err := Open(t.TempDir() + "/catalog.db")
if err != nil {
t.Fatalf("open catalog: %v", err)
}
t.Cleanup(func() { _ = cat.Close() })
now := time.Now()
for _, v := range []*Video{
{ID: "current", DriveID: "drive", FileID: "f-current", Title: "current", Tags: []string{"common", "rare"}, PublishedAt: now, CreatedAt: now, UpdatedAt: now},
{ID: "common-1", DriveID: "drive", FileID: "f-common-1", Title: "common 1", Tags: []string{"common"}, PublishedAt: now, CreatedAt: now, UpdatedAt: now},
{ID: "common-2", DriveID: "drive", FileID: "f-common-2", Title: "common 2", Tags: []string{"common"}, PublishedAt: now, CreatedAt: now, UpdatedAt: now},
{ID: "rare-1", DriveID: "drive", FileID: "f-rare-1", Title: "rare 1", Tags: []string{"rare"}, PublishedAt: now, CreatedAt: now, UpdatedAt: now},
{ID: "hidden-rare", DriveID: "drive", FileID: "f-hidden-rare", Title: "hidden rare", Tags: []string{"rare"}, PublishedAt: now, CreatedAt: now, UpdatedAt: now},
} {
if err := cat.UpsertVideo(ctx, v); err != nil {
t.Fatalf("seed %s: %v", v.ID, err)
}
}
if err := cat.HideVideo(ctx, "hidden-rare"); err != nil {
t.Fatalf("hide hidden-rare: %v", err)
}
got, err := cat.RandomVideosForPreferredVideoExcluding(ctx, "current", []string{"current"}, 3)
if err != nil {
t.Fatalf("random preferred: %v", err)
}
ids := videoIDs(got)
if len(ids) != 3 {
t.Fatalf("result ids = %#v, want 3 items", ids)
}
for _, excluded := range []string{"current", "hidden-rare"} {
if hasVideoID(ids, excluded) {
t.Fatalf("result ids = %#v, should not include %s", ids, excluded)
}
}
if !hasVideoID(ids, "rare-1") {
t.Fatalf("result ids = %#v, want rare-1 from least populated tag", ids)
}
if len(uniqueVideoIDs(ids)) != len(ids) {
t.Fatalf("result ids = %#v, want no duplicates", ids)
}
}
func TestRandomVideosForPreferredVideoFallbacksWhenPreferenceUnavailable(t *testing.T) {
ctx := context.Background()
cat, err := Open(t.TempDir() + "/catalog.db")
if err != nil {
t.Fatalf("open catalog: %v", err)
}
t.Cleanup(func() { _ = cat.Close() })
now := time.Now()
for _, v := range []*Video{
{ID: "untagged", DriveID: "drive", FileID: "f-untagged", Title: "untagged", PublishedAt: now, CreatedAt: now, UpdatedAt: now},
{ID: "visible-1", DriveID: "drive", FileID: "f-visible-1", Title: "visible 1", PublishedAt: now, CreatedAt: now, UpdatedAt: now},
{ID: "visible-2", DriveID: "drive", FileID: "f-visible-2", Title: "visible 2", PublishedAt: now, CreatedAt: now, UpdatedAt: now},
} {
if err := cat.UpsertVideo(ctx, v); err != nil {
t.Fatalf("seed %s: %v", v.ID, err)
}
}
got, err := cat.RandomVideosForPreferredVideoExcluding(ctx, "missing", []string{"untagged"}, 2)
if err != nil {
t.Fatalf("random missing preferred: %v", err)
}
if !sameVideoIDSet(videoIDs(got), []string{"visible-1", "visible-2"}) {
t.Fatalf("missing preferred ids = %#v, want visible fallback videos", videoIDs(got))
}
got, err = cat.RandomVideosForPreferredVideoExcluding(ctx, "untagged", []string{"untagged"}, 2)
if err != nil {
t.Fatalf("random untagged preferred: %v", err)
}
if !sameVideoIDSet(videoIDs(got), []string{"visible-1", "visible-2"}) {
t.Fatalf("untagged preferred ids = %#v, want visible fallback videos", videoIDs(got))
}
}
func videoIDs(videos []*Video) []string {
ids := make([]string, 0, len(videos))
for _, v := range videos {
ids = append(ids, v.ID)
}
return ids
}
func hasVideoID(ids []string, want string) bool {
for _, id := range ids {
if id == want {
return true
}
}
return false
}
func uniqueVideoIDs(ids []string) map[string]struct{} {
seen := make(map[string]struct{}, len(ids))
for _, id := range ids {
seen[id] = struct{}{}
}
return seen
}
func sameVideoIDSet(a, b []string) bool {
if len(a) != len(b) {
return false
}
seen := make(map[string]int, len(a))
for _, value := range a {
seen[value]++
}
for _, value := range b {
if seen[value] == 0 {
return false
}
seen[value]--
}
return true
}
+15
View File
@@ -66,6 +66,21 @@ func (c *Catalog) migrate(ctx context.Context) error {
if err := c.addColumnIfMissing(ctx, "videos", "thumbnail_failures", "INTEGER DEFAULT 0"); err != nil {
return err
}
// videos.transcode_*:浏览器兼容性转码状态。
// status''=未检测 / pending=已入队 / ready=已转码 / skipped=检测后无需转码 / failed=失败。
// transcoded_file_id 指向转码产物在同一 drive 上的 fileID,播放源优先使用它。
if err := c.addColumnIfMissing(ctx, "videos", "transcode_status", "TEXT DEFAULT ''"); err != nil {
return err
}
if err := c.addColumnIfMissing(ctx, "videos", "transcode_error", "TEXT DEFAULT ''"); err != nil {
return err
}
if err := c.addColumnIfMissing(ctx, "videos", "transcoded_file_id", "TEXT DEFAULT ''"); err != nil {
return err
}
if err := c.addColumnIfMissing(ctx, "videos", "transcoded_size", "INTEGER DEFAULT 0"); err != nil {
return err
}
// drives.teaser_enabled:每盘预览视频开关,替代旧的全局 preview.enabled。
// 升级路径:直接让 ALTER TABLE 的 DEFAULT 1 兜底 —— 每个现存 drive 都默认开启,
// 不读旧的 settings.preview.enabled 字段。这样老用户即便之前关过全局开关,
@@ -0,0 +1,133 @@
package catalog
import (
"context"
"testing"
"time"
)
// TestListHiddenVideosForMigration 验证:隐藏的视频不进可见列表,
// 但能被 ListHiddenVideos 拿到(供一次性迁移为墓碑)。
func TestListHiddenVideosForMigration(t *testing.T) {
ctx := context.Background()
cat, err := Open(t.TempDir() + "/catalog.db")
if err != nil {
t.Fatalf("open catalog: %v", err)
}
t.Cleanup(func() { _ = cat.Close() })
now := time.Now()
for _, id := range []string{"v1", "v2", "v3"} {
if err := cat.UpsertVideo(ctx, &Video{
ID: id, DriveID: "drive", FileID: "f-" + id, Title: id,
PublishedAt: now, CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatalf("seed %s: %v", id, err)
}
}
if err := cat.HideVideo(ctx, "v2"); err != nil {
t.Fatalf("hide v2: %v", err)
}
visible, total, err := cat.ListVideos(ctx, ListParams{Page: 1, PageSize: 50})
if err != nil {
t.Fatalf("list visible: %v", err)
}
if total != 2 || len(visible) != 2 {
t.Fatalf("visible total/len = %d/%d, want 2/2", total, len(visible))
}
for _, v := range visible {
if v.ID == "v2" {
t.Fatalf("hidden v2 leaked into visible list")
}
}
hidden, err := cat.ListHiddenVideos(ctx)
if err != nil {
t.Fatalf("list hidden: %v", err)
}
if len(hidden) != 1 || hidden[0].ID != "v2" {
t.Fatalf("ListHiddenVideos = %v, want only v2", hidden)
}
current, blacklisted, err := cat.VideoManagementCounts(ctx)
if err != nil {
t.Fatalf("counts: %v", err)
}
if current != 2 || blacklisted != 0 {
t.Fatalf("counts = current %d blacklisted %d, want 2/0", current, blacklisted)
}
}
// TestBlacklistListAndRemove 验证墓碑表的列出、关键字过滤和移除。
func TestBlacklistListAndRemove(t *testing.T) {
ctx := context.Background()
cat, err := Open(t.TempDir() + "/catalog.db")
if err != nil {
t.Fatalf("open catalog: %v", err)
}
t.Cleanup(func() { _ = cat.Close() })
now := time.Now()
seed := []struct{ id, file string }{
{"d1", "movie-alpha.avi"},
{"d2", "movie-beta.mp4"},
{"d3", "clip-gamma.wmv"},
}
for _, s := range seed {
if err := cat.UpsertVideo(ctx, &Video{
ID: s.id, DriveID: "drive", FileID: "f-" + s.id, FileName: s.file,
Title: s.id, PublishedAt: now, CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatalf("seed %s: %v", s.id, err)
}
if err := cat.DeleteVideoWithTombstone(ctx, s.id); err != nil {
t.Fatalf("tombstone %s: %v", s.id, err)
}
}
items, total, err := cat.ListDeletedVideos(ctx, "", 1, 50)
if err != nil {
t.Fatalf("list deleted: %v", err)
}
if total != 3 || len(items) != 3 {
t.Fatalf("deleted total/len = %d/%d, want 3/3", total, len(items))
}
// 关键字过滤
filtered, ftotal, err := cat.ListDeletedVideos(ctx, "movie", 1, 50)
if err != nil {
t.Fatalf("list deleted filtered: %v", err)
}
if ftotal != 2 || len(filtered) != 2 {
t.Fatalf("filtered total/len = %d/%d, want 2/2", ftotal, len(filtered))
}
// 移出黑名单
if err := cat.RemoveDeletedVideo(ctx, "d1"); err != nil {
t.Fatalf("remove d1: %v", err)
}
if deleted, err := cat.IsVideoDeleted(ctx, "d1"); err != nil || deleted {
t.Fatalf("d1 should no longer be blacklisted (deleted=%v err=%v)", deleted, err)
}
_, total, err = cat.ListDeletedVideos(ctx, "", 1, 50)
if err != nil {
t.Fatalf("list deleted after remove: %v", err)
}
if total != 2 {
t.Fatalf("deleted total after remove = %d, want 2", total)
}
if err := cat.RemoveDeletedVideo(ctx, "does-not-exist"); err == nil {
t.Fatalf("remove missing id should return error")
}
// counts: 删完一个还剩 2 个黑名单;可见视频已全部被墓碑删除
current, blacklisted, err := cat.VideoManagementCounts(ctx)
if err != nil {
t.Fatalf("counts: %v", err)
}
if current != 0 || blacklisted != 2 {
t.Fatalf("counts = current %d blacklisted %d, want 0/2", current, blacklisted)
}
}
+13 -6
View File
@@ -23,17 +23,24 @@ const maxSTRMBytes = 64 * 1024
type Config struct {
ID string
RootPath string
// STRMAllowOutsideRoot 允许 .strm 指向存储根目录之外的本地路径。
// 默认关闭:strm 等于可以让 /p/stream 读到服务器上的任意文件,只有
// 管理员明确知道自己在做什么(例如 strm 库与 rclone 挂载目录分离)
// 时才应打开。
STRMAllowOutsideRoot bool
}
type Driver struct {
id string
rootPath string
id string
rootPath string
strmAllowOutsideRoot bool
}
func New(c Config) *Driver {
return &Driver{
id: c.ID,
rootPath: c.RootPath,
id: c.ID,
rootPath: c.RootPath,
strmAllowOutsideRoot: c.STRMAllowOutsideRoot,
}
}
@@ -230,8 +237,8 @@ func (d *Driver) localSTRMLink(strmPath, target string) (*drives.StreamLink, err
if err != nil {
return nil, err
}
if !within {
return nil, errors.New("localstorage: strm target escapes root")
if !within && !d.strmAllowOutsideRoot {
return nil, errors.New("localstorage: strm target escapes root (enable strm_allow_outside_root to allow)")
}
if strings.EqualFold(filepath.Ext(p), ".strm") || strings.EqualFold(filepath.Ext(realPath), ".strm") {
return nil, errors.New("localstorage: nested strm target is not supported")
@@ -195,6 +195,46 @@ func TestStreamURLRejectsSTRMTargetEscapingRootThroughSymlink(t *testing.T) {
}
}
func TestStreamURLAllowsSTRMTargetOutsideRootWhenEnabled(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
target := filepath.Join(outside, "movie.mp4")
writeLocalStorageTestFile(t, target, []byte("movie-data"))
writeLocalStorageTestFile(t, filepath.Join(root, "movie.strm"), []byte(target+"\n"))
// 默认关闭:根目录外的目标仍被拒绝
strict := New(Config{ID: "local", RootPath: root})
if _, err := strict.StreamURL(context.Background(), encodeRel("movie.strm")); err == nil || !strings.Contains(err.Error(), "strm target escapes root") {
t.Fatalf("default error = %v, want strm target escapes root", err)
}
// 开启 strm_allow_outside_root 后放行
relaxed := New(Config{ID: "local", RootPath: root, STRMAllowOutsideRoot: true})
link, err := relaxed.StreamURL(context.Background(), encodeRel("movie.strm"))
if err != nil {
t.Fatalf("StreamURL with allow-outside-root: %v", err)
}
resolved, err := filepath.EvalSymlinks(target)
if err != nil {
t.Fatalf("eval target: %v", err)
}
if link.URL != resolved {
t.Fatalf("link url = %q, want %q", link.URL, resolved)
}
}
func TestStreamURLAllowOutsideRootStillRejectsNestedSTRM(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
writeLocalStorageTestFile(t, filepath.Join(outside, "inner.strm"), []byte("http://example.com/v.mp4\n"))
writeLocalStorageTestFile(t, filepath.Join(root, "movie.strm"), []byte(filepath.Join(outside, "inner.strm")+"\n"))
drv := New(Config{ID: "local", RootPath: root, STRMAllowOutsideRoot: true})
if _, err := drv.StreamURL(context.Background(), encodeRel("movie.strm")); err == nil || !strings.Contains(err.Error(), "nested strm") {
t.Fatalf("error = %v, want nested strm rejection", err)
}
}
func TestStreamURLRejectsSymlinkFileIDEscapingRoot(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
+178
View File
@@ -0,0 +1,178 @@
// Package transcode 实现"浏览器兼容性转码":把网盘/本地存储中浏览器
// <video> 播不动的视频(AVI/WMV/FLV、MPEG-4 Part 2、RMVB 等)转成
// H.264 + AAC 的 MP4,并把产物上传回同一存储,播放源切到产物文件。
//
// 与封面/预览生成不同,转码不会自动运行——只能由管理员在网盘管理页
// 手动开启,也可以随时手动停止。
package transcode
import (
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
)
// MediaInfo 是 ffprobe 探测出来的、做兼容性判定所需的最小信息。
type MediaInfo struct {
// FormatName 是 ffprobe 的 format_name,逗号分隔的 demuxer 别名,
// 例如 "mov,mp4,m4a,3gp,3g2,mj2" / "avi" / "matroska,webm"。
FormatName string
VideoCodecs []string
AudioCodecs []string
}
// browserCompatibleVideoCodecs 是主流浏览器 <video> 普遍可解码的视频编码。
// HEVC/H.265 只有部分平台支持,保守起见不算兼容。
var browserCompatibleVideoCodecs = map[string]bool{
"h264": true,
"vp8": true,
"vp9": true,
"av1": true,
}
// browserCompatibleAudioCodecs 是主流浏览器普遍可解码的音频编码。
var browserCompatibleAudioCodecs = map[string]bool{
"aac": true,
"mp3": true,
"opus": true,
"vorbis": true,
"flac": true,
}
// NeedsTranscode 判断这个文件是否需要转码才能在浏览器里播放。
// ext 是 catalog 里记录的扩展名(小写、不带点),用来区分 mkv 和 webm
// (两者的 format_name 都是 "matroska,webm")。
func NeedsTranscode(info MediaInfo, ext string) bool {
if !containerCompatible(info.FormatName, ext) {
return true
}
for _, codec := range info.VideoCodecs {
if !browserCompatibleVideoCodecs[strings.ToLower(codec)] {
return true
}
}
for _, codec := range info.AudioCodecs {
if !browserCompatibleAudioCodecs[strings.ToLower(codec)] {
return true
}
}
return false
}
func containerCompatible(formatName, ext string) bool {
format := strings.ToLower(formatName)
for _, name := range strings.Split(format, ",") {
if name == "mp4" {
return true
}
}
// matroska,webm:只有真 .webm 信任为浏览器可播容器;.mkv 保守转码。
if strings.Contains(format, "webm") && strings.EqualFold(ext, "webm") {
return true
}
return false
}
// ProbeFile 用 ffprobe 探测本地文件的容器与音视频编码。
func ProbeFile(ctx context.Context, ffprobePath, path string) (MediaInfo, error) {
ctx2, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx2, ffprobePath,
"-v", "error",
"-show_entries", "format=format_name",
"-show_entries", "stream=codec_type,codec_name",
"-of", "json",
path,
)
out, err := cmd.Output()
if err != nil {
return MediaInfo{}, fmt.Errorf("transcode: ffprobe: %w", err)
}
var parsed struct {
Format struct {
FormatName string `json:"format_name"`
} `json:"format"`
Streams []struct {
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
} `json:"streams"`
}
if err := json.Unmarshal(out, &parsed); err != nil {
return MediaInfo{}, fmt.Errorf("transcode: parse ffprobe output: %w", err)
}
info := MediaInfo{FormatName: parsed.Format.FormatName}
for _, s := range parsed.Streams {
switch s.CodecType {
case "video":
info.VideoCodecs = append(info.VideoCodecs, s.CodecName)
case "audio":
info.AudioCodecs = append(info.AudioCodecs, s.CodecName)
}
}
return info, nil
}
// buildFFmpegArgs 按探测结果生成转码参数:
// - 编码本就兼容、只是容器不行(如 AVI 里装 H.264)→ 流拷贝 remux,零质量损失;
// - 否则视频转 H.264(裁到偶数尺寸 + yuv420p 保证兼容性)、音频转 AAC。
//
// 两种情况都加 +faststart 把 moov 提前,便于边下边播。
func buildFFmpegArgs(info MediaInfo, inPath, outPath string) []string {
args := []string{"-y", "-i", inPath}
videoOK := true
for _, codec := range info.VideoCodecs {
if !browserCompatibleVideoCodecs[strings.ToLower(codec)] {
videoOK = false
break
}
}
audioOK := true
for _, codec := range info.AudioCodecs {
if !browserCompatibleAudioCodecs[strings.ToLower(codec)] {
audioOK = false
break
}
}
if videoOK {
args = append(args, "-c:v", "copy")
} else {
args = append(args,
"-c:v", "libx264",
"-preset", "veryfast",
"-crf", "23",
"-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2",
"-pix_fmt", "yuv420p",
)
}
if len(info.AudioCodecs) == 0 {
args = append(args, "-an")
} else if audioOK {
args = append(args, "-c:a", "copy")
} else {
args = append(args, "-c:a", "aac", "-b:a", "128k")
}
args = append(args, "-movflags", "+faststart", "-f", "mp4", outPath)
return args
}
// TranscodeFile 把本地输入文件转成浏览器可播的 MP4 写到 outPath。
func TranscodeFile(ctx context.Context, ffmpegPath string, info MediaInfo, inPath, outPath string) error {
args := buildFFmpegArgs(info, inPath, outPath)
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("transcode: ffmpeg: %w: %s", err, tailOf(string(out), 400))
}
return nil
}
func tailOf(s string, n int) string {
s = strings.TrimSpace(s)
if len(s) <= n {
return s
}
return s[len(s)-n:]
}
@@ -0,0 +1,125 @@
package transcode
import (
"strings"
"testing"
"github.com/video-site/backend/internal/catalog"
)
func TestNeedsTranscode(t *testing.T) {
cases := []struct {
name string
info MediaInfo
ext string
want bool
}{
{
name: "h264 aac mp4 is compatible",
info: MediaInfo{FormatName: "mov,mp4,m4a,3gp,3g2,mj2", VideoCodecs: []string{"h264"}, AudioCodecs: []string{"aac"}},
ext: "mp4",
want: false,
},
{
name: "mpeg4 in avi needs transcode",
info: MediaInfo{FormatName: "avi", VideoCodecs: []string{"mpeg4"}, AudioCodecs: []string{"mp3"}},
ext: "avi",
want: true,
},
{
name: "h264 in avi needs remux",
info: MediaInfo{FormatName: "avi", VideoCodecs: []string{"h264"}, AudioCodecs: []string{"aac"}},
ext: "avi",
want: true,
},
{
name: "hevc in mp4 needs transcode",
info: MediaInfo{FormatName: "mov,mp4,m4a,3gp,3g2,mj2", VideoCodecs: []string{"hevc"}, AudioCodecs: []string{"aac"}},
ext: "mp4",
want: true,
},
{
name: "vp9 opus webm is compatible",
info: MediaInfo{FormatName: "matroska,webm", VideoCodecs: []string{"vp9"}, AudioCodecs: []string{"opus"}},
ext: "webm",
want: false,
},
{
name: "h264 in mkv is conservative transcode",
info: MediaInfo{FormatName: "matroska,webm", VideoCodecs: []string{"h264"}, AudioCodecs: []string{"aac"}},
ext: "mkv",
want: true,
},
{
name: "pcm audio in mov needs transcode",
info: MediaInfo{FormatName: "mov,mp4,m4a,3gp,3g2,mj2", VideoCodecs: []string{"h264"}, AudioCodecs: []string{"pcm_s16le"}},
ext: "mov",
want: true,
},
{
name: "video only h264 mp4 is compatible",
info: MediaInfo{FormatName: "mov,mp4,m4a,3gp,3g2,mj2", VideoCodecs: []string{"h264"}},
ext: "mp4",
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := NeedsTranscode(tc.info, tc.ext); got != tc.want {
t.Fatalf("NeedsTranscode(%+v, %q) = %v, want %v", tc.info, tc.ext, got, tc.want)
}
})
}
}
func TestBuildFFmpegArgsRemuxWhenCodecsCompatible(t *testing.T) {
// AVI 里装 H.264+AAC:只需要换容器,应该走流拷贝
info := MediaInfo{FormatName: "avi", VideoCodecs: []string{"h264"}, AudioCodecs: []string{"aac"}}
args := strings.Join(buildFFmpegArgs(info, "in.avi", "out.mp4"), " ")
if !strings.Contains(args, "-c:v copy") {
t.Fatalf("expected video stream copy, got: %s", args)
}
if !strings.Contains(args, "-c:a copy") {
t.Fatalf("expected audio stream copy, got: %s", args)
}
if !strings.Contains(args, "+faststart") {
t.Fatalf("expected faststart flag, got: %s", args)
}
}
func TestBuildFFmpegArgsTranscodesIncompatibleCodecs(t *testing.T) {
info := MediaInfo{FormatName: "avi", VideoCodecs: []string{"mpeg4"}, AudioCodecs: []string{"wmav2"}}
args := strings.Join(buildFFmpegArgs(info, "in.avi", "out.mp4"), " ")
if !strings.Contains(args, "-c:v libx264") {
t.Fatalf("expected libx264 video encode, got: %s", args)
}
if !strings.Contains(args, "-c:a aac") {
t.Fatalf("expected aac audio encode, got: %s", args)
}
if !strings.Contains(args, "yuv420p") {
t.Fatalf("expected yuv420p pixel format, got: %s", args)
}
}
func TestBuildFFmpegArgsDropsAudioWhenNoAudioStream(t *testing.T) {
info := MediaInfo{FormatName: "avi", VideoCodecs: []string{"mpeg4"}}
args := strings.Join(buildFFmpegArgs(info, "in.avi", "out.mp4"), " ")
if !strings.Contains(args, "-an") {
t.Fatalf("expected -an for video without audio, got: %s", args)
}
}
func TestTranscodedName(t *testing.T) {
for _, tc := range []struct {
fileName, title, id, want string
}{
{"www.98T.la@167.avi", "www.98T.la@167", "p115-1", "www.98T.la@167.mp4"},
{"", "标题", "p115-2", "标题.mp4"},
{"a/b\\c.wmv", "", "p115-3", "a_b_c.mp4"},
} {
v := &catalog.Video{FileName: tc.fileName, Title: tc.title, ID: tc.id}
if got := transcodedName(v); got != tc.want {
t.Fatalf("transcodedName(%q,%q,%q) = %q, want %q", tc.fileName, tc.title, tc.id, got, tc.want)
}
}
}
+308
View File
@@ -0,0 +1,308 @@
package transcode
import (
"context"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/video-site/backend/internal/catalog"
"github.com/video-site/backend/internal/drives"
)
// DefaultTargetDirName 是转码产物在网盘上的存放目录(相对根目录)。
// worker 第一次上传前会 EnsureDir 并把该目录加进 drive 的扫描跳过列表,
// 避免 scanner 把转码产物当成新视频重复入库。
const DefaultTargetDirName = "91转码"
type Config struct {
FFmpegPath string
FFprobePath string
// WorkDir 是下载原始文件 / 写转码产物的本地临时目录。
WorkDir string
// TargetDirName 为空时用 DefaultTargetDirName。
TargetDirName string
}
// TaskStatus 与 preview/fingerprint worker 的状态结构对齐,供 admin 展示。
type TaskStatus struct {
State string
CurrentTitle string
QueueLength int
DoneCount int
TotalCount int
}
// Worker 串行处理一个 drive 的转码任务。生命周期与一次"开始转码"对应:
// Run 处理完整个候选列表(或 ctx 被取消)后即结束,不常驻。
type Worker struct {
cfg Config
cat *catalog.Catalog
drv drives.Drive
hc *http.Client
mu sync.Mutex
state string
currentTitle string
done int
total int
targetDirOnce sync.Once
targetDirID string
targetDirErr error
}
func NewWorker(cfg Config, cat *catalog.Catalog, drv drives.Drive) *Worker {
if cfg.FFmpegPath == "" {
cfg.FFmpegPath = "ffmpeg"
}
if cfg.FFprobePath == "" {
cfg.FFprobePath = "ffprobe"
}
if cfg.TargetDirName == "" {
cfg.TargetDirName = DefaultTargetDirName
}
if cfg.WorkDir == "" {
cfg.WorkDir = os.TempDir()
}
return &Worker{
cfg: cfg,
cat: cat,
drv: drv,
hc: &http.Client{Timeout: 0},
state: "idle",
}
}
func (w *Worker) Status() TaskStatus {
w.mu.Lock()
defer w.mu.Unlock()
queueLen := w.total - w.done
if w.state == "generating" && queueLen > 0 {
// 正在处理的那条不算"排队中"
queueLen--
}
if queueLen < 0 {
queueLen = 0
}
return TaskStatus{
State: w.state,
CurrentTitle: w.currentTitle,
QueueLength: queueLen,
DoneCount: w.done,
TotalCount: w.total,
}
}
// Run 串行转码整个候选列表。ctx 取消时停在当前条目边界(正在跑的 ffmpeg
// 会被 CommandContext 杀掉),未处理的候选保持原状态,下次开始时继续。
func (w *Worker) Run(ctx context.Context, videos []*catalog.Video) {
w.mu.Lock()
w.state = "generating"
w.total = len(videos)
w.done = 0
w.mu.Unlock()
defer func() {
w.mu.Lock()
w.state = "idle"
w.currentTitle = ""
w.mu.Unlock()
}()
for _, v := range videos {
if ctx.Err() != nil {
log.Printf("[transcode] drive=%s canceled after %d/%d", w.drv.ID(), w.doneCount(), len(videos))
return
}
w.mu.Lock()
w.currentTitle = v.Title
w.mu.Unlock()
if err := w.process(ctx, v); err != nil {
if ctx.Err() != nil {
// 取消导致的失败不要写 failed,保持候选状态便于下次继续
log.Printf("[transcode] drive=%s canceled while processing %s", w.drv.ID(), v.ID)
return
}
log.Printf("[transcode] drive=%s video=%s failed: %v", w.drv.ID(), v.ID, err)
if uerr := w.cat.UpdateVideoTranscode(context.WithoutCancel(ctx), v.ID, "failed", err.Error(), "", 0); uerr != nil {
log.Printf("[transcode] mark failed %s: %v", v.ID, uerr)
}
}
w.mu.Lock()
w.done++
w.mu.Unlock()
}
log.Printf("[transcode] drive=%s finished %d videos", w.drv.ID(), len(videos))
}
func (w *Worker) doneCount() int {
w.mu.Lock()
defer w.mu.Unlock()
return w.done
}
func (w *Worker) process(ctx context.Context, v *catalog.Video) error {
localPath, cleanup, err := w.fetchSource(ctx, v)
if err != nil {
return fmt.Errorf("fetch source: %w", err)
}
defer cleanup()
info, err := ProbeFile(ctx, w.cfg.FFprobePath, localPath)
if err != nil {
return err
}
if !NeedsTranscode(info, v.Ext) {
log.Printf("[transcode] drive=%s video=%s compatible (%s), skip", w.drv.ID(), v.ID, info.FormatName)
return w.cat.UpdateVideoTranscode(ctx, v.ID, "skipped", "", "", 0)
}
outPath := filepath.Join(w.cfg.WorkDir, sanitizeFileName(v.ID)+".transcoding.mp4")
defer os.Remove(outPath)
if err := TranscodeFile(ctx, w.cfg.FFmpegPath, info, localPath, outPath); err != nil {
return err
}
stat, err := os.Stat(outPath)
if err != nil {
return fmt.Errorf("stat transcoded output: %w", err)
}
dirID, err := w.ensureTargetDir(ctx)
if err != nil {
return fmt.Errorf("ensure target dir: %w", err)
}
f, err := os.Open(outPath)
if err != nil {
return err
}
defer f.Close()
fileID, err := w.drv.Upload(ctx, dirID, transcodedName(v), f, stat.Size())
if err != nil {
return fmt.Errorf("upload transcoded file: %w", err)
}
log.Printf("[transcode] drive=%s video=%s ready: file=%s size=%d", w.drv.ID(), v.ID, fileID, stat.Size())
return w.cat.UpdateVideoTranscode(ctx, v.ID, "ready", "", fileID, stat.Size())
}
// fetchSource 把原始文件准备成本地路径。本地存储直接复用源路径(cleanup
// 不删除源文件);云盘则整文件下载到 WorkDir。
func (w *Worker) fetchSource(ctx context.Context, v *catalog.Video) (string, func(), error) {
link, err := w.drv.StreamURL(ctx, v.FileID)
if err != nil {
return "", nil, err
}
u, err := url.Parse(link.URL)
if isLocal := err == nil && u.Scheme != "http" && u.Scheme != "https"; isLocal {
path := link.URL
if err == nil && u.Scheme == "file" {
path = u.Path
}
return path, func() {}, nil
}
tmpPath := filepath.Join(w.cfg.WorkDir, sanitizeFileName(v.ID)+".src.tmp")
cleanup := func() { os.Remove(tmpPath) }
if err := w.downloadTo(ctx, link, tmpPath); err != nil {
cleanup()
return "", nil, err
}
return tmpPath, cleanup, nil
}
func (w *Worker) downloadTo(ctx context.Context, link *drives.StreamLink, dst string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link.URL, nil)
if err != nil {
return err
}
for k, vals := range link.Headers {
for _, val := range vals {
req.Header.Add(k, val)
}
}
res, err := w.hc.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
return fmt.Errorf("download source: HTTP %d", res.StatusCode)
}
f, err := os.Create(dst)
if err != nil {
return err
}
defer f.Close()
if _, err := io.Copy(f, res.Body); err != nil {
return fmt.Errorf("download source: %w", err)
}
return f.Sync()
}
// ensureTargetDir 确保网盘上的转码产物目录存在,并把它写进 drive 的扫描
// 跳过列表(幂等),避免 scanner 把产物再当新视频收进库。
func (w *Worker) ensureTargetDir(ctx context.Context) (string, error) {
w.targetDirOnce.Do(func() {
dirID, err := w.drv.EnsureDir(ctx, w.cfg.TargetDirName)
if err != nil {
w.targetDirErr = err
return
}
w.targetDirID = dirID
if err := w.addDirToSkipList(ctx, dirID); err != nil {
// 跳过列表更新失败不阻塞转码,只记日志(最坏情况是 scanner
// 之后把产物扫成新视频,可手动加跳过目录修复)。
log.Printf("[transcode] drive=%s add skip dir %s: %v", w.drv.ID(), dirID, err)
}
})
return w.targetDirID, w.targetDirErr
}
func (w *Worker) addDirToSkipList(ctx context.Context, dirID string) error {
d, err := w.cat.GetDrive(ctx, w.drv.ID())
if err != nil {
return err
}
for _, existing := range d.SkipDirIDs {
if existing == dirID {
return nil
}
}
return w.cat.SetDriveSkipDirIDs(ctx, w.drv.ID(), append(d.SkipDirIDs, dirID))
}
// transcodedName 生成产物文件名:原文件名去掉扩展名 + .mp4。
func transcodedName(v *catalog.Video) string {
base := strings.TrimSpace(v.FileName)
if base == "" {
base = v.Title
}
if base == "" {
base = v.ID
}
if ext := filepath.Ext(base); ext != "" {
base = strings.TrimSuffix(base, ext)
}
return sanitizeFileName(base) + ".mp4"
}
// sanitizeFileName 把路径分隔符等危险字符替换掉,避免拼出意外路径。
func sanitizeFileName(name string) string {
replacer := strings.NewReplacer(
"/", "_", "\\", "_", ":", "_", "*", "_", "?", "_",
"\"", "_", "<", "_", ">", "_", "|", "_", "\x00", "_",
)
out := strings.TrimSpace(replacer.Replace(name))
if out == "" {
out = fmt.Sprintf("transcoded-%d", time.Now().UnixMilli())
}
return out
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "video-site",
"version": "0.1.6",
"version": "0.1.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "video-site",
"version": "0.1.6",
"version": "0.1.7",
"license": "MIT",
"dependencies": {
"artplayer": "^5.4.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "video-site",
"private": true,
"license": "MIT",
"version": "0.1.6",
"version": "0.1.7",
"type": "module",
"scripts": {
"dev": "vite",
+42
View File
@@ -48,6 +48,7 @@ function isDriveBusy(d: api.AdminDrive) {
d.thumbnailGenerationStatus,
d.previewGenerationStatus,
d.fingerprintGenerationStatus,
d.transcodeGenerationStatus,
].some((status) => {
const state = status?.state || "idle";
return state !== "idle";
@@ -74,6 +75,7 @@ export function DrivesPage() {
const [regenFailedThumbId, setRegenFailedThumbId] = useState("");
const [regenFailedFingerprintId, setRegenFailedFingerprintId] = useState("");
const [togglingTeaserId, setTogglingTeaserId] = useState("");
const [togglingTranscodeId, setTogglingTranscodeId] = useState("");
const [scanningAll, setScanningAll] = useState(false);
const [stoppingAll, setStoppingAll] = useState(false);
const [trackingNightly, setTrackingNightly] = useState(false);
@@ -215,6 +217,8 @@ export function DrivesPage() {
? { proxy: d.spider91Proxy ?? "" }
: d.kind === "googledrive"
? { use_online_api: (d.googleDriveUseOnlineAPI ?? true) ? "true" : "false" }
: d.kind === "localstorage"
? { strm_allow_outside_root: (d.strmAllowOutsideRoot ?? false) ? "true" : "false" }
: {},
spider91UploadDriveId: settings?.spider91UploadDriveId ?? "",
};
@@ -499,6 +503,41 @@ export function DrivesPage() {
}
}
async function handleStartTranscode(d: api.AdminDrive) {
setTogglingTranscodeId(d.id);
try {
const resp = await api.startDriveTranscode(d.id);
if (resp.accepted) {
show(`已开始「${d.name || d.id}」的视频转码`, "success");
} else {
show(resp.message || "转码任务未能开启", "info");
}
refreshDriveList();
} catch (e) {
show(e instanceof Error ? e.message : "开启失败", "error");
} finally {
setTogglingTranscodeId("");
}
}
async function handleStopTranscode(d: api.AdminDrive) {
setTogglingTranscodeId(d.id);
try {
const resp = await api.stopDriveTranscode(d.id);
show(
resp.stopped
? `已停止「${d.name || d.id}」的视频转码`
: `${d.name || d.id}」没有正在运行的转码任务`,
"success"
);
refreshDriveList();
} catch (e) {
show(e instanceof Error ? e.message : "停止失败", "error");
} finally {
setTogglingTranscodeId("");
}
}
const selectedDrive = useMemo(() => {
return selectedDriveId ? list.find((d) => d.id === selectedDriveId) : null;
}, [selectedDriveId, list]);
@@ -634,10 +673,13 @@ export function DrivesPage() {
regenFailedThumbId={regenFailedThumbId}
regenFailedFingerprintId={regenFailedFingerprintId}
togglingTeaserId={togglingTeaserId}
togglingTranscodeId={togglingTranscodeId}
onToggleTeaser={() => handleToggleTeaser(d)}
onRegenFailed={() => handleRegenFailed(d)}
onRegenFailedThumbnails={() => handleRegenFailedThumbnails(d)}
onRegenFailedFingerprints={() => handleRegenFailedFingerprints(d)}
onStartTranscode={() => handleStartTranscode(d)}
onStopTranscode={() => handleStopTranscode(d)}
/>
<div className="admin-detail-card">
+491 -174
View File
@@ -1,5 +1,17 @@
import { useEffect, useId, useState } from "react";
import { ChevronDown, Edit, RefreshCw, Search, CheckSquare, Square, Image, Trash2 } from "lucide-react";
import { useSearchParams } from "react-router-dom";
import {
ChevronDown,
Edit,
RefreshCw,
Search,
CheckSquare,
Square,
Image,
Trash2,
Ban,
RotateCcw,
} from "lucide-react";
import * as api from "./api";
import { useToast } from "./ToastContext";
import { Modal } from "./Modal";
@@ -10,7 +22,85 @@ const DESKTOP_VIDEOS_PAGE_SIZE = 50;
const MOBILE_VIDEOS_PAGE_SIZE = 20;
const VIDEOS_MOBILE_QUERY = "(max-width: 640px)";
type TabKey = "current" | "blacklist";
const TABS: { key: TabKey; label: string }[] = [
{ key: "current", label: "当前视频" },
{ key: "blacklist", label: "拉黑视频" },
];
/**
* / /
* URL ?tab= /videos/stats
*/
export function VideosPage() {
const [searchParams, setSearchParams] = useSearchParams();
const rawTab = searchParams.get("tab");
const activeTab: TabKey = rawTab === "blacklist" ? "blacklist" : "current";
const [stats, setStats] = useState<api.VideoStats | null>(null);
async function refreshStats() {
try {
setStats(await api.getVideoStats());
} catch {
// 计数仅用于标签徽标,失败不阻塞主流程。
}
}
useEffect(() => {
refreshStats();
}, []);
function selectTab(key: TabKey) {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (key === "current") next.delete("tab");
else next.set("tab", key);
return next;
},
{ replace: true }
);
}
const counts: Record<TabKey, number | undefined> = {
current: stats?.current,
blacklist: stats?.blacklisted,
};
return (
<section>
<header className="admin-page__header">
<h1 className="admin-page__title"></h1>
</header>
<div className="admin-video-tabs" role="tablist" aria-label="视频管理分类">
{TABS.map((t) => (
<button
key={t.key}
type="button"
role="tab"
aria-selected={activeTab === t.key}
className={`admin-video-tab ${activeTab === t.key ? "is-active" : ""}`}
onClick={() => selectTab(t.key)}
>
<span>{t.label}</span>
{counts[t.key] !== undefined && (
<span className="admin-video-tab__count">{counts[t.key]}</span>
)}
</button>
))}
</div>
{activeTab === "current" && <CurrentVideosTab onStatsChanged={refreshStats} />}
{activeTab === "blacklist" && <BlacklistTab onStatsChanged={refreshStats} />}
</section>
);
}
// ---------- 当前视频 ----------
function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) {
const [list, setList] = useState<api.AdminVideo[]>([]);
const [drives, setDrives] = useState<api.AdminDrive[]>([]);
const [loading, setLoading] = useState(true);
@@ -74,9 +164,7 @@ export function VideosPage() {
return () => window.clearTimeout(timer);
}, [keyword]);
const driveNameMap = new Map(
drives.map((d) => [d.id, d.name || d.id])
);
const driveNameMap = new Map(drives.map((d) => [d.id, d.name || d.id]));
const listItems = list;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
@@ -111,13 +199,14 @@ export function VideosPage() {
setBatchRegening(true);
let success = 0;
try {
const results = await Promise.allSettled(
ids.map((id) => api.regenPreview(id))
);
const results = await Promise.allSettled(ids.map((id) => api.regenPreview(id)));
for (const r of results) {
if (r.status === "fulfilled") success++;
}
show(`批量触发完成,成功 ${success} / ${ids.length}`, success === ids.length ? "success" : "info");
show(
`批量触发完成,成功 ${success} / ${ids.length}`,
success === ids.length ? "success" : "info"
);
setSelectedIds(new Set());
setBatchRegenOpen(false);
} finally {
@@ -139,6 +228,7 @@ export function VideosPage() {
return next;
});
show(result.deletedSource ? "已删除视频,并清理源文件" : "已删除视频", "success");
onStatsChanged();
if (listItems.length === 1 && page > 1) {
setPage((p) => Math.max(1, p - 1));
} else {
@@ -172,11 +262,15 @@ export function VideosPage() {
const extra = deletedSources > 0 ? `,其中 ${deletedSources} 个清理了源文件` : "";
show(`批量删除完成,成功 ${success}${extra}`, "success");
} else {
show(`批量删除完成,成功 ${success} / ${ids.length} 个,失败 ${failed}`, success > 0 ? "info" : "error");
show(
`批量删除完成,成功 ${success} / ${ids.length} 个,失败 ${failed}`,
success > 0 ? "info" : "error"
);
}
setSelectedIds(new Set());
setBatchDeleteOpen(false);
setBatchDeleteSource(false);
onStatsChanged();
if (success >= listItems.length && page > 1) {
setPage((p) => Math.max(1, p - 1));
} else {
@@ -191,7 +285,7 @@ export function VideosPage() {
if (selectedIds.size === listItems.length && listItems.length > 0) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(listItems.map(v => v.id)));
setSelectedIds(new Set(listItems.map((v) => v.id)));
}
};
@@ -209,52 +303,21 @@ export function VideosPage() {
}
return (
<section>
<header className="admin-page__header">
<h1 className="admin-page__title"></h1>
<div className="admin-page__actions admin-videos-filter">
<div className="admin-videos-filter__select-wrap">
<select
className="admin-videos-filter__select"
value={driveId}
onChange={(e) => {
setDriveId(e.target.value);
setPage(1);
}}
>
<option value=""></option>
{drives.map((d) => (
<option key={d.id} value={d.id}>
{d.name || d.id} {d.teaserReadyCount ?? 0}{" "}
{d.teaserPendingCount ?? 0}
</option>
))}
</select>
<ChevronDown size={15} className="admin-videos-filter__select-icon" aria-hidden="true" />
</div>
<form className="admin-videos-filter__search" onSubmit={handleSearchSubmit}>
<Search size={14} className="admin-videos-filter__search-icon" />
<input
aria-label="搜索标题或作者"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索标题 / 作者"
/>
</form>
<button type="button" className="admin-btn" onClick={refresh}>
<RefreshCw size={13} />
</button>
</div>
</header>
<>
<div className="admin-page__actions admin-videos-filter">
<DriveFilter drives={drives} driveId={driveId} onChange={(id) => { setDriveId(id); setPage(1); }} withCounts />
<SearchBox keyword={keyword} onChange={setKeyword} onSubmit={handleSearchSubmit} />
<button type="button" className="admin-btn" onClick={refresh}>
<RefreshCw size={13} />
</button>
</div>
{!loading && (
<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>
<span className="admin-videos-bulk-actions__count"> {selectedIds.size} </span>
<button type="button" className="admin-btn is-primary admin-videos-bulk-actions__btn" onClick={handleBatchRegen}>
<RefreshCw size={13} />
</button>
@@ -267,18 +330,9 @@ export function VideosPage() {
)}
{loading ? (
<div className="admin-loading-state">
<RefreshCw size={20} className="admin-spin" />
<span>...</span>
</div>
<LoadingState />
) : loadError ? (
<div className="admin-error-state">
<strong></strong>
<span>{loadError}</span>
<button type="button" className="admin-btn" onClick={refresh}>
<RefreshCw size={13} />
</button>
</div>
<ErrorState message={loadError} onRetry={refresh} />
) : listItems.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-state__icon">
@@ -295,14 +349,22 @@ export function VideosPage() {
<table className="admin-table is-selectable admin-videos-table">
<thead>
<tr>
<th className="is-checkbox" style={{ width: '40px' }}>
<th className="is-checkbox" style={{ width: "40px" }}>
<button
type="button"
className="admin-table-checkbox-btn"
onClick={toggleSelectAll}
aria-label={selectedIds.size > 0 && selectedIds.size === listItems.length ? "清空当前页选择" : "选择当前页视频"}
aria-label={
selectedIds.size > 0 && selectedIds.size === listItems.length
? "清空当前页选择"
: "选择当前页视频"
}
>
{selectedIds.size > 0 && selectedIds.size === listItems.length ? <CheckSquare size={16} /> : <Square size={16} />}
{selectedIds.size > 0 && selectedIds.size === listItems.length ? (
<CheckSquare size={16} />
) : (
<Square size={16} />
)}
</button>
</th>
<th></th>
@@ -323,35 +385,15 @@ export function VideosPage() {
onClick={() => toggleSelect(v.id)}
aria-label={`${selectedIds.has(v.id) ? "取消选择" : "选择"}视频 ${v.title}`}
>
{selectedIds.has(v.id) ? <CheckSquare size={16} color="var(--accent)" /> : <Square size={16} color="var(--border-strong)" />}
{selectedIds.has(v.id) ? (
<CheckSquare size={16} color="var(--accent)" />
) : (
<Square size={16} color="var(--border-strong)" />
)}
</button>
</td>
<td data-label="标题">
<div className="admin-video-title-cell">
<div className="admin-video-thumb-wrap" aria-hidden="true">
{v.thumbnailUrl ? (
<img className="admin-video-thumb" src={v.thumbnailUrl} alt="" />
) : (
<div className="admin-video-thumb-placeholder">
<Image size={14} />
</div>
)}
</div>
<div className="admin-video-title-body">
<div className="admin-video-title">{v.title}</div>
{fileMeta(v) && (
<div className="admin-video-filemeta">{fileMeta(v)}</div>
)}
{(v.tags ?? []).length > 0 && (
<div className="admin-pills admin-video-title-tags">
{(v.tags ?? []).map((t) => (
<span key={t} className="admin-pill">{t}</span>
))}
</div>
)}
<VideoFileMetaPills video={v} />
</div>
</div>
<VideoTitleCell video={v} />
</td>
<td data-label="作者">{v.author || <span className="admin-text-faint"></span>}</td>
<td data-label="时长">{formatDur(v.durationSeconds)}</td>
@@ -384,43 +426,7 @@ export function VideosPage() {
))}
</tbody>
</table>
<div className="admin-table-pagination">
<button
type="button"
className="admin-btn"
onClick={() => setPage(1)}
disabled={page <= 1}
>
</button>
<button
type="button"
className="admin-btn"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
</button>
<span className="admin-table-pagination__info">
{page} / {totalPages} {pageSize}
</span>
<button
type="button"
className="admin-btn"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
</button>
<button
type="button"
className="admin-btn"
onClick={() => setPage(totalPages)}
disabled={page >= totalPages}
>
</button>
</div>
<Pagination page={page} totalPages={totalPages} pageSize={pageSize} onPage={setPage} />
</>
)}
@@ -463,18 +469,7 @@ export function VideosPage() {
}}
onConfirm={confirmDeleteVideo}
>
<label className="admin-delete-source-option">
<input
type="checkbox"
checked={deleteSource}
disabled={deleting}
onChange={(e) => setDeleteSource(e.target.checked)}
/>
<span>
<strong></strong>
<small></small>
</span>
</label>
<DeleteSourceOption checked={deleteSource} disabled={deleting} onChange={setDeleteSource} note="开启后会先删除源文件,失败则不会删除管理库记录。" />
</ConfirmModal>
<ConfirmModal
open={batchDeleteOpen}
@@ -493,20 +488,346 @@ export function VideosPage() {
}}
onConfirm={confirmBatchDelete}
>
<label className="admin-delete-source-option">
<input
type="checkbox"
checked={batchDeleteSource}
disabled={batchDeleting}
onChange={(e) => setBatchDeleteSource(e.target.checked)}
/>
<span>
<strong></strong>
<small></small>
</span>
</label>
<DeleteSourceOption checked={batchDeleteSource} disabled={batchDeleting} onChange={setBatchDeleteSource} note="开启后会先删除源文件,失败的视频会保留管理库记录。" />
</ConfirmModal>
</section>
</>
);
}
// ---------- 拉黑视频 ----------
function BlacklistTab({ onStatsChanged }: { onStatsChanged: () => void }) {
const [list, setList] = useState<api.AdminDeletedVideo[]>([]);
const [drives, setDrives] = useState<api.AdminDrive[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState("");
const [keyword, setKeyword] = useState("");
const [searchKeyword, setSearchKeyword] = useState("");
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [removeTarget, setRemoveTarget] = useState<api.AdminDeletedVideo | null>(null);
const [removing, setRemoving] = useState(false);
const pageSize = useVideosPageSize();
const { show } = useToast();
async function refresh() {
setLoading(true);
setLoadError("");
try {
const [r, driveList] = await Promise.all([
api.listBlacklist({ page, size: pageSize, keyword: searchKeyword }),
api.listDrives(),
]);
setList(r.items ?? []);
setTotal(r.total ?? 0);
setDrives(driveList ?? []);
} catch (e) {
const message = e instanceof Error ? e.message : "加载失败";
setLoadError(message);
show(message, "error");
} finally {
setLoading(false);
}
}
useEffect(() => {
refresh();
}, [page, searchKeyword, pageSize]);
useEffect(() => {
setPage(1);
}, [pageSize]);
useEffect(() => {
if (keyword === searchKeyword) return;
const timer = window.setTimeout(() => {
setSearchKeyword(keyword);
setPage(1);
}, 300);
return () => window.clearTimeout(timer);
}, [keyword]);
const driveNameMap = new Map(drives.map((d) => [d.id, d.name || d.id]));
const totalPages = Math.max(1, Math.ceil(total / pageSize));
async function confirmRemove() {
if (!removeTarget) return;
const target = removeTarget;
setRemoving(true);
try {
await api.removeBlacklist(target.id);
setRemoveTarget(null);
show("已移出黑名单,下次扫盘会重新入库", "success");
onStatsChanged();
if (list.length === 1 && page > 1) {
setPage((p) => Math.max(1, p - 1));
} else {
refresh();
}
} catch (e) {
show(e instanceof Error ? e.message : "操作失败", "error");
} finally {
setRemoving(false);
}
}
function handleSearchSubmit(e: React.FormEvent) {
e.preventDefault();
setSearchKeyword(keyword);
setPage(1);
}
return (
<>
<div className="admin-tab-intro">
</div>
<div className="admin-page__actions admin-videos-filter">
<SearchBox keyword={keyword} onChange={setKeyword} onSubmit={handleSearchSubmit} placeholder="搜索文件名" />
<button type="button" className="admin-btn" onClick={refresh}>
<RefreshCw size={13} />
</button>
</div>
{loading ? (
<LoadingState />
) : loadError ? (
<ErrorState message={loadError} onRetry={refresh} />
) : list.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-state__icon">
<Ban size={48} />
</div>
<div className="admin-empty-state__text"></div>
</div>
) : (
<>
<div className="admin-videos-list-toolbar">
<div className="admin-videos-summary"> {total} </div>
</div>
<table className="admin-table admin-blacklist-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th className="is-actions"></th>
</tr>
</thead>
<tbody>
{list.map((v) => (
<tr key={v.id}>
<td data-label="文件名">
<span className="admin-blacklist-filename">{v.fileName || <span className="admin-text-faint"></span>}</span>
</td>
<td data-label="来源" className="admin-mono-cell">
{driveNameMap.get(v.driveId) ?? v.driveId}
</td>
<td data-label="大小">{v.size > 0 ? formatBytes(v.size) : <span className="admin-text-faint"></span>}</td>
<td data-label="拉黑时间">{formatDateTime(v.deletedAt)}</td>
<td className="is-actions" data-label="操作">
<button
type="button"
className="admin-btn admin-blacklist-restore-btn"
onClick={() => setRemoveTarget(v)}
title="移出黑名单"
>
<RotateCcw size={13} />
</button>
</td>
</tr>
))}
</tbody>
</table>
<Pagination page={page} totalPages={totalPages} pageSize={pageSize} onPage={setPage} />
</>
)}
<ConfirmModal
open={removeTarget !== null}
title="移出黑名单"
message={
removeTarget
? `确定把「${removeTarget.fileName || removeTarget.id}」移出黑名单吗?移出后它会在下次扫盘时被重新发现并入库。`
: ""
}
confirmText="移出黑名单"
centerMessage
loading={removing}
onCancel={() => {
if (!removing) setRemoveTarget(null);
}}
onConfirm={confirmRemove}
/>
</>
);
}
// ---------- 共享小组件 ----------
function DriveFilter({
drives,
driveId,
onChange,
withCounts = false,
}: {
drives: api.AdminDrive[];
driveId: string;
onChange: (id: string) => void;
withCounts?: boolean;
}) {
return (
<div className="admin-videos-filter__select-wrap">
<select
className="admin-videos-filter__select"
value={driveId}
onChange={(e) => onChange(e.target.value)}
>
<option value=""></option>
{drives.map((d) => (
<option key={d.id} value={d.id}>
{d.name || d.id}
{withCounts ? `(已生成 ${d.teaserReadyCount ?? 0},待生成 ${d.teaserPendingCount ?? 0}` : ""}
</option>
))}
</select>
<ChevronDown size={15} className="admin-videos-filter__select-icon" aria-hidden="true" />
</div>
);
}
function SearchBox({
keyword,
onChange,
onSubmit,
placeholder = "搜索标题 / 作者",
}: {
keyword: string;
onChange: (v: string) => void;
onSubmit: (e: React.FormEvent) => void;
placeholder?: string;
}) {
return (
<form className="admin-videos-filter__search" onSubmit={onSubmit}>
<Search size={14} className="admin-videos-filter__search-icon" />
<input
aria-label={placeholder}
value={keyword}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
/>
</form>
);
}
function Pagination({
page,
totalPages,
pageSize,
onPage,
}: {
page: number;
totalPages: number;
pageSize: number;
onPage: React.Dispatch<React.SetStateAction<number>>;
}) {
return (
<div className="admin-table-pagination">
<button type="button" className="admin-btn" onClick={() => onPage(() => 1)} disabled={page <= 1}>
</button>
<button type="button" className="admin-btn" onClick={() => onPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>
</button>
<span className="admin-table-pagination__info">
{page} / {totalPages} {pageSize}
</span>
<button
type="button"
className="admin-btn"
onClick={() => onPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
</button>
<button type="button" className="admin-btn" onClick={() => onPage(() => totalPages)} disabled={page >= totalPages}>
</button>
</div>
);
}
function LoadingState() {
return (
<div className="admin-loading-state">
<RefreshCw size={20} className="admin-spin" />
<span>...</span>
</div>
);
}
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div className="admin-error-state">
<strong></strong>
<span>{message}</span>
<button type="button" className="admin-btn" onClick={onRetry}>
<RefreshCw size={13} />
</button>
</div>
);
}
function DeleteSourceOption({
checked,
disabled,
onChange,
note,
}: {
checked: boolean;
disabled: boolean;
onChange: (v: boolean) => void;
note: string;
}) {
return (
<label className="admin-delete-source-option">
<input type="checkbox" checked={checked} disabled={disabled} onChange={(e) => onChange(e.target.checked)} />
<span>
<strong></strong>
<small>{note}</small>
</span>
</label>
);
}
function VideoTitleCell({ video: v }: { video: api.AdminVideo }) {
return (
<div className="admin-video-title-cell">
<div className="admin-video-thumb-wrap" aria-hidden="true">
{v.thumbnailUrl ? (
<img className="admin-video-thumb" src={v.thumbnailUrl} alt="" />
) : (
<div className="admin-video-thumb-placeholder">
<Image size={14} />
</div>
)}
</div>
<div className="admin-video-title-body">
<div className="admin-video-title">{v.title}</div>
{fileMeta(v) && <div className="admin-video-filemeta">{fileMeta(v)}</div>}
{(v.tags ?? []).length > 0 && (
<div className="admin-pills admin-video-title-tags">
{(v.tags ?? []).map((t) => (
<span key={t} className="admin-pill">
{t}
</span>
))}
</div>
)}
<VideoFileMetaPills video={v} />
</div>
</div>
);
}
@@ -529,11 +850,7 @@ function VideoFileMetaPills({ video }: { video: api.AdminVideo }) {
{part}
</span>
))}
{category && (
<span className="admin-video-filemeta-pill is-category">
{category}
</span>
)}
{category && <span className="admin-video-filemeta-pill is-category">{category}</span>}
</div>
);
}
@@ -545,11 +862,17 @@ function formatDur(sec: number): string {
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
}
function formatDateTime(ms: number): string {
if (!ms) return "—";
const d = new Date(ms);
if (Number.isNaN(d.getTime())) return "—";
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function useVideosPageSize() {
const [pageSize, setPageSize] = useState(() =>
window.matchMedia(VIDEOS_MOBILE_QUERY).matches
? MOBILE_VIDEOS_PAGE_SIZE
: DESKTOP_VIDEOS_PAGE_SIZE
window.matchMedia(VIDEOS_MOBILE_QUERY).matches ? MOBILE_VIDEOS_PAGE_SIZE : DESKTOP_VIDEOS_PAGE_SIZE
);
useEffect(() => {
@@ -683,12 +1006,12 @@ function EditVideoModal({
<div className="admin-thumbnail-preview">
<input id={`${idPrefix}-video-thumbnail`} value={thumbnail} onChange={(e) => setThumbnail(e.target.value)} />
{thumbnail && (
<img
src={thumbnail}
alt="封面预览"
className="admin-thumbnail-img"
onError={(e) => (e.currentTarget.style.display = 'none')}
onLoad={(e) => (e.currentTarget.style.display = 'block')}
<img
src={thumbnail}
alt="封面预览"
className="admin-thumbnail-img"
onError={(e) => (e.currentTarget.style.display = "none")}
onLoad={(e) => (e.currentTarget.style.display = "block")}
/>
)}
</div>
@@ -730,11 +1053,7 @@ function fileMeta(v: api.AdminVideo): string {
}
function fileMetaParts(v: api.AdminVideo): string[] {
return [
normalizeExt(v.ext),
v.quality,
v.size > 0 ? formatBytes(v.size) : "",
].filter(Boolean);
return [normalizeExt(v.ext), v.quality, v.size > 0 ? formatBytes(v.size) : ""].filter(Boolean);
}
function normalizeExt(ext: string): string {
@@ -750,7 +1069,5 @@ function splitList(s: string): string[] {
}
function toggleTag(tags: string[], label: string): string[] {
return tags.includes(label)
? tags.filter((tag) => tag !== label)
: [...tags, label];
return tags.includes(label) ? tags.filter((tag) => tag !== label) : [...tags, label];
}
+75 -1
View File
@@ -98,6 +98,8 @@ export type AdminDrive = {
spider91Proxy?: string;
// Google Drive 是否使用 OpenList 在线续期 API;未配置时后端按 true 返回。
googleDriveUseOnlineAPI?: boolean;
// localstorage 的 .strm 是否允许指向存储根目录之外;未配置时后端按 false 返回。
strmAllowOutsideRoot?: boolean;
scanGenerationStatus?: DriveGenerationStatus;
thumbnailGenerationStatus?: DriveGenerationStatus;
previewGenerationStatus?: DriveGenerationStatus;
@@ -112,6 +114,12 @@ export type AdminDrive = {
fingerprintReadyCount: number;
fingerprintPendingCount: number;
fingerprintFailedCount: number;
// 浏览器兼容性转码:候选(待处理)/已转码/失败/检测后无需转码 计数与任务状态。
transcodeGenerationStatus?: DriveGenerationStatus;
transcodePendingCount: number;
transcodeReadyCount: number;
transcodeFailedCount: number;
transcodeSkippedCount: number;
};
export type DriveGenerationStatus = {
@@ -449,6 +457,26 @@ export function regenFailedFingerprints(id: string) {
);
}
/**
* AVI/WMV H.264 MP4
*
*
*/
export function startDriveTranscode(id: string) {
return request<{ ok: boolean; accepted: boolean; message?: string }>(
`/drives/${encodeURIComponent(id)}/transcode/start`,
{ method: "POST" }
);
}
/** 手动停止某存储正在进行的转码任务。 */
export function stopDriveTranscode(id: string) {
return request<{ ok: boolean; stopped: boolean }>(
`/drives/${encodeURIComponent(id)}/transcode/stop`,
{ method: "POST" }
);
}
// ---------- Videos ----------
export type AdminVideo = {
@@ -482,7 +510,9 @@ export type AdminVideoList = {
size: number;
};
export function listVideos(params: { driveId?: string; page?: number; size?: number; keyword?: string } = {}) {
export function listVideos(
params: { driveId?: string; page?: number; size?: number; keyword?: string } = {}
) {
const qs = new URLSearchParams();
if (params.driveId) qs.set("driveId", params.driveId);
if (params.page) qs.set("page", String(params.page));
@@ -492,6 +522,50 @@ export function listVideos(params: { driveId?: string; page?: number; size?: num
return request<AdminVideoList>(`/videos${suffix}`);
}
// 后台视频管理两个标签页的计数。
export type VideoStats = {
current: number;
blacklisted: number;
};
export function getVideoStats() {
return request<VideoStats>("/videos/stats");
}
// 黑名单(被拉黑/手动删除、扫盘不再入库的视频)。原始记录已删除,
// 只剩文件名/来源盘/大小/拉黑时间。
export type AdminDeletedVideo = {
id: string;
driveId: string;
fileId: string;
fileName: string;
size: number;
deletedAt: number;
};
export type AdminBlacklistList = {
items: AdminDeletedVideo[];
total: number;
page: number;
size: number;
};
export function listBlacklist(params: { page?: number; size?: number; keyword?: string } = {}) {
const qs = new URLSearchParams();
if (params.page) qs.set("page", String(params.page));
if (params.size) qs.set("size", String(params.size));
if (params.keyword) qs.set("keyword", params.keyword);
const suffix = qs.toString() ? `?${qs.toString()}` : "";
return request<AdminBlacklistList>(`/blacklist${suffix}`);
}
// 把视频移出黑名单(删除墓碑),下次扫盘会重新入库。
export function removeBlacklist(id: string) {
return request<{ ok: boolean }>(`/blacklist/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
export type UpdateVideoInput = Partial<{
title: string;
author: string;
+47 -1
View File
@@ -1,4 +1,4 @@
import { PlayCircle, Power, PowerOff, RotateCcw } from "lucide-react";
import { CircleStop, PlayCircle, Power, PowerOff, RotateCcw, Wand2 } from "lucide-react";
import * as api from "../api";
import { formatBytes } from "../storageFormat";
import {
@@ -163,20 +163,26 @@ export function DriveGenerationPanel({
regenFailedThumbId,
regenFailedFingerprintId,
togglingTeaserId,
togglingTranscodeId,
onToggleTeaser,
onRegenFailed,
onRegenFailedThumbnails,
onRegenFailedFingerprints,
onStartTranscode,
onStopTranscode,
}: {
d: api.AdminDrive;
regenFailedId: string;
regenFailedThumbId: string;
regenFailedFingerprintId: string;
togglingTeaserId: string;
togglingTranscodeId: string;
onToggleTeaser: () => void;
onRegenFailed: () => void;
onRegenFailedThumbnails: () => void;
onRegenFailedFingerprints: () => void;
onStartTranscode: () => void;
onStopTranscode: () => void;
}) {
const canQueueThumbnails =
(d.thumbnailFailedCount ?? 0) > 0 ||
@@ -186,6 +192,12 @@ export function DriveGenerationPanel({
(d.teaserFailedCount ?? 0) > 0 || (d.teaserPendingCount ?? 0) > 0;
const canQueueFingerprints =
(d.fingerprintFailedCount ?? 0) > 0 || (d.fingerprintPendingCount ?? 0) > 0;
// 转码默认不运行,只能在这里手动开启/停止。
// 候选 = 还没出结果的不兼容格式视频 + 上次失败的(重新开始会自动重试)。
const transcodeRunning =
(d.transcodeGenerationStatus?.state || "idle") !== "idle";
const canStartTranscode =
(d.transcodePendingCount ?? 0) > 0 || (d.transcodeFailedCount ?? 0) > 0;
return (
<div className="admin-detail-card">
@@ -235,6 +247,13 @@ export function DriveGenerationPanel({
pending={d.fingerprintPendingCount}
failed={d.fingerprintFailedCount}
/>
<DriveGenCol
label="转码"
status={d.transcodeGenerationStatus}
ready={d.transcodeReadyCount}
pending={d.transcodePendingCount}
failed={d.transcodeFailedCount}
/>
</div>
<div className="admin-detail-actions">
@@ -262,6 +281,33 @@ export function DriveGenerationPanel({
<RotateCcw size={13} />
<span>{(d.fingerprintFailedCount ?? 0) > 0 ? "重试失败指纹" : "继续生成指纹"}</span>
</button>
{transcodeRunning ? (
<button
className="admin-btn is-stop"
disabled={togglingTranscodeId === d.id}
onClick={onStopTranscode}
title="停止当前的转码任务。未处理的视频保持原状态,下次开始时继续。"
>
<CircleStop size={13} />
<span>{togglingTranscodeId === d.id ? "停止中..." : "停止转码"}</span>
</button>
) : (
<button
className="admin-btn"
disabled={!canStartTranscode || togglingTranscodeId === d.id}
onClick={onStartTranscode}
title="把浏览器播放不了的视频(AVI/WMV/RMVB、MPEG-4 等老格式)转码成 H.264 MP4 并上传回本存储。转码不会自动运行,只能在这里手动开启。"
>
<Wand2 size={13} />
<span>
{togglingTranscodeId === d.id
? "开启中..."
: (d.transcodeFailedCount ?? 0) > 0 && (d.transcodePendingCount ?? 0) === 0
? "重试失败转码"
: "开始转码"}
</span>
</button>
)}
</div>
</div>
);
+13 -1
View File
@@ -162,7 +162,7 @@ export function credentialHelp(kind: Kind, isEdit: boolean): string {
? "请参考OpenList文档中关于谷歌云盘的配置方法;如不修改凭证,留空即可,保存时会沿用旧值"
: "请参考OpenList文档中关于谷歌云盘的配置方法";
case "localstorage":
return `填写服务器可访问的本地目录绝对路径,例如 /mnt/videos。系统会扫描该目录及子目录中的视频文件和 .strm 文件;.strm 可指向 HTTP/HTTPS 直链,或指向本地存储根目录内的真实视频路径。Docker 部署时请填写容器内路径。${note}`;
return `填写服务器可访问的本地目录绝对路径,例如 /mnt/videos。系统会扫描该目录及子目录中的视频文件和 .strm 文件;.strm 可指向 HTTP/HTTPS 直链或本地视频路径(指向目录外需开启下方开关)。Docker 部署时请填写容器内路径。${note}`;
case "spider91":
return "91Spider 不再支持通过网盘添加或编辑。请到后台爬虫管理页面添加爬虫脚本。";
default:
@@ -330,6 +330,18 @@ export function credentialFields(kind: Kind, creds: Record<string, string> = {})
required: true,
help: "路径必须是后端服务器上的已有目录;保存后可手动重扫,系统会递归扫描支持的视频格式。",
},
{
key: "strm_allow_outside_root",
label: ".strm 允许指向目录外",
placeholder: "",
type: "select",
defaultValue: "false",
options: [
{ value: "false", label: "关闭(默认,仅允许目录内路径)" },
{ value: "true", label: "开启(允许任意本地路径)" },
],
help: "开启后 .strm 可指向本目录之外的本地文件(如 rclone 挂载点)。注意:等于允许通过 .strm 读取服务器上任意文件,请只在自己完全掌控媒体目录时开启。Docker 部署时路径必须是容器内路径。",
},
];
case "spider91":
return [
+2 -17
View File
@@ -1,13 +1,11 @@
import { useEffect, useState } from "react";
import { EyeOff, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react";
import { ThumbsDown, ThumbsUp, Trash2 } from "lucide-react";
import type { VideoDetail } from "@/types";
import { formatCount } from "@/lib/format";
type Props = {
video: VideoDetail;
onHideVideo: () => void;
onDeleteVideo: () => void;
hideSaving?: boolean;
deleteSaving?: boolean;
};
@@ -15,7 +13,7 @@ type Props = {
*
* - 线"成体"
* - +
* - "不再显示" hover danger
* - hover danger
*
*
* - state
@@ -23,9 +21,7 @@ type Props = {
*/
export function VideoActions({
video,
onHideVideo,
onDeleteVideo,
hideSaving,
deleteSaving,
}: Props) {
const [likes, setLikes] = useState(video.likes ?? 0);
@@ -119,17 +115,6 @@ export function VideoActions({
</button>
</div>
<button
type="button"
className="vd-actions__btn vd-actions__hide"
onClick={onHideVideo}
disabled={hideSaving}
aria-label="不再显示这个视频"
>
<EyeOff size={16} />
<span>{hideSaving ? "处理中" : "不再显示"}</span>
</button>
<button
type="button"
className="vd-actions__btn vd-actions__delete"
+56 -88
View File
@@ -5,7 +5,7 @@ import {
type CSSProperties,
type MutableRefObject,
} from "react";
import Artplayer, { type Option } from "artplayer";
import Artplayer, { type Option, type SettingOption } from "artplayer";
import type Hls from "hls.js";
type Props = {
@@ -95,13 +95,22 @@ const NORMAL_RATE = 1;
Artplayer.FAST_FORWARD_VALUE = FAST_RATE;
const SETTINGS_KEY = "video-site:player-settings";
const DEFAULT_SETTINGS: PlayerSettings = {
volume: 0.7,
muted: false,
playbackRate: 1,
brightness: 1,
};
const DEFAULT_SETTING_LAYOUT = {
width: Artplayer.SETTING_WIDTH,
itemWidth: Artplayer.SETTING_ITEM_WIDTH,
itemHeight: Artplayer.SETTING_ITEM_HEIGHT,
};
const COMPACT_SETTING_LAYOUT = {
width: 172,
itemWidth: 148,
itemHeight: 30,
};
const ORIENTATION_CONTROL_NAME = "orientationToggle";
const MANUAL_ORIENTATION_CLASS = "art-manual-orientation";
const FAST_RATE_CLASS = "art-fast-rate-active";
@@ -320,10 +329,12 @@ function mountArtPlayer({
onGestureHud: (label: string, duration?: number) => void;
}) {
const sourceType = inferSourceType(src);
const settings = readPlayerSettings();
const fastActiveRef = { current: false };
const loadHlsSource = createHlsSourceLoader(onError);
const enableOrientationControl = shouldEnableMobileOrientationControl();
configureArtPlayerSettingLayout(
shouldUseCompactPlayerSettings(mount, enableOrientationControl)
);
const option: Option = {
id: "91-detail-player",
container: mount,
@@ -331,8 +342,8 @@ function mountArtPlayer({
poster,
theme: "var(--video-player-progress)",
lang: "zh-cn",
volume: settings.volume,
muted: settings.muted,
volume: DEFAULT_SETTINGS.volume,
muted: DEFAULT_SETTINGS.muted,
autoplay: false,
autoSize: false,
playbackRate: true,
@@ -358,6 +369,7 @@ function mountArtPlayer({
preload: "metadata",
playsInline: true,
},
settings: [createLoopSetting()],
controls: enableOrientationControl ? [createOrientationControl()] : [],
contextmenu: [],
cssVar: {
@@ -377,8 +389,9 @@ function mountArtPlayer({
video.setAttribute("controlsList", "nodownload");
video.setAttribute("webkit-playsinline", "true");
video.disablePictureInPicture = false;
video.playbackRate = settings.playbackRate;
applyPlayerBrightness(art, settings.brightness);
video.loop = false;
video.playbackRate = DEFAULT_SETTINGS.playbackRate;
applyPlayerBrightness(art, DEFAULT_SETTINGS.brightness);
art.url = src;
function preventContextMenu(event: Event) {
@@ -414,21 +427,6 @@ function mountArtPlayer({
onFastChange(false);
}
function handleVolumeChange() {
writePlayerSettings({
volume: clamp(video.volume, 0, 1),
muted: video.muted,
});
}
function handleRateChange() {
if (fastActiveRef.current) return;
if (!Number.isFinite(video.playbackRate)) return;
writePlayerSettings({
playbackRate: clamp(video.playbackRate, 0.5, 3),
});
}
const handleFastChange = (active: boolean) => {
fastActiveRef.current = active;
setPlayerFastRateHint(art, active);
@@ -453,8 +451,6 @@ function mountArtPlayer({
: noop;
mount.addEventListener("contextmenu", preventContextMenu);
video.addEventListener("volumechange", handleVolumeChange);
video.addEventListener("ratechange", handleRateChange);
art.on("video:loadstart", handleLoadStart);
art.on("video:loadeddata", handleReady);
@@ -473,8 +469,6 @@ function mountArtPlayer({
unbindOrientationToggle();
setPlayerFastRateHint(art, false);
mount.removeEventListener("contextmenu", preventContextMenu);
video.removeEventListener("volumechange", handleVolumeChange);
video.removeEventListener("ratechange", handleRateChange);
destroyHls(video);
art.off("video:loadstart", handleLoadStart);
art.off("video:loadeddata", handleReady);
@@ -502,10 +496,42 @@ function shouldEnableMobileOrientationControl() {
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent);
}
function shouldUseCompactPlayerSettings(
mount: HTMLElement,
mobileControls: boolean
) {
const narrowViewport =
window.matchMedia?.("(max-width: 640px)").matches ??
window.innerWidth <= 640;
return mobileControls || narrowViewport || mount.clientWidth <= 640;
}
function configureArtPlayerSettingLayout(compact: boolean) {
const layout = compact ? COMPACT_SETTING_LAYOUT : DEFAULT_SETTING_LAYOUT;
Artplayer.SETTING_WIDTH = layout.width;
Artplayer.SETTING_ITEM_WIDTH = layout.itemWidth;
Artplayer.SETTING_ITEM_HEIGHT = layout.itemHeight;
}
function shouldEnableMobileGestures() {
return shouldEnableMobileOrientationControl();
}
function createLoopSetting() {
return {
name: "mind-loop",
html: "洗脑循环",
tooltip: "关",
switch: false,
onSwitch(this: Artplayer, item: SettingOption) {
const next = !item.switch;
this.video.loop = next;
item.tooltip = next ? "开" : "关";
return next;
},
};
}
function isPlayerExpanded(art: Artplayer) {
return Boolean(
art.fullscreen || art.fullscreenWeb || getNativeFullscreenElement()
@@ -912,12 +938,10 @@ function getPlayerBrightness(art: Artplayer) {
"--video-player-brightness"
);
if (!raw.trim()) return DEFAULT_SETTINGS.brightness;
return clampNumber(
Number(raw),
DEFAULT_SETTINGS.brightness,
BRIGHTNESS_MIN,
BRIGHTNESS_MAX
);
const value = Number(raw);
return Number.isFinite(value)
? clamp(value, BRIGHTNESS_MIN, BRIGHTNESS_MAX)
: DEFAULT_SETTINGS.brightness;
}
function mobileGestureSeekSpan(duration: number) {
@@ -1321,15 +1345,6 @@ function bindMobilePlayerGestures(
);
}
}
} else if (state.mode === "brightness") {
writePlayerSettings({
brightness: getPlayerBrightness(art),
});
} else if (state.mode === "volume") {
writePlayerSettings({
volume: clamp(video.volume, 0, 1),
muted: video.muted,
});
}
resetGesture();
@@ -1401,25 +1416,6 @@ function bindProgressPreview(
};
}
function readPlayerSettings(): PlayerSettings {
const saved = safeGetJSON<Partial<PlayerSettings>>(SETTINGS_KEY) ?? {};
return {
volume: clampNumber(saved.volume, DEFAULT_SETTINGS.volume, 0, 1),
muted: typeof saved.muted === "boolean" ? saved.muted : DEFAULT_SETTINGS.muted,
playbackRate: clampNumber(saved.playbackRate, DEFAULT_SETTINGS.playbackRate, 0.5, 3),
brightness: clampNumber(
saved.brightness,
DEFAULT_SETTINGS.brightness,
BRIGHTNESS_MIN,
BRIGHTNESS_MAX
),
};
}
function writePlayerSettings(patch: Partial<PlayerSettings>) {
safeSetJSON(SETTINGS_KEY, { ...readPlayerSettings(), ...patch });
}
function mediaErrorMessage(error: MediaError | null) {
switch (error?.code) {
case MediaError.MEDIA_ERR_ABORTED:
@@ -1464,34 +1460,6 @@ function fallbackCopyText(text: string) {
}
}
function safeGetJSON<T>(key: string): T | null {
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : null;
} catch {
return null;
}
}
function safeSetJSON(key: string, value: unknown) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// ignore
}
}
function clampNumber(
value: unknown,
fallback: number,
min: number,
max: number
) {
return typeof value === "number" && Number.isFinite(value)
? clamp(value, min, max)
: fallback;
}
function clamp(n: number, min: number, max: number) {
return n < min ? min : n > max ? max : n;
}
+3 -5
View File
@@ -132,19 +132,17 @@ export type ShortsNextResponse = {
/**
* video id
* count preferredFromVideoId
*
* count
*
* + roundComplete=false
*/
export function fetchShortsNext(
seenIds: string[],
count: number,
preferredFromVideoId?: string
count: number
): Promise<ShortsNextResponse> {
return apiJSON<ShortsNextResponse>("/api/shorts/next", {
method: "POST",
body: JSON.stringify({ seenIds, count, preferredFromVideoId }),
body: JSON.stringify({ seenIds, count }),
}).catch(() => ({ items: [], total: 0, roundComplete: false }));
}
+214 -54
View File
@@ -32,9 +32,21 @@ const BATCH_SIZE = 5;
// 当队列里"还没看过的视频"少于这个数时,提前请求下一批。
const PREFETCH_THRESHOLD = 2;
// 距离 activeIndex 多少屏内的视频会被 mount 真实 <video>
// =1 表示上一屏 / 当前 / 下一屏 都加载,这样切换时几乎无空白。
const MOUNT_RADIUS = 1;
// 当前视频至少有这么多秒的前向缓冲后,才允许后续视频开始预加载
const ACTIVE_PRELOAD_BUFFER_SECONDS = 12;
// 当前视频流畅播放后,向后预加载多少条视频。
const PRELOAD_AHEAD_COUNT = 2;
// 预加载授权一旦发出,只有当前视频前向缓冲跌破这个秒数(或发生 stall)
// 才收回。高低水位之间不动作,避免缓冲量在 12s 附近波动时
// 反复绑定/剥离后续视频的 src、丢弃已预加载的数据。
const ACTIVE_PRELOAD_KEEP_SECONDS = 4;
// 维护一个固定大小的视频窗口:窗口内才 mount 真实 <video> 壳。
// 当前屏先绑定 src;后续预加载要等当前屏缓冲健康后才开始。
// 窗口内只要已经产生过可复用缓冲,就保留 src 复用浏览器缓存。
const VIDEO_WINDOW_SIZE = 6;
function loadSeenIds(): string[] {
try {
@@ -120,7 +132,6 @@ export default function ShortsPage() {
// seenIds 用 ref 维护,方便在异步 callback 里读到最新值
const seenIdsRef = useRef<string[]>(loadSeenIds());
const preferredFromVideoIdRef = useRef<string | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
// 整个页面根元素,用于 requestFullscreen
@@ -130,6 +141,11 @@ export default function ShortsPage() {
const activeIndexRef = useRef(0);
const ignoreIntersectionUntilRef = useRef(0);
const fullscreenRestoreTimersRef = useRef<number[]>([]);
const [activeReadyForPreload, setActiveReadyForPreload] = useState(false);
const [cacheableSourceIds, setCacheableSourceIds] = useState<Set<string>>(
() => new Set()
);
const [cacheWindowHighIndex, setCacheWindowHighIndex] = useState(-1);
// 当前是否处在浏览器全屏(Fullscreen API)状态。
// iOS Safari 不支持元素级 Fullscreen API,这里会一直保持 false
@@ -147,6 +163,29 @@ export default function ShortsPage() {
activeIndexRef.current = activeIndex;
}, [activeIndex]);
const handleActiveReadyForPreload = useCallback((index: number) => {
if (index === activeIndexRef.current) {
setActiveReadyForPreload(true);
}
}, []);
const handleActiveNeedsPriority = useCallback((index: number) => {
if (index === activeIndexRef.current) {
setActiveReadyForPreload(false);
}
}, []);
// 标记某条视频"浏览器里已有可复用的缓冲"。之后只要它还在缓存窗口内,
// 就保留 src 不剥离,回滑/再前滑时直接续用已缓冲数据,秒开不卡顿。
const handleSourceCached = useCallback((videoId: string) => {
setCacheableSourceIds((prev) => {
if (prev.has(videoId)) return prev;
const next = new Set(prev);
next.add(videoId);
return next;
});
}, []);
/**
*
* - liked=true POST /api/video/:id/like
@@ -171,11 +210,6 @@ export default function ShortsPage() {
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as { likes?: number };
if (liked) {
preferredFromVideoIdRef.current = videoId;
} else if (preferredFromVideoIdRef.current === videoId) {
preferredFromVideoIdRef.current = null;
}
return typeof data.likes === "number" ? data.likes : null;
} catch {
// 请求失败:回滚集合,让 Slide 自己回滚 UI
@@ -204,11 +238,7 @@ export default function ShortsPage() {
setLoading(true);
try {
const seen = seenIdsRef.current;
const resp = await fetchShortsNext(
seen,
BATCH_SIZE,
preferredFromVideoIdRef.current ?? undefined
);
const resp = await fetchShortsNext(seen, BATCH_SIZE);
if (resp.items.length === 0) {
setEmpty((prev) => prev || true /* 维持 true 即可 */);
setRoundComplete(true);
@@ -242,6 +272,8 @@ export default function ShortsPage() {
const active = items[activeIndex];
if (!active) return;
setCacheWindowHighIndex((prev) => Math.max(prev, activeIndex));
if (!seenIdsRef.current.includes(active.id)) {
seenIdsRef.current = [...seenIdsRef.current, active.id];
saveSeenIds(seenIdsRef.current);
@@ -250,8 +282,10 @@ export default function ShortsPage() {
const remaining = items.length - 1 - activeIndex;
if (remaining < PREFETCH_THRESHOLD && !loading) {
if (roundComplete) {
// 上一次后端说"本轮已耗尽",且当前已经看到队列接近末尾。
// 清空 localStorage 后再请求即可开新一轮。
// 上一次后端说"本轮已耗尽"时,必须等用户真正滑到当前队列最后一条
// 清空已看记录开新一轮。否则退出后重新进入会把未完成轮次提前重置,
// 导致刚刷过的视频再次出现在下一次会话里。
if (remaining > 0) return;
seenIdsRef.current = [];
saveSeenIds([]);
setRoundComplete(false);
@@ -280,7 +314,11 @@ export default function ShortsPage() {
if (!Number.isNaN(idx)) bestIndex = idx;
}
}
if (bestIndex >= 0) setActiveIndex(bestIndex);
if (bestIndex >= 0 && bestIndex !== activeIndexRef.current) {
activeIndexRef.current = bestIndex;
setActiveReadyForPreload(false);
setActiveIndex(bestIndex);
}
},
{
root,
@@ -300,21 +338,10 @@ export default function ShortsPage() {
video.muted = muted;
video.volume = volume;
if (video.paused) {
// 切到这个视频时从头开始播
try {
video.currentTime = 0;
} catch {
// ignore
}
video.play().catch(() => undefined);
}
} else {
if (!video.paused) video.pause();
try {
video.currentTime = 0;
} catch {
// ignore
}
}
});
}, [activeIndex, muted, volume, items.length]);
@@ -594,6 +621,8 @@ export default function ShortsPage() {
}
}, [items.length, showHud]);
const videoWindow = getVideoWindowBounds(cacheWindowHighIndex, items.length);
return (
<div className="shorts-page" ref={pageRef}>
<header className="shorts-header">
@@ -652,26 +681,49 @@ export default function ShortsPage() {
</div>
)}
{items.map((item, index) => (
<ShortsSlide
key={item.id}
item={item}
index={index}
isActive={index === activeIndex}
// 距离 active 在 MOUNT_RADIUS 之内才挂载真正的 <video>
// 其它槽位用海报占位以节省内存和带宽
shouldMount={Math.abs(index - activeIndex) <= MOUNT_RADIUS}
muted={muted}
volume={volume}
setMuted={setMuted}
setVolume={setVolume}
videoRef={setVideoRef(index)}
onLikeToggle={handleLikeToggle}
hasLiked={hasLiked}
onHideSuccess={handleHideSuccess}
showHud={showHud}
/>
))}
{items.map((item, index) => {
const isActiveSlide = index === activeIndex;
const isInCacheWindow =
index >= videoWindow.start && index <= videoWindow.end;
const preloadOffset = index - activeIndex;
const shouldPreload =
activeReadyForPreload &&
preloadOffset > 0 &&
preloadOffset <= PRELOAD_AHEAD_COUNT;
const shouldMount = isActiveSlide || isInCacheWindow || shouldPreload;
// 视频窗口内已经缓冲过的视频保留 src:
// 在窗口内来回切换时,直接复用浏览器已缓冲数据。
const shouldRetainCached =
isInCacheWindow && !isActiveSlide && cacheableSourceIds.has(item.id);
const shouldLoad = isActiveSlide || shouldPreload || shouldRetainCached;
const shouldEagerLoad = isActiveSlide || shouldPreload;
return (
<ShortsSlide
key={item.id}
item={item}
index={index}
isActive={isActiveSlide}
// 固定 6 条视频窗口内才挂载 <video> 壳;
// 当前屏先绑定 src;后两个视频等当前屏缓冲健康后再预加载;
// 已缓冲过的窗口内视频保留 src,便于来回切换复用缓存。
shouldMount={shouldMount}
shouldLoad={shouldLoad}
shouldEagerLoad={shouldEagerLoad}
muted={muted}
volume={volume}
setMuted={setMuted}
setVolume={setVolume}
videoRef={setVideoRef(index)}
onLikeToggle={handleLikeToggle}
hasLiked={hasLiked}
onHideSuccess={handleHideSuccess}
onActiveReadyForPreload={handleActiveReadyForPreload}
onActiveNeedsPriority={handleActiveNeedsPriority}
onSourceCached={handleSourceCached}
showHud={showHud}
/>
);
})}
{!empty && items.length > 0 && loading && (
<div className="shorts-loading">
@@ -689,6 +741,8 @@ type SlideProps = {
index: number;
isActive: boolean;
shouldMount: boolean;
shouldLoad: boolean;
shouldEagerLoad: boolean;
muted: boolean;
volume: number;
setMuted: (muted: boolean) => void;
@@ -702,6 +756,10 @@ type SlideProps = {
/** 父组件查询某 id 是否已经在本次会话内点过赞 */
hasLiked: (videoId: string) => boolean;
onHideSuccess: (index: number) => void;
onActiveReadyForPreload: (index: number) => void;
onActiveNeedsPriority: (index: number) => void;
/** 本条视频在浏览器里已有可复用缓冲,之后在视频窗口内保留 src */
onSourceCached: (videoId: string) => void;
showHud: (text: string, icon?: React.ReactNode) => void;
};
@@ -717,6 +775,8 @@ function ShortsSlide({
index,
isActive,
shouldMount,
shouldLoad,
shouldEagerLoad,
muted,
volume,
setMuted,
@@ -725,6 +785,9 @@ function ShortsSlide({
onLikeToggle,
hasLiked,
onHideSuccess,
onActiveReadyForPreload,
onActiveNeedsPriority,
onSourceCached,
showHud,
}: SlideProps) {
const localRef = useRef<HTMLVideoElement | null>(null);
@@ -778,6 +841,23 @@ function ShortsSlide({
[videoRef]
);
// 非当前屏/后续预加载/视频窗口内缓存视频不保留媒体源,确保离开窗口后浏览器中止原始网盘流。
useEffect(() => {
if (shouldLoad) return;
const video = localRef.current;
if (!video) return;
try {
video.pause();
video.removeAttribute("src");
video.load();
} catch {
// ignore
}
setDuration(0);
setCurrentTime(0);
setIsBuffering(false);
}, [shouldLoad, item.id]);
// 离开活跃后清掉本地的暂停状态,避免回来时 UI 还显示着 paused
useEffect(() => {
if (!isActive) {
@@ -810,7 +890,8 @@ function ShortsSlide({
}, [isMarkedHidden]);
// 监听 video 的时长 / 进度 / 缓冲状态 / 音量物理键变化。
// MOUNT_RADIUS 会让第三屏以后的 slide 先以海报占位,之后才挂载 video;
// VIDEO_WINDOW_SIZE 会让窗口外的 slide 先以海报占位,之后才挂载 video
// 只有 shouldLoad=true 的当前屏/后续预加载/缓存窗口视频会绑定 src,因此不会一次拉完整队列。
// 因此这里必须跟随 shouldMount 重新绑定,否则后续视频没有 timeupdate 事件。
useEffect(() => {
if (!shouldMount) {
@@ -832,14 +913,28 @@ function ShortsSlide({
const handleTime = () => {
// 拖动期间不要被 timeupdate 覆盖 UI
if (!scrubbingRef.current) setCurrentTime(video.currentTime);
syncActivePreloadReadiness(video);
};
const handleWaiting = () => {
setIsBuffering(true);
if (isActive) onActiveNeedsPriority(index);
};
const handlePlayingOrCanPlay = () => {
setIsBuffering(false);
// 已经能解码播放,说明浏览器里有了值得复用的数据。
if (shouldLoad) onSourceCached(item.id);
syncActivePreloadReadiness(video);
};
const handleProgress = () => {
syncActivePreloadReadiness(video);
// 窗口内视频只要已经产生缓冲,就标记为可复用;
// 之后预加载授权被收回时不再丢弃它的 src 和已缓冲数据。
if (shouldLoad && videoHasBufferedData(video)) {
onSourceCached(item.id);
}
};
const handleVolumeChange = () => {
if (!isActive) return;
// 当检测到 video 自身的 mute 状态或 volume 改变时,同步更新 React 状态。
// 这可以在移动端浏览器支持物理音量键调整时,自动反向取消静音并展示音量 HUD。
if (video.muted !== muted) {
@@ -850,6 +945,17 @@ function ShortsSlide({
}
};
function syncActivePreloadReadiness(currentVideo: HTMLVideoElement) {
if (!isActive) return;
if (videoHasComfortableBuffer(currentVideo)) {
onActiveReadyForPreload(index);
} else if (videoBufferIsCritical(currentVideo)) {
// 高低水位滞回:只有缓冲真正告急才收回预加载授权,
// 在两个水位之间维持现状,避免阈值附近来回抖动。
onActiveNeedsPriority(index);
}
}
handleLoaded();
handleTime();
video.addEventListener("loadedmetadata", handleLoaded);
@@ -858,6 +964,7 @@ function ShortsSlide({
video.addEventListener("waiting", handleWaiting);
video.addEventListener("playing", handlePlayingOrCanPlay);
video.addEventListener("canplay", handlePlayingOrCanPlay);
video.addEventListener("progress", handleProgress);
video.addEventListener("volumechange", handleVolumeChange);
// 挂载时如果已经在播放但是状态不到 ready 则置 buffering
@@ -872,9 +979,10 @@ function ShortsSlide({
video.removeEventListener("waiting", handleWaiting);
video.removeEventListener("playing", handlePlayingOrCanPlay);
video.removeEventListener("canplay", handlePlayingOrCanPlay);
video.removeEventListener("progress", handleProgress);
video.removeEventListener("volumechange", handleVolumeChange);
};
}, [shouldMount, item.id, muted, volume, setMuted, setVolume]);
}, [shouldMount, shouldLoad, item.id, index, isActive, muted, volume, setMuted, setVolume, onActiveReadyForPreload, onActiveNeedsPriority, onSourceCached]);
// 长按 2 倍速:直接绑原生事件
useEffect(() => {
@@ -1175,9 +1283,9 @@ function ShortsSlide({
<video
ref={setRef}
className="shorts-slide__video"
src={item.videoSrc}
src={shouldLoad ? item.videoSrc : undefined}
poster={item.poster}
preload="auto"
preload={shouldLoad ? (shouldEagerLoad ? "auto" : "metadata") : "none"}
playsInline
loop
muted={muted}
@@ -1210,7 +1318,7 @@ function ShortsSlide({
)}
{/* 视频加载/缓冲旋转器 */}
{isBuffering && isActive && shouldMount && !isMarkedHidden && (
{isBuffering && isActive && shouldLoad && !isMarkedHidden && (
<div className="shorts-slide__buffering" aria-hidden="true">
<Loader2 size={30} className="shorts-slide__buffering-icon" />
</div>
@@ -1309,7 +1417,7 @@ function ShortsSlide({
)}
{/* 进度条 */}
{shouldMount && !isMarkedHidden && (
{isActive && shouldLoad && !isMarkedHidden && (
<div
className={`shorts-slide__progress ${
scrubbing ? "is-scrubbing" : ""
@@ -1347,6 +1455,58 @@ function clamp(n: number, min: number, max: number) {
return n < min ? min : n > max ? max : n;
}
function getVideoWindowBounds(highestViewedIndex: number, itemCount: number) {
const size = Math.min(VIDEO_WINDOW_SIZE, itemCount);
if (size <= 0 || highestViewedIndex < 0) return { start: 0, end: -1 };
const end = clamp(highestViewedIndex, 0, itemCount - 1);
const start = Math.max(0, end - size + 1);
return { start, end };
}
/** 已经缓冲到片尾(含误差余量),不会再因网络卡顿 */
function videoBufferedToEnd(video: HTMLVideoElement) {
const duration = Number.isFinite(video.duration) ? video.duration : 0;
if (duration <= 0) return false;
const remaining = Math.max(0, duration - (video.currentTime || 0));
return bufferedAheadSeconds(video) >= remaining - 0.25;
}
function videoHasBufferedData(video: HTMLVideoElement) {
for (let i = 0; i < video.buffered.length; i += 1) {
if (video.buffered.end(i) > video.buffered.start(i)) {
return true;
}
}
return false;
}
/** 前向缓冲健康(达到高水位或已缓冲到结尾),可以放心预加载后续视频 */
function videoHasComfortableBuffer(video: HTMLVideoElement) {
if (video.readyState < 3) return false;
if (videoBufferedToEnd(video)) return true;
return bufferedAheadSeconds(video) >= ACTIVE_PRELOAD_BUFFER_SECONDS;
}
/** 前向缓冲告急(跌破低水位且没缓冲到结尾),应收回预加载授权 */
function videoBufferIsCritical(video: HTMLVideoElement) {
if (video.readyState < 3) return true;
if (videoBufferedToEnd(video)) return false;
return bufferedAheadSeconds(video) < ACTIVE_PRELOAD_KEEP_SECONDS;
}
function bufferedAheadSeconds(video: HTMLVideoElement) {
const current = video.currentTime || 0;
for (let i = 0; i < video.buffered.length; i += 1) {
const start = video.buffered.start(i);
const end = video.buffered.end(i);
if (start <= current + 0.25 && end > current) {
return Math.max(0, end - current);
}
}
return 0;
}
function formatClock(seconds: number) {
if (!Number.isFinite(seconds) || seconds < 0) return "00:00";
const total = Math.floor(seconds);
-17
View File
@@ -10,7 +10,6 @@ import {
deleteVideo,
fetchTags,
fetchVideoDetail,
hideVideo,
recordView,
updateVideoTags,
} from "@/data/videos";
@@ -23,7 +22,6 @@ export default function VideoDetailPage() {
const [tags, setTags] = useState<TagItem[]>([]);
const [loading, setLoading] = useState(true);
const [tagSaving, setTagSaving] = useState(false);
const [hideSaving, setHideSaving] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteSource, setDeleteSource] = useState(false);
const [deleteSaving, setDeleteSaving] = useState(false);
@@ -68,19 +66,6 @@ export default function VideoDetailPage() {
}
}
async function handleHideVideo() {
if (!detail || hideSaving) return;
if (!window.confirm("确定以后不再展示这个视频吗?")) return;
setHideSaving(true);
try {
await hideVideo(detail.id);
navigate("/list", { replace: true });
} catch {
setHideSaving(false);
window.alert("隐藏失败,请稍后重试");
}
}
function handleOpenDelete() {
if (!detail || deleteSaving) return;
setDeleteSource(false);
@@ -233,9 +218,7 @@ export default function VideoDetailPage() {
<VideoActions
video={detail}
onHideVideo={handleHideVideo}
onDeleteVideo={handleOpenDelete}
hideSaving={hideSaving}
deleteSaving={deleteSaving}
/>
</section>
+151 -3
View File
@@ -1819,6 +1819,10 @@
letter-spacing: 0.06em;
}
.admin-table th.is-actions {
text-align: center;
}
.admin-table tr:last-child td {
border-bottom: 0;
}
@@ -1830,7 +1834,7 @@
}
.admin-table td.is-actions {
text-align: right;
text-align: center;
white-space: nowrap;
}
@@ -3334,12 +3338,104 @@
box-shadow: 0 0 0 3px var(--accent-soft);
}
/* 视频管理:当前 / 隐藏 / 拉黑 分段标签 */
.admin-video-tabs {
display: flex;
gap: var(--space-1);
padding: 4px;
margin-bottom: var(--space-4);
background: var(--surface-2, rgba(127, 127, 127, 0.08));
border: 1px solid var(--border-default);
border-radius: 10px;
width: fit-content;
max-width: 100%;
overflow-x: auto;
}
.admin-video-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
border: none;
background: transparent;
color: var(--text-faint);
font-size: var(--font-sm);
font-weight: 500;
border-radius: 7px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.admin-video-tab:hover {
color: var(--text-default);
}
.admin-video-tab.is-active {
background: var(--accent);
color: #fff;
}
.admin-video-tab__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 18px;
padding: 0 6px;
font-size: 11px;
font-weight: 600;
line-height: 1;
border-radius: 9px;
background: var(--border-default);
color: var(--text-default);
}
.admin-video-tab.is-active .admin-video-tab__count {
background: rgba(255, 255, 255, 0.25);
color: #fff;
}
/* 标签页顶部说明文字 */
.admin-tab-intro {
font-size: var(--font-xs);
color: var(--text-faint);
line-height: 1.6;
margin-bottom: var(--space-3);
padding: 10px 12px;
background: var(--surface-2, rgba(127, 127, 127, 0.06));
border: 1px solid var(--border-default);
border-radius: 8px;
}
.admin-blacklist-filename {
display: block;
min-width: 0;
word-break: break-all;
overflow-wrap: anywhere;
}
.admin-blacklist-restore-btn {
border-color: var(--border-accent);
background: var(--accent-softer);
color: var(--accent);
box-shadow: none;
}
.admin-blacklist-restore-btn:hover:not(:disabled) {
border-color: var(--accent);
background: var(--accent-soft);
color: var(--accent-strong);
box-shadow: none;
}
.admin-videos-list-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin: -8px 0 var(--space-4);
margin: var(--space-2) 0 var(--space-4);
}
.admin-videos-summary {
@@ -3352,6 +3448,7 @@
display: inline-flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
flex: none;
}
@@ -3401,9 +3498,11 @@
}
.admin-table-pagination__info {
min-width: 0;
font-size: var(--font-xs);
color: var(--text-faint);
margin: 0 var(--space-2);
text-align: center;
}
.admin-form__divider {
@@ -3433,15 +3532,64 @@
justify-content: center;
}
.admin-table-pagination__info {
order: -1;
flex: 1 0 100%;
margin: 0 0 2px;
}
.admin-videos-list-toolbar {
align-items: stretch;
flex-direction: column;
}
.admin-videos-bulk-actions {
justify-content: space-between;
flex-wrap: wrap;
justify-content: flex-start;
width: 100%;
}
.admin-videos-bulk-actions__count {
flex: 1 0 100%;
}
.admin-videos-bulk-actions__btn {
flex: 1 1 136px;
justify-content: center;
min-width: 0;
}
.admin-blacklist-table:not(.admin-drives-table) td[data-label="文件名"] {
grid-column: 1 / -1;
}
.admin-blacklist-table:not(.admin-drives-table) td[data-label="拉黑时间"] {
grid-column: 1;
align-content: center;
}
.admin-blacklist-table:not(.admin-drives-table) td.is-actions {
grid-column: 2;
align-content: center;
align-items: center;
justify-content: flex-end;
text-align: right;
}
.admin-blacklist-table:not(.admin-drives-table) td.is-actions::before {
content: none;
display: none;
}
.admin-blacklist-table:not(.admin-drives-table) td.is-actions .admin-btn {
justify-content: center;
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
min-height: 32px;
padding: 6px 10px;
white-space: normal;
}
}
/* =========================================================
+32 -16
View File
@@ -485,6 +485,27 @@
}
@media (max-width: 640px) {
.video-player .art-video-player {
--art-settings-icon-size: 18px;
--art-settings-max-height: 132px;
--art-selector-max-height: 132px;
--art-scrollbar-size: 3px;
}
.video-player .art-settings {
border-radius: 8px;
font-size: 12px;
}
.video-player .art-settings .art-setting-panel .art-setting-item {
padding: 0 7px;
}
.video-player .art-settings .art-setting-item-left,
.video-player .art-settings .art-setting-item-right {
gap: 5px;
}
.video-player__error {
width: calc(100% - 24px);
padding: 16px;
@@ -784,7 +805,7 @@
cursor: not-allowed;
}
.vd-actions__hide {
.vd-actions__delete {
margin-left: auto;
}
@@ -1869,10 +1890,16 @@
.vd-skeleton__actions {
display: grid;
grid-template-columns: 1fr 1fr 44px;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 44px;
}
.vd-skeleton__actions span {
min-width: 0;
width: 100%;
max-width: 100%;
}
.vd-skeleton__actions span:last-child {
width: 100%;
}
@@ -1888,7 +1915,7 @@
gap: var(--space-2) var(--space-3);
}
/* 操作栏:点赞点踩组合占满主行,"不再显示"折到右侧或单独一行 */
/* 操作栏:点赞点踩组合占满主行,删除按钮固定在右侧 */
.vd-actions {
padding: var(--space-3) 0 0;
gap: var(--space-2);
@@ -1907,19 +1934,8 @@
padding: 0 var(--space-3);
}
.vd-actions__hide {
margin-left: 0;
width: 44px;
padding: 0;
justify-content: center;
flex: 0 0 auto;
}
.vd-actions__hide span {
display: none;
}
.vd-actions__delete {
margin-left: 0;
width: 44px;
padding: 0;
justify-content: center;
@@ -1931,7 +1947,7 @@
}
.vd-delete-modal {
align-items: end;
place-items: center;
padding: var(--space-3);
}
+63
View File
@@ -100,6 +100,69 @@ test("admin video bulk actions use semantic theme colors", () => {
assert.doesNotMatch(bulkBodies, /#ff5b8a|#fff6f9|rgba\(255,\s*91,\s*138/);
});
test("admin video list summary stays below filter controls", () => {
const toolbar = ruleBody(adminCss, ".admin-videos-list-toolbar");
assert.match(toolbar, /margin\s*:\s*var\(--space-2\)\s+0\s+var\(--space-4\)/);
assert.doesNotMatch(toolbar, /margin\s*:\s*-/);
});
test("admin table action headers center-align with action buttons", () => {
const actionHeader = ruleBody(adminCss, ".admin-table th.is-actions");
const actionCell = ruleBody(adminCss, ".admin-table td.is-actions");
assert.match(actionHeader, /text-align\s*:\s*center/);
assert.match(actionCell, /text-align\s*:\s*center/);
});
test("blacklist restore action uses a light button style", () => {
const restoreButton = ruleBody(adminCss, ".admin-blacklist-restore-btn");
assert.match(videosPageSource, /className="admin-btn admin-blacklist-restore-btn"/);
assert.match(restoreButton, /background\s*:\s*var\(--accent-softer\)/);
assert.match(restoreButton, /color\s*:\s*var\(--accent\)/);
assert.doesNotMatch(restoreButton, /background\s*:\s*var\(--accent\)/);
});
test("admin video management controls wrap instead of covering text on mobile", () => {
const css = mobileCss();
const paginationInfo = allRuleBodies(css, ".admin-table-pagination__info");
const bulkActions = allRuleBodies(css, ".admin-videos-bulk-actions");
const bulkCount = allRuleBodies(css, ".admin-videos-bulk-actions__count");
const bulkButton = allRuleBodies(css, ".admin-videos-bulk-actions__btn");
const blacklistName = ruleBody(
css,
'.admin-blacklist-table:not(.admin-drives-table) td[data-label="文件名"]'
);
const blacklistTime = ruleBody(
css,
'.admin-blacklist-table:not(.admin-drives-table) td[data-label="拉黑时间"]'
);
const blacklistActions = ruleBody(
css,
".admin-blacklist-table:not(.admin-drives-table) td.is-actions"
);
const blacklistActionsLabel = ruleBody(
css,
".admin-blacklist-table:not(.admin-drives-table) td.is-actions::before"
);
const blacklistActionButton = ruleBody(
css,
".admin-blacklist-table:not(.admin-drives-table) td.is-actions .admin-btn"
);
assert.match(paginationInfo, /flex\s*:\s*1\s+0\s+100%/);
assert.match(bulkActions, /flex-wrap\s*:\s*wrap/);
assert.match(bulkCount, /flex\s*:\s*1\s+0\s+100%/);
assert.match(bulkButton, /min-width\s*:\s*0/);
assert.match(blacklistName, /grid-column\s*:\s*1\s*\/\s*-1/);
assert.match(blacklistTime, /grid-column\s*:\s*1/);
assert.match(blacklistActions, /grid-column\s*:\s*2/);
assert.match(blacklistActions, /justify-content\s*:\s*flex-end/);
assert.match(blacklistActionsLabel, /content\s*:\s*none/);
assert.match(blacklistActionButton, /white-space\s*:\s*normal/);
});
test("admin loading spinner rotates around icon center", () => {
const spinner = ruleBody(adminCss, ".admin-spin");
const reducedMotion = ruleBodyByContains(adminCss, ".admin-sidebar__check-update:disabled svg");
+100 -7
View File
@@ -6,20 +6,24 @@ const shortsPageSource = readFileSync(
new URL("../src/pages/ShortsPage.tsx", import.meta.url),
"utf8"
);
const videosDataSource = readFileSync(
new URL("../src/data/videos.ts", import.meta.url),
"utf8"
);
test("shorts recommendation preference follows successful likes instead of watch time", () => {
test("shorts does not keep recommendation preference from likes or watch time", () => {
assert.doesNotMatch(shortsPageSource, /currentTime\s*>=\s*3/);
assert.doesNotMatch(shortsPageSource, /onPreferenceReady/);
assert.doesNotMatch(shortsPageSource, /preferredFromVideoId/);
assert.doesNotMatch(videosDataSource, /preferredFromVideoId/);
const match = /const handleLikeToggle[\s\S]*?const hasLiked/.exec(
shortsPageSource
);
assert.ok(match, "handleLikeToggle block should be present");
assert.match(
match[0],
/if \(liked\) \{\s*preferredFromVideoIdRef\.current = videoId;\s*\} else if \(preferredFromVideoIdRef\.current === videoId\) \{\s*preferredFromVideoIdRef\.current = null;/
);
assert.doesNotMatch(match[0], /preferred/i);
assert.match(videosDataSource, /body: JSON\.stringify\(\{ seenIds, count \}\)/);
});
test("shorts progress dragging uses immediate pointer state", () => {
@@ -34,15 +38,104 @@ test("shorts progress dragging uses immediate pointer state", () => {
test("shorts progress listeners rebind when deferred videos mount", () => {
assert.match(
shortsPageSource,
/MOUNT_RADIUS 会让第三屏以后的 slide 先以海报占位/
/VIDEO_WINDOW_SIZE 会让窗口外的 slide 先以海报占位/
);
assert.match(shortsPageSource, /if \(!shouldMount\) \{\s*setDuration\(0\);\s*setCurrentTime\(0\);/);
assert.match(
shortsPageSource,
/\}, \[shouldMount, item\.id, muted, volume, setMuted, setVolume\]\);/
/\}, \[shouldMount, shouldLoad, item\.id, index, isActive, muted, volume, setMuted, setVolume, onActiveReadyForPreload, onActiveNeedsPriority, onSourceCached\]\);/
);
});
test("shorts preloads the next two original videos only after the active video has comfortable buffer", () => {
assert.match(shortsPageSource, /const \[activeReadyForPreload, setActiveReadyForPreload\] = useState\(false\);/);
assert.match(shortsPageSource, /const ACTIVE_PRELOAD_BUFFER_SECONDS = 12;/);
assert.match(shortsPageSource, /const PRELOAD_AHEAD_COUNT = 2;/);
assert.match(
shortsPageSource,
/const preloadOffset = index - activeIndex;[\s\S]*?preloadOffset > 0 &&[\s\S]*?preloadOffset <= PRELOAD_AHEAD_COUNT;/
);
assert.match(shortsPageSource, /const shouldLoad = isActiveSlide \|\| shouldPreload \|\| shouldRetainCached;/);
assert.match(shortsPageSource, /shouldLoad=\{shouldLoad\}/);
assert.match(shortsPageSource, /setActiveReadyForPreload\(false\);\s*setActiveIndex\(bestIndex\);/);
assert.match(shortsPageSource, /function syncActivePreloadReadiness\(currentVideo: HTMLVideoElement\)/);
assert.match(shortsPageSource, /if \(videoHasComfortableBuffer\(currentVideo\)\) \{\s*onActiveReadyForPreload\(index\);/);
assert.match(shortsPageSource, /if \(isActive\) onActiveNeedsPriority\(index\);/);
assert.match(shortsPageSource, /video\.addEventListener\("progress", handleProgress\);/);
assert.match(shortsPageSource, /src=\{shouldLoad \? item\.videoSrc : undefined\}/);
assert.match(shortsPageSource, /video\.removeAttribute\("src"\)/);
assert.doesNotMatch(shortsPageSource, /src=\{shouldLoad \? item\.previewSrc/);
});
test("shorts preload grant uses high/low watermark hysteresis", () => {
// 高水位 12s 授权、低水位 4s 收回,之间维持现状,避免阈值附近抖动
assert.match(shortsPageSource, /const ACTIVE_PRELOAD_KEEP_SECONDS = 4;/);
assert.match(
shortsPageSource,
/\} else if \(videoBufferIsCritical\(currentVideo\)\) \{[\s\S]*?onActiveNeedsPriority\(index\);/
);
assert.match(shortsPageSource, /function videoBufferIsCritical\(video: HTMLVideoElement\)/);
// 已缓冲到片尾时既视为健康也不视为告急,避免临近结尾误收回授权
assert.match(shortsPageSource, /function videoBufferedToEnd\(video: HTMLVideoElement\)/);
assert.match(
shortsPageSource,
/if \(videoBufferedToEnd\(video\)\) return true;[\s\S]*?>= ACTIVE_PRELOAD_BUFFER_SECONDS;/
);
assert.match(
shortsPageSource,
/if \(videoBufferedToEnd\(video\)\) return false;[\s\S]*?< ACTIVE_PRELOAD_KEEP_SECONDS;/
);
});
test("shorts waits for the queue end before starting a new seen round", () => {
assert.match(
shortsPageSource,
/if \(roundComplete\) \{[\s\S]*?if \(remaining > 0\) return;[\s\S]*?seenIdsRef\.current = \[\];[\s\S]*?saveSeenIds\(\[\]\);/
);
});
test("shorts keeps buffered sources inside a six video window", () => {
assert.match(shortsPageSource, /const \[cacheableSourceIds, setCacheableSourceIds\] = useState<Set<string>>/);
assert.match(shortsPageSource, /setCacheableSourceIds\(\(prev\) => \{/);
assert.match(shortsPageSource, /const VIDEO_WINDOW_SIZE = 6;/);
assert.doesNotMatch(shortsPageSource, /VIDEO_WINDOW_BACKWARD_BIAS/);
assert.match(shortsPageSource, /const \[cacheWindowHighIndex, setCacheWindowHighIndex\] = useState\(-1\);/);
assert.match(shortsPageSource, /setCacheWindowHighIndex\(\(prev\) => Math\.max\(prev, activeIndex\)\);/);
assert.match(shortsPageSource, /function getVideoWindowBounds\(highestViewedIndex: number, itemCount: number\)/);
assert.match(
shortsPageSource,
/const videoWindow = getVideoWindowBounds\(cacheWindowHighIndex, items\.length\);/
);
assert.match(
shortsPageSource,
/const isInCacheWindow =\s*index >= videoWindow\.start && index <= videoWindow\.end;/
);
assert.match(
shortsPageSource,
/const shouldMount = isActiveSlide \|\| isInCacheWindow \|\| shouldPreload;/
);
// 视频窗口内已缓冲过的视频都保留 src,来回切换均复用缓存
assert.match(
shortsPageSource,
/const shouldRetainCached =\s*isInCacheWindow && !isActiveSlide && cacheableSourceIds\.has\(item\.id\);/
);
// 窗口内视频一旦 canplay 就标记可复用,快速划走的视频回滑也有缓存
assert.match(
shortsPageSource,
/if \(shouldLoad\) onSourceCached\(item\.id\);/
);
// 窗口内视频只要已经产生缓冲就同样标记,授权收回时不丢弃其数据
assert.match(
shortsPageSource,
/if \(shouldLoad && videoHasBufferedData\(video\)\) \{\s*onSourceCached\(item\.id\);/
);
const playbackBlock = /\/\/ 控制每个 video 的播放状态与音量[\s\S]*?\}, \[activeIndex, muted, volume, items\.length\]\);/.exec(shortsPageSource);
assert.ok(playbackBlock, "parent playback effect should be present");
assert.doesNotMatch(playbackBlock[0], /currentTime\s*=\s*0/);
assert.match(shortsPageSource, /shouldEagerLoad=\{shouldEagerLoad\}/);
assert.match(shortsPageSource, /preload=\{shouldLoad \? \(shouldEagerLoad \? "auto" : "metadata"\) : "none"\}/);
});
test("shorts fullscreen changes preserve the active slide", () => {
assert.match(shortsPageSource, /const activeIndexRef = useRef\(0\)/);
assert.match(shortsPageSource, /const ignoreIntersectionUntilRef = useRef\(0\)/);
+27
View File
@@ -10,6 +10,10 @@ const detailCss = readFileSync(
new URL("../src/styles/video-detail.css", import.meta.url),
"utf8"
);
const detailPageSource = readFileSync(
new URL("../src/pages/VideoDetailPage.tsx", import.meta.url),
"utf8"
);
test("detail dislike does not locally decrement persisted likes", () => {
const match = /function handleDislike\(\) \{([\s\S]*?)\n return \(/.exec(
@@ -31,3 +35,26 @@ test("detail like and dislike buttons are visually separated", () => {
/\.vd-actions__pill\s*\{[^}]*border:\s*1px solid var\(--border-subtle\)[^}]*border-radius:\s*var\(--radius-sm\)/s
);
});
test("detail playback actions only expose delete as the management action", () => {
assert.doesNotMatch(actionsSource, /不再显示/);
assert.doesNotMatch(actionsSource, /EyeOff/);
assert.doesNotMatch(actionsSource, /onHideVideo/);
assert.doesNotMatch(actionsSource, /hideSaving/);
assert.doesNotMatch(actionsSource, /vd-actions__hide/);
assert.match(actionsSource, /aria-label="删除这个视频"/);
assert.doesNotMatch(detailPageSource, /hideVideo/);
assert.doesNotMatch(detailPageSource, /handleHideVideo/);
assert.doesNotMatch(detailPageSource, /onHideVideo/);
});
test("detail delete dialog stays centered on mobile", () => {
assert.match(
detailCss,
/@media \(max-width:\s*480px\)\s*\{[\s\S]*\.vd-delete-modal\s*\{[^}]*place-items:\s*center/s
);
assert.doesNotMatch(
detailCss,
/@media \(max-width:\s*480px\)\s*\{[\s\S]*\.vd-delete-modal\s*\{[^}]*align-items:\s*end/s
);
});
+52
View File
@@ -33,6 +33,47 @@ test("detail player does not keep playback resume state", () => {
assert.doesNotMatch(detailCss, /video-player__resume/);
});
test("detail player does not persist ArtPlayer user settings", () => {
assert.doesNotMatch(playerSource, /localStorage/);
assert.doesNotMatch(playerSource, /SETTINGS_KEY/);
assert.doesNotMatch(playerSource, /readPlayerSettings/);
assert.doesNotMatch(playerSource, /writePlayerSettings/);
assert.doesNotMatch(playerSource, /video-site:player-settings/);
assert.match(playerSource, /volume:\s*DEFAULT_SETTINGS\.volume/);
assert.match(playerSource, /muted:\s*DEFAULT_SETTINGS\.muted/);
assert.match(playerSource, /video\.playbackRate = DEFAULT_SETTINGS\.playbackRate/);
assert.match(
playerSource,
/applyPlayerBrightness\(art,\s*DEFAULT_SETTINGS\.brightness\)/
);
});
test("detail player uses compact ArtPlayer settings panel on mobile", () => {
assert.match(playerSource, /const COMPACT_SETTING_LAYOUT = \{[\s\S]*width:\s*172[\s\S]*itemWidth:\s*148[\s\S]*itemHeight:\s*30/s);
assert.match(
playerSource,
/configureArtPlayerSettingLayout\(\s*shouldUseCompactPlayerSettings\(mount,\s*enableOrientationControl\)\s*\)/
);
assert.match(playerSource, /Artplayer\.SETTING_WIDTH = layout\.width/);
assert.match(playerSource, /Artplayer\.SETTING_ITEM_WIDTH = layout\.itemWidth/);
assert.match(playerSource, /Artplayer\.SETTING_ITEM_HEIGHT = layout\.itemHeight/);
assert.match(
detailCss,
/@media \(max-width:\s*640px\)\s*\{[\s\S]*\.video-player \.art-video-player\s*\{[^}]*--art-settings-icon-size:\s*18px[^}]*--art-settings-max-height:\s*132px[^}]*--art-selector-max-height:\s*132px/s
);
});
test("detail player exposes a non-persistent loop switch in ArtPlayer settings", () => {
assert.match(playerSource, /settings:\s*\[createLoopSetting\(\)\]/);
assert.match(playerSource, /function createLoopSetting\(\)/);
assert.match(playerSource, /html:\s*"洗脑循环"/);
assert.match(playerSource, /tooltip:\s*"关"/);
assert.match(playerSource, /switch:\s*false/);
assert.match(playerSource, /video\.loop = false/);
assert.match(playerSource, /this\.video\.loop = next/);
assert.match(playerSource, /item\.tooltip = next \? "开" : "关"/);
});
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"/);
@@ -58,6 +99,17 @@ test("detail loading skeleton matches current desktop video page layout", () =>
);
});
test("detail loading skeleton actions stay inside mobile viewport", () => {
assert.match(
detailCss,
/@media \(max-width:\s*480px\)\s*\{[\s\S]*\.vd-skeleton__actions\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\) minmax\(0,\s*1fr\) 44px/s
);
assert.match(
detailCss,
/@media \(max-width:\s*480px\)\s*\{[\s\S]*\.vd-skeleton__actions span:last-child\s*\{[^}]*width:\s*100%/s
);
});
test("detail video title uses a restrained size", () => {
assert.match(
detailCss,