mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 16:55:42 +08:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 052e142520 | |||
| f9351324c6 | |||
| bb83277d62 | |||
| aa856db1f6 | |||
| 7e5e67697e | |||
| 9cc8e02bec | |||
| 139e63eef2 | |||
| 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
|
||||
|
||||
@@ -20,12 +20,11 @@
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **多后端支持** — 兼容 115 云盘、PikPak 云盘、123网盘、联通网盘、OneDrive、Google Drive 和本地存储
|
||||
- **低带宽播放** — 115 云盘、PikPak 云盘、123网盘、联通网盘、OneDrive 支持302模式,在线播放视频时,不占用服务器带宽,播放体验不受服务器带宽影响;Google Drive 不支持302模式,走服务器中转,观看体验会受服务器带宽影响
|
||||
- **多后端支持** — 兼容 115 云盘、PikPak 云盘、123网盘、联通网盘、光鸭网盘、OneDrive、Google Drive 和本地存储
|
||||
- **低带宽播放** — 115 云盘、PikPak 云盘、123网盘、联通网盘、光鸭网盘、OneDrive 支持302模式,在线播放视频时,不占用服务器带宽,播放体验不受服务器带宽影响;Google Drive 不支持302模式,走服务器中转,观看体验会受服务器带宽影响
|
||||
- **封面 & 预览片段** — 自动为每个视频生成封面图和预览片段,首页快速选片
|
||||
- **爬虫脚本** — 项目支持导入自定义脚本,但是有一些规范,具体可以参考 [SpiderFor91](https://github.com/Just-Spider/SpiderFor91),项目不再内置任何爬虫脚本
|
||||
- **短视频模式** — 一键切换抖音风格,沉浸刷片
|
||||
|
||||
---
|
||||
|
||||
## 预览图
|
||||
@@ -82,6 +81,14 @@ sudo bash install.sh
|
||||
|
||||
> `video-site-91` 为等效别名,两者可互换使用。
|
||||
|
||||
**已部署用户升级:**
|
||||
|
||||
```bash
|
||||
91 update
|
||||
```
|
||||
|
||||
升级会保留现有 `config.yaml`、数据库、封面、预览、上传文件和爬虫数据。脚本会自动安装或检查 `ffmpeg` / `ffprobe` 等运行依赖,并在新版本启动失败时回滚到升级前文件。
|
||||
|
||||
**自定义端口:**
|
||||
|
||||
```bash
|
||||
@@ -153,6 +160,7 @@ docker compose up -d # 更新并重启
|
||||
```
|
||||
|
||||
> 所有配置、数据库、封面、预览及上传文件均保存在 `./data/` 目录下。
|
||||
> 从旧版本升级 Docker 部署时,执行 `docker compose pull && docker compose up -d` 即可;`./data/` 不会被镜像更新覆盖。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+5
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
视频聚合站的 Go 后端。提供三件事:
|
||||
|
||||
1. 多家网盘统一抽象(夸克 / 115 / PikPak / 联通网盘 / OneDrive / Google Drive / 本地存储)
|
||||
1. 多家网盘统一抽象(夸克 / 115 / PikPak / 联通网盘 / 光鸭网盘 / OneDrive / Google Drive / 本地存储)
|
||||
2. 视频元数据目录(SQLite)+ 扫描 + 预览视频预生成
|
||||
3. REST API(前台)+ 管理后台 + 直链代理
|
||||
4. 标签池、视频隐藏、按网盘统计和详情页来源网盘类型展示能力
|
||||
@@ -20,6 +20,7 @@ internal/
|
||||
p115/ 115(壳子 + SheltonZhu/115driver)
|
||||
pikpak/ PikPak(自己实现,参考 OpenList pikpak)
|
||||
wopan/ 联通网盘(壳子 + OpenListTeam/wopan-sdk-go)
|
||||
guangyapan/ 光鸭网盘(参考 AList GuangYaPan)
|
||||
onedrive/ OneDrive(OpenList 在线续期 + Microsoft Graph 文件接口)
|
||||
googledrive/ Google Drive(OpenList 在线续期 + Google Drive API;播放走后端代理)
|
||||
localstorage/ 本地目录扫描(服务器已有视频目录)
|
||||
@@ -108,6 +109,7 @@ go run ./cmd/server 后端 9192
|
||||
| p115 | `cookie`(形如 `UID=...; CID=...; SEID=...; KID=...`) |
|
||||
| pikpak | `username`、`password`(token、验证码和设备 ID 由服务端自动处理并保存) |
|
||||
| wopan | `access_token`、`refresh_token`,可选 `family_id` |
|
||||
| guangyapan | 推荐后台扫码登录自动写入 `access_token`、`refresh_token`;也可手工填写 token;可选 `root_path` |
|
||||
| onedrive | `refresh_token` |
|
||||
| googledrive | 默认只需 `refresh_token`;自建 OAuth 客户端模式还需 `use_online_api=false`、`client_id`、`client_secret` |
|
||||
| localstorage | `path`(服务器上的已有视频目录,如 `/mnt/videos`) |
|
||||
@@ -154,9 +156,9 @@ Google Drive 默认按 OpenList 在线 API 调用 `https://api.oplist.org/google
|
||||
## 管理能力
|
||||
|
||||
- `/admin/drives`:新增、编辑、删除网盘,触发扫描。
|
||||
- `/admin/videos`:按网盘筛选视频,每页 100 条分页,查看各网盘预览视频统计,编辑标题/作者/分类/标签,单条或全量重生预览视频。
|
||||
- `/admin/videos`:按网盘筛选视频,每页 100 条分页,查看各网盘预览视频统计,编辑标题/作者/分类/标签,单条或全量重生预览视频;拉黑视频页可查看被删除或被隐藏的视频,并支持移出黑名单后在下次扫盘重新入库。
|
||||
- `/admin/tags`:新增标签并用内置规则自动匹配已有视频;删除非系统标签时会从所有视频上同步移除该标签。
|
||||
- 播放页视频信息会展示来源网盘类型;同时提供“不再展示”,点击后会把视频标记为全局隐藏。隐藏视频不会再出现在首页、列表、搜索、相关推荐和详情接口中。目前没有管理后台恢复入口,如需恢复可把数据库里对应视频的 `hidden` 字段改回 `0`。
|
||||
- 播放页视频信息会展示来源网盘类型,并提供删除入口。被删除或被隐藏的视频会进入黑名单,不会再出现在首页、列表、搜索和详情接口中;在后台移出黑名单后,会在下次扫盘时重新发现并入库。
|
||||
|
||||
## 预览视频生成
|
||||
|
||||
|
||||
+372
-33
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/video-site/backend/internal/config"
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
"github.com/video-site/backend/internal/drives/googledrive"
|
||||
"github.com/video-site/backend/internal/drives/guangyapan"
|
||||
"github.com/video-site/backend/internal/drives/localstorage"
|
||||
"github.com/video-site/backend/internal/drives/localupload"
|
||||
"github.com/video-site/backend/internal/drives/onedrive"
|
||||
@@ -43,6 +44,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 +132,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 +178,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,12 +235,21 @@ 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)
|
||||
},
|
||||
GetDriveGenerationStatuses: func() map[string]api.DriveGenerationStatuses {
|
||||
return app.driveGenerationStatuses()
|
||||
},
|
||||
GetPreviewGenerationVideoIDs: func() map[string]bool {
|
||||
return app.previewGenerationVideoIDs()
|
||||
},
|
||||
OnTeaserEnabledChanged: func(driveID string, enabled bool) {
|
||||
// 从关到开时立刻补扫该盘 pending 预览视频,行为对齐旧的"全局开关从关到开"。
|
||||
// 关闭分支不需要做事 —— 入队前会重新查 catalog,新的 enqueue 自然停。
|
||||
@@ -297,6 +323,7 @@ func main() {
|
||||
}
|
||||
}()
|
||||
go app.attachExistingDrives(ctx)
|
||||
go app.migrateHiddenVideosToTombstone(ctx)
|
||||
|
||||
// 等待退出信号
|
||||
sigs := make(chan os.Signal, 1)
|
||||
@@ -329,13 +356,13 @@ type App struct {
|
||||
// 串行化可以避免启动后台挂载和手动扫盘按需挂载同一个 drive 时重复创建 worker。
|
||||
driveAttachMu sync.Mutex
|
||||
|
||||
// 全站主题("dark" | "pink"),从 DB 读
|
||||
// 全站主题("dark" | "pink" | "sky"),从 DB 读
|
||||
theme string
|
||||
// 显式指定的 spider91 上传目标 drive ID。
|
||||
// 空字符串表示本地保存不上传,不再自动挑选 pikpak/p115/p123/onedrive/wopan drive。
|
||||
// 空字符串表示本地保存不上传,不再自动挑选 pikpak/p115/p123/onedrive/wopan/guangyapan drive。
|
||||
spider91UploadDriveID string
|
||||
|
||||
// spider91Migrator 把 spider91 视频上传到目标 drive(PikPak、115、123、OneDrive、Google Drive 或联通网盘)。
|
||||
// spider91Migrator 把 spider91 视频上传到目标 drive(PikPak、115、123、OneDrive、Google Drive、联通网盘或光鸭网盘)。
|
||||
spider91Migrator spider91MigrationRunner
|
||||
|
||||
// nightlyRunner 是凌晨流水线调度器:每天 cron_hour 串行跑扫盘 → 91 爬虫 → 迁移。
|
||||
@@ -368,11 +395,19 @@ 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 {
|
||||
Scanned int
|
||||
Added int
|
||||
Scanned int
|
||||
Added int
|
||||
CooldownUntil time.Time
|
||||
}
|
||||
|
||||
type driveUploadProgress struct {
|
||||
@@ -421,7 +456,7 @@ func (a *App) Theme() string {
|
||||
|
||||
// SetTheme 切换并持久化主题;未知值会返回错误。
|
||||
func (a *App) SetTheme(ctx context.Context, theme string) error {
|
||||
if theme != "dark" && theme != "pink" {
|
||||
if theme != "dark" && theme != "pink" && theme != "sky" {
|
||||
return fmt.Errorf("unsupported theme %q", theme)
|
||||
}
|
||||
a.mu.Lock()
|
||||
@@ -440,7 +475,7 @@ func (a *App) loadTheme(ctx context.Context) {
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if v != "pink" && v != "dark" {
|
||||
if v != "pink" && v != "dark" && v != "sky" {
|
||||
v = "dark"
|
||||
}
|
||||
a.mu.Lock()
|
||||
@@ -449,7 +484,7 @@ func (a *App) loadTheme(ctx context.Context) {
|
||||
}
|
||||
|
||||
// Spider91UploadDriveID 返回当前配置的 spider91 上传目标 drive ID。
|
||||
// 空字符串表示本地保存不上传;只有管理员显式选择 pikpak/p115/p123/onedrive/googledrive/wopan drive 时才迁移上传。
|
||||
// 空字符串表示本地保存不上传;只有管理员显式选择 pikpak/p115/p123/onedrive/googledrive/wopan/guangyapan drive 时才迁移上传。
|
||||
func (a *App) Spider91UploadDriveID() string {
|
||||
a.mu.Lock()
|
||||
explicit := a.spider91UploadDriveID
|
||||
@@ -466,7 +501,7 @@ func (a *App) Spider91UploadDriveID() string {
|
||||
|
||||
// SetSpider91UploadDriveID 设置 spider91 上传目标 drive ID 并持久化。
|
||||
// 接受空字符串(本地保存不上传)。
|
||||
// 设置一个不存在或 kind 不是 pikpak / p115 / p123 / onedrive / googledrive / wopan 的 drive 会返回错误。
|
||||
// 设置一个不存在或 kind 不是 pikpak / p115 / p123 / onedrive / googledrive / wopan / guangyapan 的 drive 会返回错误。
|
||||
func (a *App) SetSpider91UploadDriveID(ctx context.Context, driveID string) error {
|
||||
driveID = strings.TrimSpace(driveID)
|
||||
if driveID != "" {
|
||||
@@ -475,7 +510,7 @@ func (a *App) SetSpider91UploadDriveID(ctx context.Context, driveID string) erro
|
||||
return fmt.Errorf("drive %q not found", driveID)
|
||||
}
|
||||
if !isSpider91UploadKind(d.Kind()) {
|
||||
return fmt.Errorf("drive %q kind=%s, only pikpak, p115, p123, onedrive, googledrive or wopan can be spider91 upload target", driveID, d.Kind())
|
||||
return fmt.Errorf("drive %q kind=%s, only pikpak, p115, p123, onedrive, googledrive, wopan or guangyapan can be spider91 upload target", driveID, d.Kind())
|
||||
}
|
||||
}
|
||||
a.mu.Lock()
|
||||
@@ -508,7 +543,7 @@ func formatOptionalRFC3339(t time.Time) string {
|
||||
// isSpider91UploadKind 是 spider91 迁移目标盘的 allowlist。
|
||||
// 与 spider91migrate.adaptUploadTarget 的支持范围保持一致。
|
||||
func isSpider91UploadKind(kind string) bool {
|
||||
return kind == "pikpak" || kind == "p115" || kind == "p123" || kind == "onedrive" || kind == "googledrive" || kind == "wopan"
|
||||
return kind == "pikpak" || kind == "p115" || kind == "p123" || kind == "onedrive" || kind == "googledrive" || kind == "wopan" || kind == guangyapan.Kind
|
||||
}
|
||||
|
||||
// loadSpider91UploadDriveID 从 DB 读上传目标 drive ID 设置;不存在时使用空串。
|
||||
@@ -557,18 +592,33 @@ 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))
|
||||
now := time.Now()
|
||||
for id, running := range scanningDrives {
|
||||
if !running {
|
||||
continue
|
||||
}
|
||||
progress := scanProgresses[id]
|
||||
state := "scanning"
|
||||
if progress.CooldownUntil.After(now) {
|
||||
state = "cooling"
|
||||
}
|
||||
status := out[id]
|
||||
status.Scan = api.GenerationStatus{
|
||||
State: "scanning",
|
||||
State: state,
|
||||
ScannedCount: progress.Scanned,
|
||||
AddedCount: progress.Added,
|
||||
}
|
||||
if !progress.CooldownUntil.IsZero() {
|
||||
status.Scan.CooldownUntil = progress.CooldownUntil.Format(time.RFC3339)
|
||||
}
|
||||
out[id] = status
|
||||
}
|
||||
for id, worker := range previewWorkers {
|
||||
@@ -601,6 +651,28 @@ 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
|
||||
}
|
||||
|
||||
func (a *App) previewGenerationVideoIDs() map[string]bool {
|
||||
a.mu.Lock()
|
||||
previewWorkers := make([]*preview.Worker, 0, len(a.workers))
|
||||
for _, worker := range a.workers {
|
||||
previewWorkers = append(previewWorkers, worker)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
out := make(map[string]bool)
|
||||
for _, worker := range previewWorkers {
|
||||
for _, id := range worker.ActiveVideoIDs() {
|
||||
out[id] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -687,6 +759,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()
|
||||
@@ -799,6 +991,33 @@ func (a *App) attachDriveUnlocked(ctx context.Context, d *catalog.Drive) error {
|
||||
_ = a.cat.UpsertDrive(ctx, d)
|
||||
},
|
||||
})
|
||||
case guangyapan.Kind:
|
||||
drv = guangyapan.New(guangyapan.Config{
|
||||
ID: d.ID,
|
||||
RootID: d.RootID,
|
||||
RootPath: d.Credentials["root_path"],
|
||||
PhoneNumber: d.Credentials["phone_number"],
|
||||
CaptchaToken: d.Credentials["captcha_token"],
|
||||
SendCode: parseBoolDefault(strings.TrimSpace(d.Credentials["send_code"]), false),
|
||||
VerifyCode: d.Credentials["verify_code"],
|
||||
VerificationID: d.Credentials["verification_id"],
|
||||
AccessToken: d.Credentials["access_token"],
|
||||
RefreshToken: d.Credentials["refresh_token"],
|
||||
ClientID: d.Credentials["client_id"],
|
||||
DeviceID: d.Credentials["device_id"],
|
||||
PageSize: parseIntDefault(strings.TrimSpace(d.Credentials["page_size"]), 100),
|
||||
OrderBy: parseIntDefault(strings.TrimSpace(d.Credentials["order_by"]), 3),
|
||||
SortType: parseIntDefault(strings.TrimSpace(d.Credentials["sort_type"]), 1),
|
||||
OnCredentialsUpdate: func(updated map[string]string) {
|
||||
if d.Credentials == nil {
|
||||
d.Credentials = make(map[string]string)
|
||||
}
|
||||
for k, v := range updated {
|
||||
d.Credentials[k] = v
|
||||
}
|
||||
_ = a.cat.UpsertDrive(ctx, d)
|
||||
},
|
||||
})
|
||||
case "onedrive":
|
||||
drv = onedrive.New(onedrive.Config{
|
||||
ID: d.ID,
|
||||
@@ -841,8 +1060,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{
|
||||
@@ -918,7 +1138,7 @@ func generationCooldownForDrive(drv drives.Drive) time.Duration {
|
||||
return 0
|
||||
}
|
||||
switch strings.ToLower(drv.Kind()) {
|
||||
case "wopan":
|
||||
case "wopan", "guangyapan":
|
||||
return 10 * time.Minute
|
||||
}
|
||||
return 0
|
||||
@@ -944,7 +1164,7 @@ func fingerprintConfigForDrive(drv drives.Drive) fingerprint.Config {
|
||||
return cfg
|
||||
}
|
||||
switch strings.ToLower(drv.Kind()) {
|
||||
case "p115", "p123", "onedrive", "wopan":
|
||||
case "p115", "p123", "onedrive", "wopan", "guangyapan":
|
||||
cfg.RateLimitCooldown = 10 * time.Minute
|
||||
case "pikpak":
|
||||
cfg.RateLimitCooldown = 5 * time.Minute
|
||||
@@ -1016,6 +1236,7 @@ func (a *App) attachScriptCrawler(d *catalog.Drive, drv *scriptcrawler.Driver) {
|
||||
CommonThumbDir: a.commonThumbsDir(),
|
||||
ProxyURL: proxyURL,
|
||||
ConfigJSON: configJSON,
|
||||
DisablePreview: !d.TeaserEnabled,
|
||||
OnProgress: func(progress scriptcrawler.CrawlProgress) {
|
||||
scanned := progress.Checked
|
||||
if scanned < progress.TotalEntries {
|
||||
@@ -1276,11 +1497,77 @@ func (a *App) updateDriveScanProgress(driveID string, scanned, added int) {
|
||||
if a.scanProgress == nil {
|
||||
a.scanProgress = make(map[string]driveScanProgress)
|
||||
}
|
||||
a.scanProgress[driveID] = driveScanProgress{Scanned: scanned, Added: added}
|
||||
progress := a.scanProgress[driveID]
|
||||
progress.Scanned = scanned
|
||||
progress.Added = added
|
||||
a.scanProgress[driveID] = progress
|
||||
}
|
||||
a.scanQueueMu.Unlock()
|
||||
}
|
||||
|
||||
func (a *App) updateDriveScanCooldown(driveID string, until time.Time) {
|
||||
driveID = strings.TrimSpace(driveID)
|
||||
if driveID == "" {
|
||||
return
|
||||
}
|
||||
a.scanQueueMu.Lock()
|
||||
if a.scanQueued[driveID] {
|
||||
if a.scanProgress == nil {
|
||||
a.scanProgress = make(map[string]driveScanProgress)
|
||||
}
|
||||
progress := a.scanProgress[driveID]
|
||||
progress.CooldownUntil = until
|
||||
a.scanProgress[driveID] = progress
|
||||
}
|
||||
a.scanQueueMu.Unlock()
|
||||
}
|
||||
|
||||
func (a *App) pauseDriveScanForRateLimit(ctx context.Context, driveID string, drv drives.Drive, err error) bool {
|
||||
wait, ok := drives.RateLimitRetryAfter(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if wait <= 0 {
|
||||
wait = scanCooldownForDrive(drv)
|
||||
}
|
||||
if wait <= 0 {
|
||||
wait = 5 * time.Minute
|
||||
}
|
||||
until := time.Now().Add(wait)
|
||||
a.updateDriveScanCooldown(driveID, until)
|
||||
log.Printf("[scan] drive=%s rate limited; cooling until=%s wait=%s: %v", driveID, until.Format(time.RFC3339), wait, err)
|
||||
if !sleepDriveScanCooldown(ctx, wait) {
|
||||
log.Printf("[scan] drive=%s cooldown canceled: %v", driveID, ctx.Err())
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func scanCooldownForDrive(drv drives.Drive) time.Duration {
|
||||
if drv == nil {
|
||||
return 5 * time.Minute
|
||||
}
|
||||
switch strings.ToLower(drv.Kind()) {
|
||||
case "guangyapan":
|
||||
return 10 * time.Minute
|
||||
default:
|
||||
return 5 * time.Minute
|
||||
}
|
||||
}
|
||||
|
||||
func sleepDriveScanCooldown(ctx context.Context, d time.Duration) bool {
|
||||
if d <= 0 {
|
||||
return true
|
||||
}
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) driveHasActiveWork(driveID string) bool {
|
||||
driveID = strings.TrimSpace(driveID)
|
||||
if driveID == "" {
|
||||
@@ -1435,10 +1722,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 +1747,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{}{}
|
||||
}
|
||||
@@ -1741,6 +2032,8 @@ func (a *App) runScanWithTaskContext(ctx context.Context, driveID string) {
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
log.Printf("[scan] drive=%s canceled: %v", driveID, err)
|
||||
} else if a.pauseDriveScanForRateLimit(ctx, driveID, drv, err) {
|
||||
return
|
||||
} else {
|
||||
log.Printf("[scan] drive=%s error: %v", driveID, err)
|
||||
}
|
||||
@@ -1813,6 +2106,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
|
||||
@@ -2844,18 +3164,7 @@ func (a *App) runScriptCrawlerCrawlWithTaskContext(ctx context.Context, driveID
|
||||
driveID, res.TargetNew, res.CandidateBudget, res.TotalEntries, res.NewVideos, res.Skipped, res.Failed, res.SeenSnapshot)
|
||||
}
|
||||
|
||||
if d.Credentials == nil {
|
||||
d.Credentials = make(map[string]string)
|
||||
}
|
||||
d.Credentials["last_crawl_at"] = strconv.FormatInt(time.Now().Unix(), 10)
|
||||
if runErr != nil {
|
||||
d.Status = "error"
|
||||
d.LastError = runErr.Error()
|
||||
} else {
|
||||
d.Status = "ok"
|
||||
d.LastError = ""
|
||||
}
|
||||
if err := a.cat.UpsertDrive(ctx, d); err != nil {
|
||||
if err := a.updateScriptCrawlerRunState(ctx, driveID, runErr); err != nil {
|
||||
log.Printf("[scriptcrawler] drive=%s update last_crawl_at: %v", driveID, err)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
@@ -2873,6 +3182,25 @@ func (a *App) runScriptCrawlerCrawlWithTaskContext(ctx context.Context, driveID
|
||||
return runErr == nil
|
||||
}
|
||||
|
||||
func (a *App) updateScriptCrawlerRunState(ctx context.Context, driveID string, runErr error) error {
|
||||
d, err := a.cat.GetDrive(ctx, driveID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.Credentials == nil {
|
||||
d.Credentials = make(map[string]string)
|
||||
}
|
||||
d.Credentials["last_crawl_at"] = strconv.FormatInt(time.Now().Unix(), 10)
|
||||
if runErr != nil {
|
||||
d.Status = "error"
|
||||
d.LastError = runErr.Error()
|
||||
} else {
|
||||
d.Status = "ok"
|
||||
d.LastError = ""
|
||||
}
|
||||
return a.cat.UpsertDrive(ctx, d)
|
||||
}
|
||||
|
||||
func (a *App) runSpider91MigrationAfterManualCrawl(ctx context.Context, driveID string) {
|
||||
a.runCrawlerMigrationAfterManualCrawl(ctx, driveID)
|
||||
}
|
||||
@@ -3171,3 +3499,14 @@ func parseBoolDefault(raw string, def bool) bool {
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func parseIntDefault(raw string, def int) int {
|
||||
if raw == "" {
|
||||
return def
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ func TestSpider91UploadDriveIDDoesNotAutoSelectTarget(t *testing.T) {
|
||||
reg.Set("p123-one", &spider91UploadTargetFakeDrive{id: "p123-one", kind: "p123"})
|
||||
reg.Set("onedrive-one", &spider91UploadTargetFakeDrive{id: "onedrive-one", kind: "onedrive"})
|
||||
reg.Set("wopan-one", &spider91UploadTargetFakeDrive{id: "wopan-one", kind: "wopan"})
|
||||
reg.Set("guangyapan-one", &spider91UploadTargetFakeDrive{id: "guangyapan-one", kind: "guangyapan"})
|
||||
|
||||
app := &App{registry: reg}
|
||||
if got := app.Spider91UploadDriveID(); got != "" {
|
||||
@@ -67,6 +68,11 @@ func TestSpider91UploadDriveIDDoesNotAutoSelectTarget(t *testing.T) {
|
||||
t.Fatalf("explicit wopan upload target = %q, want wopan-one", got)
|
||||
}
|
||||
|
||||
app.spider91UploadDriveID = "guangyapan-one"
|
||||
if got := app.Spider91UploadDriveID(); got != "guangyapan-one" {
|
||||
t.Fatalf("explicit guangyapan upload target = %q, want guangyapan-one", got)
|
||||
}
|
||||
|
||||
app.spider91UploadDriveID = "missing"
|
||||
if got := app.Spider91UploadDriveID(); got != "" {
|
||||
t.Fatalf("missing upload target = %q, want empty", got)
|
||||
|
||||
@@ -227,6 +227,53 @@ func TestRegisterPreviewWorkersBackfillsHistoricalFingerprints(t *testing.T) {
|
||||
t.Fatalf("fingerprint status=%q sampled=%q, want ready with hash", got.FingerprintStatus, got.SampledSHA256)
|
||||
}
|
||||
|
||||
func TestUpdateScriptCrawlerRunStatePreservesCurrentTeaserSwitch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
|
||||
if err != nil {
|
||||
t.Fatalf("open catalog: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := cat.Close(); err != nil {
|
||||
t.Fatalf("close catalog: %v", err)
|
||||
}
|
||||
})
|
||||
if err := cat.UpsertDrive(ctx, &catalog.Drive{
|
||||
ID: "crawler-id",
|
||||
Kind: scriptcrawler.Kind,
|
||||
Name: "Crawler",
|
||||
RootID: "/",
|
||||
Credentials: map[string]string{
|
||||
"script_path": "/tmp/crawler.py",
|
||||
"target_new": "10",
|
||||
},
|
||||
TeaserEnabled: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed crawler drive: %v", err)
|
||||
}
|
||||
if err := cat.SetDriveTeaserEnabled(ctx, "crawler-id", true); err != nil {
|
||||
t.Fatalf("toggle teaser: %v", err)
|
||||
}
|
||||
|
||||
app := &App{cat: cat}
|
||||
if err := app.updateScriptCrawlerRunState(ctx, "crawler-id", nil); err != nil {
|
||||
t.Fatalf("update run state: %v", err)
|
||||
}
|
||||
got, err := cat.GetDrive(ctx, "crawler-id")
|
||||
if err != nil {
|
||||
t.Fatalf("get crawler drive: %v", err)
|
||||
}
|
||||
if !got.TeaserEnabled {
|
||||
t.Fatal("teaserEnabled = false after run state update, want preserved true")
|
||||
}
|
||||
if got.Status != "ok" || got.LastError != "" {
|
||||
t.Fatalf("status=%q lastError=%q, want ok with no error", got.Status, got.LastError)
|
||||
}
|
||||
if got.Credentials["last_crawl_at"] == "" || got.Credentials["target_new"] != "10" {
|
||||
t.Fatalf("credentials after run state update = %#v", got.Credentials)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopDriveTasksCancelsQueuedTasksAndReplacesWorkers(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
@@ -391,6 +438,37 @@ func TestDriveGenerationStatusIncludesScanState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveGenerationStatusIncludesScanCooldown(t *testing.T) {
|
||||
until := time.Now().Add(time.Hour).Round(time.Second)
|
||||
app := &App{
|
||||
scanQueued: map[string]bool{"drive-id": true},
|
||||
scanProgress: map[string]driveScanProgress{
|
||||
"drive-id": {Scanned: 12, Added: 3, CooldownUntil: until},
|
||||
},
|
||||
}
|
||||
|
||||
status := app.driveGenerationStatuses()["drive-id"].Scan
|
||||
if status.State != "cooling" {
|
||||
t.Fatalf("scan status = %#v, want cooling", status)
|
||||
}
|
||||
if status.CooldownUntil != until.Format(time.RFC3339) {
|
||||
t.Fatalf("cooldown until = %q, want %q", status.CooldownUntil, until.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuangYaPanGenerationCooldowns(t *testing.T) {
|
||||
drv := &serverFakeKindDrive{id: "gy", kind: "guangyapan"}
|
||||
if got := generationCooldownForDrive(drv); got != 10*time.Minute {
|
||||
t.Fatalf("generation cooldown = %s, want 10m", got)
|
||||
}
|
||||
if got := fingerprintConfigForDrive(drv).RateLimitCooldown; got != 10*time.Minute {
|
||||
t.Fatalf("fingerprint cooldown = %s, want 10m", got)
|
||||
}
|
||||
if got := scanCooldownForDrive(drv); got != 10*time.Minute {
|
||||
t.Fatalf("scan cooldown = %s, want 10m", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSpider91MigrationAfterManualCrawlRequiresConfiguredUploadTarget(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
registry := proxy.NewRegistry()
|
||||
|
||||
@@ -56,7 +56,7 @@ preview:
|
||||
width: 480
|
||||
|
||||
# 盘列表。上线后请通过管理后台添加,本文件可留空。
|
||||
# kind 支持 quark / p115 / p123 / pikpak / wopan / onedrive / googledrive / localstorage。
|
||||
# kind 支持 quark / p115 / p123 / pikpak / wopan / guangyapan / onedrive / googledrive / localstorage。
|
||||
# OneDrive 示例:
|
||||
# - id: "my-onedrive"
|
||||
# kind: "onedrive"
|
||||
@@ -76,6 +76,17 @@ preview:
|
||||
# # use_online_api: "false"
|
||||
# # client_id: "..."
|
||||
# # client_secret: "..."
|
||||
# 光鸭网盘示例:
|
||||
# - id: "my-guangyapan"
|
||||
# kind: "guangyapan"
|
||||
# name: "我的光鸭网盘"
|
||||
# # 留空表示光鸭网盘根目录;也可以填写光鸭目录 fileId
|
||||
# root_id: ""
|
||||
# params:
|
||||
# # 推荐在后台使用扫码登录自动写入 access_token / refresh_token。
|
||||
# refresh_token: "..."
|
||||
# # 可选:按路径解析扫描根目录,优先于 root_id
|
||||
# # root_path: "影视/电影"
|
||||
# 本地存储示例:
|
||||
# - id: "local-media"
|
||||
# kind: "localstorage"
|
||||
|
||||
+221
-28
@@ -21,6 +21,7 @@ import (
|
||||
|
||||
"github.com/video-site/backend/internal/auth"
|
||||
"github.com/video-site/backend/internal/catalog"
|
||||
"github.com/video-site/backend/internal/drives/guangyapan"
|
||||
"github.com/video-site/backend/internal/drives/p123"
|
||||
"github.com/video-site/backend/internal/drives/scriptcrawler"
|
||||
"github.com/video-site/backend/internal/drives/spider91"
|
||||
@@ -48,27 +49,34 @@ type AdminServer struct {
|
||||
// LocalPreviewDir is the local directory that stores generated preview videos and thumbs.
|
||||
LocalPreviewDir string
|
||||
// Hooks:外层注入实际执行者
|
||||
OnDriveSaved func(driveID string) error
|
||||
OnDriveDeleteCleanup func(ctx context.Context, driveID string) (int, error)
|
||||
OnDriveRemoved func(driveID string)
|
||||
OnScanRequested func(driveID string) bool
|
||||
OnStopDriveTasks func(driveID string) bool
|
||||
OnStopAllTasks func() int
|
||||
OnRegenPreview func(videoID string)
|
||||
OnRegenAllPreviews func()
|
||||
OnRegenFailedPreviews func(driveID string)
|
||||
OnRegenFailedThumbnails func(driveID string)
|
||||
OnRegenFailedFingerprints func(driveID string)
|
||||
OnDeleteVideo func(ctx context.Context, videoID string, deleteSource bool) (DeleteVideoResult, error)
|
||||
GetDriveGenerationStatuses func() map[string]DriveGenerationStatuses
|
||||
OnDriveSaved func(driveID string) error
|
||||
OnDriveDeleteCleanup func(ctx context.Context, driveID string) (int, error)
|
||||
OnDriveRemoved func(driveID string)
|
||||
OnScanRequested func(driveID string) bool
|
||||
OnStopDriveTasks func(driveID string) bool
|
||||
OnStopAllTasks func() int
|
||||
OnRegenPreview func(videoID string)
|
||||
OnRegenAllPreviews func()
|
||||
OnRegenFailedPreviews func(driveID string)
|
||||
OnRegenFailedThumbnails func(driveID string)
|
||||
OnRegenFailedFingerprints func(driveID string)
|
||||
// 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
|
||||
GetPreviewGenerationVideoIDs func() map[string]bool
|
||||
// OnTeaserEnabledChanged 在 per-drive 预览视频开关被切换后调用。
|
||||
// enabled=true 时上层应该重新把 pending 预览视频入队(类似旧的全局开关从关到开);
|
||||
// enabled=false 时通常不用做事 —— worker 入队前会再次查 catalog,自然停止。
|
||||
OnTeaserEnabledChanged func(driveID string, enabled bool)
|
||||
// Theme 读写("dark" | "pink")
|
||||
// Theme 读写("dark" | "pink" | "sky")
|
||||
GetTheme func() string
|
||||
SetTheme func(theme string) error
|
||||
// Spider91 → 115/123/PikPak/OneDrive/Google Drive/联通网盘 上传目标 drive ID 读写
|
||||
// Spider91 → 115/123/PikPak/OneDrive/Google Drive/联通网盘/光鸭网盘 上传目标 drive ID 读写
|
||||
GetSpider91UploadDriveID func() string
|
||||
SetSpider91UploadDriveID func(driveID string) error
|
||||
// OnRunNightlyJob 触发一次完整的凌晨流水线(Phase1 扫盘 + Phase2 91 爬虫 +
|
||||
@@ -88,6 +96,9 @@ type AdminServer struct {
|
||||
// 联通网盘扫码登录接口测试注入;生产留空走官方 panservice.mail.wo.cn。
|
||||
WopanQRAPIBaseURL string
|
||||
WopanQRHTTPClient *http.Client
|
||||
// 光鸭网盘扫码登录接口测试注入;生产留空走官方 account.guangyapan.com。
|
||||
GuangYaPanAccountBaseURL string
|
||||
GuangYaPanHTTPClient *http.Client
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -118,6 +129,7 @@ type DriveGenerationStatuses struct {
|
||||
Preview GenerationStatus `json:"preview"`
|
||||
Fingerprint GenerationStatus `json:"fingerprint"`
|
||||
Upload GenerationStatus `json:"upload"`
|
||||
Transcode GenerationStatus `json:"transcode"`
|
||||
}
|
||||
|
||||
type NightlyJobStatus struct {
|
||||
@@ -160,6 +172,8 @@ func (a *AdminServer) Register(r chi.Router) {
|
||||
r.Get("/drives/p123/qr/{uniID}", a.handleP123QRStatus)
|
||||
r.Post("/drives/wopan/qr", a.handleWopanQRStart)
|
||||
r.Get("/drives/wopan/qr/{uuid}", a.handleWopanQRStatus)
|
||||
r.Post("/drives/guangyapan/qr", a.handleGuangYaPanQRStart)
|
||||
r.Get("/drives/guangyapan/qr/status", a.handleGuangYaPanQRStatus)
|
||||
r.Delete("/drives/{id}", a.handleDeleteDrive)
|
||||
r.Post("/drives/{id}/rescan", a.handleRescan)
|
||||
r.Post("/drives/{id}/tasks/stop", a.handleStopDriveTasks)
|
||||
@@ -169,6 +183,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 +198,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 +451,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()
|
||||
@@ -445,7 +470,8 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) {
|
||||
Status string `json:"status"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
HasCredential bool `json:"hasCredential"`
|
||||
// TeaserEnabled 控制是否给本盘生成预览视频/封面。前端用它在网盘列表/编辑表单展示开关状态。
|
||||
// TeaserEnabled 控制是否给本盘生成预览视频;封面生成不受影响。
|
||||
// 前端用它在网盘列表/编辑表单展示开关状态。
|
||||
TeaserEnabled bool `json:"teaserEnabled"`
|
||||
// SkipDirIDs 是用户在 admin 配置的"扫描跳过目录"集合(drive 侧目录 fileID)。
|
||||
// 前端用它在"设置跳过目录"弹窗里回显已选项;JSON 字段名 camelCase 与
|
||||
@@ -453,9 +479,11 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) {
|
||||
SkipDirIDs []string `json:"skipDirIds"`
|
||||
// LastCrawlAt 是 spider91 上次成功爬取的 unix 秒(来自 credentials.last_crawl_at)。
|
||||
// 其它 kind 留 0;前端用它显示"上次抓取: N 小时前"。
|
||||
Spider91Proxy string `json:"spider91Proxy,omitempty"`
|
||||
LastCrawlAt int64 `json:"lastCrawlAt,omitempty"`
|
||||
GoogleDriveUseOnlineAPI *bool `json:"googleDriveUseOnlineAPI,omitempty"`
|
||||
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 +498,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 +512,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 +526,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 +560,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 +575,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)
|
||||
@@ -550,7 +593,7 @@ type upsertDriveReq struct {
|
||||
// Deprecated: 扫描起点已固定为 rootId;保留字段只为兼容旧客户端请求体。
|
||||
ScanRootID string `json:"scanRootId"`
|
||||
Credentials map[string]string `json:"credentials"`
|
||||
// TeaserEnabled 是 per-drive 预览视频/封面生成开关。
|
||||
// TeaserEnabled 是 per-drive 预览视频生成开关;封面生成不受影响。
|
||||
// 用 *bool 区分 "未传" / "传了 false":未传时表示客户端不打算改这个字段,
|
||||
// 沿用 catalog 现有值;新建时未传一律默认开启(true)。
|
||||
TeaserEnabled *bool `json:"teaserEnabled,omitempty"`
|
||||
@@ -585,8 +628,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" || body.Kind == "guangyapan" {
|
||||
// 按键合并、空值沿用旧值:这些网盘的编辑表单允许只改某几个字段,
|
||||
// 其它 token / 路径 / 开关字段应保留旧值。
|
||||
body.Credentials = mergeNonEmptyCredentials(existing, body.Credentials)
|
||||
} else if len(body.Credentials) == 0 && existing != nil && len(existing.Credentials) > 0 {
|
||||
body.Credentials = existing.Credentials
|
||||
}
|
||||
@@ -647,6 +692,7 @@ type crawlerDTO struct {
|
||||
Proxy string `json:"proxy,omitempty"`
|
||||
TargetNew string `json:"targetNew,omitempty"`
|
||||
UploadDriveID string `json:"uploadDriveId,omitempty"`
|
||||
TeaserEnabled bool `json:"teaserEnabled"`
|
||||
LastCrawlAt int64 `json:"lastCrawlAt,omitempty"`
|
||||
ScanGenerationStatus GenerationStatus `json:"scanGenerationStatus"`
|
||||
ThumbnailGenerationStatus GenerationStatus `json:"thumbnailGenerationStatus"`
|
||||
@@ -674,6 +720,7 @@ type upsertCrawlerReq struct {
|
||||
Proxy string `json:"proxy"`
|
||||
TargetNew string `json:"targetNew"`
|
||||
UploadDriveID string `json:"uploadDriveId"`
|
||||
TeaserEnabled *bool `json:"teaserEnabled,omitempty"`
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleListCrawlers(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -735,6 +782,7 @@ func (a *AdminServer) crawlerDTOForDrive(d *catalog.Drive, assets catalog.Crawle
|
||||
Proxy: strings.TrimSpace(d.Credentials["proxy"]),
|
||||
TargetNew: strings.TrimSpace(d.Credentials["target_new"]),
|
||||
UploadDriveID: strings.TrimSpace(d.Credentials["upload_drive_id"]),
|
||||
TeaserEnabled: d.TeaserEnabled,
|
||||
LastCrawlAt: lastCrawlAt,
|
||||
ScanGenerationStatus: generation.Scan,
|
||||
ThumbnailGenerationStatus: generation.Thumbnail,
|
||||
@@ -821,6 +869,13 @@ func (a *AdminServer) handleUpsertCrawler(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
name := meta.Name
|
||||
teaserEnabled := true
|
||||
if existing != nil {
|
||||
teaserEnabled = existing.TeaserEnabled
|
||||
}
|
||||
if body.TeaserEnabled != nil {
|
||||
teaserEnabled = *body.TeaserEnabled
|
||||
}
|
||||
if id == "" {
|
||||
generatedID, err := a.generateCrawlerID(r.Context(), name)
|
||||
if err != nil {
|
||||
@@ -836,15 +891,15 @@ func (a *AdminServer) handleUpsertCrawler(w http.ResponseWriter, r *http.Request
|
||||
RootID: "/",
|
||||
Credentials: merged,
|
||||
Status: "disconnected",
|
||||
TeaserEnabled: true,
|
||||
}
|
||||
if existing != nil {
|
||||
d.TeaserEnabled = existing.TeaserEnabled
|
||||
TeaserEnabled: teaserEnabled,
|
||||
}
|
||||
if err := a.Catalog.UpsertDrive(r.Context(), d); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if existing != nil && existing.TeaserEnabled != teaserEnabled && a.OnTeaserEnabledChanged != nil {
|
||||
a.OnTeaserEnabledChanged(id, teaserEnabled)
|
||||
}
|
||||
if a.OnDriveSaved != nil {
|
||||
if err := a.OnDriveSaved(id); err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id, "warning": err.Error()})
|
||||
@@ -894,7 +949,7 @@ func (a *AdminServer) validateCrawlerUploadDrive(ctx context.Context, driveID st
|
||||
return fmt.Errorf("上传目标网盘 %q 不存在", driveID)
|
||||
}
|
||||
if !isCrawlerUploadTargetKind(d.Kind) {
|
||||
return fmt.Errorf("上传目标网盘 %q 类型为 %s,仅支持 115网盘、PikPak、123网盘、Google Drive、OneDrive、联通网盘", driveID, d.Kind)
|
||||
return fmt.Errorf("上传目标网盘 %q 类型为 %s,仅支持 115网盘、PikPak、123网盘、Google Drive、OneDrive、联通网盘、光鸭网盘", driveID, d.Kind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1322,6 +1377,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 +1412,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 和 guangyapan 的编辑表单都依赖
|
||||
// 这个语义(留空 = 不修改)。
|
||||
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 +1620,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,
|
||||
@@ -1612,6 +1714,38 @@ func (a *AdminServer) handleWopanQRStatus(w http.ResponseWriter, r *http.Request
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
|
||||
func (a *AdminServer) guangYaPanQRClient() *guangyapan.QRClient {
|
||||
return guangyapan.NewQRClient(guangyapan.QRConfig{
|
||||
AccountBaseURL: a.GuangYaPanAccountBaseURL,
|
||||
HTTPClient: a.GuangYaPanHTTPClient,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleGuangYaPanQRStart(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := a.guangYaPanQRClient().Generate(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadGateway, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusOK, session)
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleGuangYaPanQRStatus(w http.ResponseWriter, r *http.Request) {
|
||||
deviceCode := r.URL.Query().Get("deviceCode")
|
||||
if strings.TrimSpace(deviceCode) == "" {
|
||||
http.Error(w, "deviceCode is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
status, err := a.guangYaPanQRClient().Poll(r.Context(), deviceCode)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadGateway, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
|
||||
// handleRunNightlyJob 触发一次完整的凌晨流水线(不论当前时间,不论今日是否已跑)。
|
||||
// 立即返回 202;进度通过 backend 日志和下次 GET /admin/api/drives 的状态变化观察。
|
||||
// 流水线已在跑或已排队时,Runner 会拒绝重复触发。
|
||||
@@ -1798,6 +1932,14 @@ func (a *AdminServer) handleAdminListVideos(w http.ResponseWriter, r *http.Reque
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if a.GetPreviewGenerationVideoIDs != nil {
|
||||
generating := a.GetPreviewGenerationVideoIDs()
|
||||
for _, item := range items {
|
||||
if item != nil && generating[item.ID] {
|
||||
item.PreviewStatus = "generating"
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items,
|
||||
"total": total,
|
||||
@@ -1806,6 +1948,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 {
|
||||
|
||||
@@ -944,7 +944,8 @@ func TestHandleListCrawlersOnlyIncludesCrawlerPageScripts(t *testing.T) {
|
||||
"script_path": scriptPath,
|
||||
"upload_drive_id": "p115-target",
|
||||
},
|
||||
Status: "ok",
|
||||
Status: "ok",
|
||||
TeaserEnabled: false,
|
||||
},
|
||||
{
|
||||
ID: "p115-target",
|
||||
@@ -1027,6 +1028,7 @@ func TestHandleListCrawlersOnlyIncludesCrawlerPageScripts(t *testing.T) {
|
||||
Kind string `json:"kind"`
|
||||
Proxy string `json:"proxy"`
|
||||
UploadDriveID string `json:"uploadDriveId"`
|
||||
TeaserEnabled bool `json:"teaserEnabled"`
|
||||
LastCrawlAt int64 `json:"lastCrawlAt"`
|
||||
TotalCrawled int `json:"totalCrawledCount"`
|
||||
LocalVideos int `json:"localVideoCount"`
|
||||
@@ -1038,11 +1040,12 @@ func TestHandleListCrawlersOnlyIncludesCrawlerPageScripts(t *testing.T) {
|
||||
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
byID := map[string]struct {
|
||||
type crawlerListRow struct {
|
||||
Name string
|
||||
Kind string
|
||||
Proxy string
|
||||
UploadDriveID string
|
||||
TeaserEnabled bool
|
||||
LastCrawlAt int64
|
||||
TotalCrawled int
|
||||
LocalVideos int
|
||||
@@ -1050,25 +1053,15 @@ func TestHandleListCrawlersOnlyIncludesCrawlerPageScripts(t *testing.T) {
|
||||
ThumbnailReady int
|
||||
TeaserReady int
|
||||
FingerprintReady int
|
||||
}{}
|
||||
}
|
||||
byID := map[string]crawlerListRow{}
|
||||
for _, d := range got {
|
||||
byID[d.ID] = struct {
|
||||
Name string
|
||||
Kind string
|
||||
Proxy string
|
||||
UploadDriveID string
|
||||
LastCrawlAt int64
|
||||
TotalCrawled int
|
||||
LocalVideos int
|
||||
MigratedVideo int
|
||||
ThumbnailReady int
|
||||
TeaserReady int
|
||||
FingerprintReady int
|
||||
}{
|
||||
byID[d.ID] = crawlerListRow{
|
||||
Name: d.Name,
|
||||
Kind: d.Kind,
|
||||
Proxy: d.Proxy,
|
||||
UploadDriveID: d.UploadDriveID,
|
||||
TeaserEnabled: d.TeaserEnabled,
|
||||
LastCrawlAt: d.LastCrawlAt,
|
||||
TotalCrawled: d.TotalCrawled,
|
||||
LocalVideos: d.LocalVideos,
|
||||
@@ -1096,6 +1089,9 @@ func TestHandleListCrawlersOnlyIncludesCrawlerPageScripts(t *testing.T) {
|
||||
if byID["crawler-spider91"].UploadDriveID != "p115-target" {
|
||||
t.Fatalf("uploadDriveId = %q, want p115-target", byID["crawler-spider91"].UploadDriveID)
|
||||
}
|
||||
if byID["crawler-spider91"].TeaserEnabled {
|
||||
t.Fatal("teaserEnabled = true, want false from crawler drive")
|
||||
}
|
||||
if byID["crawler-spider91"].LastCrawlAt != 1800000000 {
|
||||
t.Fatalf("lastCrawlAt = %d, want 1800000000", byID["crawler-spider91"].LastCrawlAt)
|
||||
}
|
||||
@@ -1171,7 +1167,8 @@ func TestHandleUpsertCrawlerRequiresScriptPath(t *testing.T) {
|
||||
"id": "spider91-main",
|
||||
"builtin": "spider91",
|
||||
"scriptPath": "`+scriptPath+`",
|
||||
"targetNew": "15"
|
||||
"targetNew": "15",
|
||||
"teaserEnabled": false
|
||||
}`))
|
||||
rr = httptest.NewRecorder()
|
||||
srv.handleUpsertCrawler(rr, req)
|
||||
@@ -1195,6 +1192,9 @@ func TestHandleUpsertCrawlerRequiresScriptPath(t *testing.T) {
|
||||
if got.Credentials["script_path"] != scriptPath {
|
||||
t.Fatalf("script_path = %q, want %q", got.Credentials["script_path"], scriptPath)
|
||||
}
|
||||
if got.TeaserEnabled {
|
||||
t.Fatal("teaserEnabled = true, want false from request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpsertCrawlerGeneratesIDFromScriptName(t *testing.T) {
|
||||
@@ -1277,12 +1277,21 @@ func TestHandleUpsertCrawlerPersistsAndValidatesUploadDrive(t *testing.T) {
|
||||
t.Fatalf("seed drive %s: %v", d.ID, err)
|
||||
}
|
||||
}
|
||||
srv := &AdminServer{Catalog: cat}
|
||||
var teaserCallbackID string
|
||||
var teaserCallbackEnabled bool
|
||||
srv := &AdminServer{
|
||||
Catalog: cat,
|
||||
OnTeaserEnabledChanged: func(id string, enabled bool) {
|
||||
teaserCallbackID = id
|
||||
teaserCallbackEnabled = enabled
|
||||
},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/api/crawlers", strings.NewReader(`{
|
||||
"id": "crawler-upload",
|
||||
"scriptPath": "`+scriptPath+`",
|
||||
"uploadDriveId": "p115-target"
|
||||
"uploadDriveId": "p115-target",
|
||||
"teaserEnabled": false
|
||||
}`))
|
||||
rr := httptest.NewRecorder()
|
||||
srv.handleUpsertCrawler(rr, req)
|
||||
@@ -1296,6 +1305,12 @@ func TestHandleUpsertCrawlerPersistsAndValidatesUploadDrive(t *testing.T) {
|
||||
if got.Credentials["upload_drive_id"] != "p115-target" {
|
||||
t.Fatalf("upload_drive_id = %q, want p115-target", got.Credentials["upload_drive_id"])
|
||||
}
|
||||
if got.TeaserEnabled {
|
||||
t.Fatal("teaserEnabled = true, want false")
|
||||
}
|
||||
if teaserCallbackID != "" {
|
||||
t.Fatalf("teaser callback on create = %q, want none", teaserCallbackID)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/admin/api/crawlers", strings.NewReader(`{
|
||||
"id": "crawler-upload",
|
||||
@@ -1314,6 +1329,34 @@ func TestHandleUpsertCrawlerPersistsAndValidatesUploadDrive(t *testing.T) {
|
||||
if got.Credentials["upload_drive_id"] != "wopan-target" {
|
||||
t.Fatalf("upload_drive_id = %q, want wopan-target", got.Credentials["upload_drive_id"])
|
||||
}
|
||||
if got.TeaserEnabled {
|
||||
t.Fatal("teaserEnabled after edit without field = true, want preserved false")
|
||||
}
|
||||
if teaserCallbackID != "" {
|
||||
t.Fatalf("teaser callback after preserved edit = %q, want none", teaserCallbackID)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/admin/api/crawlers", strings.NewReader(`{
|
||||
"id": "crawler-upload",
|
||||
"scriptPath": "`+scriptPath+`",
|
||||
"uploadDriveId": "wopan-target",
|
||||
"teaserEnabled": true
|
||||
}`))
|
||||
rr = httptest.NewRecorder()
|
||||
srv.handleUpsertCrawler(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("enable teaser status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
got, err = cat.GetDrive(ctx, "crawler-upload")
|
||||
if err != nil {
|
||||
t.Fatalf("get crawler after teaser enable: %v", err)
|
||||
}
|
||||
if !got.TeaserEnabled {
|
||||
t.Fatal("teaserEnabled after explicit enable = false, want true")
|
||||
}
|
||||
if teaserCallbackID != "crawler-upload" || !teaserCallbackEnabled {
|
||||
t.Fatalf("teaser callback = %q/%v, want crawler-upload/true", teaserCallbackID, teaserCallbackEnabled)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/admin/api/crawlers", strings.NewReader(`{
|
||||
"id": "crawler-upload",
|
||||
@@ -1704,6 +1747,94 @@ func TestHandleWopanQRStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGuangYaPanQRStart(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Path != "/v1/auth/device/code" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if body["scope"] != "user" {
|
||||
t.Fatalf("scope = %#v, want user", body["scope"])
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"device_code": "device-1",
|
||||
"verification_uri_complete": "https://account.guangyapan.example/device?code=abc",
|
||||
"interval": 5,
|
||||
"expires_in": 300,
|
||||
})
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/api/drives/guangyapan/qr", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
(&AdminServer{GuangYaPanAccountBaseURL: upstream.URL}).handleGuangYaPanQRStart(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got struct {
|
||||
DeviceCode string `json:"deviceCode"`
|
||||
QRCodeURL string `json:"qrCodeUrl"`
|
||||
QRImageDataURL string `json:"qrImageDataUrl"`
|
||||
}
|
||||
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.DeviceCode != "device-1" || got.QRCodeURL != "https://account.guangyapan.example/device?code=abc" {
|
||||
t.Fatalf("response = %#v", got)
|
||||
}
|
||||
if !strings.HasPrefix(got.QRImageDataURL, "data:image/png;base64,") {
|
||||
t.Fatalf("qr image = %q", got.QRImageDataURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGuangYaPanQRStatus(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Path != "/v1/auth/token" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if body["device_code"] != "device-1" {
|
||||
t.Fatalf("device_code = %#v, want device-1", body["device_code"])
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "access-1",
|
||||
"refresh_token": "refresh-1",
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/api/drives/guangyapan/qr/status?deviceCode=device-1", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
(&AdminServer{GuangYaPanAccountBaseURL: upstream.URL}).handleGuangYaPanQRStatus(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got struct {
|
||||
State string `json:"state"`
|
||||
AccessToken string `json:"accessToken"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
}
|
||||
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.State != "success" || got.AccessToken != "access-1" || got.RefreshToken != "refresh-1" {
|
||||
t.Fatalf("response = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTestCrawlerScriptRunsImportedScript(t *testing.T) {
|
||||
if _, err := exec.LookPath("python3"); err != nil {
|
||||
t.Skip("python3 is required for crawler script dry-run")
|
||||
@@ -2373,6 +2504,80 @@ func TestHandleAdminListVideosPaginates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminListVideosMarksActivePreviewGeneration(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: "active-video",
|
||||
DriveID: "OneDrive",
|
||||
FileID: "active-file",
|
||||
Title: "Active video",
|
||||
PreviewStatus: "ready",
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: "idle-video",
|
||||
DriveID: "OneDrive",
|
||||
FileID: "idle-file",
|
||||
Title: "Idle video",
|
||||
PreviewStatus: "ready",
|
||||
PublishedAt: now.Add(-time.Hour),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
} {
|
||||
if err := cat.UpsertVideo(ctx, v); err != nil {
|
||||
t.Fatalf("seed video %s: %v", v.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/api/videos?driveId=OneDrive", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
(&AdminServer{
|
||||
Catalog: cat,
|
||||
GetPreviewGenerationVideoIDs: func() map[string]bool {
|
||||
return map[string]bool{"active-video": true}
|
||||
},
|
||||
}).handleAdminListVideos(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got struct {
|
||||
Items []catalog.Video `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.Total != 2 || len(got.Items) != 2 {
|
||||
t.Fatalf("response total/items = %d/%d, want 2/2", got.Total, len(got.Items))
|
||||
}
|
||||
statusByID := map[string]string{}
|
||||
for _, item := range got.Items {
|
||||
statusByID[item.ID] = item.PreviewStatus
|
||||
}
|
||||
if statusByID["active-video"] != "generating" {
|
||||
t.Fatalf("active status = %q, want generating", statusByID["active-video"])
|
||||
}
|
||||
if statusByID["idle-video"] != "ready" {
|
||||
t.Fatalf("idle status = %q, want ready", statusByID["idle-video"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRegenAllPreviewsInvokesHook(t *testing.T) {
|
||||
called := false
|
||||
server := &AdminServer{
|
||||
|
||||
+44
-22
@@ -55,12 +55,16 @@ 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
|
||||
tagCache []TagDTO
|
||||
|
||||
// GetTheme 返回当前生效的主题("dark" | "pink")。前台 /api/settings/theme 用,
|
||||
// GetTheme 返回当前生效的主题("dark" | "pink" | "sky")。前台 /api/settings/theme 用,
|
||||
// 不需要登录。无注入时返回 "dark"。
|
||||
GetTheme func() string
|
||||
}
|
||||
@@ -156,11 +160,11 @@ func (s *Server) RegisterRoutes(r chi.Router, a *auth.Authenticator) {
|
||||
}
|
||||
|
||||
// handleGetTheme 返回当前生效的主题。无需登录。响应永远是
|
||||
// {"theme": "dark"} 或 {"theme": "pink"},便于前端无脑解析。
|
||||
// {"theme": "dark" | "pink" | "sky"},便于前端无脑解析。
|
||||
func (s *Server) handleGetTheme(w http.ResponseWriter, r *http.Request) {
|
||||
theme := "dark"
|
||||
if s.GetTheme != nil {
|
||||
if v := s.GetTheme(); v == "pink" || v == "dark" {
|
||||
if v := s.GetTheme(); v == "pink" || v == "dark" || v == "sky" {
|
||||
theme = v
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -1048,6 +1068,8 @@ func driveKindLabel(kind string) string {
|
||||
return "PikPak"
|
||||
case "wopan":
|
||||
return "联通网盘"
|
||||
case "guangyapan":
|
||||
return "光鸭网盘"
|
||||
case "onedrive":
|
||||
return "OneDrive"
|
||||
case "googledrive":
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+228
-189
@@ -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
|
||||
@@ -1900,7 +1937,7 @@ type Drive struct {
|
||||
Credentials map[string]string `json:"credentials,omitempty"`
|
||||
Status string `json:"status"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
// TeaserEnabled 控制是否给本盘生成预览视频/封面。
|
||||
// TeaserEnabled 控制是否给本盘生成预览视频;封面生成不受影响。
|
||||
// 替代早期的全局 preview.enabled 开关;新建 drive 时 UpsertDrive 默认置 true。
|
||||
TeaserEnabled bool `json:"teaserEnabled"`
|
||||
// SkipDirIDs 是用户在管理后台为该盘选定的"扫描跳过目录"集合(网盘侧的目录 fileID)。
|
||||
@@ -1955,7 +1992,7 @@ func normalizeDriveRootFields(d *Drive) {
|
||||
func normalizeDriveRootID(kind, rootID string) string {
|
||||
rootID = strings.TrimSpace(rootID)
|
||||
switch kind {
|
||||
case "pikpak":
|
||||
case "pikpak", "guangyapan":
|
||||
if rootID == "0" {
|
||||
return ""
|
||||
}
|
||||
@@ -2033,7 +2070,7 @@ func (c *Catalog) DeleteDrive(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// SetDriveTeaserEnabled 切换某盘的预览视频/封面生成开关。
|
||||
// SetDriveTeaserEnabled 切换某盘的预览视频生成开关。
|
||||
//
|
||||
// 与 UpsertDrive 的区别:只动 teaser_enabled + updated_at 一列,不要求调用方
|
||||
// 重传 kind / name / credentials 等容易踩坑的字段。
|
||||
@@ -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,
|
||||
|
||||
@@ -58,6 +58,7 @@ func TestUpsertDriveDefaultsRootIDByKind(t *testing.T) {
|
||||
}{
|
||||
{id: "p115", kind: "p115", want: "0"},
|
||||
{id: "pikpak", kind: "pikpak", want: ""},
|
||||
{id: "guangyapan", kind: "guangyapan", want: ""},
|
||||
{id: "onedrive", kind: "onedrive", want: "root"},
|
||||
{id: "googledrive", kind: "googledrive", want: "root"},
|
||||
{id: "localstorage", kind: "localstorage", want: "/"},
|
||||
|
||||
@@ -21,7 +21,11 @@ CREATE TABLE IF NOT EXISTS videos (
|
||||
thumbnail_failures INTEGER DEFAULT 0, -- consecutive transient thumbnail generation failures
|
||||
preview_file_id TEXT, -- deprecated: 旧版回写网盘后的预览视频 file id
|
||||
preview_local TEXT, -- 本地预览视频路径(兜底)
|
||||
preview_status TEXT DEFAULT 'pending', -- pending / ready / failed
|
||||
preview_status TEXT DEFAULT 'pending', -- pending / ready / failed / disabled
|
||||
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,
|
||||
@@ -110,14 +114,14 @@ CREATE INDEX IF NOT EXISTS idx_crawler_seen_sources_drive
|
||||
-- 网盘账户
|
||||
CREATE TABLE IF NOT EXISTS drives (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL, -- quark / p115 / p123 / pikpak / wopan / onedrive / googledrive / localstorage / spider91
|
||||
kind TEXT NOT NULL, -- quark / p115 / p123 / pikpak / wopan / guangyapan / onedrive / googledrive / localstorage / spider91
|
||||
name TEXT NOT NULL,
|
||||
root_id TEXT NOT NULL DEFAULT '0',
|
||||
scan_root_id TEXT, -- deprecated: 扫描起点固定等于 root_id
|
||||
credentials TEXT, -- JSON: cookie / refresh_token 等
|
||||
status TEXT DEFAULT 'disconnected', -- disconnected / ok / error
|
||||
last_error TEXT,
|
||||
-- 是否给该盘生成预览视频/封面:1 开 / 0 关。
|
||||
-- 是否给该盘生成预览视频:1 开 / 0 关。封面生成不受影响。
|
||||
-- 替代了早期的全局 preview.enabled 设置(保留旧 setting 行不再读)。
|
||||
teaser_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
-- 扫描时要跳过的目录 ID 集合(JSON array of string)。命中其中任意一个的目录及其
|
||||
|
||||
@@ -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 字段。这样老用户即便之前关过全局开关,
|
||||
@@ -109,6 +124,9 @@ CREATE TABLE IF NOT EXISTS deleted_videos (
|
||||
if err := c.reconcileThumbnailStatusOnce(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.requeueSkippedPreviews(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_videos_content_hash ON videos(content_hash)`); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -281,6 +299,24 @@ UPDATE videos
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Catalog) requeueSkippedPreviews(ctx context.Context) error {
|
||||
res, err := c.db.ExecContext(ctx, `
|
||||
UPDATE videos
|
||||
SET preview_file_id = '',
|
||||
preview_local = '',
|
||||
preview_status = 'pending',
|
||||
updated_at = ?
|
||||
WHERE COALESCE(preview_status, 'pending') = 'skipped'
|
||||
`, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
return fmt.Errorf("requeue skipped previews: %w", err)
|
||||
}
|
||||
if affected, err := res.RowsAffected(); err == nil && affected > 0 {
|
||||
log.Printf("[catalog] requeued %d skipped preview(s) for generation", affected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Catalog) clearVolatileOneDriveThumbnails(ctx context.Context) error {
|
||||
// 把 OneDrive 过期的 mediap.svc.ms thumb URL 清空,让 worker 重新抽帧生成本地封面。
|
||||
// 同步把 thumbnail_status 重置为 'pending':清空后 url 是空的,本应进 worker 重做,
|
||||
|
||||
@@ -1539,6 +1539,70 @@ func TestReconcileThumbnailStatusOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequeueSkippedPreviews(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cat, err := Open(t.TempDir() + "/catalog.db")
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { cat.Close() })
|
||||
|
||||
now := time.Now()
|
||||
cases := []struct {
|
||||
id string
|
||||
status string
|
||||
local string
|
||||
fileID string
|
||||
wantStatus string
|
||||
wantLocal string
|
||||
wantFileID string
|
||||
}{
|
||||
{"preview-skipped", "skipped", "/tmp/old-preview.mp4", "old-preview-file", "pending", "", ""},
|
||||
{"preview-ready", "ready", "/tmp/ready-preview.mp4", "ready-preview-file", "ready", "/tmp/ready-preview.mp4", "ready-preview-file"},
|
||||
{"preview-failed", "failed", "/tmp/failed-preview.mp4", "failed-preview-file", "failed", "/tmp/failed-preview.mp4", "failed-preview-file"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if err := cat.UpsertVideo(ctx, &Video{
|
||||
ID: c.id, DriveID: "d", FileID: "source-" + c.id, Title: c.id,
|
||||
PreviewStatus: c.status, PreviewLocal: c.local, PreviewFileID: c.fileID,
|
||||
PublishedAt: now, CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed %s: %v", c.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := cat.requeueSkippedPreviews(ctx); err != nil {
|
||||
t.Fatalf("requeue skipped previews: %v", err)
|
||||
}
|
||||
if err := cat.requeueSkippedPreviews(ctx); err != nil {
|
||||
t.Fatalf("second requeue skipped previews: %v", err)
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got, err := cat.GetVideo(ctx, c.id)
|
||||
if err != nil {
|
||||
t.Fatalf("get %s: %v", c.id, err)
|
||||
}
|
||||
if got.PreviewStatus != c.wantStatus {
|
||||
t.Errorf("%s: preview status = %q, want %q", c.id, got.PreviewStatus, c.wantStatus)
|
||||
}
|
||||
if got.PreviewLocal != c.wantLocal {
|
||||
t.Errorf("%s: preview local = %q, want %q", c.id, got.PreviewLocal, c.wantLocal)
|
||||
}
|
||||
if got.PreviewFileID != c.wantFileID {
|
||||
t.Errorf("%s: preview file id = %q, want %q", c.id, got.PreviewFileID, c.wantFileID)
|
||||
}
|
||||
}
|
||||
|
||||
pending, err := cat.ListVideosByPreviewStatus(ctx, "d", "pending", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list pending previews: %v", err)
|
||||
}
|
||||
if len(pending) != 1 || pending[0].ID != "preview-skipped" {
|
||||
t.Fatalf("pending previews = %#v, want only preview-skipped", pending)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpsertVideoSyncsThumbnailStatus 验证 scanner 创建/补回视频时
|
||||
// thumbnail_status 跟随 thumbnail_url 自动设。这是历史 bug 的修复回归测试 ——
|
||||
// 之前 UpsertVideo 的 SQL 不带 thumbnail_status 列,所有新视频都依赖
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -207,7 +207,7 @@ type Nightly struct {
|
||||
// 这里保留 yaml 中的静态定义,用于启动时预置盘。生产建议只在 DB 里维护。
|
||||
type Drive struct {
|
||||
ID string `yaml:"id"`
|
||||
Kind string `yaml:"kind"` // quark / p115 / p123 / pikpak / wopan / onedrive / googledrive / localstorage
|
||||
Kind string `yaml:"kind"` // quark / p115 / p123 / pikpak / wopan / guangyapan / onedrive / googledrive / localstorage
|
||||
Name string `yaml:"name"`
|
||||
RootID string `yaml:"root_id"`
|
||||
Params map[string]string `yaml:"params,omitempty"`
|
||||
|
||||
@@ -647,7 +647,7 @@ func isGoogleUploadHTTPRateLimit(status int, header http.Header, body []byte, ap
|
||||
if isGoogleRateLimit(nil, apiErr) {
|
||||
return true
|
||||
}
|
||||
return googleLimitText(string(body))
|
||||
return false
|
||||
}
|
||||
|
||||
func googleUploadRateLimitError(status int, header http.Header, body []byte, message string) error {
|
||||
@@ -910,7 +910,7 @@ func isGoogleRateLimit(res *resty.Response, body apiErrorBody) bool {
|
||||
return true
|
||||
}
|
||||
for _, e := range body.Errors {
|
||||
if googleLimitReason(e.Reason) || googleLimitText(e.Message) {
|
||||
if googleLimitReason(e.Reason) {
|
||||
return true
|
||||
}
|
||||
domain := compactGoogleLimitText(e.Domain)
|
||||
@@ -918,7 +918,7 @@ func isGoogleRateLimit(res *resty.Response, body apiErrorBody) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return googleLimitText(body.Message)
|
||||
return false
|
||||
}
|
||||
|
||||
func isGoogleTokenRateLimit(res *resty.Response, out tokenResp) bool {
|
||||
@@ -930,9 +930,7 @@ func isGoogleTokenRateLimit(res *resty.Response, out tokenResp) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return googleLimitText(out.Text) ||
|
||||
googleLimitText(out.Error) ||
|
||||
googleLimitText(out.ErrorDescription)
|
||||
return googleLimitReason(out.Error)
|
||||
}
|
||||
|
||||
func googleLimitReason(reason string) bool {
|
||||
@@ -953,31 +951,6 @@ func googleLimitReason(reason string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func googleLimitText(text string) bool {
|
||||
text = strings.ToLower(strings.TrimSpace(text))
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
compact := compactGoogleLimitText(text)
|
||||
if strings.Contains(compact, "ratelimitexceeded") ||
|
||||
strings.Contains(compact, "userratelimitexceeded") ||
|
||||
strings.Contains(compact, "dailylimitexceeded") ||
|
||||
strings.Contains(compact, "downloadquotaexceeded") ||
|
||||
strings.Contains(compact, "sharingratelimitexceeded") ||
|
||||
strings.Contains(compact, "quotaexceeded") ||
|
||||
strings.Contains(compact, "toomanyrequests") {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(text, "rate limit") ||
|
||||
strings.Contains(text, "too many requests") ||
|
||||
strings.Contains(text, "quota exceeded") ||
|
||||
strings.Contains(text, "download quota") ||
|
||||
strings.Contains(text, "sharing rate") ||
|
||||
strings.Contains(text, "daily limit") ||
|
||||
strings.Contains(text, "user rate") ||
|
||||
strings.Contains(text, "usage limit")
|
||||
}
|
||||
|
||||
func compactGoogleLimitText(text string) string {
|
||||
text = strings.ToLower(strings.TrimSpace(text))
|
||||
replacer := strings.NewReplacer("_", "", "-", "", " ", "", ".", "", ":", "")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,300 @@
|
||||
package guangyapan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
)
|
||||
|
||||
func TestDriverRefreshListAndStream(t *testing.T) {
|
||||
var refreshed bool
|
||||
var listedRoot bool
|
||||
updates := map[string]string{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/auth/token":
|
||||
refreshed = true
|
||||
writeTestJSON(w, map[string]any{
|
||||
"access_token": "new-access",
|
||||
"refresh_token": "new-refresh",
|
||||
})
|
||||
case "/v1/user/me":
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer new-access" {
|
||||
t.Fatalf("auth header = %q, want new access token", got)
|
||||
}
|
||||
writeTestJSON(w, map[string]any{"sub": "user-1"})
|
||||
case "/userres/v1/file/get_file_list":
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer new-access" {
|
||||
t.Fatalf("api auth header = %q, want new access token", got)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode list body: %v", err)
|
||||
}
|
||||
if body["parentId"] != "" {
|
||||
t.Fatalf("parentId = %#v, want root empty string", body["parentId"])
|
||||
}
|
||||
listedRoot = true
|
||||
writeTestJSON(w, map[string]any{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]any{
|
||||
"total": 2,
|
||||
"list": []map[string]any{
|
||||
{"fileId": "dir-1", "parentId": "", "fileName": "Movies", "resType": 2},
|
||||
{"fileId": "file-1", "parentId": "", "fileName": "clip.mp4", "fileSize": 123, "resType": 1, "utime": 1700000000},
|
||||
},
|
||||
},
|
||||
})
|
||||
case "/nd.bizuserres.s/v1/get_res_download_url":
|
||||
writeTestJSON(w, map[string]any{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]any{"signedURL": "https://cdn.example.test/clip.mp4"},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d := New(Config{
|
||||
ID: "gy",
|
||||
RefreshToken: "old-refresh",
|
||||
AccountBaseURL: srv.URL,
|
||||
APIBaseURL: srv.URL,
|
||||
OnCredentialsUpdate: func(values map[string]string) {
|
||||
for k, v := range values {
|
||||
updates[k] = v
|
||||
}
|
||||
},
|
||||
})
|
||||
if err := d.Init(context.Background()); err != nil {
|
||||
t.Fatalf("init: %v", err)
|
||||
}
|
||||
if !refreshed {
|
||||
t.Fatal("refresh token endpoint was not called")
|
||||
}
|
||||
if updates["access_token"] != "new-access" || updates["refresh_token"] != "new-refresh" {
|
||||
t.Fatalf("updates = %#v, want refreshed tokens", updates)
|
||||
}
|
||||
|
||||
entries, err := d.List(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if !listedRoot || len(entries) != 2 {
|
||||
t.Fatalf("listedRoot=%v entries=%#v", listedRoot, entries)
|
||||
}
|
||||
if !entries[0].IsDir || entries[1].ID != "file-1" || entries[1].Size != 123 {
|
||||
t.Fatalf("entries = %#v", entries)
|
||||
}
|
||||
|
||||
link, err := d.StreamURL(context.Background(), "file-1")
|
||||
if err != nil {
|
||||
t.Fatalf("stream url: %v", err)
|
||||
}
|
||||
if link.URL != "https://cdn.example.test/clip.mp4" {
|
||||
t.Fatalf("stream url = %q", link.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriverResolvesRootPath(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/user/me":
|
||||
writeTestJSON(w, map[string]any{"sub": "user-1"})
|
||||
case "/userres/v1/file/get_file_list":
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode list body: %v", err)
|
||||
}
|
||||
parent, _ := body["parentId"].(string)
|
||||
switch parent {
|
||||
case "":
|
||||
writeTestJSON(w, listTestResponse([]map[string]any{
|
||||
{"fileId": "folder-a", "parentId": "", "fileName": "影视", "resType": 2},
|
||||
}))
|
||||
case "folder-a":
|
||||
writeTestJSON(w, listTestResponse([]map[string]any{
|
||||
{"fileId": "folder-b", "parentId": "folder-a", "fileName": "电影", "resType": 2},
|
||||
}))
|
||||
case "folder-b":
|
||||
writeTestJSON(w, listTestResponse([]map[string]any{
|
||||
{"fileId": "file-1", "parentId": "folder-b", "fileName": "movie.mp4", "fileSize": 456, "resType": 1},
|
||||
}))
|
||||
default:
|
||||
t.Fatalf("unexpected parent %q", parent)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d := New(Config{
|
||||
ID: "gy",
|
||||
RootID: "configured-root",
|
||||
RootPath: "影视/电影",
|
||||
AccessToken: "access",
|
||||
AccountBaseURL: srv.URL,
|
||||
APIBaseURL: srv.URL,
|
||||
})
|
||||
if err := d.Init(context.Background()); err != nil {
|
||||
t.Fatalf("init: %v", err)
|
||||
}
|
||||
if d.RootID() != "folder-b" {
|
||||
t.Fatalf("root id = %q, want folder-b", d.RootID())
|
||||
}
|
||||
entries, err := d.List(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("list resolved root: %v", err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].ID != "file-1" {
|
||||
t.Fatalf("entries = %#v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriverSendSMSCodeUpdatesVerificationState(t *testing.T) {
|
||||
updates := map[string]string{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/shield/captcha/init":
|
||||
writeTestJSON(w, map[string]any{"captcha_token": "captcha-1"})
|
||||
case "/v1/auth/verification":
|
||||
writeTestJSON(w, map[string]any{"verification_id": "verify-1"})
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d := New(Config{
|
||||
ID: "gy",
|
||||
PhoneNumber: "13800000000",
|
||||
SendCode: true,
|
||||
AccountBaseURL: srv.URL,
|
||||
APIBaseURL: srv.URL,
|
||||
OnCredentialsUpdate: func(values map[string]string) {
|
||||
for k, v := range values {
|
||||
updates[k] = v
|
||||
}
|
||||
},
|
||||
})
|
||||
err := d.Init(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "验证码已发送") {
|
||||
t.Fatalf("init err = %v, want verification prompt", err)
|
||||
}
|
||||
if updates["captcha_token"] != "captcha-1" || updates["verification_id"] != "verify-1" || updates["send_code"] != "false" {
|
||||
t.Fatalf("updates = %#v, want sms state saved", updates)
|
||||
}
|
||||
if updates["device_id"] == "" {
|
||||
t.Fatalf("updates = %#v, want generated device id saved", updates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListHTTP429ReturnsRateLimitError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/userres/v1/file/get_file_list" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Retry-After", "120")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
writeTestJSON(w, map[string]any{"code": 429, "msg": "操作频繁,请稍后重试"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d := New(Config{
|
||||
ID: "gy",
|
||||
AccessToken: "access",
|
||||
AccountBaseURL: srv.URL,
|
||||
APIBaseURL: srv.URL,
|
||||
})
|
||||
_, err := d.List(context.Background(), "")
|
||||
if err == nil {
|
||||
t.Fatal("list succeeded, want rate limit error")
|
||||
}
|
||||
var rateLimit *drives.RateLimitError
|
||||
if !errors.As(err, &rateLimit) {
|
||||
t.Fatalf("error = %T %[1]v, want RateLimitError", err)
|
||||
}
|
||||
if rateLimit.RetryAfter != 2*time.Minute {
|
||||
t.Fatalf("retry after = %s, want 2m", rateLimit.RetryAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCode429ReturnsRateLimitError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/userres/v1/file/get_file_list" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
writeTestJSON(w, map[string]any{"code": 429, "msg": "操作频繁,请稍后再试"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d := New(Config{
|
||||
ID: "gy",
|
||||
AccessToken: "access",
|
||||
AccountBaseURL: srv.URL,
|
||||
APIBaseURL: srv.URL,
|
||||
})
|
||||
_, err := d.List(context.Background(), "")
|
||||
if err == nil {
|
||||
t.Fatal("list succeeded, want rate limit error")
|
||||
}
|
||||
var rateLimit *drives.RateLimitError
|
||||
if !errors.As(err, &rateLimit) {
|
||||
t.Fatalf("error = %T %[1]v, want RateLimitError", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListInvalidToken403DoesNotReturnRateLimitError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/userres/v1/file/get_file_list" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
writeTestJSON(w, map[string]any{"code": 401, "msg": "invalid access token"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d := New(Config{
|
||||
ID: "gy",
|
||||
AccessToken: "access",
|
||||
AccountBaseURL: srv.URL,
|
||||
APIBaseURL: srv.URL,
|
||||
})
|
||||
_, err := d.List(context.Background(), "")
|
||||
if err == nil {
|
||||
t.Fatal("list succeeded, want auth error")
|
||||
}
|
||||
var rateLimit *drives.RateLimitError
|
||||
if errors.As(err, &rateLimit) {
|
||||
t.Fatalf("error = %T %[1]v, want non-rate-limit error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func listTestResponse(items []map[string]any) map[string]any {
|
||||
return map[string]any{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]any{
|
||||
"total": len(items),
|
||||
"list": items,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package guangyapan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/skip2/go-qrcode"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultQRScope = "user"
|
||||
deviceCodeGrantType = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
defaultQRUserAgent = "GuangYaPan-Login/1.0"
|
||||
)
|
||||
|
||||
type QRConfig struct {
|
||||
AccountBaseURL string
|
||||
HTTPClient *http.Client
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type QRClient struct {
|
||||
accountBaseURL string
|
||||
client *resty.Client
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type QRCodeSession struct {
|
||||
DeviceCode string `json:"deviceCode"`
|
||||
QRCodeURL string `json:"qrCodeUrl"`
|
||||
QRImageDataURL string `json:"qrImageDataUrl"`
|
||||
IntervalSeconds int `json:"intervalSeconds"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
}
|
||||
|
||||
type QRCodeStatus struct {
|
||||
State string `json:"state"`
|
||||
StatusText string `json:"statusText"`
|
||||
IntervalSeconds int `json:"intervalSeconds,omitempty"`
|
||||
AccessToken string `json:"accessToken,omitempty"`
|
||||
RefreshToken string `json:"refreshToken,omitempty"`
|
||||
TokenType string `json:"tokenType,omitempty"`
|
||||
ExpiresIn int64 `json:"expiresIn,omitempty"`
|
||||
}
|
||||
|
||||
type deviceCodeResp struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
VerificationURIComplete string `json:"verification_uri_complete"`
|
||||
ShortURIComplete string `json:"short_uri_complete"`
|
||||
Interval int `json:"interval"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Error string `json:"error"`
|
||||
ErrorCode int `json:"error_code"`
|
||||
ErrorDesc string `json:"error_description"`
|
||||
}
|
||||
|
||||
type deviceTokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
Error string `json:"error"`
|
||||
ErrorCode int `json:"error_code"`
|
||||
ErrorDesc string `json:"error_description"`
|
||||
}
|
||||
|
||||
func NewQRClient(c QRConfig) *QRClient {
|
||||
accountBaseURL := strings.TrimRight(strings.TrimSpace(c.AccountBaseURL), "/")
|
||||
if accountBaseURL == "" {
|
||||
accountBaseURL = defaultAccountBaseURL
|
||||
}
|
||||
httpClient := c.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
now := c.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &QRClient{
|
||||
accountBaseURL: accountBaseURL,
|
||||
client: resty.NewWithClient(httpClient).
|
||||
SetTimeout(20*time.Second).
|
||||
SetBaseURL(accountBaseURL).
|
||||
SetHeader("User-Agent", defaultQRUserAgent).
|
||||
SetHeader("Accept", "application/json").
|
||||
SetHeader("Content-Type", "application/json"),
|
||||
now: now,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *QRClient) Generate(ctx context.Context) (QRCodeSession, error) {
|
||||
var out deviceCodeResp
|
||||
var errOut deviceCodeResp
|
||||
resp, err := c.client.R().
|
||||
SetContext(ctx).
|
||||
SetBody(map[string]any{
|
||||
"client_id": defaultClientID,
|
||||
"scope": defaultQRScope,
|
||||
}).
|
||||
SetResult(&out).
|
||||
SetError(&errOut).
|
||||
Post("/v1/auth/device/code")
|
||||
if err != nil {
|
||||
return QRCodeSession{}, err
|
||||
}
|
||||
if resp.IsError() || out.Error != "" {
|
||||
if out.Error == "" {
|
||||
out = errOut
|
||||
}
|
||||
return QRCodeSession{}, fmt.Errorf("guangyapan qr: %s", deviceAPIError(out.ErrorDesc, out.Error, resp))
|
||||
}
|
||||
|
||||
deviceCode := strings.TrimSpace(out.DeviceCode)
|
||||
if deviceCode == "" {
|
||||
return QRCodeSession{}, errors.New("guangyapan qr: empty device_code")
|
||||
}
|
||||
qrURL := strings.TrimSpace(out.VerificationURIComplete)
|
||||
if qrURL == "" {
|
||||
qrURL = strings.TrimSpace(out.ShortURIComplete)
|
||||
}
|
||||
if qrURL == "" {
|
||||
return QRCodeSession{}, errors.New("guangyapan qr: empty verification uri")
|
||||
}
|
||||
interval := out.Interval
|
||||
if interval <= 0 {
|
||||
interval = 5
|
||||
}
|
||||
expiresIn := out.ExpiresIn
|
||||
if expiresIn <= 0 {
|
||||
expiresIn = 300
|
||||
}
|
||||
png, err := qrcode.Encode(qrURL, qrcode.Medium, 220)
|
||||
if err != nil {
|
||||
return QRCodeSession{}, err
|
||||
}
|
||||
return QRCodeSession{
|
||||
DeviceCode: deviceCode,
|
||||
QRCodeURL: qrURL,
|
||||
QRImageDataURL: "data:image/png;base64," + base64.StdEncoding.EncodeToString(png),
|
||||
IntervalSeconds: interval,
|
||||
ExpiresAt: c.now().Add(time.Duration(expiresIn) * time.Second).Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *QRClient) Poll(ctx context.Context, deviceCode string) (QRCodeStatus, error) {
|
||||
deviceCode = strings.TrimSpace(deviceCode)
|
||||
if deviceCode == "" {
|
||||
return QRCodeStatus{}, errors.New("deviceCode is required")
|
||||
}
|
||||
|
||||
var out deviceTokenResp
|
||||
var errOut deviceTokenResp
|
||||
resp, err := c.client.R().
|
||||
SetContext(ctx).
|
||||
SetBody(map[string]any{
|
||||
"client_id": defaultClientID,
|
||||
"grant_type": deviceCodeGrantType,
|
||||
"device_code": deviceCode,
|
||||
}).
|
||||
SetResult(&out).
|
||||
SetError(&errOut).
|
||||
Post("/v1/auth/token")
|
||||
if err != nil {
|
||||
return QRCodeStatus{}, err
|
||||
}
|
||||
if resp.IsError() && out.Error == "" {
|
||||
out = errOut
|
||||
}
|
||||
if resp.IsError() && out.Error == "" {
|
||||
_ = json.Unmarshal(resp.Body(), &out)
|
||||
}
|
||||
if out.Error != "" {
|
||||
return qrStatusForDeviceError(out), nil
|
||||
}
|
||||
if resp.IsError() {
|
||||
return QRCodeStatus{}, fmt.Errorf("guangyapan qr: status=%d body=%s", resp.StatusCode(), resp.String())
|
||||
}
|
||||
access := strings.TrimSpace(out.AccessToken)
|
||||
refresh := strings.TrimSpace(out.RefreshToken)
|
||||
if access == "" || refresh == "" {
|
||||
return QRCodeStatus{}, errors.New("guangyapan qr: login succeeded but token response is incomplete")
|
||||
}
|
||||
tokenType := strings.TrimSpace(out.TokenType)
|
||||
if tokenType == "" {
|
||||
tokenType = "Bearer"
|
||||
}
|
||||
return QRCodeStatus{
|
||||
State: "success",
|
||||
StatusText: "登录成功",
|
||||
AccessToken: access,
|
||||
RefreshToken: refresh,
|
||||
TokenType: tokenType,
|
||||
ExpiresIn: out.ExpiresIn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func qrStatusForDeviceError(out deviceTokenResp) QRCodeStatus {
|
||||
errCode := strings.TrimSpace(out.Error)
|
||||
switch errCode {
|
||||
case "authorization_pending":
|
||||
return QRCodeStatus{State: "pending", StatusText: "等待扫码确认"}
|
||||
case "slow_down":
|
||||
return QRCodeStatus{State: "pending", StatusText: "等待扫码确认,已降低查询频率", IntervalSeconds: 10}
|
||||
case "expired_token":
|
||||
return QRCodeStatus{State: "expired", StatusText: "二维码已过期"}
|
||||
case "access_denied":
|
||||
return QRCodeStatus{State: "denied", StatusText: "用户拒绝了授权"}
|
||||
default:
|
||||
msg := strings.TrimSpace(out.ErrorDesc)
|
||||
if msg == "" {
|
||||
msg = errCode
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "未知错误"
|
||||
}
|
||||
return QRCodeStatus{State: "error", StatusText: msg}
|
||||
}
|
||||
}
|
||||
|
||||
func deviceAPIError(desc, short string, resp *resty.Response) string {
|
||||
msg := strings.TrimSpace(desc)
|
||||
if msg == "" {
|
||||
msg = strings.TrimSpace(short)
|
||||
}
|
||||
if msg == "" && resp != nil {
|
||||
msg = strings.TrimSpace(resp.String())
|
||||
}
|
||||
if msg == "" && resp != nil {
|
||||
msg = fmt.Sprintf("status=%d", resp.StatusCode())
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "unknown error"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package guangyapan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestQRClientGenerate(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/auth/device/code" {
|
||||
t.Fatalf("path = %s, want device code endpoint", r.URL.Path)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if body["client_id"] != defaultClientID || body["scope"] != defaultQRScope {
|
||||
t.Fatalf("body = %#v", body)
|
||||
}
|
||||
writeTestJSON(w, map[string]any{
|
||||
"device_code": "device-1",
|
||||
"verification_uri_complete": "https://account.guangyapan.com/device?code=abc",
|
||||
"interval": 7,
|
||||
"expires_in": 180,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewQRClient(QRConfig{
|
||||
AccountBaseURL: srv.URL,
|
||||
Now: func() time.Time { return time.Unix(1700000000, 0) },
|
||||
})
|
||||
session, err := client.Generate(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
if session.DeviceCode != "device-1" || session.QRCodeURL != "https://account.guangyapan.com/device?code=abc" {
|
||||
t.Fatalf("session = %#v", session)
|
||||
}
|
||||
if session.IntervalSeconds != 7 {
|
||||
t.Fatalf("interval = %d, want 7", session.IntervalSeconds)
|
||||
}
|
||||
if session.ExpiresAt != time.Unix(1700000180, 0).Format(time.RFC3339) {
|
||||
t.Fatalf("expiresAt = %q", session.ExpiresAt)
|
||||
}
|
||||
if !strings.HasPrefix(session.QRImageDataURL, "data:image/png;base64,") {
|
||||
t.Fatalf("qr image = %q", session.QRImageDataURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQRClientPollPendingAndSuccess(t *testing.T) {
|
||||
var calls int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/auth/token" {
|
||||
t.Fatalf("path = %s, want token endpoint", r.URL.Path)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if body["client_id"] != defaultClientID ||
|
||||
body["grant_type"] != deviceCodeGrantType ||
|
||||
body["device_code"] != "device-1" {
|
||||
t.Fatalf("body = %#v", body)
|
||||
}
|
||||
calls++
|
||||
if calls == 1 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
writeTestJSON(w, map[string]any{"error": "authorization_pending"})
|
||||
return
|
||||
}
|
||||
writeTestJSON(w, map[string]any{
|
||||
"access_token": "access-1",
|
||||
"refresh_token": "refresh-1",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 7200,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewQRClient(QRConfig{AccountBaseURL: srv.URL})
|
||||
pending, err := client.Poll(context.Background(), "device-1")
|
||||
if err != nil {
|
||||
t.Fatalf("poll pending: %v", err)
|
||||
}
|
||||
if pending.State != "pending" || pending.AccessToken != "" {
|
||||
t.Fatalf("pending = %#v", pending)
|
||||
}
|
||||
|
||||
success, err := client.Poll(context.Background(), "device-1")
|
||||
if err != nil {
|
||||
t.Fatalf("poll success: %v", err)
|
||||
}
|
||||
if success.State != "success" || success.AccessToken != "access-1" || success.RefreshToken != "refresh-1" {
|
||||
t.Fatalf("success = %#v", success)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package guangyapan
|
||||
|
||||
import "time"
|
||||
|
||||
type tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
Error string `json:"error"`
|
||||
ErrorCode int `json:"error_code"`
|
||||
ErrorDesc string `json:"error_description"`
|
||||
}
|
||||
|
||||
type verificationResp struct {
|
||||
VerificationID string `json:"verification_id"`
|
||||
Error string `json:"error"`
|
||||
ErrorCode int `json:"error_code"`
|
||||
ErrorDesc string `json:"error_description"`
|
||||
}
|
||||
|
||||
type captchaInitResp struct {
|
||||
CaptchaToken string `json:"captcha_token"`
|
||||
Error string `json:"error"`
|
||||
ErrorCode int `json:"error_code"`
|
||||
ErrorDesc string `json:"error_description"`
|
||||
}
|
||||
|
||||
type verifyResp struct {
|
||||
VerificationToken string `json:"verification_token"`
|
||||
Error string `json:"error"`
|
||||
ErrorCode int `json:"error_code"`
|
||||
ErrorDesc string `json:"error_description"`
|
||||
}
|
||||
|
||||
type userMeResp struct {
|
||||
Sub string `json:"sub"`
|
||||
}
|
||||
|
||||
type listResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Total int `json:"total"`
|
||||
List []fileItem `json:"list"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type fileItem struct {
|
||||
FileID string `json:"fileId"`
|
||||
ParentID string `json:"parentId"`
|
||||
FileName string `json:"fileName"`
|
||||
FileSize int64 `json:"fileSize"`
|
||||
ResType int `json:"resType"`
|
||||
CTime int64 `json:"ctime"`
|
||||
UTime int64 `json:"utime"`
|
||||
}
|
||||
|
||||
type downloadResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
SignedURL string `json:"signedURL"`
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type createDirResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
FileID string `json:"fileId"`
|
||||
FileName string `json:"fileName"`
|
||||
ResType int `json:"resType"`
|
||||
CTime int64 `json:"ctime"`
|
||||
UTime int64 `json:"utime"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type deleteResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
TaskID string `json:"taskId"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type taskStatusResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Status int `json:"status"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type uploadTokenResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data uploadTokenData `json:"data"`
|
||||
}
|
||||
|
||||
type uploadTokenData struct {
|
||||
TaskID string `json:"taskId"`
|
||||
ObjectPath string `json:"objectPath"`
|
||||
BucketName string `json:"bucketName"`
|
||||
EndPoint string `json:"endPoint"`
|
||||
FullEndPoint string `json:"fullEndPoint"`
|
||||
AccessKeyID string `json:"accessKeyID"`
|
||||
SecretAccessKey string `json:"secretAccessKey"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
Creds struct {
|
||||
AccessKeyID string `json:"accessKeyID"`
|
||||
SecretAccessKey string `json:"secretAccessKey"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
} `json:"creds"`
|
||||
}
|
||||
|
||||
type taskInfoResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
FileID string `json:"fileId"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func unixOrZero(v int64) time.Time {
|
||||
if v <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.Unix(v, 0)
|
||||
}
|
||||
@@ -5,12 +5,14 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Drive 是多家网盘统一抽象。上层不区分盘,只区分 Kind。
|
||||
type Drive interface {
|
||||
// Kind 返回驱动代号:"quark" / "p115" / "p123" / "pikpak" / "wopan" / "onedrive" / "googledrive" / "localstorage"
|
||||
// Kind 返回驱动代号:"quark" / "p115" / "p123" / "pikpak" / "wopan" / "guangyapan" / "onedrive" / "googledrive" / "localstorage"
|
||||
Kind() string
|
||||
|
||||
// ID 返回该盘在 catalog 中的唯一标识
|
||||
@@ -119,3 +121,42 @@ func RateLimitRetryAfter(err error) (time.Duration, bool) {
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// TextMentionsHTTPStatus only looks for explicit numeric HTTP status contexts
|
||||
// in errors from tools that do not expose structured response metadata.
|
||||
func TextMentionsHTTPStatus(text string, statuses ...int) bool {
|
||||
text = strings.ToLower(strings.TrimSpace(text))
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
for _, status := range statuses {
|
||||
if status <= 0 {
|
||||
continue
|
||||
}
|
||||
code := strconv.Itoa(status)
|
||||
if strings.HasPrefix(text, code+" ") ||
|
||||
strings.Contains(text, "status="+code) ||
|
||||
strings.Contains(text, "status: "+code) ||
|
||||
strings.Contains(text, "status "+code) ||
|
||||
strings.Contains(text, "status code "+code) ||
|
||||
strings.Contains(text, "http "+code) ||
|
||||
strings.Contains(text, "http status="+code) ||
|
||||
strings.Contains(text, "http status: "+code) ||
|
||||
strings.Contains(text, "http status "+code) ||
|
||||
strings.Contains(text, "server returned "+code) ||
|
||||
strings.Contains(text, "code="+code) ||
|
||||
strings.Contains(text, "code: "+code) ||
|
||||
strings.Contains(text, "error_code="+code) ||
|
||||
strings.Contains(text, "error_code: "+code) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ErrorMentionsHTTPStatus(err error, statuses ...int) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return TextMentionsHTTPStatus(err.Error(), statuses...)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package drives
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTextMentionsHTTPStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{name: "status context", text: "request failed with status: 429 Too Many Requests", want: true},
|
||||
{name: "http context", text: "http 503 service unavailable", want: true},
|
||||
{name: "server returned context", text: "Server returned 403 Forbidden", want: true},
|
||||
{name: "message only", text: "操作频繁,请稍后重试", want: false},
|
||||
{name: "unrelated number", text: "generated 429 bytes", want: false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := TextMentionsHTTPStatus(tc.text, 403, 429, 503); got != tc.want {
|
||||
t.Fatalf("TextMentionsHTTPStatus(%q) = %v, want %v", tc.text, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -594,8 +594,8 @@ func (d *Driver) refresh(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func isRateLimitResponse(res *resty.Response, code, message string) bool {
|
||||
if isRateLimitCode(code) || isRateLimitMessage(message) {
|
||||
func isRateLimitResponse(res *resty.Response, code, _ string) bool {
|
||||
if isRateLimitCode(code) {
|
||||
return true
|
||||
}
|
||||
if res == nil {
|
||||
@@ -632,18 +632,6 @@ func isRateLimitCode(code string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func isRateLimitMessage(message string) bool {
|
||||
text := strings.ToLower(strings.TrimSpace(message))
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(text, "too many requests") ||
|
||||
strings.Contains(text, "throttl") ||
|
||||
strings.Contains(text, "rate limit") ||
|
||||
strings.Contains(text, "activity limit") ||
|
||||
strings.Contains(text, "temporarily blocked")
|
||||
}
|
||||
|
||||
func onedriveRateLimitError(res *resty.Response, message string) error {
|
||||
if strings.TrimSpace(message) == "" {
|
||||
message = "onedrive rate limited"
|
||||
|
||||
@@ -214,7 +214,7 @@ func TestGraph429ReturnsRateLimitErrorWithRetryAfter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphThrottleMessageReturnsRateLimitError(t *testing.T) {
|
||||
func TestGraphThrottleMessageDoesNotReturnRateLimitError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
@@ -238,11 +238,11 @@ func TestGraphThrottleMessageReturnsRateLimitError(t *testing.T) {
|
||||
|
||||
_, err := d.StreamURL(context.Background(), "file-id")
|
||||
if err == nil {
|
||||
t.Fatal("list succeeded, want rate limit error")
|
||||
t.Fatal("list succeeded, want graph error")
|
||||
}
|
||||
var rateLimit *drives.RateLimitError
|
||||
if !errors.As(err, &rateLimit) {
|
||||
t.Fatalf("error = %T %[1]v, want RateLimitError", err)
|
||||
if errors.As(err, &rateLimit) {
|
||||
t.Fatalf("error = %T %[1]v, want non-rate-limit error", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error)
|
||||
// p115ListCooldown 是列目录触发疑似风控错误时的冷却时长。
|
||||
//
|
||||
// 历史上是 [30min × 3],3 次都失败就放弃;新策略改为 10 分钟无限重试 ——
|
||||
// 只要错误仍属 transient(429 / 405 / WAF / blocked / 安全威胁 / unexpected),
|
||||
// 只要错误仍属明确 HTTP transient 状态(429 / 405),
|
||||
// 就持续等 10 分钟再发一次列目录请求,直到成功或 ctx 取消。这样即使 115
|
||||
// 风控持续较长时间,扫描会自然延后到风控结束,不再丢半棵子树。
|
||||
const p115ListCooldown = 10 * time.Minute
|
||||
@@ -156,17 +156,7 @@ func isTransient115UpstreamError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
text := strings.ToLower(err.Error())
|
||||
return strings.Contains(text, "405") ||
|
||||
strings.Contains(text, "429") ||
|
||||
strings.Contains(text, "too many request") ||
|
||||
strings.Contains(text, "too many requests") ||
|
||||
strings.Contains(text, "blocked") ||
|
||||
strings.Contains(text, "security") ||
|
||||
strings.Contains(text, "waf") ||
|
||||
strings.Contains(text, "unexpected error") ||
|
||||
strings.Contains(text, "访问被阻断") ||
|
||||
strings.Contains(text, "安全威胁")
|
||||
return drives.ErrorMentionsHTTPStatus(err, http.StatusMethodNotAllowed, http.StatusTooManyRequests)
|
||||
}
|
||||
|
||||
// ListDirsOnly 只列指定目录的直接**子目录**,不返回文件条目。专为 admin 后台
|
||||
|
||||
@@ -22,8 +22,9 @@ func TestIsTransient115ListError(t *testing.T) {
|
||||
want bool
|
||||
}{
|
||||
{name: "nil", err: nil, want: false},
|
||||
{name: "blocked html", err: errors.New(`<!doctype html><title>405</title>Sorry, your request has been blocked as it may cause potential threats to the server's security.`), want: true},
|
||||
{name: "chinese waf", err: errors.New("很抱歉,由于您访问的URL有可能对网站造成安全威胁,您的访问被阻断。"), want: true},
|
||||
{name: "blocked html without status context", err: errors.New(`<!doctype html><title>405</title>Sorry, your request has been blocked as it may cause potential threats to the server's security.`), want: false},
|
||||
{name: "chinese waf", err: errors.New("很抱歉,由于您访问的URL有可能对网站造成安全威胁,您的访问被阻断。"), want: false},
|
||||
{name: "status 405", err: errors.New("request failed with status: 405"), want: true},
|
||||
{name: "rate limit", err: errors.New("429 too many requests"), want: true},
|
||||
{name: "regular auth error", err: errors.New("invalid credential"), want: false},
|
||||
}
|
||||
@@ -43,10 +44,10 @@ func TestWrap115StreamTransientError(t *testing.T) {
|
||||
err error
|
||||
wantRateLimit bool
|
||||
}{
|
||||
{name: "unexpected", err: errors.New("unexpected error"), wantRateLimit: true},
|
||||
{name: "unexpected", err: errors.New("unexpected error"), wantRateLimit: false},
|
||||
{name: "405 blocked", err: errors.New("405 request has been blocked"), wantRateLimit: true},
|
||||
{name: "429", err: errors.New("429 too many requests"), wantRateLimit: true},
|
||||
{name: "blocked", err: errors.New("blocked by waf"), wantRateLimit: true},
|
||||
{name: "blocked", err: errors.New("blocked by waf"), wantRateLimit: false},
|
||||
{name: "auth", err: errors.New("invalid credential"), wantRateLimit: false},
|
||||
}
|
||||
|
||||
|
||||
@@ -754,8 +754,8 @@ func (d *Driver) request(ctx context.Context, endpoint, method string, configure
|
||||
return nil, errors.New("123pan request: unauthorized")
|
||||
}
|
||||
|
||||
func isP123RateLimitResponse(res *resty.Response, code int, message string) bool {
|
||||
if code == http.StatusTooManyRequests || isP123RateLimitMessage(message) {
|
||||
func isP123RateLimitResponse(res *resty.Response, code int, _ string) bool {
|
||||
if code == http.StatusTooManyRequests {
|
||||
return true
|
||||
}
|
||||
if res == nil {
|
||||
@@ -764,7 +764,7 @@ func isP123RateLimitResponse(res *resty.Response, code int, message string) bool
|
||||
return isP123RateLimitHTTPResponse(res.StatusCode(), res.Header().Get("Retry-After"), res.String())
|
||||
}
|
||||
|
||||
func isP123RateLimitHTTPResponse(status int, retryAfter, body string) bool {
|
||||
func isP123RateLimitHTTPResponse(status int, retryAfter, _ string) bool {
|
||||
if status == http.StatusTooManyRequests {
|
||||
return true
|
||||
}
|
||||
@@ -774,35 +774,9 @@ func isP123RateLimitHTTPResponse(status int, retryAfter, body string) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if isP123RateLimitMessage(body) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isP123RateLimitMessage(message string) bool {
|
||||
text := strings.ToLower(strings.TrimSpace(message))
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(text, "请求太频繁") ||
|
||||
strings.Contains(text, "请求过于频繁") ||
|
||||
strings.Contains(text, "请求频繁") ||
|
||||
strings.Contains(text, "操作频繁") ||
|
||||
strings.Contains(text, "频率限制") ||
|
||||
strings.Contains(text, "请求次数过多") ||
|
||||
strings.Contains(text, "too many request") ||
|
||||
strings.Contains(text, "too many requests") ||
|
||||
strings.Contains(text, "rate limit") ||
|
||||
strings.Contains(text, "rate-limit") ||
|
||||
strings.Contains(text, "ratelimit") ||
|
||||
strings.Contains(text, "throttl") ||
|
||||
strings.Contains(text, "temporarily blocked") ||
|
||||
strings.Contains(text, "request has been blocked") ||
|
||||
strings.Contains(text, "blocked") ||
|
||||
strings.Contains(text, "访问被阻断")
|
||||
}
|
||||
|
||||
func p123RateLimitError(res *resty.Response, code int, message string) error {
|
||||
if strings.TrimSpace(message) == "" {
|
||||
message = "123pan rate limited"
|
||||
|
||||
@@ -175,8 +175,8 @@ func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error)
|
||||
|
||||
// pikpakListCooldown 是列目录触发疑似限流错误时的冷却时长。
|
||||
//
|
||||
// 与 p115 driver 的 listCooldown 同语义:只要错误属 transient
|
||||
// (error_code=10 / HTTP 429 / 5xx / 通用 "rate limit" 文本),就持续
|
||||
// 与 p115 driver 的 listCooldown 同语义:只要错误属明确限流/临时状态
|
||||
// (结构化 error_code=10 / HTTP 429 / 5xx),就持续
|
||||
// 等 10 分钟再发一次列目录请求,直到成功或 ctx 取消。这样即使 PikPak
|
||||
// 风控持续较长时间,扫描会自然延后到风控结束,不再丢半棵子树。
|
||||
const pikpakListCooldown = 10 * time.Minute
|
||||
@@ -242,7 +242,6 @@ func pikpakSleepContext(ctx context.Context, d time.Duration) error {
|
||||
//
|
||||
// - PikPak 业务码 error_code=10 ("操作频繁",见 OpenList drivers/pikpak/util.go)
|
||||
// - HTTP 429 / 500 / 502 / 503 / 504 / 509(rclone 也把这些归为 retry)
|
||||
// - 通用文本:rate limit / too many requests / blocked / temporarily unavailable
|
||||
//
|
||||
// 不包含 4122/4121/16(access_token 过期)和 9/4002(captcha 过期)—— 这些
|
||||
// 由 requestOnce 内部已经做过一次自动恢复重试;如果恢复后仍然报这类错误,
|
||||
@@ -259,22 +258,14 @@ func isTransientPikPakListError(err error) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
text := strings.ToLower(err.Error())
|
||||
return strings.Contains(text, "error_code=10") ||
|
||||
strings.Contains(text, "429") ||
|
||||
strings.Contains(text, "http 500") ||
|
||||
strings.Contains(text, "http 502") ||
|
||||
strings.Contains(text, "http 503") ||
|
||||
strings.Contains(text, "http 504") ||
|
||||
strings.Contains(text, "http 509") ||
|
||||
strings.Contains(text, "too many request") ||
|
||||
strings.Contains(text, "too many requests") ||
|
||||
strings.Contains(text, "rate limit") ||
|
||||
strings.Contains(text, "operation frequent") ||
|
||||
strings.Contains(text, "操作频繁") ||
|
||||
strings.Contains(text, "blocked") ||
|
||||
strings.Contains(text, "temporarily unavailable") ||
|
||||
strings.Contains(text, "service unavailable")
|
||||
return drives.ErrorMentionsHTTPStatus(err,
|
||||
http.StatusTooManyRequests,
|
||||
http.StatusInternalServerError,
|
||||
http.StatusBadGateway,
|
||||
http.StatusServiceUnavailable,
|
||||
http.StatusGatewayTimeout,
|
||||
509,
|
||||
)
|
||||
}
|
||||
|
||||
func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) {
|
||||
|
||||
@@ -50,6 +50,7 @@ type CrawlerConfig struct {
|
||||
CommonThumbDir string
|
||||
ProxyURL string
|
||||
ConfigJSON string
|
||||
DisablePreview bool
|
||||
HTTPClient *http.Client
|
||||
DownloadTimeout time.Duration
|
||||
OnProgress func(CrawlProgress)
|
||||
@@ -562,6 +563,10 @@ func (c *Crawler) processItem(ctx context.Context, item Item) (bool, error) {
|
||||
if quality == "" {
|
||||
quality = "HD"
|
||||
}
|
||||
previewStatus := "pending"
|
||||
if c.previewDisabled(ctx) {
|
||||
previewStatus = "disabled"
|
||||
}
|
||||
v := &catalog.Video{
|
||||
ID: videoID,
|
||||
DriveID: c.cfg.Driver.ID(),
|
||||
@@ -576,7 +581,7 @@ func (c *Crawler) processItem(ctx context.Context, item Item) (bool, error) {
|
||||
Quality: quality,
|
||||
Category: strings.TrimSpace(item.Category),
|
||||
Description: strings.TrimSpace(item.Description),
|
||||
PreviewStatus: "pending",
|
||||
PreviewStatus: previewStatus,
|
||||
PublishedAt: publishedAt,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
@@ -632,6 +637,18 @@ func (c *Crawler) processItem(ctx context.Context, item Item) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *Crawler) previewDisabled(ctx context.Context) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
if c.cfg.Catalog != nil && c.cfg.Driver != nil {
|
||||
if d, err := c.cfg.Catalog.GetDrive(ctx, c.cfg.Driver.ID()); err == nil && d != nil {
|
||||
return !d.TeaserEnabled
|
||||
}
|
||||
}
|
||||
return c.cfg.DisablePreview
|
||||
}
|
||||
|
||||
func (c *Crawler) materializeMedia(ctx context.Context, ref MediaRef, dst, referer string, required bool) (int64, error) {
|
||||
if local := strings.TrimSpace(ref.LocalFile); local != "" {
|
||||
return c.copyLocalOutput(local, dst)
|
||||
|
||||
@@ -114,6 +114,128 @@ func TestCrawlerRunOnceImportsLocalFileAndSkipsExisting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrawlerRunOnceMarksPreviewDisabledWhenConfigured(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmp := t.TempDir()
|
||||
cat, err := catalog.Open(filepath.Join(tmp, "catalog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open catalog: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := cat.Close(); err != nil {
|
||||
t.Fatalf("close catalog: %v", err)
|
||||
}
|
||||
})
|
||||
drv := New(Config{ID: "demo", RootDir: filepath.Join(tmp, "crawler")})
|
||||
if err := drv.Init(ctx); err != nil {
|
||||
t.Fatalf("driver init: %v", err)
|
||||
}
|
||||
dummyScript := filepath.Join(tmp, "helper-script")
|
||||
if err := os.WriteFile(dummyScript, []byte("helper"), 0o755); err != nil {
|
||||
t.Fatalf("write dummy script: %v", err)
|
||||
}
|
||||
wrapper := filepath.Join(tmp, "helper-wrapper.sh")
|
||||
wrapperScript := fmt.Sprintf("#!/bin/sh\nexec %q -test.run=TestScriptCrawlerHelperProcess \"$@\"\n", os.Args[0])
|
||||
if err := os.WriteFile(wrapper, []byte(wrapperScript), 0o755); err != nil {
|
||||
t.Fatalf("write helper wrapper: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("GO_WANT_SCRIPTCRAWLER_HELPER", "1")
|
||||
c := NewCrawler(CrawlerConfig{
|
||||
Driver: drv,
|
||||
Catalog: cat,
|
||||
PythonPath: wrapper,
|
||||
FFprobePath: writeScriptCrawlerFFprobeStub(t, tmp, true),
|
||||
ScriptPath: dummyScript,
|
||||
DisablePreview: true,
|
||||
})
|
||||
res, err := c.RunOnce(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("run once: %v", err)
|
||||
}
|
||||
if res.NewVideos != 1 || res.Failed != 0 {
|
||||
t.Fatalf("result = new:%d failed:%d, want 1/0", res.NewVideos, res.Failed)
|
||||
}
|
||||
v, err := cat.GetVideo(ctx, BuildVideoID("demo", "abc-123"))
|
||||
if err != nil {
|
||||
t.Fatalf("get video: %v", err)
|
||||
}
|
||||
if v.PreviewStatus != "disabled" {
|
||||
t.Fatalf("preview status = %q, want disabled", v.PreviewStatus)
|
||||
}
|
||||
if v.FingerprintStatus != "ready" || v.SampledSHA256 == "" {
|
||||
t.Fatalf("fingerprint status=%q sampled=%q, want ready and sampled hash", v.FingerprintStatus, v.SampledSHA256)
|
||||
}
|
||||
pending, err := cat.ListVideosByPreviewStatus(ctx, "demo", "pending", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list pending previews: %v", err)
|
||||
}
|
||||
if len(pending) != 0 {
|
||||
t.Fatalf("pending previews = %d, want 0", len(pending))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrawlerRunOnceUsesCurrentDrivePreviewSwitch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmp := t.TempDir()
|
||||
cat, err := catalog.Open(filepath.Join(tmp, "catalog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open catalog: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := cat.Close(); err != nil {
|
||||
t.Fatalf("close catalog: %v", err)
|
||||
}
|
||||
})
|
||||
drv := New(Config{ID: "demo", RootDir: filepath.Join(tmp, "crawler")})
|
||||
if err := drv.Init(ctx); err != nil {
|
||||
t.Fatalf("driver init: %v", err)
|
||||
}
|
||||
if err := cat.UpsertDrive(ctx, &catalog.Drive{
|
||||
ID: drv.ID(),
|
||||
Kind: Kind,
|
||||
Name: "Demo",
|
||||
RootID: "/",
|
||||
Credentials: map[string]string{"script_path": "/tmp/crawler.py"},
|
||||
TeaserEnabled: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed drive: %v", err)
|
||||
}
|
||||
dummyScript := filepath.Join(tmp, "helper-script")
|
||||
if err := os.WriteFile(dummyScript, []byte("helper"), 0o755); err != nil {
|
||||
t.Fatalf("write dummy script: %v", err)
|
||||
}
|
||||
wrapper := filepath.Join(tmp, "helper-wrapper.sh")
|
||||
wrapperScript := fmt.Sprintf("#!/bin/sh\nexec %q -test.run=TestScriptCrawlerHelperProcess \"$@\"\n", os.Args[0])
|
||||
if err := os.WriteFile(wrapper, []byte(wrapperScript), 0o755); err != nil {
|
||||
t.Fatalf("write helper wrapper: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("GO_WANT_SCRIPTCRAWLER_HELPER", "1")
|
||||
c := NewCrawler(CrawlerConfig{
|
||||
Driver: drv,
|
||||
Catalog: cat,
|
||||
PythonPath: wrapper,
|
||||
FFprobePath: writeScriptCrawlerFFprobeStub(t, tmp, true),
|
||||
ScriptPath: dummyScript,
|
||||
DisablePreview: true,
|
||||
})
|
||||
res, err := c.RunOnce(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("run once: %v", err)
|
||||
}
|
||||
if res.NewVideos != 1 || res.Failed != 0 {
|
||||
t.Fatalf("result = new:%d failed:%d, want 1/0", res.NewVideos, res.Failed)
|
||||
}
|
||||
v, err := cat.GetVideo(ctx, BuildVideoID("demo", "abc-123"))
|
||||
if err != nil {
|
||||
t.Fatalf("get video: %v", err)
|
||||
}
|
||||
if v.PreviewStatus != "pending" {
|
||||
t.Fatalf("preview status = %q, want pending from current drive switch", v.PreviewStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrawlerRunOnceUsesSourceKindNamespace(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmp := t.TempDir()
|
||||
|
||||
@@ -510,42 +510,14 @@ func isWopanRateLimitError(err error) bool {
|
||||
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return false
|
||||
}
|
||||
text := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(text, "status: 429") ||
|
||||
strings.Contains(text, "status 429") ||
|
||||
strings.Contains(text, "http status: 429") ||
|
||||
strings.Contains(text, "status: 500") ||
|
||||
strings.Contains(text, "status 500") ||
|
||||
strings.Contains(text, "status: 502") ||
|
||||
strings.Contains(text, "status 502") ||
|
||||
strings.Contains(text, "status: 503") ||
|
||||
strings.Contains(text, "status 503") ||
|
||||
strings.Contains(text, "status: 504") ||
|
||||
strings.Contains(text, "status 504") ||
|
||||
strings.Contains(text, "status: 509") ||
|
||||
strings.Contains(text, "status 509") ||
|
||||
strings.Contains(text, "too many request") ||
|
||||
strings.Contains(text, "too many requests") ||
|
||||
strings.Contains(text, "rate limit") ||
|
||||
strings.Contains(text, "rate-limit") ||
|
||||
strings.Contains(text, "throttl") ||
|
||||
strings.Contains(text, "blocked") ||
|
||||
strings.Contains(text, "request has been blocked") ||
|
||||
strings.Contains(text, "操作频繁") ||
|
||||
strings.Contains(text, "请求频繁") ||
|
||||
strings.Contains(text, "请求太频繁") ||
|
||||
strings.Contains(text, "请求过于频繁") ||
|
||||
strings.Contains(text, "频率限制") ||
|
||||
strings.Contains(text, "请求次数过多") ||
|
||||
strings.Contains(text, "系统繁忙") ||
|
||||
strings.Contains(text, "服务繁忙") ||
|
||||
strings.Contains(text, "稍后再试") ||
|
||||
strings.Contains(text, "稍后重试") ||
|
||||
strings.Contains(text, "访问被阻断") ||
|
||||
strings.Contains(text, "风控")
|
||||
return drives.ErrorMentionsHTTPStatus(err,
|
||||
http.StatusTooManyRequests,
|
||||
http.StatusInternalServerError,
|
||||
http.StatusBadGateway,
|
||||
http.StatusServiceUnavailable,
|
||||
http.StatusGatewayTimeout,
|
||||
509,
|
||||
)
|
||||
}
|
||||
|
||||
func guessMime(name string) string {
|
||||
|
||||
@@ -372,37 +372,10 @@ func remoteRangeResponseLooksRateLimited(rawURL string, status int, body []byte)
|
||||
status == 509) {
|
||||
return true
|
||||
}
|
||||
text := strings.ToLower(strings.TrimSpace(string(body)))
|
||||
compact := compactRemoteRangeErrorText(text)
|
||||
if strings.Contains(text, "too many request") ||
|
||||
strings.Contains(text, "too many requests") ||
|
||||
strings.Contains(text, "rate limit") ||
|
||||
strings.Contains(text, "quota exceeded") ||
|
||||
strings.Contains(text, "操作频繁") ||
|
||||
strings.Contains(text, "请求频繁") ||
|
||||
strings.Contains(text, "请求太频繁") ||
|
||||
strings.Contains(text, "请求过于频繁") ||
|
||||
strings.Contains(text, "频率限制") ||
|
||||
strings.Contains(text, "请求次数过多") ||
|
||||
strings.Contains(text, "系统繁忙") ||
|
||||
strings.Contains(text, "服务繁忙") ||
|
||||
strings.Contains(text, "稍后再试") ||
|
||||
strings.Contains(text, "稍后重试") ||
|
||||
strings.Contains(text, "访问被阻断") ||
|
||||
strings.Contains(text, "风控") ||
|
||||
strings.Contains(text, "download quota") ||
|
||||
strings.Contains(text, "sharing rate") ||
|
||||
strings.Contains(text, "daily limit") ||
|
||||
strings.Contains(text, "user rate") ||
|
||||
strings.Contains(text, "usage limit") ||
|
||||
strings.Contains(compact, "ratelimitexceeded") ||
|
||||
strings.Contains(compact, "userratelimitexceeded") ||
|
||||
strings.Contains(compact, "dailylimitexceeded") ||
|
||||
strings.Contains(compact, "downloadquotaexceeded") ||
|
||||
strings.Contains(compact, "sharingratelimitexceeded") ||
|
||||
strings.Contains(compact, "quotaexceeded") ||
|
||||
strings.Contains(compact, "toomanyrequests") ||
|
||||
strings.Contains(compact, "usagelimits") {
|
||||
if isGuangYaPanMediaURL(rawURL) && (status == http.StatusForbidden || status == http.StatusTooManyRequests ||
|
||||
status == http.StatusInternalServerError || status == http.StatusBadGateway ||
|
||||
status == http.StatusServiceUnavailable || status == http.StatusGatewayTimeout ||
|
||||
status == 509) {
|
||||
return true
|
||||
}
|
||||
if status == http.StatusForbidden && isGoogleDriveMediaURL(rawURL) {
|
||||
@@ -424,6 +397,16 @@ func isWopanMediaURL(rawURL string) bool {
|
||||
strings.Contains(path, "/openapi/download")
|
||||
}
|
||||
|
||||
func isGuangYaPanMediaURL(rawURL string) bool {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
return strings.HasSuffix(host, "guangyacdn.com") ||
|
||||
strings.HasSuffix(host, "guangyapan.com")
|
||||
}
|
||||
|
||||
func isGoogleDriveMediaURL(rawURL string) bool {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
@@ -434,11 +417,6 @@ func isGoogleDriveMediaURL(rawURL string) bool {
|
||||
return strings.Contains(host, "googleapis.com") && strings.Contains(path, "/drive/")
|
||||
}
|
||||
|
||||
func compactRemoteRangeErrorText(text string) string {
|
||||
replacer := strings.NewReplacer("_", "", "-", "", " ", "", ".", "", ":", "")
|
||||
return replacer.Replace(strings.ToLower(strings.TrimSpace(text)))
|
||||
}
|
||||
|
||||
func parseRetryAfter(raw string) time.Duration {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
|
||||
@@ -86,16 +86,16 @@ func TestComputeRemoteUsesRangeSamples(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeRemoteGoogleQuotaExceededReturnsRateLimit(t *testing.T) {
|
||||
func TestComputeRemote429ReturnsRateLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":403,"message":"The download quota for this file has been exceeded.","errors":[{"domain":"usageLimits","reason":"downloadQuotaExceeded","message":"The download quota for this file has been exceeded."}]}}`))
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":429}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
drv := &fakeDrive{paths: map[string]string{"remote": srv.URL + "/drive/v3/files/file-1?alt=media"}}
|
||||
drv := &fakeDrive{paths: map[string]string{"remote": srv.URL + "/video.mp4"}}
|
||||
_, err := Compute(ctx, drv, &catalog.Video{ID: "remote", FileID: "remote", Size: 1024 * 1024}, Config{
|
||||
SampleSizeBytes: 4,
|
||||
FullHashMaxSize: 8,
|
||||
@@ -131,6 +131,30 @@ func TestWopanRemoteRangeErrorsLookRateLimited(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuangYaPanRemoteRangeErrorsLookRateLimited(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
rawURL string
|
||||
status int
|
||||
}{
|
||||
{rawURL: "https://txgz02-httpdown.guangyacdn.com/download/?fid=encoded", status: http.StatusForbidden},
|
||||
{rawURL: "https://txgz02-httpdown.guangyacdn.com/download/?fid=encoded", status: http.StatusServiceUnavailable},
|
||||
{rawURL: "https://txgz02-httpdown.guangyacdn.com/download/?fid=encoded", status: 509},
|
||||
} {
|
||||
if !remoteRangeResponseLooksRateLimited(tc.rawURL, tc.status, nil) {
|
||||
t.Fatalf("remoteRangeResponseLooksRateLimited(%q, %d) = false, want true", tc.rawURL, tc.status)
|
||||
}
|
||||
}
|
||||
if remoteRangeResponseLooksRateLimited("https://example.com/video.mp4", http.StatusForbidden, nil) {
|
||||
t.Fatal("generic 403 should not be treated as guangyapan rate limit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleDriveRemoteRangeForbiddenLooksRateLimitedByURL(t *testing.T) {
|
||||
if !remoteRangeResponseLooksRateLimited("https://www.googleapis.com/drive/v3/files/file-1?alt=media", http.StatusForbidden, nil) {
|
||||
t.Fatal("google drive media 403 should be treated as rate limit by URL and status")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeDrive struct {
|
||||
paths map[string]string
|
||||
}
|
||||
|
||||
@@ -952,15 +952,7 @@ func redactURLs(text string) string {
|
||||
}
|
||||
|
||||
func ffmpegOutputLooksRateLimited(output []byte) bool {
|
||||
text := strings.ToLower(string(output))
|
||||
if !strings.Contains(text, "429") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(text, "too many requests") ||
|
||||
strings.Contains(text, "throttl") ||
|
||||
strings.Contains(text, "rate limit") ||
|
||||
strings.Contains(text, "rate-limit") ||
|
||||
strings.Contains(text, "server returned 429")
|
||||
return drives.TextMentionsHTTPStatus(string(output), http.StatusTooManyRequests)
|
||||
}
|
||||
|
||||
// --- 本地落盘 ---
|
||||
@@ -1064,12 +1056,10 @@ type ThumbWorker struct {
|
||||
}
|
||||
|
||||
const (
|
||||
defaultTransientMediaCooldown = 5 * time.Minute
|
||||
defaultGenerationRateLimitCooldown = 5 * time.Minute
|
||||
defaultThumbTransientMediaMaxFailures = 3
|
||||
defaultWorkerQueueSize = 10000
|
||||
maxPreviewTeaserSizeBytes int64 = 5 * 1024 * 1024 * 1024
|
||||
previewStatusSkipped = "skipped"
|
||||
defaultTransientMediaCooldown = 5 * time.Minute
|
||||
defaultGenerationRateLimitCooldown = 5 * time.Minute
|
||||
defaultThumbTransientMediaMaxFailures = 3
|
||||
defaultWorkerQueueSize = 10000
|
||||
)
|
||||
|
||||
type rateLimitState struct {
|
||||
@@ -1124,6 +1114,19 @@ func (q *videoQueue) release(v *catalog.Video) {
|
||||
q.mu.Unlock()
|
||||
}
|
||||
|
||||
func (q *videoQueue) idsSnapshot() []string {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if len(q.ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(q.ids))
|
||||
for id := range q.ids {
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (q *videoQueue) lengthExcluding(currentID string) int {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
@@ -1251,6 +1254,13 @@ func (w *Worker) Status() TaskStatus {
|
||||
return taskStatus(&w.activity, &w.rateLimit, w.queue.lengthExcluding(currentID))
|
||||
}
|
||||
|
||||
func (w *Worker) ActiveVideoIDs() []string {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
return w.queue.idsSnapshot()
|
||||
}
|
||||
|
||||
func (w *ThumbWorker) Status() TaskStatus {
|
||||
if w == nil {
|
||||
return TaskStatus{State: "idle"}
|
||||
@@ -1518,145 +1528,21 @@ func driveErrorShouldCooldown(d drives.Drive, err error) bool {
|
||||
}
|
||||
switch d.Kind() {
|
||||
case "p115":
|
||||
text := strings.ToLower(err.Error())
|
||||
return strings.Contains(text, "server returned 403") ||
|
||||
strings.Contains(text, "403 forbidden") ||
|
||||
strings.Contains(text, "server returned 405") ||
|
||||
strings.Contains(text, "405 method") ||
|
||||
strings.Contains(text, "access denied") ||
|
||||
strings.Contains(text, "moov atom not found") ||
|
||||
strings.Contains(text, "partial file") ||
|
||||
strings.Contains(text, "request has been blocked") ||
|
||||
strings.Contains(text, "访问被阻断")
|
||||
return drives.ErrorMentionsHTTPStatus(err, http.StatusForbidden, http.StatusMethodNotAllowed, http.StatusTooManyRequests)
|
||||
case "pikpak":
|
||||
// PikPak 在预览视频 / 封面生成阶段(取链或拉直链字节)可能命中:
|
||||
// - error_code=10 操作频繁
|
||||
// - HTTP 429 / 5xx / 509 限流和服务端不可用
|
||||
// - 通用文本:rate limit / too many requests / blocked
|
||||
// 命中时让 worker 冷却 5 分钟,避免连续请求加重风控。
|
||||
text := strings.ToLower(err.Error())
|
||||
return strings.Contains(text, "error_code=10") ||
|
||||
strings.Contains(text, "操作频繁") ||
|
||||
strings.Contains(text, "429") ||
|
||||
strings.Contains(text, "http 500") ||
|
||||
strings.Contains(text, "http 502") ||
|
||||
strings.Contains(text, "http 503") ||
|
||||
strings.Contains(text, "http 504") ||
|
||||
strings.Contains(text, "http 509") ||
|
||||
strings.Contains(text, "too many request") ||
|
||||
strings.Contains(text, "too many requests") ||
|
||||
strings.Contains(text, "rate limit") ||
|
||||
strings.Contains(text, "blocked") ||
|
||||
strings.Contains(text, "partial file") ||
|
||||
strings.Contains(text, "service unavailable")
|
||||
return drives.ErrorMentionsHTTPStatus(err, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout, 509)
|
||||
case "p123":
|
||||
// 123网盘直链解析 / ffmpeg 读取阶段可能返回 429、5xx,或 WAF 类
|
||||
// blocked / 访问阻断文本。命中时冷却,避免封面和预览视频生成连续打接口。
|
||||
text := strings.ToLower(err.Error())
|
||||
return strings.Contains(text, "请求太频繁") ||
|
||||
strings.Contains(text, "请求过于频繁") ||
|
||||
strings.Contains(text, "请求频繁") ||
|
||||
strings.Contains(text, "操作频繁") ||
|
||||
strings.Contains(text, "频率限制") ||
|
||||
strings.Contains(text, "请求次数过多") ||
|
||||
strings.Contains(text, "429") ||
|
||||
strings.Contains(text, "http 500") ||
|
||||
strings.Contains(text, "http 502") ||
|
||||
strings.Contains(text, "http 503") ||
|
||||
strings.Contains(text, "http 504") ||
|
||||
strings.Contains(text, "server returned 403") ||
|
||||
strings.Contains(text, "403 forbidden") ||
|
||||
strings.Contains(text, "too many request") ||
|
||||
strings.Contains(text, "too many requests") ||
|
||||
strings.Contains(text, "rate limit") ||
|
||||
strings.Contains(text, "blocked") ||
|
||||
strings.Contains(text, "访问被阻断") ||
|
||||
strings.Contains(text, "service unavailable")
|
||||
return drives.ErrorMentionsHTTPStatus(err, http.StatusForbidden, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout)
|
||||
case "wopan":
|
||||
// 联通网盘的取链接口和下载直链都可能返回"操作频繁"、429、5xx
|
||||
// 或 WAF 阻断文本。封面/预览失败时先冷却,避免持续触发风控。
|
||||
text := strings.ToLower(err.Error())
|
||||
return strings.Contains(text, "请求太频繁") ||
|
||||
strings.Contains(text, "请求过于频繁") ||
|
||||
strings.Contains(text, "请求频繁") ||
|
||||
strings.Contains(text, "操作频繁") ||
|
||||
strings.Contains(text, "频率限制") ||
|
||||
strings.Contains(text, "请求次数过多") ||
|
||||
strings.Contains(text, "系统繁忙") ||
|
||||
strings.Contains(text, "服务繁忙") ||
|
||||
strings.Contains(text, "稍后再试") ||
|
||||
strings.Contains(text, "稍后重试") ||
|
||||
strings.Contains(text, "429") ||
|
||||
strings.Contains(text, "http 500") ||
|
||||
strings.Contains(text, "http 502") ||
|
||||
strings.Contains(text, "http 503") ||
|
||||
strings.Contains(text, "http 504") ||
|
||||
strings.Contains(text, "http 509") ||
|
||||
strings.Contains(text, "server returned 403") ||
|
||||
strings.Contains(text, "403 forbidden") ||
|
||||
strings.Contains(text, "server returned 429") ||
|
||||
strings.Contains(text, "server returned 500") ||
|
||||
strings.Contains(text, "server returned 502") ||
|
||||
strings.Contains(text, "server returned 503") ||
|
||||
strings.Contains(text, "server returned 504") ||
|
||||
strings.Contains(text, "too many request") ||
|
||||
strings.Contains(text, "too many requests") ||
|
||||
strings.Contains(text, "rate limit") ||
|
||||
strings.Contains(text, "rate-limit") ||
|
||||
strings.Contains(text, "throttl") ||
|
||||
strings.Contains(text, "blocked") ||
|
||||
strings.Contains(text, "request has been blocked") ||
|
||||
strings.Contains(text, "访问被阻断") ||
|
||||
strings.Contains(text, "风控") ||
|
||||
strings.Contains(text, "service unavailable")
|
||||
return drives.ErrorMentionsHTTPStatus(err, http.StatusForbidden, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout, 509)
|
||||
case "guangyapan":
|
||||
return drives.ErrorMentionsHTTPStatus(err, http.StatusForbidden, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout, 509)
|
||||
case "googledrive":
|
||||
// Google Drive 下载/取样阶段常把频控和配额问题包装成 403,
|
||||
// 具体标识在 error.errors[].reason/message 里(OpenList 也按该结构解析)。
|
||||
// ffmpeg/ffprobe 只能看到 stderr 文本时,按这些 reason/文本兜底冷却。
|
||||
text := strings.ToLower(err.Error())
|
||||
return googleDriveMediaErrorShouldCooldown(text)
|
||||
return drives.ErrorMentionsHTTPStatus(err, http.StatusForbidden, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func googleDriveMediaErrorShouldCooldown(text string) bool {
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
compact := compactGoogleDriveErrorText(text)
|
||||
return strings.Contains(text, "server returned 403") ||
|
||||
strings.Contains(text, "403 forbidden") ||
|
||||
strings.Contains(text, "server returned 429") ||
|
||||
strings.Contains(text, "http 429") ||
|
||||
strings.Contains(text, "http 500") ||
|
||||
strings.Contains(text, "http 502") ||
|
||||
strings.Contains(text, "http 503") ||
|
||||
strings.Contains(text, "http 504") ||
|
||||
strings.Contains(text, "too many request") ||
|
||||
strings.Contains(text, "too many requests") ||
|
||||
strings.Contains(text, "rate limit") ||
|
||||
strings.Contains(text, "quota exceeded") ||
|
||||
strings.Contains(text, "download quota") ||
|
||||
strings.Contains(text, "sharing rate") ||
|
||||
strings.Contains(text, "daily limit") ||
|
||||
strings.Contains(text, "user rate") ||
|
||||
strings.Contains(text, "usage limit") ||
|
||||
strings.Contains(text, "service unavailable") ||
|
||||
strings.Contains(compact, "ratelimitexceeded") ||
|
||||
strings.Contains(compact, "userratelimitexceeded") ||
|
||||
strings.Contains(compact, "dailylimitexceeded") ||
|
||||
strings.Contains(compact, "downloadquotaexceeded") ||
|
||||
strings.Contains(compact, "sharingratelimitexceeded") ||
|
||||
strings.Contains(compact, "quotaexceeded") ||
|
||||
strings.Contains(compact, "toomanyrequests") ||
|
||||
strings.Contains(compact, "usagelimits")
|
||||
}
|
||||
|
||||
func compactGoogleDriveErrorText(text string) string {
|
||||
replacer := strings.NewReplacer("_", "", "-", "", " ", "", ".", "", ":", "")
|
||||
return replacer.Replace(strings.ToLower(strings.TrimSpace(text)))
|
||||
}
|
||||
|
||||
func (w *ThumbWorker) process(ctx context.Context, v *catalog.Video) bool {
|
||||
if w.skipIfRateLimited(v) {
|
||||
return false
|
||||
@@ -1806,15 +1692,6 @@ func localPreviewLink(v *catalog.Video) (*drives.StreamLink, bool) {
|
||||
}
|
||||
|
||||
func (w *Worker) process(ctx context.Context, v *catalog.Video) {
|
||||
if shouldSkipTeaser(v) {
|
||||
removePreviousLocalTeaser(v.PreviewLocal, "")
|
||||
if err := w.Catalog.UpdatePreview(ctx, v.ID, "", previewStatusSkipped); err != nil {
|
||||
log.Printf("[preview] skip %s: update status: %v", v.Title, err)
|
||||
return
|
||||
}
|
||||
log.Printf("[preview] skip %s: size=%d exceeds 5GiB teaser limit", v.Title, v.Size)
|
||||
return
|
||||
}
|
||||
if w.skipIfRateLimited(v) {
|
||||
return
|
||||
}
|
||||
@@ -1867,10 +1744,6 @@ func (w *Worker) process(ctx context.Context, v *catalog.Video) {
|
||||
log.Printf("[preview] ready %s (duration=%.1fs)", v.Title, duration)
|
||||
}
|
||||
|
||||
func shouldSkipTeaser(v *catalog.Video) bool {
|
||||
return v != nil && v.Size > maxPreviewTeaserSizeBytes
|
||||
}
|
||||
|
||||
func (w *Worker) generateTeaser(ctx context.Context, v *catalog.Video, link *drives.StreamLink, duration float64) (string, error) {
|
||||
gen, ok := w.Gen.(refreshingTeaserGenerator)
|
||||
if !ok || w.Drive == nil || w.Drive.Kind() != "p115" {
|
||||
|
||||
@@ -349,42 +349,10 @@ func TestPreviewWorkerNeverCallsDriveUploadOrEnsureDir(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewWorkerSkipsTeaserForVideoLargerThanFiveGiB(t *testing.T) {
|
||||
func TestPreviewWorkerGeneratesTeaserForLargeVideo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cat, video := seedPreviewTestVideo(t, "preview-large-video")
|
||||
video.Size = maxPreviewTeaserSizeBytes + 1
|
||||
if err := cat.UpsertVideo(ctx, video); err != nil {
|
||||
t.Fatalf("update video: %v", err)
|
||||
}
|
||||
|
||||
gen := &fakeTeaserGenerator{}
|
||||
drv := &previewFakeDrive{}
|
||||
worker := NewWorker(gen, cat, drv)
|
||||
|
||||
worker.process(ctx, video)
|
||||
|
||||
got, err := cat.GetVideo(ctx, video.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get video: %v", err)
|
||||
}
|
||||
if got.PreviewStatus != previewStatusSkipped {
|
||||
t.Fatalf("preview status = %q, want skipped", got.PreviewStatus)
|
||||
}
|
||||
if got.PreviewLocal != "" {
|
||||
t.Fatalf("preview local = %q, want empty", got.PreviewLocal)
|
||||
}
|
||||
if drv.streamCalls != 0 {
|
||||
t.Fatalf("stream calls = %d, want 0", drv.streamCalls)
|
||||
}
|
||||
if gen.generateCalls != 0 {
|
||||
t.Fatalf("generate calls = %d, want 0", gen.generateCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewWorkerGeneratesTeaserAtFiveGiBBoundary(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cat, video := seedPreviewTestVideo(t, "preview-five-gib-video")
|
||||
video.Size = maxPreviewTeaserSizeBytes
|
||||
video.Size = 6 * 1024 * 1024 * 1024
|
||||
if err := cat.UpsertVideo(ctx, video); err != nil {
|
||||
t.Fatalf("update video: %v", err)
|
||||
}
|
||||
@@ -485,9 +453,9 @@ func TestThumbWorkerRateLimitHonorsRetryAfter(t *testing.T) {
|
||||
assertCooldownAround(t, worker.Status().CooldownUntil, before, 2*time.Hour)
|
||||
}
|
||||
|
||||
func TestThumbWorkerP115TransientErrorFailsAfterRetryLimit(t *testing.T) {
|
||||
func TestThumbWorkerP115MessageOnlyErrorFailsWithoutCooldown(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cat, video := seedPreviewTestVideo(t, "thumb-p115-transient")
|
||||
cat, video := seedPreviewTestVideo(t, "thumb-p115-message-only")
|
||||
|
||||
gen := &fakeThumbGenerator{
|
||||
generateErr: errors.New("ffmpeg thumb: exit status 183, stderr: partial file Cannot determine format of input 0:0 after EOF"),
|
||||
@@ -495,69 +463,26 @@ func TestThumbWorkerP115TransientErrorFailsAfterRetryLimit(t *testing.T) {
|
||||
drv := &previewFakeDrive{kind: "p115"}
|
||||
worker := NewThumbWorker(gen, cat, drv)
|
||||
|
||||
for attempt := 1; attempt <= defaultThumbTransientMediaMaxFailures; attempt++ {
|
||||
worker.rateLimit = rateLimitState{}
|
||||
worker.process(ctx, video)
|
||||
|
||||
if attempt < defaultThumbTransientMediaMaxFailures {
|
||||
pending, err := cat.ListVideosByThumbnailStatus(ctx, video.DriveID, "pending", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list pending thumbnails: %v", err)
|
||||
}
|
||||
if len(pending) != 1 || pending[0].ID != video.ID {
|
||||
t.Fatalf("attempt %d pending thumbnails = %#v, want only %s", attempt, pending, video.ID)
|
||||
}
|
||||
missing, err := cat.CountVideosNeedingThumbnail(ctx, video.DriveID)
|
||||
if err != nil {
|
||||
t.Fatalf("count missing thumbnails: %v", err)
|
||||
}
|
||||
if missing != 1 {
|
||||
t.Fatalf("attempt %d missing thumbnails = %d, want 1 before retry limit", attempt, missing)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
failed, err := cat.ListVideosByThumbnailStatus(ctx, video.DriveID, "failed", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list failed thumbnails: %v", err)
|
||||
}
|
||||
if len(failed) != 1 || failed[0].ID != video.ID {
|
||||
t.Fatalf("failed thumbnails = %#v, want only %s", failed, video.ID)
|
||||
}
|
||||
missing, err := cat.CountVideosNeedingThumbnail(ctx, video.DriveID)
|
||||
if err != nil {
|
||||
t.Fatalf("count missing thumbnails: %v", err)
|
||||
}
|
||||
if missing != 0 {
|
||||
t.Fatalf("missing thumbnails = %d, want 0 after retry limit marks failed", missing)
|
||||
}
|
||||
}
|
||||
|
||||
if gen.generateCalls != defaultThumbTransientMediaMaxFailures {
|
||||
t.Fatalf("generate calls = %d, want %d", gen.generateCalls, defaultThumbTransientMediaMaxFailures)
|
||||
}
|
||||
|
||||
if err := cat.UpdateVideoMeta(ctx, video.ID, catalog.VideoMetaPatch{
|
||||
ThumbnailStatus: "pending",
|
||||
ResetThumbnailFailures: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("reset thumbnail status: %v", err)
|
||||
}
|
||||
worker.rateLimit = rateLimitState{}
|
||||
worker.process(ctx, video)
|
||||
|
||||
pending, err := cat.ListVideosByThumbnailStatus(ctx, video.DriveID, "pending", 0)
|
||||
failed, err := cat.ListVideosByThumbnailStatus(ctx, video.DriveID, "failed", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list pending thumbnails after reset: %v", err)
|
||||
t.Fatalf("list failed thumbnails: %v", err)
|
||||
}
|
||||
if len(pending) != 1 || pending[0].ID != video.ID {
|
||||
t.Fatalf("pending thumbnails after reset = %#v, want only %s", pending, video.ID)
|
||||
if len(failed) != 1 || failed[0].ID != video.ID {
|
||||
t.Fatalf("failed thumbnails = %#v, want only %s", failed, video.ID)
|
||||
}
|
||||
if !worker.Status().CooldownUntil.IsZero() {
|
||||
t.Fatalf("cooldown until = %s, want no cooldown for message-only media error", worker.Status().CooldownUntil)
|
||||
}
|
||||
if gen.generateCalls != 1 {
|
||||
t.Fatalf("generate calls = %d, want 1", gen.generateCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThumbWorkerRequeuesP115TransientErrorBeforeRetryLimit(t *testing.T) {
|
||||
func TestThumbWorkerDoesNotRequeueP115MessageOnlyError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cat, video := seedPreviewTestVideo(t, "thumb-p115-requeue")
|
||||
cat, video := seedPreviewTestVideo(t, "thumb-p115-no-requeue")
|
||||
|
||||
gen := &fakeThumbGenerator{
|
||||
generateErr: errors.New("ffmpeg thumb: partial file Cannot determine format of input 0:0 after EOF"),
|
||||
@@ -569,11 +494,8 @@ func TestThumbWorkerRequeuesP115TransientErrorBeforeRetryLimit(t *testing.T) {
|
||||
|
||||
select {
|
||||
case queued := <-worker.ch:
|
||||
if queued.ID != video.ID {
|
||||
t.Fatalf("requeued video id = %q, want %q", queued.ID, video.ID)
|
||||
}
|
||||
t.Fatalf("unexpected requeued video id = %q", queued.ID)
|
||||
default:
|
||||
t.Fatal("expected transient thumbnail failure to requeue the same video")
|
||||
}
|
||||
|
||||
got, err := cat.GetVideo(ctx, video.ID)
|
||||
@@ -581,14 +503,14 @@ func TestThumbWorkerRequeuesP115TransientErrorBeforeRetryLimit(t *testing.T) {
|
||||
t.Fatalf("get video: %v", err)
|
||||
}
|
||||
if got.ThumbnailURL != "" {
|
||||
t.Fatalf("thumbnail = %q, want empty after transient failure", got.ThumbnailURL)
|
||||
t.Fatalf("thumbnail = %q, want empty after message-only failure", got.ThumbnailURL)
|
||||
}
|
||||
pending, err := cat.ListVideosByThumbnailStatus(ctx, video.DriveID, "pending", 0)
|
||||
failed, err := cat.ListVideosByThumbnailStatus(ctx, video.DriveID, "failed", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list pending thumbnails: %v", err)
|
||||
t.Fatalf("list failed thumbnails: %v", err)
|
||||
}
|
||||
if len(pending) != 1 || pending[0].ID != video.ID {
|
||||
t.Fatalf("pending thumbnails = %#v, want only %s", pending, video.ID)
|
||||
if len(failed) != 1 || failed[0].ID != video.ID {
|
||||
t.Fatalf("failed thumbnails = %#v, want only %s", failed, video.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -649,13 +571,15 @@ func TestP123TransientErrorsShouldCooldown(t *testing.T) {
|
||||
drv := &previewFakeDrive{kind: "p123"}
|
||||
for _, err := range []error{
|
||||
errors.New("Server returned 403 Forbidden"),
|
||||
errors.New("请求太频繁"),
|
||||
errors.New("http 503 service unavailable"),
|
||||
} {
|
||||
if !driveErrorShouldCooldown(drv, err) {
|
||||
t.Fatalf("driveErrorShouldCooldown(%v) = false, want true", err)
|
||||
}
|
||||
}
|
||||
if driveErrorShouldCooldown(drv, errors.New("请求太频繁")) {
|
||||
t.Fatal("message-only throttling text should not trigger p123 cooldown")
|
||||
}
|
||||
if driveErrorShouldCooldown(drv, errors.New("invalid credential")) {
|
||||
t.Fatal("invalid credential should not trigger p123 cooldown")
|
||||
}
|
||||
@@ -666,31 +590,58 @@ func TestWopanTransientErrorsShouldCooldown(t *testing.T) {
|
||||
for _, err := range []error{
|
||||
errors.New("ffmpeg: Server returned 403 Forbidden"),
|
||||
errors.New("wopan download url: request failed with status: 429 Too Many Requests"),
|
||||
errors.New("操作频繁,请稍后重试"),
|
||||
errors.New("http 503 service unavailable"),
|
||||
} {
|
||||
if !driveErrorShouldCooldown(drv, err) {
|
||||
t.Fatalf("driveErrorShouldCooldown(%v) = false, want true", err)
|
||||
}
|
||||
}
|
||||
if driveErrorShouldCooldown(drv, errors.New("操作频繁,请稍后重试")) {
|
||||
t.Fatal("message-only throttling text should not trigger wopan cooldown")
|
||||
}
|
||||
if driveErrorShouldCooldown(drv, errors.New("invalid access token")) {
|
||||
t.Fatal("invalid access token should not trigger wopan cooldown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuangYaPanTransientErrorsShouldCooldown(t *testing.T) {
|
||||
drv := &previewFakeDrive{kind: "guangyapan"}
|
||||
for _, err := range []error{
|
||||
errors.New("ffmpeg: Server returned 403 Forbidden"),
|
||||
errors.New("guangyapan api rate limited: status=429 msg=操作频繁,请稍后重试"),
|
||||
errors.New("http 503 service unavailable"),
|
||||
} {
|
||||
if !driveErrorShouldCooldown(drv, err) {
|
||||
t.Fatalf("driveErrorShouldCooldown(%v) = false, want true", err)
|
||||
}
|
||||
}
|
||||
if driveErrorShouldCooldown(drv, errors.New("操作频繁,请稍后重试")) {
|
||||
t.Fatal("message-only throttling text should not trigger guangyapan cooldown")
|
||||
}
|
||||
if driveErrorShouldCooldown(drv, errors.New("invalid access token")) {
|
||||
t.Fatal("invalid access token should not trigger guangyapan cooldown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleDriveMediaErrorsShouldCooldown(t *testing.T) {
|
||||
drv := &previewFakeDrive{kind: "googledrive"}
|
||||
for _, err := range []error{
|
||||
errors.New("google drive api error: usageLimits userRateLimitExceeded"),
|
||||
errors.New("ffmpeg: Server returned 403 Forbidden"),
|
||||
errors.New("downloadQuotaExceeded: The download quota for this file has been exceeded"),
|
||||
errors.New("sharingRateLimitExceeded"),
|
||||
errors.New("http 503 service unavailable"),
|
||||
} {
|
||||
if !driveErrorShouldCooldown(drv, err) {
|
||||
t.Fatalf("driveErrorShouldCooldown(%v) = false, want true", err)
|
||||
}
|
||||
}
|
||||
for _, err := range []error{
|
||||
errors.New("google drive api error: usageLimits userRateLimitExceeded"),
|
||||
errors.New("downloadQuotaExceeded: The download quota for this file has been exceeded"),
|
||||
errors.New("sharingRateLimitExceeded"),
|
||||
} {
|
||||
if driveErrorShouldCooldown(drv, err) {
|
||||
t.Fatalf("message-only google drive error %v should not trigger cooldown", err)
|
||||
}
|
||||
}
|
||||
if driveErrorShouldCooldown(drv, errors.New("invalid credentials")) {
|
||||
t.Fatal("invalid credentials should not trigger googledrive cooldown")
|
||||
}
|
||||
|
||||
@@ -151,13 +151,15 @@ func (p *Proxy) ServeStream(w http.ResponseWriter, r *http.Request, driveID, fil
|
||||
// 先解出最终 Location,浏览器可直接 302 到该短期地址
|
||||
// - wopan:联通网盘 GetDownloadUrlV2 返回的是短期直链,OpenList 也是直接
|
||||
// 将该 URL 交给客户端使用;不需要后端持续代传视频字节
|
||||
// - guangyapan:光鸭 get_res_download_url 返回 signedURL / downloadUrl,
|
||||
// 浏览器可直接访问,不需要后端持续代传视频字节
|
||||
//
|
||||
// 其余网盘(如夸克等)仍走反代,因为它们的下载
|
||||
// 链接通常需要随请求带上后端持有的 Cookie / Authorization / Range
|
||||
// 的特殊处理,浏览器拿不到这些上下文。
|
||||
func shouldRedirect(d drives.Drive) bool {
|
||||
switch d.Kind() {
|
||||
case "p115", "pikpak", "onedrive", "p123", "wopan":
|
||||
case "p115", "pikpak", "onedrive", "p123", "wopan", "guangyapan":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -226,6 +226,31 @@ func TestServeStreamRedirectsWopan(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeStreamRedirectsGuangYaPan(t *testing.T) {
|
||||
reg := NewRegistry()
|
||||
drv := &proxyFakeSimpleDrive{
|
||||
kind: "guangyapan",
|
||||
url: "https://cdn.guangyapan.example/video.mp4?sign=encoded",
|
||||
}
|
||||
reg.Set("guangyapan", drv)
|
||||
|
||||
p := New(reg)
|
||||
req := httptest.NewRequest(http.MethodGet, "/p/stream/guangyapan/file-1", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
p.ServeStream(rr, req, "guangyapan", "file-1")
|
||||
|
||||
if rr.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, want %d", rr.Code, http.StatusFound)
|
||||
}
|
||||
if got := rr.Header().Get("Location"); got != "https://cdn.guangyapan.example/video.mp4?sign=encoded" {
|
||||
t.Fatalf("Location = %q", got)
|
||||
}
|
||||
if drv.calls != 1 {
|
||||
t.Fatalf("link calls = %d, want 1", drv.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeStreamServesLocalFilePath(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "video.mp4")
|
||||
if err := os.WriteFile(path, []byte("0123456789"), 0o644); err != nil {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package spider91migrate 周期性把 spider91 drive 下载到本地的视频
|
||||
// 上传到一个指定的目标 drive 目录(PikPak、115、123、OneDrive、Google Drive 或联通网盘),上传成功后:
|
||||
// 上传到一个指定的目标 drive 目录(PikPak、115、123、OneDrive、Google Drive、联通网盘或光鸭网盘),上传成功后:
|
||||
//
|
||||
// - 改写 catalog 行:drive_id / file_id / content_hash 改成目标盘的;
|
||||
// 视频自身的 id 不变(仍是 spider91-<driveID>-<viewkey>),video_tags、
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
"github.com/video-site/backend/internal/catalog"
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
"github.com/video-site/backend/internal/drives/googledrive"
|
||||
"github.com/video-site/backend/internal/drives/guangyapan"
|
||||
"github.com/video-site/backend/internal/drives/onedrive"
|
||||
"github.com/video-site/backend/internal/drives/p115"
|
||||
"github.com/video-site/backend/internal/drives/p123"
|
||||
@@ -42,7 +43,7 @@ import (
|
||||
)
|
||||
|
||||
// uploadTarget 是 migrator 调用目标 drive 的最小接口。任何一种"接收 spider91 上传"的
|
||||
// 网盘都要实现它;当前 PikPak、115、123、OneDrive、Google Drive 和联通网盘各自通过适配器满足。
|
||||
// 网盘都要实现它;当前 PikPak、115、123、OneDrive、Google Drive、联通网盘和光鸭网盘各自通过适配器满足。
|
||||
//
|
||||
// 这一层抽象把"迁移调用方"和"具体盘的 SDK 协议"解耦:
|
||||
// - PikPak 走 GCID + OSS PutObject(pikpak.UploadResult)
|
||||
@@ -51,6 +52,7 @@ import (
|
||||
// - OneDrive 走 SHA1 + 小文件 PUT / 大文件 upload session
|
||||
// - Google Drive 走 MD5 + resumable upload session
|
||||
// - 联通网盘 走 SDK Upload2C,当前上游不返回内容 hash
|
||||
// - 光鸭网盘 走 OSS 分片上传,当前上游不返回内容 hash
|
||||
//
|
||||
// 各家返回值都被归一成本地的 UploadResult,并在 catalog 改写阶段统一处理。
|
||||
type uploadTarget interface {
|
||||
@@ -76,7 +78,7 @@ type Spider91LocalSource interface {
|
||||
// UploadResult 是 uploadTarget.UploadAndReportHash 的归一返回。
|
||||
//
|
||||
// FileID 目标盘上的新文件 ID;
|
||||
// Hash GCID(PikPak)、MD5 HEX(123 / Google Drive)或 SHA1 HEX(115 / OneDrive),写入 catalog.content_hash 用于跨盘去重;联通网盘暂为空;
|
||||
// Hash GCID(PikPak)、MD5 HEX(123 / Google Drive)或 SHA1 HEX(115 / OneDrive),写入 catalog.content_hash 用于跨盘去重;联通网盘和光鸭网盘暂为空;
|
||||
// Size 实际上传字节数。
|
||||
type UploadResult struct {
|
||||
FileID string
|
||||
@@ -99,18 +101,19 @@ const (
|
||||
)
|
||||
|
||||
type migrationPlan struct {
|
||||
source Spider91LocalSource
|
||||
row *catalog.Drive
|
||||
sourceKinds []string
|
||||
targetDriveID string
|
||||
target uploadTarget
|
||||
uploadDir string
|
||||
keepLatestN int
|
||||
requireAssetsReady bool
|
||||
legacyBackfill bool
|
||||
source Spider91LocalSource
|
||||
row *catalog.Drive
|
||||
sourceKinds []string
|
||||
targetDriveID string
|
||||
target uploadTarget
|
||||
uploadDir string
|
||||
keepLatestN int
|
||||
requireAssetsReady bool
|
||||
requirePreviewReady bool
|
||||
legacyBackfill bool
|
||||
}
|
||||
|
||||
// pikpakAdapter / p115Adapter / p123Adapter / onedriveAdapter / googledriveAdapter / wopanAdapter 把具体 driver 包装成 uploadTarget。
|
||||
// pikpakAdapter / p115Adapter / p123Adapter / onedriveAdapter / googledriveAdapter / wopanAdapter / guangyapanAdapter 把具体 driver 包装成 uploadTarget。
|
||||
//
|
||||
// 之所以不让 driver 直接实现 uploadTarget:
|
||||
//
|
||||
@@ -243,6 +246,27 @@ func (a *wopanAdapter) Rename(ctx context.Context, fileID, newName string) error
|
||||
return a.d.Rename(ctx, fileID, newName)
|
||||
}
|
||||
|
||||
type guangyapanAdapter struct {
|
||||
d *guangyapan.Driver
|
||||
}
|
||||
|
||||
func (a *guangyapanAdapter) ID() string { return a.d.ID() }
|
||||
func (a *guangyapanAdapter) Kind() string { return a.d.Kind() }
|
||||
func (a *guangyapanAdapter) RootID() string { return a.d.RootID() }
|
||||
func (a *guangyapanAdapter) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) {
|
||||
return a.d.EnsureDir(ctx, pathFromRoot)
|
||||
}
|
||||
func (a *guangyapanAdapter) UploadAndReportHash(ctx context.Context, parentID, name string, r io.Reader, size int64) (UploadResult, error) {
|
||||
fileID, err := a.d.Upload(ctx, parentID, name, r, size)
|
||||
if err != nil {
|
||||
return UploadResult{}, err
|
||||
}
|
||||
return UploadResult{FileID: fileID, Size: size}, nil
|
||||
}
|
||||
func (a *guangyapanAdapter) Rename(ctx context.Context, fileID, newName string) error {
|
||||
return a.d.Rename(ctx, fileID, newName)
|
||||
}
|
||||
|
||||
// adaptUploadTarget 把通用 drive 包装成 uploadTarget。
|
||||
// 不支持的盘 kind 返回 error;调用方静默跳过。
|
||||
func adaptUploadTarget(d drives.Drive) (uploadTarget, error) {
|
||||
@@ -259,6 +283,8 @@ func adaptUploadTarget(d drives.Drive) (uploadTarget, error) {
|
||||
return &googledriveAdapter{d: v}, nil
|
||||
case *wopan.Driver:
|
||||
return &wopanAdapter{d: v}, nil
|
||||
case *guangyapan.Driver:
|
||||
return &guangyapanAdapter{d: v}, nil
|
||||
case uploadTarget:
|
||||
// 测试或自定义实现可以直接传入;优先使用具体类型分支以拿到适配器。
|
||||
return v, nil
|
||||
@@ -572,14 +598,15 @@ func (m *Migrator) migrationPlans(ctx context.Context) []migrationPlan {
|
||||
continue
|
||||
}
|
||||
out = append(out, migrationPlan{
|
||||
source: src,
|
||||
row: row,
|
||||
sourceKinds: crawlerSourceKindsForRow(row),
|
||||
targetDriveID: resolvedID,
|
||||
target: target,
|
||||
uploadDir: scriptCrawlerUploadDir(row.ID),
|
||||
keepLatestN: 0,
|
||||
requireAssetsReady: true,
|
||||
source: src,
|
||||
row: row,
|
||||
sourceKinds: crawlerSourceKindsForRow(row),
|
||||
targetDriveID: resolvedID,
|
||||
target: target,
|
||||
uploadDir: scriptCrawlerUploadDir(row.ID),
|
||||
keepLatestN: 0,
|
||||
requireAssetsReady: true,
|
||||
requirePreviewReady: row.TeaserEnabled,
|
||||
})
|
||||
case spider91.Kind:
|
||||
if m.cfg.GetTargetDriveID == nil {
|
||||
@@ -813,7 +840,7 @@ func (m *Migrator) migrateDrive(ctx context.Context, plan migrationPlan) (int, e
|
||||
}
|
||||
|
||||
if plan.requireAssetsReady {
|
||||
ready, err := m.crawlerVideoAssetsReady(ctx, v)
|
||||
ready, err := m.crawlerVideoAssetsReady(ctx, v, plan.requirePreviewReady)
|
||||
if err != nil {
|
||||
log.Printf("[spider91migrate] %s check generated assets: %v", v.ID, err)
|
||||
continue
|
||||
@@ -889,7 +916,7 @@ func (m *Migrator) findVideoForLocalFile(ctx context.Context, plan migrationPlan
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Migrator) crawlerVideoAssetsReady(ctx context.Context, v *catalog.Video) (bool, error) {
|
||||
func (m *Migrator) crawlerVideoAssetsReady(ctx context.Context, v *catalog.Video, requirePreview bool) (bool, error) {
|
||||
if v == nil {
|
||||
return false, nil
|
||||
}
|
||||
@@ -897,6 +924,9 @@ func (m *Migrator) crawlerVideoAssetsReady(ctx context.Context, v *catalog.Video
|
||||
if !fingerprintReady {
|
||||
return false, nil
|
||||
}
|
||||
if !requirePreview {
|
||||
return true, nil
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(v.PreviewStatus), "ready") {
|
||||
return true, nil
|
||||
}
|
||||
@@ -1183,7 +1213,7 @@ func (m *Migrator) cleanupOldLocalVideos(ctx context.Context, plan migrationPlan
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// backfillFileNames 扫描目标 drive(PikPak、115、123、OneDrive、Google Drive 或联通网盘)下所有 spider91-* 起始 ID 的视频,
|
||||
// backfillFileNames 扫描目标 drive(PikPak、115、123、OneDrive、Google Drive、联通网盘或光鸭网盘)下所有 spider91-* 起始 ID 的视频,
|
||||
// 对文件名不是 desiredPikPakName(...) 期望格式的,调 target.Rename 修正,
|
||||
// 并把 catalog.file_name 同步到新名字。
|
||||
//
|
||||
|
||||
@@ -365,11 +365,19 @@ func seedScriptCrawlerDrive(t *testing.T, cat *catalog.Catalog, d *scriptcrawler
|
||||
"script_path": "/tmp/crawler.py",
|
||||
"upload_drive_id": uploadDriveID,
|
||||
},
|
||||
TeaserEnabled: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed scriptcrawler drive: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func setScriptCrawlerTeaserEnabled(t *testing.T, cat *catalog.Catalog, driveID string, enabled bool) {
|
||||
t.Helper()
|
||||
if err := cat.SetDriveTeaserEnabled(context.Background(), driveID, enabled); err != nil {
|
||||
t.Fatalf("set scriptcrawler teaser enabled: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeScriptCrawlerVideo(t *testing.T, cat *catalog.Catalog, d *scriptcrawler.Driver, sourceID, ext string, content []byte, readyAssets bool) string {
|
||||
t.Helper()
|
||||
fileID := sourceID + ext
|
||||
@@ -587,6 +595,47 @@ func TestRunOnceSkipsScriptCrawlerVideoUntilPreviewAndFingerprintReady(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunOnceMigratesScriptCrawlerVideoWithoutPreviewWhenTeaserDisabled(t *testing.T) {
|
||||
cat := setupCatalog(t)
|
||||
src := setupScriptCrawler(t, "crawler-no-preview")
|
||||
pp := newFakePikPak("pikpak-target", "pikpak-root-id")
|
||||
seedScriptCrawlerDrive(t, cat, src, pp.ID())
|
||||
setScriptCrawlerTeaserEnabled(t, cat, src.ID(), false)
|
||||
|
||||
reg := newFakeRegistry()
|
||||
reg.Add(src)
|
||||
reg.Add(pp)
|
||||
|
||||
id := writeScriptCrawlerVideo(t, cat, src, "fingerprint-ready", ".mp4", []byte("script video bytes"), false)
|
||||
if err := cat.UpdateVideoFingerprint(context.Background(), id, "sampled-fingerprint-ready", "ready", ""); err != nil {
|
||||
t.Fatalf("mark fingerprint ready: %v", err)
|
||||
}
|
||||
if err := cat.UpdatePreview(context.Background(), id, "", "disabled"); err != nil {
|
||||
t.Fatalf("mark preview disabled: %v", err)
|
||||
}
|
||||
|
||||
m := New(Config{Catalog: cat, Registry: reg})
|
||||
m.runOnce(context.Background())
|
||||
|
||||
if pp.uploadCalls != 1 {
|
||||
t.Fatalf("upload calls = %d, want 1 when preview generation is disabled", pp.uploadCalls)
|
||||
}
|
||||
got, err := cat.GetVideo(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("get migrated video: %v", err)
|
||||
}
|
||||
if got.DriveID != pp.ID() {
|
||||
t.Fatalf("drive_id = %q, want %q", got.DriveID, pp.ID())
|
||||
}
|
||||
if got.PreviewStatus != "disabled" || got.FingerprintStatus != "ready" || got.SampledSHA256 == "" {
|
||||
t.Fatalf("asset status after migration = preview %q fingerprint %q sampled %q, want disabled/ready/non-empty", got.PreviewStatus, got.FingerprintStatus, got.SampledSHA256)
|
||||
}
|
||||
videoPath, _ := src.VideoPath("fingerprint-ready.mp4")
|
||||
if _, err := os.Stat(videoPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("local scriptcrawler video still exists or stat error %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunOnceBindsScriptCrawlerDuplicateToExistingTargetWithoutUpload(t *testing.T) {
|
||||
cat := setupCatalog(t)
|
||||
src := setupScriptCrawler(t, "crawler-duplicate")
|
||||
@@ -1464,7 +1513,7 @@ func TestAdaptUploadTargetSupportsWopanDriver(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveTargetRejectsUnsupportedKind 验证当目标 drive 既不是 PikPak、115、123、OneDrive、Google Drive 也不是联通网盘时,
|
||||
// TestResolveTargetRejectsUnsupportedKind 验证当目标 drive 既不是 PikPak、115、123、OneDrive、Google Drive、联通网盘也不是光鸭网盘时,
|
||||
// resolveTarget 拒绝并返回 error,让 runOnce 静默跳过(不会做破坏性变更)。
|
||||
func TestResolveTargetRejectsUnsupportedKind(t *testing.T) {
|
||||
cat := setupCatalog(t)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
光鸭网盘 - 扫码登录脚本
|
||||
========================
|
||||
1. 调用 API 获取登录二维码
|
||||
2. 保存二维码图片,等待用户扫描
|
||||
3. 扫描成功后保存用户凭证信息
|
||||
"""
|
||||
|
||||
import io
|
||||
import sys
|
||||
|
||||
# 修复 Windows 终端 GBK 编码问题
|
||||
if sys.platform == 'win32':
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
|
||||
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
# ========== 配置 ==========
|
||||
API_ORIGIN = "https://account.guangyapan.com"
|
||||
CLIENT_ID = "aMe-8VSlkrbQXpUR"
|
||||
SCOPE = "user"
|
||||
QR_IMAGE_PATH = "login_qr.png"
|
||||
CREDENTIALS_PATH = "credentials.json"
|
||||
|
||||
# ========== 可选依赖 ==========
|
||||
try:
|
||||
import qrcode
|
||||
HAS_QRCODE = True
|
||||
except ImportError:
|
||||
HAS_QRCODE = False
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
HAS_PIL = True
|
||||
except ImportError:
|
||||
HAS_PIL = False
|
||||
|
||||
|
||||
def generate_qr_image(url: str, path: str):
|
||||
"""生成二维码图片"""
|
||||
if HAS_QRCODE:
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||||
box_size=10,
|
||||
border=4,
|
||||
)
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
img.save(path)
|
||||
print(f"[✓] 二维码已保存到: {path}")
|
||||
else:
|
||||
# Fallback: 使用 qrencode 命令行工具
|
||||
import subprocess
|
||||
try:
|
||||
subprocess.run(["qrencode", "-o", path, url], check=True)
|
||||
print(f"[✓] 二维码已保存到: {path}")
|
||||
except FileNotFoundError:
|
||||
print("[✗] 需要安装 qrcode 库: pip install qrcode[pil]")
|
||||
print(f"[!] 请手动访问以下链接扫码:")
|
||||
print(f" {url}")
|
||||
return
|
||||
|
||||
# 尝试直接显示二维码到终端
|
||||
try:
|
||||
if HAS_PIL:
|
||||
img = Image.open(path)
|
||||
img.show()
|
||||
print("[✓] 二维码已在图片查看器中打开")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 终端内显示小二维码
|
||||
if HAS_QRCODE:
|
||||
try:
|
||||
qr.print_ascii(invert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
session = requests.Session()
|
||||
session.headers.update({
|
||||
"User-Agent": "GuangYaPan-Login/1.0",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
|
||||
# ====== Step 1: 获取设备码和二维码链接 ======
|
||||
print("=" * 60)
|
||||
print("Step 1: 请求登录二维码...")
|
||||
print("=" * 60)
|
||||
|
||||
device_code_url = f"{API_ORIGIN}/v1/auth/device/code"
|
||||
device_payload = {
|
||||
"client_id": CLIENT_ID,
|
||||
"scope": SCOPE,
|
||||
}
|
||||
|
||||
try:
|
||||
resp = session.post(device_code_url, json=device_payload, timeout=30)
|
||||
resp.raise_for_status()
|
||||
device_data = resp.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"[✗] 请求失败: {e}")
|
||||
if hasattr(e, 'response') and e.response is not None:
|
||||
print(f" 响应内容: {e.response.text[:500]}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"[✓] 设备码获取成功")
|
||||
print(f" device_code: {device_data.get('device_code', 'N/A')[:30]}...")
|
||||
print(f" interval: {device_data.get('interval', 5)} 秒")
|
||||
print(f" expires_in: {device_data.get('expires_in', 'N/A')} 秒")
|
||||
|
||||
device_code = device_data["device_code"]
|
||||
interval = int(device_data.get("interval", 5))
|
||||
expires_in = int(device_data.get("expires_in", 300))
|
||||
|
||||
# 二维码链接
|
||||
qr_url = device_data.get("verification_uri_complete") or device_data.get("short_uri_complete")
|
||||
if not qr_url:
|
||||
print("[✗] 响应中没有找到二维码链接")
|
||||
print(f" 完整响应: {json.dumps(device_data, indent=2, ensure_ascii=False)}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f" qr_url: {qr_url}")
|
||||
print()
|
||||
|
||||
# ====== Step 2: 生成并保存二维码 ======
|
||||
print("=" * 60)
|
||||
print("Step 2: 生成二维码图片...")
|
||||
print("=" * 60)
|
||||
|
||||
generate_qr_image(qr_url, QR_IMAGE_PATH)
|
||||
|
||||
print()
|
||||
print("!" * 60)
|
||||
print("! 请使用「光鸭APP」扫描二维码登录")
|
||||
print("!" * 60)
|
||||
print()
|
||||
|
||||
# ====== Step 3: 轮询等待用户扫描 ======
|
||||
print("=" * 60)
|
||||
print("Step 3: 等待扫码授权...")
|
||||
print("=" * 60)
|
||||
|
||||
token_url = f"{API_ORIGIN}/v1/auth/token"
|
||||
token_payload = {
|
||||
"client_id": CLIENT_ID,
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||||
"device_code": device_code,
|
||||
}
|
||||
|
||||
start_time = time.time()
|
||||
attempt = 0
|
||||
|
||||
while True:
|
||||
attempt += 1
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# 检查是否超时
|
||||
if elapsed > expires_in:
|
||||
print(f"\n[✗] 二维码已过期({expires_in}秒),请重新运行脚本")
|
||||
sys.exit(1)
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
try:
|
||||
resp = session.post(token_url, json=token_payload, timeout=30)
|
||||
token_data = resp.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"\n[!] 网络错误: {e},重试中...")
|
||||
continue
|
||||
|
||||
if "error" in token_data:
|
||||
error = token_data["error"]
|
||||
if error in ("authorization_pending", "slow_down"):
|
||||
# 用户还未扫描或确认
|
||||
dots = "." * ((attempt % 10) + 1)
|
||||
print(f"\r 等待中{dots:<10} ({int(elapsed)}s / {expires_in}s)", end="", flush=True)
|
||||
|
||||
if error == "slow_down":
|
||||
interval = min(interval * 2, 60)
|
||||
continue
|
||||
|
||||
elif error == "expired_token":
|
||||
print(f"\n[✗] 二维码已过期,请重新运行脚本")
|
||||
sys.exit(1)
|
||||
|
||||
elif error == "access_denied":
|
||||
print(f"\n[✗] 用户拒绝了授权")
|
||||
sys.exit(1)
|
||||
|
||||
else:
|
||||
print(f"\n[✗] 未知错误: {error}")
|
||||
print(f" 完整响应: {json.dumps(token_data, indent=2, ensure_ascii=False)}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
# 成功!
|
||||
print(f"\n[✓] 扫码授权成功!({int(elapsed)}s)")
|
||||
break
|
||||
|
||||
# ====== Step 4: 保存凭证 ======
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("Step 4: 保存用户凭证...")
|
||||
print("=" * 60)
|
||||
|
||||
# 保存完整 token 响应
|
||||
credentials = {
|
||||
"saved_at": datetime.now().isoformat(),
|
||||
"api_origin": API_ORIGIN,
|
||||
"client_id": CLIENT_ID,
|
||||
"token_response": token_data,
|
||||
"cookies": dict(session.cookies),
|
||||
}
|
||||
|
||||
with open(CREDENTIALS_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(credentials, f, indent=2, ensure_ascii=False)
|
||||
print(f"[✓] 完整凭证已保存到: {CREDENTIALS_PATH}")
|
||||
|
||||
# 提取关键信息
|
||||
access_token = token_data.get("access_token", "")
|
||||
refresh_token = token_data.get("refresh_token", "")
|
||||
id_token = token_data.get("id_token", "")
|
||||
token_type = token_data.get("token_type", "Bearer")
|
||||
expires_in = token_data.get("expires_in", 0)
|
||||
|
||||
print()
|
||||
print("-" * 60)
|
||||
print("凭证摘要:")
|
||||
print("-" * 60)
|
||||
print(f" access_token: {access_token[:50]}..." if access_token else " access_token: (无)")
|
||||
print(f" refresh_token: {refresh_token[:50]}..." if refresh_token else " refresh_token: (无)")
|
||||
print(f" id_token: {id_token[:50]}..." if id_token else " id_token: (无)")
|
||||
print(f" token_type: {token_type}")
|
||||
print(f" expires_in: {expires_in} 秒")
|
||||
print(f" scope: {token_data.get('scope', SCOPE)}")
|
||||
print("-" * 60)
|
||||
|
||||
# 尝试获取用户信息
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("Step 5: 获取用户信息...")
|
||||
print("=" * 60)
|
||||
|
||||
user_info_url = f"{API_ORIGIN}/v1/user/me"
|
||||
try:
|
||||
user_headers = {
|
||||
"Authorization": f"{token_type} {access_token}",
|
||||
}
|
||||
user_resp = requests.get(user_info_url, headers=user_headers, timeout=15)
|
||||
if user_resp.status_code == 200:
|
||||
user_data = user_resp.json()
|
||||
print("[✓] 用户信息获取成功:")
|
||||
print(json.dumps(user_data, indent=2, ensure_ascii=False))
|
||||
|
||||
# 追加用户信息到凭证文件
|
||||
credentials["user_info"] = user_data
|
||||
with open(CREDENTIALS_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(credentials, f, indent=2, ensure_ascii=False)
|
||||
else:
|
||||
print(f"[!] 获取用户信息返回 {user_resp.status_code}: {user_resp.text[:200]}")
|
||||
except Exception as e:
|
||||
print(f"[!] 获取用户信息失败: {e}")
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("完成!凭证文件: " + CREDENTIALS_PATH)
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+3
-2
@@ -3,7 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/icon.png" />
|
||||
<link rel="apple-touch-icon" href="/icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="description" content="91 视频站" />
|
||||
<title>91</title>
|
||||
@@ -19,7 +20,7 @@
|
||||
(function () {
|
||||
try {
|
||||
var t = localStorage.getItem("video-site:theme");
|
||||
if (t === "pink" || t === "dark") {
|
||||
if (t === "pink" || t === "dark" || t === "sky") {
|
||||
document.documentElement.setAttribute("data-theme", t);
|
||||
} else {
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "video-site",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "video-site",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.8",
|
||||
"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.8",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 212 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 864 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 855 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
+66
-61
@@ -1,4 +1,5 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { SkyStarfield } from "@/components/SkyStarfield";
|
||||
import HomePage from "@/pages/HomePage";
|
||||
import ListingPage from "@/pages/ListingPage";
|
||||
import ShortsPage from "@/pages/ShortsPage";
|
||||
@@ -15,69 +16,73 @@ import { ThemePage } from "@/admin/ThemePage";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<>
|
||||
{/* 星空蓝主题的固定位置星星层,仅在 data-theme="sky" 下可见 */}
|
||||
<SkyStarfield />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* 主站需要登录 */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<HomePage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/list"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<ListingPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/shorts"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<ShortsPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/upload"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<UploadPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/video/:id"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<VideoDetailPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
{/* 主站需要登录 */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<HomePage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/list"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<ListingPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/shorts"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<ShortsPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/upload"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<UploadPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/video/:id"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<VideoDetailPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 管理后台也需要登录 */}
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AdminLayout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/admin/drives" replace />} />
|
||||
<Route path="drives" element={<DrivesPage />} />
|
||||
<Route path="crawlers" element={<CrawlersPage />} />
|
||||
<Route path="videos" element={<VideosPage />} />
|
||||
<Route path="tags" element={<TagsPage />} />
|
||||
<Route path="theme" element={<ThemePage />} />
|
||||
</Route>
|
||||
{/* 管理后台也需要登录 */}
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AdminLayout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/admin/drives" replace />} />
|
||||
<Route path="drives" element={<DrivesPage />} />
|
||||
<Route path="crawlers" element={<CrawlersPage />} />
|
||||
<Route path="videos" element={<VideosPage />} />
|
||||
<Route path="tags" element={<TagsPage />} />
|
||||
<Route path="theme" element={<ThemePage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
HardDrive,
|
||||
Film,
|
||||
LogOut,
|
||||
Play,
|
||||
Home,
|
||||
Tags,
|
||||
Palette,
|
||||
@@ -71,12 +70,6 @@ export function AdminLayout() {
|
||||
return (
|
||||
<div className="admin-shell">
|
||||
<aside className="admin-sidebar">
|
||||
<div className="admin-sidebar__brand">
|
||||
<span className="admin-sidebar__brand-mark">
|
||||
<Play size={14} fill="#000" />
|
||||
</span>
|
||||
<span className="admin-sidebar__brand-text">91后台</span>
|
||||
</div>
|
||||
<nav className="admin-nav">
|
||||
<div className="admin-nav__group admin-nav__group--home">
|
||||
<span className="admin-nav__group-label">主站</span>
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
Link as LinkIcon,
|
||||
Pencil,
|
||||
Plus,
|
||||
Power,
|
||||
PowerOff,
|
||||
RefreshCw,
|
||||
TestTube,
|
||||
Trash2,
|
||||
@@ -33,7 +35,7 @@ import { SpiderIcon } from "./icons/SpiderIcon";
|
||||
|
||||
const BUSY_STATES = new Set(["scanning", "generating", "uploading", "queued"]);
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
const UPLOAD_TARGET_KINDS = new Set(["p115", "pikpak", "p123", "googledrive", "onedrive", "wopan"]);
|
||||
const UPLOAD_TARGET_KINDS = new Set(["p115", "pikpak", "p123", "googledrive", "onedrive", "wopan", "guangyapan"]);
|
||||
|
||||
function statusBusy(status?: api.DriveGenerationStatus) {
|
||||
return BUSY_STATES.has(status?.state ?? "");
|
||||
@@ -56,6 +58,7 @@ export function CrawlersPage() {
|
||||
const [expandedId, setExpandedId] = useState("");
|
||||
const [runningId, setRunningId] = useState("");
|
||||
const [stoppingId, setStoppingId] = useState("");
|
||||
const [togglingTeaserId, setTogglingTeaserId] = useState("");
|
||||
// undefined = 编辑器关闭;null = 新建;其余 = 编辑已有爬虫
|
||||
const [editorTarget, setEditorTarget] = useState<api.AdminCrawler | null | undefined>(undefined);
|
||||
const [deleteTarget, setDeleteTarget] = useState<api.AdminCrawler | null>(null);
|
||||
@@ -136,6 +139,23 @@ export function CrawlersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTeaser(crawler: api.AdminCrawler) {
|
||||
const next = !crawler.teaserEnabled;
|
||||
setTogglingTeaserId(crawler.id);
|
||||
setList((prev) => prev.map((item) => (item.id === crawler.id ? { ...item, teaserEnabled: next } : item)));
|
||||
try {
|
||||
const resp = await api.setDriveTeaserEnabled(crawler.id, next);
|
||||
setList((prev) => prev.map((item) => (item.id === crawler.id ? { ...item, teaserEnabled: resp.teaserEnabled } : item)));
|
||||
show(resp.teaserEnabled ? `已开启「${crawler.name}」预览视频生成` : `已关闭「${crawler.name}」预览视频生成`, "success");
|
||||
await refresh(true);
|
||||
} catch (e) {
|
||||
setList((prev) => prev.map((item) => (item.id === crawler.id ? { ...item, teaserEnabled: crawler.teaserEnabled } : item)));
|
||||
show(e instanceof Error ? e.message : "切换预览视频失败", "error");
|
||||
} finally {
|
||||
setTogglingTeaserId("");
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
@@ -214,9 +234,11 @@ export function CrawlersPage() {
|
||||
expanded={expandedId === crawler.id}
|
||||
running={runningId === crawler.id}
|
||||
stopping={stoppingId === crawler.id}
|
||||
togglingTeaser={togglingTeaserId === crawler.id}
|
||||
onToggle={() => setExpandedId(expandedId === crawler.id ? "" : crawler.id)}
|
||||
onRun={() => run(crawler)}
|
||||
onStop={() => stop(crawler)}
|
||||
onToggleTeaser={() => toggleTeaser(crawler)}
|
||||
onEdit={() => setEditorTarget(crawler)}
|
||||
onDelete={() => setDeleteTarget(crawler)}
|
||||
/>
|
||||
@@ -290,9 +312,11 @@ function CrawlerRow({
|
||||
expanded,
|
||||
running,
|
||||
stopping,
|
||||
togglingTeaser,
|
||||
onToggle,
|
||||
onRun,
|
||||
onStop,
|
||||
onToggleTeaser,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
@@ -300,9 +324,11 @@ function CrawlerRow({
|
||||
expanded: boolean;
|
||||
running: boolean;
|
||||
stopping: boolean;
|
||||
togglingTeaser: boolean;
|
||||
onToggle: () => void;
|
||||
onRun: () => void;
|
||||
onStop: () => void;
|
||||
onToggleTeaser: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
@@ -343,6 +369,17 @@ function CrawlerRow({
|
||||
<ChevronDown size={16} className="admin-crawler-row__chevron" />
|
||||
</button>
|
||||
<div className="admin-crawler-row__actions">
|
||||
<button
|
||||
className={`admin-btn admin-crawler-preview-card-toggle ${crawler.teaserEnabled ? "is-on" : ""}`}
|
||||
type="button"
|
||||
onClick={onToggleTeaser}
|
||||
disabled={togglingTeaser}
|
||||
aria-pressed={crawler.teaserEnabled}
|
||||
title={crawler.teaserEnabled ? "关闭后,该爬虫新爬取的视频不再生成预览视频" : "开启后,该爬虫新爬取的视频会生成预览视频"}
|
||||
>
|
||||
{crawler.teaserEnabled ? <Power size={13} /> : <PowerOff size={13} />}
|
||||
<span>{crawler.teaserEnabled ? "预览:开" : "预览:关"}</span>
|
||||
</button>
|
||||
{busy ? (
|
||||
<button className="admin-btn is-stop" type="button" onClick={onStop} disabled={stopping}>
|
||||
<CircleStop size={13} /> {stopping ? "停止中..." : "停止"}
|
||||
|
||||
@@ -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);
|
||||
@@ -100,7 +102,8 @@ export function DrivesPage() {
|
||||
d.kind === "p123" ||
|
||||
d.kind === "onedrive" ||
|
||||
d.kind === "googledrive" ||
|
||||
d.kind === "wopan"
|
||||
d.kind === "wopan" ||
|
||||
d.kind === "guangyapan"
|
||||
),
|
||||
[list]
|
||||
);
|
||||
@@ -215,6 +218,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 +504,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]);
|
||||
@@ -592,7 +632,7 @@ export function DrivesPage() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn is-stop"
|
||||
className="admin-btn is-primary"
|
||||
onClick={() => handleStopDriveTasks(d)}
|
||||
disabled={!!stoppingDriveId}
|
||||
title="停止此网盘当前的扫描、封面、预览视频和视频指纹生成任务。"
|
||||
@@ -602,7 +642,7 @@ export function DrivesPage() {
|
||||
</button>
|
||||
</div>
|
||||
{d.kind !== "spider91" && (
|
||||
<button type="button" className="admin-btn" onClick={() => openEdit(d)}>
|
||||
<button type="button" className="admin-btn is-primary" onClick={() => openEdit(d)}>
|
||||
编辑配置凭证
|
||||
</button>
|
||||
)}
|
||||
@@ -634,10 +674,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">
|
||||
|
||||
@@ -79,9 +79,11 @@ export function LoginPage() {
|
||||
return (
|
||||
<div className="admin-login">
|
||||
<form className="admin-login__card" onSubmit={handleSubmit}>
|
||||
<h1 className="admin-login__title">
|
||||
<Play size={18} fill="currentColor" /> {setupRequired ? "首次设置管理员" : "登录"}
|
||||
</h1>
|
||||
{setupRequired && (
|
||||
<h1 className="admin-login__title">
|
||||
<Play size={18} fill="currentColor" /> 首次设置管理员
|
||||
</h1>
|
||||
)}
|
||||
<div className="admin-form">
|
||||
<div className="admin-form__row">
|
||||
<label htmlFor="admin-login-username">用户名</label>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check, Loader2, Moon, Sparkles } from "lucide-react";
|
||||
import { Check, Loader2, Moon, Sparkles, Star } from "lucide-react";
|
||||
import * as api from "./api";
|
||||
import type { Theme } from "./api";
|
||||
import { useToast } from "./ToastContext";
|
||||
import { applyTheme, getCurrentTheme } from "@/lib/theme";
|
||||
|
||||
function isTheme(value: unknown): value is Theme {
|
||||
return value === "dark" || value === "pink";
|
||||
return value === "dark" || value === "pink" || value === "sky";
|
||||
}
|
||||
|
||||
type Option = {
|
||||
@@ -32,6 +32,13 @@ const OPTIONS: Option[] = [
|
||||
description: "柔和奶白底 + 樱花粉主色,清爽温柔,日间使用更舒适。",
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
id: "sky",
|
||||
title: "星空蓝 + 暖星黄",
|
||||
subtitle: "Starry Sky",
|
||||
description: "浅天空蓝底 + 暖星黄主色,配上淡淡的网格与点点星光,顶级美感。",
|
||||
icon: Star,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+586
-179
@@ -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";
|
||||
@@ -9,8 +21,94 @@ import { formatBytes } from "./storageFormat";
|
||||
const DESKTOP_VIDEOS_PAGE_SIZE = 50;
|
||||
const MOBILE_VIDEOS_PAGE_SIZE = 20;
|
||||
const VIDEOS_MOBILE_QUERY = "(max-width: 640px)";
|
||||
const REGEN_PREVIEW_STATUS = "generating";
|
||||
const REGEN_PREVIEW_POLL_INTERVAL_MS = 2000;
|
||||
const REGEN_PREVIEW_TRACK_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
type TabKey = "current" | "blacklist";
|
||||
|
||||
type RegenPreviewState = {
|
||||
expiresAt: number;
|
||||
originalUpdatedAt: number;
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -31,6 +129,7 @@ export function VideosPage() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<api.AdminVideo | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteSource, setDeleteSource] = useState(false);
|
||||
const [regenPreviewById, setRegenPreviewById] = useState<Record<string, RegenPreviewState>>({});
|
||||
const pageSize = useVideosPageSize();
|
||||
const { show } = useToast();
|
||||
|
||||
@@ -57,6 +156,19 @@ export function VideosPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshListOnly() {
|
||||
try {
|
||||
const r = await api.listVideos({ driveId, page, size: pageSize, keyword: searchKeyword });
|
||||
setList(r.items ?? []);
|
||||
setTotal(r.total ?? 0);
|
||||
} catch {
|
||||
// Polling is only used to clear optimistic preview-generation state.
|
||||
}
|
||||
}
|
||||
|
||||
const trackedRegenCount = Object.keys(regenPreviewById).length;
|
||||
const hasGeneratingPreview = list.some((v) => v.previewStatus === REGEN_PREVIEW_STATUS);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [driveId, page, searchKeyword, pageSize]);
|
||||
@@ -74,9 +186,34 @@ export function VideosPage() {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [keyword]);
|
||||
|
||||
const driveNameMap = new Map(
|
||||
drives.map((d) => [d.id, d.name || d.id])
|
||||
);
|
||||
useEffect(() => {
|
||||
if (trackedRegenCount === 0 && !hasGeneratingPreview) return;
|
||||
const timer = window.setInterval(() => {
|
||||
refreshListOnly();
|
||||
}, REGEN_PREVIEW_POLL_INTERVAL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [trackedRegenCount, hasGeneratingPreview, driveId, page, pageSize, searchKeyword]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trackedRegenCount === 0) return;
|
||||
const now = Date.now();
|
||||
setRegenPreviewById((current) => {
|
||||
const next = { ...current };
|
||||
let changed = false;
|
||||
const byId = new Map(list.map((v) => [v.id, v]));
|
||||
for (const [id, state] of Object.entries(current)) {
|
||||
const video = byId.get(id);
|
||||
const updatedAt = videoUpdatedAtMs(video);
|
||||
if (!video || now >= state.expiresAt || updatedAt > state.originalUpdatedAt) {
|
||||
delete next[id];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : current;
|
||||
});
|
||||
}, [list, trackedRegenCount]);
|
||||
|
||||
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));
|
||||
@@ -89,6 +226,7 @@ export function VideosPage() {
|
||||
async function handleRegen(v: api.AdminVideo) {
|
||||
try {
|
||||
await api.regenPreview(v.id);
|
||||
trackRegeneratingPreview([v]);
|
||||
show("已触发预览视频重生", "success");
|
||||
} catch (e) {
|
||||
show(e instanceof Error ? e.message : "触发失败", "error");
|
||||
@@ -108,16 +246,24 @@ export function VideosPage() {
|
||||
|
||||
async function confirmBatchRegen() {
|
||||
const ids = [...selectedIds];
|
||||
const videoById = new Map(listItems.map((v) => [v.id, v]));
|
||||
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)));
|
||||
const acceptedVideos: api.AdminVideo[] = [];
|
||||
results.forEach((r, index) => {
|
||||
if (r.status === "fulfilled") {
|
||||
const video = videoById.get(ids[index]);
|
||||
if (video) acceptedVideos.push(video);
|
||||
success++;
|
||||
}
|
||||
});
|
||||
trackRegeneratingPreview(acceptedVideos);
|
||||
show(
|
||||
`批量触发完成,成功 ${success} / ${ids.length} 个`,
|
||||
success === ids.length ? "success" : "info"
|
||||
);
|
||||
for (const r of results) {
|
||||
if (r.status === "fulfilled") success++;
|
||||
}
|
||||
show(`批量触发完成,成功 ${success} / ${ids.length} 个`, success === ids.length ? "success" : "info");
|
||||
setSelectedIds(new Set());
|
||||
setBatchRegenOpen(false);
|
||||
} finally {
|
||||
@@ -125,6 +271,25 @@ export function VideosPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function trackRegeneratingPreview(videos: api.AdminVideo[]) {
|
||||
if (videos.length === 0) return;
|
||||
const startedAt = Date.now();
|
||||
setRegenPreviewById((current) => {
|
||||
const next = { ...current };
|
||||
for (const v of videos) {
|
||||
next[v.id] = {
|
||||
expiresAt: startedAt + REGEN_PREVIEW_TRACK_TIMEOUT_MS,
|
||||
originalUpdatedAt: videoUpdatedAtMs(v),
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function isPreviewGenerating(v: api.AdminVideo) {
|
||||
return !!regenPreviewById[v.id] || v.previewStatus === REGEN_PREVIEW_STATUS;
|
||||
}
|
||||
|
||||
async function confirmDeleteVideo() {
|
||||
if (!deleteTarget) return;
|
||||
const target = deleteTarget;
|
||||
@@ -139,6 +304,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 +338,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 +361,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 +379,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 +406,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 +425,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,40 +461,20 @@ 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>
|
||||
<td data-label="预览视频">
|
||||
<PreviewStatus s={v.previewStatus} />
|
||||
<PreviewStatus s={isPreviewGenerating(v) ? REGEN_PREVIEW_STATUS : v.previewStatus} />
|
||||
</td>
|
||||
<td data-label="来源" className="admin-mono-cell">
|
||||
{driveNameMap.get(v.driveId) ?? v.driveId}
|
||||
@@ -365,8 +483,14 @@ export function VideosPage() {
|
||||
<button type="button" className="admin-btn" onClick={() => setEditing(v)} title="编辑视频">
|
||||
<Edit size={13} />
|
||||
</button>{" "}
|
||||
<button type="button" className="admin-btn" onClick={() => handleRegen(v)} title="重生预览视频">
|
||||
<RefreshCw size={13} />
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn"
|
||||
onClick={() => handleRegen(v)}
|
||||
disabled={isPreviewGenerating(v)}
|
||||
title={isPreviewGenerating(v) ? "预览视频正在生成" : "重生预览视频"}
|
||||
>
|
||||
<RefreshCw size={13} className={isPreviewGenerating(v) ? "admin-spin" : undefined} />
|
||||
</button>{" "}
|
||||
<button
|
||||
type="button"
|
||||
@@ -384,43 +508,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 +551,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,26 +570,354 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewStatus({ s }: { s: string }) {
|
||||
if (s === REGEN_PREVIEW_STATUS) return <span className="admin-status is-generating">生成中</span>;
|
||||
if (s === "ready") return <span className="admin-status is-ok">就绪</span>;
|
||||
if (s === "failed") return <span className="admin-status is-error">失败</span>;
|
||||
if (s === "disabled") return <span className="admin-status">已关闭</span>;
|
||||
if (s === "skipped") return <span className="admin-status">跳过</span>;
|
||||
return <span className="admin-status is-pending">待生成</span>;
|
||||
}
|
||||
@@ -529,11 +934,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 +946,23 @@ 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 videoUpdatedAtMs(video?: api.AdminVideo): number {
|
||||
if (!video?.updatedAt) return 0;
|
||||
const value = Date.parse(video.updatedAt);
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
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 +1096,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 +1143,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 +1159,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];
|
||||
}
|
||||
|
||||
+107
-5
@@ -78,13 +78,13 @@ export function checkUpdate() {
|
||||
|
||||
export type AdminDrive = {
|
||||
id: string;
|
||||
kind: "quark" | "p115" | "p123" | "pikpak" | "wopan" | "onedrive" | "googledrive" | "localstorage" | "spider91";
|
||||
kind: "quark" | "p115" | "p123" | "pikpak" | "wopan" | "guangyapan" | "onedrive" | "googledrive" | "localstorage" | "spider91";
|
||||
name: string;
|
||||
rootId: string;
|
||||
status: string;
|
||||
lastError?: string;
|
||||
hasCredential: boolean;
|
||||
/** 当前是否给该盘生成预览视频/封面(per-drive 开关,替代旧的全局 preview.enabled)。 */
|
||||
/** 当前是否给该盘生成预览视频(per-drive 开关,替代旧的全局 preview.enabled;封面不受影响)。 */
|
||||
teaserEnabled: boolean;
|
||||
/**
|
||||
* 用户在 admin 配置的"扫描跳过目录"集合(drive 侧目录 fileID 列表)。
|
||||
@@ -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 = {
|
||||
@@ -147,7 +155,7 @@ export function getDriveStorage() {
|
||||
|
||||
export type UpsertDriveInput = {
|
||||
id: string;
|
||||
kind: "quark" | "p115" | "p123" | "pikpak" | "wopan" | "onedrive" | "googledrive" | "localstorage" | "spider91";
|
||||
kind: "quark" | "p115" | "p123" | "pikpak" | "wopan" | "guangyapan" | "onedrive" | "googledrive" | "localstorage" | "spider91";
|
||||
name: string;
|
||||
rootId: string;
|
||||
credentials: Record<string, string>;
|
||||
@@ -204,6 +212,7 @@ export type AdminCrawler = {
|
||||
proxy?: string;
|
||||
targetNew?: string;
|
||||
uploadDriveId?: string;
|
||||
teaserEnabled: boolean;
|
||||
lastCrawlAt?: number;
|
||||
scanGenerationStatus?: DriveGenerationStatus;
|
||||
thumbnailGenerationStatus?: DriveGenerationStatus;
|
||||
@@ -368,6 +377,33 @@ export function getWopanQRStatus(uuid: string) {
|
||||
return request<WopanQRStatus>(`/drives/wopan/qr/${encodeURIComponent(uuid)}`);
|
||||
}
|
||||
|
||||
export type GuangYaPanQRSession = {
|
||||
deviceCode: string;
|
||||
qrCodeUrl: string;
|
||||
qrImageDataUrl: string;
|
||||
intervalSeconds: number;
|
||||
expiresAt?: string;
|
||||
};
|
||||
|
||||
export type GuangYaPanQRStatus = {
|
||||
state: "pending" | "success" | "expired" | "denied" | "error";
|
||||
statusText: string;
|
||||
intervalSeconds?: number;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
tokenType?: string;
|
||||
expiresIn?: number;
|
||||
};
|
||||
|
||||
export function startGuangYaPanQRLogin() {
|
||||
return request<GuangYaPanQRSession>("/drives/guangyapan/qr", { method: "POST" });
|
||||
}
|
||||
|
||||
export function getGuangYaPanQRStatus(deviceCode: string) {
|
||||
const qs = new URLSearchParams({ deviceCode });
|
||||
return request<GuangYaPanQRStatus>(`/drives/guangyapan/qr/status?${qs.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换某个云盘的预览视频生成开关。点击网盘列表里行内的 toggle 按钮时调用。
|
||||
*
|
||||
@@ -449,6 +485,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 +538,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 +550,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;
|
||||
@@ -558,7 +660,7 @@ export function deleteTag(id: number) {
|
||||
|
||||
// ---------- Settings ----------
|
||||
|
||||
export type Theme = "dark" | "pink";
|
||||
export type Theme = "dark" | "pink" | "sky";
|
||||
|
||||
export type Settings = {
|
||||
theme: Theme;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useId, useMemo, useState } from "react";
|
||||
import { ArrowLeft, ChevronDown } from "lucide-react";
|
||||
import { P123QRCodeLogin } from "./P123QRCodeLogin";
|
||||
import { WopanQRCodeLogin } from "./WopanQRCodeLogin";
|
||||
import { GuangYaPanQRCodeLogin } from "./GuangYaPanQRCodeLogin";
|
||||
import { Spider91UploadTargetField } from "./Spider91UploadTargetField";
|
||||
import {
|
||||
FormState,
|
||||
@@ -24,6 +25,7 @@ const DRIVE_OPTIONS: DriveOption[] = [
|
||||
{ kind: "p115", label: "115 网盘", abbr: "115", desc: "302直链,不占带宽" },
|
||||
{ kind: "p123", label: "123网盘", abbr: "123", desc: "扫码登录,302直链" },
|
||||
{ kind: "pikpak", label: "PikPak", abbr: "Pk", desc: "302直链,稳定快速" },
|
||||
{ kind: "guangyapan", label: "光鸭网盘", abbr: "GY", desc: "扫码登录,302直链" },
|
||||
{ kind: "onedrive", label: "OneDrive", abbr: "OD", desc: "302直链,微软网盘" },
|
||||
{ kind: "googledrive", label: "Google Drive", abbr: "GD", desc: "服务器中转模式" },
|
||||
{ kind: "localstorage", label: "本地存储", abbr: "Lo", desc: "本机文件目录" },
|
||||
@@ -194,6 +196,21 @@ export function DriveForm({
|
||||
/>
|
||||
)}
|
||||
|
||||
{form.kind === "guangyapan" && (
|
||||
<GuangYaPanQRCodeLogin
|
||||
onCredentials={(credentials) =>
|
||||
onChange({
|
||||
...form,
|
||||
creds: {
|
||||
...form.creds,
|
||||
access_token: credentials.accessToken,
|
||||
refresh_token: credentials.refreshToken,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fields.map((f) => (
|
||||
<div key={f.key} className="admin-form__row">
|
||||
{f.type === "select" ? (
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { QrCode } from "lucide-react";
|
||||
import * as api from "../api";
|
||||
import { useToast } from "../ToastContext";
|
||||
|
||||
function guangYaPanQRStatusClass(
|
||||
status: api.GuangYaPanQRStatus | null,
|
||||
completed: boolean,
|
||||
error: string
|
||||
): string {
|
||||
if (completed || status?.state === "success") return "is-ok";
|
||||
if (error || status?.state === "expired" || status?.state === "denied" || status?.state === "error")
|
||||
return "is-error";
|
||||
return "is-pending";
|
||||
}
|
||||
|
||||
export function GuangYaPanQRCodeLogin({
|
||||
onCredentials,
|
||||
}: {
|
||||
onCredentials: (credentials: {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}) => void;
|
||||
}) {
|
||||
const { show } = useToast();
|
||||
const [session, setSession] = useState<api.GuangYaPanQRSession | null>(null);
|
||||
const [status, setStatus] = useState<api.GuangYaPanQRStatus | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [pollingError, setPollingError] = useState("");
|
||||
const [completed, setCompleted] = useState(false);
|
||||
|
||||
async function start() {
|
||||
setStarting(true);
|
||||
setPollingError("");
|
||||
setCompleted(false);
|
||||
setStatus(null);
|
||||
try {
|
||||
const next = await api.startGuangYaPanQRLogin();
|
||||
setSession(next);
|
||||
} catch (e) {
|
||||
setSession(null);
|
||||
show(e instanceof Error ? e.message : "生成二维码失败", "error");
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || completed) return;
|
||||
const activeSession = session;
|
||||
let stopped = false;
|
||||
let timer: number | undefined;
|
||||
let delayMs = Math.max(1000, (activeSession.intervalSeconds || 5) * 1000);
|
||||
|
||||
async function poll() {
|
||||
if (stopped) return;
|
||||
try {
|
||||
const next = await api.getGuangYaPanQRStatus(activeSession.deviceCode);
|
||||
if (stopped) return;
|
||||
setStatus(next);
|
||||
setPollingError("");
|
||||
if (next.intervalSeconds && next.intervalSeconds > 0) {
|
||||
delayMs = Math.max(1000, next.intervalSeconds * 1000);
|
||||
}
|
||||
if (next.accessToken && next.refreshToken) {
|
||||
stopped = true;
|
||||
if (timer) window.clearTimeout(timer);
|
||||
setCompleted(true);
|
||||
onCredentials({
|
||||
accessToken: next.accessToken,
|
||||
refreshToken: next.refreshToken,
|
||||
});
|
||||
show("扫码成功,已填入 access_token 和 refresh_token,保存后生效", "success");
|
||||
return;
|
||||
}
|
||||
if (next.state === "expired" || next.state === "denied" || next.state === "error") {
|
||||
stopped = true;
|
||||
if (timer) window.clearTimeout(timer);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
if (stopped) return;
|
||||
setPollingError(e instanceof Error ? e.message : "查询扫码状态失败");
|
||||
}
|
||||
if (!stopped) {
|
||||
timer = window.setTimeout(poll, delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
return () => {
|
||||
stopped = true;
|
||||
if (timer) window.clearTimeout(timer);
|
||||
};
|
||||
}, [session, completed, onCredentials, show]);
|
||||
|
||||
const statusText = completed
|
||||
? "已获取凭证"
|
||||
: pollingError || status?.statusText || (session ? "等待扫码" : "未生成二维码");
|
||||
const statusClass = guangYaPanQRStatusClass(status, completed, pollingError);
|
||||
|
||||
return (
|
||||
<div className="admin-form__row">
|
||||
<label>扫码登录</label>
|
||||
<div className="admin-p123-qr">
|
||||
<div className="admin-p123-qr__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn"
|
||||
onClick={start}
|
||||
disabled={starting}
|
||||
>
|
||||
<QrCode size={14} />
|
||||
{starting ? "生成中..." : session ? "重新生成二维码" : "生成二维码"}
|
||||
</button>
|
||||
<span className={`admin-status ${statusClass}`}>{statusText}</span>
|
||||
</div>
|
||||
|
||||
{session && (
|
||||
<div className="admin-p123-qr__body">
|
||||
<img
|
||||
className="admin-p123-qr__image"
|
||||
src={session.qrImageDataUrl}
|
||||
alt="光鸭网盘扫码登录二维码"
|
||||
/>
|
||||
<div className="admin-p123-qr__meta">
|
||||
<div className="admin-form__help">
|
||||
使用光鸭 App 扫码并确认登录;确认后系统会自动填入 access_token 和 refresh_token。
|
||||
</div>
|
||||
{session.expiresAt && (
|
||||
<div className="admin-form__help">
|
||||
过期时间:{new Date(session.expiresAt).toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{(status?.state === "expired" || status?.state === "denied") && (
|
||||
<div className="admin-form__help">
|
||||
当前二维码{status.state === "denied" ? "已被拒绝" : "已过期"},请重新生成。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export type Kind = "quark" | "p115" | "p123" | "pikpak" | "wopan" | "onedrive" | "googledrive" | "localstorage" | "spider91";
|
||||
export type Kind = "quark" | "p115" | "p123" | "pikpak" | "wopan" | "guangyapan" | "onedrive" | "googledrive" | "localstorage" | "spider91";
|
||||
|
||||
export const kindAbbr: Record<string, string> = {
|
||||
quark: "Qk",
|
||||
@@ -6,6 +6,7 @@ export const kindAbbr: Record<string, string> = {
|
||||
p123: "123",
|
||||
pikpak: "Pk",
|
||||
wopan: "Wo",
|
||||
guangyapan: "GY",
|
||||
onedrive: "OD",
|
||||
googledrive: "GD",
|
||||
localstorage: "Lo",
|
||||
@@ -28,6 +29,7 @@ export const kindLabel: Record<string, string> = {
|
||||
p123: "123网盘",
|
||||
pikpak: "PikPak",
|
||||
wopan: "联通网盘",
|
||||
guangyapan: "光鸭网盘",
|
||||
onedrive: "OneDrive",
|
||||
googledrive: "Google Drive",
|
||||
localstorage: "本地存储",
|
||||
@@ -126,6 +128,7 @@ export function formatClock(value: string): string {
|
||||
|
||||
export function defaultRootId(kind: Kind): string {
|
||||
if (kind === "pikpak") return "";
|
||||
if (kind === "guangyapan") return "";
|
||||
if (kind === "onedrive") return "root";
|
||||
if (kind === "googledrive") return "root";
|
||||
if (kind === "localstorage") return "/";
|
||||
@@ -155,6 +158,8 @@ export function credentialHelp(kind: Kind, isEdit: boolean): string {
|
||||
return `填写 PikPak 账号和密码即可。平台、设备 ID、验证码 token 和 refresh token 会由服务端自动处理并保存。${note}`;
|
||||
case "wopan":
|
||||
return `推荐使用扫码登录自动获取 access_token 和 refresh_token;也可以手工粘贴已有凭证。${note}`;
|
||||
case "guangyapan":
|
||||
return `推荐使用扫码登录自动获取 access_token 和 refresh_token;也可以手工粘贴已有 token。${note}`;
|
||||
case "onedrive":
|
||||
return `按 OpenList 默认应用在线挂载,只需要 refresh_token;保存时会自动刷新并保存 token。${note}`;
|
||||
case "googledrive":
|
||||
@@ -162,7 +167,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:
|
||||
@@ -272,6 +277,29 @@ export function credentialFields(kind: Kind, creds: Record<string, string> = {})
|
||||
placeholder: "留空走个人空间",
|
||||
},
|
||||
];
|
||||
case "guangyapan":
|
||||
return [
|
||||
{
|
||||
key: "root_path",
|
||||
label: "根目录路径(可选)",
|
||||
placeholder: "例如:影视/电影;留空使用上方根目录 ID",
|
||||
help: "如果填写 root_path,服务端会按路径解析光鸭目录,并优先作为扫描根目录。",
|
||||
},
|
||||
{
|
||||
key: "refresh_token",
|
||||
label: "refresh_token",
|
||||
placeholder: "推荐填写,服务端会自动刷新 access_token",
|
||||
multiline: true,
|
||||
help: "扫码成功后会自动填入该字段。",
|
||||
},
|
||||
{
|
||||
key: "access_token",
|
||||
label: "access_token",
|
||||
placeholder: "Bearer eyJ... 或直接粘贴 token",
|
||||
multiline: true,
|
||||
help: "扫码成功后会自动填入该字段;如果 token 过期,重新扫码后保存即可。",
|
||||
},
|
||||
];
|
||||
case "onedrive":
|
||||
return [
|
||||
{
|
||||
@@ -330,6 +358,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 [
|
||||
|
||||
@@ -3,7 +3,6 @@ import { NavLink } from "react-router-dom";
|
||||
import {
|
||||
Film,
|
||||
Menu,
|
||||
Play,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Upload,
|
||||
@@ -25,9 +24,8 @@ export function MainNav() {
|
||||
<div className="container main-nav__inner">
|
||||
<NavLink to="/" className="main-nav__logo">
|
||||
<span className="main-nav__logo-mark">
|
||||
<Play size={16} fill="#000" />
|
||||
<img src="/icon.png" alt="" className="main-nav__logo-img" />
|
||||
</span>
|
||||
<span className="main-nav__logo-text">91</span>
|
||||
</NavLink>
|
||||
|
||||
<ul className="main-nav__list" role="menubar">
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
/**
|
||||
* 星空蓝主题专属:视口级星星贴纸层。
|
||||
*
|
||||
* 用 vip.215.im 那套动画 GIF 贴纸:每个 GIF 自带逐帧闪烁动画,
|
||||
* 比 CSS opacity 呼吸真实得多。桌面和手机分开维护点位,避免首屏密度
|
||||
* 被页面高度拉伸,也避免手机端星星过大。
|
||||
*
|
||||
* - 资源在 public/stickers/star-*.gif,会被打包到 dist/stickers/
|
||||
* - 渲染在 App 根节点,主站和后台都看得到
|
||||
* - data-theme!=="sky" 时 CSS display: none,不占布局
|
||||
* - aria-hidden + pointer-events: none,对可访问性和点击都透明
|
||||
* - 加 / 减 / 调星只动 DESKTOP_STARS / MOBILE_STARS 数组
|
||||
*/
|
||||
|
||||
const STICKERS = [
|
||||
"/stickers/star-gold.gif",
|
||||
"/stickers/star-pink.gif",
|
||||
"/stickers/star-sparkle.gif",
|
||||
"/stickers/star-mini.gif",
|
||||
];
|
||||
|
||||
type StarSpec = {
|
||||
/** 锚点用百分号写,CSS 直接当 top/left/right/bottom 用 */
|
||||
top?: string;
|
||||
bottom?: string;
|
||||
left?: string;
|
||||
right?: string;
|
||||
/** 像素,控制 GIF 渲染尺寸 */
|
||||
size: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 桌面:星星偏四周和顶部,主体阅读区保持干净。
|
||||
* 大星只放边角,小星补顶部和侧边空隙。
|
||||
*/
|
||||
const DESKTOP_STARS: StarSpec[] = [
|
||||
{ top: "6%", left: "5%", size: 44 },
|
||||
{ top: "4%", left: "24%", size: 26 },
|
||||
{ top: "8%", right: "12%", size: 48 },
|
||||
{ top: "17%", right: "31%", size: 30 },
|
||||
{ top: "24%", left: "8%", size: 34 },
|
||||
{ top: "28%", right: "5%", size: 38 },
|
||||
{ top: "43%", left: "3%", size: 24 },
|
||||
{ top: "49%", right: "9%", size: 28 },
|
||||
{ top: "63%", left: "11%", size: 32 },
|
||||
{ top: "66%", right: "18%", size: 44 },
|
||||
{ bottom: "14%", left: "5%", size: 36 },
|
||||
{ bottom: "10%", right: "6%", size: 42 },
|
||||
{ bottom: "4%", left: "33%", size: 24 },
|
||||
{ bottom: "6%", right: "34%", size: 28 },
|
||||
{ top: "13%", left: "52%", size: 22 },
|
||||
{ bottom: "24%", right: "41%", size: 22 },
|
||||
];
|
||||
|
||||
/**
|
||||
* 手机:数量更少、尺寸更小,只做边缘点缀。
|
||||
*/
|
||||
const MOBILE_STARS: StarSpec[] = [
|
||||
{ top: "7%", left: "6%", size: 30 },
|
||||
{ top: "11%", right: "7%", size: 28 },
|
||||
{ top: "24%", right: "3%", size: 22 },
|
||||
{ top: "39%", left: "4%", size: 22 },
|
||||
{ top: "57%", right: "6%", size: 26 },
|
||||
{ bottom: "23%", left: "9%", size: 24 },
|
||||
{ bottom: "12%", right: "12%", size: 30 },
|
||||
{ bottom: "5%", left: "48%", size: 20 },
|
||||
];
|
||||
|
||||
export function SkyStarfield() {
|
||||
return (
|
||||
<div className="sky-starfield" aria-hidden="true">
|
||||
{DESKTOP_STARS.map((s, i) => {
|
||||
const style: CSSProperties = {
|
||||
top: s.top,
|
||||
bottom: s.bottom,
|
||||
left: s.left,
|
||||
right: s.right,
|
||||
width: s.size,
|
||||
height: s.size,
|
||||
};
|
||||
const src = STICKERS[i % STICKERS.length];
|
||||
return (
|
||||
<img
|
||||
key={`desktop-${i}`}
|
||||
className="sky-star sky-star--desktop"
|
||||
src={src}
|
||||
alt=""
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{MOBILE_STARS.map((s, i) => {
|
||||
const style: CSSProperties = {
|
||||
top: s.top,
|
||||
bottom: s.bottom,
|
||||
left: s.left,
|
||||
right: s.right,
|
||||
width: s.size,
|
||||
height: s.size,
|
||||
};
|
||||
const src = STICKERS[(i + 1) % STICKERS.length];
|
||||
return (
|
||||
<img
|
||||
key={`mobile-${i}`}
|
||||
className="sky-star sky-star--mobile"
|
||||
src={src}
|
||||
alt=""
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -295,6 +295,7 @@ function sourceKindFromLabel(label: string): string {
|
||||
if (value.includes("123") || value.includes("p123")) return "p123";
|
||||
if (value.includes("pikpak")) return "pikpak";
|
||||
if (value.includes("沃盘") || value.includes("wopan") || value.includes("联通")) return "wopan";
|
||||
if (value.includes("光鸭") || value.includes("guangyapan") || value.includes("guangya")) return "guangyapan";
|
||||
if (value.includes("onedrive") || value.includes("one drive")) return "onedrive";
|
||||
if (value.includes("本地") || value.includes("localstorage") || value.includes("local storage")) return "localstorage";
|
||||
return "";
|
||||
|
||||
@@ -74,6 +74,8 @@ function sourceKindFromLabel(label: string): string {
|
||||
if (value.includes("pikpak")) return "pikpak";
|
||||
if (value.includes("沃盘") || value.includes("wopan") || value.includes("联通"))
|
||||
return "wopan";
|
||||
if (value.includes("光鸭") || value.includes("guangyapan") || value.includes("guangya"))
|
||||
return "guangyapan";
|
||||
if (value.includes("onedrive") || value.includes("one drive")) return "onedrive";
|
||||
if (value.includes("本地") || value.includes("localstorage") || value.includes("local storage"))
|
||||
return "localstorage";
|
||||
|
||||
@@ -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 = {
|
||||
@@ -92,16 +92,28 @@ const LONG_PRESS_MS = 400;
|
||||
const FAST_RATE = 2;
|
||||
/** 默认倍速。 */
|
||||
const NORMAL_RATE = 1;
|
||||
/** ArtPlayer 内部播放失败自动重连次数。 */
|
||||
const ARTPLAYER_RECONNECT_TIME_MAX = 3;
|
||||
|
||||
Artplayer.FAST_FORWARD_VALUE = FAST_RATE;
|
||||
Artplayer.RECONNECT_TIME_MAX = ARTPLAYER_RECONNECT_TIME_MAX;
|
||||
|
||||
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 +332,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 +345,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 +372,7 @@ function mountArtPlayer({
|
||||
preload: "metadata",
|
||||
playsInline: true,
|
||||
},
|
||||
settings: [createLoopSetting()],
|
||||
controls: enableOrientationControl ? [createOrientationControl()] : [],
|
||||
contextmenu: [],
|
||||
cssVar: {
|
||||
@@ -377,8 +392,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 +430,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 +454,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 +472,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 +499,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 +941,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 +1348,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 +1419,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 +1463,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 }));
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -10,13 +10,13 @@
|
||||
// 公开端点 /api/settings/theme 不需要登录,原因见 backend/internal/api/api.go 中
|
||||
// 的注释——登录页本身就要在用户登录之前正确显示主题。
|
||||
|
||||
export type Theme = "dark" | "pink";
|
||||
export type Theme = "dark" | "pink" | "sky";
|
||||
|
||||
export const THEMES: Theme[] = ["dark", "pink"];
|
||||
export const THEMES: Theme[] = ["dark", "pink", "sky"];
|
||||
const STORAGE_KEY = "video-site:theme";
|
||||
|
||||
function isTheme(value: unknown): value is Theme {
|
||||
return value === "dark" || value === "pink";
|
||||
return value === "dark" || value === "pink" || value === "sky";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+215
-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);
|
||||
@@ -1372,6 +1532,7 @@ function getDriveShortName(source: string): string {
|
||||
if (s.includes("quark") || s.includes("夸克")) return "Quak";
|
||||
if (s.includes("onedrive")) return "OneDrive";
|
||||
if (s.includes("wopan") || s.includes("沃盘")) return "沃盘";
|
||||
if (s.includes("guangyapan") || s.includes("guangya") || s.includes("光鸭")) return "光鸭";
|
||||
if (s.includes("localstorage") || s.includes("本地")) return "本地";
|
||||
if (s.includes("spider") || s.includes("爬虫")) return "爬虫";
|
||||
return source.substring(0, 4);
|
||||
|
||||
@@ -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>
|
||||
|
||||
+250
-18
@@ -32,8 +32,9 @@
|
||||
.admin-sidebar__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 12px var(--space-6);
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
padding: 0 12px var(--space-5);
|
||||
font-size: var(--font-xl);
|
||||
font-weight: var(--weight-bold);
|
||||
color: var(--text-strong);
|
||||
@@ -52,22 +53,29 @@
|
||||
.admin-sidebar__brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent-gradient);
|
||||
color: var(--text-on-accent);
|
||||
box-shadow: 0 4px 14px var(--accent-glow), var(--shadow-inset);
|
||||
background: transparent;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.16);
|
||||
overflow: hidden;
|
||||
animation: admin-brand-pulse 3s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.admin-sidebar__brand-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
@keyframes admin-brand-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 4px 12px var(--accent-glow), var(--shadow-inset);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.14);
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 6px 20px rgba(255, 138, 60, 0.45), var(--shadow-inset);
|
||||
box-shadow: 0 6px 20px var(--accent-glow);
|
||||
transform: scale(1.04);
|
||||
}
|
||||
}
|
||||
@@ -76,8 +84,10 @@
|
||||
.admin-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
padding: var(--space-5) 0;
|
||||
flex: 1;
|
||||
justify-content: space-evenly;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-2) 0 var(--space-4);
|
||||
}
|
||||
|
||||
.admin-nav__group {
|
||||
@@ -758,6 +768,25 @@
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.admin-crawler-preview-card-toggle {
|
||||
min-width: 96px;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.admin-crawler-preview-card-toggle.is-on {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--text-on-accent);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.admin-crawler-preview-card-toggle.is-on:hover:not(:disabled) {
|
||||
border-color: var(--accent-hover);
|
||||
background: var(--accent-hover);
|
||||
color: var(--text-on-accent);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.admin-crawler-row__delete {
|
||||
padding-inline: 10px;
|
||||
}
|
||||
@@ -1819,6 +1848,10 @@
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.admin-table th.is-actions {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.admin-table tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
@@ -1830,7 +1863,7 @@
|
||||
}
|
||||
|
||||
.admin-table td.is-actions {
|
||||
text-align: right;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1865,6 +1898,7 @@
|
||||
.admin-status.is-ok { background: var(--success-soft); color: var(--success); }
|
||||
.admin-status.is-error { background: var(--danger-soft); color: var(--danger); }
|
||||
.admin-status.is-pending { background: var(--warning-soft); color: var(--warning); }
|
||||
.admin-status.is-generating { background: var(--info-soft); color: var(--info); }
|
||||
|
||||
.admin-generation-state.is-generating { background: var(--info-soft); color: var(--info); }
|
||||
.admin-generation-state.is-cooling { background: var(--warning-soft); color: var(--warning); }
|
||||
@@ -3334,12 +3368,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 +3478,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
@@ -3401,9 +3528,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 +3562,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;
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
@@ -3506,6 +3684,7 @@
|
||||
.admin-drive-type-card[data-kind="spider91"]:hover { border-color: var(--accent); box-shadow: 0 4px 18px var(--accent-glow); }
|
||||
.admin-drive-type-card[data-kind="quark"]:hover { border-color: var(--drive-quark); box-shadow: 0 4px 18px rgba(91,141,239,.2); }
|
||||
.admin-drive-type-card[data-kind="wopan"]:hover { border-color: var(--drive-wopan); box-shadow: 0 4px 18px rgba(255,138,60,.2); }
|
||||
.admin-drive-type-card[data-kind="guangyapan"]:hover { border-color: var(--drive-guangyapan); box-shadow: 0 4px 18px rgba(48,195,168,.2); }
|
||||
|
||||
.admin-drive-type-card__icon {
|
||||
display: grid;
|
||||
@@ -3531,6 +3710,7 @@
|
||||
.admin-drive-type-card__icon[data-kind="spider91"] { background: var(--accent-soft); color: var(--accent); }
|
||||
.admin-drive-type-card__icon[data-kind="quark"] { background: rgba(91,141,239,.14); color: var(--drive-quark); }
|
||||
.admin-drive-type-card__icon[data-kind="wopan"] { background: rgba(255,138,60,.14); color: var(--drive-wopan); }
|
||||
.admin-drive-type-card__icon[data-kind="guangyapan"] { background: rgba(48,195,168,.14); color: var(--drive-guangyapan); }
|
||||
|
||||
.admin-drive-type-card__label {
|
||||
font-size: var(--font-sm);
|
||||
@@ -3589,6 +3769,7 @@
|
||||
.admin-drive-selected-bar__icon[data-kind="spider91"] { background: var(--accent-soft); color: var(--accent); }
|
||||
.admin-drive-selected-bar__icon[data-kind="quark"] { background: rgba(91,141,239,.14); color: var(--drive-quark); }
|
||||
.admin-drive-selected-bar__icon[data-kind="wopan"] { background: rgba(255,138,60,.14); color: var(--drive-wopan); }
|
||||
.admin-drive-selected-bar__icon[data-kind="guangyapan"] { background: rgba(48,195,168,.14); color: var(--drive-guangyapan); }
|
||||
|
||||
.admin-drive-selected-bar__text {
|
||||
flex: 1;
|
||||
@@ -3743,12 +3924,16 @@
|
||||
.theme-card[data-preview="dark"] .theme-card__preview {
|
||||
background:
|
||||
radial-gradient(80% 60% at 50% 0%, rgba(255, 138, 60, 0.18), transparent 70%),
|
||||
linear-gradient(to right, rgba(255, 138, 60, 0.14) 1px, transparent 1px) 0 0 / 18px 18px,
|
||||
linear-gradient(to bottom, rgba(255, 138, 60, 0.14) 1px, transparent 1px) 0 0 / 18px 18px,
|
||||
linear-gradient(180deg, #14161c 0%, #0b0c10 100%);
|
||||
}
|
||||
|
||||
.theme-card[data-preview="pink"] .theme-card__preview {
|
||||
background:
|
||||
radial-gradient(80% 60% at 50% 0%, rgba(255, 91, 138, 0.16), transparent 70%),
|
||||
linear-gradient(to right, rgba(255, 91, 138, 0.18) 1px, transparent 1px) 0 0 / 18px 18px,
|
||||
linear-gradient(to bottom, rgba(255, 91, 138, 0.18) 1px, transparent 1px) 0 0 / 18px 18px,
|
||||
linear-gradient(180deg, #ffffff 0%, #fff5f7 100%);
|
||||
}
|
||||
|
||||
@@ -3869,6 +4054,43 @@
|
||||
border-color: rgba(255, 91, 138, 0.6);
|
||||
}
|
||||
|
||||
/* ----- 星空蓝预览:浅蓝渐变 + 隐约网格 + 几颗黄星点缀 ----- */
|
||||
.theme-card[data-preview="sky"] .theme-card__preview {
|
||||
background:
|
||||
radial-gradient(80% 60% at 50% 0%, rgba(255, 200, 61, 0.22), transparent 70%),
|
||||
linear-gradient(to right, rgba(255, 255, 255, 0.5) 1px, transparent 1px) 0 0 / 18px 18px,
|
||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.5) 1px, transparent 1px) 0 0 / 18px 18px,
|
||||
linear-gradient(180deg, #d6ecff 0%, #b8dcff 100%);
|
||||
}
|
||||
|
||||
.theme-card[data-preview="sky"] .theme-card__bar {
|
||||
background: linear-gradient(135deg, #ffe28a 0%, #ffc83d 100%);
|
||||
box-shadow: 0 0 12px rgba(255, 200, 61, 0.5);
|
||||
}
|
||||
|
||||
.theme-card[data-preview="sky"] .theme-card__player {
|
||||
box-shadow: 0 0 0 1px rgba(255, 200, 61, 0.5),
|
||||
0 12px 28px rgba(40, 80, 160, 0.22);
|
||||
}
|
||||
|
||||
.theme-card[data-preview="sky"] .theme-card__line {
|
||||
background: rgba(40, 70, 140, 0.22);
|
||||
}
|
||||
|
||||
.theme-card[data-preview="sky"] .theme-card__line--lg {
|
||||
background: rgba(27, 37, 71, 0.55);
|
||||
}
|
||||
|
||||
.theme-card[data-preview="sky"] .theme-card__chip {
|
||||
background: rgba(40, 70, 140, 0.08);
|
||||
border: 1px solid rgba(40, 70, 140, 0.22);
|
||||
}
|
||||
|
||||
.theme-card[data-preview="sky"] .theme-card__chip--accent {
|
||||
background: rgba(255, 200, 61, 0.28);
|
||||
border-color: rgba(255, 200, 61, 0.7);
|
||||
}
|
||||
|
||||
/* 卡片底部信息区 */
|
||||
.theme-card__body {
|
||||
display: flex;
|
||||
@@ -4132,6 +4354,7 @@
|
||||
.admin-drive-card__brand-icon[data-kind="p123"] { background: var(--drive-p123); }
|
||||
.admin-drive-card__brand-icon[data-kind="pikpak"] { background: var(--drive-pikpak); }
|
||||
.admin-drive-card__brand-icon[data-kind="wopan"] { background: var(--drive-wopan); }
|
||||
.admin-drive-card__brand-icon[data-kind="guangyapan"] { background: var(--drive-guangyapan); }
|
||||
.admin-drive-card__brand-icon[data-kind="onedrive"] { background: var(--drive-onedrive); }
|
||||
.admin-drive-card__brand-icon[data-kind="googledrive"] { background: #4285f4; }
|
||||
.admin-drive-card__brand-icon[data-kind="localstorage"] { background: var(--drive-localstorage); }
|
||||
@@ -4730,14 +4953,23 @@
|
||||
}
|
||||
|
||||
.admin-tag-card__alias-pill {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
line-height: 1.3;
|
||||
padding: 2px 7px;
|
||||
background: var(--bg-sunken);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-subtle);
|
||||
color: var(--text-default);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
:root[data-theme="sky"] .admin-tag-card__alias-pill {
|
||||
background: rgba(47, 111, 214, 0.13);
|
||||
border-color: rgba(47, 111, 214, 0.2);
|
||||
color: #2f436f;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.admin-tag-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+11
-15
@@ -49,33 +49,29 @@
|
||||
}
|
||||
|
||||
.main-nav__logo:hover .main-nav__logo-mark {
|
||||
transform: rotate(6deg) scale(1.05);
|
||||
box-shadow: 0 6px 20px rgba(255, 138, 60, 0.45), var(--shadow-inset);
|
||||
transform: rotate(3deg) scale(1.04);
|
||||
box-shadow: 0 6px 20px var(--accent-glow);
|
||||
}
|
||||
|
||||
.main-nav__logo-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent-gradient);
|
||||
color: var(--text-on-accent);
|
||||
box-shadow:
|
||||
0 4px 14px var(--accent-glow),
|
||||
var(--shadow-inset);
|
||||
background: transparent;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.16);
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.main-nav__logo-mark::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.18), transparent 60%);
|
||||
pointer-events: none;
|
||||
.main-nav__logo-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
/* ----- 链接列表 ----- */
|
||||
|
||||
@@ -173,6 +173,7 @@
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-2);
|
||||
margin-top: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
@@ -285,6 +286,7 @@
|
||||
.sort-toolbar {
|
||||
padding: var(--space-2);
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.sort-toolbar__group {
|
||||
|
||||
+408
-5
@@ -1,15 +1,16 @@
|
||||
/* =========================================================
|
||||
* Design Tokens
|
||||
*
|
||||
* 两套主题:
|
||||
* 三套主题:
|
||||
* [data-theme="dark"] 暗黑 + 暖橙(默认 / 兜底)
|
||||
* [data-theme="pink"] 奶油白 + 樱花粉
|
||||
* [data-theme="sky"] 星空蓝 + 暖星黄
|
||||
*
|
||||
* 切换方式:
|
||||
* document.documentElement.setAttribute("data-theme", "pink" | "dark")
|
||||
* document.documentElement.setAttribute("data-theme", "dark" | "pink" | "sky")
|
||||
*
|
||||
* 设计约束:
|
||||
* - 两套用相同的 token key,组件 CSS 一行不动
|
||||
* - 三套用相同的 token key,组件 CSS 一行不动
|
||||
* - 间距 / 圆角 / 字号 / 过渡等"非主题"变量挂在 :root
|
||||
* ========================================================= */
|
||||
|
||||
@@ -134,6 +135,7 @@
|
||||
--drive-p123: #22b8c8;
|
||||
--drive-pikpak: #8a6dff;
|
||||
--drive-wopan: #ff8a3c;
|
||||
--drive-guangyapan: #30c3a8;
|
||||
--drive-onedrive: #4cabea;
|
||||
--drive-localstorage: #35b88f;
|
||||
|
||||
@@ -152,6 +154,17 @@
|
||||
--shadow-inset: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* 暗黑主题:保留暖橙光晕,再叠一层低对比度网格。 */
|
||||
:root body::before,
|
||||
:root[data-theme="dark"] body::before {
|
||||
background:
|
||||
linear-gradient(to right, rgba(255, 138, 60, 0.09) 1px, transparent 1px) 0 0 / 88px 88px,
|
||||
linear-gradient(to bottom, rgba(255, 138, 60, 0.09) 1px, transparent 1px) 0 0 / 88px 88px,
|
||||
radial-gradient(1200px 600px at 85% -10%, rgba(255, 138, 60, 0.12), transparent 60%),
|
||||
radial-gradient(900px 500px at 10% 110%, rgba(90, 120, 255, 0.06), transparent 60%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent 44%);
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
* 奶油白 + 樱花粉
|
||||
*
|
||||
@@ -215,6 +228,7 @@
|
||||
--drive-p123: #1596a8;
|
||||
--drive-pikpak: #8466e6;
|
||||
--drive-wopan: #e57a36;
|
||||
--drive-guangyapan: #229f8b;
|
||||
--drive-onedrive: #2f95cf;
|
||||
--drive-localstorage: #239978;
|
||||
|
||||
@@ -233,11 +247,13 @@
|
||||
--shadow-inset: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
/* 粉白主题下,把 body::before 的"暖色光晕"改得更柔粉
|
||||
/* 粉白主题下,把 body::before 改成柔粉网格 + 轻光晕
|
||||
(base.css 用的是写死的 rgba 渐变,这里放一层覆盖) */
|
||||
:root[data-theme="pink"] body::before {
|
||||
background:
|
||||
radial-gradient(1200px 600px at 85% -10%, rgba(255, 91, 138, 0.1), transparent 60%),
|
||||
linear-gradient(to right, rgba(255, 91, 138, 0.13) 1px, transparent 1px) 0 0 / 82px 82px,
|
||||
linear-gradient(to bottom, rgba(255, 91, 138, 0.13) 1px, transparent 1px) 0 0 / 82px 82px,
|
||||
radial-gradient(1200px 600px at 85% -10%, rgba(255, 91, 138, 0.12), transparent 60%),
|
||||
radial-gradient(900px 500px at 10% 110%, rgba(190, 130, 200, 0.07), transparent 60%);
|
||||
}
|
||||
|
||||
@@ -251,3 +267,390 @@
|
||||
:root[data-theme="pink"] * {
|
||||
scrollbar-color: rgba(180, 90, 120, 0.28) transparent;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
* 星空蓝 + 暖星黄
|
||||
*
|
||||
* 设计要点:
|
||||
* - 页底用浅天空蓝(带一点点紫的清透蓝),不用纯白避免缺少氛围
|
||||
* - 卡片提亮到接近白色但带蓝调,避免和背景太接近
|
||||
* - 主色用暖星黄(#ffc83d),与天蓝形成互补关系,呼应背景里的星点
|
||||
* - 文本用深夜蓝(#1b2547),避免纯黑割裂梦幻氛围
|
||||
* - 边框/阴影用蓝调,让卡片看起来像漂在天空里
|
||||
* ========================================================= */
|
||||
:root[data-theme="sky"] {
|
||||
/* ----- 表面色 ----- */
|
||||
--bg-page: #c9e4ff;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-elevated: #eaf4ff;
|
||||
--bg-sunken: #b8d8f5;
|
||||
--bg-overlay: rgba(210, 230, 250, 0.82);
|
||||
|
||||
/* 半透明玻璃(蓝白叠透明) */
|
||||
--glass-nav: rgba(232, 244, 255, 0.78);
|
||||
--glass-card: rgba(255, 255, 255, 0.72);
|
||||
|
||||
/* ----- 边框(深蓝描边) ----- */
|
||||
--border-subtle: rgba(60, 100, 170, 0.1);
|
||||
--border-default: rgba(60, 100, 170, 0.18);
|
||||
--border-strong: rgba(40, 70, 140, 0.26);
|
||||
--border-accent: rgba(255, 200, 61, 0.6);
|
||||
|
||||
/* ----- 文本 ----- */
|
||||
--text-strong: #1b2547;
|
||||
--text-default: #324063;
|
||||
--text-muted: #6a7898;
|
||||
--text-faint: #a5b1c8;
|
||||
--text-disabled: #ccd4e2;
|
||||
--text-on-accent: #3a2400;
|
||||
--text-on-dark: #1b2547;
|
||||
|
||||
/* ----- 主色(暖星黄) ----- */
|
||||
--accent: #ffc83d;
|
||||
--accent-hover: #ffd76b;
|
||||
--accent-strong: #f0b21f;
|
||||
--accent-soft: rgba(255, 200, 61, 0.16);
|
||||
--accent-softer: rgba(255, 200, 61, 0.08);
|
||||
--accent-glow: rgba(255, 200, 61, 0.35);
|
||||
--accent-gradient: linear-gradient(135deg, #ffe28a 0%, #ffc83d 100%);
|
||||
--accent-gradient-strong: linear-gradient(135deg, #ffeca8 0%, #ffc83d 55%, #f0a514 100%);
|
||||
|
||||
/* ----- 状态色(浅蓝底下加深一些) ----- */
|
||||
--success: #1ea974;
|
||||
--success-soft: rgba(30, 169, 116, 0.16);
|
||||
--warning: #d99022;
|
||||
--warning-soft: rgba(217, 144, 34, 0.16);
|
||||
--danger: #e43b5c;
|
||||
--danger-soft: rgba(228, 59, 92, 0.14);
|
||||
--info: #2f6fd6;
|
||||
--info-soft: rgba(47, 111, 214, 0.14);
|
||||
|
||||
/* ----- 网盘品牌色(浅蓝底下重新调谐,整体加深) ----- */
|
||||
--drive-quark: #4467c8;
|
||||
--drive-p115: #d8485d;
|
||||
--drive-p123: #128da0;
|
||||
--drive-pikpak: #6b4ed4;
|
||||
--drive-wopan: #dc6d28;
|
||||
--drive-guangyapan: #158b7a;
|
||||
--drive-onedrive: #1f7fc0;
|
||||
--drive-localstorage: #198866;
|
||||
|
||||
/* ----- 阴影(蓝色柔投影) ----- */
|
||||
--shadow-sm: 0 1px 2px rgba(40, 80, 160, 0.1);
|
||||
--shadow-md:
|
||||
0 2px 4px rgba(40, 80, 160, 0.1),
|
||||
0 8px 20px rgba(40, 80, 160, 0.12);
|
||||
--shadow-lg:
|
||||
0 4px 10px rgba(40, 80, 160, 0.12),
|
||||
0 18px 40px rgba(40, 80, 160, 0.16);
|
||||
--shadow-xl:
|
||||
0 8px 16px rgba(40, 80, 160, 0.12),
|
||||
0 28px 60px rgba(40, 80, 160, 0.2);
|
||||
--shadow-glow: 0 0 0 1px var(--border-accent), 0 8px 28px var(--accent-glow);
|
||||
--shadow-inset: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
/* 星空蓝主题下,body::before 改为承载"网格"层(base.css 里它画的是暖光晕,这里覆写)。
|
||||
* 星星 / 闪光不再用 CSS 瓦片,改由 React 组件 <SkyStarfield /> 放固定位置元素,
|
||||
* 见本文件下方 .sky-starfield / .sky-star 规则与 src/components/SkyStarfield.tsx。 */
|
||||
:root[data-theme="sky"] body::before {
|
||||
background:
|
||||
linear-gradient(to right, rgba(255, 255, 255, 0.55) 1px, transparent 1px) 0 0 / 80px 80px,
|
||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.55) 1px, transparent 1px) 0 0 / 80px 80px;
|
||||
}
|
||||
|
||||
/* 星空蓝主题:自定义滚动条(覆盖 base.css 的硬编码白色透明) */
|
||||
:root[data-theme="sky"] ::-webkit-scrollbar-thumb {
|
||||
background: rgba(60, 100, 170, 0.22);
|
||||
}
|
||||
:root[data-theme="sky"] ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(60, 100, 170, 0.36);
|
||||
}
|
||||
:root[data-theme="sky"] * {
|
||||
scrollbar-color: rgba(60, 100, 170, 0.28) transparent;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
* 星空蓝主题专属:视口级星星贴纸(SkyStarfield 组件)
|
||||
*
|
||||
* 设计原则:
|
||||
* - 不用 CSS 瓦片 pattern——那会让星星在屏幕上"重复堆叠"显脏
|
||||
* - 桌面和手机分开点位:桌面围绕四周,手机减少数量并缩小尺寸
|
||||
* - 使用 fixed 定位,让首屏星星密度稳定,不被长页面高度稀释
|
||||
* - pointer-events: none,对滚动和点击都透明
|
||||
* - 非 sky 主题下整层 display: none,不渲染也不占位
|
||||
* ========================================================= */
|
||||
|
||||
.sky-starfield {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:root[data-theme="sky"] .sky-starfield {
|
||||
/* fixed + 四边显式 0:星星锚定视口,滚动时保持稳定的背景氛围。
|
||||
* 用 top/left/right/bottom 而不是 inset 是为了避免某些旧浏览器对 inset
|
||||
* 简写的支持差异(Safari 14.1 之前)。
|
||||
* overflow: hidden 防止 left 百分比在窄屏下溢出造成水平滚动条。 */
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sky-star {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
user-select: none;
|
||||
max-width: none;
|
||||
opacity: 0.72;
|
||||
/* GIF 自身就是动画 + 彩色,不再叠 CSS twinkle / drop-shadow */
|
||||
}
|
||||
|
||||
.sky-star--mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 每颗星的 top/left/right/bottom + width/height 都走 inline style。 */
|
||||
|
||||
:root .app-shell,
|
||||
:root .admin-shell,
|
||||
:root .admin-main,
|
||||
:root .admin-login,
|
||||
:root .admin-loading-screen {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:root[data-theme="sky"] .sky-star--desktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:root[data-theme="sky"] .sky-star--mobile {
|
||||
display: block;
|
||||
opacity: 0.64;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
:root .admin-sidebar,
|
||||
:root[data-theme="dark"] .admin-sidebar {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(18, 20, 27, 0.86) 0%, rgba(18, 20, 27, 0.66) 72%, rgba(18, 20, 27, 0.24) 100%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.045) 0%, rgba(255, 138, 60, 0.008) 100%);
|
||||
border-right: 0;
|
||||
box-shadow: none;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
:root .admin-sidebar__brand,
|
||||
:root .admin-sidebar__footer,
|
||||
:root[data-theme="dark"] .admin-sidebar__brand,
|
||||
:root[data-theme="dark"] .admin-sidebar__footer {
|
||||
border-color: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
:root .admin-nav__icon,
|
||||
:root .admin-sidebar__footer .admin-sidebar__home svg,
|
||||
:root .admin-sidebar__footer .admin-sidebar__check-update svg,
|
||||
:root .admin-sidebar__footer .admin-sidebar__logout svg,
|
||||
:root[data-theme="dark"] .admin-nav__icon,
|
||||
:root[data-theme="dark"] .admin-sidebar__footer .admin-sidebar__home svg,
|
||||
:root[data-theme="dark"] .admin-sidebar__footer .admin-sidebar__check-update svg,
|
||||
:root[data-theme="dark"] .admin-sidebar__footer .admin-sidebar__logout svg {
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
:root .admin-nav__link:hover,
|
||||
:root .admin-sidebar__footer .admin-sidebar__home:hover,
|
||||
:root .admin-sidebar__footer .admin-sidebar__check-update:hover:not(:disabled),
|
||||
:root[data-theme="dark"] .admin-nav__link:hover,
|
||||
:root[data-theme="dark"] .admin-sidebar__footer .admin-sidebar__home:hover,
|
||||
:root[data-theme="dark"] .admin-sidebar__footer .admin-sidebar__check-update:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
border-color: rgba(255, 138, 60, 0.065);
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
:root .admin-nav__link:hover .admin-nav__icon,
|
||||
:root .admin-sidebar__footer .admin-sidebar__home:hover svg,
|
||||
:root .admin-sidebar__footer .admin-sidebar__check-update:hover:not(:disabled) svg,
|
||||
:root[data-theme="dark"] .admin-nav__link:hover .admin-nav__icon,
|
||||
:root[data-theme="dark"] .admin-sidebar__footer .admin-sidebar__home:hover svg,
|
||||
:root[data-theme="dark"] .admin-sidebar__footer .admin-sidebar__check-update:hover:not(:disabled) svg {
|
||||
background: rgba(255, 255, 255, 0.085);
|
||||
border-color: rgba(255, 255, 255, 0.13);
|
||||
}
|
||||
|
||||
:root .admin-nav__link.is-active,
|
||||
:root[data-theme="dark"] .admin-nav__link.is-active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 138, 60, 0.14);
|
||||
box-shadow:
|
||||
0 10px 26px rgba(0, 0, 0, 0.22),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
:root .admin-nav__link.is-active .admin-nav__icon,
|
||||
:root[data-theme="dark"] .admin-nav__link.is-active .admin-nav__icon {
|
||||
background: rgba(255, 138, 60, 0.07);
|
||||
border-color: rgba(255, 138, 60, 0.2);
|
||||
color: rgba(255, 176, 112, 0.76);
|
||||
}
|
||||
|
||||
:root .admin-nav__link.is-active::before,
|
||||
:root[data-theme="dark"] .admin-nav__link.is-active::before {
|
||||
left: 8px;
|
||||
width: 2px;
|
||||
opacity: 0.45;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:root .admin-sidebar__footer .admin-sidebar__logout:hover,
|
||||
:root[data-theme="dark"] .admin-sidebar__footer .admin-sidebar__logout:hover {
|
||||
background: rgba(241, 85, 108, 0.12);
|
||||
border-color: rgba(241, 85, 108, 0.25);
|
||||
}
|
||||
|
||||
:root[data-theme="pink"] .admin-sidebar {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.58) 0%, rgba(255, 245, 249, 0.34) 72%, transparent 100%),
|
||||
linear-gradient(180deg, rgba(255, 91, 138, 0.08) 0%, rgba(255, 255, 255, 0.18) 100%);
|
||||
border-right: 0;
|
||||
box-shadow: none;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
:root[data-theme="pink"] .admin-sidebar__brand,
|
||||
:root[data-theme="pink"] .admin-sidebar__footer {
|
||||
border-color: rgba(255, 91, 138, 0.11);
|
||||
}
|
||||
|
||||
:root[data-theme="pink"] .admin-nav__icon,
|
||||
:root[data-theme="pink"] .admin-sidebar__footer .admin-sidebar__home svg,
|
||||
:root[data-theme="pink"] .admin-sidebar__footer .admin-sidebar__check-update svg,
|
||||
:root[data-theme="pink"] .admin-sidebar__footer .admin-sidebar__logout svg {
|
||||
background: rgba(255, 255, 255, 0.38);
|
||||
border-color: rgba(255, 91, 138, 0.12);
|
||||
}
|
||||
|
||||
:root[data-theme="pink"] .admin-nav__link:hover,
|
||||
:root[data-theme="pink"] .admin-sidebar__footer .admin-sidebar__home:hover,
|
||||
:root[data-theme="pink"] .admin-sidebar__footer .admin-sidebar__check-update:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.42);
|
||||
border-color: rgba(255, 91, 138, 0.16);
|
||||
box-shadow: 0 8px 22px rgba(180, 90, 120, 0.08);
|
||||
}
|
||||
|
||||
:root[data-theme="pink"] .admin-nav__link:hover .admin-nav__icon,
|
||||
:root[data-theme="pink"] .admin-sidebar__footer .admin-sidebar__home:hover svg,
|
||||
:root[data-theme="pink"] .admin-sidebar__footer .admin-sidebar__check-update:hover:not(:disabled) svg {
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
border-color: rgba(255, 91, 138, 0.2);
|
||||
}
|
||||
|
||||
:root[data-theme="pink"] .admin-nav__link.is-active {
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
border-color: rgba(255, 91, 138, 0.34);
|
||||
box-shadow:
|
||||
0 10px 26px rgba(180, 90, 120, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
:root[data-theme="pink"] .admin-nav__link.is-active .admin-nav__icon {
|
||||
background: rgba(255, 91, 138, 0.16);
|
||||
border-color: rgba(255, 91, 138, 0.42);
|
||||
}
|
||||
|
||||
:root[data-theme="pink"] .admin-nav__link.is-active::before {
|
||||
left: 8px;
|
||||
width: 2px;
|
||||
opacity: 0.72;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:root[data-theme="pink"] .admin-sidebar__footer .admin-sidebar__logout:hover {
|
||||
background: rgba(228, 59, 92, 0.1);
|
||||
border-color: rgba(228, 59, 92, 0.22);
|
||||
}
|
||||
|
||||
:root[data-theme="sky"] .admin-sidebar {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.34) 0%, rgba(255, 255, 255, 0.18) 72%, transparent 100%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.22) 0%, rgba(232, 244, 255, 0.08) 100%);
|
||||
border-right: 0;
|
||||
box-shadow: none;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
:root[data-theme="sky"] .admin-sidebar__brand,
|
||||
:root[data-theme="sky"] .admin-sidebar__footer {
|
||||
border-color: rgba(60, 100, 170, 0.1);
|
||||
}
|
||||
|
||||
:root[data-theme="sky"] .admin-nav__icon,
|
||||
:root[data-theme="sky"] .admin-sidebar__footer .admin-sidebar__home svg,
|
||||
:root[data-theme="sky"] .admin-sidebar__footer .admin-sidebar__check-update svg,
|
||||
:root[data-theme="sky"] .admin-sidebar__footer .admin-sidebar__logout svg {
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
border-color: rgba(60, 100, 170, 0.12);
|
||||
}
|
||||
|
||||
:root[data-theme="sky"] .admin-nav__link:hover,
|
||||
:root[data-theme="sky"] .admin-sidebar__footer .admin-sidebar__home:hover,
|
||||
:root[data-theme="sky"] .admin-sidebar__footer .admin-sidebar__check-update:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.26);
|
||||
border-color: rgba(60, 100, 170, 0.14);
|
||||
box-shadow: 0 8px 22px rgba(40, 80, 160, 0.08);
|
||||
}
|
||||
|
||||
:root[data-theme="sky"] .admin-nav__link:hover .admin-nav__icon,
|
||||
:root[data-theme="sky"] .admin-sidebar__footer .admin-sidebar__home:hover svg,
|
||||
:root[data-theme="sky"] .admin-sidebar__footer .admin-sidebar__check-update:hover:not(:disabled) svg {
|
||||
background: rgba(255, 255, 255, 0.42);
|
||||
border-color: rgba(60, 100, 170, 0.18);
|
||||
}
|
||||
|
||||
:root[data-theme="sky"] .admin-nav__link.is-active {
|
||||
background: rgba(255, 255, 255, 0.42);
|
||||
border-color: rgba(255, 200, 61, 0.42);
|
||||
box-shadow:
|
||||
0 10px 26px rgba(40, 80, 160, 0.09),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
:root[data-theme="sky"] .admin-nav__link.is-active .admin-nav__icon {
|
||||
background: rgba(255, 200, 61, 0.2);
|
||||
border-color: rgba(255, 200, 61, 0.58);
|
||||
}
|
||||
|
||||
:root[data-theme="sky"] .admin-nav__link.is-active::before {
|
||||
left: 8px;
|
||||
width: 2px;
|
||||
opacity: 0.72;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:root[data-theme="sky"] .admin-sidebar__footer .admin-sidebar__logout:hover {
|
||||
background: rgba(228, 59, 92, 0.1);
|
||||
border-color: rgba(228, 59, 92, 0.22);
|
||||
}
|
||||
}
|
||||
|
||||
/* 后台和登录页默认会用 var(--bg-page) 填满,网格会被遮挡。
|
||||
* 三套主题都让外壳透明,露出 body::before 的底纹;卡片本身仍是实心表面。 */
|
||||
:root .admin-shell,
|
||||
:root .admin-main,
|
||||
:root .admin-login,
|
||||
:root .admin-loading-screen {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -305,6 +305,14 @@
|
||||
--drive-shadow: rgba(255, 138, 60, 0.15);
|
||||
--drive-shadow-strong: rgba(255, 138, 60, 0.4);
|
||||
}
|
||||
.source-badge[data-kind="guangyapan"] {
|
||||
--drive-color: var(--drive-guangyapan);
|
||||
--drive-bg: rgba(48, 195, 168, 0.12);
|
||||
--drive-text: #9debdc;
|
||||
--drive-border: rgba(48, 195, 168, 0.35);
|
||||
--drive-shadow: rgba(48, 195, 168, 0.15);
|
||||
--drive-shadow-strong: rgba(48, 195, 168, 0.4);
|
||||
}
|
||||
.source-badge[data-kind="onedrive"] {
|
||||
--drive-color: var(--drive-onedrive);
|
||||
--drive-bg: rgba(76, 171, 234, 0.12);
|
||||
@@ -456,6 +464,7 @@
|
||||
.video-card__source[data-kind="p123"] { --source-color: var(--drive-p123); }
|
||||
.video-card__source[data-kind="pikpak"] { --source-color: var(--drive-pikpak); }
|
||||
.video-card__source[data-kind="wopan"] { --source-color: var(--drive-wopan); }
|
||||
.video-card__source[data-kind="guangyapan"] { --source-color: var(--drive-guangyapan); }
|
||||
.video-card__source[data-kind="onedrive"] { --source-color: var(--drive-onedrive); }
|
||||
.video-card__source[data-kind="localstorage"] { --source-color: var(--drive-localstorage); }
|
||||
|
||||
@@ -483,10 +492,15 @@
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
--skeleton-card-bg: var(--bg-surface);
|
||||
--skeleton-card-border: var(--border-subtle);
|
||||
--skeleton-shimmer-base: rgba(255, 255, 255, 0.03);
|
||||
--skeleton-shimmer-highlight: rgba(255, 255, 255, 0.08);
|
||||
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 12.5;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--skeleton-card-bg);
|
||||
border: 1px solid var(--skeleton-card-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
@@ -495,6 +509,18 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:root[data-theme="pink"] .skeleton-card {
|
||||
--skeleton-card-border: rgba(255, 91, 138, 0.18);
|
||||
--skeleton-shimmer-base: rgba(255, 91, 138, 0.12);
|
||||
--skeleton-shimmer-highlight: rgba(255, 91, 138, 0.26);
|
||||
}
|
||||
|
||||
:root[data-theme="sky"] .skeleton-card {
|
||||
--skeleton-card-border: rgba(60, 100, 170, 0.18);
|
||||
--skeleton-shimmer-base: rgba(60, 100, 170, 0.13);
|
||||
--skeleton-shimmer-highlight: rgba(60, 100, 170, 0.26);
|
||||
}
|
||||
|
||||
/* Skeleton image thumbnail area */
|
||||
.skeleton-card::before {
|
||||
content: "";
|
||||
@@ -504,9 +530,9 @@
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.03) 25%,
|
||||
rgba(255, 255, 255, 0.08) 50%,
|
||||
rgba(255, 255, 255, 0.03) 75%
|
||||
var(--skeleton-shimmer-base) 25%,
|
||||
var(--skeleton-shimmer-highlight) 50%,
|
||||
var(--skeleton-shimmer-base) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.6s ease-in-out infinite;
|
||||
@@ -520,8 +546,8 @@
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 25%, rgba(255, 255, 255, 0.08) 50%, rgba(255, 255, 255, 0.03) 75%) 0 0 / 70% 12px no-repeat,
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 25%, rgba(255, 255, 255, 0.08) 50%, rgba(255, 255, 255, 0.03) 75%) 0 20px / 42% 8px no-repeat;
|
||||
linear-gradient(90deg, var(--skeleton-shimmer-base) 25%, var(--skeleton-shimmer-highlight) 50%, var(--skeleton-shimmer-base) 75%) 0 0 / 70% 12px no-repeat,
|
||||
linear-gradient(90deg, var(--skeleton-shimmer-base) 25%, var(--skeleton-shimmer-highlight) 50%, var(--skeleton-shimmer-base) 75%) 0 20px / 42% 8px no-repeat;
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
+52
-16
@@ -94,6 +94,13 @@
|
||||
--video-player-progress-hover: rgba(255, 197, 216, 0.42);
|
||||
}
|
||||
|
||||
:root[data-theme="sky"] .video-player {
|
||||
--video-player-progress: #58b8ff;
|
||||
--video-player-progress-loaded: rgba(88, 184, 255, 0.36);
|
||||
--video-player-progress-track: rgba(210, 236, 255, 0.32);
|
||||
--video-player-progress-hover: rgba(166, 220, 255, 0.45);
|
||||
}
|
||||
|
||||
.video-player__mount {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -485,6 +492,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;
|
||||
@@ -648,6 +676,12 @@
|
||||
color: var(--drive-wopan);
|
||||
}
|
||||
|
||||
.vd-meta__chip[data-tone="guangyapan"] {
|
||||
background: rgba(48, 195, 168, 0.14);
|
||||
border-color: rgba(48, 195, 168, 0.3);
|
||||
color: var(--drive-guangyapan);
|
||||
}
|
||||
|
||||
.vd-meta__chip[data-tone="onedrive"] {
|
||||
background: rgba(76, 171, 234, 0.14);
|
||||
border-color: rgba(76, 171, 234, 0.3);
|
||||
@@ -784,7 +818,7 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.vd-actions__hide {
|
||||
.vd-actions__delete {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -1420,6 +1454,13 @@
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
:root[data-theme="sky"] .vd-rail__hd {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-accent);
|
||||
border: 0;
|
||||
box-shadow: 0 4px 10px var(--accent-glow);
|
||||
}
|
||||
|
||||
.vd-rail__body {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
@@ -1869,10 +1910,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 +1935,7 @@
|
||||
gap: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
/* 操作栏:点赞点踩组合占满主行,"不再显示"折到右侧或单独一行 */
|
||||
/* 操作栏:点赞点踩组合占满主行,删除按钮固定在右侧 */
|
||||
.vd-actions {
|
||||
padding: var(--space-3) 0 0;
|
||||
gap: var(--space-2);
|
||||
@@ -1907,19 +1954,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 +1967,7 @@
|
||||
}
|
||||
|
||||
.vd-delete-modal {
|
||||
align-items: end;
|
||||
place-items: center;
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
|
||||
@@ -78,9 +78,9 @@ test("spider91 upload target uses explicit local-save option instead of auto tar
|
||||
assert.match(combinedSource, /本地保存,不上传/);
|
||||
assert.match(
|
||||
combinedSource,
|
||||
/d\.kind === "pikpak"[\s\S]*d\.kind === "p115"[\s\S]*d\.kind === "p123"[\s\S]*d\.kind === "onedrive"[\s\S]*d\.kind === "googledrive"[\s\S]*d\.kind === "wopan"/
|
||||
/d\.kind === "pikpak"[\s\S]*d\.kind === "p115"[\s\S]*d\.kind === "p123"[\s\S]*d\.kind === "onedrive"[\s\S]*d\.kind === "googledrive"[\s\S]*d\.kind === "wopan"[\s\S]*d\.kind === "guangyapan"/
|
||||
);
|
||||
assert.match(crawlerPageSource, /UPLOAD_TARGET_KINDS[\s\S]*"wopan"/);
|
||||
assert.match(crawlerPageSource, /UPLOAD_TARGET_KINDS[\s\S]*"wopan"[\s\S]*"guangyapan"/);
|
||||
assert.doesNotMatch(combinedSource, /自动:唯一/);
|
||||
assert.doesNotMatch(combinedSource, /自动模式/);
|
||||
assert.doesNotMatch(combinedSource, /较早的视频会上传到该云盘根目录下的 91 Spider 文件夹/);
|
||||
@@ -175,6 +175,32 @@ test("pikpak drive form only exposes account login fields", () => {
|
||||
assert.doesNotMatch(fields, /key: "disable_media_link"/);
|
||||
});
|
||||
|
||||
test("guangyapan drive form exposes qr login and token fields", () => {
|
||||
assertDriveTypeOption("guangyapan", "光鸭网盘");
|
||||
assert.match(driveFormSource, /GuangYaPanQRCodeLogin/);
|
||||
assert.match(driveFormSource, /form\.kind === "guangyapan"/);
|
||||
assert.match(apiSource, /startGuangYaPanQRLogin/);
|
||||
assert.match(apiSource, /getGuangYaPanQRStatus/);
|
||||
|
||||
const match =
|
||||
/case "guangyapan":\s*return \[([\s\S]*?)\];\s*case "onedrive":/.exec(
|
||||
combinedSource
|
||||
);
|
||||
assert.ok(match, "guangyapan credential field block should be present");
|
||||
const fields = match[1];
|
||||
|
||||
assert.match(fields, /key: "root_path"/);
|
||||
assert.match(fields, /key: "refresh_token"/);
|
||||
assert.match(fields, /key: "access_token"/);
|
||||
assert.doesNotMatch(fields, /key: "phone_number"/);
|
||||
assert.doesNotMatch(fields, /key: "send_code"/);
|
||||
assert.doesNotMatch(fields, /key: "verify_code"/);
|
||||
assert.doesNotMatch(fields, /key: "captcha_token"/);
|
||||
assert.doesNotMatch(fields, /key: "client_id"/);
|
||||
assert.doesNotMatch(fields, /key: "device_id"/);
|
||||
assert.match(combinedSource, /if \(kind === "guangyapan"\) return ""/);
|
||||
});
|
||||
|
||||
test("localstorage drive form asks for a server directory path", () => {
|
||||
assertDriveTypeOption("localstorage", "本地存储");
|
||||
|
||||
@@ -196,6 +222,7 @@ test("drive type selector keeps primary source order", () => {
|
||||
{ value: "p115", label: "115 网盘" },
|
||||
{ value: "p123", label: "123网盘" },
|
||||
{ value: "pikpak", label: "PikPak" },
|
||||
{ value: "guangyapan", label: "光鸭网盘" },
|
||||
{ value: "onedrive", label: "OneDrive" },
|
||||
{ value: "googledrive", label: "Google Drive" },
|
||||
{ value: "localstorage", label: "本地存储" },
|
||||
@@ -232,6 +259,13 @@ test("crawler management is a separate admin section", () => {
|
||||
assert.match(crawlerPageSource, /测试通过/);
|
||||
assert.match(crawlerPageSource, /Spider91UploadTargetField/);
|
||||
assert.match(crawlerPageSource, /uploadDriveId/);
|
||||
assert.match(crawlerPageSource, /api\.setDriveTeaserEnabled/);
|
||||
assert.match(crawlerPageSource, /admin-crawler-preview-card-toggle/);
|
||||
assert.match(crawlerPageSource, /预览:开/);
|
||||
assert.match(crawlerPageSource, /预览:关/);
|
||||
assert.match(crawlerPageSource, /aria-pressed=\{crawler\.teaserEnabled\}/);
|
||||
assert.doesNotMatch(crawlerPageSource, /teaserEnabled: form\.teaserEnabled/);
|
||||
assert.doesNotMatch(crawlerPageSource, /aria-pressed=\{form\.teaserEnabled\}/);
|
||||
assert.match(crawlerPageSource, /UPLOAD_TARGET_KINDS/);
|
||||
assert.doesNotMatch(crawlerPageSource, /新建脚本/);
|
||||
assert.doesNotMatch(crawlerPageSource, /爬虫 ID/);
|
||||
@@ -247,6 +281,8 @@ test("crawler management is a separate admin section", () => {
|
||||
assert.doesNotMatch(crawlerPageSource, /内置 91/);
|
||||
assert.match(apiSource, /type AdminCrawler/);
|
||||
assert.match(apiSource, /uploadDriveId\?: string/);
|
||||
assert.match(apiSource, /teaserEnabled: boolean/);
|
||||
assert.doesNotMatch(apiSource, /teaserEnabled\?: boolean/);
|
||||
assert.match(apiSource, /"\/crawlers"/);
|
||||
assert.match(apiSource, /"\/crawlers\/import-file"/);
|
||||
assert.match(apiSource, /"\/crawlers\/import-url"/);
|
||||
@@ -264,6 +300,7 @@ test("drive cards use configured abbreviations and visible fallback icon colors"
|
||||
assert.match(drivesPageSource, /driveKindAbbr\(d\.kind\)/);
|
||||
assert.match(adminCss, /\.admin-drive-card__brand-icon\s*\{[^}]*background:\s*var\(--accent\);/s);
|
||||
assert.match(adminCss, /\.admin-drive-card__brand-icon\[data-kind="googledrive"\]\s*\{\s*background:\s*#4285f4;\s*\}/);
|
||||
assert.match(adminCss, /\.admin-drive-card__brand-icon\[data-kind="guangyapan"\]\s*\{\s*background:\s*var\(--drive-guangyapan\);/);
|
||||
});
|
||||
|
||||
test("drive management exposes stop task controls", () => {
|
||||
@@ -276,6 +313,21 @@ test("drive management exposes stop task controls", () => {
|
||||
assert.match(drivesPageSource, /停止所有网盘任务/);
|
||||
});
|
||||
|
||||
test("drive detail primary actions use the rescan button color", () => {
|
||||
assert.match(
|
||||
drivesPageSource,
|
||||
/className="admin-btn is-primary"\s+onClick=\{\(\) => handleRescan\(d\)\}/
|
||||
);
|
||||
assert.match(
|
||||
drivesPageSource,
|
||||
/className="admin-btn is-primary"\s+onClick=\{\(\) => handleStopDriveTasks\(d\)\}/
|
||||
);
|
||||
assert.match(
|
||||
drivesPageSource,
|
||||
/className="admin-btn is-primary"\s+onClick=\{\(\) => openEdit\(d\)\}/
|
||||
);
|
||||
});
|
||||
|
||||
test("drive rescan reports busy storage tasks instead of queueing duplicates", () => {
|
||||
assert.match(apiSource, /accepted:\s*boolean;\s*message\?:\s*string/);
|
||||
assert.match(apiSource, /scanGenerationStatus\?: DriveGenerationStatus/);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -20,3 +20,19 @@ test("admin videos batch delete runs deletions sequentially", () => {
|
||||
/Promise\.allSettled\(\s*ids\.map\(\(id\) => api\.deleteVideo\(id(?:, [^)]+)?\)\)\s*\)/
|
||||
);
|
||||
});
|
||||
|
||||
test("admin videos show generating status after preview regeneration is accepted", () => {
|
||||
assert.match(videosPageSource, /const REGEN_PREVIEW_STATUS = "generating";/);
|
||||
assert.match(videosPageSource, /const \[regenPreviewById, setRegenPreviewById\]/);
|
||||
assert.match(videosPageSource, /trackRegeneratingPreview\(\[v\]\)/);
|
||||
assert.match(videosPageSource, /<PreviewStatus s=\{isPreviewGenerating\(v\) \? REGEN_PREVIEW_STATUS : v\.previewStatus\} \/>/);
|
||||
assert.match(videosPageSource, /refreshListOnly\(\)/);
|
||||
});
|
||||
|
||||
test("admin videos keep generating status after page refresh", () => {
|
||||
assert.match(videosPageSource, /const hasGeneratingPreview = list\.some\(\(v\) => v\.previewStatus === REGEN_PREVIEW_STATUS\);/);
|
||||
assert.match(videosPageSource, /if \(trackedRegenCount === 0 && !hasGeneratingPreview\) return;/);
|
||||
assert.match(videosPageSource, /function isPreviewGenerating\(v: api\.AdminVideo\)/);
|
||||
assert.match(videosPageSource, /return !!regenPreviewById\[v\.id\] \|\| v\.previewStatus === REGEN_PREVIEW_STATUS;/);
|
||||
assert.match(videosPageSource, /disabled=\{isPreviewGenerating\(v\)\}/);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const videoCardCss = readFileSync(
|
||||
new URL("../src/styles/video-card.css", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
function ruleBody(css: string, selector: string): string {
|
||||
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`));
|
||||
assert.ok(match, `Expected CSS rule for ${selector}`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
test("home video skeleton uses theme-aware non-white shimmer colors", () => {
|
||||
const skeleton = ruleBody(videoCardCss, ".skeleton-card");
|
||||
const pink = ruleBody(videoCardCss, ':root[data-theme="pink"] .skeleton-card');
|
||||
const sky = ruleBody(videoCardCss, ':root[data-theme="sky"] .skeleton-card');
|
||||
const thumb = ruleBody(videoCardCss, ".skeleton-card::before");
|
||||
const text = ruleBody(videoCardCss, ".skeleton-card::after");
|
||||
|
||||
assert.match(skeleton, /--skeleton-shimmer-base\s*:/);
|
||||
assert.match(skeleton, /--skeleton-shimmer-highlight\s*:/);
|
||||
assert.match(thumb, /var\(--skeleton-shimmer-base\)/);
|
||||
assert.match(thumb, /var\(--skeleton-shimmer-highlight\)/);
|
||||
assert.match(text, /var\(--skeleton-shimmer-base\)/);
|
||||
assert.match(text, /var\(--skeleton-shimmer-highlight\)/);
|
||||
|
||||
assert.match(pink, /--skeleton-shimmer-base\s*:\s*rgba\(255,\s*91,\s*138,\s*0\.12\)/);
|
||||
assert.match(pink, /--skeleton-shimmer-highlight\s*:\s*rgba\(255,\s*91,\s*138,\s*0\.26\)/);
|
||||
assert.match(sky, /--skeleton-shimmer-base\s*:\s*rgba\(60,\s*100,\s*170,\s*0\.13\)/);
|
||||
assert.match(sky, /--skeleton-shimmer-highlight\s*:\s*rgba\(60,\s*100,\s*170,\s*0\.26\)/);
|
||||
});
|
||||
@@ -33,6 +33,55 @@ 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 player limits ArtPlayer automatic reconnect attempts", () => {
|
||||
assert.match(playerSource, /const ARTPLAYER_RECONNECT_TIME_MAX = 3;/);
|
||||
assert.match(
|
||||
playerSource,
|
||||
/Artplayer\.RECONNECT_TIME_MAX = ARTPLAYER_RECONNECT_TIME_MAX;/
|
||||
);
|
||||
});
|
||||
|
||||
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 +107,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