mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 00:44:30 +08:00
Add OneDrive storage and video management
This commit is contained in:
@@ -1,10 +1,19 @@
|
||||
# 视频聚合站
|
||||
|
||||
把夸克 / 115 / PikPak / 联通沃盘作为存储后端的视频聚合前台。按 `video-site-implementation-plan.md` 的设计实现。
|
||||
把夸克 / 115 / PikPak / 联通沃盘 / OneDrive 作为存储后端的视频聚合前台。按 `video-site-implementation-plan.md` 的设计实现。
|
||||
|
||||
- 前端:React 18 + Vite + TypeScript
|
||||
- 后端:Go 1.23,SQLite(纯 Go 驱动,无 CGO),ffmpeg 生成 teaser 和封面
|
||||
- 网盘接入:夸克自研 + 115driver SDK + PikPak 自研(参考 OpenList)+ wopan-sdk-go SDK
|
||||
- 网盘接入:夸克自研 + 115driver SDK + PikPak 自研(参考 OpenList)+ wopan-sdk-go SDK + OneDrive(OpenList 在线续期 + Microsoft Graph 文件接口)
|
||||
|
||||
## 当前功能
|
||||
|
||||
- 前台需要登录后访问,支持首页、列表页、搜索、分类/标签筛选、分页、详情播放和相关推荐。
|
||||
- 视频卡片支持封面、画质、时长、点赞/点踩、移动端点按预览;列表页会记住筛选、分页和滚动位置。
|
||||
- 播放页提供点赞、标签编辑和 **不再展示**。不再展示是全局隐藏:写入数据库后,该视频不会再出现在首页、列表、相关推荐中,详情接口也会返回 404。
|
||||
- 管理后台支持网盘管理、视频管理、标签管理和运行时 Teaser 生成开关。
|
||||
- 视频管理支持按网盘筛选、每页 100 条分页、每个网盘的 Teaser 已生成/待生成/失败统计、单条或全量重生 teaser、编辑标题/作者/分类/标签等元数据。
|
||||
- 标签管理支持创建标签并自动分类已有视频;内置规则会把常见番号污染归并到 `AV` 等系统标签,降低标签列表噪声。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -18,14 +27,26 @@ Windows 用户可以把 Go 和 ffmpeg 解压到 `%USERPROFILE%\tools\`,然后
|
||||
|
||||
### 运行
|
||||
|
||||
Linux / WSL 环境推荐用仓库根目录的脚本同时启动前后端:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
./start.sh # 前端 9191,后端 9192
|
||||
./start.sh --status # 查看运行状态
|
||||
./start.sh --restart # 重启
|
||||
./start.sh --stop # 停止
|
||||
```
|
||||
|
||||
也可以分两个终端手动启动:
|
||||
|
||||
```bash
|
||||
# 前端
|
||||
npm install
|
||||
npm run dev # 监听 http://127.0.0.1:5173
|
||||
npm run dev # 监听 http://127.0.0.1:9191
|
||||
|
||||
# 后端(另开终端)
|
||||
cd backend
|
||||
go run ./cmd/server # 监听 :8080,依赖已 vendor 入库,无需 go mod tidy
|
||||
go run ./cmd/server # 默认监听 127.0.0.1:9192,依赖已 vendor 入库,无需 go mod tidy
|
||||
```
|
||||
|
||||
首次启动后端会自动生成:
|
||||
@@ -34,7 +55,7 @@ go run ./cmd/server # 监听 :8080,依赖已 vendor 入库,无需 go mo
|
||||
- `backend/data/video-site.db`(SQLite)
|
||||
- `backend/data/previews/`(teaser 和封面本地目录)
|
||||
|
||||
Vite dev server 已配置把 `/api`、`/p`、`/admin/api` 反代到 `:8080`。浏览器访问 `http://127.0.0.1:5173/` 进入前台,`/admin` 进入管理后台(默认 `admin` / `admin123`,请在 `config.yaml` 里改)。
|
||||
Vite dev server 已配置把 `/api`、`/p`、`/admin/api` 反代到 `127.0.0.1:9192`。浏览器访问 `http://127.0.0.1:9191/` 进入前台,`/admin` 进入管理后台(默认 `admin` / `admin123`,请在 `backend/config.yaml` 里改)。如果本地已经存在旧的 `backend/config.yaml`,请确认 `server.listen` 与 Vite 代理端口一致。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -43,8 +64,9 @@ Vite dev server 已配置把 `/api`、`/p`、`/admin/api` 反代到 `:8080`。
|
||||
├─ src/ React 前端
|
||||
├─ backend/ Go 后端(单体服务)
|
||||
│ └─ vendor/ Go 依赖全量源码,入库,支持完全离线构建
|
||||
├─ vendor-refs/ 可选的阅读资料,.gitignore 忽略
|
||||
│ └─ OpenList-4.2.1/ OpenList 完整源码,网盘协议对接参考
|
||||
├─ OpenList-4.2.1/ OpenList 完整源码,网盘协议对接参考
|
||||
├─ tests/ 前端纯逻辑测试
|
||||
├─ start.sh 本地前后端启动脚本
|
||||
├─ video-site-implementation-plan.md 完整的设计和实现记录
|
||||
└─ README.md
|
||||
```
|
||||
@@ -70,10 +92,10 @@ git add vendor/ # 入库
|
||||
## 加一个网盘
|
||||
|
||||
1. 登录 `/admin` → 网盘管理 → 新建
|
||||
2. 选类型(夸克 / 115 / PikPak / 沃盘),填名称 + 凭证
|
||||
2. 选类型(夸克 / 115 / PikPak / 沃盘 / OneDrive),填名称 + 凭证
|
||||
3. 保存后会自动触发一次扫描
|
||||
4. 在 `/admin/videos` 里看扫到了多少视频
|
||||
5. 侧栏底部 **Teaser 生成** 开关开着,就会按配置给每个视频生成封面和 10 秒 teaser
|
||||
5. 侧栏底部 **Teaser 生成** 开关开着,就会按配置给每个视频生成封面和多段 teaser
|
||||
|
||||
各网盘的凭证字段:
|
||||
|
||||
@@ -83,15 +105,40 @@ git add vendor/ # 入库
|
||||
| 115 | `cookie` | 115.com 登录后拷 Cookie(`UID=...; CID=...; SEID=...; KID=...`) |
|
||||
| PikPak | `username`、`password`,可选 `refresh_token`、`captcha_token`、`device_id`、`platform`、`disable_media_link` | 参考 OpenList PikPak driver;首次登录成功会自动回写 token |
|
||||
| 沃盘 | `access_token`、`refresh_token`、可选 `family_id` | 第一版只能手动粘贴 token;后续会加扫码/短信登录 |
|
||||
| OneDrive | `refresh_token`,可选 `access_token`、`api_url_address`、`region`、`is_sharepoint`、`site_id` | 按 OpenList 默认方式调用 `https://api.oplist.org/onedrive/renewapi` 在线刷新 token;`rootId` / `scanRootId` 默认填 `root`,SharePoint 需填 `is_sharepoint=true` 和 `site_id` |
|
||||
|
||||
### OneDrive 说明
|
||||
|
||||
OneDrive 当前采用 OpenList 在线 API 的续期方式,不要求用户提供 Azure 应用的 `client_id` / `client_secret` / `redirect_uri`。配置时至少填 `refresh_token`;如使用 OpenList 代刷获得的 token,可把 refresh token 填到本项目。普通 OneDrive 的 `rootId` / `scanRootId` 推荐填 `root`,SharePoint 文档库需额外设置 `is_sharepoint=true` 和 `site_id`。
|
||||
|
||||
## Teaser 和封面生成策略
|
||||
|
||||
- 封面:根据视频时长从 20% 或 30% 位置抽一帧 jpg
|
||||
- Teaser:3 段拼接(`20% / 50% / 80%` 位置各 3 秒,带 0.2s fade-in/out),总长约 9 秒,目标体积 500 KB - 1.5 MB
|
||||
- 短视频 (< 30s) 自动降级为单段
|
||||
- 首次失败的任务标 `preview_status = failed`,不再自动重试;管理后台可手动重生
|
||||
- Teaser:每段固定 3 秒;30 秒以下最多 3 段,30 秒及以上固定 4 段;长视频在 20% 到 80% 区间均匀取段
|
||||
- 极短视频会按可容纳的完整 3 秒片段数自动降级
|
||||
- 首次失败的任务标 `preview_status = failed`,不再自动重试;管理后台可手动重新生成
|
||||
- 服务启动或网盘重新挂载时,如果 Teaser 开关已开启,会自动把历史 `pending` 任务重新入队,避免重启后停在“待生成”。
|
||||
- OneDrive 直链生成 teaser 时可能触发 Microsoft 429 限流;这种失败会被记录为 `failed`,可稍后在管理后台重生。
|
||||
- 详见 plan 15.12 节
|
||||
|
||||
## 常用管理能力
|
||||
|
||||
- `/admin/drives`:新增/编辑/删除网盘,触发扫描。
|
||||
- `/admin/videos`:按网盘查看视频、分页浏览、查看各网盘 Teaser 统计、编辑元数据、重生 teaser。
|
||||
- `/admin/tags`:新增标签并自动匹配已有视频。
|
||||
- 播放页的“不再展示”是全局隐藏功能;当前没有恢复入口,如需恢复可直接把数据库中对应视频的 `hidden` 字段改回 `0`,后续可在管理后台补恢复 UI。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run build
|
||||
node --test tests/previewIntent.test.ts
|
||||
|
||||
cd backend
|
||||
go test ./... -count=1
|
||||
```
|
||||
|
||||
## 部署到 Linux
|
||||
|
||||
```bash
|
||||
|
||||
+50
-12
@@ -2,9 +2,10 @@
|
||||
|
||||
视频聚合站的 Go 后端。提供三件事:
|
||||
|
||||
1. 多家网盘统一抽象(夸克 / 115 / PikPak / 联通沃盘)
|
||||
1. 多家网盘统一抽象(夸克 / 115 / PikPak / 联通沃盘 / OneDrive)
|
||||
2. 视频元数据目录(SQLite)+ 扫描 + teaser 预生成
|
||||
3. REST API(前台)+ 管理后台 + 直链代理
|
||||
4. 标签池、视频隐藏、按网盘统计和管理查询能力
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -19,8 +20,9 @@ internal/
|
||||
p115/ 115(壳子 + SheltonZhu/115driver)
|
||||
pikpak/ PikPak(自己实现,参考 OpenList pikpak)
|
||||
wopan/ 联通沃盘(壳子 + OpenListTeam/wopan-sdk-go)
|
||||
onedrive/ OneDrive(OpenList 在线续期 + Microsoft Graph 文件接口)
|
||||
scanner/ 扫目录 → 落库
|
||||
preview/ ffmpeg 抽 10s teaser
|
||||
preview/ ffmpeg 抽封面和生成多段 teaser
|
||||
proxy/ /p/stream/*、/p/preview/* 代理
|
||||
auth/ 管理员 session
|
||||
api/ REST 路由
|
||||
@@ -41,9 +43,17 @@ C:\Users\<you>\tools\
|
||||
|
||||
### 第一次启动
|
||||
|
||||
Git Bash / WSL 环境推荐从仓库根目录启动完整开发环境:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
./start.sh
|
||||
```
|
||||
|
||||
PowerShell 下可以分两个终端手动启动,后端命令如下:
|
||||
|
||||
```powershell
|
||||
cd F:\VideoProject\backend
|
||||
go mod tidy
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
@@ -53,19 +63,23 @@ go run ./cmd/server
|
||||
- `data/video-site.db`
|
||||
- `data/previews/`
|
||||
|
||||
默认监听 `:8080`,默认管理员 `admin / admin123`(务必在 `config.yaml` 里改)。
|
||||
默认监听 `127.0.0.1:9192`,默认管理员 `admin / admin123`(务必在 `config.yaml` 里改)。如果本地已有旧的 `config.yaml`,请确认 `server.listen` 与前端代理端口一致。
|
||||
|
||||
### 连接前端
|
||||
|
||||
`vite.config.ts` 已经把 `/api`、`/p`、`/admin` 代理到 `8080`。
|
||||
`vite.config.ts` 已经把 `/api`、`/p`、`/admin/api` 代理到 `127.0.0.1:9192`。
|
||||
|
||||
```
|
||||
npm run dev 前端 5173
|
||||
go run ./cmd/server 后端 8080
|
||||
npm run dev 前端 9191
|
||||
go run ./cmd/server 后端 9192
|
||||
```
|
||||
|
||||
## 添加一个盘
|
||||
|
||||
推荐在前端管理后台 `/admin/drives` 新增网盘。保存后会立即挂载并触发扫描;视频结果可在 `/admin/videos` 按网盘查看,每页 100 条,页面会同时显示各网盘 Teaser 已生成、待生成、失败数量。
|
||||
|
||||
也可以直接调用后端接口:
|
||||
|
||||
1. 登录管理后台:`POST /admin/api/login` body `{"username":"admin","password":"admin123"}`
|
||||
2. 新建盘:`POST /admin/api/drives`
|
||||
```json
|
||||
@@ -90,6 +104,9 @@ go run ./cmd/server 后端 8080
|
||||
| p115 | `cookie`(形如 `UID=...; CID=...; SEID=...; KID=...`) |
|
||||
| pikpak | `username`、`password`,可选 `refresh_token`、`captcha_token`、`device_id`、`platform`、`disable_media_link` |
|
||||
| wopan | `access_token`、`refresh_token`,可选 `family_id` |
|
||||
| onedrive | `refresh_token`,可选 `access_token`、`api_url_address`、`region`、`is_sharepoint`、`site_id` |
|
||||
|
||||
OneDrive 按 OpenList 默认方式调用 `https://api.oplist.org/onedrive/renewapi` 在线刷新 token,不需要配置 Azure 应用的 `client_id` / `client_secret` / `redirect_uri`。OpenList 代刷得到的 refresh token 可以直接填到本项目。普通 OneDrive 的 `rootId` / `scanRootId` 可填 `root`;SharePoint 文档库需要额外设置 `is_sharepoint=true` 和 `site_id`。
|
||||
|
||||
## 文件名约定
|
||||
|
||||
@@ -100,22 +117,43 @@ go run ./cmd/server 后端 8080
|
||||
3. `标题 - 作者.mp4`
|
||||
4. `标题.mp4`
|
||||
|
||||
标签分隔符支持 `, , 、` 和空格。解析结果可在管理后台覆盖。
|
||||
标签分隔符支持 `, , 、` 和空格。解析结果会和系统标签池匹配,常见番号类噪声会归并到 `AV` 等系统标签,避免把每个番号都变成独立标签。解析结果可在管理后台覆盖。
|
||||
|
||||
## 管理能力
|
||||
|
||||
- `/admin/drives`:新增、编辑、删除网盘,触发扫描。
|
||||
- `/admin/videos`:按网盘筛选视频,每页 100 条分页,查看各网盘 Teaser 统计,编辑标题/作者/分类/标签,单条或全量重生 teaser。
|
||||
- `/admin/tags`:新增标签并用标签规则自动匹配已有视频。
|
||||
- 播放页提供“不再展示”,点击后会把视频标记为全局隐藏;隐藏视频不会再出现在首页、列表、搜索、相关推荐和详情接口中。目前没有管理后台恢复入口,如需恢复可把数据库里对应视频的 `hidden` 字段改回 `0`。
|
||||
|
||||
## Teaser 生成
|
||||
|
||||
scanner 扫到新视频会把 `(driveID, videoID)` 丢进 worker 队列,调用:
|
||||
scanner 扫到新视频会把 `(driveID, videoID)` 丢进 worker 队列。worker 会先用 `ffprobe` 探测时长,再用 `ffmpeg` 抽封面和生成无声 teaser:
|
||||
|
||||
```
|
||||
ffmpeg -ss 10 -headers "UA/Cookie/Referer" -i <直链> \
|
||||
-t 10 -an -vf scale=480:-2 -c:v libx264 -preset veryfast -crf 28 \
|
||||
ffmpeg -ss <起点> -headers "UA/Cookie/Referer" -i <直链> \
|
||||
-t 3 -an -vf scale=480:-2 -c:v libx264 -preset veryfast -crf 28 \
|
||||
-movflags +faststart -y <local>.mp4
|
||||
```
|
||||
|
||||
优先把 teaser 上传回网盘的 `previews/` 目录;失败时保留本地 `data/previews/<videoID>.mp4` 作为兜底。
|
||||
当前策略是每段固定 3 秒;30 秒以下最多 3 段,30 秒及以上固定 4 段;长视频在 20% 到 80% 区间均匀取段。优先把 teaser 上传回网盘的 `previews/` 目录;失败时保留本地 `data/previews/<videoID>.mp4` 作为兜底。
|
||||
|
||||
服务启动或网盘重新挂载时,如果 Teaser 开关已开启,后端会把历史 `pending` 任务重新入队,避免重启后长期停在“待生成”。OneDrive 直链生成 teaser 时可能触发 Microsoft 429 限流;这类任务会标记为 `failed`,可稍后在视频管理页重生。
|
||||
|
||||
前端卡片的 `previewSrc` 统一指向 `/p/preview/<videoID>`,后端自动选择网盘代理或本地文件。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
# 前端,在仓库根目录执行
|
||||
npm run lint
|
||||
npm run build
|
||||
node --test tests/previewIntent.test.ts
|
||||
|
||||
# 后端,在 backend/ 执行
|
||||
go test ./... -count=1
|
||||
```
|
||||
|
||||
## 部署到 Linux
|
||||
|
||||
```bash
|
||||
|
||||
+104
-8
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"github.com/video-site/backend/internal/catalog"
|
||||
"github.com/video-site/backend/internal/config"
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
"github.com/video-site/backend/internal/drives/onedrive"
|
||||
"github.com/video-site/backend/internal/drives/p115"
|
||||
"github.com/video-site/backend/internal/drives/pikpak"
|
||||
"github.com/video-site/backend/internal/drives/quark"
|
||||
@@ -109,6 +111,9 @@ func main() {
|
||||
OnRegenPreview: func(videoID string) {
|
||||
go app.regenPreview(ctx, videoID)
|
||||
},
|
||||
OnRegenAllPreviews: func() {
|
||||
go app.regenAllPreviews(ctx)
|
||||
},
|
||||
GetPreviewEnabled: func() bool { return app.PreviewEnabled() },
|
||||
SetPreviewEnabled: func(enabled bool) error {
|
||||
return app.SetPreviewEnabled(ctx, enabled)
|
||||
@@ -272,6 +277,25 @@ func (a *App) attachDrive(ctx context.Context, d *catalog.Drive) error {
|
||||
_ = a.cat.UpsertDrive(ctx, d)
|
||||
},
|
||||
})
|
||||
case "onedrive":
|
||||
drv = onedrive.New(onedrive.Config{
|
||||
ID: d.ID,
|
||||
RootID: d.RootID,
|
||||
Region: d.Credentials["region"],
|
||||
AccessToken: d.Credentials["access_token"],
|
||||
RefreshToken: d.Credentials["refresh_token"],
|
||||
IsSharePoint: parseBoolDefault(d.Credentials["is_sharepoint"], false),
|
||||
SiteID: d.Credentials["site_id"],
|
||||
RenewAPIURL: d.Credentials["api_url_address"],
|
||||
OnTokenUpdate: func(access, refresh string) {
|
||||
if d.Credentials == nil {
|
||||
d.Credentials = make(map[string]string)
|
||||
}
|
||||
d.Credentials["access_token"] = access
|
||||
d.Credentials["refresh_token"] = refresh
|
||||
_ = a.cat.UpsertDrive(ctx, d)
|
||||
},
|
||||
})
|
||||
default:
|
||||
return fmt.Errorf("unknown drive kind: %s", d.Kind)
|
||||
}
|
||||
@@ -306,19 +330,46 @@ func (a *App) attachDrive(ctx context.Context, d *catalog.Drive) error {
|
||||
go worker.Run(workerCtx)
|
||||
go thumbWorker.Run(workerCtx)
|
||||
|
||||
a.registerPreviewWorkers(ctx, d.ID, worker, thumbWorker, cancel)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) registerPreviewWorkers(ctx context.Context, driveID string, worker *preview.Worker, thumbWorker *preview.ThumbWorker, cancel context.CancelFunc) {
|
||||
a.mu.Lock()
|
||||
if a.cancels == nil {
|
||||
a.cancels = make(map[string]context.CancelFunc)
|
||||
}
|
||||
if old, ok := a.cancels[d.ID]; ok {
|
||||
if a.workers == nil {
|
||||
a.workers = make(map[string]*preview.Worker)
|
||||
}
|
||||
if a.thumbWorkers == nil {
|
||||
a.thumbWorkers = make(map[string]*preview.ThumbWorker)
|
||||
}
|
||||
if old, ok := a.cancels[driveID]; ok && old != nil {
|
||||
old()
|
||||
}
|
||||
a.workers[d.ID] = worker
|
||||
a.thumbWorkers[d.ID] = thumbWorker
|
||||
a.cancels[d.ID] = cancel
|
||||
if worker != nil {
|
||||
a.workers[driveID] = worker
|
||||
} else {
|
||||
delete(a.workers, driveID)
|
||||
}
|
||||
if thumbWorker != nil {
|
||||
a.thumbWorkers[driveID] = thumbWorker
|
||||
} else {
|
||||
delete(a.thumbWorkers, driveID)
|
||||
}
|
||||
if cancel != nil {
|
||||
a.cancels[driveID] = cancel
|
||||
} else {
|
||||
delete(a.cancels, driveID)
|
||||
}
|
||||
previewEnabled := a.previewEnabled
|
||||
a.mu.Unlock()
|
||||
|
||||
return nil
|
||||
if previewEnabled && worker != nil {
|
||||
go a.enqueuePending(ctx, driveID, worker)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) enqueuePending(ctx context.Context, driveID string, w *preview.Worker) {
|
||||
@@ -332,7 +383,10 @@ func (a *App) enqueuePending(ctx context.Context, driveID string, w *preview.Wor
|
||||
}
|
||||
log.Printf("[preview] enqueue %d pending videos for drive=%s", len(pending), driveID)
|
||||
for _, v := range pending {
|
||||
w.Enqueue(v)
|
||||
if !w.EnqueueBlocking(ctx, v) {
|
||||
log.Printf("[preview] enqueue pending canceled for drive=%s", driveID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,7 +401,10 @@ func (a *App) enqueueThumbnails(ctx context.Context, driveID string, w *preview.
|
||||
}
|
||||
log.Printf("[thumb] enqueue %d missing thumbnails for drive=%s", len(pending), driveID)
|
||||
for _, v := range pending {
|
||||
w.Enqueue(v)
|
||||
if !w.EnqueueBlocking(ctx, v) {
|
||||
log.Printf("[thumb] enqueue missing thumbnails canceled for drive=%s", driveID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,10 +481,38 @@ func (a *App) regenPreview(ctx context.Context, videoID string) {
|
||||
worker := a.workers[v.DriveID]
|
||||
a.mu.Unlock()
|
||||
if worker != nil {
|
||||
worker.Enqueue(v)
|
||||
worker.EnqueueBlocking(ctx, v)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) regenAllPreviews(ctx context.Context) {
|
||||
items, total, err := a.cat.ListVideos(ctx, catalog.ListParams{Page: 1, PageSize: 1000000})
|
||||
if err != nil {
|
||||
log.Printf("[preview] list all videos for regen: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("[preview] enqueue all visible videos for regen count=%d total=%d", len(items), total)
|
||||
queued := 0
|
||||
for _, v := range items {
|
||||
if err := ctx.Err(); err != nil {
|
||||
log.Printf("[preview] enqueue all canceled after %d videos: %v", queued, err)
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
worker := a.workers[v.DriveID]
|
||||
a.mu.Unlock()
|
||||
if worker == nil {
|
||||
continue
|
||||
}
|
||||
if !worker.EnqueueBlocking(ctx, v) {
|
||||
log.Printf("[preview] enqueue all canceled after %d videos", queued)
|
||||
return
|
||||
}
|
||||
queued++
|
||||
}
|
||||
log.Printf("[preview] enqueued all visible videos for regen queued=%d", queued)
|
||||
}
|
||||
|
||||
func (a *App) scanLoop(ctx context.Context) {
|
||||
// 启动后立刻扫一次
|
||||
a.scanAllOnce(ctx)
|
||||
@@ -475,3 +560,14 @@ func originOr(r *http.Request, fallback string) string {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func parseBoolDefault(raw string, def bool) bool {
|
||||
if raw == "" {
|
||||
return def
|
||||
}
|
||||
v, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/video-site/backend/internal/catalog"
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
"github.com/video-site/backend/internal/preview"
|
||||
)
|
||||
|
||||
func TestRegisterPreviewWorkerBackfillsPendingWhenPreviewEnabled(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
video := &catalog.Video{
|
||||
ID: "video-1",
|
||||
DriveID: "drive-id",
|
||||
FileID: "file-id",
|
||||
Title: "Clip",
|
||||
PreviewStatus: "pending",
|
||||
PublishedAt: time.Now(),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := cat.UpsertVideo(ctx, video); err != nil {
|
||||
t.Fatalf("seed video: %v", err)
|
||||
}
|
||||
|
||||
app := &App{
|
||||
cat: cat,
|
||||
workers: make(map[string]*preview.Worker),
|
||||
thumbWorkers: make(map[string]*preview.ThumbWorker),
|
||||
previewEnabled: true,
|
||||
}
|
||||
worker := preview.NewWorker(&serverFakeTeaserGenerator{}, cat, &serverFakeDrive{}, "")
|
||||
go worker.Run(ctx)
|
||||
|
||||
app.registerPreviewWorkers(ctx, "drive-id", worker, nil, func() {})
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
got, err := cat.GetVideo(ctx, video.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get video: %v", err)
|
||||
}
|
||||
if got.PreviewStatus == "ready" {
|
||||
if got.PreviewLocal != "/tmp/video-1.mp4" {
|
||||
t.Fatalf("preview local = %q, want generated local teaser path", got.PreviewLocal)
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
got, err := cat.GetVideo(ctx, video.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get video after timeout: %v", err)
|
||||
}
|
||||
t.Fatalf("preview status = %q, want ready", got.PreviewStatus)
|
||||
}
|
||||
|
||||
type serverFakeTeaserGenerator struct{}
|
||||
|
||||
func (g *serverFakeTeaserGenerator) Probe(context.Context, *drives.StreamLink) (float64, error) {
|
||||
return 30, nil
|
||||
}
|
||||
|
||||
func (g *serverFakeTeaserGenerator) Generate(context.Context, *drives.StreamLink, float64) (string, error) {
|
||||
return "/tmp/source-teaser.mp4", nil
|
||||
}
|
||||
|
||||
func (g *serverFakeTeaserGenerator) MoveToLocal(_ string, videoID string) (string, error) {
|
||||
return "/tmp/" + videoID + ".mp4", nil
|
||||
}
|
||||
|
||||
type serverFakeDrive struct{}
|
||||
|
||||
func (d *serverFakeDrive) Kind() string { return "fake" }
|
||||
func (d *serverFakeDrive) ID() string { return "drive-id" }
|
||||
func (d *serverFakeDrive) Init(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
func (d *serverFakeDrive) List(context.Context, string) ([]drives.Entry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (d *serverFakeDrive) Stat(context.Context, string) (*drives.Entry, error) {
|
||||
return nil, drives.ErrNotSupported
|
||||
}
|
||||
func (d *serverFakeDrive) StreamURL(context.Context, string) (*drives.StreamLink, error) {
|
||||
return &drives.StreamLink{URL: "https://video.example/clip.mp4"}, nil
|
||||
}
|
||||
func (d *serverFakeDrive) Upload(context.Context, string, string, io.Reader, int64) (string, error) {
|
||||
return "", drives.ErrNotSupported
|
||||
}
|
||||
func (d *serverFakeDrive) EnsureDir(context.Context, string) (string, error) {
|
||||
return "", drives.ErrNotSupported
|
||||
}
|
||||
func (d *serverFakeDrive) RootID() string { return "root" }
|
||||
@@ -1,6 +1,7 @@
|
||||
# backend 配置示例。首次启动若未发现 config.yaml,会基于此文件自动创建。
|
||||
server:
|
||||
listen: ":8080"
|
||||
# 本地开发默认配合 vite.config.ts 的代理端口。
|
||||
listen: "127.0.0.1:9192"
|
||||
# 管理后台用户,生产环境请务必修改
|
||||
admin:
|
||||
username: "admin"
|
||||
@@ -28,9 +29,9 @@ preview:
|
||||
# ffmpeg / ffprobe 可执行文件名或绝对路径
|
||||
ffmpeg_path: "ffmpeg"
|
||||
ffprobe_path: "ffprobe"
|
||||
# teaser 总时长(秒)。多段模式下会均分给每段
|
||||
duration_seconds: 9
|
||||
# teaser 段数。1=从视频 25% 位置取单段;>=2 按时长自适应切段并拼接
|
||||
# teaser 每段时长(秒),实际生成时每段最多 3 秒
|
||||
duration_seconds: 3
|
||||
# 兼容旧配置;当前 30 秒以下最多 3 段,30 秒及以上固定 4 段
|
||||
segments: 3
|
||||
# teaser 视频宽度
|
||||
width: 480
|
||||
@@ -38,5 +39,14 @@ preview:
|
||||
remote_dir: "/previews"
|
||||
|
||||
# 盘列表。上线后请通过管理后台添加,本文件可留空。
|
||||
# kind 支持 quark / p115 / pikpak / wopan。
|
||||
# kind 支持 quark / p115 / pikpak / wopan / onedrive。
|
||||
# OneDrive 示例:
|
||||
# - id: "my-onedrive"
|
||||
# kind: "onedrive"
|
||||
# name: "我的 OneDrive"
|
||||
# root_id: "root"
|
||||
# params:
|
||||
# refresh_token: "..."
|
||||
# api_url_address: "https://api.oplist.org/onedrive/renewapi"
|
||||
# region: "global"
|
||||
drives: []
|
||||
|
||||
+104
-18
@@ -2,7 +2,9 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
@@ -14,10 +16,11 @@ type AdminServer struct {
|
||||
Catalog *catalog.Catalog
|
||||
Auth *auth.Authenticator
|
||||
// Hooks:外层注入实际执行者
|
||||
OnDriveSaved func(driveID string) error
|
||||
OnDriveRemoved func(driveID string)
|
||||
OnScanRequested func(driveID string)
|
||||
OnRegenPreview func(videoID string)
|
||||
OnDriveSaved func(driveID string) error
|
||||
OnDriveRemoved func(driveID string)
|
||||
OnScanRequested func(driveID string)
|
||||
OnRegenPreview func(videoID string)
|
||||
OnRegenAllPreviews func()
|
||||
// Preview 开关读写
|
||||
GetPreviewEnabled func() bool
|
||||
SetPreviewEnabled func(enabled bool) error
|
||||
@@ -43,8 +46,13 @@ func (a *AdminServer) Register(r chi.Router) {
|
||||
// 视频
|
||||
r.Get("/videos", a.handleAdminListVideos)
|
||||
r.Put("/videos/{id}", a.handleUpdateVideo)
|
||||
r.Post("/videos/regen-preview", a.handleRegenAllPreviews)
|
||||
r.Post("/videos/{id}/regen-preview", a.handleRegenPreview)
|
||||
|
||||
// 标签
|
||||
r.Get("/tags", a.handleListTags)
|
||||
r.Post("/tags", a.handleCreateTag)
|
||||
|
||||
// 运行时设置
|
||||
r.Get("/settings", a.handleGetSettings)
|
||||
r.Put("/settings", a.handlePutSettings)
|
||||
@@ -96,24 +104,36 @@ func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
teaserCounts, err := a.Catalog.CountTeasersByDrive(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
// 出参不返回凭证明文,只告诉前端是否已配置
|
||||
type out struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
RootID string `json:"rootId"`
|
||||
ScanRootID string `json:"scanRootId"`
|
||||
Status string `json:"status"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
HasCredential bool `json:"hasCredential"`
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
RootID string `json:"rootId"`
|
||||
ScanRootID string `json:"scanRootId"`
|
||||
Status string `json:"status"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
HasCredential bool `json:"hasCredential"`
|
||||
TeaserReadyCount int `json:"teaserReadyCount"`
|
||||
TeaserPendingCount int `json:"teaserPendingCount"`
|
||||
TeaserFailedCount int `json:"teaserFailedCount"`
|
||||
}
|
||||
list := make([]out, 0, len(drives))
|
||||
for _, d := range drives {
|
||||
counts := teaserCounts[d.ID]
|
||||
list = append(list, out{
|
||||
ID: d.ID, Kind: d.Kind, Name: d.Name,
|
||||
RootID: d.RootID, ScanRootID: d.ScanRootID,
|
||||
Status: d.Status, LastError: d.LastError,
|
||||
HasCredential: len(d.Credentials) > 0,
|
||||
HasCredential: len(d.Credentials) > 0,
|
||||
TeaserReadyCount: counts.Ready,
|
||||
TeaserPendingCount: counts.Pending,
|
||||
TeaserFailedCount: counts.Failed,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
@@ -183,14 +203,61 @@ func (a *AdminServer) handleRescan(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleAdminListVideos(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.ListVideos(r.Context(), catalog.ListParams{
|
||||
Page: 1, PageSize: 100,
|
||||
DriveID: q.Get("driveId"),
|
||||
Page: page,
|
||||
PageSize: size,
|
||||
})
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total})
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleListTags(w http.ResponseWriter, r *http.Request) {
|
||||
tags, err := a.Catalog.ListTags(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tags)
|
||||
}
|
||||
|
||||
type createTagReq struct {
|
||||
Label string `json:"label"`
|
||||
Aliases []string `json:"aliases"`
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleCreateTag(w http.ResponseWriter, r *http.Request) {
|
||||
var body createTagReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
classified, err := a.Catalog.CreateTagAndClassify(r.Context(), body.Label, body.Aliases, "user")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"label": body.Label,
|
||||
"classified": classified,
|
||||
})
|
||||
}
|
||||
|
||||
type updateVideoReq struct {
|
||||
@@ -223,9 +290,6 @@ func (a *AdminServer) handleUpdateVideo(w http.ResponseWriter, r *http.Request)
|
||||
if body.Author != "" {
|
||||
v.Author = body.Author
|
||||
}
|
||||
if body.Tags != nil {
|
||||
v.Tags = body.Tags
|
||||
}
|
||||
if body.Category != "" {
|
||||
v.Category = body.Category
|
||||
}
|
||||
@@ -248,6 +312,21 @@ func (a *AdminServer) handleUpdateVideo(w http.ResponseWriter, r *http.Request)
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if body.Tags != nil {
|
||||
if err := a.Catalog.SetManualVideoTags(r.Context(), id, body.Tags); err != nil {
|
||||
if errors.Is(err, catalog.ErrUnknownTag) {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
v, err = a.Catalog.GetVideo(r.Context(), id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, v)
|
||||
}
|
||||
|
||||
@@ -259,6 +338,13 @@ func (a *AdminServer) handleRegenPreview(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleRegenAllPreviews(w http.ResponseWriter, r *http.Request) {
|
||||
if a.OnRegenAllPreviews != nil {
|
||||
a.OnRegenAllPreviews()
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// ---------- Settings ----------
|
||||
|
||||
type settingsDTO struct {
|
||||
|
||||
@@ -3,10 +3,12 @@ package api
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/video-site/backend/internal/catalog"
|
||||
)
|
||||
@@ -116,3 +118,259 @@ func TestHandleUpsertDriveReplacesExistingCredentialsWhenProvided(t *testing.T)
|
||||
t.Fatalf("cookie credential = %q, want new-cookie", got.Credentials["cookie"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleListDrivesIncludesTeaserCounts(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)
|
||||
}
|
||||
})
|
||||
|
||||
for _, d := range []*catalog.Drive{
|
||||
{ID: "OneDrive", Kind: "onedrive", Name: "OneDrive", RootID: "root", Status: "ok"},
|
||||
{ID: "PikPak", Kind: "pikpak", Name: "PikPak", RootID: "", Status: "ok"},
|
||||
} {
|
||||
if err := cat.UpsertDrive(ctx, d); err != nil {
|
||||
t.Fatalf("seed drive %s: %v", d.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
videos := []*catalog.Video{
|
||||
{ID: "od-ready-1", DriveID: "OneDrive", FileID: "od-file-1", Title: "OD Ready 1", PreviewStatus: "ready", PublishedAt: now, CreatedAt: now, UpdatedAt: now},
|
||||
{ID: "od-ready-2", DriveID: "OneDrive", FileID: "od-file-2", Title: "OD Ready 2", PreviewStatus: "ready", PublishedAt: now, CreatedAt: now, UpdatedAt: now},
|
||||
{ID: "od-pending", DriveID: "OneDrive", FileID: "od-file-3", Title: "OD Pending", PreviewStatus: "pending", PublishedAt: now, CreatedAt: now, UpdatedAt: now},
|
||||
{ID: "pp-pending", DriveID: "PikPak", FileID: "pp-file-1", Title: "PP Pending", PreviewStatus: "pending", PublishedAt: now, CreatedAt: now, UpdatedAt: now},
|
||||
{ID: "pp-failed", DriveID: "PikPak", FileID: "pp-file-2", Title: "PP Failed", PreviewStatus: "failed", PublishedAt: now, CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
for _, v := range videos {
|
||||
if err := cat.UpsertVideo(ctx, v); err != nil {
|
||||
t.Fatalf("seed video %s: %v", v.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/api/drives", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
(&AdminServer{Catalog: cat}).handleListDrives(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got []struct {
|
||||
ID string `json:"id"`
|
||||
TeaserReadyCount int `json:"teaserReadyCount"`
|
||||
TeaserPendingCount int `json:"teaserPendingCount"`
|
||||
TeaserFailedCount int `json:"teaserFailedCount"`
|
||||
}
|
||||
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
byID := map[string]struct {
|
||||
Ready int
|
||||
Pending int
|
||||
Failed int
|
||||
}{}
|
||||
for _, d := range got {
|
||||
byID[d.ID] = struct {
|
||||
Ready int
|
||||
Pending int
|
||||
Failed int
|
||||
}{Ready: d.TeaserReadyCount, Pending: d.TeaserPendingCount, Failed: d.TeaserFailedCount}
|
||||
}
|
||||
if byID["OneDrive"].Ready != 2 || byID["OneDrive"].Pending != 1 || byID["OneDrive"].Failed != 0 {
|
||||
t.Fatalf("OneDrive counts = %#v, want ready=2 pending=1 failed=0", byID["OneDrive"])
|
||||
}
|
||||
if byID["PikPak"].Ready != 0 || byID["PikPak"].Pending != 1 || byID["PikPak"].Failed != 1 {
|
||||
t.Fatalf("PikPak counts = %#v, want ready=0 pending=1 failed=1", byID["PikPak"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCreateTagClassifiesExistingVideos(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()
|
||||
if err := cat.UpsertVideo(ctx, &catalog.Video{
|
||||
ID: "video-1",
|
||||
DriveID: "drive",
|
||||
FileID: "file-1",
|
||||
Title: "清纯短发",
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed video: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/api/tags", strings.NewReader(`{"label":"清纯"}`))
|
||||
rr := httptest.NewRecorder()
|
||||
(&AdminServer{Catalog: cat}).handleCreateTag(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got struct {
|
||||
Label string `json:"label"`
|
||||
Classified int `json:"classified"`
|
||||
}
|
||||
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.Label != "清纯" || got.Classified != 1 {
|
||||
t.Fatalf("response = %#v, want 清纯 classified 1", got)
|
||||
}
|
||||
|
||||
video, err := cat.GetVideo(ctx, "video-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get video: %v", err)
|
||||
}
|
||||
if len(video.Tags) != 1 || video.Tags[0] != "清纯" {
|
||||
t.Fatalf("video tags = %#v, want 清纯", video.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminListVideosFiltersByDriveID(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()
|
||||
videos := []*catalog.Video{
|
||||
{
|
||||
ID: "od-video",
|
||||
DriveID: "OneDrive",
|
||||
FileID: "od-file",
|
||||
Title: "OneDrive video",
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: "pp-video",
|
||||
DriveID: "PikPak",
|
||||
FileID: "pp-file",
|
||||
Title: "PikPak video",
|
||||
PublishedAt: now.Add(-time.Hour),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
for _, v := range videos {
|
||||
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}).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 != 1 || len(got.Items) != 1 {
|
||||
t.Fatalf("response total/items = %d/%d, want 1/1: %#v", got.Total, len(got.Items), got.Items)
|
||||
}
|
||||
if got.Items[0].DriveID != "OneDrive" || got.Items[0].ID != "od-video" {
|
||||
t.Fatalf("item = %#v, want OneDrive od-video", got.Items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminListVideosPaginates(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 i, title := range []string{"first", "second", "third"} {
|
||||
v := &catalog.Video{
|
||||
ID: title,
|
||||
DriveID: "OneDrive",
|
||||
FileID: title + "-file",
|
||||
Title: title,
|
||||
PublishedAt: now.Add(-time.Duration(i) * 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&page=2&size=2", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
(&AdminServer{Catalog: cat}).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"`
|
||||
Page int `json:"page"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.Total != 3 || got.Page != 2 || got.Size != 2 {
|
||||
t.Fatalf("pagination meta = total:%d page:%d size:%d, want 3/2/2", got.Total, got.Page, got.Size)
|
||||
}
|
||||
if len(got.Items) != 1 || got.Items[0].ID != "third" {
|
||||
t.Fatalf("items = %#v, want only third", got.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRegenAllPreviewsInvokesHook(t *testing.T) {
|
||||
called := false
|
||||
server := &AdminServer{
|
||||
OnRegenAllPreviews: func() {
|
||||
called = true
|
||||
},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/api/videos/regen-preview", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
server.handleRegenAllPreviews(rr, req)
|
||||
|
||||
if rr.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("regen all previews hook was not called")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -17,7 +19,6 @@ import (
|
||||
|
||||
"github.com/video-site/backend/internal/auth"
|
||||
"github.com/video-site/backend/internal/catalog"
|
||||
"github.com/video-site/backend/internal/fixedtags"
|
||||
"github.com/video-site/backend/internal/proxy"
|
||||
)
|
||||
|
||||
@@ -88,7 +89,9 @@ func (s *Server) RegisterRoutes(r chi.Router, a *auth.Authenticator) {
|
||||
r.Get("/api/home", s.handleHome)
|
||||
r.Get("/api/list", s.handleList)
|
||||
r.Get("/api/video/{id}", s.handleVideoDetail)
|
||||
r.Put("/api/video/{id}/tags", s.handleUpdateVideoTags)
|
||||
r.Post("/api/video/{id}/like", s.handleLike)
|
||||
r.Post("/api/video/{id}/hide", s.handleHideVideo)
|
||||
r.Get("/api/tags", s.handleTags)
|
||||
|
||||
// 代理路由同样需要鉴权,防止绕过
|
||||
@@ -147,6 +150,10 @@ func (s *Server) handleVideoDetail(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
if v.Hidden {
|
||||
writeErr(w, http.StatusNotFound, sql.ErrNoRows)
|
||||
return
|
||||
}
|
||||
related, _, _ := s.Catalog.ListVideos(r.Context(), catalog.ListParams{
|
||||
Sort: "hot", Page: 1, PageSize: 8,
|
||||
})
|
||||
@@ -170,7 +177,7 @@ func (s *Server) handleVideoDetail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := s.Catalog.CountTags(r.Context(), fixedtags.Labels)
|
||||
stats, err := s.Catalog.ListTags(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
@@ -187,6 +194,33 @@ func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type updateVideoTagsReq struct {
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateVideoTags(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var body updateVideoTagsReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := s.Catalog.SetManualVideoTags(r.Context(), id, body.Tags); err != nil {
|
||||
if errors.Is(err, catalog.ErrUnknownTag) {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
v, err := s.Catalog.GetVideo(r.Context(), id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, mapVideo(v))
|
||||
}
|
||||
|
||||
func (s *Server) handleLike(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
likes, err := s.Catalog.IncrementLike(r.Context(), id)
|
||||
@@ -197,6 +231,19 @@ func (s *Server) handleLike(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"likes": likes})
|
||||
}
|
||||
|
||||
func (s *Server) handleHideVideo(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := s.Catalog.HideVideo(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 (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||
driveID := chi.URLParam(r, "driveID")
|
||||
fileID := chi.URLParam(r, "fileID")
|
||||
@@ -388,7 +435,7 @@ func mapVideo(v *catalog.Video) VideoDTO {
|
||||
Title: v.Title,
|
||||
Thumbnail: thumbnailURL(v),
|
||||
PreviewSrc: "/p/preview/" + v.ID,
|
||||
PreviewDuration: 10,
|
||||
PreviewDuration: 12,
|
||||
PreviewStrategy: "teaser-file",
|
||||
Duration: formatDuration(v.DurationSeconds),
|
||||
Badges: badges,
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/video-site/backend/internal/catalog"
|
||||
)
|
||||
|
||||
@@ -78,7 +80,7 @@ func TestTranscodeTempPathKeepsMp4Extension(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTagsReturnsFixedTagsOnly(t *testing.T) {
|
||||
func TestHandleTagsReturnsUnifiedTagPool(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
|
||||
if err != nil {
|
||||
@@ -94,8 +96,8 @@ func TestHandleTagsReturnsFixedTagsOnly(t *testing.T) {
|
||||
ID: "video-1",
|
||||
DriveID: "drive",
|
||||
FileID: "file-1",
|
||||
Title: "女大后入",
|
||||
Tags: []string{"后入", "女大", "sunny"},
|
||||
Title: "清纯女大后入",
|
||||
Tags: []string{"后入", "女大"},
|
||||
Category: "random-category",
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
@@ -103,6 +105,9 @@ func TestHandleTagsReturnsFixedTagsOnly(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatalf("seed video: %v", err)
|
||||
}
|
||||
if _, err := cat.CreateTagAndClassify(ctx, "清纯", nil, "user"); err != nil {
|
||||
t.Fatalf("create tag: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/tags", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
@@ -123,12 +128,170 @@ func TestHandleTagsReturnsFixedTagsOnly(t *testing.T) {
|
||||
for _, tag := range got {
|
||||
labels = append(labels, tag.Label)
|
||||
}
|
||||
want := []string{"后入", "奶子", "口交", "臀", "人妻", "女大"}
|
||||
if !sameStrings(labels, want) {
|
||||
t.Fatalf("labels = %#v, want %#v", labels, want)
|
||||
if !containsString(labels, "清纯") {
|
||||
t.Fatalf("labels = %#v, want user tag 清纯", labels)
|
||||
}
|
||||
if got[0].Count != 1 || got[5].Count != 1 {
|
||||
t.Fatalf("counts = %#v, want 后入 and 女大 count 1", got)
|
||||
if !containsString(labels, "后入") {
|
||||
t.Fatalf("labels = %#v, want system tag 后入", labels)
|
||||
}
|
||||
var qingchunCount int
|
||||
for _, tag := range got {
|
||||
if tag.Label == "清纯" {
|
||||
qingchunCount = tag.Count
|
||||
}
|
||||
}
|
||||
if qingchunCount != 1 {
|
||||
t.Fatalf("清纯 count = %d, want 1; tags = %#v", qingchunCount, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpdateVideoTagsRejectsUnknownTags(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()
|
||||
if err := cat.UpsertVideo(ctx, &catalog.Video{
|
||||
ID: "video-1",
|
||||
DriveID: "drive",
|
||||
FileID: "file-1",
|
||||
Title: "普通标题",
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed video: %v", err)
|
||||
}
|
||||
|
||||
req := requestWithVideoID(http.MethodPut, "/api/video/video-1/tags", "video-1", strings.NewReader(`{"tags":["不存在"]}`))
|
||||
rr := httptest.NewRecorder()
|
||||
(&Server{Catalog: cat}).handleUpdateVideoTags(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpdateVideoTagsSavesExistingTags(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()
|
||||
if err := cat.UpsertVideo(ctx, &catalog.Video{
|
||||
ID: "video-1",
|
||||
DriveID: "drive",
|
||||
FileID: "file-1",
|
||||
Title: "清纯标题",
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed video: %v", err)
|
||||
}
|
||||
if _, err := cat.CreateTagAndClassify(ctx, "清纯", nil, "user"); err != nil {
|
||||
t.Fatalf("create tag: %v", err)
|
||||
}
|
||||
|
||||
req := requestWithVideoID(http.MethodPut, "/api/video/video-1/tags", "video-1", strings.NewReader(`{"tags":["清纯"]}`))
|
||||
rr := httptest.NewRecorder()
|
||||
(&Server{Catalog: cat}).handleUpdateVideoTags(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
got, err := cat.GetVideo(ctx, "video-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get video: %v", err)
|
||||
}
|
||||
if !sameStrings(got.Tags, []string{"清纯"}) {
|
||||
t.Fatalf("tags = %#v, want 清纯", got.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleHideVideoRemovesVideoFromPublicListAndDetail(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: "video-hidden",
|
||||
DriveID: "drive",
|
||||
FileID: "file-hidden",
|
||||
Title: "Hide me",
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: "video-visible",
|
||||
DriveID: "drive",
|
||||
FileID: "file-visible",
|
||||
Title: "Keep me",
|
||||
PublishedAt: now.Add(-time.Minute),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
} {
|
||||
if err := cat.UpsertVideo(ctx, v); err != nil {
|
||||
t.Fatalf("seed video %s: %v", v.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
server := &Server{Catalog: cat}
|
||||
hideReq := requestWithVideoID(http.MethodPost, "/api/video/video-hidden/hide", "video-hidden", strings.NewReader(``))
|
||||
hideRR := httptest.NewRecorder()
|
||||
server.handleHideVideo(hideRR, hideReq)
|
||||
|
||||
if hideRR.Code != http.StatusOK {
|
||||
t.Fatalf("hide status = %d, body = %s", hideRR.Code, hideRR.Body.String())
|
||||
}
|
||||
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/api/list?page=1&size=24", nil)
|
||||
listRR := httptest.NewRecorder()
|
||||
server.handleList(listRR, listReq)
|
||||
|
||||
if listRR.Code != http.StatusOK {
|
||||
t.Fatalf("list status = %d, body = %s", listRR.Code, listRR.Body.String())
|
||||
}
|
||||
var listed struct {
|
||||
Items []VideoDTO `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.NewDecoder(listRR.Body).Decode(&listed); err != nil {
|
||||
t.Fatalf("decode list: %v", err)
|
||||
}
|
||||
if listed.Total != 1 || len(listed.Items) != 1 || listed.Items[0].ID != "video-visible" {
|
||||
t.Fatalf("listed = total:%d items:%#v, want only video-visible", listed.Total, listed.Items)
|
||||
}
|
||||
|
||||
detailReq := requestWithVideoID(http.MethodGet, "/api/video/video-hidden", "video-hidden", strings.NewReader(``))
|
||||
detailRR := httptest.NewRecorder()
|
||||
server.handleVideoDetail(detailRR, detailReq)
|
||||
|
||||
if detailRR.Code != http.StatusNotFound {
|
||||
t.Fatalf("detail status = %d, want 404; body = %s", detailRR.Code, detailRR.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,3 +306,20 @@ func sameStrings(a, b []string) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func containsString(list []string, value string) bool {
|
||||
for _, item := range list {
|
||||
if item == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func requestWithVideoID(method, target, videoID string, body *strings.Reader) *http.Request {
|
||||
req := httptest.NewRequest(method, target, body)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", videoID)
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
return req
|
||||
}
|
||||
|
||||
@@ -28,7 +28,12 @@ func Open(path string) (*Catalog, error) {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("apply schema: %w", err)
|
||||
}
|
||||
return &Catalog{db: db}, nil
|
||||
c := &Catalog{db: db}
|
||||
if err := c.migrate(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migrate catalog: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) Close() error { return c.db.Close() }
|
||||
@@ -39,6 +44,7 @@ type Video struct {
|
||||
ID string `json:"id"`
|
||||
DriveID string `json:"driveId"`
|
||||
FileID string `json:"fileId"`
|
||||
ContentHash string `json:"contentHash"`
|
||||
ParentID string `json:"parentId"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
@@ -57,6 +63,7 @@ type Video struct {
|
||||
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"`
|
||||
@@ -65,6 +72,8 @@ type Video struct {
|
||||
}
|
||||
|
||||
func (c *Catalog) UpsertVideo(ctx context.Context, v *Video) error {
|
||||
existed := c.videoExists(ctx, v.ID)
|
||||
v.ContentHash = normalizeContentHash(v.ContentHash)
|
||||
tagsJSON, _ := json.Marshal(v.Tags)
|
||||
badgesJSON, _ := json.Marshal(v.Badges)
|
||||
now := time.Now().UnixMilli()
|
||||
@@ -75,22 +84,26 @@ func (c *Catalog) UpsertVideo(ctx context.Context, v *Video) error {
|
||||
|
||||
_, err := c.db.ExecContext(ctx, `
|
||||
INSERT INTO videos (
|
||||
id, drive_id, file_id, parent_id, title, author, tags,
|
||||
id, drive_id, file_id, content_hash, parent_id, title, author, tags,
|
||||
duration_seconds, size_bytes, ext, quality, thumbnail_url,
|
||||
preview_file_id, preview_local, preview_status,
|
||||
views, favorites, comments, likes, dislikes,
|
||||
category, badges, description, published_at, created_at, updated_at
|
||||
category, hidden, badges, description, published_at, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?,
|
||||
?, ?, ?,
|
||||
?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?
|
||||
?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
author = excluded.author,
|
||||
tags = excluded.tags,
|
||||
content_hash = CASE
|
||||
WHEN excluded.content_hash != '' THEN excluded.content_hash
|
||||
ELSE videos.content_hash
|
||||
END,
|
||||
duration_seconds= excluded.duration_seconds,
|
||||
size_bytes = excluded.size_bytes,
|
||||
ext = excluded.ext,
|
||||
@@ -101,14 +114,20 @@ ON CONFLICT(id) DO UPDATE SET
|
||||
description = excluded.description,
|
||||
updated_at = excluded.updated_at
|
||||
`,
|
||||
v.ID, v.DriveID, v.FileID, v.ParentID, v.Title, v.Author, string(tagsJSON),
|
||||
v.ID, v.DriveID, v.FileID, v.ContentHash, v.ParentID, v.Title, v.Author, string(tagsJSON),
|
||||
v.DurationSeconds, v.Size, v.Ext, v.Quality, v.ThumbnailURL,
|
||||
v.PreviewFileID, v.PreviewLocal, nullableStatus(v.PreviewStatus),
|
||||
v.Views, v.Favorites, v.Comments, v.Likes, v.Dislikes,
|
||||
v.Category, string(badgesJSON), v.Description,
|
||||
v.Category, boolToInt(v.Hidden), string(badgesJSON), v.Description,
|
||||
v.PublishedAt.UnixMilli(), v.CreatedAt.UnixMilli(), v.UpdatedAt.UnixMilli(),
|
||||
)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(v.Tags) > 0 && !existed {
|
||||
return c.replaceVideoTags(ctx, v.ID, v.Tags, "auto", false, true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nullableStatus(s string) string {
|
||||
@@ -125,6 +144,19 @@ func (c *Catalog) UpdatePreview(ctx context.Context, id, previewFileID, previewL
|
||||
return 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 = ?`,
|
||||
time.Now().UnixMilli(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows, err := res.RowsAffected(); err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IncrementLike 原子 +1,返回最新点赞数
|
||||
func (c *Catalog) IncrementLike(ctx context.Context, id string) (int, error) {
|
||||
tx, err := c.db.BeginTx(ctx, nil)
|
||||
@@ -152,6 +184,7 @@ type VideoMetaPatch struct {
|
||||
ThumbnailURL string
|
||||
DurationSeconds int
|
||||
Category string
|
||||
ContentHash string
|
||||
Tags []string
|
||||
TagsSet bool
|
||||
}
|
||||
@@ -171,6 +204,10 @@ func (c *Catalog) UpdateVideoMeta(ctx context.Context, id string, p VideoMetaPat
|
||||
parts = append(parts, "category = ?")
|
||||
args = append(args, p.Category)
|
||||
}
|
||||
if p.ContentHash != "" {
|
||||
parts = append(parts, "content_hash = ?")
|
||||
args = append(args, normalizeContentHash(p.ContentHash))
|
||||
}
|
||||
if p.TagsSet {
|
||||
tagsJSON, _ := json.Marshal(p.Tags)
|
||||
parts = append(parts, "tags = ?")
|
||||
@@ -183,8 +220,13 @@ func (c *Catalog) UpdateVideoMeta(ctx context.Context, id string, p VideoMetaPat
|
||||
args = append(args, time.Now().UnixMilli())
|
||||
args = append(args, id)
|
||||
q := `UPDATE videos SET ` + strings.Join(parts, ", ") + ` WHERE id = ?`
|
||||
_, err := c.db.ExecContext(ctx, q, args...)
|
||||
return err
|
||||
if _, err := c.db.ExecContext(ctx, q, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.TagsSet {
|
||||
return c.SetAutoVideoTags(ctx, id, p.Tags)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListCategories 聚合所有 category,按视频数降序
|
||||
@@ -198,6 +240,7 @@ func (c *Catalog) ListCategories(ctx context.Context) ([]CategoryStat, error) {
|
||||
`SELECT COALESCE(category, '') AS c, COUNT(*) AS cnt
|
||||
FROM videos
|
||||
WHERE category IS NOT NULL AND category != ''
|
||||
AND COALESCE(hidden, 0) = 0
|
||||
GROUP BY c
|
||||
ORDER BY cnt DESC, c ASC`)
|
||||
if err != nil {
|
||||
@@ -225,8 +268,13 @@ func (c *Catalog) CountTags(ctx context.Context, labels []string) ([]TagStat, er
|
||||
for _, label := range labels {
|
||||
var count int
|
||||
if err := c.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM videos WHERE tags LIKE ?`,
|
||||
"%\""+label+"\"%",
|
||||
`SELECT COUNT(*)
|
||||
FROM video_tags vt
|
||||
JOIN tags t ON t.id = vt.tag_id
|
||||
JOIN videos v ON v.id = vt.video_id
|
||||
WHERE t.label = ? COLLATE NOCASE
|
||||
AND COALESCE(v.hidden, 0) = 0`,
|
||||
label,
|
||||
).Scan(&count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -241,7 +289,11 @@ func (c *Catalog) ListVideosByPreviewStatus(ctx context.Context, driveID, status
|
||||
limit = 10000
|
||||
}
|
||||
rows, err := c.db.QueryContext(ctx,
|
||||
`SELECT `+allVideoCols+` FROM videos WHERE drive_id = ? AND preview_status = ? ORDER BY created_at ASC LIMIT ?`,
|
||||
`SELECT `+allVideoCols+` FROM videos
|
||||
WHERE drive_id = ? AND preview_status = ?
|
||||
AND COALESCE(hidden, 0) = 0
|
||||
AND `+uniqueVideoWhereSQL+`
|
||||
ORDER BY created_at ASC LIMIT ?`,
|
||||
driveID, status, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -267,6 +319,8 @@ func (c *Catalog) ListVideosNeedingThumbnail(ctx context.Context, driveID string
|
||||
`SELECT `+allVideoCols+` FROM videos
|
||||
WHERE drive_id = ?
|
||||
AND COALESCE(thumbnail_url, '') = ''
|
||||
AND COALESCE(hidden, 0) = 0
|
||||
AND `+uniqueVideoWhereSQL+`
|
||||
ORDER BY created_at ASC
|
||||
LIMIT ?`,
|
||||
driveID, limit)
|
||||
@@ -290,8 +344,23 @@ func (c *Catalog) GetVideo(ctx context.Context, id string) (*Video, error) {
|
||||
return scanVideo(row)
|
||||
}
|
||||
|
||||
func (c *Catalog) FindVideoByContentHash(ctx context.Context, hash string) (*Video, error) {
|
||||
hash = normalizeContentHash(hash)
|
||||
if hash == "" {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
row := c.db.QueryRowContext(ctx,
|
||||
`SELECT `+allVideoCols+`
|
||||
FROM videos
|
||||
WHERE content_hash = ?
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT 1`, hash)
|
||||
return scanVideo(row)
|
||||
}
|
||||
|
||||
type ListParams struct {
|
||||
Keyword string
|
||||
DriveID string
|
||||
Tag string
|
||||
Category string
|
||||
Sort string // latest | hot | week | long
|
||||
@@ -314,19 +383,28 @@ func (c *Catalog) ListVideos(ctx context.Context, p ListParams) ([]*Video, int,
|
||||
like := "%" + p.Keyword + "%"
|
||||
args = append(args, like, like)
|
||||
}
|
||||
if p.DriveID != "" {
|
||||
where = append(where, "drive_id = ?")
|
||||
args = append(args, p.DriveID)
|
||||
}
|
||||
if p.Tag != "" {
|
||||
where = append(where, "tags LIKE ?")
|
||||
args = append(args, "%\""+p.Tag+"\"%")
|
||||
where = append(where, `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
|
||||
)`)
|
||||
args = append(args, p.Tag)
|
||||
}
|
||||
if p.Category != "" && p.Category != "all" {
|
||||
where = append(where, "category = ?")
|
||||
args = append(args, p.Category)
|
||||
}
|
||||
where = append(where, "COALESCE(hidden, 0) = 0")
|
||||
where = append(where, uniqueVideoWhereSQL)
|
||||
|
||||
whereSQL := ""
|
||||
if len(where) > 0 {
|
||||
whereSQL = " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
whereSQL = " WHERE " + strings.Join(where, " AND ")
|
||||
|
||||
orderBy := " ORDER BY published_at DESC"
|
||||
switch p.Sort {
|
||||
@@ -366,6 +444,42 @@ func (c *Catalog) ListVideos(ctx context.Context, p ListParams) ([]*Video, int,
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
type DriveTeaserCounts struct {
|
||||
Ready int
|
||||
Pending int
|
||||
Failed int
|
||||
}
|
||||
|
||||
func (c *Catalog) CountTeasersByDrive(ctx context.Context) (map[string]DriveTeaserCounts, error) {
|
||||
rows, err := c.db.QueryContext(ctx,
|
||||
`SELECT drive_id,
|
||||
COUNT(CASE WHEN COALESCE(preview_status, 'pending') = 'ready' THEN 1 END) AS ready_count,
|
||||
COUNT(CASE WHEN COALESCE(preview_status, 'pending') = 'pending' THEN 1 END) AS pending_count,
|
||||
COUNT(CASE WHEN COALESCE(preview_status, 'pending') = 'failed' THEN 1 END) AS failed_count
|
||||
FROM videos
|
||||
WHERE COALESCE(hidden, 0) = 0
|
||||
AND `+uniqueVideoWhereSQL+`
|
||||
GROUP BY drive_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[string]DriveTeaserCounts)
|
||||
for rows.Next() {
|
||||
var driveID string
|
||||
var counts DriveTeaserCounts
|
||||
if err := rows.Scan(&driveID, &counts.Ready, &counts.Pending, &counts.Failed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[driveID] = counts
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---------- Drive ----------
|
||||
|
||||
type Drive struct {
|
||||
@@ -498,14 +612,26 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.upd
|
||||
// ---------- helpers ----------
|
||||
|
||||
const allVideoCols = `
|
||||
id, drive_id, file_id, COALESCE(parent_id, ''), title, COALESCE(author, ''), COALESCE(tags, '[]'),
|
||||
id, drive_id, file_id, COALESCE(content_hash, ''), 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'),
|
||||
views, favorites, comments, likes, dislikes,
|
||||
COALESCE(category, ''), COALESCE(badges, '[]'), COALESCE(description, ''),
|
||||
COALESCE(category, ''), COALESCE(hidden, 0), COALESCE(badges, '[]'), COALESCE(description, ''),
|
||||
published_at, created_at, updated_at
|
||||
`
|
||||
|
||||
const uniqueVideoWhereSQL = `(COALESCE(videos.content_hash, '') = ''
|
||||
OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM videos AS dup
|
||||
WHERE dup.content_hash = videos.content_hash
|
||||
AND COALESCE(dup.content_hash, '') != ''
|
||||
AND (
|
||||
dup.created_at < videos.created_at
|
||||
OR (dup.created_at = videos.created_at AND dup.id < videos.id)
|
||||
)
|
||||
))`
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
@@ -514,12 +640,13 @@ func scanVideo(row rowScanner) (*Video, error) {
|
||||
v := &Video{}
|
||||
var tagsJSON, badgesJSON string
|
||||
var publishedAt, createdAt, updatedAt int64
|
||||
var hidden int
|
||||
err := row.Scan(
|
||||
&v.ID, &v.DriveID, &v.FileID, &v.ParentID, &v.Title, &v.Author, &tagsJSON,
|
||||
&v.ID, &v.DriveID, &v.FileID, &v.ContentHash, &v.ParentID, &v.Title, &v.Author, &tagsJSON,
|
||||
&v.DurationSeconds, &v.Size, &v.Ext, &v.Quality, &v.ThumbnailURL,
|
||||
&v.PreviewFileID, &v.PreviewLocal, &v.PreviewStatus,
|
||||
&v.Views, &v.Favorites, &v.Comments, &v.Likes, &v.Dislikes,
|
||||
&v.Category, &badgesJSON, &v.Description,
|
||||
&v.Category, &hidden, &badgesJSON, &v.Description,
|
||||
&publishedAt, &createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -527,8 +654,20 @@ func scanVideo(row rowScanner) (*Video, error) {
|
||||
}
|
||||
_ = json.Unmarshal([]byte(tagsJSON), &v.Tags)
|
||||
_ = json.Unmarshal([]byte(badgesJSON), &v.Badges)
|
||||
v.Hidden = hidden == 1
|
||||
v.PublishedAt = time.UnixMilli(publishedAt)
|
||||
v.CreatedAt = time.UnixMilli(createdAt)
|
||||
v.UpdatedAt = time.UnixMilli(updatedAt)
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func normalizeContentHash(hash string) string {
|
||||
return strings.ToLower(strings.TrimSpace(hash))
|
||||
}
|
||||
|
||||
func boolToInt(v bool) int {
|
||||
if v {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ CREATE TABLE IF NOT EXISTS videos (
|
||||
id TEXT PRIMARY KEY, -- <drive>-<fileID> 拼接的稳定 ID
|
||||
drive_id TEXT NOT NULL,
|
||||
file_id TEXT NOT NULL,
|
||||
content_hash TEXT DEFAULT '',
|
||||
parent_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
author TEXT,
|
||||
@@ -21,6 +22,8 @@ CREATE TABLE IF NOT EXISTS videos (
|
||||
likes INTEGER DEFAULT 0,
|
||||
dislikes INTEGER DEFAULT 0,
|
||||
category TEXT,
|
||||
hidden INTEGER DEFAULT 0, -- 1 = hidden from public display
|
||||
tags_manual INTEGER DEFAULT 0, -- 1 = user explicitly curated tags
|
||||
badges TEXT, -- JSON array
|
||||
description TEXT,
|
||||
published_at INTEGER NOT NULL, -- unix ms
|
||||
@@ -32,10 +35,31 @@ CREATE INDEX IF NOT EXISTS idx_videos_drive ON videos(drive_id, file_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_videos_pub ON videos(published_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_videos_views ON videos(views DESC);
|
||||
|
||||
-- 统一标签池
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
label TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
aliases TEXT NOT NULL DEFAULT '[]', -- JSON array
|
||||
source TEXT NOT NULL DEFAULT 'user', -- system / user / collection / legacy
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS video_tags (
|
||||
video_id TEXT NOT NULL,
|
||||
tag_id INTEGER NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'auto', -- auto / manual / legacy
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (video_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_video_tags_tag ON video_tags(tag_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_video_tags_video ON video_tags(video_id);
|
||||
|
||||
-- 网盘账户
|
||||
CREATE TABLE IF NOT EXISTS drives (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL, -- quark / p115 / pikpak / wopan
|
||||
kind TEXT NOT NULL, -- quark / p115 / pikpak / wopan / onedrive
|
||||
name TEXT NOT NULL,
|
||||
root_id TEXT NOT NULL DEFAULT '0',
|
||||
scan_root_id TEXT, -- 扫描起点(默认 root_id)
|
||||
|
||||
@@ -0,0 +1,822 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/video-site/backend/internal/fixedtags"
|
||||
)
|
||||
|
||||
var ErrUnknownTag = errors.New("unknown tag")
|
||||
|
||||
const avTagLabel = "AV"
|
||||
|
||||
var (
|
||||
avCodePattern = regexp.MustCompile(`(?i)^[A-Z]{2,8}[-_ ]?\d{3,6}(?:[-_ ]?[A-Z0-9]{1,4})?$`)
|
||||
ccAVCodePattern = regexp.MustCompile(`(?i)^CC[-_ ]?\d{3,8}(?:[-_ ]?[A-Z0-9]{1,4})?$`)
|
||||
fc2AVCodePattern = regexp.MustCompile(`(?i)^FC2[-_ ]?(?:PPV[-_ ]?)?\d{4,8}(?:[-_ ]?[A-Z0-9]{1,4})?$`)
|
||||
numericPrefixAVCodePattern = regexp.MustCompile(`(?i)^\d{2,4}[A-Z]{2,8}[-_ ]?\d{3,6}(?:[-_ ]?[A-Z0-9]{1,4})?$`)
|
||||
avCodeInTextPattern = regexp.MustCompile(`(?i)(?:^|[^A-Za-z0-9])((?:[A-Z]{2,8}[-_ ]?\d{3,6}(?:[-_ ]?[A-Z0-9]{1,4})?)|(?:CC[-_ ]?\d{3,8}(?:[-_ ]?[A-Z0-9]{1,4})?)|(?:FC2[-_ ]?(?:PPV[-_ ]?)?\d{4,8}(?:[-_ ]?[A-Z0-9]{1,4})?)|(?:\d{2,4}[A-Z]{2,8}[-_ ]?\d{3,6}(?:[-_ ]?[A-Z0-9]{1,4})?))(?:$|[^A-Za-z0-9])`)
|
||||
)
|
||||
|
||||
type Tag struct {
|
||||
ID int64 `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
Source string `json:"source"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func (c *Catalog) migrate(ctx context.Context) error {
|
||||
if err := c.addColumnIfMissing(ctx, "videos", "tags_manual", "INTEGER DEFAULT 0"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.addColumnIfMissing(ctx, "videos", "content_hash", "TEXT DEFAULT ''"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.addColumnIfMissing(ctx, "videos", "hidden", "INTEGER DEFAULT 0"); 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
|
||||
}
|
||||
if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_videos_hidden ON videos(hidden)`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.seedSystemTags(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.backfillVideoTags(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.collapseAVCodeTags(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.createCollectionTagsFromCategories(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Catalog) addColumnIfMissing(ctx context.Context, table, column, definition string) error {
|
||||
rows, err := c.db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notNull int
|
||||
var defaultValue any
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.EqualFold(name, column) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
_, err = c.db.ExecContext(ctx, `ALTER TABLE `+table+` ADD COLUMN `+column+` `+definition)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Catalog) seedSystemTags(ctx context.Context) error {
|
||||
for _, label := range fixedtags.Labels {
|
||||
if _, err := c.ensureTag(ctx, label, fixedtags.AliasesFor(label), "system"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Catalog) backfillVideoTags(ctx context.Context) error {
|
||||
rows, err := c.db.QueryContext(ctx, `SELECT id, COALESCE(tags, '[]') FROM videos`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var videoID, tagsJSON string
|
||||
if err := rows.Scan(&videoID, &tagsJSON); err != nil {
|
||||
return err
|
||||
}
|
||||
var labels []string
|
||||
if err := json.Unmarshal([]byte(tagsJSON), &labels); err != nil {
|
||||
continue
|
||||
}
|
||||
if len(labels) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := c.addVideoTags(ctx, videoID, labels, "legacy", true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.syncVideoTagsJSON(ctx, videoID, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Catalog) createCollectionTagsFromCategories(ctx context.Context) error {
|
||||
rows, err := c.db.QueryContext(ctx, `
|
||||
SELECT category, COUNT(*) FROM videos
|
||||
WHERE COALESCE(category, '') != ''
|
||||
GROUP BY category`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type categoryStat struct {
|
||||
category string
|
||||
count int
|
||||
}
|
||||
var categories []categoryStat
|
||||
for rows.Next() {
|
||||
var stat categoryStat
|
||||
if err := rows.Scan(&stat.category, &stat.count); err != nil {
|
||||
return err
|
||||
}
|
||||
categories = append(categories, stat)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, stat := range categories {
|
||||
if isAVCodePollutedLabel(stat.category) {
|
||||
if _, err := c.ensureTag(ctx, avTagLabel, fixedtags.AliasesFor(avTagLabel), "system"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.addTagToVideosByCategory(ctx, stat.category, avTagLabel, "auto"); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if stat.count < 3 {
|
||||
continue
|
||||
}
|
||||
if !LooksLikeCollectionTag(stat.category) {
|
||||
continue
|
||||
}
|
||||
if _, err := c.ensureTag(ctx, stat.category, nil, "collection"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.addCollectionTagToVideos(ctx, stat.category); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Catalog) CreateTagAndClassify(ctx context.Context, label string, aliases []string, source string) (int, error) {
|
||||
tag, err := c.ensureTag(ctx, label, aliases, source)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return c.classifyTag(ctx, tag)
|
||||
}
|
||||
|
||||
func (c *Catalog) ListTags(ctx context.Context) ([]Tag, error) {
|
||||
rows, err := c.db.QueryContext(ctx, `
|
||||
SELECT t.id, t.label, t.aliases, t.source, COUNT(v.id) AS cnt
|
||||
FROM tags t
|
||||
LEFT JOIN video_tags vt ON vt.tag_id = t.id
|
||||
LEFT JOIN videos v ON v.id = vt.video_id AND COALESCE(v.hidden, 0) = 0
|
||||
GROUP BY t.id, t.label, t.aliases, t.source
|
||||
ORDER BY cnt DESC, t.label ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Tag
|
||||
for rows.Next() {
|
||||
tag, err := scanTag(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, tag)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) SetManualVideoTags(ctx context.Context, videoID string, labels []string) error {
|
||||
if _, err := c.GetVideo(ctx, videoID); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.replaceVideoTags(ctx, videoID, labels, "manual", true, false)
|
||||
}
|
||||
|
||||
func (c *Catalog) SetAutoVideoTags(ctx context.Context, videoID string, labels []string) error {
|
||||
if c.hasManualTags(ctx, videoID) {
|
||||
return nil
|
||||
}
|
||||
return c.replaceVideoTags(ctx, videoID, labels, "auto", false, false)
|
||||
}
|
||||
|
||||
func (c *Catalog) MatchTags(ctx context.Context, text string) ([]string, error) {
|
||||
tags, err := c.ListTags(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matcher := normalizeTagText(text)
|
||||
out := make([]string, 0, len(tags))
|
||||
if ContainsAVCode(text) {
|
||||
out = append(out, avTagLabel)
|
||||
}
|
||||
for _, tag := range tags {
|
||||
candidates := append([]string{tag.Label}, tag.Aliases...)
|
||||
for _, candidate := range candidates {
|
||||
if matcher.contains(candidate) {
|
||||
out = append(out, tag.Label)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return sortLabelsByTagOrder(tags, uniqueStrings(out)), nil
|
||||
}
|
||||
|
||||
func (c *Catalog) EnsureCollectionTag(ctx context.Context, label string) (string, bool, error) {
|
||||
label = cleanTagLabel(label)
|
||||
if isAVCodePollutedLabel(label) {
|
||||
if _, err := c.ensureTag(ctx, avTagLabel, fixedtags.AliasesFor(avTagLabel), "system"); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if err := c.addTagToVideosByCategory(ctx, label, avTagLabel, "auto"); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return avTagLabel, true, nil
|
||||
}
|
||||
if !LooksLikeCollectionTag(label) {
|
||||
return "", false, nil
|
||||
}
|
||||
if !c.tagExists(ctx, label) {
|
||||
count, err := c.categoryVideoCount(ctx, label)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if count < 2 {
|
||||
return "", false, nil
|
||||
}
|
||||
}
|
||||
if _, err := c.ensureTag(ctx, label, nil, "collection"); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if err := c.addCollectionTagToVideos(ctx, label); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return label, true, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) ensureTag(ctx context.Context, label string, aliases []string, source string) (Tag, error) {
|
||||
label = cleanTagLabel(label)
|
||||
if label == "" {
|
||||
return Tag{}, errors.New("tag label is required")
|
||||
}
|
||||
if isAVCodePollutedLabel(label) {
|
||||
label = avTagLabel
|
||||
aliases = fixedtags.AliasesFor(avTagLabel)
|
||||
source = "system"
|
||||
}
|
||||
if source == "" {
|
||||
source = "user"
|
||||
}
|
||||
aliases = cleanAliases(aliases, label)
|
||||
aliasesJSON, _ := json.Marshal(aliases)
|
||||
now := time.Now().UnixMilli()
|
||||
if _, err := c.db.ExecContext(ctx, `
|
||||
INSERT OR IGNORE INTO tags (label, aliases, source, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)`, label, string(aliasesJSON), source, now, now); err != nil {
|
||||
return Tag{}, err
|
||||
}
|
||||
if len(aliases) > 0 {
|
||||
if _, err := c.db.ExecContext(ctx,
|
||||
`UPDATE tags SET aliases = ?, updated_at = ? WHERE label = ? COLLATE NOCASE`,
|
||||
string(aliasesJSON), now, label); err != nil {
|
||||
return Tag{}, err
|
||||
}
|
||||
}
|
||||
return c.getTagByLabel(ctx, label)
|
||||
}
|
||||
|
||||
func (c *Catalog) getTagByLabel(ctx context.Context, label string) (Tag, error) {
|
||||
row := c.db.QueryRowContext(ctx,
|
||||
`SELECT id, label, aliases, source, 0 FROM tags WHERE label = ? COLLATE NOCASE`,
|
||||
label)
|
||||
return scanTag(row)
|
||||
}
|
||||
|
||||
func (c *Catalog) classifyTag(ctx context.Context, tag Tag) (int, error) {
|
||||
rows, err := c.db.QueryContext(ctx, `
|
||||
SELECT id, title, COALESCE(author, ''), COALESCE(category, ''), COALESCE(tags_manual, 0)
|
||||
FROM videos`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
classified := 0
|
||||
for rows.Next() {
|
||||
var videoID, title, author, category string
|
||||
var manual int
|
||||
if err := rows.Scan(&videoID, &title, &author, &category, &manual); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if manual == 1 {
|
||||
continue
|
||||
}
|
||||
matcher := normalizeTagText(title + " " + author + " " + category)
|
||||
if !matcher.contains(tag.Label) {
|
||||
matchedAlias := false
|
||||
for _, alias := range tag.Aliases {
|
||||
if matcher.contains(alias) {
|
||||
matchedAlias = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matchedAlias {
|
||||
continue
|
||||
}
|
||||
}
|
||||
added, err := c.addVideoTag(ctx, videoID, tag.Label, "auto", false)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if added {
|
||||
classified++
|
||||
}
|
||||
if err := c.syncVideoTagsJSON(ctx, videoID, false); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return classified, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) replaceVideoTags(ctx context.Context, videoID string, labels []string, source string, manual bool, createMissing bool) error {
|
||||
labels = uniqueStrings(cleanLabels(labels))
|
||||
if createMissing {
|
||||
for _, label := range labels {
|
||||
if _, err := c.ensureTag(ctx, label, nil, "legacy"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := c.validateTagsExist(ctx, labels); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
tx, err := c.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM video_tags WHERE video_id = ?`, videoID); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
for _, label := range labels {
|
||||
tag, err := c.getTagByLabelTx(ctx, tx, label)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT OR IGNORE INTO video_tags (video_id, tag_id, source, created_at) VALUES (?, ?, ?, ?)`,
|
||||
videoID, tag.ID, source, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
manualValue := 0
|
||||
if manual {
|
||||
manualValue = 1
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE videos SET tags_manual = ? WHERE id = ?`, manualValue, videoID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.syncVideoTagsJSON(ctx, videoID, manual)
|
||||
}
|
||||
|
||||
func (c *Catalog) addVideoTags(ctx context.Context, videoID string, labels []string, source string, createMissing bool) error {
|
||||
for _, label := range uniqueStrings(cleanLabels(labels)) {
|
||||
if _, err := c.addVideoTag(ctx, videoID, label, source, createMissing); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Catalog) addVideoTag(ctx context.Context, videoID, label, source string, createMissing bool) (bool, error) {
|
||||
if createMissing {
|
||||
if _, err := c.ensureTag(ctx, label, nil, "legacy"); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
tag, err := c.getTagByLabel(ctx, label)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
res, err := c.db.ExecContext(ctx,
|
||||
`INSERT OR IGNORE INTO video_tags (video_id, tag_id, source, created_at) VALUES (?, ?, ?, ?)`,
|
||||
videoID, tag.ID, source, now)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) addCollectionTagToVideos(ctx context.Context, category string) error {
|
||||
return c.addTagToVideosByCategory(ctx, category, category, "auto")
|
||||
}
|
||||
|
||||
func (c *Catalog) addTagToVideosByCategory(ctx context.Context, category, label, source string) error {
|
||||
rows, err := c.db.QueryContext(ctx, `SELECT id FROM videos WHERE category = ? AND COALESCE(tags_manual, 0) = 0`, category)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var videoIDs []string
|
||||
for rows.Next() {
|
||||
var videoID string
|
||||
if err := rows.Scan(&videoID); err != nil {
|
||||
return err
|
||||
}
|
||||
videoIDs = append(videoIDs, videoID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, videoID := range videoIDs {
|
||||
if _, err := c.addVideoTag(ctx, videoID, label, source, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.syncVideoTagsJSON(ctx, videoID, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Catalog) collapseAVCodeTags(ctx context.Context) error {
|
||||
if _, err := c.ensureTag(ctx, avTagLabel, fixedtags.AliasesFor(avTagLabel), "system"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := c.db.QueryContext(ctx, `SELECT id, label FROM tags`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type pollutedTag struct {
|
||||
id int64
|
||||
label string
|
||||
}
|
||||
var polluted []pollutedTag
|
||||
for rows.Next() {
|
||||
var tag pollutedTag
|
||||
if err := rows.Scan(&tag.id, &tag.label); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.EqualFold(tag.label, avTagLabel) || !isAVCodePollutedLabel(tag.label) {
|
||||
continue
|
||||
}
|
||||
polluted = append(polluted, tag)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, tag := range polluted {
|
||||
videoIDs, err := c.videoIDsForTagID(ctx, tag.id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, videoID := range videoIDs {
|
||||
if _, err := c.addVideoTag(ctx, videoID, avTagLabel, "auto", false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := c.db.ExecContext(ctx, `DELETE FROM video_tags WHERE tag_id = ?`, tag.id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := c.db.ExecContext(ctx, `DELETE FROM tags WHERE id = ?`, tag.id); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, videoID := range videoIDs {
|
||||
if err := c.syncVideoTagsJSON(ctx, videoID, c.hasManualTags(ctx, videoID)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Catalog) videoIDsForTagID(ctx context.Context, tagID int64) ([]string, error) {
|
||||
rows, err := c.db.QueryContext(ctx, `SELECT video_id FROM video_tags WHERE tag_id = ?`, tagID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var videoIDs []string
|
||||
for rows.Next() {
|
||||
var videoID string
|
||||
if err := rows.Scan(&videoID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
videoIDs = append(videoIDs, videoID)
|
||||
}
|
||||
return videoIDs, rows.Err()
|
||||
}
|
||||
|
||||
func (c *Catalog) validateTagsExist(ctx context.Context, labels []string) error {
|
||||
for _, label := range labels {
|
||||
if _, err := c.getTagByLabel(ctx, label); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("%w: %s", ErrUnknownTag, label)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Catalog) syncVideoTagsJSON(ctx context.Context, videoID string, manual bool) error {
|
||||
rows, err := c.db.QueryContext(ctx, `
|
||||
SELECT t.label
|
||||
FROM video_tags vt
|
||||
JOIN tags t ON t.id = vt.tag_id
|
||||
WHERE vt.video_id = ?
|
||||
ORDER BY t.id ASC`, videoID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var labels []string
|
||||
for rows.Next() {
|
||||
var label string
|
||||
if err := rows.Scan(&label); err != nil {
|
||||
return err
|
||||
}
|
||||
labels = append(labels, label)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
labelsJSON, _ := json.Marshal(labels)
|
||||
manualValue := 0
|
||||
if manual {
|
||||
manualValue = 1
|
||||
}
|
||||
_, err = c.db.ExecContext(ctx,
|
||||
`UPDATE videos SET tags = ?, tags_manual = ?, updated_at = ? WHERE id = ?`,
|
||||
string(labelsJSON), manualValue, time.Now().UnixMilli(), videoID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Catalog) hasManualTags(ctx context.Context, videoID string) bool {
|
||||
var manual int
|
||||
err := c.db.QueryRowContext(ctx, `SELECT COALESCE(tags_manual, 0) FROM videos WHERE id = ?`, videoID).Scan(&manual)
|
||||
return err == nil && manual == 1
|
||||
}
|
||||
|
||||
func (c *Catalog) videoExists(ctx context.Context, videoID string) bool {
|
||||
var exists int
|
||||
err := c.db.QueryRowContext(ctx, `SELECT 1 FROM videos WHERE id = ?`, videoID).Scan(&exists)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (c *Catalog) tagExists(ctx context.Context, label string) bool {
|
||||
var exists int
|
||||
err := c.db.QueryRowContext(ctx, `SELECT 1 FROM tags WHERE label = ? COLLATE NOCASE`, label).Scan(&exists)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (c *Catalog) categoryVideoCount(ctx context.Context, category string) (int, error) {
|
||||
var count int
|
||||
err := c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM videos WHERE category = ?`, category).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (c *Catalog) getTagByLabelTx(ctx context.Context, tx *sql.Tx, label string) (Tag, error) {
|
||||
row := tx.QueryRowContext(ctx,
|
||||
`SELECT id, label, aliases, source, 0 FROM tags WHERE label = ? COLLATE NOCASE`,
|
||||
label)
|
||||
return scanTag(row)
|
||||
}
|
||||
|
||||
type tagRowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanTag(row tagRowScanner) (Tag, error) {
|
||||
var tag Tag
|
||||
var aliasesJSON string
|
||||
if err := row.Scan(&tag.ID, &tag.Label, &aliasesJSON, &tag.Source, &tag.Count); err != nil {
|
||||
return Tag{}, err
|
||||
}
|
||||
_ = json.Unmarshal([]byte(aliasesJSON), &tag.Aliases)
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
type normalizedTagText struct {
|
||||
lower string
|
||||
compact string
|
||||
tokens map[string]struct{}
|
||||
}
|
||||
|
||||
func normalizeTagText(s string) normalizedTagText {
|
||||
lower := strings.ToLower(s)
|
||||
var compact strings.Builder
|
||||
var spaced strings.Builder
|
||||
for _, r := range lower {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
compact.WriteRune(r)
|
||||
spaced.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
spaced.WriteByte(' ')
|
||||
}
|
||||
tokens := make(map[string]struct{})
|
||||
for _, token := range strings.Fields(spaced.String()) {
|
||||
tokens[token] = struct{}{}
|
||||
}
|
||||
return normalizedTagText{lower: lower, compact: compact.String(), tokens: tokens}
|
||||
}
|
||||
|
||||
func (n normalizedTagText) contains(alias string) bool {
|
||||
lowerAlias := strings.ToLower(strings.TrimSpace(alias))
|
||||
compactAlias := compactTagText(lowerAlias)
|
||||
if compactAlias == "" {
|
||||
return false
|
||||
}
|
||||
if isShortASCIIWord(compactAlias) && compactAlias == lowerAlias {
|
||||
_, ok := n.tokens[compactAlias]
|
||||
return ok
|
||||
}
|
||||
if strings.Contains(n.lower, lowerAlias) {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(n.compact, compactAlias)
|
||||
}
|
||||
|
||||
func compactTagText(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func isShortASCIIWord(s string) bool {
|
||||
if len(s) > 3 {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r > unicode.MaxASCII || (!unicode.IsLetter(r) && !unicode.IsDigit(r)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func LooksLikeCollectionTag(label string) bool {
|
||||
label = cleanTagLabel(label)
|
||||
if label == "" {
|
||||
return false
|
||||
}
|
||||
if isAVCodePollutedLabel(label) {
|
||||
return false
|
||||
}
|
||||
runes := []rune(label)
|
||||
if len(runes) < 2 || len(runes) > 24 {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(label)
|
||||
blocked := map[string]bool{
|
||||
"v": true, "pv": true, "my pack": true, "my upload": true,
|
||||
"视频": true, "视频1": true, "第一直播": true, "男人必备": true,
|
||||
"瑟女聚集地": true, "成人色游": true, "ai女友": true,
|
||||
}
|
||||
if blocked[lower] {
|
||||
return false
|
||||
}
|
||||
hasLetter := false
|
||||
for _, r := range label {
|
||||
if unicode.IsLetter(r) {
|
||||
hasLetter = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasLetter {
|
||||
return false
|
||||
}
|
||||
for _, r := range label {
|
||||
switch r {
|
||||
case ',', '。', '!', '?', ';', '、', ':', '~', '~':
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func IsAVCode(label string) bool {
|
||||
label = cleanTagLabel(label)
|
||||
if label == "" {
|
||||
return false
|
||||
}
|
||||
return avCodePattern.MatchString(label) || ccAVCodePattern.MatchString(label) || fc2AVCodePattern.MatchString(label) || numericPrefixAVCodePattern.MatchString(label)
|
||||
}
|
||||
|
||||
func ContainsAVCode(text string) bool {
|
||||
return avCodeInTextPattern.MatchString(text)
|
||||
}
|
||||
|
||||
func isAVCodePollutedLabel(label string) bool {
|
||||
label = cleanTagLabel(label)
|
||||
if label == "" {
|
||||
return false
|
||||
}
|
||||
return IsAVCode(label) || ContainsAVCode(label)
|
||||
}
|
||||
|
||||
func cleanLabels(labels []string) []string {
|
||||
out := make([]string, 0, len(labels))
|
||||
for _, label := range labels {
|
||||
label = cleanTagLabel(label)
|
||||
if label != "" {
|
||||
if isAVCodePollutedLabel(label) {
|
||||
label = avTagLabel
|
||||
}
|
||||
out = append(out, label)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cleanTagLabel(label string) string {
|
||||
return strings.TrimSpace(label)
|
||||
}
|
||||
|
||||
func cleanAliases(aliases []string, label string) []string {
|
||||
out := make([]string, 0, len(aliases))
|
||||
seen := map[string]bool{strings.ToLower(label): true}
|
||||
for _, alias := range aliases {
|
||||
alias = strings.TrimSpace(alias)
|
||||
if alias == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(alias)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, alias)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
key := strings.ToLower(value)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortLabelsByTagOrder(tags []Tag, labels []string) []string {
|
||||
order := make(map[string]int, len(tags))
|
||||
for i, tag := range tags {
|
||||
order[strings.ToLower(tag.Label)] = i
|
||||
}
|
||||
sort.SliceStable(labels, func(i, j int) bool {
|
||||
return order[strings.ToLower(labels[i])] < order[strings.ToLower(labels[j])]
|
||||
})
|
||||
return labels
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCreateTagAndClassifyAddsTagToMatchingExistingVideos(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() {
|
||||
if err := cat.Close(); err != nil {
|
||||
t.Fatalf("close catalog: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
now := time.Now()
|
||||
if err := cat.UpsertVideo(ctx, &Video{
|
||||
ID: "video-1",
|
||||
DriveID: "drive",
|
||||
FileID: "file-1",
|
||||
Title: "清纯短发合集",
|
||||
Category: "普通目录",
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed matching video: %v", err)
|
||||
}
|
||||
if err := cat.UpsertVideo(ctx, &Video{
|
||||
ID: "video-2",
|
||||
DriveID: "drive",
|
||||
FileID: "file-2",
|
||||
Title: "普通标题",
|
||||
Category: "普通目录",
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed non-matching video: %v", err)
|
||||
}
|
||||
|
||||
classified, err := cat.CreateTagAndClassify(ctx, "清纯", nil, "user")
|
||||
if err != nil {
|
||||
t.Fatalf("create tag: %v", err)
|
||||
}
|
||||
if classified != 1 {
|
||||
t.Fatalf("classified = %d, want 1", classified)
|
||||
}
|
||||
|
||||
got, err := cat.GetVideo(ctx, "video-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get matching video: %v", err)
|
||||
}
|
||||
if !sameStrings(got.Tags, []string{"清纯"}) {
|
||||
t.Fatalf("matching tags = %#v, want 清纯", got.Tags)
|
||||
}
|
||||
|
||||
other, err := cat.GetVideo(ctx, "video-2")
|
||||
if err != nil {
|
||||
t.Fatalf("get non-matching video: %v", err)
|
||||
}
|
||||
if len(other.Tags) != 0 {
|
||||
t.Fatalf("non-matching tags = %#v, want none", other.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetManualVideoTagsRejectsUnknownLabels(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() {
|
||||
if err := cat.Close(); err != nil {
|
||||
t.Fatalf("close catalog: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
now := time.Now()
|
||||
if err := cat.UpsertVideo(ctx, &Video{
|
||||
ID: "video-1",
|
||||
DriveID: "drive",
|
||||
FileID: "file-1",
|
||||
Title: "普通标题",
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed video: %v", err)
|
||||
}
|
||||
|
||||
if err := cat.SetManualVideoTags(ctx, "video-1", []string{"不存在"}); err == nil {
|
||||
t.Fatal("SetManualVideoTags accepted an unknown tag label")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAutoVideoTagsDoesNotOverwriteManualVideoTags(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() {
|
||||
if err := cat.Close(); err != nil {
|
||||
t.Fatalf("close catalog: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
now := time.Now()
|
||||
if err := cat.UpsertVideo(ctx, &Video{
|
||||
ID: "video-1",
|
||||
DriveID: "drive",
|
||||
FileID: "file-1",
|
||||
Title: "清纯后入",
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed video: %v", err)
|
||||
}
|
||||
if _, err := cat.CreateTagAndClassify(ctx, "清纯", nil, "user"); err != nil {
|
||||
t.Fatalf("create user tag: %v", err)
|
||||
}
|
||||
if err := cat.SetManualVideoTags(ctx, "video-1", []string{"清纯"}); err != nil {
|
||||
t.Fatalf("set manual tags: %v", err)
|
||||
}
|
||||
|
||||
if err := cat.SetAutoVideoTags(ctx, "video-1", []string{"后入"}); err != nil {
|
||||
t.Fatalf("set auto tags: %v", err)
|
||||
}
|
||||
|
||||
got, err := cat.GetVideo(ctx, "video-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get video: %v", err)
|
||||
}
|
||||
if !sameStrings(got.Tags, []string{"清纯"}) {
|
||||
t.Fatalf("tags = %#v, want manual 清纯 only", got.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTagAndClassifyMapsAVCodeLabelToAV(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() {
|
||||
if err := cat.Close(); err != nil {
|
||||
t.Fatalf("close catalog: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if _, err := cat.CreateTagAndClassify(ctx, "cc-1750027", nil, "user"); err != nil {
|
||||
t.Fatalf("create code tag: %v", err)
|
||||
}
|
||||
|
||||
tags, err := cat.ListTags(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list tags: %v", err)
|
||||
}
|
||||
for _, tag := range tags {
|
||||
if tag.Label == "cc-1750027" {
|
||||
t.Fatal("created standalone AV code tag cc-1750027")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeCollectionTagRejectsAVCodes(t *testing.T) {
|
||||
cases := []string{
|
||||
"DASS-499-C",
|
||||
"dass-499-c",
|
||||
"ADN-778",
|
||||
"SONE-247-C",
|
||||
"JUQ-502-UC",
|
||||
"ABF-032",
|
||||
"SSIS-233",
|
||||
"MIDA-607",
|
||||
"cc-1750027",
|
||||
"FC2-PPV-74663555",
|
||||
"ADN-778-FHD(1)",
|
||||
"ADN-778-中文字幕",
|
||||
"[44x.me]idbd-786",
|
||||
"NTRH-018_FHD_CH",
|
||||
"390JAC-233",
|
||||
}
|
||||
for _, label := range cases {
|
||||
if LooksLikeCollectionTag(label) {
|
||||
t.Fatalf("LooksLikeCollectionTag(%q) = true, want false", label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateCollapsesAVCodeTagsIntoAV(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() {
|
||||
if err := cat.Close(); err != nil {
|
||||
t.Fatalf("close catalog: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
now := time.Now()
|
||||
for _, seed := range []struct {
|
||||
id string
|
||||
label string
|
||||
}{
|
||||
{id: "video-1", label: "cc-1750027"},
|
||||
{id: "video-2", label: "ADN-778-FHD(1)"},
|
||||
{id: "video-3", label: "[44x.me]idbd-786"},
|
||||
{id: "video-4", label: "390JAC-233"},
|
||||
} {
|
||||
if err := cat.UpsertVideo(ctx, &Video{
|
||||
ID: seed.id,
|
||||
DriveID: "drive",
|
||||
FileID: seed.id,
|
||||
Title: seed.label + " sample",
|
||||
Tags: []string{seed.label},
|
||||
Category: seed.label,
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed polluted video %s: %v", seed.label, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := cat.migrate(ctx); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
tags, err := cat.ListTags(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list tags: %v", err)
|
||||
}
|
||||
var sawAV bool
|
||||
polluted := map[string]bool{}
|
||||
for _, tag := range tags {
|
||||
if tag.Label == "AV" {
|
||||
sawAV = true
|
||||
}
|
||||
if tag.Label != "AV" && isAVCodePollutedLabel(tag.Label) {
|
||||
polluted[tag.Label] = true
|
||||
}
|
||||
}
|
||||
if !sawAV {
|
||||
t.Fatal("AV tag was not seeded")
|
||||
}
|
||||
if len(polluted) > 0 {
|
||||
t.Fatalf("AV code tags were not removed: %#v", polluted)
|
||||
}
|
||||
|
||||
for _, id := range []string{"video-1", "video-2", "video-3", "video-4"} {
|
||||
got, err := cat.GetVideo(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("get video %s: %v", id, err)
|
||||
}
|
||||
if !sameStrings(got.Tags, []string{"AV"}) {
|
||||
t.Fatalf("%s tags = %#v, want AV", id, got.Tags)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListVideosHidesDuplicateContentHashes(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() {
|
||||
if err := cat.Close(); err != nil {
|
||||
t.Fatalf("close catalog: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
now := time.Now()
|
||||
for _, v := range []*Video{
|
||||
{
|
||||
ID: "video-1",
|
||||
DriveID: "drive",
|
||||
FileID: "file-1",
|
||||
Title: "First",
|
||||
ContentHash: "hash-same",
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: "video-2",
|
||||
DriveID: "drive",
|
||||
FileID: "file-2",
|
||||
Title: "Second",
|
||||
ContentHash: "hash-same",
|
||||
PublishedAt: now.Add(time.Second),
|
||||
CreatedAt: now.Add(time.Second),
|
||||
UpdatedAt: now.Add(time.Second),
|
||||
},
|
||||
} {
|
||||
if err := cat.UpsertVideo(ctx, v); err != nil {
|
||||
t.Fatalf("seed video %s: %v", v.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
items, total, err := cat.ListVideos(ctx, ListParams{Page: 1, PageSize: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("list videos: %v", err)
|
||||
}
|
||||
if total != 1 || len(items) != 1 {
|
||||
t.Fatalf("visible videos total=%d len=%d, want 1", total, len(items))
|
||||
}
|
||||
if items[0].ID != "video-1" {
|
||||
t.Fatalf("visible id = %q, want video-1", items[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func sameStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -9,11 +9,11 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server Server `yaml:"server"`
|
||||
Storage Storage `yaml:"storage"`
|
||||
Scanner Scanner `yaml:"scanner"`
|
||||
Preview Preview `yaml:"preview"`
|
||||
Drives []Drive `yaml:"drives"`
|
||||
Server Server `yaml:"server"`
|
||||
Storage Storage `yaml:"storage"`
|
||||
Scanner Scanner `yaml:"scanner"`
|
||||
Preview Preview `yaml:"preview"`
|
||||
Drives []Drive `yaml:"drives"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -51,11 +51,11 @@ type Preview struct {
|
||||
// Drive 配置项中的敏感字段(Cookie / RefreshToken 等)最终由管理后台写入 DB
|
||||
// 这里保留 yaml 中的静态定义,用于启动时预置盘。生产建议只在 DB 里维护。
|
||||
type Drive struct {
|
||||
ID string `yaml:"id"`
|
||||
Kind string `yaml:"kind"` // quark / p115 / pikpak / wopan
|
||||
Name string `yaml:"name"`
|
||||
RootID string `yaml:"root_id"`
|
||||
Params map[string]string `yaml:"params,omitempty"`
|
||||
ID string `yaml:"id"`
|
||||
Kind string `yaml:"kind"` // quark / p115 / pikpak / wopan / onedrive
|
||||
Name string `yaml:"name"`
|
||||
RootID string `yaml:"root_id"`
|
||||
Params map[string]string `yaml:"params,omitempty"`
|
||||
}
|
||||
|
||||
// Load 读取配置;若不存在则从 config.example.yaml 复制一份并返回
|
||||
@@ -105,8 +105,8 @@ func (c *Config) applyDefaults() {
|
||||
if c.Preview.FFprobePath == "" {
|
||||
c.Preview.FFprobePath = "ffprobe"
|
||||
}
|
||||
if c.Preview.DurationSeconds == 0 {
|
||||
c.Preview.DurationSeconds = 9
|
||||
if c.Preview.DurationSeconds != 3 {
|
||||
c.Preview.DurationSeconds = 3
|
||||
}
|
||||
if c.Preview.Width == 0 {
|
||||
c.Preview.Width = 480
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Drive 是三家网盘统一抽象。上层不区分盘,只区分 Kind。
|
||||
// Drive 是多家网盘统一抽象。上层不区分盘,只区分 Kind。
|
||||
type Drive interface {
|
||||
// Kind 返回驱动代号:"quark" / "p115" / "pikpak" / "wopan"
|
||||
// Kind 返回驱动代号:"quark" / "p115" / "pikpak" / "wopan" / "onedrive"
|
||||
Kind() string
|
||||
|
||||
// ID 返回该盘在 catalog 中的唯一标识
|
||||
@@ -45,6 +45,7 @@ type Entry struct {
|
||||
ID string
|
||||
Name string
|
||||
Size int64
|
||||
Hash string
|
||||
IsDir bool
|
||||
ParentID string
|
||||
MimeType string
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
package onedrive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
)
|
||||
|
||||
const (
|
||||
maxSmallUploadSize = 250 * 1024 * 1024
|
||||
defaultRenewAPIURL = "https://api.oplist.org/onedrive/renewapi"
|
||||
)
|
||||
|
||||
type Driver struct {
|
||||
id string
|
||||
rootID string
|
||||
region string
|
||||
accessToken string
|
||||
refreshToken string
|
||||
isSharePoint bool
|
||||
siteID string
|
||||
apiBaseURL string
|
||||
renewAPIURL string
|
||||
client *resty.Client
|
||||
onTokenUpdate func(access, refresh string)
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ID string
|
||||
RootID string
|
||||
Region string
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
IsSharePoint bool
|
||||
SiteID string
|
||||
OnTokenUpdate func(access, refresh string)
|
||||
|
||||
RenewAPIURL string
|
||||
APIBaseURL string
|
||||
}
|
||||
|
||||
func New(c Config) *Driver {
|
||||
rootID := strings.TrimSpace(c.RootID)
|
||||
if rootID == "" {
|
||||
rootID = "root"
|
||||
}
|
||||
region := strings.ToLower(strings.TrimSpace(c.Region))
|
||||
if region == "" {
|
||||
region = "global"
|
||||
}
|
||||
h, ok := hostMap[region]
|
||||
if !ok {
|
||||
h = hostMap["global"]
|
||||
}
|
||||
apiBaseURL := strings.TrimRight(strings.TrimSpace(c.APIBaseURL), "/")
|
||||
if apiBaseURL == "" {
|
||||
apiBaseURL = h.api
|
||||
}
|
||||
renewAPIURL := strings.TrimSpace(c.RenewAPIURL)
|
||||
if renewAPIURL == "" {
|
||||
renewAPIURL = defaultRenewAPIURL
|
||||
}
|
||||
return &Driver{
|
||||
id: c.ID,
|
||||
rootID: rootID,
|
||||
region: region,
|
||||
accessToken: strings.TrimSpace(c.AccessToken),
|
||||
refreshToken: strings.TrimSpace(c.RefreshToken),
|
||||
isSharePoint: c.IsSharePoint,
|
||||
siteID: strings.TrimSpace(c.SiteID),
|
||||
apiBaseURL: apiBaseURL,
|
||||
renewAPIURL: renewAPIURL,
|
||||
onTokenUpdate: c.OnTokenUpdate,
|
||||
client: resty.New().
|
||||
SetTimeout(30*time.Second).
|
||||
SetHeader("Accept", "application/json, text/plain, */*"),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Driver) Kind() string { return "onedrive" }
|
||||
func (d *Driver) ID() string { return d.id }
|
||||
func (d *Driver) RootID() string { return d.rootID }
|
||||
|
||||
func (d *Driver) Init(ctx context.Context) error {
|
||||
if d.refreshToken == "" {
|
||||
return errors.New("onedrive init: refresh_token is required")
|
||||
}
|
||||
if d.isSharePoint && d.siteID == "" {
|
||||
return errors.New("onedrive init: site_id is required for SharePoint")
|
||||
}
|
||||
return d.refresh(ctx)
|
||||
}
|
||||
|
||||
func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) {
|
||||
if dirID == "" {
|
||||
dirID = d.rootID
|
||||
}
|
||||
nextLink := d.childrenURL(dirID)
|
||||
first := true
|
||||
out := make([]drives.Entry, 0)
|
||||
for nextLink != "" {
|
||||
var resp filesResp
|
||||
err := d.request(ctx, nextLink, http.MethodGet, func(req *resty.Request) {
|
||||
if first {
|
||||
req.SetQueryParams(map[string]string{
|
||||
"$top": "1000",
|
||||
"$expand": "thumbnails($select=medium)",
|
||||
"$select": "id,name,size,fileSystemInfo,content.downloadUrl,file,parentReference,folder",
|
||||
})
|
||||
}
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("onedrive list: %w", err)
|
||||
}
|
||||
for _, item := range resp.Value {
|
||||
out = append(out, itemToEntry(item, dirID))
|
||||
}
|
||||
nextLink = resp.NextLink
|
||||
first = false
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) {
|
||||
var item graphItem
|
||||
if err := d.request(ctx, d.itemURL(fileID), http.MethodGet, nil, &item); err != nil {
|
||||
return nil, fmt.Errorf("onedrive stat: %w", err)
|
||||
}
|
||||
e := itemToEntry(item, "")
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) {
|
||||
var item graphItem
|
||||
if err := d.request(ctx, d.itemURL(fileID), http.MethodGet, nil, &item); err != nil {
|
||||
return nil, fmt.Errorf("onedrive download url: %w", err)
|
||||
}
|
||||
if item.DownloadURL == "" {
|
||||
return nil, errors.New("onedrive download url: empty")
|
||||
}
|
||||
return &drives.StreamLink{
|
||||
URL: item.DownloadURL,
|
||||
Headers: http.Header{},
|
||||
Expires: time.Now().Add(10 * time.Minute),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) {
|
||||
if parentID == "" {
|
||||
parentID = d.rootID
|
||||
}
|
||||
if size > maxSmallUploadSize {
|
||||
return "", fmt.Errorf("onedrive upload: files over %d bytes require upload session", maxSmallUploadSize)
|
||||
}
|
||||
data, err := readSmallUpload(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
u := fmt.Sprintf("%s/items/%s:/%s:/content", d.driveBaseURL(), url.PathEscape(parentID), url.PathEscape(name))
|
||||
var item graphItem
|
||||
err = d.request(ctx, u, http.MethodPut, func(req *resty.Request) {
|
||||
req.SetBody(bytes.NewReader(data))
|
||||
req.SetContentLength(true)
|
||||
}, &item)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("onedrive upload: %w", err)
|
||||
}
|
||||
if item.ID == "" {
|
||||
return "", errors.New("onedrive upload: empty item id")
|
||||
}
|
||||
return item.ID, nil
|
||||
}
|
||||
|
||||
func readSmallUpload(r io.Reader) ([]byte, error) {
|
||||
if r == nil {
|
||||
return nil, errors.New("onedrive upload: body is required")
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(r, maxSmallUploadSize+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("onedrive upload: read body: %w", err)
|
||||
}
|
||||
if len(data) > maxSmallUploadSize {
|
||||
return nil, fmt.Errorf("onedrive upload: files over %d bytes require upload session", maxSmallUploadSize)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) {
|
||||
currentID := d.rootID
|
||||
for _, name := range splitPath(pathFromRoot) {
|
||||
childID, err := d.findChildDir(ctx, currentID, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if childID == "" {
|
||||
childID, err = d.makeDir(ctx, currentID, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
currentID = childID
|
||||
}
|
||||
return currentID, nil
|
||||
}
|
||||
|
||||
func (d *Driver) findChildDir(ctx context.Context, parentID, name string) (string, error) {
|
||||
entries, err := d.List(ctx, parentID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir && e.Name == name {
|
||||
return e.ID, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (d *Driver) makeDir(ctx context.Context, parentID, name string) (string, error) {
|
||||
body := map[string]any{
|
||||
"name": name,
|
||||
"folder": map[string]any{},
|
||||
"@microsoft.graph.conflictBehavior": "rename",
|
||||
}
|
||||
var item graphItem
|
||||
err := d.request(ctx, d.childrenURL(parentID), http.MethodPost, func(req *resty.Request) {
|
||||
req.SetBody(body)
|
||||
}, &item)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("onedrive mkdir %s: %w", name, err)
|
||||
}
|
||||
if item.ID == "" {
|
||||
return "", fmt.Errorf("onedrive mkdir %s: empty item id", name)
|
||||
}
|
||||
return item.ID, nil
|
||||
}
|
||||
|
||||
func (d *Driver) request(ctx context.Context, rawURL, method string, configure func(*resty.Request), out any) error {
|
||||
return d.requestOnce(ctx, rawURL, method, configure, out, true)
|
||||
}
|
||||
|
||||
func (d *Driver) requestOnce(ctx context.Context, rawURL, method string, configure func(*resty.Request), out any, retry bool) error {
|
||||
req := d.client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Authorization", "Bearer "+d.accessToken)
|
||||
if configure != nil {
|
||||
configure(req)
|
||||
}
|
||||
if out != nil {
|
||||
req.SetResult(out)
|
||||
}
|
||||
var graphErr graphErrorResp
|
||||
req.SetError(&graphErr)
|
||||
res, err := req.Execute(method, rawURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if graphErr.Error.Code != "" {
|
||||
if graphErr.Error.Code == "InvalidAuthenticationToken" && retry {
|
||||
if err := d.refresh(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.requestOnce(ctx, rawURL, method, configure, out, false)
|
||||
}
|
||||
if graphErr.Error.Message != "" {
|
||||
return errors.New(graphErr.Error.Message)
|
||||
}
|
||||
return fmt.Errorf("graph api error: %s", graphErr.Error.Code)
|
||||
}
|
||||
if res.IsError() {
|
||||
return fmt.Errorf("graph api error: status=%d body=%s", res.StatusCode(), strings.TrimSpace(res.String()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Driver) refresh(ctx context.Context) error {
|
||||
var out tokenResp
|
||||
res, err := d.client.R().
|
||||
SetContext(ctx).
|
||||
SetQueryParams(map[string]string{
|
||||
"refresh_ui": d.refreshToken,
|
||||
"server_use": "true",
|
||||
"driver_txt": "onedrive_pr",
|
||||
}).
|
||||
SetResult(&out).
|
||||
SetError(&out).
|
||||
Get(d.renewAPIURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("onedrive refresh token: %w", err)
|
||||
}
|
||||
if out.Text != "" {
|
||||
return fmt.Errorf("onedrive refresh token: %s", out.Text)
|
||||
}
|
||||
if out.Error != "" {
|
||||
if out.Description != "" {
|
||||
return fmt.Errorf("onedrive refresh token: %s", out.Description)
|
||||
}
|
||||
return fmt.Errorf("onedrive refresh token: %s", out.Error)
|
||||
}
|
||||
if res.IsError() {
|
||||
return fmt.Errorf("onedrive refresh token: status=%d body=%s", res.StatusCode(), strings.TrimSpace(res.String()))
|
||||
}
|
||||
if out.AccessToken == "" || out.RefreshToken == "" {
|
||||
return errors.New("onedrive refresh token: empty token")
|
||||
}
|
||||
d.accessToken = out.AccessToken
|
||||
d.refreshToken = out.RefreshToken
|
||||
if d.onTokenUpdate != nil {
|
||||
d.onTokenUpdate(out.AccessToken, out.RefreshToken)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Driver) driveBaseURL() string {
|
||||
if d.isSharePoint {
|
||||
return fmt.Sprintf("%s/v1.0/sites/%s/drive", d.apiBaseURL, url.PathEscape(d.siteID))
|
||||
}
|
||||
return d.apiBaseURL + "/v1.0/me/drive"
|
||||
}
|
||||
|
||||
func (d *Driver) itemURL(itemID string) string {
|
||||
if itemID == "" {
|
||||
itemID = d.rootID
|
||||
}
|
||||
return d.driveBaseURL() + "/items/" + url.PathEscape(itemID)
|
||||
}
|
||||
|
||||
func (d *Driver) childrenURL(parentID string) string {
|
||||
return d.itemURL(parentID) + "/children"
|
||||
}
|
||||
|
||||
func itemToEntry(item graphItem, fallbackParentID string) drives.Entry {
|
||||
parentID := item.ParentRef.ID
|
||||
if parentID == "" {
|
||||
parentID = fallbackParentID
|
||||
}
|
||||
isDir := item.Folder != nil
|
||||
mod := time.Time{}
|
||||
if item.FileSystemInfo != nil {
|
||||
mod = item.FileSystemInfo.LastModifiedDateTime
|
||||
}
|
||||
mimeType := ""
|
||||
if item.File != nil {
|
||||
mimeType = item.File.MimeType
|
||||
}
|
||||
if mimeType == "" && !isDir {
|
||||
mimeType = guessMime(item.Name)
|
||||
}
|
||||
thumb := ""
|
||||
if len(item.Thumbnails) > 0 {
|
||||
thumb = item.Thumbnails[0].Medium.URL
|
||||
}
|
||||
return drives.Entry{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
Size: item.Size,
|
||||
IsDir: isDir,
|
||||
ParentID: parentID,
|
||||
MimeType: mimeType,
|
||||
ModTime: mod,
|
||||
ThumbnailURL: thumb,
|
||||
}
|
||||
}
|
||||
|
||||
func splitPath(p string) []string {
|
||||
p = strings.Trim(p, "/")
|
||||
if p == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(p, "/")
|
||||
}
|
||||
|
||||
func guessMime(name string) string {
|
||||
ext := strings.ToLower(path.Ext(name))
|
||||
switch ext {
|
||||
case ".mp4":
|
||||
return "video/mp4"
|
||||
case ".mkv":
|
||||
return "video/x-matroska"
|
||||
case ".mov":
|
||||
return "video/quicktime"
|
||||
case ".webm":
|
||||
return "video/webm"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
var _ drives.Drive = (*Driver)(nil)
|
||||
@@ -0,0 +1,434 @@
|
||||
package onedrive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
)
|
||||
|
||||
func TestInitRefreshesTokenThroughOpenListOnlineAPIAndPersistsUpdate(t *testing.T) {
|
||||
var tokenRequestSeen bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/renewapi" {
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
|
||||
}
|
||||
tokenRequestSeen = true
|
||||
want := map[string]string{
|
||||
"refresh_ui": "old-refresh",
|
||||
"server_use": "true",
|
||||
"driver_txt": "onedrive_pr",
|
||||
}
|
||||
for key, value := range want {
|
||||
if got := r.URL.Query().Get(key); got != value {
|
||||
t.Fatalf("query %s = %q, want %q", key, got, value)
|
||||
}
|
||||
}
|
||||
writeJSON(t, w, map[string]any{
|
||||
"access_token": "new-access",
|
||||
"refresh_token": "new-refresh",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
var persistedAccess, persistedRefresh string
|
||||
d := New(Config{
|
||||
ID: "od-main",
|
||||
RefreshToken: "old-refresh",
|
||||
RenewAPIURL: srv.URL + "/renewapi",
|
||||
APIBaseURL: srv.URL,
|
||||
OnTokenUpdate: func(access, refresh string) {
|
||||
persistedAccess = access
|
||||
persistedRefresh = refresh
|
||||
},
|
||||
})
|
||||
|
||||
if d.Kind() != "onedrive" {
|
||||
t.Fatalf("kind = %q, want onedrive", d.Kind())
|
||||
}
|
||||
if d.ID() != "od-main" {
|
||||
t.Fatalf("id = %q, want od-main", d.ID())
|
||||
}
|
||||
if d.RootID() != "root" {
|
||||
t.Fatalf("root id = %q, want root", d.RootID())
|
||||
}
|
||||
if err := d.Init(context.Background()); err != nil {
|
||||
t.Fatalf("init: %v", err)
|
||||
}
|
||||
if !tokenRequestSeen {
|
||||
t.Fatal("OpenList renew API was not called")
|
||||
}
|
||||
if persistedAccess != "new-access" || persistedRefresh != "new-refresh" {
|
||||
t.Fatalf("persisted tokens = %q/%q, want new-access/new-refresh", persistedAccess, persistedRefresh)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFollowsPaginationAndMapsEntries(t *testing.T) {
|
||||
var srv *httptest.Server
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer access-token" {
|
||||
t.Fatalf("authorization = %q, want bearer token", got)
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/v1.0/me/drive/items/root/children":
|
||||
if r.URL.Query().Get("$top") != "1000" {
|
||||
t.Fatalf("$top = %q, want 1000", r.URL.Query().Get("$top"))
|
||||
}
|
||||
writeJSON(t, w, map[string]any{
|
||||
"value": []map[string]any{
|
||||
{
|
||||
"id": "folder-id",
|
||||
"name": "Movies",
|
||||
"size": 0,
|
||||
"folder": map[string]any{
|
||||
"childCount": 2,
|
||||
},
|
||||
"fileSystemInfo": map[string]any{
|
||||
"lastModifiedDateTime": "2026-05-10T12:30:00Z",
|
||||
},
|
||||
"parentReference": map[string]any{
|
||||
"id": "root",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "file-id",
|
||||
"name": "demo.mp4",
|
||||
"size": 12345,
|
||||
"file": map[string]any{
|
||||
"mimeType": "video/mp4",
|
||||
},
|
||||
"fileSystemInfo": map[string]any{
|
||||
"lastModifiedDateTime": "2026-05-10T13:30:00Z",
|
||||
},
|
||||
"thumbnails": []map[string]any{
|
||||
{"medium": map[string]any{"url": "https://thumb.example/demo.jpg"}},
|
||||
},
|
||||
"parentReference": map[string]any{
|
||||
"id": "root",
|
||||
},
|
||||
},
|
||||
},
|
||||
"@odata.nextLink": srv.URL + "/next-page",
|
||||
})
|
||||
case "/next-page":
|
||||
writeJSON(t, w, map[string]any{
|
||||
"value": []map[string]any{
|
||||
{
|
||||
"id": "file-2",
|
||||
"name": "second.mkv",
|
||||
"size": 77,
|
||||
"file": map[string]any{
|
||||
"mimeType": "video/x-matroska",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d := New(Config{
|
||||
ID: "od-main",
|
||||
AccessToken: "access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
APIBaseURL: srv.URL,
|
||||
})
|
||||
|
||||
got, err := d.List(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("entries len = %d, want 3", len(got))
|
||||
}
|
||||
if !got[0].IsDir || got[0].ID != "folder-id" || got[0].ParentID != "root" {
|
||||
t.Fatalf("folder entry = %#v", got[0])
|
||||
}
|
||||
if got[1].IsDir || got[1].MimeType != "video/mp4" || got[1].ThumbnailURL != "https://thumb.example/demo.jpg" {
|
||||
t.Fatalf("file entry = %#v", got[1])
|
||||
}
|
||||
if got[1].ModTime.IsZero() {
|
||||
t.Fatal("file mod time should be parsed")
|
||||
}
|
||||
if got[2].Name != "second.mkv" || got[2].Size != 77 {
|
||||
t.Fatalf("paginated entry = %#v", got[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphItemWithoutFolderFacetIsNotDirectory(t *testing.T) {
|
||||
got := itemToEntry(graphItem{
|
||||
ID: "special-id",
|
||||
Name: "个人保管库",
|
||||
}, "root")
|
||||
|
||||
if got.IsDir {
|
||||
t.Fatalf("special Graph item without folder facet should not be treated as a directory: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatAndStreamURLUseDriveItemMetadata(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer access-token" {
|
||||
t.Fatalf("authorization = %q, want bearer token", got)
|
||||
}
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/v1.0/me/drive/items/file-id" {
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
|
||||
}
|
||||
writeJSON(t, w, map[string]any{
|
||||
"id": "file-id",
|
||||
"name": "movie.mov",
|
||||
"size": 2048,
|
||||
"file": map[string]any{
|
||||
"mimeType": "video/quicktime",
|
||||
},
|
||||
"parentReference": map[string]any{
|
||||
"id": "parent-id",
|
||||
},
|
||||
"@microsoft.graph.downloadUrl": "https://download.example/movie.mov",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d := New(Config{
|
||||
ID: "od-main",
|
||||
AccessToken: "access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
APIBaseURL: srv.URL,
|
||||
})
|
||||
|
||||
entry, err := d.Stat(context.Background(), "file-id")
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if entry.ID != "file-id" || entry.Name != "movie.mov" || entry.ParentID != "parent-id" {
|
||||
t.Fatalf("entry = %#v", entry)
|
||||
}
|
||||
|
||||
link, err := d.StreamURL(context.Background(), "file-id")
|
||||
if err != nil {
|
||||
t.Fatalf("stream url: %v", err)
|
||||
}
|
||||
if link.URL != "https://download.example/movie.mov" {
|
||||
t.Fatalf("stream url = %q, want download url", link.URL)
|
||||
}
|
||||
if len(link.Headers) != 0 {
|
||||
t.Fatalf("headers = %#v, want none", link.Headers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDirCreatesMissingFolders(t *testing.T) {
|
||||
var created []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer access-token" {
|
||||
t.Fatalf("authorization = %q, want bearer token", got)
|
||||
}
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1.0/me/drive/items/root/children":
|
||||
writeJSON(t, w, map[string]any{
|
||||
"value": []map[string]any{
|
||||
{
|
||||
"id": "existing-id",
|
||||
"name": "existing",
|
||||
"folder": map[string]any{},
|
||||
},
|
||||
},
|
||||
})
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1.0/me/drive/items/existing-id/children":
|
||||
writeJSON(t, w, map[string]any{"value": []map[string]any{}})
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/v1.0/me/drive/items/existing-id/children":
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode mkdir body: %v", err)
|
||||
}
|
||||
created = append(created, body["name"].(string))
|
||||
if body["@microsoft.graph.conflictBehavior"] != "rename" {
|
||||
t.Fatalf("conflict behavior = %#v, want rename", body["@microsoft.graph.conflictBehavior"])
|
||||
}
|
||||
writeJSON(t, w, map[string]any{
|
||||
"id": "created-id",
|
||||
"name": body["name"],
|
||||
"folder": map[string]any{},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d := New(Config{
|
||||
ID: "od-main",
|
||||
AccessToken: "access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
APIBaseURL: srv.URL,
|
||||
})
|
||||
|
||||
got, err := d.EnsureDir(context.Background(), "/existing/previews")
|
||||
if err != nil {
|
||||
t.Fatalf("ensure dir: %v", err)
|
||||
}
|
||||
if got != "created-id" {
|
||||
t.Fatalf("dir id = %q, want created-id", got)
|
||||
}
|
||||
if len(created) != 1 || created[0] != "previews" {
|
||||
t.Fatalf("created folders = %#v, want previews", created)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadSmallFileReturnsNewItemID(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer access-token" {
|
||||
t.Fatalf("authorization = %q, want bearer token", got)
|
||||
}
|
||||
if r.Method != http.MethodPut || r.URL.EscapedPath() != "/v1.0/me/drive/items/parent-id:/preview%20file.mp4:/content" {
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
|
||||
}
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read upload body: %v", err)
|
||||
}
|
||||
if string(data) != "preview-bytes" {
|
||||
t.Fatalf("upload body = %q, want preview-bytes", string(data))
|
||||
}
|
||||
writeJSON(t, w, map[string]any{
|
||||
"id": "uploaded-id",
|
||||
"name": "preview file.mp4",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d := New(Config{
|
||||
ID: "od-main",
|
||||
AccessToken: "access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
APIBaseURL: srv.URL,
|
||||
})
|
||||
|
||||
got, err := d.Upload(context.Background(), "parent-id", "preview file.mp4", strings.NewReader("preview-bytes"), int64(len("preview-bytes")))
|
||||
if err != nil {
|
||||
t.Fatalf("upload: %v", err)
|
||||
}
|
||||
if got != "uploaded-id" {
|
||||
t.Fatalf("uploaded id = %q, want uploaded-id", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadRefreshesExpiredTokenAndReplaysBody(t *testing.T) {
|
||||
var uploadAttempts int
|
||||
var tokenRefreshes int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodPut && r.URL.EscapedPath() == "/v1.0/me/drive/items/parent-id:/preview.mp4:/content":
|
||||
uploadAttempts++
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read upload body: %v", err)
|
||||
}
|
||||
if string(data) != "preview-bytes" {
|
||||
t.Fatalf("upload attempt %d body = %q, want preview-bytes", uploadAttempts, string(data))
|
||||
}
|
||||
if uploadAttempts == 1 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": map[string]any{
|
||||
"code": "InvalidAuthenticationToken",
|
||||
"message": "token expired",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("write json: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer new-access" {
|
||||
t.Fatalf("retry authorization = %q, want new access token", got)
|
||||
}
|
||||
writeJSON(t, w, map[string]any{"id": "uploaded-id"})
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/renewapi":
|
||||
tokenRefreshes++
|
||||
if got := r.URL.Query().Get("refresh_ui"); got != "old-refresh" {
|
||||
t.Fatalf("refresh_ui = %q, want old-refresh", got)
|
||||
}
|
||||
writeJSON(t, w, map[string]any{
|
||||
"access_token": "new-access",
|
||||
"refresh_token": "new-refresh",
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d := New(Config{
|
||||
ID: "od-main",
|
||||
AccessToken: "expired-access",
|
||||
RefreshToken: "old-refresh",
|
||||
RenewAPIURL: srv.URL + "/renewapi",
|
||||
APIBaseURL: srv.URL,
|
||||
})
|
||||
|
||||
got, err := d.Upload(context.Background(), "parent-id", "preview.mp4", strings.NewReader("preview-bytes"), int64(len("preview-bytes")))
|
||||
if err != nil {
|
||||
t.Fatalf("upload: %v", err)
|
||||
}
|
||||
if got != "uploaded-id" {
|
||||
t.Fatalf("uploaded id = %q, want uploaded-id", got)
|
||||
}
|
||||
if uploadAttempts != 2 || tokenRefreshes != 1 {
|
||||
t.Fatalf("attempts/refreshes = %d/%d, want 2/1", uploadAttempts, tokenRefreshes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharePointUsesSiteDriveBase(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/renewapi":
|
||||
if r.Method != http.MethodGet {
|
||||
t.Fatalf("renew method = %s, want GET", r.Method)
|
||||
}
|
||||
writeJSON(t, w, map[string]any{
|
||||
"access_token": "access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
})
|
||||
case "/v1.0/sites/site-123/drive/items/root/children":
|
||||
writeJSON(t, w, map[string]any{"value": []map[string]any{}})
|
||||
default:
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d := New(Config{
|
||||
ID: "od-sp",
|
||||
RefreshToken: "old-refresh",
|
||||
IsSharePoint: true,
|
||||
SiteID: "site-123",
|
||||
RenewAPIURL: srv.URL + "/renewapi",
|
||||
APIBaseURL: srv.URL,
|
||||
})
|
||||
if err := d.Init(context.Background()); err != nil {
|
||||
t.Fatalf("init: %v", err)
|
||||
}
|
||||
if _, err := d.List(context.Background(), "root"); err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriverImplementsInterface(t *testing.T) {
|
||||
var _ drives.Drive = (*Driver)(nil)
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, body any) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||
t.Fatalf("write json: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package onedrive
|
||||
|
||||
import "time"
|
||||
|
||||
type host struct {
|
||||
oauth string
|
||||
api string
|
||||
}
|
||||
|
||||
var hostMap = map[string]host{
|
||||
"global": {
|
||||
oauth: "https://login.microsoftonline.com",
|
||||
api: "https://graph.microsoft.com",
|
||||
},
|
||||
"cn": {
|
||||
oauth: "https://login.chinacloudapi.cn",
|
||||
api: "https://microsoftgraph.chinacloudapi.cn",
|
||||
},
|
||||
"us": {
|
||||
oauth: "https://login.microsoftonline.us",
|
||||
api: "https://graph.microsoft.us",
|
||||
},
|
||||
"de": {
|
||||
oauth: "https://login.microsoftonline.de",
|
||||
api: "https://graph.microsoft.de",
|
||||
},
|
||||
}
|
||||
|
||||
type tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Error string `json:"error"`
|
||||
Description string `json:"error_description"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type graphErrorResp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
type graphItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
FileSystemInfo *fileSystemInfo `json:"fileSystemInfo"`
|
||||
DownloadURL string `json:"@microsoft.graph.downloadUrl"`
|
||||
File *fileFacet `json:"file"`
|
||||
Folder *folderFacet `json:"folder"`
|
||||
Thumbnails []thumbnail `json:"thumbnails"`
|
||||
ParentRef parentRef `json:"parentReference"`
|
||||
}
|
||||
|
||||
type fileSystemInfo struct {
|
||||
CreatedDateTime time.Time `json:"createdDateTime"`
|
||||
LastModifiedDateTime time.Time `json:"lastModifiedDateTime"`
|
||||
}
|
||||
|
||||
type fileFacet struct {
|
||||
MimeType string `json:"mimeType"`
|
||||
}
|
||||
|
||||
type folderFacet struct {
|
||||
ChildCount int `json:"childCount"`
|
||||
}
|
||||
|
||||
type thumbnail struct {
|
||||
Medium struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"medium"`
|
||||
}
|
||||
|
||||
type parentRef struct {
|
||||
ID string `json:"id"`
|
||||
DriveID string `json:"driveId"`
|
||||
}
|
||||
|
||||
type filesResp struct {
|
||||
Value []graphItem `json:"value"`
|
||||
NextLink string `json:"@odata.nextLink"`
|
||||
}
|
||||
@@ -41,6 +41,7 @@ func TestFileToEntry(t *testing.T) {
|
||||
ID: "file-id",
|
||||
Name: "movie.mp4",
|
||||
Kind: "drive#file",
|
||||
Hash: "hash-value",
|
||||
Size: "12345",
|
||||
ThumbnailLink: "https://thumbnail.example/movie.jpg",
|
||||
ModifiedTime: mod,
|
||||
@@ -69,6 +70,9 @@ func TestFileToEntry(t *testing.T) {
|
||||
if got.ThumbnailURL != "https://thumbnail.example/movie.jpg" {
|
||||
t.Fatalf("thumbnail = %q, want remote thumbnail", got.ThumbnailURL)
|
||||
}
|
||||
if got.Hash != "hash-value" {
|
||||
t.Fatalf("hash = %q, want hash-value", got.Hash)
|
||||
}
|
||||
if !got.ModTime.Equal(mod) {
|
||||
t.Fatalf("mod time = %v, want %v", got.ModTime, mod)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package pikpak
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
@@ -78,6 +79,7 @@ func fileToEntry(f file, parentID string) drives.Entry {
|
||||
ID: f.ID,
|
||||
Name: f.Name,
|
||||
Size: size,
|
||||
Hash: strings.TrimSpace(f.Hash),
|
||||
IsDir: f.Kind == "drive#folder",
|
||||
ParentID: parentID,
|
||||
MimeType: guessMime(f.Name),
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var Labels = []string{"后入", "奶子", "口交", "臀", "人妻", "女大"}
|
||||
var Labels = []string{"后入", "奶子", "口交", "臀", "人妻", "女大", "AV"}
|
||||
|
||||
var aliases = map[string][]string{
|
||||
"后入": {"后入", "後入", "后入式", "後入式", "后进", "後進", "后位", "後位", "背入", "背后", "后背", "背后式", "后背位", "狗爬", "狗爬式", "追尾", "doggy", "doggystyle", "doggy style", "doggy-style", "backshot", "back shot", "back-shot", "from behind", "rear entry"},
|
||||
@@ -14,6 +14,14 @@ var aliases = map[string][]string{
|
||||
"臀": {"臀", "屁股", "屁屁", "翘臀", "美臀", "肥臀", "巨臀", "蜜桃臀", "大屁股", "尻", "后庭", "後庭", "菊花", "肛", "肛交", "屁眼", "ass", "big ass", "big-ass", "butt", "big butt", "big-butt", "booty", "buttocks", "hip"},
|
||||
"人妻": {"人妻", "妻子", "老婆", "太太", "少妇", "少熟", "熟女", "已婚", "良家", "人妇", "人夫", "wife", "housewife", "married", "married woman", "young wife", "milf"},
|
||||
"女大": {"女大", "女大学生", "大学生", "女子大生", "大学", "女学生", "学生妹", "校花", "学妹", "校园", "大一", "大二", "大三", "大四", "college", "college student", "university", "university student", "campus", "coed"},
|
||||
"AV": {"AV", "JAV", "番号", "番號"},
|
||||
}
|
||||
|
||||
func AliasesFor(label string) []string {
|
||||
values := aliases[label]
|
||||
out := make([]string, len(values))
|
||||
copy(out, values)
|
||||
return out
|
||||
}
|
||||
|
||||
func MatchFilename(name string) []string {
|
||||
|
||||
@@ -20,9 +20,9 @@ import (
|
||||
type Config struct {
|
||||
FFmpegPath string
|
||||
FFprobePath string
|
||||
DurationSeconds int // 单段时长(秒),用于单段 fallback;拼接模式下每段 = DurationSeconds / 段数
|
||||
DurationSeconds int // 兼容旧配置;当前 teaser 每段固定 3 秒
|
||||
Width int
|
||||
Segments int // teaser 段数,1=单段,推荐 3
|
||||
Segments int // 兼容旧配置;当前 30 秒及以上视频固定使用 4 段
|
||||
LocalDir string // 本地兜底
|
||||
RemoteDir string // 远端目录路径(相对盘根)
|
||||
}
|
||||
@@ -49,8 +49,8 @@ func New(cfg Config) *Generator {
|
||||
if cfg.FFprobePath == "" {
|
||||
cfg.FFprobePath = "ffprobe"
|
||||
}
|
||||
if cfg.DurationSeconds == 0 {
|
||||
cfg.DurationSeconds = 9 // 3 段 × 3 秒
|
||||
if cfg.DurationSeconds != 3 {
|
||||
cfg.DurationSeconds = 3
|
||||
}
|
||||
if cfg.Width == 0 {
|
||||
cfg.Width = 480
|
||||
@@ -63,12 +63,40 @@ func New(cfg Config) *Generator {
|
||||
|
||||
// --- 选段策略 ---
|
||||
|
||||
type teaserPlan struct {
|
||||
starts []float64
|
||||
eachSec float64
|
||||
}
|
||||
|
||||
func buildTeaserPlan(cfg Config, duration float64) teaserPlan {
|
||||
if cfg.DurationSeconds != 3 {
|
||||
cfg.DurationSeconds = 3
|
||||
}
|
||||
if cfg.Segments <= 0 {
|
||||
cfg.Segments = 3
|
||||
}
|
||||
|
||||
segs := 1
|
||||
if duration > 0 && duration < 30 {
|
||||
segs = 3
|
||||
} else if duration >= 30 {
|
||||
segs = 4
|
||||
}
|
||||
|
||||
eachSec := 3.0
|
||||
|
||||
return teaserPlan{
|
||||
starts: pickSegmentStarts(duration, segs, eachSec),
|
||||
eachSec: eachSec,
|
||||
}
|
||||
}
|
||||
|
||||
// pickSegmentStarts 根据视频总时长选出 N 段起点秒数(按时间升序)
|
||||
//
|
||||
// 规则:
|
||||
// - duration < 30s → 单段从 max(2, duration*0.1) 起
|
||||
// - 30s ≤ duration < 10min → N 段:前段跳过片头、末段避开片尾
|
||||
// - duration ≥ 10min → 20% / 50% / 80%(或按 N 等距分布)
|
||||
// - duration < 30s → 最多 3 段;放不下完整 3 秒片段时丢弃对应片段
|
||||
// - 30s ≤ duration < 10min → 4 段:前段跳过片头、末段避开片尾
|
||||
// - duration ≥ 10min → 固定 4 段,按 20% ~ 80% 等距分布
|
||||
func pickSegmentStarts(duration float64, n int, eachSec float64) []float64 {
|
||||
if n <= 0 {
|
||||
n = 1
|
||||
@@ -77,20 +105,33 @@ func pickSegmentStarts(duration float64, n int, eachSec float64) []float64 {
|
||||
// 未知时长,用保守默认
|
||||
return []float64{10}
|
||||
}
|
||||
if duration < 30 {
|
||||
completeSegments := int(math.Floor(duration / eachSec))
|
||||
if completeSegments > n {
|
||||
completeSegments = n
|
||||
}
|
||||
if completeSegments <= 0 {
|
||||
return nil
|
||||
}
|
||||
usable := duration - eachSec
|
||||
first := math.Min(duration*0.1, usable)
|
||||
if completeSegments == 1 {
|
||||
return []float64{math.Max(0, first)}
|
||||
}
|
||||
starts := make([]float64, 0, completeSegments)
|
||||
step := (usable - first) / float64(completeSegments-1)
|
||||
for i := 0; i < completeSegments; i++ {
|
||||
starts = append(starts, first+step*float64(i))
|
||||
}
|
||||
return starts
|
||||
}
|
||||
|
||||
// 余量:保证最后一段结束前留 1 秒,避免切到文件末尾
|
||||
usable := duration - eachSec - 1
|
||||
if usable < 0 {
|
||||
usable = 0
|
||||
}
|
||||
|
||||
if duration < 30 {
|
||||
start := math.Max(2, duration*0.1)
|
||||
if start > usable {
|
||||
start = math.Max(0, usable)
|
||||
}
|
||||
return []float64{start}
|
||||
}
|
||||
|
||||
if duration < 600 {
|
||||
// 30s ~ 10min:20% 起,均匀分段
|
||||
starts := make([]float64, 0, n)
|
||||
@@ -235,22 +276,13 @@ func (g *Generator) Generate(ctx context.Context, link *drives.StreamLink, durat
|
||||
return "", err
|
||||
}
|
||||
|
||||
segs := g.cfg.Segments
|
||||
// 视频太短直接单段
|
||||
if duration > 0 && duration < 30 {
|
||||
segs = 1
|
||||
plan := buildTeaserPlan(g.cfg, duration)
|
||||
starts := plan.starts
|
||||
eachSec := plan.eachSec
|
||||
if len(starts) == 0 {
|
||||
return "", fmt.Errorf("video too short for %.0fs teaser segment", eachSec)
|
||||
}
|
||||
|
||||
eachSec := float64(g.cfg.DurationSeconds)
|
||||
if segs > 1 {
|
||||
eachSec = float64(g.cfg.DurationSeconds) / float64(segs)
|
||||
if eachSec < 2 {
|
||||
eachSec = 2
|
||||
}
|
||||
}
|
||||
|
||||
starts := pickSegmentStarts(duration, segs, eachSec)
|
||||
|
||||
ctx2, cancel := context.WithTimeout(ctx, 4*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
@@ -390,10 +422,27 @@ func NewWorker(gen TeaserGenerator, cat *catalog.Catalog, drv drives.Drive, remo
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) Enqueue(v *catalog.Video) {
|
||||
func (w *Worker) Enqueue(v *catalog.Video) bool {
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case w.ch <- v:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) EnqueueBlocking(ctx context.Context, v *catalog.Video) bool {
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case w.ch <- v:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,10 +462,27 @@ func NewThumbWorker(gen ThumbnailGenerator, cat *catalog.Catalog, drv drives.Dri
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ThumbWorker) Enqueue(v *catalog.Video) {
|
||||
func (w *ThumbWorker) Enqueue(v *catalog.Video) bool {
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case w.ch <- v:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ThumbWorker) EnqueueBlocking(ctx context.Context, v *catalog.Video) bool {
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case w.ch <- v:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -522,10 +588,23 @@ func (w *Worker) process(ctx context.Context, v *catalog.Video) {
|
||||
log.Printf("[preview] upload %s: %v (local only)", v.Title, uerr)
|
||||
}
|
||||
}
|
||||
removePreviousLocalTeaser(v.PreviewLocal, local)
|
||||
w.Catalog.UpdatePreview(ctx, v.ID, previewFileID, local, "ready")
|
||||
log.Printf("[preview] ready %s (duration=%.1fs)", v.Title, duration)
|
||||
}
|
||||
|
||||
func removePreviousLocalTeaser(previous, current string) {
|
||||
if previous == "" {
|
||||
return
|
||||
}
|
||||
if filepath.Clean(previous) == filepath.Clean(current) {
|
||||
return
|
||||
}
|
||||
if err := os.Remove(previous); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("[preview] remove old local teaser %s: %v", previous, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) uploadToDrive(ctx context.Context, videoID, localPath string) (string, error) {
|
||||
parentID, err := w.Drive.EnsureDir(ctx, w.RemoteDir)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewDefaultsToThreeSecondTeaserSegments(t *testing.T) {
|
||||
gen := New(Config{})
|
||||
if gen.cfg.DurationSeconds != 3 {
|
||||
t.Fatalf("DurationSeconds = %d, want 3", gen.cfg.DurationSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediumVideoPreviewPlanUsesFourThreeSecondSegments(t *testing.T) {
|
||||
plan := buildTeaserPlan(Config{DurationSeconds: 3, Segments: 3}, 300)
|
||||
if len(plan.starts) != 4 {
|
||||
t.Fatalf("segments = %d, want 4", len(plan.starts))
|
||||
}
|
||||
if plan.eachSec != 3 {
|
||||
t.Fatalf("eachSec = %.2f, want 3", plan.eachSec)
|
||||
}
|
||||
want := []float64{15, 95, 175, 255}
|
||||
for i := range want {
|
||||
if math.Abs(plan.starts[i]-want[i]) > 0.001 {
|
||||
t.Fatalf("start[%d] = %.2f, want %.2f", i, plan.starts[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLongVideoPreviewPlanUsesFourThreeSecondSegments(t *testing.T) {
|
||||
plan := buildTeaserPlan(Config{DurationSeconds: 15, Segments: 3}, 601)
|
||||
if len(plan.starts) != 4 {
|
||||
t.Fatalf("segments = %d, want 4", len(plan.starts))
|
||||
}
|
||||
if plan.eachSec != 3 {
|
||||
t.Fatalf("eachSec = %.2f, want 3", plan.eachSec)
|
||||
}
|
||||
want := []float64{120.2, 240.4, 360.6, 480.8}
|
||||
for i := range want {
|
||||
if math.Abs(plan.starts[i]-want[i]) > 0.001 {
|
||||
t.Fatalf("start[%d] = %.2f, want %.2f", i, plan.starts[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortVideoPreviewPlanUsesUpToThreeThreeSecondSegments(t *testing.T) {
|
||||
plan := buildTeaserPlan(Config{DurationSeconds: 15, Segments: 3}, 20)
|
||||
if len(plan.starts) != 3 {
|
||||
t.Fatalf("segments = %d, want 3", len(plan.starts))
|
||||
}
|
||||
if plan.eachSec != 3 {
|
||||
t.Fatalf("eachSec = %.2f, want 3", plan.eachSec)
|
||||
}
|
||||
want := []float64{2, 9.5, 17}
|
||||
for i := range want {
|
||||
if math.Abs(plan.starts[i]-want[i]) > 0.001 {
|
||||
t.Fatalf("start[%d] = %.2f, want %.2f", i, plan.starts[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortVideoPreviewPlanDropsSegmentsThatDoNotFit(t *testing.T) {
|
||||
plan := buildTeaserPlan(Config{DurationSeconds: 15, Segments: 3}, 8)
|
||||
if len(plan.starts) != 2 {
|
||||
t.Fatalf("segments = %d, want 2", len(plan.starts))
|
||||
}
|
||||
if plan.eachSec != 3 {
|
||||
t.Fatalf("eachSec = %.2f, want 3", plan.eachSec)
|
||||
}
|
||||
want := []float64{0.8, 5}
|
||||
for i := range want {
|
||||
if math.Abs(plan.starts[i]-want[i]) > 0.001 {
|
||||
t.Fatalf("start[%d] = %.2f, want %.2f", i, plan.starts[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortVideoPreviewPlanReturnsNoSegmentsWhenOneSegmentCannotFit(t *testing.T) {
|
||||
plan := buildTeaserPlan(Config{DurationSeconds: 15, Segments: 3}, 2.5)
|
||||
if len(plan.starts) != 0 {
|
||||
t.Fatalf("segments = %d, want 0", len(plan.starts))
|
||||
}
|
||||
if plan.eachSec != 3 {
|
||||
t.Fatalf("eachSec = %.2f, want 3", plan.eachSec)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package preview
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -73,6 +75,39 @@ func TestPreviewWorkerGeneratesTeaserWithoutReplacingExistingThumbnail(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewWorkerRemovesPreviousLocalTeaserAfterNewTeaserIsReady(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cat, video := seedPreviewTestVideo(t, "preview-cleanup-video")
|
||||
oldPath := filepath.Join(t.TempDir(), "old-teaser.mp4")
|
||||
if err := os.WriteFile(oldPath, []byte("old teaser"), 0o644); err != nil {
|
||||
t.Fatalf("write old teaser: %v", err)
|
||||
}
|
||||
video.PreviewLocal = oldPath
|
||||
video.PreviewStatus = "ready"
|
||||
if err := cat.UpsertVideo(ctx, video); err != nil {
|
||||
t.Fatalf("update video: %v", err)
|
||||
}
|
||||
|
||||
gen := &fakeTeaserGenerator{
|
||||
localPath: filepath.Join(t.TempDir(), "new-teaser.mp4"),
|
||||
}
|
||||
drv := &previewFakeDrive{}
|
||||
worker := NewWorker(gen, cat, drv, "")
|
||||
|
||||
worker.process(ctx, video)
|
||||
|
||||
if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("old teaser still exists or stat failed with unexpected error: %v", err)
|
||||
}
|
||||
got, err := cat.GetVideo(ctx, video.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get video: %v", err)
|
||||
}
|
||||
if got.PreviewLocal != gen.localPath {
|
||||
t.Fatalf("preview local = %q, want %q", got.PreviewLocal, gen.localPath)
|
||||
}
|
||||
}
|
||||
|
||||
func seedPreviewTestVideo(t *testing.T, id string) (*catalog.Catalog, *catalog.Video) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
@@ -117,7 +152,9 @@ func (g *fakeThumbGenerator) GenerateThumbnail(_ context.Context, _ *drives.Stre
|
||||
return "/tmp/" + videoID + ".jpg", nil
|
||||
}
|
||||
|
||||
type fakeTeaserGenerator struct{}
|
||||
type fakeTeaserGenerator struct {
|
||||
localPath string
|
||||
}
|
||||
|
||||
func (g *fakeTeaserGenerator) Probe(context.Context, *drives.StreamLink) (float64, error) {
|
||||
return 0, nil
|
||||
@@ -128,6 +165,9 @@ func (g *fakeTeaserGenerator) Generate(context.Context, *drives.StreamLink, floa
|
||||
}
|
||||
|
||||
func (g *fakeTeaserGenerator) MoveToLocal(_ string, videoID string) (string, error) {
|
||||
if g.localPath != "" {
|
||||
return g.localPath, nil
|
||||
}
|
||||
return "/tmp/" + videoID + ".mp4", nil
|
||||
}
|
||||
|
||||
|
||||
@@ -91,9 +91,24 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, depth int, st
|
||||
if parsed.Title == "" {
|
||||
parsed.Title = strings.TrimSuffix(e.Name, ext)
|
||||
}
|
||||
tags := parsed.Tags
|
||||
if matched, err := s.Catalog.MatchTags(ctx, e.Name+" "+dirName+" "+parsed.Author); err == nil {
|
||||
tags = mergeTags(tags, matched)
|
||||
}
|
||||
if label, ok, err := s.Catalog.EnsureCollectionTag(ctx, dirName); err == nil && ok {
|
||||
tags = mergeTags(tags, []string{label})
|
||||
}
|
||||
|
||||
existing, _ := s.Catalog.GetVideo(ctx, id)
|
||||
if existing != nil {
|
||||
if e.Hash != "" && existing.ContentHash == "" {
|
||||
_ = s.Catalog.UpdateVideoMeta(ctx, id, catalog.VideoMetaPatch{ContentHash: e.Hash})
|
||||
existing.ContentHash = e.Hash
|
||||
}
|
||||
if dup := s.findDuplicateByHash(ctx, e.Hash, id); dup != nil {
|
||||
s.backfillDuplicateThumbnail(ctx, dup, e.ThumbnailURL)
|
||||
continue
|
||||
}
|
||||
// 已存在但轻量元数据空缺时,顺便补齐。
|
||||
patch := catalog.VideoMetaPatch{}
|
||||
if existing.Category == "" && dirName != "" {
|
||||
@@ -102,13 +117,17 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, depth int, st
|
||||
if existing.ThumbnailURL == "" && e.ThumbnailURL != "" {
|
||||
patch.ThumbnailURL = e.ThumbnailURL
|
||||
}
|
||||
if !sameTags(existing.Tags, parsed.Tags) {
|
||||
patch.Tags = parsed.Tags
|
||||
patch.TagsSet = true
|
||||
}
|
||||
if patch.Category != "" || patch.ThumbnailURL != "" || patch.TagsSet {
|
||||
if patch.Category != "" || patch.ThumbnailURL != "" {
|
||||
_ = s.Catalog.UpdateVideoMeta(ctx, id, patch)
|
||||
}
|
||||
if !sameTags(existing.Tags, tags) {
|
||||
_ = s.Catalog.SetAutoVideoTags(ctx, id, tags)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if dup := s.findDuplicateByHash(ctx, e.Hash, id); dup != nil {
|
||||
s.backfillDuplicateThumbnail(ctx, dup, e.ThumbnailURL)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -117,10 +136,11 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, depth int, st
|
||||
ID: id,
|
||||
DriveID: s.Drive.ID(),
|
||||
FileID: e.ID,
|
||||
ContentHash: e.Hash,
|
||||
ParentID: e.ParentID,
|
||||
Title: parsed.Title,
|
||||
Author: parsed.Author,
|
||||
Tags: parsed.Tags,
|
||||
Tags: tags,
|
||||
Ext: strings.TrimPrefix(ext, "."),
|
||||
Quality: "HD",
|
||||
Size: e.Size,
|
||||
@@ -143,6 +163,24 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, depth int, st
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Scanner) findDuplicateByHash(ctx context.Context, hash, currentID string) *catalog.Video {
|
||||
if hash == "" {
|
||||
return nil
|
||||
}
|
||||
dup, err := s.Catalog.FindVideoByContentHash(ctx, hash)
|
||||
if err != nil || dup == nil || dup.ID == currentID {
|
||||
return nil
|
||||
}
|
||||
return dup
|
||||
}
|
||||
|
||||
func (s *Scanner) backfillDuplicateThumbnail(ctx context.Context, canonical *catalog.Video, thumbnailURL string) {
|
||||
if canonical.ThumbnailURL != "" || thumbnailURL == "" {
|
||||
return
|
||||
}
|
||||
_ = s.Catalog.UpdateVideoMeta(ctx, canonical.ID, catalog.VideoMetaPatch{ThumbnailURL: thumbnailURL})
|
||||
}
|
||||
|
||||
func orDefault(t time.Time, d time.Time) time.Time {
|
||||
if t.IsZero() {
|
||||
return d
|
||||
@@ -161,3 +199,18 @@ func sameTags(a, b []string) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func mergeTags(lists ...[]string) []string {
|
||||
out := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, list := range lists {
|
||||
for _, tag := range list {
|
||||
if tag == "" || seen[tag] {
|
||||
continue
|
||||
}
|
||||
seen[tag] = true
|
||||
out = append(out, tag)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -157,6 +157,181 @@ func TestRunReplacesExistingVideoTagsWithFixedFilenameTags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAddsShortCollectionDirectoryAsTag(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 _, id := range []string{"existing-1", "existing-2"} {
|
||||
if err := cat.UpsertVideo(ctx, &catalog.Video{
|
||||
ID: id,
|
||||
DriveID: "drive",
|
||||
FileID: id,
|
||||
Title: "Existing",
|
||||
Category: "sunny",
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed existing sunny video: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
drv := &scannerTreeFakeDrive{
|
||||
entries: map[string][]drives.Entry{
|
||||
"root": {{
|
||||
ID: "dir-1",
|
||||
Name: "sunny",
|
||||
IsDir: true,
|
||||
}},
|
||||
"dir-1": {{
|
||||
ID: "file-1",
|
||||
ParentID: "dir-1",
|
||||
Name: "clip.mp4",
|
||||
Size: 123,
|
||||
ModTime: now,
|
||||
}},
|
||||
},
|
||||
}
|
||||
sc := New(cat, drv, []string{".mp4"}, 5, nil)
|
||||
|
||||
if _, err := sc.Run(ctx, ""); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
|
||||
got, err := cat.GetVideo(ctx, "fake-drive-file-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get video: %v", err)
|
||||
}
|
||||
if !sameStrings(got.Tags, []string{"sunny"}) {
|
||||
t.Fatalf("tags = %#v, want sunny", got.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMapsAVCodeDirectoryToAVTag(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 _, id := range []string{"existing-1", "existing-2"} {
|
||||
if err := cat.UpsertVideo(ctx, &catalog.Video{
|
||||
ID: id,
|
||||
DriveID: "drive",
|
||||
FileID: id,
|
||||
Title: "Existing",
|
||||
Category: "cc-1750027",
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed existing AV code video: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
drv := &scannerTreeFakeDrive{
|
||||
entries: map[string][]drives.Entry{
|
||||
"root": {{
|
||||
ID: "dir-1",
|
||||
Name: "cc-1750027",
|
||||
IsDir: true,
|
||||
}},
|
||||
"dir-1": {{
|
||||
ID: "file-1",
|
||||
ParentID: "dir-1",
|
||||
Name: "clip.mp4",
|
||||
Size: 123,
|
||||
ModTime: now,
|
||||
}},
|
||||
},
|
||||
}
|
||||
sc := New(cat, drv, []string{".mp4"}, 5, nil)
|
||||
|
||||
if _, err := sc.Run(ctx, ""); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
|
||||
got, err := cat.GetVideo(ctx, "fake-drive-file-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get video: %v", err)
|
||||
}
|
||||
if !sameStrings(got.Tags, []string{"AV"}) {
|
||||
t.Fatalf("tags = %#v, want AV", got.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkipsDuplicateFileHashes(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()
|
||||
drv := &scannerFakeDrive{
|
||||
entries: []drives.Entry{
|
||||
{
|
||||
ID: "file-1",
|
||||
Name: "first.mp4",
|
||||
Size: 123,
|
||||
Hash: "hash-same",
|
||||
ModTime: now,
|
||||
},
|
||||
{
|
||||
ID: "file-2",
|
||||
Name: "second.mp4",
|
||||
Size: 123,
|
||||
Hash: "hash-same",
|
||||
ModTime: now,
|
||||
},
|
||||
},
|
||||
}
|
||||
addedIDs := []string{}
|
||||
sc := New(cat, drv, []string{".mp4"}, 5, func(v *catalog.Video) {
|
||||
addedIDs = append(addedIDs, v.ID)
|
||||
})
|
||||
|
||||
stats, err := sc.Run(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
if stats.Added != 1 {
|
||||
t.Fatalf("added = %d, want 1", stats.Added)
|
||||
}
|
||||
if len(addedIDs) != 1 || addedIDs[0] != "fake-drive-file-1" {
|
||||
t.Fatalf("on new ids = %#v, want first file only", addedIDs)
|
||||
}
|
||||
|
||||
items, total, err := cat.ListVideos(ctx, catalog.ListParams{Page: 1, PageSize: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("list videos: %v", err)
|
||||
}
|
||||
if total != 1 || len(items) != 1 {
|
||||
t.Fatalf("visible videos total=%d len=%d, want 1", total, len(items))
|
||||
}
|
||||
if items[0].FileID != "file-1" {
|
||||
t.Fatalf("visible file id = %q, want file-1", items[0].FileID)
|
||||
}
|
||||
}
|
||||
|
||||
type scannerFakeDrive struct {
|
||||
entries []drives.Entry
|
||||
}
|
||||
@@ -182,3 +357,29 @@ func (d *scannerFakeDrive) EnsureDir(context.Context, string) (string, error) {
|
||||
return "", drives.ErrNotSupported
|
||||
}
|
||||
func (d *scannerFakeDrive) RootID() string { return "root" }
|
||||
|
||||
type scannerTreeFakeDrive struct {
|
||||
entries map[string][]drives.Entry
|
||||
}
|
||||
|
||||
func (d *scannerTreeFakeDrive) Kind() string { return "fake" }
|
||||
func (d *scannerTreeFakeDrive) ID() string { return "drive" }
|
||||
func (d *scannerTreeFakeDrive) Init(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
func (d *scannerTreeFakeDrive) List(_ context.Context, parentID string) ([]drives.Entry, error) {
|
||||
return d.entries[parentID], nil
|
||||
}
|
||||
func (d *scannerTreeFakeDrive) Stat(context.Context, string) (*drives.Entry, error) {
|
||||
return nil, drives.ErrNotSupported
|
||||
}
|
||||
func (d *scannerTreeFakeDrive) StreamURL(context.Context, string) (*drives.StreamLink, error) {
|
||||
return &drives.StreamLink{URL: "https://video.example/clip.mp4"}, nil
|
||||
}
|
||||
func (d *scannerTreeFakeDrive) Upload(context.Context, string, string, io.Reader, int64) (string, error) {
|
||||
return "", drives.ErrNotSupported
|
||||
}
|
||||
func (d *scannerTreeFakeDrive) EnsureDir(context.Context, string) (string, error) {
|
||||
return "", drives.ErrNotSupported
|
||||
}
|
||||
func (d *scannerTreeFakeDrive) RootID() string { return "root" }
|
||||
|
||||
@@ -7,6 +7,7 @@ import { LoginPage } from "@/admin/LoginPage";
|
||||
import { RequireAuth } from "@/admin/RequireAuth";
|
||||
import { DrivesPage } from "@/admin/DrivesPage";
|
||||
import { VideosPage } from "@/admin/VideosPage";
|
||||
import { TagsPage } from "@/admin/TagsPage";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -51,6 +52,7 @@ export default function App() {
|
||||
<Route index element={<Navigate to="/admin/drives" replace />} />
|
||||
<Route path="drives" element={<DrivesPage />} />
|
||||
<Route path="videos" element={<VideosPage />} />
|
||||
<Route path="tags" element={<TagsPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import { HardDrive, Film, LogOut, Play, Home } from "lucide-react";
|
||||
import { HardDrive, Film, LogOut, Play, Home, Tags } from "lucide-react";
|
||||
import { useAuth } from "./AuthContext";
|
||||
import { useToast } from "./ToastContext";
|
||||
import { PreviewToggle } from "./PreviewToggle";
|
||||
@@ -48,6 +48,14 @@ export function AdminLayout() {
|
||||
>
|
||||
<Film size={16} /> 视频管理
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/admin/tags"
|
||||
className={({ isActive }) =>
|
||||
`admin-nav__link ${isActive ? "is-active" : ""}`
|
||||
}
|
||||
>
|
||||
<Tags size={16} /> 标签管理
|
||||
</NavLink>
|
||||
</nav>
|
||||
<div className="admin-sidebar__footer">
|
||||
<PreviewToggle />
|
||||
|
||||
@@ -9,6 +9,7 @@ const kindLabel: Record<string, string> = {
|
||||
p115: "115 网盘",
|
||||
pikpak: "PikPak",
|
||||
wopan: "联通沃盘",
|
||||
onedrive: "OneDrive",
|
||||
};
|
||||
|
||||
type Kind = api.AdminDrive["kind"];
|
||||
@@ -135,7 +136,7 @@ export function DrivesPage() {
|
||||
<div className="admin-empty">加载中...</div>
|
||||
) : list.length === 0 ? (
|
||||
<div className="admin-card admin-empty">
|
||||
还没有配置任何网盘。点击右上角「新建」,选择夸克 / 115 / PikPak / 沃盘,填入凭证即可。
|
||||
还没有配置任何网盘。点击右上角「新建」,选择夸克 / 115 / PikPak / 沃盘 / OneDrive,填入凭证即可。
|
||||
</div>
|
||||
) : (
|
||||
<table className="admin-table">
|
||||
@@ -283,6 +284,7 @@ function DriveForm({
|
||||
<option value="p115">115 网盘</option>
|
||||
<option value="pikpak">PikPak</option>
|
||||
<option value="wopan">联通沃盘</option>
|
||||
<option value="onedrive">OneDrive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
@@ -290,7 +292,7 @@ function DriveForm({
|
||||
<input
|
||||
value={form.rootId}
|
||||
onChange={(e) => set("rootId", e.target.value)}
|
||||
placeholder={form.kind === "pikpak" ? "留空表示根目录" : "0"}
|
||||
placeholder={form.kind === "pikpak" ? "留空表示根目录" : form.kind === "onedrive" ? "root" : "0"}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
@@ -345,6 +347,8 @@ function credentialHelp(kind: Kind, isEdit: boolean): string {
|
||||
return `参考 OpenList 的 PikPak 登录方式。可填用户名和密码首次登录,也可填 refresh_token;如返回验证码链接,打开验证后把 captcha_token 粘贴回来。${note}`;
|
||||
case "wopan":
|
||||
return `需要 access_token 和 refresh_token。后续会加扫码/短信登录入口,第一版只能手工粘贴。${note}`;
|
||||
case "onedrive":
|
||||
return `按 OpenList 默认方式,通过 api.oplist.org 在线刷新 token。只需要 refresh_token;保存后会自动回写新的 access_token / refresh_token。${note}`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
@@ -441,9 +445,50 @@ function credentialFields(kind: Kind): Array<{
|
||||
placeholder: "留空走个人空间",
|
||||
},
|
||||
];
|
||||
case "onedrive":
|
||||
return [
|
||||
{
|
||||
key: "refresh_token",
|
||||
label: "refresh_token",
|
||||
placeholder: "OpenList OneDrive refresh_token",
|
||||
multiline: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "access_token",
|
||||
label: "access_token(可选)",
|
||||
placeholder: "留空也可以,保存时会在线刷新",
|
||||
multiline: true,
|
||||
},
|
||||
{
|
||||
key: "api_url_address",
|
||||
label: "api_url_address(可选)",
|
||||
placeholder: "https://api.oplist.org/onedrive/renewapi",
|
||||
help: "默认使用 OpenList 的在线刷新 API;除非你有自建兼容服务,否则留空。",
|
||||
},
|
||||
{
|
||||
key: "region",
|
||||
label: "region(可选)",
|
||||
placeholder: "global(可选:global / cn / us / de)",
|
||||
help: "默认 global;世纪互联填 cn,美国政府云填 us,德国云填 de。",
|
||||
},
|
||||
{
|
||||
key: "is_sharepoint",
|
||||
label: "is_sharepoint(可选)",
|
||||
placeholder: "false",
|
||||
help: "普通 OneDrive 留空或 false;SharePoint 文档库填 true,并填写 site_id。",
|
||||
},
|
||||
{
|
||||
key: "site_id",
|
||||
label: "site_id(SharePoint 必填)",
|
||||
placeholder: "SharePoint site id",
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function defaultRootId(kind: Kind): string {
|
||||
return kind === "pikpak" ? "" : "0";
|
||||
if (kind === "pikpak") return "";
|
||||
if (kind === "onedrive") return "root";
|
||||
return "0";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, RefreshCw, Tags } from "lucide-react";
|
||||
import * as api from "./api";
|
||||
import { useToast } from "./ToastContext";
|
||||
|
||||
export function TagsPage() {
|
||||
const [tags, setTags] = useState<api.AdminTag[]>([]);
|
||||
const [label, setLabel] = useState("");
|
||||
const [aliases, setAliases] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const { show } = useToast();
|
||||
|
||||
async function refresh() {
|
||||
setLoading(true);
|
||||
try {
|
||||
setTags(await api.listTags());
|
||||
} catch (e) {
|
||||
show(e instanceof Error ? e.message : "加载标签失败", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
async function handleCreate() {
|
||||
const cleanLabel = label.trim();
|
||||
if (!cleanLabel) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const r = await api.createTag(cleanLabel, splitList(aliases));
|
||||
show(`已添加标签,自动归类 ${r.classified} 个视频`, "success");
|
||||
setLabel("");
|
||||
setAliases("");
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
show(e instanceof Error ? e.message : "添加标签失败", "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<header className="admin-page__header">
|
||||
<h1 className="admin-page__title">标签管理</h1>
|
||||
<button className="admin-btn" onClick={refresh}>
|
||||
<RefreshCw size={13} /> 刷新
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="admin-card">
|
||||
<div className="admin-card__title">
|
||||
<Tags size={15} /> 新增标签
|
||||
</div>
|
||||
<div className="admin-form">
|
||||
<div className="admin-form__row">
|
||||
<label>标签名</label>
|
||||
<input
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="例如:清纯"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>别名</label>
|
||||
<input
|
||||
value={aliases}
|
||||
onChange={(e) => setAliases(e.target.value)}
|
||||
placeholder="逗号分隔,例如:纯欲, 清新, 乖巧"
|
||||
/>
|
||||
<div className="admin-form__help">
|
||||
新增后会按标签名和别名匹配已有视频的标题、作者和目录。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="admin-btn is-primary"
|
||||
onClick={handleCreate}
|
||||
disabled={saving || !label.trim()}
|
||||
>
|
||||
<Plus size={13} /> {saving ? "添加中..." : "添加并归类"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="admin-empty">加载中...</div>
|
||||
) : (
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>标签</th>
|
||||
<th>视频数</th>
|
||||
<th>来源</th>
|
||||
<th>别名</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tags.map((tag) => (
|
||||
<tr key={tag.id}>
|
||||
<td>
|
||||
<span className="admin-pill">{tag.label}</span>
|
||||
</td>
|
||||
<td>{tag.count}</td>
|
||||
<td>{sourceLabel(tag.source)}</td>
|
||||
<td>
|
||||
{(tag.aliases ?? []).length > 0 ? (
|
||||
<div className="admin-pills">
|
||||
{(tag.aliases ?? []).map((alias) => (
|
||||
<span key={alias} className="admin-pill">
|
||||
{alias}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ color: "#aaa" }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function splitList(s: string): string[] {
|
||||
return s
|
||||
.split(/[,,、\s]+/)
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function sourceLabel(source: string): string {
|
||||
if (source === "system") return "系统";
|
||||
if (source === "collection") return "合集";
|
||||
if (source === "legacy") return "旧数据";
|
||||
return "用户";
|
||||
}
|
||||
+245
-63
@@ -4,18 +4,32 @@ import * as api from "./api";
|
||||
import { useToast } from "./ToastContext";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
export function VideosPage() {
|
||||
const [list, setList] = useState<api.AdminVideo[]>([]);
|
||||
const [drives, setDrives] = useState<api.AdminDrive[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [driveId, setDriveId] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [editing, setEditing] = useState<api.AdminVideo | null>(null);
|
||||
const [availableTags, setAvailableTags] = useState<api.AdminTag[]>([]);
|
||||
const { show } = useToast();
|
||||
|
||||
async function refresh() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await api.listVideos();
|
||||
const [r, tagList, driveList] = await Promise.all([
|
||||
api.listVideos({ driveId, page, size: PAGE_SIZE }),
|
||||
api.listTags(),
|
||||
api.listDrives(),
|
||||
]);
|
||||
setList(r.items ?? []);
|
||||
setTotal(r.total ?? 0);
|
||||
setAvailableTags(tagList);
|
||||
setDrives(driveList ?? []);
|
||||
} catch (e) {
|
||||
show(e instanceof Error ? e.message : "加载失败", "error");
|
||||
} finally {
|
||||
@@ -25,18 +39,24 @@ export function VideosPage() {
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
}, [driveId, page]);
|
||||
|
||||
const driveNameMap = new Map(
|
||||
drives.map((d) => [d.id, d.name || d.id])
|
||||
);
|
||||
|
||||
const filtered = keyword.trim()
|
||||
? list.filter((v) => {
|
||||
const k = keyword.toLowerCase();
|
||||
return (
|
||||
v.title.toLowerCase().includes(k) ||
|
||||
(v.author ?? "").toLowerCase().includes(k) ||
|
||||
v.id.toLowerCase().includes(k)
|
||||
(v.author ?? "").toLowerCase().includes(k)
|
||||
);
|
||||
})
|
||||
: list;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const pageStart = total === 0 ? 0 : (page - 1) * PAGE_SIZE + 1;
|
||||
const pageEnd = Math.min(total, page * PAGE_SIZE);
|
||||
|
||||
async function handleRegen(v: api.AdminVideo) {
|
||||
try {
|
||||
@@ -52,6 +72,27 @@ export function VideosPage() {
|
||||
<header className="admin-page__header">
|
||||
<h1 className="admin-page__title">视频管理</h1>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<select
|
||||
value={driveId}
|
||||
onChange={(e) => {
|
||||
setDriveId(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
style={{
|
||||
border: "1px solid var(--color-line)",
|
||||
borderRadius: 3,
|
||||
minWidth: 180,
|
||||
padding: "8px 10px",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
@@ -70,7 +111,7 @@ export function VideosPage() {
|
||||
<input
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="搜索标题 / 作者 / ID"
|
||||
placeholder="搜索标题 / 作者"
|
||||
style={{
|
||||
padding: "8px 10px 8px 30px",
|
||||
border: "1px solid var(--color-line)",
|
||||
@@ -85,68 +126,155 @@ export function VideosPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{drives.length > 0 && (
|
||||
<div className="admin-drive-teasers" aria-label="网盘 Teaser 统计">
|
||||
{drives.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
type="button"
|
||||
className={`admin-drive-teaser${
|
||||
driveId === d.id ? " is-active" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
setDriveId(d.id);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<span className="admin-drive-teaser__name">{d.name || d.id}</span>
|
||||
<span className="admin-drive-teaser__metric is-ready">
|
||||
已生成 {d.teaserReadyCount ?? 0}
|
||||
</span>
|
||||
<span className="admin-drive-teaser__metric is-pending">
|
||||
待生成 {d.teaserPendingCount ?? 0}
|
||||
</span>
|
||||
{(d.teaserFailedCount ?? 0) > 0 && (
|
||||
<span className="admin-drive-teaser__metric is-failed">
|
||||
失败 {d.teaserFailedCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<div className="admin-form__help" style={{ margin: "-10px 0 14px" }}>
|
||||
{driveId
|
||||
? `${driveNameMap.get(driveId) ?? driveId}:共 ${total} 个视频,第 ${page} / ${totalPages} 页,显示 ${pageStart}-${pageEnd}`
|
||||
: `全部网盘:共 ${total} 个视频,第 ${page} / ${totalPages} 页,显示 ${pageStart}-${pageEnd}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="admin-empty">加载中...</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="admin-card admin-empty">
|
||||
还没有视频。先在「网盘管理」里配置好盘并触发扫描。
|
||||
{driveId
|
||||
? "这个网盘下还没有可显示的视频。可以在「网盘管理」里触发重扫。"
|
||||
: "还没有视频。先在「网盘管理」里配置好盘并触发扫描。"}
|
||||
</div>
|
||||
) : (
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>标题</th>
|
||||
<th>作者</th>
|
||||
<th>标签</th>
|
||||
<th>时长</th>
|
||||
<th>Teaser</th>
|
||||
<th>来源</th>
|
||||
<th className="is-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((v) => (
|
||||
<tr key={v.id}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600 }}>{v.title}</div>
|
||||
<div style={{ color: "#999", fontSize: 12, fontFamily: "ui-monospace" }}>
|
||||
{v.id}
|
||||
</div>
|
||||
</td>
|
||||
<td>{v.author || <span style={{ color: "#aaa" }}>—</span>}</td>
|
||||
<td>
|
||||
<div className="admin-pills">
|
||||
{(v.tags ?? []).map((t) => (
|
||||
<span key={t} className="admin-pill">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatDur(v.durationSeconds)}</td>
|
||||
<td>
|
||||
<PreviewStatus s={v.previewStatus} />
|
||||
</td>
|
||||
<td style={{ fontFamily: "ui-monospace", fontSize: 12 }}>
|
||||
{v.driveId}
|
||||
</td>
|
||||
<td className="is-actions">
|
||||
<button className="admin-btn" onClick={() => setEditing(v)}>
|
||||
<Edit size={13} /> 编辑
|
||||
</button>{" "}
|
||||
<button className="admin-btn" onClick={() => handleRegen(v)}>
|
||||
<RefreshCw size={13} /> 重生 teaser
|
||||
</button>
|
||||
</td>
|
||||
<>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>标题</th>
|
||||
<th>作者</th>
|
||||
<th>标签</th>
|
||||
<th>时长</th>
|
||||
<th>Teaser</th>
|
||||
<th>来源</th>
|
||||
<th className="is-actions">操作</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((v) => (
|
||||
<tr key={v.id}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600 }}>{v.title}</div>
|
||||
{fileMeta(v) && (
|
||||
<div style={{ color: "#999", fontSize: 12 }}>
|
||||
{fileMeta(v)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td>{v.author || <span style={{ color: "#aaa" }}>—</span>}</td>
|
||||
<td>
|
||||
<div className="admin-pills">
|
||||
{(v.tags ?? []).map((t) => (
|
||||
<span key={t} className="admin-pill">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatDur(v.durationSeconds)}</td>
|
||||
<td>
|
||||
<PreviewStatus s={v.previewStatus} />
|
||||
</td>
|
||||
<td style={{ fontFamily: "ui-monospace", fontSize: 12 }}>
|
||||
{driveNameMap.get(v.driveId) ?? v.driveId}
|
||||
</td>
|
||||
<td className="is-actions">
|
||||
<button className="admin-btn" onClick={() => setEditing(v)}>
|
||||
<Edit size={13} /> 编辑
|
||||
</button>{" "}
|
||||
<button className="admin-btn" onClick={() => handleRegen(v)}>
|
||||
<RefreshCw size={13} /> 重生 teaser
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="admin-btn"
|
||||
onClick={() => setPage(1)}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
首页
|
||||
</button>
|
||||
<button
|
||||
className="admin-btn"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="admin-form__help" style={{ margin: 0 }}>
|
||||
第 {page} / {totalPages} 页,每页 {PAGE_SIZE} 个
|
||||
</span>
|
||||
<button
|
||||
className="admin-btn"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
<button
|
||||
className="admin-btn"
|
||||
onClick={() => setPage(totalPages)}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
末页
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<EditVideoModal
|
||||
video={editing}
|
||||
availableTags={availableTags}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
setEditing(null);
|
||||
@@ -173,16 +301,18 @@ function formatDur(sec: number): string {
|
||||
|
||||
function EditVideoModal({
|
||||
video,
|
||||
availableTags,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
video: api.AdminVideo;
|
||||
availableTags: api.AdminTag[];
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [title, setTitle] = useState(video.title);
|
||||
const [author, setAuthor] = useState(video.author ?? "");
|
||||
const [tags, setTags] = useState((video.tags ?? []).join(", "));
|
||||
const [selectedTags, setSelectedTags] = useState(video.tags ?? []);
|
||||
const [category, setCategory] = useState(video.category ?? "");
|
||||
const [badges, setBadges] = useState((video.badges ?? []).join(", "));
|
||||
const [description, setDescription] = useState(video.description ?? "");
|
||||
@@ -198,7 +328,7 @@ function EditVideoModal({
|
||||
await api.updateVideo(video.id, {
|
||||
title: title.trim(),
|
||||
author: author.trim(),
|
||||
tags: splitList(tags),
|
||||
tags: selectedTags,
|
||||
category: category.trim(),
|
||||
badges: splitList(badges),
|
||||
description,
|
||||
@@ -241,8 +371,20 @@ function EditVideoModal({
|
||||
<input value={author} onChange={(e) => setAuthor(e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>标签(逗号分隔)</label>
|
||||
<input value={tags} onChange={(e) => setTags(e.target.value)} />
|
||||
<label>标签</label>
|
||||
<div className="admin-tag-picker">
|
||||
{availableTags.map((tag) => (
|
||||
<label key={tag.id} className="admin-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTags.includes(tag.label)}
|
||||
onChange={() => setSelectedTags(toggleTag(selectedTags, tag.label))}
|
||||
/>
|
||||
<span>{tag.label}</span>
|
||||
<em>{tag.count}</em>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>分类</label>
|
||||
@@ -280,25 +422,65 @@ function EditVideoModal({
|
||||
/>
|
||||
</div>
|
||||
<dl className="admin-kv" style={{ marginTop: 8 }}>
|
||||
<dt>视频 ID</dt>
|
||||
<dd style={{ fontFamily: "ui-monospace", fontSize: 12 }}>{video.id}</dd>
|
||||
<dt>来源盘</dt>
|
||||
<dd>{video.driveId}</dd>
|
||||
<dt>文件 ID</dt>
|
||||
<dd style={{ fontFamily: "ui-monospace", fontSize: 12 }}>{video.fileId}</dd>
|
||||
<dt>文件信息</dt>
|
||||
<dd>{fileMeta(video) || "—"}</dd>
|
||||
<dt>Teaser</dt>
|
||||
<dd>
|
||||
<PreviewStatus s={video.previewStatus} />
|
||||
</dd>
|
||||
</dl>
|
||||
<details className="admin-form__help" style={{ marginTop: 8 }}>
|
||||
<summary>技术信息(排查用)</summary>
|
||||
<dl className="admin-kv" style={{ marginTop: 8 }}>
|
||||
<dt>内部视频 ID</dt>
|
||||
<dd style={{ fontFamily: "ui-monospace", fontSize: 12 }}>{video.id}</dd>
|
||||
<dt>网盘文件 ID</dt>
|
||||
<dd style={{ fontFamily: "ui-monospace", fontSize: 12 }}>{video.fileId}</dd>
|
||||
</dl>
|
||||
</details>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function fileMeta(v: api.AdminVideo): string {
|
||||
const parts = [
|
||||
normalizeExt(v.ext),
|
||||
v.quality,
|
||||
formatBytes(v.size),
|
||||
].filter(Boolean);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function normalizeExt(ext: string): string {
|
||||
const value = (ext ?? "").replace(/^\./, "").trim();
|
||||
return value ? value.toUpperCase() : "";
|
||||
}
|
||||
|
||||
function formatBytes(size: number): string {
|
||||
if (!size || size <= 0) return "";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let value = size;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
const digits = unit === 0 || value >= 100 ? 0 : 1;
|
||||
return `${value.toFixed(digits)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
function splitList(s: string): string[] {
|
||||
return s
|
||||
.split(/[,,、\s]+/)
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function toggleTag(tags: string[], label: string): string[] {
|
||||
return tags.includes(label)
|
||||
? tags.filter((tag) => tag !== label)
|
||||
: [...tags, label];
|
||||
}
|
||||
|
||||
+40
-4
@@ -54,13 +54,16 @@ export function me() {
|
||||
|
||||
export type AdminDrive = {
|
||||
id: string;
|
||||
kind: "quark" | "p115" | "pikpak" | "wopan";
|
||||
kind: "quark" | "p115" | "pikpak" | "wopan" | "onedrive";
|
||||
name: string;
|
||||
rootId: string;
|
||||
scanRootId: string;
|
||||
status: string;
|
||||
lastError?: string;
|
||||
hasCredential: boolean;
|
||||
teaserReadyCount: number;
|
||||
teaserPendingCount: number;
|
||||
teaserFailedCount: number;
|
||||
};
|
||||
|
||||
export function listDrives() {
|
||||
@@ -69,7 +72,7 @@ export function listDrives() {
|
||||
|
||||
export type UpsertDriveInput = {
|
||||
id: string;
|
||||
kind: "quark" | "p115" | "pikpak" | "wopan";
|
||||
kind: "quark" | "p115" | "pikpak" | "wopan" | "onedrive";
|
||||
name: string;
|
||||
rootId: string;
|
||||
scanRootId: string;
|
||||
@@ -122,8 +125,20 @@ export type AdminVideo = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export function listVideos() {
|
||||
return request<{ items: AdminVideo[]; total: number }>("/videos");
|
||||
export type AdminVideoList = {
|
||||
items: AdminVideo[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export function listVideos(params: { driveId?: string; page?: number; size?: number } = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.driveId) qs.set("driveId", params.driveId);
|
||||
if (params.page) qs.set("page", String(params.page));
|
||||
if (params.size) qs.set("size", String(params.size));
|
||||
const suffix = qs.toString() ? `?${qs.toString()}` : "";
|
||||
return request<AdminVideoList>(`/videos${suffix}`);
|
||||
}
|
||||
|
||||
export type UpdateVideoInput = Partial<{
|
||||
@@ -152,6 +167,27 @@ export function regenPreview(id: string) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Tags ----------
|
||||
|
||||
export type AdminTag = {
|
||||
id: number;
|
||||
label: string;
|
||||
aliases?: string[];
|
||||
source: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export function listTags() {
|
||||
return request<AdminTag[]>("/tags");
|
||||
}
|
||||
|
||||
export function createTag(label: string, aliases: string[]) {
|
||||
return request<{ label: string; classified: number }>("/tags", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ label, aliases }),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Settings ----------
|
||||
|
||||
export type Settings = {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Bookmark,
|
||||
Download,
|
||||
Flag,
|
||||
EyeOff,
|
||||
MessageSquare,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
@@ -13,9 +14,16 @@ import { formatCount } from "@/lib/format";
|
||||
type Props = {
|
||||
video: VideoDetail;
|
||||
onJumpToComments: () => void;
|
||||
onHideVideo: () => void;
|
||||
hideSaving?: boolean;
|
||||
};
|
||||
|
||||
export function VideoActions({ video, onJumpToComments }: Props) {
|
||||
export function VideoActions({
|
||||
video,
|
||||
onJumpToComments,
|
||||
onHideVideo,
|
||||
hideSaving,
|
||||
}: Props) {
|
||||
const [likes, setLikes] = useState(video.likes ?? 0);
|
||||
const [dislikes, setDislikes] = useState(video.dislikes ?? 0);
|
||||
const [bursting, setBursting] = useState(false);
|
||||
@@ -112,6 +120,14 @@ export function VideoActions({ video, onJumpToComments }: Props) {
|
||||
<Flag size={14} />
|
||||
举报
|
||||
</button>
|
||||
<button
|
||||
className="video-actions__btn is-danger"
|
||||
onClick={onHideVideo}
|
||||
disabled={hideSaving}
|
||||
>
|
||||
<EyeOff size={14} />
|
||||
{hideSaving ? "隐藏中" : "不再展示"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,10 @@ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { PreviewState, VideoItem } from "@/types";
|
||||
import { previewController } from "@/lib/previewController";
|
||||
import {
|
||||
shouldInterceptPreviewTap,
|
||||
shouldStartInstantPreview,
|
||||
} from "@/lib/previewIntent";
|
||||
import { useInViewport } from "@/lib/useInViewport";
|
||||
import { formatCount } from "@/lib/format";
|
||||
import { PreviewVideo } from "./PreviewVideo";
|
||||
@@ -29,6 +33,8 @@ export function VideoCard({ video }: Props) {
|
||||
const rootRef = useRef<HTMLElement | null>(null);
|
||||
const hoverTimerRef = useRef<number | null>(null);
|
||||
const thumbnailRetryTimerRef = useRef<number | null>(null);
|
||||
const lastPointerTypeRef = useRef<string>("");
|
||||
const canHoverRef = useRef(true);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
|
||||
const activeId = useActivePreviewId();
|
||||
@@ -61,6 +67,16 @@ export function VideoCard({ video }: Props) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia("(hover: hover) and (pointer: fine)");
|
||||
const update = () => {
|
||||
canHoverRef.current = media.matches;
|
||||
};
|
||||
update();
|
||||
media.addEventListener("change", update);
|
||||
return () => media.removeEventListener("change", update);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setThumbnailRetry(0);
|
||||
if (thumbnailRetryTimerRef.current) {
|
||||
@@ -112,34 +128,77 @@ export function VideoCard({ video }: Props) {
|
||||
|
||||
function startPreviewIntent() {
|
||||
if (!inView) return;
|
||||
if (hoverTimerRef.current) return;
|
||||
setPreviewState("intent");
|
||||
|
||||
hoverTimerRef.current = window.setTimeout(() => {
|
||||
// 抢占全局播放锁
|
||||
previewController.setActiveId(video.id);
|
||||
setShouldRenderPreview(true);
|
||||
setPreviewState("loading");
|
||||
hoverTimerRef.current = null;
|
||||
startPreviewNow({ requireInView: true });
|
||||
}, HOVER_DELAY_MS);
|
||||
}
|
||||
|
||||
function startPreviewNow(options: { requireInView: boolean }) {
|
||||
if (options.requireInView && !inView) return;
|
||||
if (hoverTimerRef.current) {
|
||||
window.clearTimeout(hoverTimerRef.current);
|
||||
hoverTimerRef.current = null;
|
||||
}
|
||||
previewController.setActiveId(video.id);
|
||||
setShouldRenderPreview(true);
|
||||
setPreviewState("loading");
|
||||
}
|
||||
|
||||
function stopPreview() {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
// 移动端:首次点击卡片触发预览,浮层播放按钮跳转详情
|
||||
// 为了让 Link 正常跳转,我们不拦截移动端点击,移动端表现为直接跳转详情
|
||||
// 如需长按预览,后续可在此扩展
|
||||
function handlePointerEnter(event: React.PointerEvent<HTMLElement>) {
|
||||
lastPointerTypeRef.current = event.pointerType;
|
||||
if (shouldStartInstantPreview({ pointerType: event.pointerType })) return;
|
||||
startPreviewIntent();
|
||||
}
|
||||
|
||||
function handlePointerLeave(event: React.PointerEvent<HTMLElement>) {
|
||||
if (shouldStartInstantPreview({ pointerType: event.pointerType })) return;
|
||||
stopPreview();
|
||||
}
|
||||
|
||||
function handlePointerDown(event: React.PointerEvent<HTMLElement>) {
|
||||
lastPointerTypeRef.current = event.pointerType;
|
||||
}
|
||||
|
||||
function handleClickCapture(event: React.MouseEvent<HTMLAnchorElement>) {
|
||||
const previewActive = activeId === video.id && shouldRenderPreview;
|
||||
if (
|
||||
!shouldInterceptPreviewTap({
|
||||
pointerType: lastPointerTypeRef.current,
|
||||
canHover: canHoverRef.current,
|
||||
previewActive,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
startPreviewNow({ requireInView: false });
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
ref={rootRef as React.RefObject<HTMLElement>}
|
||||
className="video-card"
|
||||
onPointerEnter={startPreviewIntent}
|
||||
onPointerLeave={stopPreview}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
onPointerDown={handlePointerDown}
|
||||
onFocus={startPreviewIntent}
|
||||
onBlur={stopPreview}
|
||||
>
|
||||
<Link to={video.href} className="video-card__link" tabIndex={0}>
|
||||
<Link
|
||||
to={video.href}
|
||||
className="video-card__link"
|
||||
tabIndex={0}
|
||||
onClickCapture={handleClickCapture}
|
||||
>
|
||||
<div className="thumb-frame">
|
||||
<img
|
||||
className="thumb-image"
|
||||
|
||||
@@ -1,33 +1,38 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Copy } from "lucide-react";
|
||||
import type { VideoDetail } from "@/types";
|
||||
import { formatCount } from "@/lib/format";
|
||||
import { useState } from "react";
|
||||
import type { TagItem, VideoDetail } from "@/types";
|
||||
|
||||
type Props = {
|
||||
video: VideoDetail;
|
||||
availableTags?: TagItem[];
|
||||
tagSaving?: boolean;
|
||||
onTagsChange?: (tags: string[]) => Promise<void>;
|
||||
};
|
||||
|
||||
export function VideoInfoPanel({ video }: Props) {
|
||||
export function VideoInfoPanel({
|
||||
video,
|
||||
availableTags = [],
|
||||
tagSaving = false,
|
||||
onTagsChange,
|
||||
}: Props) {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [following, setFollowing] = useState(
|
||||
video.authorProfile.isFollowing ?? false
|
||||
);
|
||||
const embedRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [editingTags, setEditingTags] = useState(false);
|
||||
const [draftTags, setDraftTags] = useState<string[]>(video.tags ?? []);
|
||||
const [tagError, setTagError] = useState("");
|
||||
|
||||
async function copyEmbed() {
|
||||
const value = video.embedUrl;
|
||||
function openTagEditor() {
|
||||
setDraftTags(video.tags ?? []);
|
||||
setTagError("");
|
||||
setEditingTags(true);
|
||||
}
|
||||
|
||||
async function saveTags() {
|
||||
if (!onTagsChange) return;
|
||||
setTagError("");
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} else if (embedRef.current) {
|
||||
embedRef.current.select();
|
||||
document.execCommand("copy");
|
||||
}
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1600);
|
||||
} catch {
|
||||
// noop
|
||||
await onTagsChange(draftTags);
|
||||
setEditingTags(false);
|
||||
} catch (e) {
|
||||
setTagError(e instanceof Error ? e.message : "保存标签失败");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,48 +46,52 @@ export function VideoInfoPanel({ video }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="info-row">
|
||||
<span className="info-row__label">作者</span>
|
||||
<span className="info-row__label">来源/合集</span>
|
||||
<div className="info-row__value">
|
||||
<div className="author-card">
|
||||
<div className="author-card__avatar">
|
||||
{video.authorProfile.name.slice(0, 1)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="author-card__name">
|
||||
{video.authorProfile.name}
|
||||
</div>
|
||||
<div className="author-card__meta">
|
||||
{video.authorProfile.signupAge} ·{" "}
|
||||
{formatCount(video.authorProfile.followers)} 粉丝 ·{" "}
|
||||
{formatCount(video.authorProfile.videoCount)} 视频
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className={`author-card__follow ${
|
||||
following ? "is-following" : ""
|
||||
}`}
|
||||
onClick={() => setFollowing((v) => !v)}
|
||||
aria-pressed={following}
|
||||
>
|
||||
{following ? "已关注" : "关注"}
|
||||
</button>
|
||||
</div>
|
||||
{video.category || video.author || "未设置"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-row">
|
||||
<span className="info-row__label">标签</span>
|
||||
<span className="info-row__value">
|
||||
{(video.tags ?? []).map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="tag-chip"
|
||||
style={{ marginRight: 6, marginBottom: 4, display: "inline-block" }}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<div className="info-row__value">
|
||||
<div className="detail-tags">
|
||||
{(video.tags ?? []).map((t) => (
|
||||
<span key={t} className="tag-chip">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
{onTagsChange && (
|
||||
<button className="detail-tags__edit" onClick={openTagEditor}>
|
||||
选择标签
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{editingTags && (
|
||||
<div className="detail-tag-editor">
|
||||
<div className="detail-tag-editor__grid">
|
||||
{availableTags.map((tag) => (
|
||||
<label key={tag.id} className="detail-tag-editor__item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draftTags.includes(tag.label)}
|
||||
onChange={() => setDraftTags(toggleTag(draftTags, tag.label))}
|
||||
/>
|
||||
<span>{tag.label}</span>
|
||||
{typeof tag.count === "number" && <em>{tag.count}</em>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{tagError && <div className="detail-tag-editor__error">{tagError}</div>}
|
||||
<div className="detail-tag-editor__actions">
|
||||
<button onClick={() => setEditingTags(false)}>取消</button>
|
||||
<button onClick={saveTags} disabled={tagSaving}>
|
||||
{tagSaving ? "保存中..." : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-row">
|
||||
@@ -102,29 +111,13 @@ export function VideoInfoPanel({ video }: Props) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="info-row">
|
||||
<span className="info-row__label">嵌入代码</span>
|
||||
<span className="info-row__value">
|
||||
<div className="embed-box">
|
||||
<textarea
|
||||
ref={embedRef}
|
||||
className="embed-box__input"
|
||||
readOnly
|
||||
value={video.embedUrl}
|
||||
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
|
||||
aria-label="嵌入代码"
|
||||
/>
|
||||
<button
|
||||
className={`embed-box__copy ${copied ? "is-copied" : ""}`}
|
||||
onClick={copyEmbed}
|
||||
>
|
||||
<Copy size={14} />
|
||||
{copied ? "已复制" : "复制"}
|
||||
</button>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function toggleTag(tags: string[], label: string): string[] {
|
||||
return tags.includes(label)
|
||||
? tags.filter((tag) => tag !== label)
|
||||
: [...tags, label];
|
||||
}
|
||||
|
||||
@@ -29,6 +29,23 @@ export function fetchVideoDetail(id: string): Promise<VideoDetail | null> {
|
||||
);
|
||||
}
|
||||
|
||||
export function updateVideoTags(
|
||||
id: string,
|
||||
tags: string[]
|
||||
): Promise<VideoItem> {
|
||||
return apiJSON<VideoItem>(`/api/video/${encodeURIComponent(id)}/tags`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ tags }),
|
||||
});
|
||||
}
|
||||
|
||||
export function hideVideo(id: string): Promise<{ ok: boolean }> {
|
||||
return apiJSON<{ ok: boolean }>(
|
||||
`/api/video/${encodeURIComponent(id)}/hide`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
}
|
||||
|
||||
export type TagItem = { id: string; label: string; count?: number };
|
||||
|
||||
export function fetchTags(): Promise<TagItem[]> {
|
||||
@@ -40,3 +57,13 @@ async function apiGet<T>(path: string): Promise<T> {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiJSON<T>(path: string, init: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export type PreviewIntentInput = {
|
||||
pointerType?: string;
|
||||
canHover: boolean;
|
||||
previewActive: boolean;
|
||||
};
|
||||
|
||||
export function shouldInterceptPreviewTap(input: PreviewIntentInput): boolean {
|
||||
return isTouchLike(input.pointerType, input.canHover) && !input.previewActive;
|
||||
}
|
||||
|
||||
export function shouldStartInstantPreview(input: {
|
||||
pointerType?: string;
|
||||
}): boolean {
|
||||
return input.pointerType === "touch";
|
||||
}
|
||||
|
||||
function isTouchLike(pointerType: string | undefined, canHover: boolean): boolean {
|
||||
if (pointerType === "mouse") return false;
|
||||
if (pointerType === "touch") return true;
|
||||
return !canHover;
|
||||
}
|
||||
+133
-9
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { PromoStrip } from "@/components/PromoStrip";
|
||||
@@ -12,24 +12,46 @@ import { fetchListing } from "@/data/videos";
|
||||
import type { SortKey, VideoItem } from "@/types";
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
const LISTING_STATE_PREFIX = "video-site:list-state:";
|
||||
|
||||
type ListingState = {
|
||||
sort: SortKey;
|
||||
view: ViewMode;
|
||||
page: number;
|
||||
scrollY: number;
|
||||
};
|
||||
|
||||
export default function ListingPage() {
|
||||
const [params] = useSearchParams();
|
||||
const keyword = params.get("q") ?? "";
|
||||
const tag = params.get("tag") ?? "";
|
||||
const cat = params.get("cat") ?? "";
|
||||
const listKey = useMemo(
|
||||
() => listingStateKey({ keyword, tag, cat }),
|
||||
[keyword, tag, cat]
|
||||
);
|
||||
const initialState = useMemo(() => readListingState(listKey), [listKey]);
|
||||
const activeListKeyRef = useRef(listKey);
|
||||
const pendingScrollYRef = useRef<number | null>(
|
||||
initialState ? initialState.scrollY : null
|
||||
);
|
||||
|
||||
const [sort, setSort] = useState<SortKey>("latest");
|
||||
const [view, setView] = useState<ViewMode>("grid");
|
||||
const [page, setPage] = useState(1);
|
||||
const [sort, setSort] = useState<SortKey>(initialState?.sort ?? "latest");
|
||||
const [view, setView] = useState<ViewMode>(initialState?.view ?? "grid");
|
||||
const [page, setPage] = useState(initialState?.page ?? 1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [items, setItems] = useState<VideoItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
// 筛选条件变更时回到第一页
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [keyword, tag, cat, sort]);
|
||||
if (activeListKeyRef.current === listKey) return;
|
||||
activeListKeyRef.current = listKey;
|
||||
const saved = readListingState(listKey);
|
||||
setSort(saved?.sort ?? "latest");
|
||||
setView(saved?.view ?? "grid");
|
||||
setPage(saved?.page ?? 1);
|
||||
pendingScrollYRef.current = saved ? saved.scrollY : 0;
|
||||
}, [listKey]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = keyword
|
||||
@@ -53,6 +75,50 @@ export default function ListingPage() {
|
||||
};
|
||||
}, [keyword, tag, cat, sort, page]);
|
||||
|
||||
useEffect(() => {
|
||||
const previous = window.history.scrollRestoration;
|
||||
window.history.scrollRestoration = "manual";
|
||||
return () => {
|
||||
window.history.scrollRestoration = previous;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let frame = 0;
|
||||
const save = () => {
|
||||
writeListingState(listKey, { sort, view, page, scrollY: window.scrollY });
|
||||
};
|
||||
const saveOnScroll = () => {
|
||||
if (frame) return;
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
save();
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", saveOnScroll, { passive: true });
|
||||
window.addEventListener("pagehide", save);
|
||||
save();
|
||||
return () => {
|
||||
if (frame) window.cancelAnimationFrame(frame);
|
||||
window.removeEventListener("scroll", saveOnScroll);
|
||||
window.removeEventListener("pagehide", save);
|
||||
save();
|
||||
};
|
||||
}, [listKey, sort, view, page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
const scrollY = pendingScrollYRef.current;
|
||||
if (scrollY === null) return;
|
||||
pendingScrollYRef.current = null;
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
window.scrollTo({ top: scrollY, behavior: "auto" });
|
||||
});
|
||||
});
|
||||
}, [loading, items.length, listKey]);
|
||||
|
||||
const title = keyword
|
||||
? `搜索结果:${keyword}`
|
||||
: tag
|
||||
@@ -74,8 +140,15 @@ export default function ListingPage() {
|
||||
<SortToolbar
|
||||
sort={sort}
|
||||
view={view}
|
||||
onSortChange={setSort}
|
||||
onViewChange={setView}
|
||||
onSortChange={(nextSort) => {
|
||||
pendingScrollYRef.current = 0;
|
||||
setSort(nextSort);
|
||||
setPage(1);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
onViewChange={(nextView) => {
|
||||
setView(nextView);
|
||||
}}
|
||||
/>
|
||||
<VideoGrid
|
||||
videos={items}
|
||||
@@ -89,6 +162,7 @@ export default function ListingPage() {
|
||||
pageSize={PAGE_SIZE}
|
||||
total={total}
|
||||
onChange={(p) => {
|
||||
pendingScrollYRef.current = 0;
|
||||
setPage(p);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
@@ -97,3 +171,53 @@ export default function ListingPage() {
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function listingStateKey(filters: {
|
||||
keyword: string;
|
||||
tag: string;
|
||||
cat: string;
|
||||
}): string {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.keyword) params.set("q", filters.keyword);
|
||||
if (filters.tag) params.set("tag", filters.tag);
|
||||
if (filters.cat) params.set("cat", filters.cat);
|
||||
return `${LISTING_STATE_PREFIX}${params.toString()}`;
|
||||
}
|
||||
|
||||
function readListingState(key: string): ListingState | null {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
const value = JSON.parse(raw) as Partial<ListingState>;
|
||||
return {
|
||||
sort: isSortKey(value.sort) ? value.sort : "latest",
|
||||
view: value.view === "compact" ? "compact" : "grid",
|
||||
page: typeof value.page === "number" && value.page > 0 ? value.page : 1,
|
||||
scrollY:
|
||||
typeof value.scrollY === "number" && value.scrollY > 0
|
||||
? value.scrollY
|
||||
: 0,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeListingState(key: string, state: ListingState) {
|
||||
try {
|
||||
window.sessionStorage.setItem(key, JSON.stringify(state));
|
||||
} catch {
|
||||
// Storage can be unavailable in private browsing modes.
|
||||
}
|
||||
}
|
||||
|
||||
function isSortKey(value: unknown): value is SortKey {
|
||||
return (
|
||||
value === "latest" ||
|
||||
value === "hot" ||
|
||||
value === "week" ||
|
||||
value === "long" ||
|
||||
value === "hd" ||
|
||||
value === "featured"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { SearchPanel } from "@/components/SearchPanel";
|
||||
import { VideoPlayer } from "@/components/VideoPlayer";
|
||||
@@ -7,22 +7,34 @@ import { VideoActions } from "@/components/VideoActions";
|
||||
import { VideoInfoPanel } from "@/components/VideoInfoPanel";
|
||||
import { CommentPanel } from "@/components/CommentPanel";
|
||||
import { RecommendedRail } from "@/components/RecommendedRail";
|
||||
import { fetchVideoDetail } from "@/data/videos";
|
||||
import type { VideoDetail } from "@/types";
|
||||
import {
|
||||
fetchTags,
|
||||
fetchVideoDetail,
|
||||
hideVideo,
|
||||
updateVideoTags,
|
||||
} from "@/data/videos";
|
||||
import type { TagItem, VideoDetail } from "@/types";
|
||||
|
||||
export default function VideoDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [detail, setDetail] = useState<VideoDetail | null>(null);
|
||||
const [tags, setTags] = useState<TagItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tagSaving, setTagSaving] = useState(false);
|
||||
const [hideSaving, setHideSaving] = useState(false);
|
||||
const detailTopRef = useRef<HTMLDivElement | null>(null);
|
||||
const commentRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let active = true;
|
||||
window.scrollTo({ top: 0, behavior: "auto" });
|
||||
setLoading(true);
|
||||
fetchVideoDetail(id).then((d) => {
|
||||
Promise.all([fetchVideoDetail(id), fetchTags()]).then(([d, tagList]) => {
|
||||
if (!active) return;
|
||||
setDetail(d);
|
||||
setTags(tagList);
|
||||
setLoading(false);
|
||||
document.title = d ? `${d.title} · 视频聚合站` : "视频不存在";
|
||||
});
|
||||
@@ -31,10 +43,44 @@ export default function VideoDetailPage() {
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (loading || !detail) return;
|
||||
window.requestAnimationFrame(() => {
|
||||
detailTopRef.current?.scrollIntoView({
|
||||
block: "start",
|
||||
behavior: "auto",
|
||||
});
|
||||
});
|
||||
}, [loading, detail?.id]);
|
||||
|
||||
function jumpToComments() {
|
||||
commentRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
async function handleTagsChange(nextTags: string[]) {
|
||||
if (!detail) return;
|
||||
setTagSaving(true);
|
||||
try {
|
||||
const updated = await updateVideoTags(detail.id, nextTags);
|
||||
setDetail({ ...detail, tags: updated.tags ?? [] });
|
||||
} finally {
|
||||
setTagSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
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("隐藏失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AppShell>
|
||||
@@ -68,7 +114,7 @@ export default function VideoDetailPage() {
|
||||
|
||||
<div className="container">
|
||||
<div className="detail-layout">
|
||||
<div className="detail-main">
|
||||
<div className="detail-main" ref={detailTopRef}>
|
||||
<div className="detail-title-bar">{detail.title}</div>
|
||||
<VideoPlayer
|
||||
src={detail.videoSrc}
|
||||
@@ -78,8 +124,15 @@ export default function VideoDetailPage() {
|
||||
<VideoActions
|
||||
video={detail}
|
||||
onJumpToComments={jumpToComments}
|
||||
onHideVideo={handleHideVideo}
|
||||
hideSaving={hideSaving}
|
||||
/>
|
||||
<VideoInfoPanel
|
||||
video={detail}
|
||||
availableTags={tags}
|
||||
tagSaving={tagSaving}
|
||||
onTagsChange={handleTagsChange}
|
||||
/>
|
||||
<VideoInfoPanel video={detail} />
|
||||
<CommentPanel
|
||||
ref={commentRef}
|
||||
comments={detail.commentsList}
|
||||
|
||||
@@ -210,6 +210,62 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.admin-drive-teasers {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 10px;
|
||||
margin: -4px 0 var(--space-4);
|
||||
}
|
||||
|
||||
.admin-drive-teaser {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-md);
|
||||
background: #fff;
|
||||
color: var(--color-text);
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-drive-teaser:hover,
|
||||
.admin-drive-teaser.is-active {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px rgba(255, 136, 0, 0.12);
|
||||
}
|
||||
|
||||
.admin-drive-teaser__name {
|
||||
min-width: 0;
|
||||
margin-right: auto;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-drive-teaser__metric {
|
||||
font-size: 12px;
|
||||
color: var(--color-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-drive-teaser__metric.is-ready {
|
||||
color: #168a4f;
|
||||
}
|
||||
|
||||
.admin-drive-teaser__metric.is-pending {
|
||||
color: #d96f00;
|
||||
}
|
||||
|
||||
.admin-drive-teaser__metric.is-failed {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -322,6 +378,39 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.admin-tag-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-sm);
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.admin-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.admin-check input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.admin-check em {
|
||||
color: var(--color-muted);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.admin-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ body {
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
background: var(--color-page);
|
||||
line-height: 1.5;
|
||||
line-height: 1.45;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
|
||||
+29
-20
@@ -2,6 +2,9 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: var(--color-page);
|
||||
color: var(--color-muted-light);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.app-shell__main {
|
||||
@@ -12,11 +15,11 @@
|
||||
.container {
|
||||
max-width: var(--container-max);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-4);
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.page-section {
|
||||
padding: var(--space-5) 0;
|
||||
padding: var(--space-4) 0;
|
||||
}
|
||||
|
||||
.page-section + .page-section {
|
||||
@@ -27,12 +30,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 0 var(--space-4);
|
||||
padding: 0 14px;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
font-size: 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-accent-dark);
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
|
||||
.section-header__title {
|
||||
@@ -40,26 +44,22 @@
|
||||
}
|
||||
|
||||
.section-header__extra {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.promo-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
gap: 10px;
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.promo-card {
|
||||
height: 96px;
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-nav) 0%,
|
||||
#3a3a3a 100%
|
||||
);
|
||||
height: 82px;
|
||||
padding: 10px;
|
||||
border-radius: 2px;
|
||||
background: #111111;
|
||||
color: var(--color-text-invert);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -71,11 +71,11 @@
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-accent);
|
||||
letter-spacing: 0.08em;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.promo-card__title {
|
||||
font-size: 15px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -113,10 +113,11 @@
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: var(--space-8);
|
||||
padding: var(--space-6) 0;
|
||||
margin-top: var(--space-6);
|
||||
padding: var(--space-5) 0;
|
||||
background: var(--color-nav);
|
||||
color: var(--color-muted-light);
|
||||
border-top: 1px solid #252525;
|
||||
}
|
||||
|
||||
.footer__inner {
|
||||
@@ -145,6 +146,14 @@
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.container {
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.page-section {
|
||||
padding: var(--space-3) 0;
|
||||
}
|
||||
|
||||
.promo-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
+21
-14
@@ -3,6 +3,7 @@
|
||||
background: var(--color-topbar);
|
||||
color: var(--color-muted-light);
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid #111111;
|
||||
}
|
||||
|
||||
.top-bar__inner {
|
||||
@@ -15,7 +16,7 @@
|
||||
.top-bar__side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.top-bar a {
|
||||
@@ -29,12 +30,13 @@
|
||||
.main-nav {
|
||||
background: var(--color-nav);
|
||||
color: var(--color-text-invert);
|
||||
border-bottom: 1px solid #1f1f1f;
|
||||
}
|
||||
|
||||
.main-nav__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 60px;
|
||||
height: 58px;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
@@ -43,14 +45,14 @@
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
font-size: 18px;
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
.main-nav__logo-mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
border-radius: 5px;
|
||||
background: var(--color-accent);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -67,21 +69,21 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: 15px;
|
||||
height: 58px;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
color: var(--color-muted-light);
|
||||
border-radius: var(--radius-sm);
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.main-nav__link:hover {
|
||||
color: var(--color-text-invert);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.main-nav__link.is-active {
|
||||
color: var(--color-text-invert);
|
||||
border-bottom: 2px solid var(--color-accent);
|
||||
border-radius: 0;
|
||||
border-bottom-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.main-nav__toggle {
|
||||
@@ -90,7 +92,7 @@
|
||||
}
|
||||
|
||||
.sub-nav {
|
||||
background: #1a1a1a;
|
||||
background: #1b1b1b;
|
||||
color: var(--color-text-invert);
|
||||
border-bottom: 1px solid #050505;
|
||||
}
|
||||
@@ -98,7 +100,7 @@
|
||||
.sub-nav__list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-5);
|
||||
gap: 24px;
|
||||
height: 36px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
@@ -110,7 +112,7 @@
|
||||
|
||||
.sub-nav__item {
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
@@ -119,6 +121,10 @@
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-nav__inner {
|
||||
height: 58px;
|
||||
}
|
||||
|
||||
.main-nav__list {
|
||||
display: none;
|
||||
}
|
||||
@@ -131,7 +137,7 @@
|
||||
.main-nav.is-open .main-nav__list {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 90px;
|
||||
top: 88px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
flex-direction: column;
|
||||
@@ -143,6 +149,7 @@
|
||||
}
|
||||
|
||||
.main-nav.is-open .main-nav__link {
|
||||
height: auto;
|
||||
padding: var(--space-3) var(--space-2);
|
||||
border-bottom: 1px solid #1f1f1f;
|
||||
}
|
||||
|
||||
+63
-39
@@ -1,26 +1,26 @@
|
||||
.search-panel {
|
||||
background: var(--color-section);
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-line);
|
||||
margin-top: var(--space-4);
|
||||
background: #111111;
|
||||
padding: 12px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid #303030;
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.search-panel__form {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.search-panel__input {
|
||||
flex: 1 1 220px;
|
||||
min-width: 200px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 38px;
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
color: var(--color-text);
|
||||
padding: 0 12px;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 2px;
|
||||
background: #f7f7f7;
|
||||
color: #222222;
|
||||
}
|
||||
|
||||
.search-panel__input:focus {
|
||||
@@ -30,23 +30,26 @@
|
||||
|
||||
.search-panel__select {
|
||||
height: 38px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
color: var(--color-text);
|
||||
min-width: 110px;
|
||||
flex: 0 0 118px;
|
||||
min-width: 0;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 2px;
|
||||
background: #f7f7f7;
|
||||
color: #222222;
|
||||
}
|
||||
|
||||
.search-panel__submit {
|
||||
height: 38px;
|
||||
padding: 0 var(--space-5);
|
||||
flex: 0 0 88px;
|
||||
padding: 0 12px;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: 2px;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
@@ -55,53 +58,57 @@
|
||||
}
|
||||
|
||||
.tag-cloud {
|
||||
margin-top: var(--space-3);
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag-cloud__label {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
color: var(--color-muted);
|
||||
align-self: center;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
padding: 4px 10px;
|
||||
border-radius: 99px;
|
||||
background: #f0f0ef;
|
||||
color: var(--color-text);
|
||||
padding: 3px 9px;
|
||||
border-radius: 2px;
|
||||
background: #171717;
|
||||
color: var(--color-muted-light);
|
||||
font-size: 12px;
|
||||
border: 1px solid transparent;
|
||||
border: 1px solid #282828;
|
||||
}
|
||||
|
||||
.tag-chip:hover,
|
||||
.tag-chip.is-active {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
border-color: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
.sort-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) 0;
|
||||
gap: 8px;
|
||||
padding: 8px 0 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sort-toolbar__group {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.sort-toolbar__btn {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
color: var(--color-text);
|
||||
min-height: 31px;
|
||||
padding: 5px 11px;
|
||||
font-size: 12px;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 2px;
|
||||
background: #151515;
|
||||
color: var(--color-muted-light);
|
||||
}
|
||||
|
||||
.sort-toolbar__btn:hover {
|
||||
@@ -120,6 +127,23 @@
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.search-panel {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.search-panel__form {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.search-panel__select {
|
||||
flex-basis: 104px;
|
||||
}
|
||||
|
||||
.search-panel__submit {
|
||||
flex-basis: 80px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.tag-cloud {
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
+10
-10
@@ -1,16 +1,16 @@
|
||||
:root {
|
||||
--color-page: #f4f4f2;
|
||||
--color-topbar: #232323;
|
||||
--color-nav: #111111;
|
||||
--color-card: #151515;
|
||||
--color-card-border: #2a2a2a;
|
||||
--color-accent: #ff8800;
|
||||
--color-accent-dark: #d96f00;
|
||||
--color-page: #050505;
|
||||
--color-topbar: #242424;
|
||||
--color-nav: #0d0d0d;
|
||||
--color-card: #111111;
|
||||
--color-card-border: #303030;
|
||||
--color-accent: #ff8a00;
|
||||
--color-accent-dark: #d97300;
|
||||
--color-text: #202020;
|
||||
--color-text-invert: #ffffff;
|
||||
--color-muted: #8a8a8a;
|
||||
--color-muted-light: #b5b5b5;
|
||||
--color-line: #dddddd;
|
||||
--color-muted-light: #c6c6c6;
|
||||
--color-line: #d8d8d8;
|
||||
--color-section: #ffffff;
|
||||
--color-danger: #e23b3b;
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 6px;
|
||||
|
||||
--container-max: 1200px;
|
||||
--container-max: 1170px;
|
||||
|
||||
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||
--shadow-elevated: 0 4px 14px rgba(0, 0, 0, 0.25);
|
||||
|
||||
+73
-46
@@ -1,8 +1,8 @@
|
||||
.video-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-3);
|
||||
gap: 12px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
@@ -33,43 +33,57 @@
|
||||
.video-grid.is-compact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.video-grid.is-compact .video-card {
|
||||
display: block;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.video-grid.is-compact .video-card__link {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2);
|
||||
grid-template-columns: 140px minmax(0, 1fr);
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 4px 10px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.video-grid.is-compact .thumb-frame {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
|
||||
.video-grid.is-compact .video-card .video-title {
|
||||
grid-column: 2;
|
||||
white-space: normal;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.video-grid.is-compact .video-meta {
|
||||
grid-column: 2;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.video-card {
|
||||
display: block;
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-card-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px;
|
||||
border-radius: 2px;
|
||||
padding: 4px;
|
||||
color: var(--color-text-invert);
|
||||
transition: transform 160ms ease, box-shadow 160ms ease;
|
||||
box-shadow: var(--shadow-card);
|
||||
transition: border-color 160ms ease, background-color 160ms ease;
|
||||
}
|
||||
|
||||
.video-card:hover,
|
||||
.video-card:focus-within {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
background: #171717;
|
||||
border-color: #545454;
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
@@ -87,7 +101,8 @@
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid #202020;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.thumb-image,
|
||||
@@ -115,32 +130,32 @@
|
||||
|
||||
.duration {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
z-index: 3;
|
||||
padding: 2px 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: 2px;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.badge-row {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.video-badge {
|
||||
padding: 2px 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: 2px;
|
||||
background: var(--color-accent);
|
||||
color: #000;
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
text-transform: uppercase;
|
||||
@@ -192,11 +207,11 @@
|
||||
/* 右上角的 "预览" 角标 */
|
||||
.preview-tag {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 3;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: 2px;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
@@ -214,26 +229,27 @@
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: var(--color-text-invert);
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--color-text-invert);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.video-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 10px;
|
||||
gap: 2px 8px;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
@@ -245,13 +261,15 @@
|
||||
padding: var(--space-8) 0;
|
||||
text-align: center;
|
||||
color: var(--color-muted);
|
||||
background: #111111;
|
||||
border: 1px solid #303030;
|
||||
}
|
||||
|
||||
.video-grid-loading {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-3);
|
||||
gap: 12px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
@@ -264,7 +282,8 @@
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.2s ease-in-out infinite;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid #303030;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
@@ -280,20 +299,20 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
margin-top: var(--space-5);
|
||||
gap: 5px;
|
||||
margin-top: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pagination__btn {
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 2px;
|
||||
background: #151515;
|
||||
font-size: 12px;
|
||||
color: var(--color-muted-light);
|
||||
}
|
||||
|
||||
.pagination__btn:hover:not(:disabled) {
|
||||
@@ -313,12 +332,16 @@
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.video-grid {
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.video-card {
|
||||
padding: 4px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.video-meta span:nth-child(n + 3) {
|
||||
@@ -326,6 +349,10 @@
|
||||
}
|
||||
|
||||
.video-grid.is-compact .video-card {
|
||||
grid-template-columns: 120px 1fr;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.video-grid.is-compact .video-card__link {
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
+173
-58
@@ -1,8 +1,8 @@
|
||||
.detail-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: var(--space-5);
|
||||
margin-top: var(--space-4);
|
||||
gap: 16px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.detail-main {
|
||||
@@ -12,10 +12,12 @@
|
||||
.detail-title-bar {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
font-size: 18px;
|
||||
padding: 9px 14px;
|
||||
border: 1px solid var(--color-accent-dark);
|
||||
border-radius: 2px 2px 0 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.video-player {
|
||||
@@ -23,6 +25,8 @@
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
border: 1px solid #303030;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.video-player video {
|
||||
@@ -40,19 +44,20 @@
|
||||
padding: var(--space-4);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.video-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
gap: 14px;
|
||||
padding: 10px 14px;
|
||||
background: var(--color-card);
|
||||
color: var(--color-text-invert);
|
||||
border-top: 1px solid var(--color-card-border);
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--color-card-border);
|
||||
border-top: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.video-stats__item {
|
||||
@@ -73,21 +78,22 @@
|
||||
.video-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
gap: 7px;
|
||||
padding: 10px 14px 12px;
|
||||
background: #1a1a1a;
|
||||
border-top: 1px solid var(--color-card-border);
|
||||
border: 1px solid var(--color-card-border);
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.video-actions__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 7px 12px;
|
||||
border-radius: 2px;
|
||||
background: #262626;
|
||||
color: var(--color-text-invert);
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--color-card-border);
|
||||
}
|
||||
|
||||
@@ -96,44 +102,59 @@
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
.video-actions__btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.video-actions__btn.is-active {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.video-actions__btn.is-danger {
|
||||
color: #ffb3b3;
|
||||
}
|
||||
|
||||
.video-actions__btn.is-danger:hover {
|
||||
background: #3a2020;
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.video-actions__btn.is-danger.is-active {
|
||||
background: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
margin-top: var(--space-4);
|
||||
background: var(--color-section);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-md);
|
||||
margin-top: 16px;
|
||||
background: #111111;
|
||||
border: 1px solid #303030;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-panel__header {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: 14px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--color-accent-dark);
|
||||
}
|
||||
|
||||
.info-panel__body {
|
||||
padding: var(--space-4);
|
||||
padding: 13px 14px;
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: grid;
|
||||
grid-template-columns: 96px 1fr;
|
||||
gap: var(--space-3);
|
||||
font-size: 13px;
|
||||
grid-template-columns: 84px 1fr;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.info-row__label {
|
||||
@@ -141,7 +162,97 @@
|
||||
}
|
||||
|
||||
.info-row__value {
|
||||
color: var(--color-text);
|
||||
color: var(--color-muted-light);
|
||||
}
|
||||
|
||||
.detail-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-tags__edit {
|
||||
padding: 4px 9px;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 2px;
|
||||
background: #181818;
|
||||
color: var(--color-muted-light);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-tags__edit:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.detail-tag-editor {
|
||||
margin-top: var(--space-2);
|
||||
padding: 10px;
|
||||
border: 1px solid #303030;
|
||||
border-radius: 2px;
|
||||
background: #171717;
|
||||
}
|
||||
|
||||
.detail-tag-editor__grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.detail-tag-editor__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 2px;
|
||||
background: #111111;
|
||||
color: var(--color-muted-light);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-tag-editor__item input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.detail-tag-editor__item em {
|
||||
color: var(--color-muted);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.detail-tag-editor__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.detail-tag-editor__actions button {
|
||||
padding: 6px 12px;
|
||||
border-radius: 2px;
|
||||
background: #2a2a2a;
|
||||
color: var(--color-muted-light);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-tag-editor__actions button:last-child {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
.detail-tag-editor__actions button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.detail-tag-editor__error {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--color-danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.author-card {
|
||||
@@ -149,14 +260,15 @@
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: #fafafa;
|
||||
border-radius: var(--radius-sm);
|
||||
background: #171717;
|
||||
border: 1px solid #303030;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.author-card__avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border-radius: 2px;
|
||||
background: var(--color-accent);
|
||||
color: #000;
|
||||
display: grid;
|
||||
@@ -166,7 +278,7 @@
|
||||
|
||||
.author-card__name {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
.author-card__meta {
|
||||
@@ -179,8 +291,8 @@
|
||||
padding: 6px 14px;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
border-radius: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.author-card__follow.is-following {
|
||||
@@ -189,9 +301,9 @@
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--color-text);
|
||||
color: var(--color-muted-light);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@@ -205,7 +317,7 @@
|
||||
.description-toggle {
|
||||
margin-top: var(--space-1);
|
||||
color: var(--color-accent);
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.embed-box {
|
||||
@@ -218,10 +330,10 @@
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fafafa;
|
||||
color: var(--color-text);
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 2px;
|
||||
background: #171717;
|
||||
color: var(--color-muted-light);
|
||||
resize: none;
|
||||
min-height: 36px;
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
@@ -231,8 +343,8 @@
|
||||
padding: 0 var(--space-3);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
border-radius: 2px;
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
@@ -243,23 +355,24 @@
|
||||
}
|
||||
|
||||
.comment-panel {
|
||||
margin-top: var(--space-4);
|
||||
background: var(--color-section);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-md);
|
||||
margin-top: 16px;
|
||||
background: #111111;
|
||||
border: 1px solid #303030;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.comment-panel__header {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: 14px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--color-accent-dark);
|
||||
}
|
||||
|
||||
.comment-panel__body {
|
||||
padding: var(--space-4);
|
||||
padding: 13px 14px;
|
||||
}
|
||||
|
||||
.comment-list {
|
||||
@@ -270,7 +383,7 @@
|
||||
|
||||
.comment-item {
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
border-bottom: 1px solid #303030;
|
||||
}
|
||||
|
||||
.comment-item:last-child {
|
||||
@@ -287,7 +400,7 @@
|
||||
}
|
||||
|
||||
.comment-item__author {
|
||||
color: var(--color-text);
|
||||
color: var(--color-text-invert);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -304,24 +417,26 @@
|
||||
.detail-side__header {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--color-accent-dark);
|
||||
border-radius: 2px 2px 0 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-2);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.detail-layout {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.video-stats {
|
||||
gap: var(--space-3);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
grid-template-columns: 80px 1fr;
|
||||
grid-template-columns: 72px 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
FRONTEND_HOST="${FRONTEND_HOST:-0.0.0.0}"
|
||||
FRONTEND_PORT="${FRONTEND_PORT:-9191}"
|
||||
BACKEND_PORT="${BACKEND_PORT:-9192}"
|
||||
LOG_DIR="${LOG_DIR:-/tmp/video-site-91}"
|
||||
|
||||
FRONTEND_LOG="$LOG_DIR/frontend.log"
|
||||
BACKEND_LOG="$LOG_DIR/backend.log"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: ./start.sh [--restart|--stop|--status]
|
||||
|
||||
Environment overrides:
|
||||
FRONTEND_HOST=$FRONTEND_HOST
|
||||
FRONTEND_PORT=$FRONTEND_PORT
|
||||
BACKEND_PORT=$BACKEND_PORT
|
||||
LOG_DIR=$LOG_DIR
|
||||
|
||||
Logs:
|
||||
frontend: $FRONTEND_LOG
|
||||
backend: $BACKEND_LOG
|
||||
EOF
|
||||
}
|
||||
|
||||
need_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
echo "missing required command: $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
pids_on_port() {
|
||||
local port="$1"
|
||||
ss -ltnp 2>/dev/null \
|
||||
| awk -v needle=":$port" '$4 ~ needle {print $0}' \
|
||||
| sed -nE 's/.*pid=([0-9]+).*/\1/p' \
|
||||
| sort -u
|
||||
}
|
||||
|
||||
print_port_status() {
|
||||
local name="$1"
|
||||
local port="$2"
|
||||
local pids
|
||||
pids="$(pids_on_port "$port" | tr '\n' ' ' | sed 's/[[:space:]]*$//')"
|
||||
if [[ -n "$pids" ]]; then
|
||||
echo "$name listening on port $port (pid: $pids)"
|
||||
else
|
||||
echo "$name not listening on port $port"
|
||||
fi
|
||||
}
|
||||
|
||||
stop_port() {
|
||||
local name="$1"
|
||||
local port="$2"
|
||||
local pids
|
||||
pids="$(pids_on_port "$port" | tr '\n' ' ' | sed 's/[[:space:]]*$//')"
|
||||
if [[ -z "$pids" ]]; then
|
||||
echo "$name is not running on port $port"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "stopping $name on port $port (pid: $pids)"
|
||||
kill $pids 2>/dev/null || true
|
||||
|
||||
for _ in $(seq 1 20); do
|
||||
if [[ -z "$(pids_on_port "$port")" ]]; then
|
||||
return
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
echo "$name did not stop gracefully; sending SIGKILL"
|
||||
kill -9 $pids 2>/dev/null || true
|
||||
}
|
||||
|
||||
wait_for_port() {
|
||||
local name="$1"
|
||||
local port="$2"
|
||||
for _ in $(seq 1 60); do
|
||||
if [[ -n "$(pids_on_port "$port")" ]]; then
|
||||
print_port_status "$name" "$port"
|
||||
return 0
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
echo "$name did not start on port $port. Check logs in $LOG_DIR" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
start_backend() {
|
||||
if [[ -n "$(pids_on_port "$BACKEND_PORT")" ]]; then
|
||||
print_port_status "backend" "$BACKEND_PORT"
|
||||
return
|
||||
fi
|
||||
|
||||
need_cmd go
|
||||
mkdir -p "$LOG_DIR"
|
||||
echo "starting backend on 127.0.0.1:$BACKEND_PORT"
|
||||
(
|
||||
cd "$ROOT_DIR/backend"
|
||||
setsid nohup go run ./cmd/server >>"$BACKEND_LOG" 2>&1 </dev/null &
|
||||
)
|
||||
wait_for_port "backend" "$BACKEND_PORT"
|
||||
}
|
||||
|
||||
start_frontend() {
|
||||
if [[ -n "$(pids_on_port "$FRONTEND_PORT")" ]]; then
|
||||
print_port_status "frontend" "$FRONTEND_PORT"
|
||||
return
|
||||
fi
|
||||
|
||||
need_cmd npm
|
||||
mkdir -p "$LOG_DIR"
|
||||
echo "starting frontend on $FRONTEND_HOST:$FRONTEND_PORT"
|
||||
(
|
||||
cd "$ROOT_DIR"
|
||||
setsid nohup npm run dev -- --host "$FRONTEND_HOST" --port "$FRONTEND_PORT" >>"$FRONTEND_LOG" 2>&1 </dev/null &
|
||||
)
|
||||
wait_for_port "frontend" "$FRONTEND_PORT"
|
||||
}
|
||||
|
||||
main() {
|
||||
local action="${1:-start}"
|
||||
|
||||
case "$action" in
|
||||
start)
|
||||
need_cmd ss
|
||||
start_backend
|
||||
start_frontend
|
||||
echo
|
||||
echo "ready:"
|
||||
echo " frontend: http://127.0.0.1:$FRONTEND_PORT/"
|
||||
echo " backend: http://127.0.0.1:$BACKEND_PORT/"
|
||||
;;
|
||||
--restart|restart)
|
||||
need_cmd ss
|
||||
stop_port "frontend" "$FRONTEND_PORT"
|
||||
stop_port "backend" "$BACKEND_PORT"
|
||||
start_backend
|
||||
start_frontend
|
||||
echo
|
||||
echo "restarted:"
|
||||
echo " frontend: http://127.0.0.1:$FRONTEND_PORT/"
|
||||
echo " backend: http://127.0.0.1:$BACKEND_PORT/"
|
||||
;;
|
||||
--stop|stop)
|
||||
need_cmd ss
|
||||
stop_port "frontend" "$FRONTEND_PORT"
|
||||
stop_port "backend" "$BACKEND_PORT"
|
||||
;;
|
||||
--status|status)
|
||||
need_cmd ss
|
||||
print_port_status "frontend" "$FRONTEND_PORT"
|
||||
print_port_status "backend" "$BACKEND_PORT"
|
||||
;;
|
||||
-h|--help|help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
shouldInterceptPreviewTap,
|
||||
shouldStartInstantPreview,
|
||||
} from "../src/lib/previewIntent.ts";
|
||||
|
||||
test("touch tap starts preview instead of navigating when preview is idle", () => {
|
||||
assert.equal(
|
||||
shouldInterceptPreviewTap({
|
||||
canHover: false,
|
||||
pointerType: "touch",
|
||||
previewActive: false,
|
||||
}),
|
||||
true
|
||||
);
|
||||
assert.equal(shouldStartInstantPreview({ pointerType: "touch" }), true);
|
||||
});
|
||||
|
||||
test("touch tap navigates when the same card preview is already active", () => {
|
||||
assert.equal(
|
||||
shouldInterceptPreviewTap({
|
||||
canHover: false,
|
||||
pointerType: "touch",
|
||||
previewActive: true,
|
||||
}),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("mouse click does not intercept normal navigation", () => {
|
||||
assert.equal(
|
||||
shouldInterceptPreviewTap({
|
||||
canHover: true,
|
||||
pointerType: "mouse",
|
||||
previewActive: false,
|
||||
}),
|
||||
false
|
||||
);
|
||||
assert.equal(shouldStartInstantPreview({ pointerType: "mouse" }), false);
|
||||
});
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/types.ts","./src/admin/adminlayout.tsx","./src/admin/authcontext.tsx","./src/admin/drivespage.tsx","./src/admin/loginpage.tsx","./src/admin/modal.tsx","./src/admin/previewtoggle.tsx","./src/admin/requireauth.tsx","./src/admin/toastcontext.tsx","./src/admin/videospage.tsx","./src/admin/api.ts","./src/components/appshell.tsx","./src/components/backtotop.tsx","./src/components/commentpanel.tsx","./src/components/footer.tsx","./src/components/mainnav.tsx","./src/components/pagination.tsx","./src/components/previewvideo.tsx","./src/components/promostrip.tsx","./src/components/recommendedrail.tsx","./src/components/searchpanel.tsx","./src/components/sectionheader.tsx","./src/components/sorttoolbar.tsx","./src/components/subnav.tsx","./src/components/tagcloud.tsx","./src/components/topbar.tsx","./src/components/videoactions.tsx","./src/components/videocard.tsx","./src/components/videogrid.tsx","./src/components/videoinfopanel.tsx","./src/components/videoplayer.tsx","./src/data/categories.ts","./src/data/tags.ts","./src/data/videos.ts","./src/lib/format.ts","./src/lib/previewcontroller.ts","./src/lib/useinviewport.ts","./src/pages/homepage.tsx","./src/pages/listingpage.tsx","./src/pages/videodetailpage.tsx"],"version":"5.6.3"}
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/admin/AdminLayout.tsx","./src/admin/AuthContext.tsx","./src/admin/DrivesPage.tsx","./src/admin/LoginPage.tsx","./src/admin/Modal.tsx","./src/admin/PreviewToggle.tsx","./src/admin/RequireAuth.tsx","./src/admin/TagsPage.tsx","./src/admin/ToastContext.tsx","./src/admin/VideosPage.tsx","./src/admin/api.ts","./src/components/AppShell.tsx","./src/components/BackToTop.tsx","./src/components/CommentPanel.tsx","./src/components/Footer.tsx","./src/components/MainNav.tsx","./src/components/Pagination.tsx","./src/components/PreviewVideo.tsx","./src/components/PromoStrip.tsx","./src/components/RecommendedRail.tsx","./src/components/SearchPanel.tsx","./src/components/SectionHeader.tsx","./src/components/SortToolbar.tsx","./src/components/SubNav.tsx","./src/components/TagCloud.tsx","./src/components/TopBar.tsx","./src/components/VideoActions.tsx","./src/components/VideoCard.tsx","./src/components/VideoGrid.tsx","./src/components/VideoInfoPanel.tsx","./src/components/VideoPlayer.tsx","./src/data/categories.ts","./src/data/tags.ts","./src/data/videos.ts","./src/lib/format.ts","./src/lib/previewController.ts","./src/lib/previewIntent.ts","./src/lib/useInViewport.ts","./src/pages/HomePage.tsx","./src/pages/ListingPage.tsx","./src/pages/VideoDetailPage.tsx"],"version":"5.6.3"}
|
||||
Reference in New Issue
Block a user