mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-16 01:05:42 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8388eba59 | |||
| 76782f3801 | |||
| 1ae1408fb6 | |||
| 738406162a | |||
| 0f111b846d | |||
| 4dd9015bd7 | |||
| 84fbb6f51c |
@@ -37,6 +37,13 @@ __pycache__/
|
||||
*.pyc
|
||||
|
||||
# Local scratch images
|
||||
/*.png
|
||||
/*.jpg
|
||||
/*.jpeg
|
||||
/*.gif
|
||||
/*.webp
|
||||
/*.bmp
|
||||
/*.ico
|
||||
/image.jpg
|
||||
/image003.jpg
|
||||
/image004.jpg
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 返回 nil(JSON 省略)。未配置时默认 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
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,保留视频自身的 id(spider91-<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(¤t); 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Generated
+2
-2
@@ -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
@@ -2,7 +2,7 @@
|
||||
"name": "video-site",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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\)/);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user