mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 00:44:30 +08:00
Add PikPak drive support
Add PikPak backend driver, fixed tag matching, cached transcode playback, fast cover handling, and LF normalization.
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
*.ico binary
|
||||
*.db binary
|
||||
*.mp4 binary
|
||||
*.sqlite binary
|
||||
+22
-21
@@ -1,21 +1,22 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
*.log
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# 第三方源码参考,可选,详见 vendor-refs/README.md
|
||||
vendor-refs/
|
||||
|
||||
# 后端数据目录(SQLite + teaser/封面)
|
||||
backend/data/
|
||||
backend/config.yaml
|
||||
|
||||
# 工具链
|
||||
tools/
|
||||
|
||||
# 注意:backend/vendor/ 是 Go modules vendor 目录,故意入库
|
||||
# 目的是让任何人 clone 后断网也能直接 go build
|
||||
# 不要加 `vendor/` 或 `backend/vendor/` 到这里
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
*.log
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# 第三方源码参考,可选,详见 vendor-refs/README.md
|
||||
vendor-refs/
|
||||
OpenList-4.2.1/
|
||||
|
||||
# 后端数据目录(SQLite + teaser/封面)
|
||||
backend/data/
|
||||
backend/config.yaml
|
||||
|
||||
# 工具链
|
||||
tools/
|
||||
|
||||
# 注意:backend/vendor/ 是 Go modules vendor 目录,故意入库
|
||||
# 目的是让任何人 clone 后断网也能直接 go build
|
||||
# 不要加 `vendor/` 或 `backend/vendor/` 到这里
|
||||
|
||||
@@ -1,111 +1,112 @@
|
||||
# 视频聚合站
|
||||
|
||||
把夸克 / 115 / 联通沃盘作为存储后端的视频聚合前台。按 `video-site-implementation-plan.md` 的设计实现。
|
||||
|
||||
- 前端:React 18 + Vite + TypeScript
|
||||
- 后端:Go 1.23,SQLite(纯 Go 驱动,无 CGO),ffmpeg 生成 teaser 和封面
|
||||
- 三家网盘接入:夸克自研 + 115driver SDK + wopan-sdk-go SDK
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js 18+ 和 npm
|
||||
- Go 1.23+
|
||||
- ffmpeg 和 ffprobe(用于生成预览 teaser 和抽封面)
|
||||
|
||||
Windows 用户可以把 Go 和 ffmpeg 解压到 `%USERPROFILE%\tools\`,然后把 `\tools\go\bin` 和 `\tools\ffmpeg\bin` 加到 PATH 即可,不需要管理员权限。
|
||||
|
||||
### 运行
|
||||
|
||||
```bash
|
||||
# 前端
|
||||
npm install
|
||||
npm run dev # 监听 http://127.0.0.1:5173
|
||||
|
||||
# 后端(另开终端)
|
||||
cd backend
|
||||
go run ./cmd/server # 监听 :8080,依赖已 vendor 入库,无需 go mod tidy
|
||||
```
|
||||
|
||||
首次启动后端会自动生成:
|
||||
|
||||
- `backend/config.yaml`(从 `config.example.yaml` 复制)
|
||||
- `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` 里改)。
|
||||
|
||||
## 目录
|
||||
|
||||
```
|
||||
.
|
||||
├─ src/ React 前端
|
||||
├─ backend/ Go 后端(单体服务)
|
||||
│ └─ vendor/ Go 依赖全量源码,入库,支持完全离线构建
|
||||
├─ vendor-refs/ 可选的阅读资料,.gitignore 忽略
|
||||
│ └─ OpenList-4.2.1/ OpenList 完整源码,网盘协议对接参考
|
||||
├─ video-site-implementation-plan.md 完整的设计和实现记录
|
||||
└─ README.md
|
||||
```
|
||||
|
||||
### 依赖管理
|
||||
|
||||
所有 Go 依赖都已通过 `go mod vendor` 打包进 `backend/vendor/` 并入库。别人 clone 仓库后,**无需联网**,直接 `go run ./cmd/server` 就能编译运行。
|
||||
|
||||
升级依赖的流程:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go get github.com/SheltonZhu/115driver@<新版本>
|
||||
go mod tidy
|
||||
go mod vendor # 把新依赖同步到 vendor 目录
|
||||
git add vendor/ # 入库
|
||||
```
|
||||
|
||||
### `vendor-refs/` 要不要在意?
|
||||
|
||||
不需要。它只存 OpenList 源码作协议参考,删除或保留都不影响项目编译。
|
||||
|
||||
## 加一个网盘
|
||||
|
||||
1. 登录 `/admin` → 网盘管理 → 新建
|
||||
2. 选类型(夸克 / 115 / 沃盘),填名称 + 凭证
|
||||
3. 保存后会自动触发一次扫描
|
||||
4. 在 `/admin/videos` 里看扫到了多少视频
|
||||
5. 侧栏底部 **Teaser 生成** 开关开着,就会按配置给每个视频生成封面和 10 秒 teaser
|
||||
|
||||
三家盘的凭证字段:
|
||||
|
||||
| 类型 | 凭证字段 | 获取方式 |
|
||||
|---|---|---|
|
||||
| 夸克 | `cookie` | pan.quark.cn 登录后 F12 拷 Cookie |
|
||||
| 115 | `cookie` | 115.com 登录后拷 Cookie(`UID=...; CID=...; SEID=...; KID=...`) |
|
||||
| 沃盘 | `access_token`、`refresh_token`、可选 `family_id` | 第一版只能手动粘贴 token;后续会加扫码/短信登录 |
|
||||
|
||||
## 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`,不再自动重试;管理后台可手动重生
|
||||
- 详见 plan 15.12 节
|
||||
|
||||
## 部署到 Linux
|
||||
|
||||
```bash
|
||||
# 本机交叉编译
|
||||
cd backend
|
||||
GOOS=linux GOARCH=amd64 go build -o video-server ./cmd/server
|
||||
|
||||
# 目标服务器
|
||||
sudo apt install ffmpeg
|
||||
scp video-server user@host:/opt/video-site/
|
||||
# 配 systemd + nginx 反代到 /、/api、/p、/admin
|
||||
```
|
||||
|
||||
完整部署方式见 plan 15.10 节。
|
||||
|
||||
## 贡献
|
||||
|
||||
任何代码改动请保持和 `video-site-implementation-plan.md` 同步;重要的设计决策追加到第 14 节(实现备注)或第 15 节(后端)。
|
||||
# 视频聚合站
|
||||
|
||||
把夸克 / 115 / PikPak / 联通沃盘作为存储后端的视频聚合前台。按 `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
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js 18+ 和 npm
|
||||
- Go 1.23+
|
||||
- ffmpeg 和 ffprobe(用于生成预览 teaser 和抽封面)
|
||||
|
||||
Windows 用户可以把 Go 和 ffmpeg 解压到 `%USERPROFILE%\tools\`,然后把 `\tools\go\bin` 和 `\tools\ffmpeg\bin` 加到 PATH 即可,不需要管理员权限。
|
||||
|
||||
### 运行
|
||||
|
||||
```bash
|
||||
# 前端
|
||||
npm install
|
||||
npm run dev # 监听 http://127.0.0.1:5173
|
||||
|
||||
# 后端(另开终端)
|
||||
cd backend
|
||||
go run ./cmd/server # 监听 :8080,依赖已 vendor 入库,无需 go mod tidy
|
||||
```
|
||||
|
||||
首次启动后端会自动生成:
|
||||
|
||||
- `backend/config.yaml`(从 `config.example.yaml` 复制)
|
||||
- `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` 里改)。
|
||||
|
||||
## 目录
|
||||
|
||||
```
|
||||
.
|
||||
├─ src/ React 前端
|
||||
├─ backend/ Go 后端(单体服务)
|
||||
│ └─ vendor/ Go 依赖全量源码,入库,支持完全离线构建
|
||||
├─ vendor-refs/ 可选的阅读资料,.gitignore 忽略
|
||||
│ └─ OpenList-4.2.1/ OpenList 完整源码,网盘协议对接参考
|
||||
├─ video-site-implementation-plan.md 完整的设计和实现记录
|
||||
└─ README.md
|
||||
```
|
||||
|
||||
### 依赖管理
|
||||
|
||||
所有 Go 依赖都已通过 `go mod vendor` 打包进 `backend/vendor/` 并入库。别人 clone 仓库后,**无需联网**,直接 `go run ./cmd/server` 就能编译运行。
|
||||
|
||||
升级依赖的流程:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go get github.com/SheltonZhu/115driver@<新版本>
|
||||
go mod tidy
|
||||
go mod vendor # 把新依赖同步到 vendor 目录
|
||||
git add vendor/ # 入库
|
||||
```
|
||||
|
||||
### `vendor-refs/` 要不要在意?
|
||||
|
||||
不需要。它只存 OpenList 源码作协议参考,删除或保留都不影响项目编译。
|
||||
|
||||
## 加一个网盘
|
||||
|
||||
1. 登录 `/admin` → 网盘管理 → 新建
|
||||
2. 选类型(夸克 / 115 / PikPak / 沃盘),填名称 + 凭证
|
||||
3. 保存后会自动触发一次扫描
|
||||
4. 在 `/admin/videos` 里看扫到了多少视频
|
||||
5. 侧栏底部 **Teaser 生成** 开关开着,就会按配置给每个视频生成封面和 10 秒 teaser
|
||||
|
||||
各网盘的凭证字段:
|
||||
|
||||
| 类型 | 凭证字段 | 获取方式 |
|
||||
|---|---|---|
|
||||
| 夸克 | `cookie` | pan.quark.cn 登录后 F12 拷 Cookie |
|
||||
| 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;后续会加扫码/短信登录 |
|
||||
|
||||
## 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`,不再自动重试;管理后台可手动重生
|
||||
- 详见 plan 15.12 节
|
||||
|
||||
## 部署到 Linux
|
||||
|
||||
```bash
|
||||
# 本机交叉编译
|
||||
cd backend
|
||||
GOOS=linux GOARCH=amd64 go build -o video-server ./cmd/server
|
||||
|
||||
# 目标服务器
|
||||
sudo apt install ffmpeg
|
||||
scp video-server user@host:/opt/video-site/
|
||||
# 配 systemd + nginx 反代到 /、/api、/p、/admin
|
||||
```
|
||||
|
||||
完整部署方式见 plan 15.10 节。
|
||||
|
||||
## 贡献
|
||||
|
||||
任何代码改动请保持和 `video-site-implementation-plan.md` 同步;重要的设计决策追加到第 14 节(实现备注)或第 15 节(后端)。
|
||||
|
||||
+135
-133
@@ -1,133 +1,135 @@
|
||||
# backend
|
||||
|
||||
视频聚合站的 Go 后端。提供三件事:
|
||||
|
||||
1. 三家网盘统一抽象(夸克 / 115 / 联通沃盘)
|
||||
2. 视频元数据目录(SQLite)+ 扫描 + teaser 预生成
|
||||
3. REST API(前台)+ 管理后台 + 直链代理
|
||||
|
||||
## 目录
|
||||
|
||||
```
|
||||
cmd/server/main.go 入口
|
||||
internal/
|
||||
config/ YAML 配置
|
||||
catalog/ SQLite 元数据
|
||||
drives/
|
||||
iface.go Drive 接口
|
||||
quark/ 夸克(自己实现,参考 OpenList quark_uc)
|
||||
p115/ 115(壳子 + SheltonZhu/115driver)
|
||||
wopan/ 联通沃盘(壳子 + OpenListTeam/wopan-sdk-go)
|
||||
scanner/ 扫目录 → 落库
|
||||
preview/ ffmpeg 抽 10s teaser
|
||||
proxy/ /p/stream/*、/p/preview/* 代理
|
||||
auth/ 管理员 session
|
||||
api/ REST 路由
|
||||
config.example.yaml 配置模板
|
||||
```
|
||||
|
||||
## 开发环境(Windows)
|
||||
|
||||
本仓库假设工具都装在用户目录,不需要管理员权限。
|
||||
|
||||
```
|
||||
C:\Users\<you>\tools\
|
||||
go\bin\go.exe Go 1.23+
|
||||
ffmpeg\bin\ffmpeg.exe 任意 ≥ 4.x 版本
|
||||
```
|
||||
|
||||
并加到 `PATH`。
|
||||
|
||||
### 第一次启动
|
||||
|
||||
```powershell
|
||||
cd F:\VideoProject\backend
|
||||
go mod tidy
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
首次启动会在当前目录创建:
|
||||
|
||||
- `config.yaml`(从 `config.example.yaml` 复制)
|
||||
- `data/video-site.db`
|
||||
- `data/previews/`
|
||||
|
||||
默认监听 `:8080`,默认管理员 `admin / admin123`(务必在 `config.yaml` 里改)。
|
||||
|
||||
### 连接前端
|
||||
|
||||
`vite.config.ts` 已经把 `/api`、`/p`、`/admin` 代理到 `8080`。
|
||||
|
||||
```
|
||||
npm run dev 前端 5173
|
||||
go run ./cmd/server 后端 8080
|
||||
```
|
||||
|
||||
## 添加一个盘
|
||||
|
||||
1. 登录管理后台:`POST /admin/api/login` body `{"username":"admin","password":"admin123"}`
|
||||
2. 新建盘:`POST /admin/api/drives`
|
||||
```json
|
||||
{
|
||||
"id": "my-quark",
|
||||
"kind": "quark",
|
||||
"name": "我的夸克盘",
|
||||
"rootId": "0",
|
||||
"scanRootId": "0",
|
||||
"credentials": {
|
||||
"cookie": "粘贴浏览器 F12 复制的 pan.quark.cn Cookie"
|
||||
}
|
||||
}
|
||||
```
|
||||
3. 手动触发扫描:`POST /admin/api/drives/my-quark/rescan`
|
||||
|
||||
三家盘的凭证字段:
|
||||
|
||||
| kind | credentials 字段 |
|
||||
|--------|---------------------------------------------------------------|
|
||||
| quark | `cookie` |
|
||||
| p115 | `cookie`(形如 `UID=...; CID=...; SEID=...; KID=...`) |
|
||||
| wopan | `access_token`、`refresh_token`,可选 `family_id` |
|
||||
|
||||
## 文件名约定
|
||||
|
||||
扫描器按以下顺序解析文件名:
|
||||
|
||||
1. `[tag1,tag2] 标题 - 作者.mp4`
|
||||
2. `[tag1,tag2] 标题.mp4`
|
||||
3. `标题 - 作者.mp4`
|
||||
4. `标题.mp4`
|
||||
|
||||
标签分隔符支持 `, , 、` 和空格。解析结果可在管理后台覆盖。
|
||||
|
||||
## Teaser 生成
|
||||
|
||||
scanner 扫到新视频会把 `(driveID, videoID)` 丢进 worker 队列,调用:
|
||||
|
||||
```
|
||||
ffmpeg -ss 10 -headers "UA/Cookie/Referer" -i <直链> \
|
||||
-t 10 -an -vf scale=480:-2 -c:v libx264 -preset veryfast -crf 28 \
|
||||
-movflags +faststart -y <local>.mp4
|
||||
```
|
||||
|
||||
优先把 teaser 上传回网盘的 `previews/` 目录;失败时保留本地 `data/previews/<videoID>.mp4` 作为兜底。
|
||||
|
||||
前端卡片的 `previewSrc` 统一指向 `/p/preview/<videoID>`,后端自动选择网盘代理或本地文件。
|
||||
|
||||
## 部署到 Linux
|
||||
|
||||
```bash
|
||||
# 交叉编译
|
||||
GOOS=linux GOARCH=amd64 go build -o video-server ./cmd/server
|
||||
|
||||
# 目标机
|
||||
sudo apt install ffmpeg
|
||||
scp video-server user@host:/opt/video-site/
|
||||
ssh user@host
|
||||
cd /opt/video-site
|
||||
cp config.example.yaml config.yaml
|
||||
# 改密码、监听地址
|
||||
./video-server
|
||||
```
|
||||
|
||||
配 systemd + nginx 反代到 `/` 和 `/api`、`/p`、`/admin`。
|
||||
# backend
|
||||
|
||||
视频聚合站的 Go 后端。提供三件事:
|
||||
|
||||
1. 多家网盘统一抽象(夸克 / 115 / PikPak / 联通沃盘)
|
||||
2. 视频元数据目录(SQLite)+ 扫描 + teaser 预生成
|
||||
3. REST API(前台)+ 管理后台 + 直链代理
|
||||
|
||||
## 目录
|
||||
|
||||
```
|
||||
cmd/server/main.go 入口
|
||||
internal/
|
||||
config/ YAML 配置
|
||||
catalog/ SQLite 元数据
|
||||
drives/
|
||||
iface.go Drive 接口
|
||||
quark/ 夸克(自己实现,参考 OpenList quark_uc)
|
||||
p115/ 115(壳子 + SheltonZhu/115driver)
|
||||
pikpak/ PikPak(自己实现,参考 OpenList pikpak)
|
||||
wopan/ 联通沃盘(壳子 + OpenListTeam/wopan-sdk-go)
|
||||
scanner/ 扫目录 → 落库
|
||||
preview/ ffmpeg 抽 10s teaser
|
||||
proxy/ /p/stream/*、/p/preview/* 代理
|
||||
auth/ 管理员 session
|
||||
api/ REST 路由
|
||||
config.example.yaml 配置模板
|
||||
```
|
||||
|
||||
## 开发环境(Windows)
|
||||
|
||||
本仓库假设工具都装在用户目录,不需要管理员权限。
|
||||
|
||||
```
|
||||
C:\Users\<you>\tools\
|
||||
go\bin\go.exe Go 1.23+
|
||||
ffmpeg\bin\ffmpeg.exe 任意 ≥ 4.x 版本
|
||||
```
|
||||
|
||||
并加到 `PATH`。
|
||||
|
||||
### 第一次启动
|
||||
|
||||
```powershell
|
||||
cd F:\VideoProject\backend
|
||||
go mod tidy
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
首次启动会在当前目录创建:
|
||||
|
||||
- `config.yaml`(从 `config.example.yaml` 复制)
|
||||
- `data/video-site.db`
|
||||
- `data/previews/`
|
||||
|
||||
默认监听 `:8080`,默认管理员 `admin / admin123`(务必在 `config.yaml` 里改)。
|
||||
|
||||
### 连接前端
|
||||
|
||||
`vite.config.ts` 已经把 `/api`、`/p`、`/admin` 代理到 `8080`。
|
||||
|
||||
```
|
||||
npm run dev 前端 5173
|
||||
go run ./cmd/server 后端 8080
|
||||
```
|
||||
|
||||
## 添加一个盘
|
||||
|
||||
1. 登录管理后台:`POST /admin/api/login` body `{"username":"admin","password":"admin123"}`
|
||||
2. 新建盘:`POST /admin/api/drives`
|
||||
```json
|
||||
{
|
||||
"id": "my-quark",
|
||||
"kind": "quark",
|
||||
"name": "我的夸克盘",
|
||||
"rootId": "0",
|
||||
"scanRootId": "0",
|
||||
"credentials": {
|
||||
"cookie": "粘贴浏览器 F12 复制的 pan.quark.cn Cookie"
|
||||
}
|
||||
}
|
||||
```
|
||||
3. 手动触发扫描:`POST /admin/api/drives/my-quark/rescan`
|
||||
|
||||
各网盘的凭证字段:
|
||||
|
||||
| kind | credentials 字段 |
|
||||
|--------|---------------------------------------------------------------|
|
||||
| quark | `cookie` |
|
||||
| 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` |
|
||||
|
||||
## 文件名约定
|
||||
|
||||
扫描器按以下顺序解析文件名:
|
||||
|
||||
1. `[tag1,tag2] 标题 - 作者.mp4`
|
||||
2. `[tag1,tag2] 标题.mp4`
|
||||
3. `标题 - 作者.mp4`
|
||||
4. `标题.mp4`
|
||||
|
||||
标签分隔符支持 `, , 、` 和空格。解析结果可在管理后台覆盖。
|
||||
|
||||
## Teaser 生成
|
||||
|
||||
scanner 扫到新视频会把 `(driveID, videoID)` 丢进 worker 队列,调用:
|
||||
|
||||
```
|
||||
ffmpeg -ss 10 -headers "UA/Cookie/Referer" -i <直链> \
|
||||
-t 10 -an -vf scale=480:-2 -c:v libx264 -preset veryfast -crf 28 \
|
||||
-movflags +faststart -y <local>.mp4
|
||||
```
|
||||
|
||||
优先把 teaser 上传回网盘的 `previews/` 目录;失败时保留本地 `data/previews/<videoID>.mp4` 作为兜底。
|
||||
|
||||
前端卡片的 `previewSrc` 统一指向 `/p/preview/<videoID>`,后端自动选择网盘代理或本地文件。
|
||||
|
||||
## 部署到 Linux
|
||||
|
||||
```bash
|
||||
# 交叉编译
|
||||
GOOS=linux GOARCH=amd64 go build -o video-server ./cmd/server
|
||||
|
||||
# 目标机
|
||||
sudo apt install ffmpeg
|
||||
scp video-server user@host:/opt/video-site/
|
||||
ssh user@host
|
||||
cd /opt/video-site
|
||||
cp config.example.yaml config.yaml
|
||||
# 改密码、监听地址
|
||||
./video-server
|
||||
```
|
||||
|
||||
配 systemd + nginx 反代到 `/` 和 `/api`、`/p`、`/admin`。
|
||||
|
||||
+477
-425
@@ -1,425 +1,477 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/video-site/backend/internal/api"
|
||||
"github.com/video-site/backend/internal/auth"
|
||||
"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/p115"
|
||||
"github.com/video-site/backend/internal/drives/quark"
|
||||
"github.com/video-site/backend/internal/drives/wopan"
|
||||
"github.com/video-site/backend/internal/preview"
|
||||
"github.com/video-site/backend/internal/proxy"
|
||||
"github.com/video-site/backend/internal/scanner"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfgPath := "./config.yaml"
|
||||
if v := os.Getenv("VIDEO_CONFIG"); v != "" {
|
||||
cfgPath = v
|
||||
}
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(cfg.Storage.DBPath), 0o755); err != nil {
|
||||
log.Fatalf("mkdir db dir: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(cfg.Storage.LocalPreviewDir, 0o755); err != nil {
|
||||
log.Fatalf("mkdir preview dir: %v", err)
|
||||
}
|
||||
|
||||
cat, err := catalog.Open(cfg.Storage.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open catalog: %v", err)
|
||||
}
|
||||
defer cat.Close()
|
||||
|
||||
app := &App{
|
||||
cfg: cfg,
|
||||
cat: cat,
|
||||
registry: proxy.NewRegistry(),
|
||||
workers: make(map[string]*preview.Worker),
|
||||
}
|
||||
app.proxy = proxy.New(app.registry)
|
||||
|
||||
// 初始化现有 drives
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
app.loadPreviewEnabled(ctx)
|
||||
|
||||
existing, err := cat.ListDrives(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("list drives: %v", err)
|
||||
}
|
||||
for _, d := range existing {
|
||||
if err := app.attachDrive(ctx, d); err != nil {
|
||||
log.Printf("[drive %s] attach failed: %v", d.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
authr := &auth.Authenticator{
|
||||
Username: cfg.Server.Admin.Username,
|
||||
Password: cfg.Server.Admin.Password,
|
||||
Catalog: cat,
|
||||
}
|
||||
|
||||
apiServer := &api.Server{
|
||||
Catalog: cat,
|
||||
Proxy: app.proxy,
|
||||
LocalDir: cfg.Storage.LocalPreviewDir,
|
||||
}
|
||||
|
||||
adminServer := &api.AdminServer{
|
||||
Catalog: cat,
|
||||
Auth: authr,
|
||||
OnDriveSaved: func(driveID string) error {
|
||||
d, err := cat.GetDrive(ctx, driveID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return app.attachDrive(ctx, d)
|
||||
},
|
||||
OnDriveRemoved: func(driveID string) {
|
||||
app.detachDrive(driveID)
|
||||
},
|
||||
OnScanRequested: func(driveID string) {
|
||||
go app.runScan(ctx, driveID)
|
||||
},
|
||||
OnRegenPreview: func(videoID string) {
|
||||
go app.regenPreview(ctx, videoID)
|
||||
},
|
||||
GetPreviewEnabled: func() bool { return app.PreviewEnabled() },
|
||||
SetPreviewEnabled: func(enabled bool) error {
|
||||
return app.SetPreviewEnabled(ctx, enabled)
|
||||
},
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(corsMiddleware)
|
||||
|
||||
apiServer.RegisterRoutes(r, authr)
|
||||
adminServer.Register(r)
|
||||
|
||||
// 启动定时扫描
|
||||
go app.scanLoop(ctx)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Server.Listen,
|
||||
Handler: r,
|
||||
}
|
||||
go func() {
|
||||
log.Printf("video-site backend listening on %s", cfg.Server.Listen)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 等待退出信号
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigs
|
||||
log.Println("shutting down...")
|
||||
shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer shutCancel()
|
||||
_ = srv.Shutdown(shutCtx)
|
||||
}
|
||||
|
||||
// ---------- App ----------
|
||||
|
||||
type App struct {
|
||||
cfg *config.Config
|
||||
cat *catalog.Catalog
|
||||
registry *proxy.Registry
|
||||
proxy *proxy.Proxy
|
||||
|
||||
mu sync.Mutex
|
||||
workers map[string]*preview.Worker
|
||||
cancels map[string]context.CancelFunc
|
||||
|
||||
// 运行时 preview 开关(从 DB 读)
|
||||
previewEnabled bool
|
||||
}
|
||||
|
||||
// PreviewEnabled 线程安全读
|
||||
func (a *App) PreviewEnabled() bool {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.previewEnabled
|
||||
}
|
||||
|
||||
// SetPreviewEnabled 切换开关,写库 + 若开启则立刻补扫 pending
|
||||
func (a *App) SetPreviewEnabled(ctx context.Context, enabled bool) error {
|
||||
a.mu.Lock()
|
||||
a.previewEnabled = enabled
|
||||
a.mu.Unlock()
|
||||
|
||||
val := "0"
|
||||
if enabled {
|
||||
val = "1"
|
||||
}
|
||||
if err := a.cat.SetSetting(ctx, "preview.enabled", val); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if enabled {
|
||||
// 异步补扫所有盘
|
||||
go func() {
|
||||
for _, d := range a.registry.All() {
|
||||
a.mu.Lock()
|
||||
w := a.workers[d.ID()]
|
||||
a.mu.Unlock()
|
||||
if w != nil {
|
||||
a.enqueuePending(ctx, d.ID(), w)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadPreviewEnabled 从 DB 读运行时开关,首次启动取 config 默认值
|
||||
func (a *App) loadPreviewEnabled(ctx context.Context) {
|
||||
def := "0"
|
||||
if a.cfg.Preview.Enabled {
|
||||
def = "1"
|
||||
}
|
||||
v, err := a.cat.GetSetting(ctx, "preview.enabled", def)
|
||||
if err != nil {
|
||||
log.Printf("[preview] load setting: %v (fallback to config)", err)
|
||||
a.mu.Lock()
|
||||
a.previewEnabled = a.cfg.Preview.Enabled
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.previewEnabled = v == "1"
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *App) attachDrive(ctx context.Context, d *catalog.Drive) error {
|
||||
var drv drives.Drive
|
||||
switch d.Kind {
|
||||
case "quark":
|
||||
drv = quark.New(quark.Config{
|
||||
ID: d.ID,
|
||||
Cookie: d.Credentials["cookie"],
|
||||
RootID: d.RootID,
|
||||
OnCookieUpdate: func(cookie string) {
|
||||
d.Credentials["cookie"] = cookie
|
||||
_ = a.cat.UpsertDrive(ctx, d)
|
||||
},
|
||||
})
|
||||
case "p115":
|
||||
drv = p115.New(p115.Config{
|
||||
ID: d.ID,
|
||||
Cookie: d.Credentials["cookie"],
|
||||
RootID: d.RootID,
|
||||
})
|
||||
case "wopan":
|
||||
drv = wopan.New(wopan.Config{
|
||||
ID: d.ID,
|
||||
AccessToken: d.Credentials["access_token"],
|
||||
RefreshToken: d.Credentials["refresh_token"],
|
||||
FamilyID: d.Credentials["family_id"],
|
||||
RootID: d.RootID,
|
||||
OnTokenUpdate: func(access, refresh 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)
|
||||
}
|
||||
|
||||
if err := drv.Init(ctx); err != nil {
|
||||
d.Status = "error"
|
||||
d.LastError = err.Error()
|
||||
_ = a.cat.UpsertDrive(ctx, d)
|
||||
return err
|
||||
}
|
||||
|
||||
d.Status = "ok"
|
||||
d.LastError = ""
|
||||
_ = a.cat.UpsertDrive(ctx, d)
|
||||
|
||||
a.registry.Set(d.ID, drv)
|
||||
|
||||
// preview worker
|
||||
gen := preview.New(preview.Config{
|
||||
FFmpegPath: a.cfg.Preview.FFmpegPath,
|
||||
FFprobePath: a.cfg.Preview.FFprobePath,
|
||||
DurationSeconds: a.cfg.Preview.DurationSeconds,
|
||||
Width: a.cfg.Preview.Width,
|
||||
Segments: a.cfg.Preview.Segments,
|
||||
LocalDir: a.cfg.Storage.LocalPreviewDir,
|
||||
RemoteDir: a.cfg.Preview.RemoteDir,
|
||||
})
|
||||
worker := preview.NewWorker(gen, a.cat, drv, a.cfg.Preview.RemoteDir)
|
||||
|
||||
workerCtx, cancel := context.WithCancel(ctx)
|
||||
go worker.Run(workerCtx)
|
||||
|
||||
a.mu.Lock()
|
||||
if a.cancels == nil {
|
||||
a.cancels = make(map[string]context.CancelFunc)
|
||||
}
|
||||
if old, ok := a.cancels[d.ID]; ok {
|
||||
old()
|
||||
}
|
||||
a.workers[d.ID] = worker
|
||||
a.cancels[d.ID] = cancel
|
||||
a.mu.Unlock()
|
||||
|
||||
// 启动补扫:把这个盘下所有 pending 的视频塞进 worker 队列
|
||||
// 使用 goroutine 因为队列可能比预期的小,Enqueue 直接丢弃,调用方也无需等待
|
||||
if a.PreviewEnabled() {
|
||||
go a.enqueuePending(workerCtx, d.ID, worker)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) enqueuePending(ctx context.Context, driveID string, w *preview.Worker) {
|
||||
pending, err := a.cat.ListVideosByPreviewStatus(ctx, driveID, "pending", 0)
|
||||
if err != nil {
|
||||
log.Printf("[preview] list pending %s: %v", driveID, err)
|
||||
return
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
log.Printf("[preview] enqueue %d pending videos for drive=%s", len(pending), driveID)
|
||||
for _, v := range pending {
|
||||
w.Enqueue(v)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) detachDrive(id string) {
|
||||
a.registry.Remove(id)
|
||||
a.mu.Lock()
|
||||
if cancel, ok := a.cancels[id]; ok {
|
||||
cancel()
|
||||
delete(a.cancels, id)
|
||||
}
|
||||
delete(a.workers, id)
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *App) runScan(ctx context.Context, driveID string) {
|
||||
drv, ok := a.registry.Get(driveID)
|
||||
if !ok {
|
||||
log.Printf("[scan] drive %s not attached", driveID)
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
worker := a.workers[driveID]
|
||||
a.mu.Unlock()
|
||||
|
||||
var onNew func(v *catalog.Video)
|
||||
if a.PreviewEnabled() && worker != nil {
|
||||
onNew = worker.Enqueue
|
||||
}
|
||||
|
||||
sc := scanner.New(a.cat, drv, a.cfg.Scanner.VideoExtensions, a.cfg.Scanner.MaxDepth, onNew)
|
||||
|
||||
// 使用 drive 的 scan_root_id,否则 root_id
|
||||
d, err := a.cat.GetDrive(ctx, driveID)
|
||||
if err != nil {
|
||||
log.Printf("[scan] get drive %s: %v", driveID, err)
|
||||
return
|
||||
}
|
||||
startID := d.ScanRootID
|
||||
if startID == "" {
|
||||
startID = d.RootID
|
||||
}
|
||||
|
||||
log.Printf("[scan] drive=%s start=%s", driveID, startID)
|
||||
stats, err := sc.Run(ctx, startID)
|
||||
if err != nil {
|
||||
log.Printf("[scan] drive=%s error: %v", driveID, err)
|
||||
return
|
||||
}
|
||||
log.Printf("[scan] drive=%s done scanned=%d added=%d", driveID, stats.Scanned, stats.Added)
|
||||
}
|
||||
|
||||
func (a *App) regenPreview(ctx context.Context, videoID string) {
|
||||
v, err := a.cat.GetVideo(ctx, videoID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
worker := a.workers[v.DriveID]
|
||||
a.mu.Unlock()
|
||||
if worker != nil {
|
||||
worker.Enqueue(v)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) scanLoop(ctx context.Context) {
|
||||
// 启动后立刻扫一次
|
||||
a.scanAllOnce(ctx)
|
||||
|
||||
if a.cfg.Scanner.IntervalSeconds <= 0 {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(time.Duration(a.cfg.Scanner.IntervalSeconds) * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
a.scanAllOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) scanAllOnce(ctx context.Context) {
|
||||
for _, d := range a.registry.All() {
|
||||
a.runScan(ctx, d.ID())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- middleware ----------
|
||||
|
||||
func corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", originOr(r, "*"))
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func originOr(r *http.Request, fallback string) string {
|
||||
if o := r.Header.Get("Origin"); o != "" {
|
||||
return o
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/video-site/backend/internal/api"
|
||||
"github.com/video-site/backend/internal/auth"
|
||||
"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/p115"
|
||||
"github.com/video-site/backend/internal/drives/pikpak"
|
||||
"github.com/video-site/backend/internal/drives/quark"
|
||||
"github.com/video-site/backend/internal/drives/wopan"
|
||||
"github.com/video-site/backend/internal/preview"
|
||||
"github.com/video-site/backend/internal/proxy"
|
||||
"github.com/video-site/backend/internal/scanner"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfgPath := "./config.yaml"
|
||||
if v := os.Getenv("VIDEO_CONFIG"); v != "" {
|
||||
cfgPath = v
|
||||
}
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(cfg.Storage.DBPath), 0o755); err != nil {
|
||||
log.Fatalf("mkdir db dir: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(cfg.Storage.LocalPreviewDir, 0o755); err != nil {
|
||||
log.Fatalf("mkdir preview dir: %v", err)
|
||||
}
|
||||
|
||||
cat, err := catalog.Open(cfg.Storage.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open catalog: %v", err)
|
||||
}
|
||||
defer cat.Close()
|
||||
|
||||
app := &App{
|
||||
cfg: cfg,
|
||||
cat: cat,
|
||||
registry: proxy.NewRegistry(),
|
||||
workers: make(map[string]*preview.Worker),
|
||||
thumbWorkers: make(map[string]*preview.ThumbWorker),
|
||||
}
|
||||
app.proxy = proxy.New(app.registry)
|
||||
|
||||
// 初始化现有 drives
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
app.loadPreviewEnabled(ctx)
|
||||
|
||||
existing, err := cat.ListDrives(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("list drives: %v", err)
|
||||
}
|
||||
for _, d := range existing {
|
||||
if err := app.attachDrive(ctx, d); err != nil {
|
||||
log.Printf("[drive %s] attach failed: %v", d.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
authr := &auth.Authenticator{
|
||||
Username: cfg.Server.Admin.Username,
|
||||
Password: cfg.Server.Admin.Password,
|
||||
Catalog: cat,
|
||||
}
|
||||
|
||||
apiServer := &api.Server{
|
||||
Catalog: cat,
|
||||
Proxy: app.proxy,
|
||||
LocalDir: cfg.Storage.LocalPreviewDir,
|
||||
FFmpegPath: cfg.Preview.FFmpegPath,
|
||||
}
|
||||
|
||||
adminServer := &api.AdminServer{
|
||||
Catalog: cat,
|
||||
Auth: authr,
|
||||
OnDriveSaved: func(driveID string) error {
|
||||
d, err := cat.GetDrive(ctx, driveID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return app.attachDrive(ctx, d)
|
||||
},
|
||||
OnDriveRemoved: func(driveID string) {
|
||||
app.detachDrive(driveID)
|
||||
},
|
||||
OnScanRequested: func(driveID string) {
|
||||
go app.runScan(ctx, driveID)
|
||||
},
|
||||
OnRegenPreview: func(videoID string) {
|
||||
go app.regenPreview(ctx, videoID)
|
||||
},
|
||||
GetPreviewEnabled: func() bool { return app.PreviewEnabled() },
|
||||
SetPreviewEnabled: func(enabled bool) error {
|
||||
return app.SetPreviewEnabled(ctx, enabled)
|
||||
},
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(corsMiddleware)
|
||||
|
||||
apiServer.RegisterRoutes(r, authr)
|
||||
adminServer.Register(r)
|
||||
|
||||
// 启动定时扫描
|
||||
go app.scanLoop(ctx)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Server.Listen,
|
||||
Handler: r,
|
||||
}
|
||||
go func() {
|
||||
log.Printf("video-site backend listening on %s", cfg.Server.Listen)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 等待退出信号
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigs
|
||||
log.Println("shutting down...")
|
||||
shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer shutCancel()
|
||||
_ = srv.Shutdown(shutCtx)
|
||||
}
|
||||
|
||||
// ---------- App ----------
|
||||
|
||||
type App struct {
|
||||
cfg *config.Config
|
||||
cat *catalog.Catalog
|
||||
registry *proxy.Registry
|
||||
proxy *proxy.Proxy
|
||||
|
||||
mu sync.Mutex
|
||||
workers map[string]*preview.Worker
|
||||
thumbWorkers map[string]*preview.ThumbWorker
|
||||
cancels map[string]context.CancelFunc
|
||||
|
||||
// 运行时 preview 开关(从 DB 读)
|
||||
previewEnabled bool
|
||||
}
|
||||
|
||||
// PreviewEnabled 线程安全读
|
||||
func (a *App) PreviewEnabled() bool {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.previewEnabled
|
||||
}
|
||||
|
||||
// SetPreviewEnabled 切换开关,写库 + 若开启则立刻补扫 pending
|
||||
func (a *App) SetPreviewEnabled(ctx context.Context, enabled bool) error {
|
||||
a.mu.Lock()
|
||||
a.previewEnabled = enabled
|
||||
a.mu.Unlock()
|
||||
|
||||
val := "0"
|
||||
if enabled {
|
||||
val = "1"
|
||||
}
|
||||
if err := a.cat.SetSetting(ctx, "preview.enabled", val); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if enabled {
|
||||
// 异步补扫所有盘
|
||||
go func() {
|
||||
for _, d := range a.registry.All() {
|
||||
a.mu.Lock()
|
||||
w := a.workers[d.ID()]
|
||||
a.mu.Unlock()
|
||||
if w != nil {
|
||||
a.enqueuePending(ctx, d.ID(), w)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadPreviewEnabled 从 DB 读运行时开关,首次启动取 config 默认值
|
||||
func (a *App) loadPreviewEnabled(ctx context.Context) {
|
||||
def := "0"
|
||||
if a.cfg.Preview.Enabled {
|
||||
def = "1"
|
||||
}
|
||||
v, err := a.cat.GetSetting(ctx, "preview.enabled", def)
|
||||
if err != nil {
|
||||
log.Printf("[preview] load setting: %v (fallback to config)", err)
|
||||
a.mu.Lock()
|
||||
a.previewEnabled = a.cfg.Preview.Enabled
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.previewEnabled = v == "1"
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *App) attachDrive(ctx context.Context, d *catalog.Drive) error {
|
||||
var drv drives.Drive
|
||||
switch d.Kind {
|
||||
case "quark":
|
||||
drv = quark.New(quark.Config{
|
||||
ID: d.ID,
|
||||
Cookie: d.Credentials["cookie"],
|
||||
RootID: d.RootID,
|
||||
OnCookieUpdate: func(cookie string) {
|
||||
d.Credentials["cookie"] = cookie
|
||||
_ = a.cat.UpsertDrive(ctx, d)
|
||||
},
|
||||
})
|
||||
case "p115":
|
||||
drv = p115.New(p115.Config{
|
||||
ID: d.ID,
|
||||
Cookie: d.Credentials["cookie"],
|
||||
RootID: d.RootID,
|
||||
})
|
||||
case "pikpak":
|
||||
drv = pikpak.New(pikpak.Config{
|
||||
ID: d.ID,
|
||||
Username: d.Credentials["username"],
|
||||
Password: d.Credentials["password"],
|
||||
Platform: d.Credentials["platform"],
|
||||
RefreshToken: d.Credentials["refresh_token"],
|
||||
AccessToken: d.Credentials["access_token"],
|
||||
CaptchaToken: d.Credentials["captcha_token"],
|
||||
DeviceID: d.Credentials["device_id"],
|
||||
RootID: d.RootID,
|
||||
DisableMediaLink: pikpak.ParseBoolDefault(d.Credentials["disable_media_link"], true),
|
||||
OnTokenUpdate: func(access, refresh, captcha, deviceID string) {
|
||||
d.Credentials["access_token"] = access
|
||||
d.Credentials["refresh_token"] = refresh
|
||||
d.Credentials["captcha_token"] = captcha
|
||||
d.Credentials["device_id"] = deviceID
|
||||
_ = a.cat.UpsertDrive(ctx, d)
|
||||
},
|
||||
})
|
||||
case "wopan":
|
||||
drv = wopan.New(wopan.Config{
|
||||
ID: d.ID,
|
||||
AccessToken: d.Credentials["access_token"],
|
||||
RefreshToken: d.Credentials["refresh_token"],
|
||||
FamilyID: d.Credentials["family_id"],
|
||||
RootID: d.RootID,
|
||||
OnTokenUpdate: func(access, refresh 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)
|
||||
}
|
||||
|
||||
if err := drv.Init(ctx); err != nil {
|
||||
d.Status = "error"
|
||||
d.LastError = err.Error()
|
||||
_ = a.cat.UpsertDrive(ctx, d)
|
||||
return err
|
||||
}
|
||||
|
||||
d.Status = "ok"
|
||||
d.LastError = ""
|
||||
_ = a.cat.UpsertDrive(ctx, d)
|
||||
|
||||
a.registry.Set(d.ID, drv)
|
||||
|
||||
// preview worker
|
||||
gen := preview.New(preview.Config{
|
||||
FFmpegPath: a.cfg.Preview.FFmpegPath,
|
||||
FFprobePath: a.cfg.Preview.FFprobePath,
|
||||
DurationSeconds: a.cfg.Preview.DurationSeconds,
|
||||
Width: a.cfg.Preview.Width,
|
||||
Segments: a.cfg.Preview.Segments,
|
||||
LocalDir: a.cfg.Storage.LocalPreviewDir,
|
||||
RemoteDir: a.cfg.Preview.RemoteDir,
|
||||
})
|
||||
worker := preview.NewWorker(gen, a.cat, drv, a.cfg.Preview.RemoteDir)
|
||||
thumbWorker := preview.NewThumbWorker(gen, a.cat, drv)
|
||||
|
||||
workerCtx, cancel := context.WithCancel(ctx)
|
||||
go worker.Run(workerCtx)
|
||||
go thumbWorker.Run(workerCtx)
|
||||
|
||||
a.mu.Lock()
|
||||
if a.cancels == nil {
|
||||
a.cancels = make(map[string]context.CancelFunc)
|
||||
}
|
||||
if old, ok := a.cancels[d.ID]; ok {
|
||||
old()
|
||||
}
|
||||
a.workers[d.ID] = worker
|
||||
a.thumbWorkers[d.ID] = thumbWorker
|
||||
a.cancels[d.ID] = cancel
|
||||
a.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) enqueuePending(ctx context.Context, driveID string, w *preview.Worker) {
|
||||
pending, err := a.cat.ListVideosByPreviewStatus(ctx, driveID, "pending", 0)
|
||||
if err != nil {
|
||||
log.Printf("[preview] list pending %s: %v", driveID, err)
|
||||
return
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
log.Printf("[preview] enqueue %d pending videos for drive=%s", len(pending), driveID)
|
||||
for _, v := range pending {
|
||||
w.Enqueue(v)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) enqueueThumbnails(ctx context.Context, driveID string, w *preview.ThumbWorker) {
|
||||
pending, err := a.cat.ListVideosNeedingThumbnail(ctx, driveID, 0)
|
||||
if err != nil {
|
||||
log.Printf("[thumb] list pending %s: %v", driveID, err)
|
||||
return
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
log.Printf("[thumb] enqueue %d missing thumbnails for drive=%s", len(pending), driveID)
|
||||
for _, v := range pending {
|
||||
w.Enqueue(v)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) detachDrive(id string) {
|
||||
a.registry.Remove(id)
|
||||
a.mu.Lock()
|
||||
if cancel, ok := a.cancels[id]; ok {
|
||||
cancel()
|
||||
delete(a.cancels, id)
|
||||
}
|
||||
delete(a.workers, id)
|
||||
delete(a.thumbWorkers, id)
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *App) runScan(ctx context.Context, driveID string) {
|
||||
drv, ok := a.registry.Get(driveID)
|
||||
if !ok {
|
||||
log.Printf("[scan] drive %s not attached", driveID)
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
worker := a.workers[driveID]
|
||||
thumbWorker := a.thumbWorkers[driveID]
|
||||
a.mu.Unlock()
|
||||
|
||||
var onNew func(v *catalog.Video)
|
||||
if thumbWorker != nil || (a.PreviewEnabled() && worker != nil) {
|
||||
onNew = func(v *catalog.Video) {
|
||||
if thumbWorker != nil && v.ThumbnailURL == "" {
|
||||
thumbWorker.Enqueue(v)
|
||||
}
|
||||
if a.PreviewEnabled() && worker != nil {
|
||||
worker.Enqueue(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sc := scanner.New(a.cat, drv, a.cfg.Scanner.VideoExtensions, a.cfg.Scanner.MaxDepth, onNew)
|
||||
|
||||
// 使用 drive 的 scan_root_id,否则 root_id
|
||||
d, err := a.cat.GetDrive(ctx, driveID)
|
||||
if err != nil {
|
||||
log.Printf("[scan] get drive %s: %v", driveID, err)
|
||||
return
|
||||
}
|
||||
startID := d.ScanRootID
|
||||
if startID == "" {
|
||||
startID = d.RootID
|
||||
}
|
||||
|
||||
log.Printf("[scan] drive=%s start=%s", driveID, startID)
|
||||
stats, err := sc.Run(ctx, startID)
|
||||
if err != nil {
|
||||
log.Printf("[scan] drive=%s error: %v", driveID, err)
|
||||
return
|
||||
}
|
||||
log.Printf("[scan] drive=%s done scanned=%d added=%d", driveID, stats.Scanned, stats.Added)
|
||||
if thumbWorker != nil {
|
||||
a.enqueueThumbnails(ctx, driveID, thumbWorker)
|
||||
}
|
||||
if a.PreviewEnabled() && worker != nil {
|
||||
go a.enqueuePending(ctx, driveID, worker)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) regenPreview(ctx context.Context, videoID string) {
|
||||
v, err := a.cat.GetVideo(ctx, videoID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
worker := a.workers[v.DriveID]
|
||||
a.mu.Unlock()
|
||||
if worker != nil {
|
||||
worker.Enqueue(v)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) scanLoop(ctx context.Context) {
|
||||
// 启动后立刻扫一次
|
||||
a.scanAllOnce(ctx)
|
||||
|
||||
if a.cfg.Scanner.IntervalSeconds <= 0 {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(time.Duration(a.cfg.Scanner.IntervalSeconds) * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
a.scanAllOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) scanAllOnce(ctx context.Context) {
|
||||
for _, d := range a.registry.All() {
|
||||
a.runScan(ctx, d.ID())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- middleware ----------
|
||||
|
||||
func corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", originOr(r, "*"))
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func originOr(r *http.Request, fallback string) string {
|
||||
if o := r.Header.Get("Origin"); o != "" {
|
||||
return o
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
+42
-41
@@ -1,41 +1,42 @@
|
||||
# backend 配置示例。首次启动若未发现 config.yaml,会基于此文件自动创建。
|
||||
server:
|
||||
listen: ":8080"
|
||||
# 管理后台用户,生产环境请务必修改
|
||||
admin:
|
||||
username: "admin"
|
||||
password: "admin123"
|
||||
# 用于签发 admin session cookie,生产请改成随机字符串
|
||||
session_secret: "change-me-to-a-random-string"
|
||||
|
||||
storage:
|
||||
# SQLite 数据库文件路径
|
||||
db_path: "./data/video-site.db"
|
||||
# 本地 teaser 兜底目录(网盘写入失败时使用)
|
||||
local_preview_dir: "./data/previews"
|
||||
|
||||
scanner:
|
||||
# 扫描间隔(秒),0 表示只启动时扫一次
|
||||
interval_seconds: 21600
|
||||
# 单次扫描每家网盘目录递归层数上限
|
||||
max_depth: 5
|
||||
# 被扫描的扩展名
|
||||
video_extensions: [".mp4", ".mkv", ".mov", ".webm", ".avi"]
|
||||
|
||||
preview:
|
||||
# 是否启用 ffmpeg 抽帧生成 teaser
|
||||
enabled: true
|
||||
# ffmpeg / ffprobe 可执行文件名或绝对路径
|
||||
ffmpeg_path: "ffmpeg"
|
||||
ffprobe_path: "ffprobe"
|
||||
# teaser 总时长(秒)。多段模式下会均分给每段
|
||||
duration_seconds: 9
|
||||
# teaser 段数。1=从视频 25% 位置取单段;>=2 按时长自适应切段并拼接
|
||||
segments: 3
|
||||
# teaser 视频宽度
|
||||
width: 480
|
||||
# teaser 上传到网盘的目录(相对网盘根)。空值则写本地
|
||||
remote_dir: "/previews"
|
||||
|
||||
# 盘列表。上线后请通过管理后台添加,本文件可留空
|
||||
drives: []
|
||||
# backend 配置示例。首次启动若未发现 config.yaml,会基于此文件自动创建。
|
||||
server:
|
||||
listen: ":8080"
|
||||
# 管理后台用户,生产环境请务必修改
|
||||
admin:
|
||||
username: "admin"
|
||||
password: "admin123"
|
||||
# 用于签发 admin session cookie,生产请改成随机字符串
|
||||
session_secret: "change-me-to-a-random-string"
|
||||
|
||||
storage:
|
||||
# SQLite 数据库文件路径
|
||||
db_path: "./data/video-site.db"
|
||||
# 本地 teaser 兜底目录(网盘写入失败时使用)
|
||||
local_preview_dir: "./data/previews"
|
||||
|
||||
scanner:
|
||||
# 扫描间隔(秒),0 表示只启动时扫一次
|
||||
interval_seconds: 21600
|
||||
# 单次扫描每家网盘目录递归层数上限
|
||||
max_depth: 5
|
||||
# 被扫描的扩展名
|
||||
video_extensions: [".mp4", ".mkv", ".mov", ".webm", ".avi"]
|
||||
|
||||
preview:
|
||||
# 是否启用 ffmpeg 抽帧生成 teaser
|
||||
enabled: true
|
||||
# ffmpeg / ffprobe 可执行文件名或绝对路径
|
||||
ffmpeg_path: "ffmpeg"
|
||||
ffprobe_path: "ffprobe"
|
||||
# teaser 总时长(秒)。多段模式下会均分给每段
|
||||
duration_seconds: 9
|
||||
# teaser 段数。1=从视频 25% 位置取单段;>=2 按时长自适应切段并拼接
|
||||
segments: 3
|
||||
# teaser 视频宽度
|
||||
width: 480
|
||||
# teaser 上传到网盘的目录(相对网盘根)。空值则写本地
|
||||
remote_dir: "/previews"
|
||||
|
||||
# 盘列表。上线后请通过管理后台添加,本文件可留空。
|
||||
# kind 支持 quark / p115 / pikpak / wopan。
|
||||
drives: []
|
||||
|
||||
+289
-284
@@ -1,284 +1,289 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/video-site/backend/internal/auth"
|
||||
"github.com/video-site/backend/internal/catalog"
|
||||
)
|
||||
|
||||
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)
|
||||
// Preview 开关读写
|
||||
GetPreviewEnabled func() bool
|
||||
SetPreviewEnabled func(enabled bool) error
|
||||
}
|
||||
|
||||
func (a *AdminServer) Register(r chi.Router) {
|
||||
r.Route("/admin/api", func(r chi.Router) {
|
||||
// 登录、登出不需要鉴权
|
||||
r.Post("/login", a.handleLogin)
|
||||
r.Post("/logout", a.handleLogout)
|
||||
r.Get("/me", a.handleMe)
|
||||
|
||||
// 其余路由需鉴权
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.Auth.Required)
|
||||
|
||||
// 网盘
|
||||
r.Get("/drives", a.handleListDrives)
|
||||
r.Post("/drives", a.handleUpsertDrive)
|
||||
r.Delete("/drives/{id}", a.handleDeleteDrive)
|
||||
r.Post("/drives/{id}/rescan", a.handleRescan)
|
||||
|
||||
// 视频
|
||||
r.Get("/videos", a.handleAdminListVideos)
|
||||
r.Put("/videos/{id}", a.handleUpdateVideo)
|
||||
r.Post("/videos/{id}/regen-preview", a.handleRegenPreview)
|
||||
|
||||
// 运行时设置
|
||||
r.Get("/settings", a.handleGetSettings)
|
||||
r.Put("/settings", a.handlePutSettings)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
type loginReq struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var body loginReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
ok, err := a.Auth.Login(w, r, body.Username, body.Password)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
a.Auth.Logout(w, r)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie("vs_admin")
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"authenticated": false})
|
||||
return
|
||||
}
|
||||
ok, _ := a.Catalog.ValidateSession(r.Context(), c.Value)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"authenticated": ok})
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) {
|
||||
drives, err := a.Catalog.ListDrives(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"`
|
||||
}
|
||||
list := make([]out, 0, len(drives))
|
||||
for _, d := range drives {
|
||||
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,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
type upsertDriveReq struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
RootID string `json:"rootId"`
|
||||
ScanRootID string `json:"scanRootId"`
|
||||
Credentials map[string]string `json:"credentials"`
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleUpsertDrive(w http.ResponseWriter, r *http.Request) {
|
||||
var body upsertDriveReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if body.ID == "" || body.Kind == "" {
|
||||
http.Error(w, "id and kind are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
d := &catalog.Drive{
|
||||
ID: body.ID, Kind: body.Kind, Name: body.Name,
|
||||
RootID: body.RootID, ScanRootID: body.ScanRootID,
|
||||
Credentials: body.Credentials,
|
||||
Status: "disconnected",
|
||||
}
|
||||
if err := a.Catalog.UpsertDrive(r.Context(), d); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if a.OnDriveSaved != nil {
|
||||
if err := a.OnDriveSaved(body.ID); err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "warning": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleDeleteDrive(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := a.Catalog.DeleteDrive(r.Context(), id); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if a.OnDriveRemoved != nil {
|
||||
a.OnDriveRemoved(id)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleRescan(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if a.OnScanRequested != nil {
|
||||
a.OnScanRequested(id)
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleAdminListVideos(w http.ResponseWriter, r *http.Request) {
|
||||
items, total, err := a.Catalog.ListVideos(r.Context(), catalog.ListParams{
|
||||
Page: 1, PageSize: 100,
|
||||
})
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total})
|
||||
}
|
||||
|
||||
type updateVideoReq struct {
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Tags []string `json:"tags"`
|
||||
Category string `json:"category"`
|
||||
Badges []string `json:"badges"`
|
||||
Description string `json:"description"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Quality string `json:"quality"`
|
||||
DurationSec int `json:"durationSeconds"`
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleUpdateVideo(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var body updateVideoReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
v, err := a.Catalog.GetVideo(r.Context(), id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
if body.Title != "" {
|
||||
v.Title = body.Title
|
||||
}
|
||||
if body.Author != "" {
|
||||
v.Author = body.Author
|
||||
}
|
||||
if body.Tags != nil {
|
||||
v.Tags = body.Tags
|
||||
}
|
||||
if body.Category != "" {
|
||||
v.Category = body.Category
|
||||
}
|
||||
if body.Badges != nil {
|
||||
v.Badges = body.Badges
|
||||
}
|
||||
if body.Description != "" {
|
||||
v.Description = body.Description
|
||||
}
|
||||
if body.Thumbnail != "" {
|
||||
v.ThumbnailURL = body.Thumbnail
|
||||
}
|
||||
if body.Quality != "" {
|
||||
v.Quality = body.Quality
|
||||
}
|
||||
if body.DurationSec > 0 {
|
||||
v.DurationSeconds = body.DurationSec
|
||||
}
|
||||
if err := a.Catalog.UpsertVideo(r.Context(), v); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, v)
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleRegenPreview(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if a.OnRegenPreview != nil {
|
||||
a.OnRegenPreview(id)
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// ---------- Settings ----------
|
||||
|
||||
type settingsDTO struct {
|
||||
PreviewEnabled bool `json:"previewEnabled"`
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
enabled := false
|
||||
if a.GetPreviewEnabled != nil {
|
||||
enabled = a.GetPreviewEnabled()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, settingsDTO{PreviewEnabled: enabled})
|
||||
}
|
||||
|
||||
func (a *AdminServer) handlePutSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var body settingsDTO
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if a.SetPreviewEnabled != nil {
|
||||
if err := a.SetPreviewEnabled(body.PreviewEnabled); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, settingsDTO{PreviewEnabled: body.PreviewEnabled})
|
||||
}
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/video-site/backend/internal/auth"
|
||||
"github.com/video-site/backend/internal/catalog"
|
||||
)
|
||||
|
||||
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)
|
||||
// Preview 开关读写
|
||||
GetPreviewEnabled func() bool
|
||||
SetPreviewEnabled func(enabled bool) error
|
||||
}
|
||||
|
||||
func (a *AdminServer) Register(r chi.Router) {
|
||||
r.Route("/admin/api", func(r chi.Router) {
|
||||
// 登录、登出不需要鉴权
|
||||
r.Post("/login", a.handleLogin)
|
||||
r.Post("/logout", a.handleLogout)
|
||||
r.Get("/me", a.handleMe)
|
||||
|
||||
// 其余路由需鉴权
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.Auth.Required)
|
||||
|
||||
// 网盘
|
||||
r.Get("/drives", a.handleListDrives)
|
||||
r.Post("/drives", a.handleUpsertDrive)
|
||||
r.Delete("/drives/{id}", a.handleDeleteDrive)
|
||||
r.Post("/drives/{id}/rescan", a.handleRescan)
|
||||
|
||||
// 视频
|
||||
r.Get("/videos", a.handleAdminListVideos)
|
||||
r.Put("/videos/{id}", a.handleUpdateVideo)
|
||||
r.Post("/videos/{id}/regen-preview", a.handleRegenPreview)
|
||||
|
||||
// 运行时设置
|
||||
r.Get("/settings", a.handleGetSettings)
|
||||
r.Put("/settings", a.handlePutSettings)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
type loginReq struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var body loginReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
ok, err := a.Auth.Login(w, r, body.Username, body.Password)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
a.Auth.Logout(w, r)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie("vs_admin")
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"authenticated": false})
|
||||
return
|
||||
}
|
||||
ok, _ := a.Catalog.ValidateSession(r.Context(), c.Value)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"authenticated": ok})
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) {
|
||||
drives, err := a.Catalog.ListDrives(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"`
|
||||
}
|
||||
list := make([]out, 0, len(drives))
|
||||
for _, d := range drives {
|
||||
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,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
type upsertDriveReq struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
RootID string `json:"rootId"`
|
||||
ScanRootID string `json:"scanRootId"`
|
||||
Credentials map[string]string `json:"credentials"`
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleUpsertDrive(w http.ResponseWriter, r *http.Request) {
|
||||
var body upsertDriveReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if body.ID == "" || body.Kind == "" {
|
||||
http.Error(w, "id and kind are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(body.Credentials) == 0 {
|
||||
if existing, err := a.Catalog.GetDrive(r.Context(), body.ID); err == nil && len(existing.Credentials) > 0 {
|
||||
body.Credentials = existing.Credentials
|
||||
}
|
||||
}
|
||||
d := &catalog.Drive{
|
||||
ID: body.ID, Kind: body.Kind, Name: body.Name,
|
||||
RootID: body.RootID, ScanRootID: body.ScanRootID,
|
||||
Credentials: body.Credentials,
|
||||
Status: "disconnected",
|
||||
}
|
||||
if err := a.Catalog.UpsertDrive(r.Context(), d); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if a.OnDriveSaved != nil {
|
||||
if err := a.OnDriveSaved(body.ID); err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "warning": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleDeleteDrive(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := a.Catalog.DeleteDrive(r.Context(), id); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if a.OnDriveRemoved != nil {
|
||||
a.OnDriveRemoved(id)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleRescan(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if a.OnScanRequested != nil {
|
||||
a.OnScanRequested(id)
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleAdminListVideos(w http.ResponseWriter, r *http.Request) {
|
||||
items, total, err := a.Catalog.ListVideos(r.Context(), catalog.ListParams{
|
||||
Page: 1, PageSize: 100,
|
||||
})
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total})
|
||||
}
|
||||
|
||||
type updateVideoReq struct {
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Tags []string `json:"tags"`
|
||||
Category string `json:"category"`
|
||||
Badges []string `json:"badges"`
|
||||
Description string `json:"description"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Quality string `json:"quality"`
|
||||
DurationSec int `json:"durationSeconds"`
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleUpdateVideo(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var body updateVideoReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
v, err := a.Catalog.GetVideo(r.Context(), id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
if body.Title != "" {
|
||||
v.Title = body.Title
|
||||
}
|
||||
if body.Author != "" {
|
||||
v.Author = body.Author
|
||||
}
|
||||
if body.Tags != nil {
|
||||
v.Tags = body.Tags
|
||||
}
|
||||
if body.Category != "" {
|
||||
v.Category = body.Category
|
||||
}
|
||||
if body.Badges != nil {
|
||||
v.Badges = body.Badges
|
||||
}
|
||||
if body.Description != "" {
|
||||
v.Description = body.Description
|
||||
}
|
||||
if body.Thumbnail != "" {
|
||||
v.ThumbnailURL = body.Thumbnail
|
||||
}
|
||||
if body.Quality != "" {
|
||||
v.Quality = body.Quality
|
||||
}
|
||||
if body.DurationSec > 0 {
|
||||
v.DurationSeconds = body.DurationSec
|
||||
}
|
||||
if err := a.Catalog.UpsertVideo(r.Context(), v); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, v)
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleRegenPreview(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if a.OnRegenPreview != nil {
|
||||
a.OnRegenPreview(id)
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// ---------- Settings ----------
|
||||
|
||||
type settingsDTO struct {
|
||||
PreviewEnabled bool `json:"previewEnabled"`
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
enabled := false
|
||||
if a.GetPreviewEnabled != nil {
|
||||
enabled = a.GetPreviewEnabled()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, settingsDTO{PreviewEnabled: enabled})
|
||||
}
|
||||
|
||||
func (a *AdminServer) handlePutSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var body settingsDTO
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if a.SetPreviewEnabled != nil {
|
||||
if err := a.SetPreviewEnabled(body.PreviewEnabled); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, settingsDTO{PreviewEnabled: body.PreviewEnabled})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/video-site/backend/internal/catalog"
|
||||
)
|
||||
|
||||
func TestHandleUpsertDrivePreservesExistingCredentialsWhenRequestCredentialsEmpty(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
|
||||
if err != nil {
|
||||
t.Fatalf("open catalog: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := cat.Close(); err != nil {
|
||||
t.Fatalf("close catalog: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if err := cat.UpsertDrive(ctx, &catalog.Drive{
|
||||
ID: "quark-main",
|
||||
Kind: "quark",
|
||||
Name: "Old name",
|
||||
RootID: "0",
|
||||
ScanRootID: "0",
|
||||
Credentials: map[string]string{
|
||||
"cookie": "existing-cookie",
|
||||
},
|
||||
Status: "ok",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed drive: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/api/drives", strings.NewReader(`{
|
||||
"id": "quark-main",
|
||||
"kind": "quark",
|
||||
"name": "New name",
|
||||
"rootId": "0",
|
||||
"scanRootId": "scan-root",
|
||||
"credentials": {}
|
||||
}`))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
(&AdminServer{Catalog: cat}).handleUpsertDrive(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
got, err := cat.GetDrive(ctx, "quark-main")
|
||||
if err != nil {
|
||||
t.Fatalf("get drive: %v", err)
|
||||
}
|
||||
if got.Name != "New name" {
|
||||
t.Fatalf("name = %q, want New name", got.Name)
|
||||
}
|
||||
if got.ScanRootID != "scan-root" {
|
||||
t.Fatalf("scanRootId = %q, want scan-root", got.ScanRootID)
|
||||
}
|
||||
if got.Credentials["cookie"] != "existing-cookie" {
|
||||
t.Fatalf("cookie credential = %q, want existing-cookie", got.Credentials["cookie"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpsertDriveReplacesExistingCredentialsWhenProvided(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
|
||||
if err != nil {
|
||||
t.Fatalf("open catalog: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := cat.Close(); err != nil {
|
||||
t.Fatalf("close catalog: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if err := cat.UpsertDrive(ctx, &catalog.Drive{
|
||||
ID: "quark-main",
|
||||
Kind: "quark",
|
||||
Name: "Old name",
|
||||
RootID: "0",
|
||||
ScanRootID: "0",
|
||||
Credentials: map[string]string{
|
||||
"cookie": "existing-cookie",
|
||||
},
|
||||
Status: "ok",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed drive: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/api/drives", bytes.NewBufferString(`{
|
||||
"id": "quark-main",
|
||||
"kind": "quark",
|
||||
"name": "New name",
|
||||
"rootId": "0",
|
||||
"scanRootId": "0",
|
||||
"credentials": {"cookie": "new-cookie"}
|
||||
}`))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
(&AdminServer{Catalog: cat}).handleUpsertDrive(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
got, err := cat.GetDrive(ctx, "quark-main")
|
||||
if err != nil {
|
||||
t.Fatalf("get drive: %v", err)
|
||||
}
|
||||
if got.Credentials["cookie"] != "new-cookie" {
|
||||
t.Fatalf("cookie credential = %q, want new-cookie", got.Credentials["cookie"])
|
||||
}
|
||||
}
|
||||
+515
-305
@@ -1,305 +1,515 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/video-site/backend/internal/auth"
|
||||
"github.com/video-site/backend/internal/catalog"
|
||||
"github.com/video-site/backend/internal/proxy"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
Catalog *catalog.Catalog
|
||||
Proxy *proxy.Proxy
|
||||
LocalDir string
|
||||
}
|
||||
|
||||
// VideoDTO 是返回给前端的视频对象,字段名跟前端 VideoItem 对齐
|
||||
type VideoDTO struct {
|
||||
ID string `json:"id"`
|
||||
Href string `json:"href"`
|
||||
Title string `json:"title"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
PreviewSrc string `json:"previewSrc"`
|
||||
PreviewDuration int `json:"previewDuration"`
|
||||
PreviewStrategy string `json:"previewStrategy"`
|
||||
Duration string `json:"duration"`
|
||||
Badges []string `json:"badges"`
|
||||
Quality string `json:"quality,omitempty"`
|
||||
Author string `json:"author"`
|
||||
Views int `json:"views"`
|
||||
Favorites int `json:"favorites"`
|
||||
Comments int `json:"comments"`
|
||||
Likes int `json:"likes"`
|
||||
Dislikes int `json:"dislikes"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
}
|
||||
|
||||
type VideoDetailDTO struct {
|
||||
VideoDTO
|
||||
VideoSrc string `json:"videoSrc"`
|
||||
Poster string `json:"poster"`
|
||||
Description string `json:"description"`
|
||||
EmbedURL string `json:"embedUrl"`
|
||||
Points int `json:"points,omitempty"`
|
||||
AuthorProfile AuthorProfile `json:"authorProfile"`
|
||||
RelatedVideos []VideoDTO `json:"relatedVideos"`
|
||||
CommentsList []Comment `json:"commentsList"`
|
||||
}
|
||||
|
||||
type AuthorProfile struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Href string `json:"href"`
|
||||
Badges []string `json:"badges"`
|
||||
}
|
||||
|
||||
type Comment struct {
|
||||
ID string `json:"id"`
|
||||
Author string `json:"author"`
|
||||
Body string `json:"body"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Likes int `json:"likes,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterRoutes 挂载前台 REST 路由。前台接口需要登录态。
|
||||
func (s *Server) RegisterRoutes(r chi.Router, a *auth.Authenticator) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.Required)
|
||||
r.Get("/api/home", s.handleHome)
|
||||
r.Get("/api/list", s.handleList)
|
||||
r.Get("/api/video/{id}", s.handleVideoDetail)
|
||||
r.Post("/api/video/{id}/like", s.handleLike)
|
||||
r.Get("/api/tags", s.handleTags)
|
||||
|
||||
// 代理路由同样需要鉴权,防止绕过
|
||||
r.Get("/p/stream/{driveID}/{fileID}", s.handleStream)
|
||||
r.Get("/p/preview/{videoID}", s.handlePreview)
|
||||
r.Get("/p/thumb/{videoID}", s.handleThumb)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
items, _, err := s.Catalog.ListVideos(r.Context(), catalog.ListParams{
|
||||
Sort: "hot", Page: 1, PageSize: 24,
|
||||
})
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, mapVideos(items))
|
||||
}
|
||||
|
||||
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
page, _ := strconv.Atoi(q.Get("page"))
|
||||
size, _ := strconv.Atoi(q.Get("size"))
|
||||
if size <= 0 {
|
||||
size = 24
|
||||
}
|
||||
params := catalog.ListParams{
|
||||
Keyword: q.Get("q"),
|
||||
Tag: q.Get("tag"),
|
||||
Category: q.Get("cat"),
|
||||
Sort: q.Get("sort"),
|
||||
Page: page,
|
||||
PageSize: size,
|
||||
}
|
||||
items, total, err := s.Catalog.ListVideos(r.Context(), params)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": mapVideos(items),
|
||||
"total": total,
|
||||
"page": params.Page,
|
||||
"size": params.PageSize,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleVideoDetail(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
v, err := s.Catalog.GetVideo(r.Context(), id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
related, _, _ := s.Catalog.ListVideos(r.Context(), catalog.ListParams{
|
||||
Sort: "hot", Page: 1, PageSize: 8,
|
||||
})
|
||||
|
||||
detail := VideoDetailDTO{
|
||||
VideoDTO: mapVideo(v),
|
||||
VideoSrc: fmt.Sprintf("/p/stream/%s/%s", v.DriveID, v.FileID),
|
||||
Poster: v.ThumbnailURL,
|
||||
Description: v.Description,
|
||||
EmbedURL: fmt.Sprintf(`<iframe src="/embed/%s" width="640" height="360" frameborder="0" allowfullscreen></iframe>`, v.ID),
|
||||
AuthorProfile: AuthorProfile{
|
||||
ID: "author-" + v.Author,
|
||||
Name: v.Author,
|
||||
Href: "/author/" + v.Author,
|
||||
Badges: []string{},
|
||||
},
|
||||
RelatedVideos: filterVideos(mapVideos(related), v.ID),
|
||||
CommentsList: []Comment{},
|
||||
}
|
||||
writeJSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) {
|
||||
cats, err := s.Catalog.ListCategories(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
type tag struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
out := make([]tag, 0, len(cats))
|
||||
for _, c := range cats {
|
||||
out = append(out, tag{ID: c.Category, Label: c.Category, Count: c.Count})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleLike(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
likes, err := s.Catalog.IncrementLike(r.Context(), id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"likes": likes})
|
||||
}
|
||||
|
||||
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||
driveID := chi.URLParam(r, "driveID")
|
||||
fileID := chi.URLParam(r, "fileID")
|
||||
s.Proxy.ServeStream(w, r, driveID, fileID)
|
||||
}
|
||||
|
||||
func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
|
||||
videoID := chi.URLParam(r, "videoID")
|
||||
v, err := s.Catalog.GetVideo(r.Context(), videoID)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if v.PreviewStatus != "ready" {
|
||||
http.Error(w, "preview not ready", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if v.PreviewFileID != "" {
|
||||
s.Proxy.ServeStream(w, r, v.DriveID, v.PreviewFileID)
|
||||
return
|
||||
}
|
||||
if v.PreviewLocal != "" {
|
||||
if !strings.HasPrefix(filepath.Clean(v.PreviewLocal), filepath.Clean(s.LocalDir)) {
|
||||
http.Error(w, "invalid local path", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
s.Proxy.ServeLocal(w, r, v.PreviewLocal)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) handleThumb(w http.ResponseWriter, r *http.Request) {
|
||||
videoID := chi.URLParam(r, "videoID")
|
||||
// 直接读本地 thumbs 目录中 <videoID>.jpg
|
||||
path := filepath.Join(s.LocalDir, "thumbs", videoID+".jpg")
|
||||
clean := filepath.Clean(path)
|
||||
if !strings.HasPrefix(clean, filepath.Clean(s.LocalDir)) {
|
||||
http.Error(w, "invalid path", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(clean); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.Proxy.ServeLocal(w, r, clean)
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
func mapVideo(v *catalog.Video) VideoDTO {
|
||||
badges := v.Badges
|
||||
if badges == nil {
|
||||
badges = []string{}
|
||||
}
|
||||
tags := v.Tags
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
return VideoDTO{
|
||||
ID: v.ID,
|
||||
Href: "/video/" + v.ID,
|
||||
Title: v.Title,
|
||||
Thumbnail: v.ThumbnailURL,
|
||||
PreviewSrc: "/p/preview/" + v.ID,
|
||||
PreviewDuration: 10,
|
||||
PreviewStrategy: "teaser-file",
|
||||
Duration: formatDuration(v.DurationSeconds),
|
||||
Badges: badges,
|
||||
Quality: v.Quality,
|
||||
Author: v.Author,
|
||||
Views: v.Views,
|
||||
Favorites: v.Favorites,
|
||||
Comments: v.Comments,
|
||||
Likes: v.Likes,
|
||||
Dislikes: v.Dislikes,
|
||||
PublishedAt: v.PublishedAt.Format("2006-01-02"),
|
||||
Tags: tags,
|
||||
Category: v.Category,
|
||||
}
|
||||
}
|
||||
|
||||
func mapVideos(vs []*catalog.Video) []VideoDTO {
|
||||
out := make([]VideoDTO, 0, len(vs))
|
||||
for _, v := range vs {
|
||||
out = append(out, mapVideo(v))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterVideos(vs []VideoDTO, exclude string) []VideoDTO {
|
||||
out := make([]VideoDTO, 0, len(vs))
|
||||
for _, v := range vs {
|
||||
if v.ID != exclude {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func formatDuration(sec int) string {
|
||||
if sec <= 0 {
|
||||
return "00:00"
|
||||
}
|
||||
m := sec / 60
|
||||
s := sec % 60
|
||||
return fmt.Sprintf("%02d:%02d", m, s)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func writeErr(w http.ResponseWriter, code int, err error) {
|
||||
writeJSON(w, code, map[string]string{"error": err.Error()})
|
||||
}
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
Catalog *catalog.Catalog
|
||||
Proxy *proxy.Proxy
|
||||
LocalDir string
|
||||
FFmpegPath string
|
||||
|
||||
transcodeMu sync.Mutex
|
||||
transcodeJobs map[string]bool
|
||||
}
|
||||
|
||||
// VideoDTO 是返回给前端的视频对象,字段名跟前端 VideoItem 对齐
|
||||
type VideoDTO struct {
|
||||
ID string `json:"id"`
|
||||
Href string `json:"href"`
|
||||
Title string `json:"title"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
PreviewSrc string `json:"previewSrc"`
|
||||
PreviewDuration int `json:"previewDuration"`
|
||||
PreviewStrategy string `json:"previewStrategy"`
|
||||
Duration string `json:"duration"`
|
||||
Badges []string `json:"badges"`
|
||||
Quality string `json:"quality,omitempty"`
|
||||
Author string `json:"author"`
|
||||
Views int `json:"views"`
|
||||
Favorites int `json:"favorites"`
|
||||
Comments int `json:"comments"`
|
||||
Likes int `json:"likes"`
|
||||
Dislikes int `json:"dislikes"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
}
|
||||
|
||||
type VideoDetailDTO struct {
|
||||
VideoDTO
|
||||
VideoSrc string `json:"videoSrc"`
|
||||
Poster string `json:"poster"`
|
||||
Description string `json:"description"`
|
||||
EmbedURL string `json:"embedUrl"`
|
||||
Points int `json:"points,omitempty"`
|
||||
AuthorProfile AuthorProfile `json:"authorProfile"`
|
||||
RelatedVideos []VideoDTO `json:"relatedVideos"`
|
||||
CommentsList []Comment `json:"commentsList"`
|
||||
}
|
||||
|
||||
type AuthorProfile struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Href string `json:"href"`
|
||||
Badges []string `json:"badges"`
|
||||
}
|
||||
|
||||
type Comment struct {
|
||||
ID string `json:"id"`
|
||||
Author string `json:"author"`
|
||||
Body string `json:"body"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Likes int `json:"likes,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterRoutes 挂载前台 REST 路由。前台接口需要登录态。
|
||||
func (s *Server) RegisterRoutes(r chi.Router, a *auth.Authenticator) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.Required)
|
||||
r.Get("/api/home", s.handleHome)
|
||||
r.Get("/api/list", s.handleList)
|
||||
r.Get("/api/video/{id}", s.handleVideoDetail)
|
||||
r.Post("/api/video/{id}/like", s.handleLike)
|
||||
r.Get("/api/tags", s.handleTags)
|
||||
|
||||
// 代理路由同样需要鉴权,防止绕过
|
||||
r.Get("/p/stream/{driveID}/{fileID}", s.handleStream)
|
||||
r.Get("/p/transcode/{videoID}/status", s.handleTranscodeStatus)
|
||||
r.Post("/p/transcode/{videoID}/start", s.handleTranscodeStart)
|
||||
r.Get("/p/transcode/{videoID}", s.handleTranscode)
|
||||
r.Get("/p/preview/{videoID}", s.handlePreview)
|
||||
r.Get("/p/thumb/{videoID}", s.handleThumb)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
items, _, err := s.Catalog.ListVideos(r.Context(), catalog.ListParams{
|
||||
Sort: "hot", Page: 1, PageSize: 24,
|
||||
})
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, mapVideos(items))
|
||||
}
|
||||
|
||||
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
page, _ := strconv.Atoi(q.Get("page"))
|
||||
size, _ := strconv.Atoi(q.Get("size"))
|
||||
if size <= 0 {
|
||||
size = 24
|
||||
}
|
||||
params := catalog.ListParams{
|
||||
Keyword: q.Get("q"),
|
||||
Tag: q.Get("tag"),
|
||||
Category: q.Get("cat"),
|
||||
Sort: q.Get("sort"),
|
||||
Page: page,
|
||||
PageSize: size,
|
||||
}
|
||||
items, total, err := s.Catalog.ListVideos(r.Context(), params)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": mapVideos(items),
|
||||
"total": total,
|
||||
"page": params.Page,
|
||||
"size": params.PageSize,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleVideoDetail(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
v, err := s.Catalog.GetVideo(r.Context(), id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
related, _, _ := s.Catalog.ListVideos(r.Context(), catalog.ListParams{
|
||||
Sort: "hot", Page: 1, PageSize: 8,
|
||||
})
|
||||
|
||||
detail := VideoDetailDTO{
|
||||
VideoDTO: mapVideo(v),
|
||||
VideoSrc: videoSource(v),
|
||||
Poster: thumbnailURL(v),
|
||||
Description: v.Description,
|
||||
EmbedURL: fmt.Sprintf(`<iframe src="/embed/%s" width="640" height="360" frameborder="0" allowfullscreen></iframe>`, v.ID),
|
||||
AuthorProfile: AuthorProfile{
|
||||
ID: "author-" + v.Author,
|
||||
Name: v.Author,
|
||||
Href: "/author/" + v.Author,
|
||||
Badges: []string{},
|
||||
},
|
||||
RelatedVideos: filterVideos(mapVideos(related), v.ID),
|
||||
CommentsList: []Comment{},
|
||||
}
|
||||
writeJSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := s.Catalog.CountTags(r.Context(), fixedtags.Labels)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
type tag struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
out := make([]tag, 0, len(stats))
|
||||
for _, stat := range stats {
|
||||
out = append(out, tag{ID: stat.Label, Label: stat.Label, Count: stat.Count})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleLike(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
likes, err := s.Catalog.IncrementLike(r.Context(), id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"likes": likes})
|
||||
}
|
||||
|
||||
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||
driveID := chi.URLParam(r, "driveID")
|
||||
fileID := chi.URLParam(r, "fileID")
|
||||
s.Proxy.ServeStream(w, r, driveID, fileID)
|
||||
}
|
||||
|
||||
func (s *Server) handleTranscode(w http.ResponseWriter, r *http.Request) {
|
||||
videoID := chi.URLParam(r, "videoID")
|
||||
v, err := s.Catalog.GetVideo(r.Context(), videoID)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
path := s.transcodePath(v.ID)
|
||||
if s.transcodeStatus(v.ID) == "ready" {
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
w.Header().Set("Cache-Control", "private, max-age=86400")
|
||||
http.ServeFile(w, r, path)
|
||||
return
|
||||
}
|
||||
s.startTranscode(v)
|
||||
w.Header().Set("Retry-After", "3")
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": s.transcodeStatus(v.ID)})
|
||||
}
|
||||
|
||||
func (s *Server) handleTranscodeStatus(w http.ResponseWriter, r *http.Request) {
|
||||
videoID := chi.URLParam(r, "videoID")
|
||||
if _, err := s.Catalog.GetVideo(r.Context(), videoID); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": s.transcodeStatus(videoID)})
|
||||
}
|
||||
|
||||
func (s *Server) handleTranscodeStart(w http.ResponseWriter, r *http.Request) {
|
||||
videoID := chi.URLParam(r, "videoID")
|
||||
v, err := s.Catalog.GetVideo(r.Context(), videoID)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if s.transcodeStatus(v.ID) != "ready" {
|
||||
s.startTranscode(v)
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": s.transcodeStatus(v.ID)})
|
||||
}
|
||||
|
||||
func (s *Server) startTranscode(v *catalog.Video) {
|
||||
if s.transcodeStatus(v.ID) == "ready" {
|
||||
return
|
||||
}
|
||||
s.transcodeMu.Lock()
|
||||
if s.transcodeJobs == nil {
|
||||
s.transcodeJobs = make(map[string]bool)
|
||||
}
|
||||
if s.transcodeJobs[v.ID] {
|
||||
s.transcodeMu.Unlock()
|
||||
return
|
||||
}
|
||||
s.transcodeJobs[v.ID] = true
|
||||
s.transcodeMu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer s.setTranscoding(v.ID, false)
|
||||
if err := s.generateTranscode(v); err != nil {
|
||||
log.Printf("[transcode] %s: %v", v.Title, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) generateTranscode(v *catalog.Video) error {
|
||||
drv, ok := s.Proxy.Registry.Get(v.DriveID)
|
||||
if !ok {
|
||||
return fmt.Errorf("drive not found")
|
||||
}
|
||||
link, err := drv.StreamURL(context.Background(), v.FileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ffmpeg := s.FFmpegPath
|
||||
if ffmpeg == "" {
|
||||
ffmpeg = "ffmpeg"
|
||||
}
|
||||
args := []string{
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-nostdin",
|
||||
}
|
||||
if h := buildFFmpegHeaders(link.Headers); h != "" {
|
||||
args = append(args, "-headers", h)
|
||||
}
|
||||
args = append(args,
|
||||
"-i", link.URL,
|
||||
"-map", "0:v:0",
|
||||
"-map", "0:a:0?",
|
||||
"-c:v", "libx264",
|
||||
"-preset", "veryfast",
|
||||
"-tune", "zerolatency",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac",
|
||||
"-b:a", "128k",
|
||||
"-movflags", "+faststart",
|
||||
"-y",
|
||||
)
|
||||
|
||||
dst := s.transcodePath(v.ID)
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := s.transcodeTempPath(v.ID)
|
||||
_ = os.Remove(tmp)
|
||||
args = append(args, tmp)
|
||||
cmd := exec.Command(ffmpeg, args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("ffmpeg: %w, stderr: %s", err, string(out))
|
||||
}
|
||||
info, err := os.Stat(tmp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("ffmpeg produced empty file")
|
||||
}
|
||||
return os.Rename(tmp, dst)
|
||||
}
|
||||
|
||||
func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
|
||||
videoID := chi.URLParam(r, "videoID")
|
||||
v, err := s.Catalog.GetVideo(r.Context(), videoID)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if v.PreviewStatus != "ready" {
|
||||
http.Error(w, "preview not ready", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if v.PreviewFileID != "" {
|
||||
s.Proxy.ServeStream(w, r, v.DriveID, v.PreviewFileID)
|
||||
return
|
||||
}
|
||||
if v.PreviewLocal != "" {
|
||||
if !strings.HasPrefix(filepath.Clean(v.PreviewLocal), filepath.Clean(s.LocalDir)) {
|
||||
http.Error(w, "invalid local path", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
s.Proxy.ServeLocal(w, r, v.PreviewLocal)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) handleThumb(w http.ResponseWriter, r *http.Request) {
|
||||
videoID := chi.URLParam(r, "videoID")
|
||||
// 直接读本地 thumbs 目录中 <videoID>.jpg
|
||||
path := filepath.Join(s.LocalDir, "thumbs", videoID+".jpg")
|
||||
clean := filepath.Clean(path)
|
||||
if !strings.HasPrefix(clean, filepath.Clean(s.LocalDir)) {
|
||||
http.Error(w, "invalid path", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(clean); err != nil {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "private, max-age=86400")
|
||||
s.Proxy.ServeLocal(w, r, clean)
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
func mapVideo(v *catalog.Video) VideoDTO {
|
||||
badges := v.Badges
|
||||
if badges == nil {
|
||||
badges = []string{}
|
||||
}
|
||||
tags := v.Tags
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
return VideoDTO{
|
||||
ID: v.ID,
|
||||
Href: "/video/" + v.ID,
|
||||
Title: v.Title,
|
||||
Thumbnail: thumbnailURL(v),
|
||||
PreviewSrc: "/p/preview/" + v.ID,
|
||||
PreviewDuration: 10,
|
||||
PreviewStrategy: "teaser-file",
|
||||
Duration: formatDuration(v.DurationSeconds),
|
||||
Badges: badges,
|
||||
Quality: v.Quality,
|
||||
Author: v.Author,
|
||||
Views: v.Views,
|
||||
Favorites: v.Favorites,
|
||||
Comments: v.Comments,
|
||||
Likes: v.Likes,
|
||||
Dislikes: v.Dislikes,
|
||||
PublishedAt: v.PublishedAt.Format("2006-01-02"),
|
||||
Tags: tags,
|
||||
Category: v.Category,
|
||||
}
|
||||
}
|
||||
|
||||
func thumbnailURL(v *catalog.Video) string {
|
||||
if v.ThumbnailURL != "" {
|
||||
return v.ThumbnailURL
|
||||
}
|
||||
return "/p/thumb/" + v.ID
|
||||
}
|
||||
|
||||
func videoSource(v *catalog.Video) string {
|
||||
if needsBrowserTranscode(v.Ext) {
|
||||
return "/p/transcode/" + v.ID
|
||||
}
|
||||
return fmt.Sprintf("/p/stream/%s/%s", v.DriveID, v.FileID)
|
||||
}
|
||||
|
||||
func needsBrowserTranscode(ext string) bool {
|
||||
switch strings.ToLower(strings.TrimPrefix(ext, ".")) {
|
||||
case "avi", "mkv":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func buildFFmpegHeaders(h http.Header) string {
|
||||
if len(h) == 0 {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
for k, vs := range h {
|
||||
for _, v := range vs {
|
||||
sb.WriteString(k)
|
||||
sb.WriteString(": ")
|
||||
sb.WriteString(v)
|
||||
sb.WriteString("\r\n")
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (s *Server) transcodeStatus(videoID string) string {
|
||||
if info, err := os.Stat(s.transcodePath(videoID)); err == nil && info.Size() > 0 {
|
||||
return "ready"
|
||||
}
|
||||
s.transcodeMu.Lock()
|
||||
defer s.transcodeMu.Unlock()
|
||||
if s.transcodeJobs != nil && s.transcodeJobs[videoID] {
|
||||
return "processing"
|
||||
}
|
||||
return "missing"
|
||||
}
|
||||
|
||||
func (s *Server) setTranscoding(videoID string, processing bool) {
|
||||
s.transcodeMu.Lock()
|
||||
defer s.transcodeMu.Unlock()
|
||||
if s.transcodeJobs == nil {
|
||||
s.transcodeJobs = make(map[string]bool)
|
||||
}
|
||||
if processing {
|
||||
s.transcodeJobs[videoID] = true
|
||||
return
|
||||
}
|
||||
delete(s.transcodeJobs, videoID)
|
||||
}
|
||||
|
||||
func (s *Server) transcodePath(videoID string) string {
|
||||
return filepath.Join(s.LocalDir, "transcodes", videoID+".mp4")
|
||||
}
|
||||
|
||||
func (s *Server) transcodeTempPath(videoID string) string {
|
||||
return filepath.Join(s.LocalDir, "transcodes", videoID+".tmp.mp4")
|
||||
}
|
||||
|
||||
func mapVideos(vs []*catalog.Video) []VideoDTO {
|
||||
out := make([]VideoDTO, 0, len(vs))
|
||||
for _, v := range vs {
|
||||
out = append(out, mapVideo(v))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterVideos(vs []VideoDTO, exclude string) []VideoDTO {
|
||||
out := make([]VideoDTO, 0, len(vs))
|
||||
for _, v := range vs {
|
||||
if v.ID != exclude {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func formatDuration(sec int) string {
|
||||
if sec <= 0 {
|
||||
return "00:00"
|
||||
}
|
||||
m := sec / 60
|
||||
s := sec % 60
|
||||
return fmt.Sprintf("%02d:%02d", m, s)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func writeErr(w http.ResponseWriter, code int, err error) {
|
||||
writeJSON(w, code, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/video-site/backend/internal/catalog"
|
||||
)
|
||||
|
||||
func TestVideoSourceUsesTranscodeForAvi(t *testing.T) {
|
||||
v := &catalog.Video{
|
||||
ID: "video-1",
|
||||
DriveID: "drive-1",
|
||||
FileID: "file-1",
|
||||
Ext: "avi",
|
||||
}
|
||||
|
||||
got := videoSource(v)
|
||||
|
||||
if got != "/p/transcode/video-1" {
|
||||
t.Fatalf("video source = %q, want transcode route", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoSourceKeepsDirectStreamForMp4(t *testing.T) {
|
||||
v := &catalog.Video{
|
||||
ID: "video-1",
|
||||
DriveID: "drive-1",
|
||||
FileID: "file-1",
|
||||
Ext: "mp4",
|
||||
}
|
||||
|
||||
got := videoSource(v)
|
||||
|
||||
if got != "/p/stream/drive-1/file-1" {
|
||||
t.Fatalf("video source = %q, want direct stream route", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscodeStatusReadyWhenCachedFileExists(t *testing.T) {
|
||||
s := &Server{LocalDir: t.TempDir()}
|
||||
videoID := "video-1"
|
||||
path := s.transcodePath(videoID)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir cache dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("mp4"), 0o644); err != nil {
|
||||
t.Fatalf("write cache file: %v", err)
|
||||
}
|
||||
|
||||
if got := s.transcodeStatus(videoID); got != "ready" {
|
||||
t.Fatalf("status = %q, want ready", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscodeStatusProcessingWhenJobActive(t *testing.T) {
|
||||
s := &Server{LocalDir: t.TempDir()}
|
||||
videoID := "video-1"
|
||||
s.setTranscoding(videoID, true)
|
||||
|
||||
if got := s.transcodeStatus(videoID); got != "processing" {
|
||||
t.Fatalf("status = %q, want processing", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscodeTempPathKeepsMp4Extension(t *testing.T) {
|
||||
s := &Server{LocalDir: t.TempDir()}
|
||||
|
||||
if got := s.transcodeTempPath("video-1"); !strings.HasSuffix(got, ".mp4") {
|
||||
t.Fatalf("temp transcode path = %q, want .mp4 suffix for ffmpeg muxer detection", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTagsReturnsFixedTagsOnly(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: "女大后入",
|
||||
Tags: []string{"后入", "女大", "sunny"},
|
||||
Category: "random-category",
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed video: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/tags", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
(&Server{Catalog: cat}).handleTags(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"`
|
||||
Label string `json:"label"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
labels := make([]string, 0, len(got))
|
||||
for _, tag := range got {
|
||||
labels = append(labels, tag.Label)
|
||||
}
|
||||
want := []string{"后入", "奶子", "口交", "臀", "人妻", "女大"}
|
||||
if !sameStrings(labels, want) {
|
||||
t.Fatalf("labels = %#v, want %#v", labels, want)
|
||||
}
|
||||
if got[0].Count != 1 || got[5].Count != 1 {
|
||||
t.Fatalf("counts = %#v, want 后入 and 女大 count 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,81 +1,81 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/video-site/backend/internal/catalog"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookie = "vs_admin"
|
||||
sessionTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
type Authenticator struct {
|
||||
Username string
|
||||
Password string
|
||||
Catalog *catalog.Catalog
|
||||
}
|
||||
|
||||
func (a *Authenticator) Login(w http.ResponseWriter, r *http.Request, user, pass string) (bool, error) {
|
||||
if subtle.ConstantTimeCompare([]byte(user), []byte(a.Username)) != 1 ||
|
||||
subtle.ConstantTimeCompare([]byte(pass), []byte(a.Password)) != 1 {
|
||||
return false, nil
|
||||
}
|
||||
token, err := randomToken()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := a.Catalog.CreateSession(r.Context(), token, sessionTTL); err != nil {
|
||||
return false, err
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(sessionTTL),
|
||||
})
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *Authenticator) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(sessionCookie); err == nil {
|
||||
_ = a.Catalog.DeleteSession(r.Context(), c.Value)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Expires: time.Unix(0, 0),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Authenticator) Required(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
ok, err := a.Catalog.ValidateSession(r.Context(), c.Value)
|
||||
if err != nil || !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func randomToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/video-site/backend/internal/catalog"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookie = "vs_admin"
|
||||
sessionTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
type Authenticator struct {
|
||||
Username string
|
||||
Password string
|
||||
Catalog *catalog.Catalog
|
||||
}
|
||||
|
||||
func (a *Authenticator) Login(w http.ResponseWriter, r *http.Request, user, pass string) (bool, error) {
|
||||
if subtle.ConstantTimeCompare([]byte(user), []byte(a.Username)) != 1 ||
|
||||
subtle.ConstantTimeCompare([]byte(pass), []byte(a.Password)) != 1 {
|
||||
return false, nil
|
||||
}
|
||||
token, err := randomToken()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := a.Catalog.CreateSession(r.Context(), token, sessionTTL); err != nil {
|
||||
return false, err
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(sessionTTL),
|
||||
})
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *Authenticator) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(sessionCookie); err == nil {
|
||||
_ = a.Catalog.DeleteSession(r.Context(), c.Value)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Expires: time.Unix(0, 0),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Authenticator) Required(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
ok, err := a.Catalog.ValidateSession(r.Context(), c.Value)
|
||||
if err != nil || !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func randomToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
+534
-479
File diff suppressed because it is too large
Load Diff
@@ -1,72 +1,72 @@
|
||||
-- 视频元数据主表
|
||||
CREATE TABLE IF NOT EXISTS videos (
|
||||
id TEXT PRIMARY KEY, -- <drive>-<fileID> 拼接的稳定 ID
|
||||
drive_id TEXT NOT NULL,
|
||||
file_id TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
author TEXT,
|
||||
tags TEXT, -- JSON array
|
||||
duration_seconds INTEGER DEFAULT 0,
|
||||
size_bytes INTEGER DEFAULT 0,
|
||||
ext TEXT,
|
||||
quality TEXT, -- HD / SD
|
||||
thumbnail_url TEXT,
|
||||
preview_file_id TEXT, -- 回写网盘后的 teaser file id
|
||||
preview_local TEXT, -- 本地 teaser 路径(兜底)
|
||||
preview_status TEXT DEFAULT 'pending', -- pending / ready / failed
|
||||
views INTEGER DEFAULT 0,
|
||||
favorites INTEGER DEFAULT 0,
|
||||
comments INTEGER DEFAULT 0,
|
||||
likes INTEGER DEFAULT 0,
|
||||
dislikes INTEGER DEFAULT 0,
|
||||
category TEXT,
|
||||
badges TEXT, -- JSON array
|
||||
description TEXT,
|
||||
published_at INTEGER NOT NULL, -- unix ms
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
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 drives (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL, -- quark / p115 / wopan
|
||||
name TEXT NOT NULL,
|
||||
root_id TEXT NOT NULL DEFAULT '0',
|
||||
scan_root_id TEXT, -- 扫描起点(默认 root_id)
|
||||
credentials TEXT, -- JSON: cookie / refresh_token 等
|
||||
status TEXT DEFAULT 'disconnected', -- disconnected / ok / error
|
||||
last_error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- 扫描任务状态
|
||||
CREATE TABLE IF NOT EXISTS scans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
drive_id TEXT NOT NULL,
|
||||
started_at INTEGER NOT NULL,
|
||||
finished_at INTEGER,
|
||||
scanned INTEGER DEFAULT 0,
|
||||
added INTEGER DEFAULT 0,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
-- 管理后台 session(简单 token 存储)
|
||||
CREATE TABLE IF NOT EXISTS admin_sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- 全局 key-value 设置(preview 开关等)
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
-- 视频元数据主表
|
||||
CREATE TABLE IF NOT EXISTS videos (
|
||||
id TEXT PRIMARY KEY, -- <drive>-<fileID> 拼接的稳定 ID
|
||||
drive_id TEXT NOT NULL,
|
||||
file_id TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
author TEXT,
|
||||
tags TEXT, -- JSON array
|
||||
duration_seconds INTEGER DEFAULT 0,
|
||||
size_bytes INTEGER DEFAULT 0,
|
||||
ext TEXT,
|
||||
quality TEXT, -- HD / SD
|
||||
thumbnail_url TEXT,
|
||||
preview_file_id TEXT, -- 回写网盘后的 teaser file id
|
||||
preview_local TEXT, -- 本地 teaser 路径(兜底)
|
||||
preview_status TEXT DEFAULT 'pending', -- pending / ready / failed
|
||||
views INTEGER DEFAULT 0,
|
||||
favorites INTEGER DEFAULT 0,
|
||||
comments INTEGER DEFAULT 0,
|
||||
likes INTEGER DEFAULT 0,
|
||||
dislikes INTEGER DEFAULT 0,
|
||||
category TEXT,
|
||||
badges TEXT, -- JSON array
|
||||
description TEXT,
|
||||
published_at INTEGER NOT NULL, -- unix ms
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
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 drives (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL, -- quark / p115 / pikpak / wopan
|
||||
name TEXT NOT NULL,
|
||||
root_id TEXT NOT NULL DEFAULT '0',
|
||||
scan_root_id TEXT, -- 扫描起点(默认 root_id)
|
||||
credentials TEXT, -- JSON: cookie / refresh_token 等
|
||||
status TEXT DEFAULT 'disconnected', -- disconnected / ok / error
|
||||
last_error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- 扫描任务状态
|
||||
CREATE TABLE IF NOT EXISTS scans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
drive_id TEXT NOT NULL,
|
||||
started_at INTEGER NOT NULL,
|
||||
finished_at INTEGER,
|
||||
scanned INTEGER DEFAULT 0,
|
||||
added INTEGER DEFAULT 0,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
-- 管理后台 session(简单 token 存储)
|
||||
CREATE TABLE IF NOT EXISTS admin_sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- 全局 key-value 设置(preview 开关等)
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
+117
-117
@@ -1,117 +1,117 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server Server `yaml:"server"`
|
||||
Storage Storage `yaml:"storage"`
|
||||
Scanner Scanner `yaml:"scanner"`
|
||||
Preview Preview `yaml:"preview"`
|
||||
Drives []Drive `yaml:"drives"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Listen string `yaml:"listen"`
|
||||
Admin Admin `yaml:"admin"`
|
||||
SessionSecret string `yaml:"session_secret"`
|
||||
}
|
||||
|
||||
type Admin struct {
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
}
|
||||
|
||||
type Storage struct {
|
||||
DBPath string `yaml:"db_path"`
|
||||
LocalPreviewDir string `yaml:"local_preview_dir"`
|
||||
}
|
||||
|
||||
type Scanner struct {
|
||||
IntervalSeconds int `yaml:"interval_seconds"`
|
||||
MaxDepth int `yaml:"max_depth"`
|
||||
VideoExtensions []string `yaml:"video_extensions"`
|
||||
}
|
||||
|
||||
type Preview struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
FFmpegPath string `yaml:"ffmpeg_path"`
|
||||
FFprobePath string `yaml:"ffprobe_path"`
|
||||
DurationSeconds int `yaml:"duration_seconds"`
|
||||
Width int `yaml:"width"`
|
||||
Segments int `yaml:"segments"`
|
||||
RemoteDir string `yaml:"remote_dir"`
|
||||
}
|
||||
|
||||
// Drive 配置项中的敏感字段(Cookie / RefreshToken 等)最终由管理后台写入 DB
|
||||
// 这里保留 yaml 中的静态定义,用于启动时预置盘。生产建议只在 DB 里维护。
|
||||
type Drive struct {
|
||||
ID string `yaml:"id"`
|
||||
Kind string `yaml:"kind"` // quark / p115 / wopan
|
||||
Name string `yaml:"name"`
|
||||
RootID string `yaml:"root_id"`
|
||||
Params map[string]string `yaml:"params,omitempty"`
|
||||
}
|
||||
|
||||
// Load 读取配置;若不存在则从 config.example.yaml 复制一份并返回
|
||||
func Load(path string) (*Config, error) {
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
example := filepath.Join(filepath.Dir(path), "config.example.yaml")
|
||||
data, err := os.ReadFile(example)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config not found and example missing: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return nil, fmt.Errorf("write default config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
var c Config
|
||||
if err := yaml.Unmarshal(b, &c); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
c.applyDefaults()
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (c *Config) applyDefaults() {
|
||||
if c.Server.Listen == "" {
|
||||
c.Server.Listen = ":8080"
|
||||
}
|
||||
if c.Storage.DBPath == "" {
|
||||
c.Storage.DBPath = "./data/video-site.db"
|
||||
}
|
||||
if c.Storage.LocalPreviewDir == "" {
|
||||
c.Storage.LocalPreviewDir = "./data/previews"
|
||||
}
|
||||
if c.Scanner.MaxDepth == 0 {
|
||||
c.Scanner.MaxDepth = 5
|
||||
}
|
||||
if len(c.Scanner.VideoExtensions) == 0 {
|
||||
c.Scanner.VideoExtensions = []string{".mp4", ".mkv", ".mov", ".webm", ".avi"}
|
||||
}
|
||||
if c.Preview.FFmpegPath == "" {
|
||||
c.Preview.FFmpegPath = "ffmpeg"
|
||||
}
|
||||
if c.Preview.FFprobePath == "" {
|
||||
c.Preview.FFprobePath = "ffprobe"
|
||||
}
|
||||
if c.Preview.DurationSeconds == 0 {
|
||||
c.Preview.DurationSeconds = 9
|
||||
}
|
||||
if c.Preview.Width == 0 {
|
||||
c.Preview.Width = 480
|
||||
}
|
||||
if c.Preview.Segments == 0 {
|
||||
c.Preview.Segments = 3
|
||||
}
|
||||
}
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server Server `yaml:"server"`
|
||||
Storage Storage `yaml:"storage"`
|
||||
Scanner Scanner `yaml:"scanner"`
|
||||
Preview Preview `yaml:"preview"`
|
||||
Drives []Drive `yaml:"drives"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Listen string `yaml:"listen"`
|
||||
Admin Admin `yaml:"admin"`
|
||||
SessionSecret string `yaml:"session_secret"`
|
||||
}
|
||||
|
||||
type Admin struct {
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
}
|
||||
|
||||
type Storage struct {
|
||||
DBPath string `yaml:"db_path"`
|
||||
LocalPreviewDir string `yaml:"local_preview_dir"`
|
||||
}
|
||||
|
||||
type Scanner struct {
|
||||
IntervalSeconds int `yaml:"interval_seconds"`
|
||||
MaxDepth int `yaml:"max_depth"`
|
||||
VideoExtensions []string `yaml:"video_extensions"`
|
||||
}
|
||||
|
||||
type Preview struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
FFmpegPath string `yaml:"ffmpeg_path"`
|
||||
FFprobePath string `yaml:"ffprobe_path"`
|
||||
DurationSeconds int `yaml:"duration_seconds"`
|
||||
Width int `yaml:"width"`
|
||||
Segments int `yaml:"segments"`
|
||||
RemoteDir string `yaml:"remote_dir"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// Load 读取配置;若不存在则从 config.example.yaml 复制一份并返回
|
||||
func Load(path string) (*Config, error) {
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
example := filepath.Join(filepath.Dir(path), "config.example.yaml")
|
||||
data, err := os.ReadFile(example)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config not found and example missing: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return nil, fmt.Errorf("write default config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
var c Config
|
||||
if err := yaml.Unmarshal(b, &c); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
c.applyDefaults()
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (c *Config) applyDefaults() {
|
||||
if c.Server.Listen == "" {
|
||||
c.Server.Listen = ":8080"
|
||||
}
|
||||
if c.Storage.DBPath == "" {
|
||||
c.Storage.DBPath = "./data/video-site.db"
|
||||
}
|
||||
if c.Storage.LocalPreviewDir == "" {
|
||||
c.Storage.LocalPreviewDir = "./data/previews"
|
||||
}
|
||||
if c.Scanner.MaxDepth == 0 {
|
||||
c.Scanner.MaxDepth = 5
|
||||
}
|
||||
if len(c.Scanner.VideoExtensions) == 0 {
|
||||
c.Scanner.VideoExtensions = []string{".mp4", ".mkv", ".mov", ".webm", ".avi"}
|
||||
}
|
||||
if c.Preview.FFmpegPath == "" {
|
||||
c.Preview.FFmpegPath = "ffmpeg"
|
||||
}
|
||||
if c.Preview.FFprobePath == "" {
|
||||
c.Preview.FFprobePath = "ffprobe"
|
||||
}
|
||||
if c.Preview.DurationSeconds == 0 {
|
||||
c.Preview.DurationSeconds = 9
|
||||
}
|
||||
if c.Preview.Width == 0 {
|
||||
c.Preview.Width = 480
|
||||
}
|
||||
if c.Preview.Segments == 0 {
|
||||
c.Preview.Segments = 3
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +1,65 @@
|
||||
package drives
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Drive 是三家网盘统一抽象。上层不区分盘,只区分 Kind。
|
||||
type Drive interface {
|
||||
// Kind 返回驱动代号:"quark" / "p115" / "wopan"
|
||||
Kind() string
|
||||
|
||||
// ID 返回该盘在 catalog 中的唯一标识
|
||||
ID() string
|
||||
|
||||
// Init 完成登录态校验;登录态由 Authenticator 另行获取后注入
|
||||
Init(ctx context.Context) error
|
||||
|
||||
// List 列指定目录下的直接子项
|
||||
List(ctx context.Context, dirID string) ([]Entry, error)
|
||||
|
||||
// Stat 拿到单个文件的元数据
|
||||
Stat(ctx context.Context, fileID string) (*Entry, error)
|
||||
|
||||
// StreamURL 返回一次性直链 + 必须的请求头
|
||||
// 代理层据此回源,透传 Range
|
||||
StreamURL(ctx context.Context, fileID string) (*StreamLink, error)
|
||||
|
||||
// Upload 把本地流写入指定目录,返回新文件 fileID
|
||||
// 用于 scanner 把 teaser 写回网盘
|
||||
Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error)
|
||||
|
||||
// EnsureDir 保证指定路径存在(相对根目录),返回最终目录 fileID
|
||||
// 例如传 "/previews" 会保证根下有一个 previews 目录
|
||||
EnsureDir(ctx context.Context, pathFromRoot string) (string, error)
|
||||
|
||||
// RootID 返回根目录 fileID
|
||||
RootID() string
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
ID string
|
||||
Name string
|
||||
Size int64
|
||||
IsDir bool
|
||||
ParentID string
|
||||
MimeType string
|
||||
ModTime time.Time
|
||||
|
||||
// 部分网盘额外信息
|
||||
Category int // 1=视频 (quark)
|
||||
}
|
||||
|
||||
type StreamLink struct {
|
||||
URL string
|
||||
Headers http.Header
|
||||
Expires time.Time
|
||||
}
|
||||
|
||||
// ErrNotSupported 代表某家盘不支持某操作
|
||||
var ErrNotSupported = errors.New("operation not supported by this drive")
|
||||
package drives
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Drive 是三家网盘统一抽象。上层不区分盘,只区分 Kind。
|
||||
type Drive interface {
|
||||
// Kind 返回驱动代号:"quark" / "p115" / "pikpak" / "wopan"
|
||||
Kind() string
|
||||
|
||||
// ID 返回该盘在 catalog 中的唯一标识
|
||||
ID() string
|
||||
|
||||
// Init 完成登录态校验;登录态由 Authenticator 另行获取后注入
|
||||
Init(ctx context.Context) error
|
||||
|
||||
// List 列指定目录下的直接子项
|
||||
List(ctx context.Context, dirID string) ([]Entry, error)
|
||||
|
||||
// Stat 拿到单个文件的元数据
|
||||
Stat(ctx context.Context, fileID string) (*Entry, error)
|
||||
|
||||
// StreamURL 返回一次性直链 + 必须的请求头
|
||||
// 代理层据此回源,透传 Range
|
||||
StreamURL(ctx context.Context, fileID string) (*StreamLink, error)
|
||||
|
||||
// Upload 把本地流写入指定目录,返回新文件 fileID
|
||||
// 用于 scanner 把 teaser 写回网盘
|
||||
Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error)
|
||||
|
||||
// EnsureDir 保证指定路径存在(相对根目录),返回最终目录 fileID
|
||||
// 例如传 "/previews" 会保证根下有一个 previews 目录
|
||||
EnsureDir(ctx context.Context, pathFromRoot string) (string, error)
|
||||
|
||||
// RootID 返回根目录 fileID
|
||||
RootID() string
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
ID string
|
||||
Name string
|
||||
Size int64
|
||||
IsDir bool
|
||||
ParentID string
|
||||
MimeType string
|
||||
ModTime time.Time
|
||||
|
||||
// 部分网盘额外信息
|
||||
Category int // 1=视频 (quark)
|
||||
ThumbnailURL string // 网盘侧已提供的快速缩略图
|
||||
}
|
||||
|
||||
type StreamLink struct {
|
||||
URL string
|
||||
Headers http.Header
|
||||
Expires time.Time
|
||||
}
|
||||
|
||||
// ErrNotSupported 代表某家盘不支持某操作
|
||||
var ErrNotSupported = errors.New("operation not supported by this drive")
|
||||
|
||||
@@ -1,217 +1,217 @@
|
||||
package p115
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "github.com/SheltonZhu/115driver/pkg/driver"
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
)
|
||||
|
||||
type Driver struct {
|
||||
id string
|
||||
cookie string
|
||||
rootID string
|
||||
client *sdk.Pan115Client
|
||||
ua string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ID string
|
||||
Cookie string // 形如 "UID=xxx; CID=xxx; SEID=xxx; KID=xxx"
|
||||
RootID string // 默认 "0"
|
||||
UA string // 默认 UA115Browser
|
||||
}
|
||||
|
||||
func New(c Config) *Driver {
|
||||
rootID := c.RootID
|
||||
if rootID == "" {
|
||||
rootID = "0"
|
||||
}
|
||||
ua := c.UA
|
||||
if ua == "" {
|
||||
ua = sdk.UA115Browser
|
||||
}
|
||||
return &Driver{
|
||||
id: c.ID,
|
||||
cookie: c.Cookie,
|
||||
rootID: rootID,
|
||||
ua: ua,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Driver) Kind() string { return "p115" }
|
||||
func (d *Driver) ID() string { return d.id }
|
||||
func (d *Driver) RootID() string { return d.rootID }
|
||||
|
||||
func (d *Driver) Init(ctx context.Context) error {
|
||||
cr := &sdk.Credential{}
|
||||
if err := cr.FromCookie(d.cookie); err != nil {
|
||||
return fmt.Errorf("parse cookie: %w", err)
|
||||
}
|
||||
d.client = sdk.New(sdk.UA(d.ua)).ImportCredential(cr)
|
||||
return d.client.LoginCheck()
|
||||
}
|
||||
|
||||
func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) {
|
||||
files, err := d.client.ListWithLimit(dirID, sdk.FileListLimit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("115 list: %w", err)
|
||||
}
|
||||
if files == nil {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]drives.Entry, 0, len(*files))
|
||||
for _, f := range *files {
|
||||
out = append(out, fileToEntry(&f, dirID))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) {
|
||||
f, err := d.client.GetFile(fileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("115 stat: %w", err)
|
||||
}
|
||||
if f == nil {
|
||||
return nil, errors.New("115 stat: not found")
|
||||
}
|
||||
e := fileToEntry(f, f.ParentID)
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) {
|
||||
// 需要先拿到 pickCode
|
||||
f, err := d.client.GetFile(fileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("115 get file: %w", err)
|
||||
}
|
||||
info, err := d.client.DownloadWithUA(f.PickCode, d.ua)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("115 download url: %w", err)
|
||||
}
|
||||
if info == nil || info.Url.Url == "" {
|
||||
return nil, errors.New("115 download url: empty")
|
||||
}
|
||||
|
||||
headers := http.Header{}
|
||||
headers.Set("User-Agent", d.ua)
|
||||
// 115 直链会返回一组 Cookie / Referer,info.Header 里带了
|
||||
for k, vs := range info.Header {
|
||||
for _, v := range vs {
|
||||
headers.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
return &drives.StreamLink{
|
||||
URL: info.Url.Url,
|
||||
Headers: headers,
|
||||
Expires: time.Now().Add(25 * time.Minute), // 115 直链 30 分钟过期,留余量
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) {
|
||||
// 115 上传流程比较复杂:RapidUpload -> OSS 分片
|
||||
// 第一版 teaser 文件小(<2MB),直接读全量写 seeker,走 RapidUploadOrByOSS
|
||||
buf, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rs := strings.NewReader(string(buf))
|
||||
if err := d.client.RapidUploadOrByOSS(parentID, name, size, rs); err != nil {
|
||||
return "", fmt.Errorf("115 upload: %w", err)
|
||||
}
|
||||
// RapidUploadOrByOSS 目前没返回 fileID,需要回查
|
||||
files, err := d.client.ListWithLimit(parentID, sdk.FileListLimit)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("115 upload verify: %w", err)
|
||||
}
|
||||
if files != nil {
|
||||
for _, f := range *files {
|
||||
if !f.IsDirectory && f.Name == name {
|
||||
return f.FileID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", errors.New("115 upload: file not found after upload")
|
||||
}
|
||||
|
||||
func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) {
|
||||
parts := splitPath(pathFromRoot)
|
||||
currentID := d.rootID
|
||||
for _, name := range parts {
|
||||
childID, err := d.findChildDir(ctx, currentID, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if childID == "" {
|
||||
id, err := d.client.Mkdir(currentID, name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("115 mkdir %s: %w", name, err)
|
||||
}
|
||||
childID = id
|
||||
}
|
||||
currentID = childID
|
||||
}
|
||||
return currentID, nil
|
||||
}
|
||||
|
||||
func (d *Driver) findChildDir(ctx context.Context, parent, name string) (string, error) {
|
||||
entries, err := d.List(ctx, parent)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir && e.Name == name {
|
||||
return e.ID, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func splitPath(p string) []string {
|
||||
p = strings.Trim(p, "/")
|
||||
if p == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(p, "/")
|
||||
}
|
||||
|
||||
func fileToEntry(f *sdk.File, parentID string) drives.Entry {
|
||||
return drives.Entry{
|
||||
ID: f.FileID,
|
||||
Name: f.Name,
|
||||
Size: f.Size,
|
||||
IsDir: f.IsDirectory,
|
||||
ParentID: parentID,
|
||||
MimeType: guessMime(f.Name),
|
||||
ModTime: f.UpdateTime,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
package p115
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "github.com/SheltonZhu/115driver/pkg/driver"
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
)
|
||||
|
||||
type Driver struct {
|
||||
id string
|
||||
cookie string
|
||||
rootID string
|
||||
client *sdk.Pan115Client
|
||||
ua string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ID string
|
||||
Cookie string // 形如 "UID=xxx; CID=xxx; SEID=xxx; KID=xxx"
|
||||
RootID string // 默认 "0"
|
||||
UA string // 默认 UA115Browser
|
||||
}
|
||||
|
||||
func New(c Config) *Driver {
|
||||
rootID := c.RootID
|
||||
if rootID == "" {
|
||||
rootID = "0"
|
||||
}
|
||||
ua := c.UA
|
||||
if ua == "" {
|
||||
ua = sdk.UA115Browser
|
||||
}
|
||||
return &Driver{
|
||||
id: c.ID,
|
||||
cookie: c.Cookie,
|
||||
rootID: rootID,
|
||||
ua: ua,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Driver) Kind() string { return "p115" }
|
||||
func (d *Driver) ID() string { return d.id }
|
||||
func (d *Driver) RootID() string { return d.rootID }
|
||||
|
||||
func (d *Driver) Init(ctx context.Context) error {
|
||||
cr := &sdk.Credential{}
|
||||
if err := cr.FromCookie(d.cookie); err != nil {
|
||||
return fmt.Errorf("parse cookie: %w", err)
|
||||
}
|
||||
d.client = sdk.New(sdk.UA(d.ua)).ImportCredential(cr)
|
||||
return d.client.LoginCheck()
|
||||
}
|
||||
|
||||
func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) {
|
||||
files, err := d.client.ListWithLimit(dirID, sdk.FileListLimit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("115 list: %w", err)
|
||||
}
|
||||
if files == nil {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]drives.Entry, 0, len(*files))
|
||||
for _, f := range *files {
|
||||
out = append(out, fileToEntry(&f, dirID))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) {
|
||||
f, err := d.client.GetFile(fileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("115 stat: %w", err)
|
||||
}
|
||||
if f == nil {
|
||||
return nil, errors.New("115 stat: not found")
|
||||
}
|
||||
e := fileToEntry(f, f.ParentID)
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) {
|
||||
// 需要先拿到 pickCode
|
||||
f, err := d.client.GetFile(fileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("115 get file: %w", err)
|
||||
}
|
||||
info, err := d.client.DownloadWithUA(f.PickCode, d.ua)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("115 download url: %w", err)
|
||||
}
|
||||
if info == nil || info.Url.Url == "" {
|
||||
return nil, errors.New("115 download url: empty")
|
||||
}
|
||||
|
||||
headers := http.Header{}
|
||||
headers.Set("User-Agent", d.ua)
|
||||
// 115 直链会返回一组 Cookie / Referer,info.Header 里带了
|
||||
for k, vs := range info.Header {
|
||||
for _, v := range vs {
|
||||
headers.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
return &drives.StreamLink{
|
||||
URL: info.Url.Url,
|
||||
Headers: headers,
|
||||
Expires: time.Now().Add(25 * time.Minute), // 115 直链 30 分钟过期,留余量
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) {
|
||||
// 115 上传流程比较复杂:RapidUpload -> OSS 分片
|
||||
// 第一版 teaser 文件小(<2MB),直接读全量写 seeker,走 RapidUploadOrByOSS
|
||||
buf, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rs := strings.NewReader(string(buf))
|
||||
if err := d.client.RapidUploadOrByOSS(parentID, name, size, rs); err != nil {
|
||||
return "", fmt.Errorf("115 upload: %w", err)
|
||||
}
|
||||
// RapidUploadOrByOSS 目前没返回 fileID,需要回查
|
||||
files, err := d.client.ListWithLimit(parentID, sdk.FileListLimit)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("115 upload verify: %w", err)
|
||||
}
|
||||
if files != nil {
|
||||
for _, f := range *files {
|
||||
if !f.IsDirectory && f.Name == name {
|
||||
return f.FileID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", errors.New("115 upload: file not found after upload")
|
||||
}
|
||||
|
||||
func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) {
|
||||
parts := splitPath(pathFromRoot)
|
||||
currentID := d.rootID
|
||||
for _, name := range parts {
|
||||
childID, err := d.findChildDir(ctx, currentID, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if childID == "" {
|
||||
id, err := d.client.Mkdir(currentID, name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("115 mkdir %s: %w", name, err)
|
||||
}
|
||||
childID = id
|
||||
}
|
||||
currentID = childID
|
||||
}
|
||||
return currentID, nil
|
||||
}
|
||||
|
||||
func (d *Driver) findChildDir(ctx context.Context, parent, name string) (string, error) {
|
||||
entries, err := d.List(ctx, parent)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir && e.Name == name {
|
||||
return e.ID, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func splitPath(p string) []string {
|
||||
p = strings.Trim(p, "/")
|
||||
if p == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(p, "/")
|
||||
}
|
||||
|
||||
func fileToEntry(f *sdk.File, parentID string) drives.Entry {
|
||||
return drives.Entry{
|
||||
ID: f.FileID,
|
||||
Name: f.Name,
|
||||
Size: f.Size,
|
||||
IsDir: f.IsDirectory,
|
||||
ParentID: parentID,
|
||||
MimeType: guessMime(f.Name),
|
||||
ModTime: f.UpdateTime,
|
||||
}
|
||||
}
|
||||
|
||||
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,283 @@
|
||||
package pikpak
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var androidAlgorithms = []string{
|
||||
"SOP04dGzk0TNO7t7t9ekDbAmx+eq0OI1ovEx",
|
||||
"nVBjhYiND4hZ2NCGyV5beamIr7k6ifAsAbl",
|
||||
"Ddjpt5B/Cit6EDq2a6cXgxY9lkEIOw4yC1GDF28KrA",
|
||||
"VVCogcmSNIVvgV6U+AochorydiSymi68YVNGiz",
|
||||
"u5ujk5sM62gpJOsB/1Gu/zsfgfZO",
|
||||
"dXYIiBOAHZgzSruaQ2Nhrqc2im",
|
||||
"z5jUTBSIpBN9g4qSJGlidNAutX6",
|
||||
"KJE2oveZ34du/g1tiimm",
|
||||
}
|
||||
|
||||
var webAlgorithms = []string{
|
||||
"C9qPpZLN8ucRTaTiUMWYS9cQvWOE",
|
||||
"+r6CQVxjzJV6LCV",
|
||||
"F",
|
||||
"pFJRC",
|
||||
"9WXYIDGrwTCz2OiVlgZa90qpECPD6olt",
|
||||
"/750aCr4lm/Sly/c",
|
||||
"RB+DT/gZCrbV",
|
||||
"",
|
||||
"CyLsf7hdkIRxRm215hl",
|
||||
"7xHvLi2tOYP0Y92b",
|
||||
"ZGTXXxu8E/MIWaEDB+Sm/",
|
||||
"1UI3",
|
||||
"E7fP5Pfijd+7K+t6Tg/NhuLq0eEUVChpJSkrKxpO",
|
||||
"ihtqpG6FMt65+Xk+tWUH2",
|
||||
"NhXXU9rg4XXdzo7u5o",
|
||||
}
|
||||
|
||||
var pcAlgorithms = []string{
|
||||
"KHBJ07an7ROXDoK7Db",
|
||||
"G6n399rSWkl7WcQmw5rpQInurc1DkLmLJqE",
|
||||
"JZD1A3M4x+jBFN62hkr7VDhkkZxb9g3rWqRZqFAAb",
|
||||
"fQnw/AmSlbbI91Ik15gpddGgyU7U",
|
||||
"/Dv9JdPYSj3sHiWjouR95NTQff",
|
||||
"yGx2zuTjbWENZqecNI+edrQgqmZKP",
|
||||
"ljrbSzdHLwbqcRn",
|
||||
"lSHAsqCkGDGxQqqwrVu",
|
||||
"TsWXI81fD1",
|
||||
"vk7hBjawK/rOSrSWajtbMk95nfgf3",
|
||||
}
|
||||
|
||||
func (d *Driver) applyPlatformDefaults() {
|
||||
switch d.platform {
|
||||
case "android":
|
||||
d.clientID = "YNxT9w7GMdWvEOKa"
|
||||
d.clientSecret = "dbw2OtmVEeuUvIptb1Coyg"
|
||||
d.clientVersion = "1.53.2"
|
||||
d.packageName = "com.pikcloud.pikpak"
|
||||
d.algorithms = androidAlgorithms
|
||||
d.userAgent = buildAndroidUserAgent(d.deviceID, d.clientID, d.packageName, "2.0.6.206003", d.clientVersion, d.packageName, d.userID)
|
||||
case "pc":
|
||||
d.clientID = "YvtoWO6GNHiuCl7x"
|
||||
d.clientSecret = "1NIH5R1IEe2pAxZE3hv3uA"
|
||||
d.clientVersion = "undefined"
|
||||
d.packageName = "mypikpak.com"
|
||||
d.algorithms = pcAlgorithms
|
||||
d.userAgent = "MainWindow Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) PikPak/2.6.11.4955 Chrome/100.0.4896.160 Electron/18.3.15 Safari/537.36"
|
||||
default:
|
||||
d.platform = "web"
|
||||
d.clientID = "YUMx5nI8ZU8Ap8pm"
|
||||
d.clientSecret = "dbw2OtmVEeuUvIptb1Coyg"
|
||||
d.clientVersion = "2.0.0"
|
||||
d.packageName = "mypikpak.com"
|
||||
d.algorithms = webAlgorithms
|
||||
d.userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36"
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Driver) login(ctx context.Context) error {
|
||||
if d.username == "" || d.password == "" {
|
||||
return fmt.Errorf("pikpak username or password is empty")
|
||||
}
|
||||
if d.captchaToken == "" {
|
||||
if err := d.refreshCaptchaTokenInLogin(ctx, getAction(http.MethodPost, signinURL), d.username); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var out authResp
|
||||
var e errResp
|
||||
res, err := d.client.R().
|
||||
SetContext(ctx).
|
||||
SetError(&e).
|
||||
SetResult(&out).
|
||||
SetQueryParam("client_id", d.clientID).
|
||||
SetBody(map[string]any{
|
||||
"captcha_token": d.captchaToken,
|
||||
"client_id": d.clientID,
|
||||
"client_secret": d.clientSecret,
|
||||
"username": d.username,
|
||||
"password": d.password,
|
||||
}).
|
||||
Post(signinURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e.isError() {
|
||||
return &e
|
||||
}
|
||||
if res.IsError() {
|
||||
return fmt.Errorf("pikpak signin http %d: %s", res.StatusCode(), string(res.Body()))
|
||||
}
|
||||
d.applyAuth(out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Driver) refresh(ctx context.Context, refreshToken string) error {
|
||||
if refreshToken == "" {
|
||||
return fmt.Errorf("pikpak refresh_token is empty")
|
||||
}
|
||||
var out authResp
|
||||
var e errResp
|
||||
res, err := d.client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("User-Agent", "").
|
||||
SetError(&e).
|
||||
SetResult(&out).
|
||||
SetQueryParam("client_id", d.clientID).
|
||||
SetBody(map[string]any{
|
||||
"client_id": d.clientID,
|
||||
"client_secret": d.clientSecret,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refreshToken,
|
||||
}).
|
||||
Post(tokenURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e.isError() {
|
||||
if e.ErrorCode == 4126 && d.username != "" && d.password != "" {
|
||||
return d.login(ctx)
|
||||
}
|
||||
return &e
|
||||
}
|
||||
if res.IsError() {
|
||||
return fmt.Errorf("pikpak refresh http %d: %s", res.StatusCode(), string(res.Body()))
|
||||
}
|
||||
d.applyAuth(out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Driver) applyAuth(out authResp) {
|
||||
d.accessToken = out.AccessToken
|
||||
d.refreshToken = out.RefreshToken
|
||||
d.userID = out.Sub
|
||||
if d.platform == "android" {
|
||||
d.userAgent = buildAndroidUserAgent(d.deviceID, d.clientID, d.packageName, "2.0.6.206003", d.clientVersion, d.packageName, d.userID)
|
||||
}
|
||||
d.persistTokens()
|
||||
}
|
||||
|
||||
func (d *Driver) persistTokens() {
|
||||
if d.onTokenUpdate != nil {
|
||||
d.onTokenUpdate(d.accessToken, d.refreshToken, d.captchaToken, d.deviceID)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Driver) refreshCaptchaTokenAtLogin(ctx context.Context, action, userID string) error {
|
||||
timestamp, sign := d.captchaSign()
|
||||
return d.refreshCaptchaToken(ctx, action, map[string]string{
|
||||
"client_version": d.clientVersion,
|
||||
"package_name": d.packageName,
|
||||
"user_id": userID,
|
||||
"timestamp": timestamp,
|
||||
"captcha_sign": sign,
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Driver) refreshCaptchaTokenInLogin(ctx context.Context, action, username string) error {
|
||||
meta := make(map[string]string)
|
||||
if ok, _ := regexp.MatchString(`\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*`, username); ok {
|
||||
meta["email"] = username
|
||||
} else if len(username) >= 11 && len(username) <= 18 {
|
||||
meta["phone_number"] = username
|
||||
} else {
|
||||
meta["username"] = username
|
||||
}
|
||||
return d.refreshCaptchaToken(ctx, action, meta)
|
||||
}
|
||||
|
||||
func (d *Driver) refreshCaptchaToken(ctx context.Context, action string, meta map[string]string) error {
|
||||
var e errResp
|
||||
var out captchaTokenResponse
|
||||
req := d.client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("User-Agent", d.userAgent).
|
||||
SetHeader("X-Device-ID", d.deviceID).
|
||||
SetError(&e).
|
||||
SetResult(&out).
|
||||
SetQueryParam("client_id", d.clientID).
|
||||
SetBody(captchaTokenRequest{
|
||||
Action: action,
|
||||
CaptchaToken: d.captchaToken,
|
||||
ClientID: d.clientID,
|
||||
DeviceID: d.deviceID,
|
||||
Meta: meta,
|
||||
RedirectURI: "xlaccsdk01://xbase.cloud/callback?state=harbor",
|
||||
})
|
||||
if d.accessToken != "" {
|
||||
req.SetHeader("Authorization", "Bearer "+d.accessToken)
|
||||
}
|
||||
res, err := req.Post(captchaInitURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e.isError() {
|
||||
return &e
|
||||
}
|
||||
if res.IsError() {
|
||||
return fmt.Errorf("pikpak captcha http %d: %s", res.StatusCode(), string(res.Body()))
|
||||
}
|
||||
if out.URL != "" {
|
||||
return fmt.Errorf("pikpak captcha verification required: %s", out.URL)
|
||||
}
|
||||
d.captchaToken = out.CaptchaToken
|
||||
d.persistTokens()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Driver) captchaSign() (timestamp, sign string) {
|
||||
timestamp = fmt.Sprint(time.Now().UnixMilli())
|
||||
raw := fmt.Sprint(d.clientID, d.clientVersion, d.packageName, d.deviceID, timestamp)
|
||||
for _, algorithm := range d.algorithms {
|
||||
raw = md5Hex(raw + algorithm)
|
||||
}
|
||||
return timestamp, "1." + raw
|
||||
}
|
||||
|
||||
func getAction(method, rawURL string) string {
|
||||
match := regexp.MustCompile(`://[^/]+((/[^/\s?#]+)*)`).FindStringSubmatch(rawURL)
|
||||
if len(match) < 2 {
|
||||
return method + ":" + rawURL
|
||||
}
|
||||
return method + ":" + match[1]
|
||||
}
|
||||
|
||||
func generateDeviceSign(deviceID, packageName string) string {
|
||||
signatureBase := fmt.Sprintf("%s%s%s%s", deviceID, packageName, "1", "appkey")
|
||||
sha1Hash := sha1.Sum([]byte(signatureBase))
|
||||
md5Hash := md5.Sum([]byte(hex.EncodeToString(sha1Hash[:])))
|
||||
return fmt.Sprintf("div101.%s%s", deviceID, hex.EncodeToString(md5Hash[:]))
|
||||
}
|
||||
|
||||
func buildAndroidUserAgent(deviceID, clientID, appName, sdkVersion, clientVersion, packageName, userID string) string {
|
||||
deviceSign := generateDeviceSign(deviceID, packageName)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("ANDROID-%s/%s ", appName, clientVersion))
|
||||
sb.WriteString("protocolVersion/200 accesstype/ ")
|
||||
sb.WriteString(fmt.Sprintf("clientid/%s ", clientID))
|
||||
sb.WriteString(fmt.Sprintf("clientversion/%s ", clientVersion))
|
||||
sb.WriteString("action_type/ networktype/WIFI sessionid/ ")
|
||||
sb.WriteString(fmt.Sprintf("deviceid/%s ", deviceID))
|
||||
sb.WriteString("providername/NONE ")
|
||||
sb.WriteString(fmt.Sprintf("devicesign/%s ", deviceSign))
|
||||
sb.WriteString("refresh_token/ ")
|
||||
sb.WriteString(fmt.Sprintf("sdkversion/%s ", sdkVersion))
|
||||
sb.WriteString(fmt.Sprintf("datetime/%d ", time.Now().UnixMilli()))
|
||||
sb.WriteString(fmt.Sprintf("usrno/%s ", userID))
|
||||
sb.WriteString(fmt.Sprintf("appname/android-%s ", appName))
|
||||
sb.WriteString("session_origin/ grant_type/ appid/ clientip/ ")
|
||||
sb.WriteString("devicename/Xiaomi_M2004j7ac osversion/13 platformversion/10 accessmode/ devicemodel/M2004J7AC ")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func md5Hex(raw string) string {
|
||||
sum := md5.Sum([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package pikpak
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
)
|
||||
|
||||
const (
|
||||
filesURL = "https://api-drive.mypikpak.net/drive/v1/files"
|
||||
signinURL = "https://user.mypikpak.net/v1/auth/signin"
|
||||
tokenURL = "https://user.mypikpak.net/v1/auth/token"
|
||||
captchaInitURL = "https://user.mypikpak.net/v1/shield/captcha/init"
|
||||
)
|
||||
|
||||
type Driver struct {
|
||||
id string
|
||||
rootID string
|
||||
username string
|
||||
password string
|
||||
platform string
|
||||
refreshToken string
|
||||
accessToken string
|
||||
captchaToken string
|
||||
deviceID string
|
||||
userID string
|
||||
disableMediaLink bool
|
||||
|
||||
clientID string
|
||||
clientSecret string
|
||||
clientVersion string
|
||||
packageName string
|
||||
algorithms []string
|
||||
userAgent string
|
||||
|
||||
client *resty.Client
|
||||
onTokenUpdate func(access, refresh, captcha, deviceID string)
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ID string
|
||||
Username string
|
||||
Password string
|
||||
Platform string
|
||||
RefreshToken string
|
||||
AccessToken string
|
||||
CaptchaToken string
|
||||
DeviceID string
|
||||
RootID string
|
||||
DisableMediaLink bool
|
||||
OnTokenUpdate func(access, refresh, captcha, deviceID string)
|
||||
}
|
||||
|
||||
func New(c Config) *Driver {
|
||||
rootID := strings.TrimSpace(c.RootID)
|
||||
if rootID == "0" {
|
||||
rootID = ""
|
||||
}
|
||||
platform := strings.ToLower(strings.TrimSpace(c.Platform))
|
||||
if platform == "" {
|
||||
platform = "web"
|
||||
}
|
||||
deviceID := strings.TrimSpace(c.DeviceID)
|
||||
if deviceID == "" {
|
||||
seed := c.Username + c.Password
|
||||
if seed == "" {
|
||||
seed = c.ID
|
||||
}
|
||||
deviceID = md5Hex(seed)
|
||||
}
|
||||
d := &Driver{
|
||||
id: c.ID,
|
||||
rootID: rootID,
|
||||
username: c.Username,
|
||||
password: c.Password,
|
||||
platform: platform,
|
||||
refreshToken: c.RefreshToken,
|
||||
accessToken: c.AccessToken,
|
||||
captchaToken: c.CaptchaToken,
|
||||
deviceID: deviceID,
|
||||
disableMediaLink: c.DisableMediaLink,
|
||||
onTokenUpdate: c.OnTokenUpdate,
|
||||
client: resty.New().
|
||||
SetTimeout(30*time.Second).
|
||||
SetHeader("Accept", "application/json, text/plain, */*"),
|
||||
}
|
||||
d.applyPlatformDefaults()
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *Driver) Kind() string { return "pikpak" }
|
||||
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 != "" {
|
||||
if err := d.refresh(ctx, d.refreshToken); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := d.login(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := d.refreshCaptchaTokenAtLogin(ctx, getAction(http.MethodGet, filesURL), d.userID); err != nil {
|
||||
return err
|
||||
}
|
||||
d.persistTokens()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) {
|
||||
if dirID == "" {
|
||||
dirID = d.rootID
|
||||
}
|
||||
files, err := d.getFiles(ctx, dirID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]drives.Entry, 0, len(files))
|
||||
for _, f := range files {
|
||||
out = append(out, fileToEntry(f, dirID))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) {
|
||||
var f file
|
||||
err := d.request(ctx, filesURL+"/"+fileID, http.MethodGet, func(req *resty.Request) {
|
||||
req.SetQueryParams(map[string]string{
|
||||
"_magic": "2021",
|
||||
"usage": "FETCH",
|
||||
"thumbnail_size": "SIZE_LARGE",
|
||||
})
|
||||
}, &f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pikpak stat: %w", err)
|
||||
}
|
||||
e := fileToEntry(f, "")
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) {
|
||||
var f file
|
||||
usage := "FETCH"
|
||||
if !d.disableMediaLink {
|
||||
usage = "CACHE"
|
||||
}
|
||||
err := d.request(ctx, filesURL+"/"+fileID, http.MethodGet, func(req *resty.Request) {
|
||||
req.SetQueryParams(map[string]string{
|
||||
"_magic": "2021",
|
||||
"usage": usage,
|
||||
"thumbnail_size": "SIZE_LARGE",
|
||||
})
|
||||
}, &f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pikpak download url: %w", err)
|
||||
}
|
||||
|
||||
url := f.WebContentLink
|
||||
expires := time.Now().Add(10 * time.Minute)
|
||||
if !d.disableMediaLink {
|
||||
if m, ok := pickMediaLink(f.Medias); ok {
|
||||
url = m.Link.URL
|
||||
if !m.Link.Expire.IsZero() {
|
||||
expires = m.Link.Expire
|
||||
}
|
||||
}
|
||||
}
|
||||
if url == "" {
|
||||
return nil, errors.New("pikpak download url: empty")
|
||||
}
|
||||
headers := http.Header{}
|
||||
if d.userAgent != "" {
|
||||
headers.Set("User-Agent", d.userAgent)
|
||||
}
|
||||
return &drives.StreamLink{
|
||||
URL: url,
|
||||
Headers: headers,
|
||||
Expires: expires,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) {
|
||||
return "", drives.ErrNotSupported
|
||||
}
|
||||
|
||||
func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) {
|
||||
return "", drives.ErrNotSupported
|
||||
}
|
||||
|
||||
func (d *Driver) getFiles(ctx context.Context, parentID string) ([]file, error) {
|
||||
out := make([]file, 0)
|
||||
pageToken := "first"
|
||||
for pageToken != "" {
|
||||
if pageToken == "first" {
|
||||
pageToken = ""
|
||||
}
|
||||
query := map[string]string{
|
||||
"parent_id": parentID,
|
||||
"thumbnail_size": "SIZE_LARGE",
|
||||
"with_audit": "true",
|
||||
"limit": "100",
|
||||
"filters": `{"phase":{"eq":"PHASE_TYPE_COMPLETE"},"trashed":{"eq":false}}`,
|
||||
"page_token": pageToken,
|
||||
}
|
||||
var resp filesResp
|
||||
if err := d.request(ctx, filesURL, http.MethodGet, func(req *resty.Request) {
|
||||
req.SetQueryParams(query)
|
||||
}, &resp); err != nil {
|
||||
return nil, fmt.Errorf("pikpak list: %w", err)
|
||||
}
|
||||
out = append(out, resp.Files...)
|
||||
pageToken = resp.NextPageToken
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (d *Driver) request(ctx context.Context, url, method string, configure func(*resty.Request), out any) error {
|
||||
return d.requestOnce(ctx, url, method, configure, out, true)
|
||||
}
|
||||
|
||||
func (d *Driver) requestOnce(ctx context.Context, url, method string, configure func(*resty.Request), out any, retry bool) error {
|
||||
req := d.client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("User-Agent", d.userAgent).
|
||||
SetHeader("X-Device-ID", d.deviceID).
|
||||
SetHeader("X-Captcha-Token", d.captchaToken)
|
||||
if d.accessToken != "" {
|
||||
req.SetHeader("Authorization", "Bearer "+d.accessToken)
|
||||
}
|
||||
if configure != nil {
|
||||
configure(req)
|
||||
}
|
||||
if out != nil {
|
||||
req.SetResult(out)
|
||||
}
|
||||
var e errResp
|
||||
req.SetError(&e)
|
||||
|
||||
res, err := req.Execute(method, url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e.isError() {
|
||||
switch e.ErrorCode {
|
||||
case 4122, 4121, 16:
|
||||
if retry {
|
||||
if err := d.refresh(ctx, d.refreshToken); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.requestOnce(ctx, url, method, configure, out, false)
|
||||
}
|
||||
case 9:
|
||||
if retry {
|
||||
if err := d.refreshCaptchaTokenAtLogin(ctx, getAction(method, url), d.userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.requestOnce(ctx, url, method, configure, out, false)
|
||||
}
|
||||
}
|
||||
return &e
|
||||
}
|
||||
if res.IsError() {
|
||||
return fmt.Errorf("pikpak http %d: %s", res.StatusCode(), string(res.Body()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pickMediaLink(items []media) (media, bool) {
|
||||
if len(items) == 0 {
|
||||
return media{}, false
|
||||
}
|
||||
for _, m := range items {
|
||||
if m.IsOrigin && m.Link.URL != "" {
|
||||
return m, true
|
||||
}
|
||||
}
|
||||
for _, m := range items {
|
||||
if m.IsDefault && m.Link.URL != "" {
|
||||
return m, true
|
||||
}
|
||||
}
|
||||
for _, m := range items {
|
||||
if m.Link.URL != "" {
|
||||
return m, true
|
||||
}
|
||||
}
|
||||
return media{}, false
|
||||
}
|
||||
|
||||
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 ".avi":
|
||||
return "video/x-msvideo"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
func ParseBoolDefault(raw string, def bool) bool {
|
||||
if raw == "" {
|
||||
return def
|
||||
}
|
||||
v, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
var _ drives.Drive = (*Driver)(nil)
|
||||
@@ -0,0 +1,103 @@
|
||||
package pikpak
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
)
|
||||
|
||||
func TestNewDefaults(t *testing.T) {
|
||||
d := New(Config{
|
||||
ID: "pikpak-main",
|
||||
Username: "user@example.com",
|
||||
Password: "secret",
|
||||
RootID: "0",
|
||||
})
|
||||
|
||||
if d.Kind() != "pikpak" {
|
||||
t.Fatalf("kind = %q, want pikpak", d.Kind())
|
||||
}
|
||||
if d.ID() != "pikpak-main" {
|
||||
t.Fatalf("id = %q, want pikpak-main", d.ID())
|
||||
}
|
||||
if d.RootID() != "" {
|
||||
t.Fatalf("root id = %q, want empty PikPak root", d.RootID())
|
||||
}
|
||||
if d.platform != "web" {
|
||||
t.Fatalf("platform = %q, want web", d.platform)
|
||||
}
|
||||
if d.deviceID == "" {
|
||||
t.Fatal("device id should be generated")
|
||||
}
|
||||
if d.userAgent == "" {
|
||||
t.Fatal("user agent should be selected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileToEntry(t *testing.T) {
|
||||
mod := time.Date(2026, 5, 10, 12, 30, 0, 0, time.UTC)
|
||||
f := file{
|
||||
ID: "file-id",
|
||||
Name: "movie.mp4",
|
||||
Kind: "drive#file",
|
||||
Size: "12345",
|
||||
ThumbnailLink: "https://thumbnail.example/movie.jpg",
|
||||
ModifiedTime: mod,
|
||||
}
|
||||
|
||||
got := fileToEntry(f, "parent-id")
|
||||
|
||||
if got.ID != "file-id" {
|
||||
t.Fatalf("id = %q, want file-id", got.ID)
|
||||
}
|
||||
if got.Name != "movie.mp4" {
|
||||
t.Fatalf("name = %q, want movie.mp4", got.Name)
|
||||
}
|
||||
if got.IsDir {
|
||||
t.Fatal("file should not be a directory")
|
||||
}
|
||||
if got.Size != 12345 {
|
||||
t.Fatalf("size = %d, want 12345", got.Size)
|
||||
}
|
||||
if got.ParentID != "parent-id" {
|
||||
t.Fatalf("parent id = %q, want parent-id", got.ParentID)
|
||||
}
|
||||
if got.MimeType != "video/mp4" {
|
||||
t.Fatalf("mime = %q, want video/mp4", got.MimeType)
|
||||
}
|
||||
if got.ThumbnailURL != "https://thumbnail.example/movie.jpg" {
|
||||
t.Fatalf("thumbnail = %q, want remote thumbnail", got.ThumbnailURL)
|
||||
}
|
||||
if !got.ModTime.Equal(mod) {
|
||||
t.Fatalf("mod time = %v, want %v", got.ModTime, mod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderToEntry(t *testing.T) {
|
||||
f := file{
|
||||
ID: "folder-id",
|
||||
Name: "Videos",
|
||||
Kind: "drive#folder",
|
||||
}
|
||||
|
||||
got := fileToEntry(f, "")
|
||||
|
||||
if !got.IsDir {
|
||||
t.Fatal("folder should be a directory")
|
||||
}
|
||||
if got.Size != 0 {
|
||||
t.Fatalf("size = %d, want 0", got.Size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsupportedUploadOperations(t *testing.T) {
|
||||
d := New(Config{ID: "pikpak-main"})
|
||||
|
||||
if _, err := d.EnsureDir(nil, "/previews"); err != drives.ErrNotSupported {
|
||||
t.Fatalf("EnsureDir error = %v, want ErrNotSupported", err)
|
||||
}
|
||||
if _, err := d.Upload(nil, "", "preview.mp4", nil, 0); err != drives.ErrNotSupported {
|
||||
t.Fatalf("Upload error = %v, want ErrNotSupported", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package pikpak
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
)
|
||||
|
||||
type filesResp struct {
|
||||
Files []file `json:"files"`
|
||||
NextPageToken string `json:"next_page_token"`
|
||||
}
|
||||
|
||||
type file struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
CreatedTime time.Time `json:"created_time"`
|
||||
ModifiedTime time.Time `json:"modified_time"`
|
||||
Hash string `json:"hash"`
|
||||
Size string `json:"size"`
|
||||
ThumbnailLink string `json:"thumbnail_link"`
|
||||
WebContentLink string `json:"web_content_link"`
|
||||
Medias []media `json:"medias"`
|
||||
}
|
||||
|
||||
type media struct {
|
||||
Link struct {
|
||||
URL string `json:"url"`
|
||||
Token string `json:"token"`
|
||||
Expire time.Time `json:"expire"`
|
||||
} `json:"link"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
IsOrigin bool `json:"is_origin"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
|
||||
type authResp struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
AccessToken string `json:"access_token"`
|
||||
Sub string `json:"sub"`
|
||||
}
|
||||
|
||||
type errResp struct {
|
||||
ErrorCode int64 `json:"error_code"`
|
||||
ErrorMsg string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
|
||||
func (e *errResp) isError() bool {
|
||||
return e.ErrorCode != 0 || e.ErrorMsg != "" || e.ErrorDescription != ""
|
||||
}
|
||||
|
||||
func (e *errResp) Error() string {
|
||||
return fmt.Sprintf("pikpak error_code=%d error=%s description=%s", e.ErrorCode, e.ErrorMsg, e.ErrorDescription)
|
||||
}
|
||||
|
||||
type captchaTokenRequest struct {
|
||||
Action string `json:"action"`
|
||||
CaptchaToken string `json:"captcha_token"`
|
||||
ClientID string `json:"client_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Meta map[string]string `json:"meta"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
}
|
||||
|
||||
type captchaTokenResponse struct {
|
||||
CaptchaToken string `json:"captcha_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func fileToEntry(f file, parentID string) drives.Entry {
|
||||
size, _ := strconv.ParseInt(f.Size, 10, 64)
|
||||
return drives.Entry{
|
||||
ID: f.ID,
|
||||
Name: f.Name,
|
||||
Size: size,
|
||||
IsDir: f.Kind == "drive#folder",
|
||||
ParentID: parentID,
|
||||
MimeType: guessMime(f.Name),
|
||||
ModTime: f.ModifiedTime,
|
||||
ThumbnailURL: f.ThumbnailLink,
|
||||
}
|
||||
}
|
||||
@@ -1,345 +1,345 @@
|
||||
package quark
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/2.5.20 Chrome/100.0.4896.160 Electron/18.3.5.4-b478491100 Safari/537.36 Channel/pckk_other_ch"
|
||||
defaultReferer = "https://pan.quark.cn"
|
||||
defaultAPI = "https://drive.quark.cn/1/clouddrive"
|
||||
defaultPR = "ucpro"
|
||||
)
|
||||
|
||||
type Driver struct {
|
||||
id string
|
||||
cookie string
|
||||
rootID string
|
||||
ua string
|
||||
referer string
|
||||
apiBase string
|
||||
pr string
|
||||
client *resty.Client
|
||||
onCookieUpdate func(string)
|
||||
useTranscodingAddress bool
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ID string
|
||||
Cookie string
|
||||
RootID string
|
||||
UseTranscodingAddress bool // 开启后对视频文件返回转码直链(支持 302),但可能画质不一致
|
||||
OnCookieUpdate func(cookie string)
|
||||
}
|
||||
|
||||
func New(c Config) *Driver {
|
||||
rootID := c.RootID
|
||||
if rootID == "" {
|
||||
rootID = "0"
|
||||
}
|
||||
d := &Driver{
|
||||
id: c.ID,
|
||||
cookie: c.Cookie,
|
||||
rootID: rootID,
|
||||
ua: defaultUA,
|
||||
referer: defaultReferer,
|
||||
apiBase: defaultAPI,
|
||||
pr: defaultPR,
|
||||
useTranscodingAddress: c.UseTranscodingAddress,
|
||||
onCookieUpdate: c.OnCookieUpdate,
|
||||
}
|
||||
d.client = resty.New().
|
||||
SetTimeout(30 * time.Second).
|
||||
SetHeader("Accept", "application/json, text/plain, */*").
|
||||
SetHeader("Referer", d.referer).
|
||||
SetHeader("User-Agent", d.ua)
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *Driver) Kind() string { return "quark" }
|
||||
func (d *Driver) ID() string { return d.id }
|
||||
func (d *Driver) RootID() string { return d.rootID }
|
||||
|
||||
// ---------- 公共请求 ----------
|
||||
|
||||
type resp struct {
|
||||
Status int `json:"status"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (d *Driver) request(ctx context.Context, path, method string, query map[string]string, body any, out any) error {
|
||||
req := d.client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Cookie", d.cookie).
|
||||
SetQueryParam("pr", d.pr).
|
||||
SetQueryParam("fr", "pc")
|
||||
if query != nil {
|
||||
req.SetQueryParams(query)
|
||||
}
|
||||
if body != nil {
|
||||
req.SetBody(body)
|
||||
}
|
||||
if out != nil {
|
||||
req.SetResult(out)
|
||||
}
|
||||
var e resp
|
||||
req.SetError(&e)
|
||||
|
||||
res, err := req.Execute(method, d.apiBase+path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 处理 cookie 刷新(__puus)
|
||||
for _, ck := range res.Cookies() {
|
||||
if ck.Name == "__puus" {
|
||||
d.cookie = setCookieValue(d.cookie, "__puus", ck.Value)
|
||||
if d.onCookieUpdate != nil {
|
||||
d.onCookieUpdate(d.cookie)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if e.Status >= 400 || e.Code != 0 {
|
||||
if e.Message == "" {
|
||||
return fmt.Errorf("quark api error: status=%d code=%d", e.Status, e.Code)
|
||||
}
|
||||
return errors.New(e.Message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Driver) Init(ctx context.Context) error {
|
||||
return d.request(ctx, "/config", http.MethodGet, nil, nil, nil)
|
||||
}
|
||||
|
||||
// ---------- 列目录 ----------
|
||||
|
||||
type file struct {
|
||||
Fid string `json:"fid"`
|
||||
FileName string `json:"file_name"`
|
||||
Size int64 `json:"size"`
|
||||
Category int `json:"category"`
|
||||
File bool `json:"file"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type sortResp struct {
|
||||
Data struct {
|
||||
List []file `json:"list"`
|
||||
} `json:"data"`
|
||||
Metadata struct {
|
||||
Total int `json:"_total"`
|
||||
} `json:"metadata"`
|
||||
}
|
||||
|
||||
func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) {
|
||||
var out []drives.Entry
|
||||
page := 1
|
||||
size := 100
|
||||
for {
|
||||
q := map[string]string{
|
||||
"pdir_fid": dirID,
|
||||
"_size": strconv.Itoa(size),
|
||||
"_page": strconv.Itoa(page),
|
||||
"_fetch_total": "1",
|
||||
"fetch_all_file": "1",
|
||||
"fetch_risk_file_name": "1",
|
||||
}
|
||||
var r sortResp
|
||||
if err := d.request(ctx, "/file/sort", http.MethodGet, q, nil, &r); err != nil {
|
||||
return nil, fmt.Errorf("quark list: %w", err)
|
||||
}
|
||||
for _, f := range r.Data.List {
|
||||
out = append(out, fileToEntry(&f, dirID))
|
||||
}
|
||||
if page*size >= r.Metadata.Total {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) {
|
||||
// 夸克没提供单文件查询接口,回退到父目录遍历需要额外信息
|
||||
return nil, drives.ErrNotSupported
|
||||
}
|
||||
|
||||
// ---------- 下载直链 ----------
|
||||
|
||||
type downResp struct {
|
||||
Data []struct {
|
||||
DownloadUrl string `json:"download_url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) {
|
||||
body := map[string]any{"fids": []string{fileID}}
|
||||
var r downResp
|
||||
if err := d.request(ctx, "/file/download", http.MethodPost, nil, body, &r); err != nil {
|
||||
return nil, fmt.Errorf("quark download: %w", err)
|
||||
}
|
||||
if len(r.Data) == 0 || r.Data[0].DownloadUrl == "" {
|
||||
return nil, errors.New("quark download: empty url")
|
||||
}
|
||||
|
||||
headers := http.Header{}
|
||||
headers.Set("User-Agent", d.ua)
|
||||
headers.Set("Referer", d.referer)
|
||||
headers.Set("Cookie", d.cookie)
|
||||
|
||||
return &drives.StreamLink{
|
||||
URL: r.Data[0].DownloadUrl,
|
||||
Headers: headers,
|
||||
Expires: time.Now().Add(10 * time.Minute),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------- 创建目录 ----------
|
||||
|
||||
type mkdirResp struct {
|
||||
Data struct {
|
||||
Fid string `json:"fid"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func (d *Driver) MakeDir(ctx context.Context, parentID, name string) (string, error) {
|
||||
body := map[string]any{
|
||||
"dir_init_lock": false,
|
||||
"dir_path": "",
|
||||
"file_name": name,
|
||||
"pdir_fid": parentID,
|
||||
}
|
||||
var r mkdirResp
|
||||
if err := d.request(ctx, "/file", http.MethodPost, nil, body, &r); err != nil {
|
||||
return "", fmt.Errorf("quark mkdir: %w", err)
|
||||
}
|
||||
return r.Data.Fid, nil
|
||||
}
|
||||
|
||||
func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) {
|
||||
parts := splitPath(pathFromRoot)
|
||||
currentID := d.rootID
|
||||
for _, name := range parts {
|
||||
childID, err := d.findChildDir(ctx, currentID, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if childID == "" {
|
||||
id, err := d.MakeDir(ctx, currentID, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
childID = id
|
||||
}
|
||||
currentID = childID
|
||||
}
|
||||
return currentID, nil
|
||||
}
|
||||
|
||||
func (d *Driver) findChildDir(ctx context.Context, parent, name string) (string, error) {
|
||||
entries, err := d.List(ctx, parent)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir && e.Name == name {
|
||||
return e.ID, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// ---------- 上传(第一版不实现,走本地 teaser 兜底) ----------
|
||||
|
||||
func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) {
|
||||
return "", drives.ErrNotSupported
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
func fileToEntry(f *file, parentID string) drives.Entry {
|
||||
return drives.Entry{
|
||||
ID: f.Fid,
|
||||
Name: f.FileName,
|
||||
Size: f.Size,
|
||||
IsDir: !f.File,
|
||||
ParentID: parentID,
|
||||
MimeType: guessMime(f.FileName),
|
||||
ModTime: time.UnixMilli(f.UpdatedAt),
|
||||
Category: f.Category,
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
func splitPath(p string) []string {
|
||||
p = strings.Trim(p, "/")
|
||||
if p == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(p, "/")
|
||||
}
|
||||
|
||||
// setCookieValue 替换 cookie 字符串中某个 key 的值,不存在则追加
|
||||
func setCookieValue(cookie, key, value string) string {
|
||||
if cookie == "" {
|
||||
return key + "=" + value
|
||||
}
|
||||
parts := strings.Split(cookie, ";")
|
||||
var out []string
|
||||
found := false
|
||||
for _, p := range parts {
|
||||
kv := strings.TrimSpace(p)
|
||||
if kv == "" {
|
||||
continue
|
||||
}
|
||||
eq := strings.IndexByte(kv, '=')
|
||||
if eq < 0 {
|
||||
out = append(out, kv)
|
||||
continue
|
||||
}
|
||||
if kv[:eq] == key {
|
||||
out = append(out, key+"="+value)
|
||||
found = true
|
||||
} else {
|
||||
out = append(out, kv)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
out = append(out, key+"="+value)
|
||||
}
|
||||
return strings.Join(out, "; ")
|
||||
}
|
||||
|
||||
var _ drives.Drive = (*Driver)(nil)
|
||||
package quark
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/2.5.20 Chrome/100.0.4896.160 Electron/18.3.5.4-b478491100 Safari/537.36 Channel/pckk_other_ch"
|
||||
defaultReferer = "https://pan.quark.cn"
|
||||
defaultAPI = "https://drive.quark.cn/1/clouddrive"
|
||||
defaultPR = "ucpro"
|
||||
)
|
||||
|
||||
type Driver struct {
|
||||
id string
|
||||
cookie string
|
||||
rootID string
|
||||
ua string
|
||||
referer string
|
||||
apiBase string
|
||||
pr string
|
||||
client *resty.Client
|
||||
onCookieUpdate func(string)
|
||||
useTranscodingAddress bool
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ID string
|
||||
Cookie string
|
||||
RootID string
|
||||
UseTranscodingAddress bool // 开启后对视频文件返回转码直链(支持 302),但可能画质不一致
|
||||
OnCookieUpdate func(cookie string)
|
||||
}
|
||||
|
||||
func New(c Config) *Driver {
|
||||
rootID := c.RootID
|
||||
if rootID == "" {
|
||||
rootID = "0"
|
||||
}
|
||||
d := &Driver{
|
||||
id: c.ID,
|
||||
cookie: c.Cookie,
|
||||
rootID: rootID,
|
||||
ua: defaultUA,
|
||||
referer: defaultReferer,
|
||||
apiBase: defaultAPI,
|
||||
pr: defaultPR,
|
||||
useTranscodingAddress: c.UseTranscodingAddress,
|
||||
onCookieUpdate: c.OnCookieUpdate,
|
||||
}
|
||||
d.client = resty.New().
|
||||
SetTimeout(30 * time.Second).
|
||||
SetHeader("Accept", "application/json, text/plain, */*").
|
||||
SetHeader("Referer", d.referer).
|
||||
SetHeader("User-Agent", d.ua)
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *Driver) Kind() string { return "quark" }
|
||||
func (d *Driver) ID() string { return d.id }
|
||||
func (d *Driver) RootID() string { return d.rootID }
|
||||
|
||||
// ---------- 公共请求 ----------
|
||||
|
||||
type resp struct {
|
||||
Status int `json:"status"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (d *Driver) request(ctx context.Context, path, method string, query map[string]string, body any, out any) error {
|
||||
req := d.client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Cookie", d.cookie).
|
||||
SetQueryParam("pr", d.pr).
|
||||
SetQueryParam("fr", "pc")
|
||||
if query != nil {
|
||||
req.SetQueryParams(query)
|
||||
}
|
||||
if body != nil {
|
||||
req.SetBody(body)
|
||||
}
|
||||
if out != nil {
|
||||
req.SetResult(out)
|
||||
}
|
||||
var e resp
|
||||
req.SetError(&e)
|
||||
|
||||
res, err := req.Execute(method, d.apiBase+path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 处理 cookie 刷新(__puus)
|
||||
for _, ck := range res.Cookies() {
|
||||
if ck.Name == "__puus" {
|
||||
d.cookie = setCookieValue(d.cookie, "__puus", ck.Value)
|
||||
if d.onCookieUpdate != nil {
|
||||
d.onCookieUpdate(d.cookie)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if e.Status >= 400 || e.Code != 0 {
|
||||
if e.Message == "" {
|
||||
return fmt.Errorf("quark api error: status=%d code=%d", e.Status, e.Code)
|
||||
}
|
||||
return errors.New(e.Message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Driver) Init(ctx context.Context) error {
|
||||
return d.request(ctx, "/config", http.MethodGet, nil, nil, nil)
|
||||
}
|
||||
|
||||
// ---------- 列目录 ----------
|
||||
|
||||
type file struct {
|
||||
Fid string `json:"fid"`
|
||||
FileName string `json:"file_name"`
|
||||
Size int64 `json:"size"`
|
||||
Category int `json:"category"`
|
||||
File bool `json:"file"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type sortResp struct {
|
||||
Data struct {
|
||||
List []file `json:"list"`
|
||||
} `json:"data"`
|
||||
Metadata struct {
|
||||
Total int `json:"_total"`
|
||||
} `json:"metadata"`
|
||||
}
|
||||
|
||||
func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) {
|
||||
var out []drives.Entry
|
||||
page := 1
|
||||
size := 100
|
||||
for {
|
||||
q := map[string]string{
|
||||
"pdir_fid": dirID,
|
||||
"_size": strconv.Itoa(size),
|
||||
"_page": strconv.Itoa(page),
|
||||
"_fetch_total": "1",
|
||||
"fetch_all_file": "1",
|
||||
"fetch_risk_file_name": "1",
|
||||
}
|
||||
var r sortResp
|
||||
if err := d.request(ctx, "/file/sort", http.MethodGet, q, nil, &r); err != nil {
|
||||
return nil, fmt.Errorf("quark list: %w", err)
|
||||
}
|
||||
for _, f := range r.Data.List {
|
||||
out = append(out, fileToEntry(&f, dirID))
|
||||
}
|
||||
if page*size >= r.Metadata.Total {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) {
|
||||
// 夸克没提供单文件查询接口,回退到父目录遍历需要额外信息
|
||||
return nil, drives.ErrNotSupported
|
||||
}
|
||||
|
||||
// ---------- 下载直链 ----------
|
||||
|
||||
type downResp struct {
|
||||
Data []struct {
|
||||
DownloadUrl string `json:"download_url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) {
|
||||
body := map[string]any{"fids": []string{fileID}}
|
||||
var r downResp
|
||||
if err := d.request(ctx, "/file/download", http.MethodPost, nil, body, &r); err != nil {
|
||||
return nil, fmt.Errorf("quark download: %w", err)
|
||||
}
|
||||
if len(r.Data) == 0 || r.Data[0].DownloadUrl == "" {
|
||||
return nil, errors.New("quark download: empty url")
|
||||
}
|
||||
|
||||
headers := http.Header{}
|
||||
headers.Set("User-Agent", d.ua)
|
||||
headers.Set("Referer", d.referer)
|
||||
headers.Set("Cookie", d.cookie)
|
||||
|
||||
return &drives.StreamLink{
|
||||
URL: r.Data[0].DownloadUrl,
|
||||
Headers: headers,
|
||||
Expires: time.Now().Add(10 * time.Minute),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------- 创建目录 ----------
|
||||
|
||||
type mkdirResp struct {
|
||||
Data struct {
|
||||
Fid string `json:"fid"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func (d *Driver) MakeDir(ctx context.Context, parentID, name string) (string, error) {
|
||||
body := map[string]any{
|
||||
"dir_init_lock": false,
|
||||
"dir_path": "",
|
||||
"file_name": name,
|
||||
"pdir_fid": parentID,
|
||||
}
|
||||
var r mkdirResp
|
||||
if err := d.request(ctx, "/file", http.MethodPost, nil, body, &r); err != nil {
|
||||
return "", fmt.Errorf("quark mkdir: %w", err)
|
||||
}
|
||||
return r.Data.Fid, nil
|
||||
}
|
||||
|
||||
func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) {
|
||||
parts := splitPath(pathFromRoot)
|
||||
currentID := d.rootID
|
||||
for _, name := range parts {
|
||||
childID, err := d.findChildDir(ctx, currentID, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if childID == "" {
|
||||
id, err := d.MakeDir(ctx, currentID, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
childID = id
|
||||
}
|
||||
currentID = childID
|
||||
}
|
||||
return currentID, nil
|
||||
}
|
||||
|
||||
func (d *Driver) findChildDir(ctx context.Context, parent, name string) (string, error) {
|
||||
entries, err := d.List(ctx, parent)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir && e.Name == name {
|
||||
return e.ID, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// ---------- 上传(第一版不实现,走本地 teaser 兜底) ----------
|
||||
|
||||
func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) {
|
||||
return "", drives.ErrNotSupported
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
func fileToEntry(f *file, parentID string) drives.Entry {
|
||||
return drives.Entry{
|
||||
ID: f.Fid,
|
||||
Name: f.FileName,
|
||||
Size: f.Size,
|
||||
IsDir: !f.File,
|
||||
ParentID: parentID,
|
||||
MimeType: guessMime(f.FileName),
|
||||
ModTime: time.UnixMilli(f.UpdatedAt),
|
||||
Category: f.Category,
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
func splitPath(p string) []string {
|
||||
p = strings.Trim(p, "/")
|
||||
if p == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(p, "/")
|
||||
}
|
||||
|
||||
// setCookieValue 替换 cookie 字符串中某个 key 的值,不存在则追加
|
||||
func setCookieValue(cookie, key, value string) string {
|
||||
if cookie == "" {
|
||||
return key + "=" + value
|
||||
}
|
||||
parts := strings.Split(cookie, ";")
|
||||
var out []string
|
||||
found := false
|
||||
for _, p := range parts {
|
||||
kv := strings.TrimSpace(p)
|
||||
if kv == "" {
|
||||
continue
|
||||
}
|
||||
eq := strings.IndexByte(kv, '=')
|
||||
if eq < 0 {
|
||||
out = append(out, kv)
|
||||
continue
|
||||
}
|
||||
if kv[:eq] == key {
|
||||
out = append(out, key+"="+value)
|
||||
found = true
|
||||
} else {
|
||||
out = append(out, kv)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
out = append(out, key+"="+value)
|
||||
}
|
||||
return strings.Join(out, "; ")
|
||||
}
|
||||
|
||||
var _ drives.Drive = (*Driver)(nil)
|
||||
|
||||
@@ -1,231 +1,231 @@
|
||||
package wopan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "github.com/OpenListTeam/wopan-sdk-go"
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
)
|
||||
|
||||
// Driver 封装联通沃盘
|
||||
type Driver struct {
|
||||
id string
|
||||
rootID string
|
||||
familyID string
|
||||
accessToken string
|
||||
refreshToken string
|
||||
client *sdk.WoClient
|
||||
onTokenUpdate func(access, refresh string)
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ID string
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
FamilyID string // 空则走个人空间,有值则走家庭空间
|
||||
RootID string // 根目录 ID,默认 "0"
|
||||
// 当 SDK 刷新 token 时回调,便于持久化
|
||||
OnTokenUpdate func(access, refresh string)
|
||||
}
|
||||
|
||||
func New(c Config) *Driver {
|
||||
rootID := c.RootID
|
||||
if rootID == "" {
|
||||
rootID = "0"
|
||||
}
|
||||
return &Driver{
|
||||
id: c.ID,
|
||||
rootID: rootID,
|
||||
familyID: c.FamilyID,
|
||||
accessToken: c.AccessToken,
|
||||
refreshToken: c.RefreshToken,
|
||||
onTokenUpdate: c.OnTokenUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Driver) Kind() string { return "wopan" }
|
||||
func (d *Driver) ID() string { return d.id }
|
||||
func (d *Driver) RootID() string {
|
||||
return d.rootID
|
||||
}
|
||||
|
||||
func (d *Driver) Init(ctx context.Context) error {
|
||||
d.client = sdk.DefaultWithRefreshToken(d.refreshToken)
|
||||
d.client.SetAccessToken(d.accessToken)
|
||||
d.client.OnRefreshToken(func(access, refresh string) {
|
||||
d.accessToken = access
|
||||
d.refreshToken = refresh
|
||||
if d.onTokenUpdate != nil {
|
||||
d.onTokenUpdate(access, refresh)
|
||||
}
|
||||
})
|
||||
// InitData 会触发一次 token 校验
|
||||
return d.client.InitData()
|
||||
}
|
||||
|
||||
func (d *Driver) spaceType() string {
|
||||
if d.familyID != "" {
|
||||
return sdk.SpaceTypeFamily
|
||||
}
|
||||
return sdk.SpaceTypePersonal
|
||||
}
|
||||
|
||||
func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) {
|
||||
var result []drives.Entry
|
||||
pageNum := 0
|
||||
pageSize := 100
|
||||
for {
|
||||
data, err := d.client.QueryAllFiles(d.spaceType(), dirID, pageNum, pageSize, 0, d.familyID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wopan list: %w", err)
|
||||
}
|
||||
for _, f := range data.Files {
|
||||
result = append(result, fileToEntry(f, dirID))
|
||||
}
|
||||
if len(data.Files) < pageSize {
|
||||
break
|
||||
}
|
||||
pageNum++
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) {
|
||||
// 沃盘 SDK 没有单文件查询,退化为遍历父目录 —— 这里第一版只在 scanner 路径使用 List,Stat 保留 stub
|
||||
return nil, drives.ErrNotSupported
|
||||
}
|
||||
|
||||
func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) {
|
||||
data, err := d.client.GetDownloadUrlV2([]string{fileID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wopan download url: %w", err)
|
||||
}
|
||||
if len(data.List) == 0 {
|
||||
return nil, fmt.Errorf("wopan download url: empty response")
|
||||
}
|
||||
return &drives.StreamLink{
|
||||
URL: data.List[0].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) {
|
||||
// wopan SDK 要求 *os.File,先把流落到临时文件再上传
|
||||
tmp, err := os.CreateTemp("", "wopan-upload-*.tmp")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() {
|
||||
tmp.Close()
|
||||
os.Remove(tmp.Name())
|
||||
}()
|
||||
if _, err := io.Copy(tmp, r); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := tmp.Seek(0, 0); err != nil {
|
||||
return "", err
|
||||
}
|
||||
fid, err := d.client.Upload2C(d.spaceType(), sdk.Upload2CFile{
|
||||
Name: name,
|
||||
Size: size,
|
||||
Content: tmp,
|
||||
ContentType: "application/octet-stream",
|
||||
}, parentID, d.familyID, sdk.Upload2COption{Ctx: ctx})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wopan upload: %w", err)
|
||||
}
|
||||
return fid, nil
|
||||
}
|
||||
|
||||
func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) {
|
||||
parts := splitPath(pathFromRoot)
|
||||
currentID := d.rootID
|
||||
for _, name := range parts {
|
||||
childID, err := d.findChildDir(ctx, currentID, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if childID == "" {
|
||||
resp, err := d.client.CreateDirectory(d.spaceType(), currentID, name, d.familyID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wopan mkdir %s: %w", name, err)
|
||||
}
|
||||
childID = resp.Id
|
||||
}
|
||||
currentID = childID
|
||||
}
|
||||
return currentID, nil
|
||||
}
|
||||
|
||||
func (d *Driver) findChildDir(ctx context.Context, parent, name string) (string, error) {
|
||||
entries, err := d.List(ctx, parent)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir && e.Name == name {
|
||||
return e.ID, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func splitPath(p string) []string {
|
||||
p = strings.Trim(p, "/")
|
||||
if p == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(p, "/")
|
||||
}
|
||||
|
||||
func fileToEntry(f *sdk.File, parentID string) drives.Entry {
|
||||
mod, _ := time.Parse("2006-01-02 15:04:05", f.CreateTime)
|
||||
name := f.Name
|
||||
isDir := f.Type == 0
|
||||
id := f.Fid
|
||||
if id == "" {
|
||||
id = f.Id
|
||||
}
|
||||
if isDir && !strings.HasSuffix(name, "/") {
|
||||
// 不改 name,只标志
|
||||
}
|
||||
return drives.Entry{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Size: f.Size,
|
||||
IsDir: isDir,
|
||||
ParentID: parentID,
|
||||
MimeType: guessMime(name),
|
||||
ModTime: mod,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
package wopan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "github.com/OpenListTeam/wopan-sdk-go"
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
)
|
||||
|
||||
// Driver 封装联通沃盘
|
||||
type Driver struct {
|
||||
id string
|
||||
rootID string
|
||||
familyID string
|
||||
accessToken string
|
||||
refreshToken string
|
||||
client *sdk.WoClient
|
||||
onTokenUpdate func(access, refresh string)
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ID string
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
FamilyID string // 空则走个人空间,有值则走家庭空间
|
||||
RootID string // 根目录 ID,默认 "0"
|
||||
// 当 SDK 刷新 token 时回调,便于持久化
|
||||
OnTokenUpdate func(access, refresh string)
|
||||
}
|
||||
|
||||
func New(c Config) *Driver {
|
||||
rootID := c.RootID
|
||||
if rootID == "" {
|
||||
rootID = "0"
|
||||
}
|
||||
return &Driver{
|
||||
id: c.ID,
|
||||
rootID: rootID,
|
||||
familyID: c.FamilyID,
|
||||
accessToken: c.AccessToken,
|
||||
refreshToken: c.RefreshToken,
|
||||
onTokenUpdate: c.OnTokenUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Driver) Kind() string { return "wopan" }
|
||||
func (d *Driver) ID() string { return d.id }
|
||||
func (d *Driver) RootID() string {
|
||||
return d.rootID
|
||||
}
|
||||
|
||||
func (d *Driver) Init(ctx context.Context) error {
|
||||
d.client = sdk.DefaultWithRefreshToken(d.refreshToken)
|
||||
d.client.SetAccessToken(d.accessToken)
|
||||
d.client.OnRefreshToken(func(access, refresh string) {
|
||||
d.accessToken = access
|
||||
d.refreshToken = refresh
|
||||
if d.onTokenUpdate != nil {
|
||||
d.onTokenUpdate(access, refresh)
|
||||
}
|
||||
})
|
||||
// InitData 会触发一次 token 校验
|
||||
return d.client.InitData()
|
||||
}
|
||||
|
||||
func (d *Driver) spaceType() string {
|
||||
if d.familyID != "" {
|
||||
return sdk.SpaceTypeFamily
|
||||
}
|
||||
return sdk.SpaceTypePersonal
|
||||
}
|
||||
|
||||
func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) {
|
||||
var result []drives.Entry
|
||||
pageNum := 0
|
||||
pageSize := 100
|
||||
for {
|
||||
data, err := d.client.QueryAllFiles(d.spaceType(), dirID, pageNum, pageSize, 0, d.familyID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wopan list: %w", err)
|
||||
}
|
||||
for _, f := range data.Files {
|
||||
result = append(result, fileToEntry(f, dirID))
|
||||
}
|
||||
if len(data.Files) < pageSize {
|
||||
break
|
||||
}
|
||||
pageNum++
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) {
|
||||
// 沃盘 SDK 没有单文件查询,退化为遍历父目录 —— 这里第一版只在 scanner 路径使用 List,Stat 保留 stub
|
||||
return nil, drives.ErrNotSupported
|
||||
}
|
||||
|
||||
func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) {
|
||||
data, err := d.client.GetDownloadUrlV2([]string{fileID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wopan download url: %w", err)
|
||||
}
|
||||
if len(data.List) == 0 {
|
||||
return nil, fmt.Errorf("wopan download url: empty response")
|
||||
}
|
||||
return &drives.StreamLink{
|
||||
URL: data.List[0].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) {
|
||||
// wopan SDK 要求 *os.File,先把流落到临时文件再上传
|
||||
tmp, err := os.CreateTemp("", "wopan-upload-*.tmp")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() {
|
||||
tmp.Close()
|
||||
os.Remove(tmp.Name())
|
||||
}()
|
||||
if _, err := io.Copy(tmp, r); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := tmp.Seek(0, 0); err != nil {
|
||||
return "", err
|
||||
}
|
||||
fid, err := d.client.Upload2C(d.spaceType(), sdk.Upload2CFile{
|
||||
Name: name,
|
||||
Size: size,
|
||||
Content: tmp,
|
||||
ContentType: "application/octet-stream",
|
||||
}, parentID, d.familyID, sdk.Upload2COption{Ctx: ctx})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wopan upload: %w", err)
|
||||
}
|
||||
return fid, nil
|
||||
}
|
||||
|
||||
func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) {
|
||||
parts := splitPath(pathFromRoot)
|
||||
currentID := d.rootID
|
||||
for _, name := range parts {
|
||||
childID, err := d.findChildDir(ctx, currentID, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if childID == "" {
|
||||
resp, err := d.client.CreateDirectory(d.spaceType(), currentID, name, d.familyID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wopan mkdir %s: %w", name, err)
|
||||
}
|
||||
childID = resp.Id
|
||||
}
|
||||
currentID = childID
|
||||
}
|
||||
return currentID, nil
|
||||
}
|
||||
|
||||
func (d *Driver) findChildDir(ctx context.Context, parent, name string) (string, error) {
|
||||
entries, err := d.List(ctx, parent)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir && e.Name == name {
|
||||
return e.ID, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func splitPath(p string) []string {
|
||||
p = strings.Trim(p, "/")
|
||||
if p == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(p, "/")
|
||||
}
|
||||
|
||||
func fileToEntry(f *sdk.File, parentID string) drives.Entry {
|
||||
mod, _ := time.Parse("2006-01-02 15:04:05", f.CreateTime)
|
||||
name := f.Name
|
||||
isDir := f.Type == 0
|
||||
id := f.Fid
|
||||
if id == "" {
|
||||
id = f.Id
|
||||
}
|
||||
if isDir && !strings.HasSuffix(name, "/") {
|
||||
// 不改 name,只标志
|
||||
}
|
||||
return drives.Entry{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Size: f.Size,
|
||||
IsDir: isDir,
|
||||
ParentID: parentID,
|
||||
MimeType: guessMime(name),
|
||||
ModTime: mod,
|
||||
}
|
||||
}
|
||||
|
||||
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,100 @@
|
||||
package fixedtags
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var Labels = []string{"后入", "奶子", "口交", "臀", "人妻", "女大"}
|
||||
|
||||
var aliases = map[string][]string{
|
||||
"后入": {"后入", "後入", "后入式", "後入式", "后进", "後進", "后位", "後位", "背入", "背后", "后背", "背后式", "后背位", "狗爬", "狗爬式", "追尾", "doggy", "doggystyle", "doggy style", "doggy-style", "backshot", "back shot", "back-shot", "from behind", "rear entry"},
|
||||
"奶子": {"奶子", "奶", "大奶", "巨乳", "美乳", "爆乳", "丰乳", "丰胸", "大胸", "胸", "胸部", "胸器", "胸前", "揉胸", "揉奶", "揉乳", "双乳", "乳房", "乳头", "美胸", "boob", "boobs", "big boobs", "big-boobs", "tits", "titties", "titty", "breast", "breasts"},
|
||||
"口交": {"口交", "口爆", "口活", "口射", "吹箫", "吹萧", "深喉", "吞精", "含屌", "含鸡巴", "含龟头", "舔屌", "bj", "blowjob", "blow job", "oral", "oral sex", "oral-sex", "oralsex", "fellatio"},
|
||||
"臀": {"臀", "屁股", "屁屁", "翘臀", "美臀", "肥臀", "巨臀", "蜜桃臀", "大屁股", "尻", "后庭", "後庭", "菊花", "肛", "肛交", "屁眼", "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"},
|
||||
}
|
||||
|
||||
func MatchFilename(name string) []string {
|
||||
text := normalize(name)
|
||||
out := make([]string, 0, len(Labels))
|
||||
for _, label := range Labels {
|
||||
for _, alias := range aliases[label] {
|
||||
if text.contains(alias) {
|
||||
out = append(out, label)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type normalizedText struct {
|
||||
lower string
|
||||
compact string
|
||||
tokens map[string]struct{}
|
||||
}
|
||||
|
||||
func normalize(s string) normalizedText {
|
||||
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 normalizedText{
|
||||
lower: lower,
|
||||
compact: compact.String(),
|
||||
tokens: tokens,
|
||||
}
|
||||
}
|
||||
|
||||
func (n normalizedText) contains(alias string) bool {
|
||||
lowerAlias := strings.ToLower(alias)
|
||||
compactAlias := compact(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 compact(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
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package fixedtags
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMatchFilenameMapsSimilarTermsToFixedLabels(t *testing.T) {
|
||||
got := MatchFilename("back-shot oral-sex big boobs big ass wife college student.mp4")
|
||||
want := []string{"后入", "奶子", "口交", "臀", "人妻", "女大"}
|
||||
|
||||
if !sameStrings(got, want) {
|
||||
t.Fatalf("tags = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchFilenameMapsChineseSimilarTermsToFixedLabels(t *testing.T) {
|
||||
got := MatchFilename("背后式揉乳口活蜜桃臀少妇大学.mp4")
|
||||
want := []string{"后入", "奶子", "口交", "臀", "人妻", "女大"}
|
||||
|
||||
if !sameStrings(got, want) {
|
||||
t.Fatalf("tags = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
+562
-493
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/video-site/backend/internal/catalog"
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
)
|
||||
|
||||
func TestThumbWorkerUpdatesThumbnailWithoutChangingPreviewStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cat, video := seedPreviewTestVideo(t, "thumb-worker-video")
|
||||
|
||||
gen := &fakeThumbGenerator{}
|
||||
drv := &previewFakeDrive{}
|
||||
worker := NewThumbWorker(gen, cat, drv)
|
||||
|
||||
worker.process(ctx, video)
|
||||
|
||||
got, err := cat.GetVideo(ctx, video.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get video: %v", err)
|
||||
}
|
||||
if got.ThumbnailURL != "/p/thumb/"+video.ID {
|
||||
t.Fatalf("thumbnail = %q, want generated thumb URL", got.ThumbnailURL)
|
||||
}
|
||||
if got.PreviewStatus != "pending" {
|
||||
t.Fatalf("preview status = %q, want pending", got.PreviewStatus)
|
||||
}
|
||||
if got.DurationSeconds != 42 {
|
||||
t.Fatalf("duration = %d, want probed duration", got.DurationSeconds)
|
||||
}
|
||||
if gen.thumbnailVideoID != video.ID {
|
||||
t.Fatalf("thumbnail video id = %q, want %q", gen.thumbnailVideoID, video.ID)
|
||||
}
|
||||
if gen.thumbnailDuration != 42 {
|
||||
t.Fatalf("thumbnail duration = %.1f, want 42", gen.thumbnailDuration)
|
||||
}
|
||||
if drv.streamFileID != video.FileID {
|
||||
t.Fatalf("stream file id = %q, want %q", drv.streamFileID, video.FileID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewWorkerGeneratesTeaserWithoutReplacingExistingThumbnail(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cat, video := seedPreviewTestVideo(t, "preview-worker-video")
|
||||
video.ThumbnailURL = "https://thumbnail.example/original.jpg"
|
||||
if err := cat.UpsertVideo(ctx, video); err != nil {
|
||||
t.Fatalf("update video: %v", err)
|
||||
}
|
||||
|
||||
gen := &fakeTeaserGenerator{}
|
||||
drv := &previewFakeDrive{}
|
||||
worker := NewWorker(gen, cat, drv, "")
|
||||
|
||||
worker.process(ctx, video)
|
||||
|
||||
got, err := cat.GetVideo(ctx, video.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get video: %v", err)
|
||||
}
|
||||
if got.ThumbnailURL != "https://thumbnail.example/original.jpg" {
|
||||
t.Fatalf("thumbnail = %q, want existing thumbnail unchanged", got.ThumbnailURL)
|
||||
}
|
||||
if got.PreviewStatus != "ready" {
|
||||
t.Fatalf("preview status = %q, want ready", got.PreviewStatus)
|
||||
}
|
||||
if got.PreviewLocal != "/tmp/"+video.ID+".mp4" {
|
||||
t.Fatalf("preview local = %q, want moved teaser path", got.PreviewLocal)
|
||||
}
|
||||
}
|
||||
|
||||
func seedPreviewTestVideo(t *testing.T, id string) (*catalog.Catalog, *catalog.Video) {
|
||||
t.Helper()
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
video := &catalog.Video{
|
||||
ID: id,
|
||||
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)
|
||||
}
|
||||
return cat, video
|
||||
}
|
||||
|
||||
type fakeThumbGenerator struct {
|
||||
thumbnailVideoID string
|
||||
thumbnailDuration float64
|
||||
}
|
||||
|
||||
func (g *fakeThumbGenerator) Probe(context.Context, *drives.StreamLink) (float64, error) {
|
||||
return 42, nil
|
||||
}
|
||||
|
||||
func (g *fakeThumbGenerator) GenerateThumbnail(_ context.Context, _ *drives.StreamLink, videoID string, duration float64) (string, error) {
|
||||
g.thumbnailVideoID = videoID
|
||||
g.thumbnailDuration = duration
|
||||
return "/tmp/" + videoID + ".jpg", nil
|
||||
}
|
||||
|
||||
type fakeTeaserGenerator struct{}
|
||||
|
||||
func (g *fakeTeaserGenerator) Probe(context.Context, *drives.StreamLink) (float64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (g *fakeTeaserGenerator) Generate(context.Context, *drives.StreamLink, float64) (string, error) {
|
||||
return "/tmp/source-teaser.mp4", nil
|
||||
}
|
||||
|
||||
func (g *fakeTeaserGenerator) MoveToLocal(_ string, videoID string) (string, error) {
|
||||
return "/tmp/" + videoID + ".mp4", nil
|
||||
}
|
||||
|
||||
type previewFakeDrive struct {
|
||||
streamFileID string
|
||||
}
|
||||
|
||||
func (d *previewFakeDrive) Kind() string { return "fake" }
|
||||
func (d *previewFakeDrive) ID() string { return "drive-id" }
|
||||
func (d *previewFakeDrive) Init(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
func (d *previewFakeDrive) List(context.Context, string) ([]drives.Entry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (d *previewFakeDrive) Stat(context.Context, string) (*drives.Entry, error) {
|
||||
return nil, drives.ErrNotSupported
|
||||
}
|
||||
func (d *previewFakeDrive) StreamURL(_ context.Context, fileID string) (*drives.StreamLink, error) {
|
||||
d.streamFileID = fileID
|
||||
return &drives.StreamLink{URL: "https://video.example/clip.mp4"}, nil
|
||||
}
|
||||
func (d *previewFakeDrive) Upload(context.Context, string, string, io.Reader, int64) (string, error) {
|
||||
return "", drives.ErrNotSupported
|
||||
}
|
||||
func (d *previewFakeDrive) EnsureDir(context.Context, string) (string, error) {
|
||||
return "", drives.ErrNotSupported
|
||||
}
|
||||
func (d *previewFakeDrive) RootID() string { return "root" }
|
||||
+169
-169
@@ -1,169 +1,169 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
)
|
||||
|
||||
// Registry 管理多个 Drive 实例
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
drives map[string]drives.Drive
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{drives: make(map[string]drives.Drive)}
|
||||
}
|
||||
|
||||
func (r *Registry) Set(id string, d drives.Drive) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.drives[id] = d
|
||||
}
|
||||
|
||||
func (r *Registry) Get(id string) (drives.Drive, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
d, ok := r.drives[id]
|
||||
return d, ok
|
||||
}
|
||||
|
||||
func (r *Registry) All() []drives.Drive {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]drives.Drive, 0, len(r.drives))
|
||||
for _, d := range r.drives {
|
||||
out = append(out, d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Registry) Remove(id string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
delete(r.drives, id)
|
||||
}
|
||||
|
||||
// Proxy 根据 driveID + fileID 反向代理到真实网盘直链
|
||||
type Proxy struct {
|
||||
Registry *Registry
|
||||
// linkCache key: driveID + "/" + fileID,value: cachedLink
|
||||
cacheMu sync.Mutex
|
||||
cache map[string]cachedLink
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
type cachedLink struct {
|
||||
link *drives.StreamLink
|
||||
fetched time.Time
|
||||
}
|
||||
|
||||
func New(r *Registry) *Proxy {
|
||||
return &Proxy{
|
||||
Registry: r,
|
||||
cache: make(map[string]cachedLink),
|
||||
http: &http.Client{
|
||||
Timeout: 0, // 流式不设超时
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Proxy) getLink(ctx context.Context, driveID, fileID string) (*drives.StreamLink, error) {
|
||||
key := driveID + "/" + fileID
|
||||
|
||||
p.cacheMu.Lock()
|
||||
if c, ok := p.cache[key]; ok {
|
||||
// 缓存 30 秒,且不超过 link.Expires
|
||||
if time.Since(c.fetched) < 30*time.Second && time.Now().Before(c.link.Expires) {
|
||||
p.cacheMu.Unlock()
|
||||
return c.link, nil
|
||||
}
|
||||
}
|
||||
p.cacheMu.Unlock()
|
||||
|
||||
d, ok := p.Registry.Get(driveID)
|
||||
if !ok {
|
||||
return nil, errDriveNotFound
|
||||
}
|
||||
link, err := d.StreamURL(ctx, fileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.cacheMu.Lock()
|
||||
p.cache[key] = cachedLink{link: link, fetched: time.Now()}
|
||||
p.cacheMu.Unlock()
|
||||
return link, nil
|
||||
}
|
||||
|
||||
func (p *Proxy) ServeStream(w http.ResponseWriter, r *http.Request, driveID, fileID string) {
|
||||
link, err := p.getLink(r.Context(), driveID, fileID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
p.serve(w, r, link)
|
||||
}
|
||||
|
||||
func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, link *drives.StreamLink) {
|
||||
// 构造上游请求
|
||||
u, err := url.Parse(link.URL)
|
||||
if err != nil {
|
||||
http.Error(w, "bad upstream url", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
req, err := http.NewRequestWithContext(r.Context(), r.Method, u.String(), nil)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// 复制上游请求头
|
||||
for k, vs := range link.Headers {
|
||||
for _, v := range vs {
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
// 透传 Range
|
||||
if rng := r.Header.Get("Range"); rng != "" {
|
||||
req.Header.Set("Range", rng)
|
||||
}
|
||||
|
||||
resp, err := p.http.Do(req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 透传响应头
|
||||
for _, k := range []string{
|
||||
"Content-Type", "Content-Length", "Content-Range",
|
||||
"Accept-Ranges", "Last-Modified", "Etag",
|
||||
} {
|
||||
if v := resp.Header.Get(k); v != "" {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Cache-Control", "private, max-age=300")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
// ServeLocal 服务本地 teaser 文件
|
||||
func (p *Proxy) ServeLocal(w http.ResponseWriter, r *http.Request, path string) {
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
|
||||
var errDriveNotFound = &httpError{Code: http.StatusNotFound, Msg: "drive not found"}
|
||||
|
||||
type httpError struct {
|
||||
Code int
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (e *httpError) Error() string { return e.Msg }
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
)
|
||||
|
||||
// Registry 管理多个 Drive 实例
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
drives map[string]drives.Drive
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{drives: make(map[string]drives.Drive)}
|
||||
}
|
||||
|
||||
func (r *Registry) Set(id string, d drives.Drive) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.drives[id] = d
|
||||
}
|
||||
|
||||
func (r *Registry) Get(id string) (drives.Drive, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
d, ok := r.drives[id]
|
||||
return d, ok
|
||||
}
|
||||
|
||||
func (r *Registry) All() []drives.Drive {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]drives.Drive, 0, len(r.drives))
|
||||
for _, d := range r.drives {
|
||||
out = append(out, d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Registry) Remove(id string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
delete(r.drives, id)
|
||||
}
|
||||
|
||||
// Proxy 根据 driveID + fileID 反向代理到真实网盘直链
|
||||
type Proxy struct {
|
||||
Registry *Registry
|
||||
// linkCache key: driveID + "/" + fileID,value: cachedLink
|
||||
cacheMu sync.Mutex
|
||||
cache map[string]cachedLink
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
type cachedLink struct {
|
||||
link *drives.StreamLink
|
||||
fetched time.Time
|
||||
}
|
||||
|
||||
func New(r *Registry) *Proxy {
|
||||
return &Proxy{
|
||||
Registry: r,
|
||||
cache: make(map[string]cachedLink),
|
||||
http: &http.Client{
|
||||
Timeout: 0, // 流式不设超时
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Proxy) getLink(ctx context.Context, driveID, fileID string) (*drives.StreamLink, error) {
|
||||
key := driveID + "/" + fileID
|
||||
|
||||
p.cacheMu.Lock()
|
||||
if c, ok := p.cache[key]; ok {
|
||||
// 缓存 30 秒,且不超过 link.Expires
|
||||
if time.Since(c.fetched) < 30*time.Second && time.Now().Before(c.link.Expires) {
|
||||
p.cacheMu.Unlock()
|
||||
return c.link, nil
|
||||
}
|
||||
}
|
||||
p.cacheMu.Unlock()
|
||||
|
||||
d, ok := p.Registry.Get(driveID)
|
||||
if !ok {
|
||||
return nil, errDriveNotFound
|
||||
}
|
||||
link, err := d.StreamURL(ctx, fileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.cacheMu.Lock()
|
||||
p.cache[key] = cachedLink{link: link, fetched: time.Now()}
|
||||
p.cacheMu.Unlock()
|
||||
return link, nil
|
||||
}
|
||||
|
||||
func (p *Proxy) ServeStream(w http.ResponseWriter, r *http.Request, driveID, fileID string) {
|
||||
link, err := p.getLink(r.Context(), driveID, fileID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
p.serve(w, r, link)
|
||||
}
|
||||
|
||||
func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, link *drives.StreamLink) {
|
||||
// 构造上游请求
|
||||
u, err := url.Parse(link.URL)
|
||||
if err != nil {
|
||||
http.Error(w, "bad upstream url", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
req, err := http.NewRequestWithContext(r.Context(), r.Method, u.String(), nil)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// 复制上游请求头
|
||||
for k, vs := range link.Headers {
|
||||
for _, v := range vs {
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
// 透传 Range
|
||||
if rng := r.Header.Get("Range"); rng != "" {
|
||||
req.Header.Set("Range", rng)
|
||||
}
|
||||
|
||||
resp, err := p.http.Do(req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 透传响应头
|
||||
for _, k := range []string{
|
||||
"Content-Type", "Content-Length", "Content-Range",
|
||||
"Accept-Ranges", "Last-Modified", "Etag",
|
||||
} {
|
||||
if v := resp.Header.Get(k); v != "" {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Cache-Control", "private, max-age=300")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
// ServeLocal 服务本地 teaser 文件
|
||||
func (p *Proxy) ServeLocal(w http.ResponseWriter, r *http.Request, path string) {
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
|
||||
var errDriveNotFound = &httpError{Code: http.StatusNotFound, Msg: "drive not found"}
|
||||
|
||||
type httpError struct {
|
||||
Code int
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (e *httpError) Error() string { return e.Msg }
|
||||
|
||||
@@ -1,49 +1,42 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParsedName 从文件名里解析出的视频元数据
|
||||
type ParsedName struct {
|
||||
Title string
|
||||
Author string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
var (
|
||||
reTags = regexp.MustCompile(`^\s*\[([^\]]+)\]\s*`) // [tag1,tag2]
|
||||
reAuthor = regexp.MustCompile(`\s*-\s*([^-]+?)\s*$`) // - author
|
||||
)
|
||||
|
||||
// Parse 按约定解析:[tag1,tag2] 标题 - 作者.ext
|
||||
// 任何字段缺失都能降级
|
||||
func Parse(filename string) ParsedName {
|
||||
name := strings.TrimSuffix(filename, path.Ext(filename))
|
||||
|
||||
var out ParsedName
|
||||
|
||||
if m := reTags.FindStringSubmatch(name); m != nil {
|
||||
raw := m[1]
|
||||
parts := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == ',' || r == '、' || r == ' '
|
||||
})
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out.Tags = append(out.Tags, p)
|
||||
}
|
||||
}
|
||||
name = strings.TrimSpace(name[len(m[0]):])
|
||||
}
|
||||
|
||||
if m := reAuthor.FindStringSubmatch(name); m != nil {
|
||||
out.Author = strings.TrimSpace(m[1])
|
||||
name = strings.TrimSpace(name[:len(name)-len(m[0])])
|
||||
}
|
||||
|
||||
out.Title = strings.TrimSpace(name)
|
||||
return out
|
||||
}
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/video-site/backend/internal/fixedtags"
|
||||
)
|
||||
|
||||
// ParsedName 从文件名里解析出的视频元数据
|
||||
type ParsedName struct {
|
||||
Title string
|
||||
Author string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
var (
|
||||
reTags = regexp.MustCompile(`^\s*\[([^\]]+)\]\s*`) // [tag1,tag2]
|
||||
reAuthor = regexp.MustCompile(`\s*-\s*([^-]+?)\s*$`) // - author
|
||||
)
|
||||
|
||||
// Parse 按约定解析:[tag1,tag2] 标题 - 作者.ext
|
||||
// 任何字段缺失都能降级
|
||||
func Parse(filename string) ParsedName {
|
||||
name := strings.TrimSuffix(filename, path.Ext(filename))
|
||||
|
||||
var out ParsedName
|
||||
|
||||
if m := reTags.FindStringSubmatch(name); m != nil {
|
||||
name = strings.TrimSpace(name[len(m[0]):])
|
||||
}
|
||||
|
||||
if m := reAuthor.FindStringSubmatch(name); m != nil {
|
||||
out.Author = strings.TrimSpace(m[1])
|
||||
name = strings.TrimSpace(name[:len(name)-len(m[0])])
|
||||
}
|
||||
|
||||
out.Title = strings.TrimSpace(name)
|
||||
out.Tags = fixedtags.MatchFilename(filename)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package scanner
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseMatchesOnlyFixedTagsFromFilename(t *testing.T) {
|
||||
got := Parse("[乱七八糟] 女大人妻后入口交奶子臀.mp4")
|
||||
want := []string{"后入", "奶子", "口交", "臀", "人妻", "女大"}
|
||||
|
||||
if !sameStrings(got.Tags, want) {
|
||||
t.Fatalf("tags = %#v, want %#v", got.Tags, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDoesNotKeepBracketTags(t *testing.T) {
|
||||
got := Parse("[sunny,kenny] 普通标题.mp4")
|
||||
|
||||
if len(got.Tags) != 0 {
|
||||
t.Fatalf("tags = %#v, want none", got.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
+163
-139
@@ -1,139 +1,163 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/video-site/backend/internal/catalog"
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
)
|
||||
|
||||
type Scanner struct {
|
||||
Catalog *catalog.Catalog
|
||||
Drive drives.Drive
|
||||
Exts map[string]bool
|
||||
MaxDepth int
|
||||
// 回调:新视频被加入后触发 teaser 生成
|
||||
OnNewVideo func(v *catalog.Video)
|
||||
}
|
||||
|
||||
func New(cat *catalog.Catalog, drv drives.Drive, exts []string, maxDepth int, onNew func(v *catalog.Video)) *Scanner {
|
||||
m := make(map[string]bool, len(exts))
|
||||
for _, e := range exts {
|
||||
m[strings.ToLower(e)] = true
|
||||
}
|
||||
if maxDepth == 0 {
|
||||
maxDepth = 5
|
||||
}
|
||||
return &Scanner{
|
||||
Catalog: cat,
|
||||
Drive: drv,
|
||||
Exts: m,
|
||||
MaxDepth: maxDepth,
|
||||
OnNewVideo: onNew,
|
||||
}
|
||||
}
|
||||
|
||||
type Stats struct {
|
||||
Scanned int
|
||||
Added int
|
||||
}
|
||||
|
||||
// Run 从 Drive.RootID 开始扫描
|
||||
func (s *Scanner) Run(ctx context.Context, startDirID string) (Stats, error) {
|
||||
if startDirID == "" {
|
||||
startDirID = s.Drive.RootID()
|
||||
}
|
||||
stats := Stats{}
|
||||
if err := s.walk(ctx, startDirID, "", 0, &stats); err != nil {
|
||||
return stats, err
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *Scanner) walk(ctx context.Context, dirID, dirName string, depth int, stats *Stats) error {
|
||||
if depth >= s.MaxDepth {
|
||||
return nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries, err := s.Drive.List(ctx, dirID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list %s: %w", dirID, err)
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir {
|
||||
// 跳过 previews 目录,避免扫到自己生成的 teaser
|
||||
if strings.EqualFold(e.Name, "previews") {
|
||||
continue
|
||||
}
|
||||
if err := s.walk(ctx, e.ID, e.Name, depth+1, stats); err != nil {
|
||||
log.Printf("[scanner] walk %s error: %v", e.Name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
stats.Scanned++
|
||||
ext := strings.ToLower(path.Ext(e.Name))
|
||||
if !s.Exts[ext] {
|
||||
continue
|
||||
}
|
||||
|
||||
id := s.Drive.Kind() + "-" + s.Drive.ID() + "-" + e.ID
|
||||
existing, _ := s.Catalog.GetVideo(ctx, id)
|
||||
if existing != nil {
|
||||
// 已存在但 category 空缺时,顺便补 category
|
||||
if existing.Category == "" && dirName != "" {
|
||||
_ = s.Catalog.UpdateVideoMeta(ctx, id, catalog.VideoMetaPatch{Category: dirName})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
parsed := Parse(e.Name)
|
||||
if parsed.Title == "" {
|
||||
parsed.Title = strings.TrimSuffix(e.Name, ext)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
v := &catalog.Video{
|
||||
ID: id,
|
||||
DriveID: s.Drive.ID(),
|
||||
FileID: e.ID,
|
||||
ParentID: e.ParentID,
|
||||
Title: parsed.Title,
|
||||
Author: parsed.Author,
|
||||
Tags: parsed.Tags,
|
||||
Ext: strings.TrimPrefix(ext, "."),
|
||||
Quality: "HD",
|
||||
Size: e.Size,
|
||||
PreviewStatus: "pending",
|
||||
Category: dirName,
|
||||
PublishedAt: orDefault(e.ModTime, now),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := s.Catalog.UpsertVideo(ctx, v); err != nil {
|
||||
log.Printf("[scanner] upsert %s error: %v", v.Title, err)
|
||||
continue
|
||||
}
|
||||
stats.Added++
|
||||
if s.OnNewVideo != nil {
|
||||
s.OnNewVideo(v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func orDefault(t time.Time, d time.Time) time.Time {
|
||||
if t.IsZero() {
|
||||
return d
|
||||
}
|
||||
return t
|
||||
}
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/video-site/backend/internal/catalog"
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
)
|
||||
|
||||
type Scanner struct {
|
||||
Catalog *catalog.Catalog
|
||||
Drive drives.Drive
|
||||
Exts map[string]bool
|
||||
MaxDepth int
|
||||
// 回调:新视频被加入后触发 teaser 生成
|
||||
OnNewVideo func(v *catalog.Video)
|
||||
}
|
||||
|
||||
func New(cat *catalog.Catalog, drv drives.Drive, exts []string, maxDepth int, onNew func(v *catalog.Video)) *Scanner {
|
||||
m := make(map[string]bool, len(exts))
|
||||
for _, e := range exts {
|
||||
m[strings.ToLower(e)] = true
|
||||
}
|
||||
if maxDepth == 0 {
|
||||
maxDepth = 5
|
||||
}
|
||||
return &Scanner{
|
||||
Catalog: cat,
|
||||
Drive: drv,
|
||||
Exts: m,
|
||||
MaxDepth: maxDepth,
|
||||
OnNewVideo: onNew,
|
||||
}
|
||||
}
|
||||
|
||||
type Stats struct {
|
||||
Scanned int
|
||||
Added int
|
||||
}
|
||||
|
||||
// Run 从 Drive.RootID 开始扫描
|
||||
func (s *Scanner) Run(ctx context.Context, startDirID string) (Stats, error) {
|
||||
if startDirID == "" {
|
||||
startDirID = s.Drive.RootID()
|
||||
}
|
||||
stats := Stats{}
|
||||
if err := s.walk(ctx, startDirID, "", 0, &stats); err != nil {
|
||||
return stats, err
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *Scanner) walk(ctx context.Context, dirID, dirName string, depth int, stats *Stats) error {
|
||||
if depth >= s.MaxDepth {
|
||||
return nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries, err := s.Drive.List(ctx, dirID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list %s: %w", dirID, err)
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir {
|
||||
// 跳过 previews 目录,避免扫到自己生成的 teaser
|
||||
if strings.EqualFold(e.Name, "previews") {
|
||||
continue
|
||||
}
|
||||
if err := s.walk(ctx, e.ID, e.Name, depth+1, stats); err != nil {
|
||||
log.Printf("[scanner] walk %s error: %v", e.Name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
stats.Scanned++
|
||||
ext := strings.ToLower(path.Ext(e.Name))
|
||||
if !s.Exts[ext] {
|
||||
continue
|
||||
}
|
||||
|
||||
id := s.Drive.Kind() + "-" + s.Drive.ID() + "-" + e.ID
|
||||
parsed := Parse(e.Name)
|
||||
if parsed.Title == "" {
|
||||
parsed.Title = strings.TrimSuffix(e.Name, ext)
|
||||
}
|
||||
|
||||
existing, _ := s.Catalog.GetVideo(ctx, id)
|
||||
if existing != nil {
|
||||
// 已存在但轻量元数据空缺时,顺便补齐。
|
||||
patch := catalog.VideoMetaPatch{}
|
||||
if existing.Category == "" && dirName != "" {
|
||||
patch.Category = dirName
|
||||
}
|
||||
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 {
|
||||
_ = s.Catalog.UpdateVideoMeta(ctx, id, patch)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
v := &catalog.Video{
|
||||
ID: id,
|
||||
DriveID: s.Drive.ID(),
|
||||
FileID: e.ID,
|
||||
ParentID: e.ParentID,
|
||||
Title: parsed.Title,
|
||||
Author: parsed.Author,
|
||||
Tags: parsed.Tags,
|
||||
Ext: strings.TrimPrefix(ext, "."),
|
||||
Quality: "HD",
|
||||
Size: e.Size,
|
||||
ThumbnailURL: e.ThumbnailURL,
|
||||
PreviewStatus: "pending",
|
||||
Category: dirName,
|
||||
PublishedAt: orDefault(e.ModTime, now),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := s.Catalog.UpsertVideo(ctx, v); err != nil {
|
||||
log.Printf("[scanner] upsert %s error: %v", v.Title, err)
|
||||
continue
|
||||
}
|
||||
stats.Added++
|
||||
if s.OnNewVideo != nil {
|
||||
s.OnNewVideo(v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func orDefault(t time.Time, d time.Time) time.Time {
|
||||
if t.IsZero() {
|
||||
return d
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func sameTags(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/video-site/backend/internal/catalog"
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
)
|
||||
|
||||
func TestRunPersistsRemoteThumbnailFromDriveEntry(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)
|
||||
}
|
||||
})
|
||||
|
||||
drv := &scannerFakeDrive{
|
||||
entries: []drives.Entry{{
|
||||
ID: "file-1",
|
||||
Name: "clip.mp4",
|
||||
Size: 123,
|
||||
MimeType: "video/mp4",
|
||||
ModTime: time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC),
|
||||
ThumbnailURL: "https://thumbnail.example/clip.jpg",
|
||||
}},
|
||||
}
|
||||
sc := New(cat, drv, []string{".mp4"}, 5, nil)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
got, err := cat.GetVideo(ctx, "fake-drive-file-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get video: %v", err)
|
||||
}
|
||||
if got.ThumbnailURL != "https://thumbnail.example/clip.jpg" {
|
||||
t.Fatalf("thumbnail = %q, want remote thumbnail", got.ThumbnailURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBackfillsRemoteThumbnailForExistingVideo(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: "fake-drive-file-1",
|
||||
DriveID: "drive",
|
||||
FileID: "file-1",
|
||||
Title: "Clip",
|
||||
PreviewStatus: "pending",
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed video: %v", err)
|
||||
}
|
||||
|
||||
drv := &scannerFakeDrive{
|
||||
entries: []drives.Entry{{
|
||||
ID: "file-1",
|
||||
Name: "clip.mp4",
|
||||
Size: 123,
|
||||
MimeType: "video/mp4",
|
||||
ModTime: now,
|
||||
ThumbnailURL: "https://thumbnail.example/backfilled.jpg",
|
||||
}},
|
||||
}
|
||||
sc := New(cat, drv, []string{".mp4"}, 5, nil)
|
||||
|
||||
stats, err := sc.Run(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
if stats.Added != 0 {
|
||||
t.Fatalf("added = %d, want 0", stats.Added)
|
||||
}
|
||||
|
||||
got, err := cat.GetVideo(ctx, "fake-drive-file-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get video: %v", err)
|
||||
}
|
||||
if got.ThumbnailURL != "https://thumbnail.example/backfilled.jpg" {
|
||||
t.Fatalf("thumbnail = %q, want backfilled remote thumbnail", got.ThumbnailURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReplacesExistingVideoTagsWithFixedFilenameTags(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: "fake-drive-file-1",
|
||||
DriveID: "drive",
|
||||
FileID: "file-1",
|
||||
Title: "Old",
|
||||
Tags: []string{"sunny", "kenny"},
|
||||
PreviewStatus: "pending",
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed video: %v", err)
|
||||
}
|
||||
|
||||
drv := &scannerFakeDrive{
|
||||
entries: []drives.Entry{{
|
||||
ID: "file-1",
|
||||
Name: "女大后入.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)
|
||||
}
|
||||
want := []string{"后入", "女大"}
|
||||
if !sameStrings(got.Tags, want) {
|
||||
t.Fatalf("tags = %#v, want %#v", got.Tags, want)
|
||||
}
|
||||
}
|
||||
|
||||
type scannerFakeDrive struct {
|
||||
entries []drives.Entry
|
||||
}
|
||||
|
||||
func (d *scannerFakeDrive) Kind() string { return "fake" }
|
||||
func (d *scannerFakeDrive) ID() string { return "drive" }
|
||||
func (d *scannerFakeDrive) Init(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
func (d *scannerFakeDrive) List(context.Context, string) ([]drives.Entry, error) {
|
||||
return d.entries, nil
|
||||
}
|
||||
func (d *scannerFakeDrive) Stat(context.Context, string) (*drives.Entry, error) {
|
||||
return nil, drives.ErrNotSupported
|
||||
}
|
||||
func (d *scannerFakeDrive) StreamURL(context.Context, string) (*drives.StreamLink, error) {
|
||||
return &drives.StreamLink{URL: "https://video.example/clip.mp4"}, nil
|
||||
}
|
||||
func (d *scannerFakeDrive) Upload(context.Context, string, string, io.Reader, int64) (string, error) {
|
||||
return "", drives.ErrNotSupported
|
||||
}
|
||||
func (d *scannerFakeDrive) EnsureDir(context.Context, string) (string, error) {
|
||||
return "", drives.ErrNotSupported
|
||||
}
|
||||
func (d *scannerFakeDrive) RootID() string { return "root" }
|
||||
+14
-14
@@ -1,14 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="视频聚合站首页 Demo" />
|
||||
<title>视频聚合站</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="视频聚合站首页 Demo" />
|
||||
<title>视频聚合站</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+26
-26
@@ -1,26 +1,26 @@
|
||||
{
|
||||
"name": "video-site",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:raw": "node node_modules/vite/bin/vite.js --host 127.0.0.1 --port 5173",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "0.453.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-router-dom": "6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@vitejs/plugin-react": "4.3.3",
|
||||
"typescript": "5.6.3",
|
||||
"vite": "5.4.10"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "video-site",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:raw": "node node_modules/vite/bin/vite.js --host 127.0.0.1 --port 5173",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "0.453.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-router-dom": "6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@vitejs/plugin-react": "4.3.3",
|
||||
"typescript": "5.6.3",
|
||||
"vite": "5.4.10"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#111"/><path d="M12 10l12 6-12 6z" fill="#ff8800"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#111"/><path d="M12 10l12 6-12 6z" fill="#ff8800"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 161 B After Width: | Height: | Size: 160 B |
+59
-59
@@ -1,59 +1,59 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import HomePage from "@/pages/HomePage";
|
||||
import ListingPage from "@/pages/ListingPage";
|
||||
import VideoDetailPage from "@/pages/VideoDetailPage";
|
||||
import { AdminLayout } from "@/admin/AdminLayout";
|
||||
import { LoginPage } from "@/admin/LoginPage";
|
||||
import { RequireAuth } from "@/admin/RequireAuth";
|
||||
import { DrivesPage } from "@/admin/DrivesPage";
|
||||
import { VideosPage } from "@/admin/VideosPage";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* 主站需要登录 */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<HomePage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/list"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<ListingPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/video/:id"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<VideoDetailPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 管理后台也需要登录 */}
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AdminLayout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/admin/drives" replace />} />
|
||||
<Route path="drives" element={<DrivesPage />} />
|
||||
<Route path="videos" element={<VideosPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import HomePage from "@/pages/HomePage";
|
||||
import ListingPage from "@/pages/ListingPage";
|
||||
import VideoDetailPage from "@/pages/VideoDetailPage";
|
||||
import { AdminLayout } from "@/admin/AdminLayout";
|
||||
import { LoginPage } from "@/admin/LoginPage";
|
||||
import { RequireAuth } from "@/admin/RequireAuth";
|
||||
import { DrivesPage } from "@/admin/DrivesPage";
|
||||
import { VideosPage } from "@/admin/VideosPage";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* 主站需要登录 */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<HomePage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/list"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<ListingPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/video/:id"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<VideoDetailPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 管理后台也需要登录 */}
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AdminLayout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/admin/drives" replace />} />
|
||||
<Route path="drives" element={<DrivesPage />} />
|
||||
<Route path="videos" element={<VideosPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
+65
-65
@@ -1,65 +1,65 @@
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import { HardDrive, Film, LogOut, Play, Home } from "lucide-react";
|
||||
import { useAuth } from "./AuthContext";
|
||||
import { useToast } from "./ToastContext";
|
||||
import { PreviewToggle } from "./PreviewToggle";
|
||||
|
||||
export function AdminLayout() {
|
||||
const { logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { show } = useToast();
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await logout();
|
||||
show("已退出登录", "success");
|
||||
navigate("/login", { replace: true });
|
||||
} catch {
|
||||
show("退出失败", "error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-shell">
|
||||
<aside className="admin-sidebar">
|
||||
<div className="admin-sidebar__brand">
|
||||
<span className="admin-sidebar__brand-mark">
|
||||
<Play size={14} fill="#000" />
|
||||
</span>
|
||||
视频站后台
|
||||
</div>
|
||||
<nav className="admin-nav">
|
||||
<NavLink to="/" className="admin-nav__link">
|
||||
<Home size={16} /> 返回主站
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/admin/drives"
|
||||
className={({ isActive }) =>
|
||||
`admin-nav__link ${isActive ? "is-active" : ""}`
|
||||
}
|
||||
>
|
||||
<HardDrive size={16} /> 网盘管理
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/admin/videos"
|
||||
className={({ isActive }) =>
|
||||
`admin-nav__link ${isActive ? "is-active" : ""}`
|
||||
}
|
||||
>
|
||||
<Film size={16} /> 视频管理
|
||||
</NavLink>
|
||||
</nav>
|
||||
<div className="admin-sidebar__footer">
|
||||
<PreviewToggle />
|
||||
<button className="admin-sidebar__logout" onClick={handleLogout}>
|
||||
<LogOut size={14} style={{ verticalAlign: -2, marginRight: 4 }} />
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="admin-main">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import { HardDrive, Film, LogOut, Play, Home } from "lucide-react";
|
||||
import { useAuth } from "./AuthContext";
|
||||
import { useToast } from "./ToastContext";
|
||||
import { PreviewToggle } from "./PreviewToggle";
|
||||
|
||||
export function AdminLayout() {
|
||||
const { logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { show } = useToast();
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await logout();
|
||||
show("已退出登录", "success");
|
||||
navigate("/login", { replace: true });
|
||||
} catch {
|
||||
show("退出失败", "error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-shell">
|
||||
<aside className="admin-sidebar">
|
||||
<div className="admin-sidebar__brand">
|
||||
<span className="admin-sidebar__brand-mark">
|
||||
<Play size={14} fill="#000" />
|
||||
</span>
|
||||
视频站后台
|
||||
</div>
|
||||
<nav className="admin-nav">
|
||||
<NavLink to="/" className="admin-nav__link">
|
||||
<Home size={16} /> 返回主站
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/admin/drives"
|
||||
className={({ isActive }) =>
|
||||
`admin-nav__link ${isActive ? "is-active" : ""}`
|
||||
}
|
||||
>
|
||||
<HardDrive size={16} /> 网盘管理
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/admin/videos"
|
||||
className={({ isActive }) =>
|
||||
`admin-nav__link ${isActive ? "is-active" : ""}`
|
||||
}
|
||||
>
|
||||
<Film size={16} /> 视频管理
|
||||
</NavLink>
|
||||
</nav>
|
||||
<div className="admin-sidebar__footer">
|
||||
<PreviewToggle />
|
||||
<button className="admin-sidebar__logout" onClick={handleLogout}>
|
||||
<LogOut size={14} style={{ verticalAlign: -2, marginRight: 4 }} />
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="admin-main">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+65
-65
@@ -1,65 +1,65 @@
|
||||
import {
|
||||
ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import * as api from "./api";
|
||||
|
||||
type AuthStatus = "loading" | "authed" | "guest";
|
||||
|
||||
type AuthCtx = {
|
||||
status: AuthStatus;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
};
|
||||
|
||||
const Ctx = createContext<AuthCtx | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [status, setStatus] = useState<AuthStatus>("loading");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.me();
|
||||
setStatus(r.authenticated ? "authed" : "guest");
|
||||
} catch {
|
||||
setStatus("guest");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 只在挂载时查一次
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const login = useCallback(async (u: string, p: string) => {
|
||||
await api.login(u, p);
|
||||
setStatus("authed");
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await api.logout();
|
||||
} finally {
|
||||
setStatus("guest");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ status, login, logout, refresh }),
|
||||
[status, login, logout, refresh]
|
||||
);
|
||||
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthCtx {
|
||||
const ctx = useContext(Ctx);
|
||||
if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
|
||||
return ctx;
|
||||
}
|
||||
import {
|
||||
ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import * as api from "./api";
|
||||
|
||||
type AuthStatus = "loading" | "authed" | "guest";
|
||||
|
||||
type AuthCtx = {
|
||||
status: AuthStatus;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
};
|
||||
|
||||
const Ctx = createContext<AuthCtx | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [status, setStatus] = useState<AuthStatus>("loading");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.me();
|
||||
setStatus(r.authenticated ? "authed" : "guest");
|
||||
} catch {
|
||||
setStatus("guest");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 只在挂载时查一次
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const login = useCallback(async (u: string, p: string) => {
|
||||
await api.login(u, p);
|
||||
setStatus("authed");
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await api.logout();
|
||||
} finally {
|
||||
setStatus("guest");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ status, login, logout, refresh }),
|
||||
[status, login, logout, refresh]
|
||||
);
|
||||
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthCtx {
|
||||
const ctx = useContext(Ctx);
|
||||
if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
+449
-390
@@ -1,390 +1,449 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import * as api from "./api";
|
||||
import { useToast } from "./ToastContext";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
const kindLabel: Record<string, string> = {
|
||||
quark: "夸克网盘",
|
||||
p115: "115 网盘",
|
||||
wopan: "联通沃盘",
|
||||
};
|
||||
|
||||
type Kind = api.AdminDrive["kind"];
|
||||
|
||||
type FormState = {
|
||||
id: string;
|
||||
kind: Kind;
|
||||
name: string;
|
||||
rootId: string;
|
||||
scanRootId: string;
|
||||
creds: Record<string, string>;
|
||||
};
|
||||
|
||||
const emptyForm: FormState = {
|
||||
id: "",
|
||||
kind: "quark",
|
||||
name: "",
|
||||
rootId: "0",
|
||||
scanRootId: "0",
|
||||
creds: {},
|
||||
};
|
||||
|
||||
export function DrivesPage() {
|
||||
const [list, setList] = useState<api.AdminDrive[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const { show } = useToast();
|
||||
|
||||
async function refresh() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.listDrives();
|
||||
setList(data ?? []);
|
||||
} catch (e) {
|
||||
show(e instanceof Error ? e.message : "加载失败", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
function openCreate() {
|
||||
setForm(emptyForm);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(d: api.AdminDrive) {
|
||||
setForm({
|
||||
id: d.id,
|
||||
kind: d.kind,
|
||||
name: d.name,
|
||||
rootId: d.rootId,
|
||||
scanRootId: d.scanRootId || d.rootId,
|
||||
creds: {},
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.id || !form.kind) {
|
||||
show("请填 ID 和类型", "error");
|
||||
return;
|
||||
}
|
||||
// 若编辑且没有提供凭证,提示一下但仍允许保存(不改凭证)
|
||||
setSaving(true);
|
||||
try {
|
||||
const resp = await api.upsertDrive({
|
||||
id: form.id,
|
||||
kind: form.kind,
|
||||
name: form.name || form.id,
|
||||
rootId: form.rootId || "0",
|
||||
scanRootId: form.scanRootId || form.rootId || "0",
|
||||
credentials: form.creds,
|
||||
});
|
||||
if (resp.warning) {
|
||||
show(`已保存,但 driver 初始化失败:${resp.warning}`, "error");
|
||||
} else {
|
||||
show("已保存", "success");
|
||||
}
|
||||
setModalOpen(false);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
show(e instanceof Error ? e.message : "保存失败", "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(d: api.AdminDrive) {
|
||||
if (!window.confirm(`确定删除 ${d.name || d.id}?\n这会移除盘配置,但不会删除其中的视频元数据。`)) return;
|
||||
try {
|
||||
await api.deleteDrive(d.id);
|
||||
show("已删除", "success");
|
||||
refresh();
|
||||
} catch (e) {
|
||||
show(e instanceof Error ? e.message : "删除失败", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRescan(d: api.AdminDrive) {
|
||||
try {
|
||||
await api.rescan(d.id);
|
||||
show("已触发扫描,可稍后刷新视频列表查看", "success");
|
||||
} catch (e) {
|
||||
show(e instanceof Error ? e.message : "触发失败", "error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<header className="admin-page__header">
|
||||
<h1 className="admin-page__title">网盘管理</h1>
|
||||
<button className="admin-btn is-primary" onClick={openCreate}>
|
||||
<Plus size={14} /> 新建
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{loading ? (
|
||||
<div className="admin-empty">加载中...</div>
|
||||
) : list.length === 0 ? (
|
||||
<div className="admin-card admin-empty">
|
||||
还没有配置任何网盘。点击右上角「新建」,选择夸克 / 115 / 沃盘,填入凭证即可。
|
||||
</div>
|
||||
) : (
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>类型</th>
|
||||
<th>ID</th>
|
||||
<th>状态</th>
|
||||
<th>扫描根</th>
|
||||
<th className="is-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((d) => (
|
||||
<tr key={d.id}>
|
||||
<td>{d.name || <span style={{ color: "#aaa" }}>(未命名)</span>}</td>
|
||||
<td>{kindLabel[d.kind] ?? d.kind}</td>
|
||||
<td style={{ fontFamily: "ui-monospace", fontSize: 12 }}>{d.id}</td>
|
||||
<td>
|
||||
<StatusTag status={d.status} error={d.lastError} hasCred={d.hasCredential} />
|
||||
</td>
|
||||
<td style={{ fontFamily: "ui-monospace", fontSize: 12 }}>
|
||||
{d.scanRootId || d.rootId}
|
||||
</td>
|
||||
<td className="is-actions">
|
||||
<button className="admin-btn" onClick={() => handleRescan(d)}>
|
||||
<RefreshCw size={13} /> 重扫
|
||||
</button>{" "}
|
||||
<button className="admin-btn" onClick={() => openEdit(d)}>
|
||||
编辑
|
||||
</button>{" "}
|
||||
<button className="admin-btn is-danger" onClick={() => handleDelete(d)}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={form.id && list.find((x) => x.id === form.id) ? "编辑网盘" : "新建网盘"}
|
||||
onClose={() => setModalOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<button className="admin-btn" onClick={() => setModalOpen(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="admin-btn is-primary"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "保存中..." : "保存"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<DriveForm form={form} onChange={setForm} isEdit={!!list.find((x) => x.id === form.id)} />
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusTag({
|
||||
status,
|
||||
error,
|
||||
hasCred,
|
||||
}: {
|
||||
status: string;
|
||||
error?: string;
|
||||
hasCred: boolean;
|
||||
}) {
|
||||
if (!hasCred) {
|
||||
return <span className="admin-status is-pending">未配置凭证</span>;
|
||||
}
|
||||
if (status === "ok") return <span className="admin-status is-ok">已连接</span>;
|
||||
if (status === "error")
|
||||
return (
|
||||
<span className="admin-status is-error" title={error}>
|
||||
错误
|
||||
</span>
|
||||
);
|
||||
return <span className="admin-status">{status || "未连接"}</span>;
|
||||
}
|
||||
|
||||
function DriveForm({
|
||||
form,
|
||||
onChange,
|
||||
isEdit,
|
||||
}: {
|
||||
form: FormState;
|
||||
onChange: (f: FormState) => void;
|
||||
isEdit: boolean;
|
||||
}) {
|
||||
const fields = useMemo(() => credentialFields(form.kind), [form.kind]);
|
||||
|
||||
function set<K extends keyof FormState>(k: K, v: FormState[K]) {
|
||||
onChange({ ...form, [k]: v });
|
||||
}
|
||||
function setCred(k: string, v: string) {
|
||||
onChange({ ...form, creds: { ...form.creds, [k]: v } });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-form">
|
||||
<div className="admin-form__row">
|
||||
<label>ID(英文,唯一)</label>
|
||||
<input
|
||||
value={form.id}
|
||||
onChange={(e) => set("id", e.target.value)}
|
||||
placeholder="例如 my-quark"
|
||||
disabled={isEdit}
|
||||
/>
|
||||
{isEdit && <div className="admin-form__help">已创建的盘 ID 不能修改</div>}
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>名称</label>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
placeholder="给这个盘起个名字"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>类型</label>
|
||||
<select
|
||||
value={form.kind}
|
||||
onChange={(e) => set("kind", e.target.value as Kind)}
|
||||
disabled={isEdit}
|
||||
>
|
||||
<option value="quark">夸克网盘</option>
|
||||
<option value="p115">115 网盘</option>
|
||||
<option value="wopan">联通沃盘</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>根目录 ID</label>
|
||||
<input
|
||||
value={form.rootId}
|
||||
onChange={(e) => set("rootId", e.target.value)}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>扫描起点目录 ID</label>
|
||||
<input
|
||||
value={form.scanRootId}
|
||||
onChange={(e) => set("scanRootId", e.target.value)}
|
||||
placeholder="留空则使用根目录"
|
||||
/>
|
||||
<div className="admin-form__help">
|
||||
可以指定一个子目录作为视频库入口,避免扫描整个网盘
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr style={{ border: 0, borderTop: "1px solid #eee", margin: "8px 0" }} />
|
||||
|
||||
<div className="admin-form__help" style={{ fontSize: 13, color: "#555" }}>
|
||||
{credentialHelp(form.kind, isEdit)}
|
||||
</div>
|
||||
|
||||
{fields.map((f) => (
|
||||
<div key={f.key} className="admin-form__row">
|
||||
<label>{f.label}{f.required && " *"}</label>
|
||||
{f.multiline ? (
|
||||
<textarea
|
||||
value={form.creds[f.key] ?? ""}
|
||||
onChange={(e) => setCred(f.key, e.target.value)}
|
||||
placeholder={f.placeholder}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
value={form.creds[f.key] ?? ""}
|
||||
onChange={(e) => setCred(f.key, e.target.value)}
|
||||
placeholder={f.placeholder}
|
||||
/>
|
||||
)}
|
||||
{f.help && <div className="admin-form__help">{f.help}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function credentialHelp(kind: Kind, isEdit: boolean): string {
|
||||
const note = isEdit ? "如不修改凭证,留空即可,保存时会沿用旧值。" : "";
|
||||
switch (kind) {
|
||||
case "quark":
|
||||
return `在 pan.quark.cn 登录后,F12 → Network → 任意请求 → Request Headers 里复制整段 Cookie 粘贴到下方。${note}`;
|
||||
case "p115":
|
||||
return `登录 115.com 后复制 Cookie,形如 "UID=...; CID=...; SEID=...; KID=..."。${note}`;
|
||||
case "wopan":
|
||||
return `需要 access_token 和 refresh_token。后续会加扫码/短信登录入口,第一版只能手工粘贴。${note}`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function credentialFields(kind: Kind): Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
multiline?: boolean;
|
||||
required?: boolean;
|
||||
help?: string;
|
||||
}> {
|
||||
switch (kind) {
|
||||
case "quark":
|
||||
return [
|
||||
{
|
||||
key: "cookie",
|
||||
label: "Cookie",
|
||||
placeholder: "__pus=...; __puus=...; ...",
|
||||
multiline: true,
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
case "p115":
|
||||
return [
|
||||
{
|
||||
key: "cookie",
|
||||
label: "Cookie",
|
||||
placeholder: "UID=xxx; CID=xxx; SEID=xxx; KID=xxx",
|
||||
multiline: true,
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
case "wopan":
|
||||
return [
|
||||
{
|
||||
key: "access_token",
|
||||
label: "access_token",
|
||||
placeholder: "",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "refresh_token",
|
||||
label: "refresh_token",
|
||||
placeholder: "",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "family_id",
|
||||
label: "family_id(家庭空间可选)",
|
||||
placeholder: "留空走个人空间",
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import * as api from "./api";
|
||||
import { useToast } from "./ToastContext";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
const kindLabel: Record<string, string> = {
|
||||
quark: "夸克网盘",
|
||||
p115: "115 网盘",
|
||||
pikpak: "PikPak",
|
||||
wopan: "联通沃盘",
|
||||
};
|
||||
|
||||
type Kind = api.AdminDrive["kind"];
|
||||
|
||||
type FormState = {
|
||||
id: string;
|
||||
kind: Kind;
|
||||
name: string;
|
||||
rootId: string;
|
||||
scanRootId: string;
|
||||
creds: Record<string, string>;
|
||||
};
|
||||
|
||||
const emptyForm: FormState = {
|
||||
id: "",
|
||||
kind: "quark",
|
||||
name: "",
|
||||
rootId: "0",
|
||||
scanRootId: "0",
|
||||
creds: {},
|
||||
};
|
||||
|
||||
export function DrivesPage() {
|
||||
const [list, setList] = useState<api.AdminDrive[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const { show } = useToast();
|
||||
|
||||
async function refresh() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.listDrives();
|
||||
setList(data ?? []);
|
||||
} catch (e) {
|
||||
show(e instanceof Error ? e.message : "加载失败", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
function openCreate() {
|
||||
setForm(emptyForm);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(d: api.AdminDrive) {
|
||||
setForm({
|
||||
id: d.id,
|
||||
kind: d.kind,
|
||||
name: d.name,
|
||||
rootId: d.rootId,
|
||||
scanRootId: d.scanRootId || d.rootId,
|
||||
creds: {},
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.id || !form.kind) {
|
||||
show("请填 ID 和类型", "error");
|
||||
return;
|
||||
}
|
||||
// 若编辑且没有提供凭证,提示一下但仍允许保存(不改凭证)
|
||||
setSaving(true);
|
||||
try {
|
||||
const resp = await api.upsertDrive({
|
||||
id: form.id,
|
||||
kind: form.kind,
|
||||
name: form.name || form.id,
|
||||
rootId: form.rootId || defaultRootId(form.kind),
|
||||
scanRootId: form.scanRootId || form.rootId || defaultRootId(form.kind),
|
||||
credentials: form.creds,
|
||||
});
|
||||
if (resp.warning) {
|
||||
show(`已保存,但 driver 初始化失败:${resp.warning}`, "error");
|
||||
} else {
|
||||
show("已保存", "success");
|
||||
}
|
||||
setModalOpen(false);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
show(e instanceof Error ? e.message : "保存失败", "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(d: api.AdminDrive) {
|
||||
if (!window.confirm(`确定删除 ${d.name || d.id}?\n这会移除盘配置,但不会删除其中的视频元数据。`)) return;
|
||||
try {
|
||||
await api.deleteDrive(d.id);
|
||||
show("已删除", "success");
|
||||
refresh();
|
||||
} catch (e) {
|
||||
show(e instanceof Error ? e.message : "删除失败", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRescan(d: api.AdminDrive) {
|
||||
try {
|
||||
await api.rescan(d.id);
|
||||
show("已触发扫描,可稍后刷新视频列表查看", "success");
|
||||
} catch (e) {
|
||||
show(e instanceof Error ? e.message : "触发失败", "error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<header className="admin-page__header">
|
||||
<h1 className="admin-page__title">网盘管理</h1>
|
||||
<button className="admin-btn is-primary" onClick={openCreate}>
|
||||
<Plus size={14} /> 新建
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{loading ? (
|
||||
<div className="admin-empty">加载中...</div>
|
||||
) : list.length === 0 ? (
|
||||
<div className="admin-card admin-empty">
|
||||
还没有配置任何网盘。点击右上角「新建」,选择夸克 / 115 / PikPak / 沃盘,填入凭证即可。
|
||||
</div>
|
||||
) : (
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>类型</th>
|
||||
<th>ID</th>
|
||||
<th>状态</th>
|
||||
<th>扫描根</th>
|
||||
<th className="is-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((d) => (
|
||||
<tr key={d.id}>
|
||||
<td>{d.name || <span style={{ color: "#aaa" }}>(未命名)</span>}</td>
|
||||
<td>{kindLabel[d.kind] ?? d.kind}</td>
|
||||
<td style={{ fontFamily: "ui-monospace", fontSize: 12 }}>{d.id}</td>
|
||||
<td>
|
||||
<StatusTag status={d.status} error={d.lastError} hasCred={d.hasCredential} />
|
||||
</td>
|
||||
<td style={{ fontFamily: "ui-monospace", fontSize: 12 }}>
|
||||
{d.scanRootId || d.rootId}
|
||||
</td>
|
||||
<td className="is-actions">
|
||||
<button className="admin-btn" onClick={() => handleRescan(d)}>
|
||||
<RefreshCw size={13} /> 重扫
|
||||
</button>{" "}
|
||||
<button className="admin-btn" onClick={() => openEdit(d)}>
|
||||
编辑
|
||||
</button>{" "}
|
||||
<button className="admin-btn is-danger" onClick={() => handleDelete(d)}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={form.id && list.find((x) => x.id === form.id) ? "编辑网盘" : "新建网盘"}
|
||||
onClose={() => setModalOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<button className="admin-btn" onClick={() => setModalOpen(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="admin-btn is-primary"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "保存中..." : "保存"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<DriveForm form={form} onChange={setForm} isEdit={!!list.find((x) => x.id === form.id)} />
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusTag({
|
||||
status,
|
||||
error,
|
||||
hasCred,
|
||||
}: {
|
||||
status: string;
|
||||
error?: string;
|
||||
hasCred: boolean;
|
||||
}) {
|
||||
if (!hasCred) {
|
||||
return <span className="admin-status is-pending">未配置凭证</span>;
|
||||
}
|
||||
if (status === "ok") return <span className="admin-status is-ok">已连接</span>;
|
||||
if (status === "error")
|
||||
return (
|
||||
<span className="admin-status is-error" title={error}>
|
||||
错误
|
||||
</span>
|
||||
);
|
||||
return <span className="admin-status">{status || "未连接"}</span>;
|
||||
}
|
||||
|
||||
function DriveForm({
|
||||
form,
|
||||
onChange,
|
||||
isEdit,
|
||||
}: {
|
||||
form: FormState;
|
||||
onChange: (f: FormState) => void;
|
||||
isEdit: boolean;
|
||||
}) {
|
||||
const fields = useMemo(() => credentialFields(form.kind), [form.kind]);
|
||||
|
||||
function set<K extends keyof FormState>(k: K, v: FormState[K]) {
|
||||
onChange({ ...form, [k]: v });
|
||||
}
|
||||
function setCred(k: string, v: string) {
|
||||
onChange({ ...form, creds: { ...form.creds, [k]: v } });
|
||||
}
|
||||
function setKind(v: Kind) {
|
||||
onChange({
|
||||
...form,
|
||||
kind: v,
|
||||
rootId: defaultRootId(v),
|
||||
scanRootId: defaultRootId(v),
|
||||
creds: {},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-form">
|
||||
<div className="admin-form__row">
|
||||
<label>ID(英文,唯一)</label>
|
||||
<input
|
||||
value={form.id}
|
||||
onChange={(e) => set("id", e.target.value)}
|
||||
placeholder="例如 my-quark"
|
||||
disabled={isEdit}
|
||||
/>
|
||||
{isEdit && <div className="admin-form__help">已创建的盘 ID 不能修改</div>}
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>名称</label>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
placeholder="给这个盘起个名字"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>类型</label>
|
||||
<select
|
||||
value={form.kind}
|
||||
onChange={(e) => setKind(e.target.value as Kind)}
|
||||
disabled={isEdit}
|
||||
>
|
||||
<option value="quark">夸克网盘</option>
|
||||
<option value="p115">115 网盘</option>
|
||||
<option value="pikpak">PikPak</option>
|
||||
<option value="wopan">联通沃盘</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>根目录 ID</label>
|
||||
<input
|
||||
value={form.rootId}
|
||||
onChange={(e) => set("rootId", e.target.value)}
|
||||
placeholder={form.kind === "pikpak" ? "留空表示根目录" : "0"}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>扫描起点目录 ID</label>
|
||||
<input
|
||||
value={form.scanRootId}
|
||||
onChange={(e) => set("scanRootId", e.target.value)}
|
||||
placeholder="留空则使用根目录"
|
||||
/>
|
||||
<div className="admin-form__help">
|
||||
可以指定一个子目录作为视频库入口,避免扫描整个网盘
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr style={{ border: 0, borderTop: "1px solid #eee", margin: "8px 0" }} />
|
||||
|
||||
<div className="admin-form__help" style={{ fontSize: 13, color: "#555" }}>
|
||||
{credentialHelp(form.kind, isEdit)}
|
||||
</div>
|
||||
|
||||
{fields.map((f) => (
|
||||
<div key={f.key} className="admin-form__row">
|
||||
<label>{f.label}{f.required && " *"}</label>
|
||||
{f.multiline ? (
|
||||
<textarea
|
||||
value={form.creds[f.key] ?? ""}
|
||||
onChange={(e) => setCred(f.key, e.target.value)}
|
||||
placeholder={f.placeholder}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
value={form.creds[f.key] ?? ""}
|
||||
onChange={(e) => setCred(f.key, e.target.value)}
|
||||
placeholder={f.placeholder}
|
||||
/>
|
||||
)}
|
||||
{f.help && <div className="admin-form__help">{f.help}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function credentialHelp(kind: Kind, isEdit: boolean): string {
|
||||
const note = isEdit ? "如不修改凭证,留空即可,保存时会沿用旧值。" : "";
|
||||
switch (kind) {
|
||||
case "quark":
|
||||
return `在 pan.quark.cn 登录后,F12 → Network → 任意请求 → Request Headers 里复制整段 Cookie 粘贴到下方。${note}`;
|
||||
case "p115":
|
||||
return `登录 115.com 后复制 Cookie,形如 "UID=...; CID=...; SEID=...; KID=..."。${note}`;
|
||||
case "pikpak":
|
||||
return `参考 OpenList 的 PikPak 登录方式。可填用户名和密码首次登录,也可填 refresh_token;如返回验证码链接,打开验证后把 captcha_token 粘贴回来。${note}`;
|
||||
case "wopan":
|
||||
return `需要 access_token 和 refresh_token。后续会加扫码/短信登录入口,第一版只能手工粘贴。${note}`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function credentialFields(kind: Kind): Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
multiline?: boolean;
|
||||
required?: boolean;
|
||||
help?: string;
|
||||
}> {
|
||||
switch (kind) {
|
||||
case "quark":
|
||||
return [
|
||||
{
|
||||
key: "cookie",
|
||||
label: "Cookie",
|
||||
placeholder: "__pus=...; __puus=...; ...",
|
||||
multiline: true,
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
case "p115":
|
||||
return [
|
||||
{
|
||||
key: "cookie",
|
||||
label: "Cookie",
|
||||
placeholder: "UID=xxx; CID=xxx; SEID=xxx; KID=xxx",
|
||||
multiline: true,
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
case "pikpak":
|
||||
return [
|
||||
{
|
||||
key: "username",
|
||||
label: "用户名 / 邮箱(无 refresh_token 时必填)",
|
||||
placeholder: "user@example.com",
|
||||
},
|
||||
{
|
||||
key: "password",
|
||||
label: "密码(无 refresh_token 时必填)",
|
||||
placeholder: "PikPak 密码",
|
||||
},
|
||||
{
|
||||
key: "platform",
|
||||
label: "platform",
|
||||
placeholder: "web(可选:android / web / pc)",
|
||||
help: "默认 web;如果登录或直链异常,可尝试 android 或 pc。",
|
||||
},
|
||||
{
|
||||
key: "refresh_token",
|
||||
label: "refresh_token(可选)",
|
||||
placeholder: "已有 token 时可直接粘贴",
|
||||
multiline: true,
|
||||
},
|
||||
{
|
||||
key: "captcha_token",
|
||||
label: "captcha_token(可选)",
|
||||
placeholder: "遇到验证码校验时粘贴",
|
||||
multiline: true,
|
||||
},
|
||||
{
|
||||
key: "device_id",
|
||||
label: "device_id(可选)",
|
||||
placeholder: "留空自动生成并保存",
|
||||
},
|
||||
{
|
||||
key: "disable_media_link",
|
||||
label: "disable_media_link",
|
||||
placeholder: "true",
|
||||
help: "默认 true,使用原始下载链接;填 false 可尝试使用媒体缓存链接。",
|
||||
},
|
||||
];
|
||||
case "wopan":
|
||||
return [
|
||||
{
|
||||
key: "access_token",
|
||||
label: "access_token",
|
||||
placeholder: "",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "refresh_token",
|
||||
label: "refresh_token",
|
||||
placeholder: "",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "family_id",
|
||||
label: "family_id(家庭空间可选)",
|
||||
placeholder: "留空走个人空间",
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function defaultRootId(kind: Kind): string {
|
||||
return kind === "pikpak" ? "" : "0";
|
||||
}
|
||||
|
||||
+91
-91
@@ -1,91 +1,91 @@
|
||||
import { useState } from "react";
|
||||
import { Navigate, useLocation, useNavigate } from "react-router-dom";
|
||||
import { Play } from "lucide-react";
|
||||
import { useAuth } from "./AuthContext";
|
||||
import { useToast } from "./ToastContext";
|
||||
|
||||
export function LoginPage() {
|
||||
const { status, login } = useAuth();
|
||||
const [u, setU] = useState("");
|
||||
const [p, setP] = useState("");
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { show } = useToast();
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "#888",
|
||||
}}
|
||||
>
|
||||
检查登录状态...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 已登录:回到来源页,或默认去首页
|
||||
if (status === "authed") {
|
||||
const from = (location.state as { from?: string } | null)?.from ?? "/";
|
||||
return <Navigate to={from} replace />;
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(u, p);
|
||||
show("登录成功", "success");
|
||||
const from = (location.state as { from?: string } | null)?.from ?? "/";
|
||||
navigate(from, { replace: true });
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "登录失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-login">
|
||||
<form className="admin-login__card" onSubmit={handleSubmit}>
|
||||
<h1 className="admin-login__title">
|
||||
<Play size={18} fill="currentColor" /> 登录
|
||||
</h1>
|
||||
<div className="admin-form">
|
||||
<div className="admin-form__row">
|
||||
<label>用户名</label>
|
||||
<input
|
||||
autoFocus
|
||||
value={u}
|
||||
onChange={(e) => setU(e.target.value)}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={p}
|
||||
onChange={(e) => setP(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="admin-btn is-primary"
|
||||
type="submit"
|
||||
disabled={loading || !u || !p}
|
||||
>
|
||||
{loading ? "登录中..." : "登录"}
|
||||
</button>
|
||||
{err && <div className="admin-login__error">{err}</div>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useState } from "react";
|
||||
import { Navigate, useLocation, useNavigate } from "react-router-dom";
|
||||
import { Play } from "lucide-react";
|
||||
import { useAuth } from "./AuthContext";
|
||||
import { useToast } from "./ToastContext";
|
||||
|
||||
export function LoginPage() {
|
||||
const { status, login } = useAuth();
|
||||
const [u, setU] = useState("");
|
||||
const [p, setP] = useState("");
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { show } = useToast();
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "#888",
|
||||
}}
|
||||
>
|
||||
检查登录状态...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 已登录:回到来源页,或默认去首页
|
||||
if (status === "authed") {
|
||||
const from = (location.state as { from?: string } | null)?.from ?? "/";
|
||||
return <Navigate to={from} replace />;
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(u, p);
|
||||
show("登录成功", "success");
|
||||
const from = (location.state as { from?: string } | null)?.from ?? "/";
|
||||
navigate(from, { replace: true });
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "登录失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-login">
|
||||
<form className="admin-login__card" onSubmit={handleSubmit}>
|
||||
<h1 className="admin-login__title">
|
||||
<Play size={18} fill="currentColor" /> 登录
|
||||
</h1>
|
||||
<div className="admin-form">
|
||||
<div className="admin-form__row">
|
||||
<label>用户名</label>
|
||||
<input
|
||||
autoFocus
|
||||
value={u}
|
||||
onChange={(e) => setU(e.target.value)}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={p}
|
||||
onChange={(e) => setP(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="admin-btn is-primary"
|
||||
type="submit"
|
||||
disabled={loading || !u || !p}
|
||||
>
|
||||
{loading ? "登录中..." : "登录"}
|
||||
</button>
|
||||
{err && <div className="admin-login__error">{err}</div>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+38
-38
@@ -1,38 +1,38 @@
|
||||
import { ReactNode } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
};
|
||||
|
||||
export function Modal({ open, title, onClose, children, footer }: Props) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div
|
||||
className="admin-modal-backdrop"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="admin-modal" role="dialog" aria-modal="true">
|
||||
<div className="admin-modal__header">
|
||||
<span>{title}</span>
|
||||
<button
|
||||
className="admin-btn"
|
||||
onClick={onClose}
|
||||
aria-label="关闭"
|
||||
style={{ padding: "4px 8px" }}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-modal__body">{children}</div>
|
||||
{footer && <div className="admin-modal__footer">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { ReactNode } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
};
|
||||
|
||||
export function Modal({ open, title, onClose, children, footer }: Props) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div
|
||||
className="admin-modal-backdrop"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="admin-modal" role="dialog" aria-modal="true">
|
||||
<div className="admin-modal__header">
|
||||
<span>{title}</span>
|
||||
<button
|
||||
className="admin-btn"
|
||||
onClick={onClose}
|
||||
aria-label="关闭"
|
||||
style={{ padding: "4px 8px" }}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-modal__body">{children}</div>
|
||||
{footer && <div className="admin-modal__footer">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+70
-70
@@ -1,70 +1,70 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Film } from "lucide-react";
|
||||
import * as api from "./api";
|
||||
import { useToast } from "./ToastContext";
|
||||
|
||||
// 预览生成开关。放在侧栏底部。
|
||||
export function PreviewToggle() {
|
||||
const [enabled, setEnabled] = useState<boolean | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const { show } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
api
|
||||
.getSettings()
|
||||
.then((s) => {
|
||||
if (active) setEnabled(s.previewEnabled);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setEnabled(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleToggle() {
|
||||
if (enabled === null || saving) return;
|
||||
const next = !enabled;
|
||||
setSaving(true);
|
||||
// 乐观更新
|
||||
setEnabled(next);
|
||||
try {
|
||||
const resp = await api.updateSettings({ previewEnabled: next });
|
||||
setEnabled(resp.previewEnabled);
|
||||
show(
|
||||
next ? "已开启预览生成,正在补扫 pending" : "已关闭预览生成",
|
||||
"success"
|
||||
);
|
||||
} catch (e) {
|
||||
// 回滚
|
||||
setEnabled(!next);
|
||||
show(e instanceof Error ? e.message : "切换失败", "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="preview-toggle">
|
||||
<div className="preview-toggle__head">
|
||||
<Film size={14} />
|
||||
<span className="preview-toggle__label">Teaser 生成</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled ?? false}
|
||||
className={`toggle-switch ${enabled ? "is-on" : ""} ${
|
||||
saving ? "is-saving" : ""
|
||||
}`}
|
||||
onClick={handleToggle}
|
||||
disabled={enabled === null || saving}
|
||||
title={enabled ? "点击关闭" : "点击开启"}
|
||||
>
|
||||
<span className="toggle-switch__dot" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useEffect, useState } from "react";
|
||||
import { Film } from "lucide-react";
|
||||
import * as api from "./api";
|
||||
import { useToast } from "./ToastContext";
|
||||
|
||||
// 预览生成开关。放在侧栏底部。
|
||||
export function PreviewToggle() {
|
||||
const [enabled, setEnabled] = useState<boolean | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const { show } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
api
|
||||
.getSettings()
|
||||
.then((s) => {
|
||||
if (active) setEnabled(s.previewEnabled);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setEnabled(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleToggle() {
|
||||
if (enabled === null || saving) return;
|
||||
const next = !enabled;
|
||||
setSaving(true);
|
||||
// 乐观更新
|
||||
setEnabled(next);
|
||||
try {
|
||||
const resp = await api.updateSettings({ previewEnabled: next });
|
||||
setEnabled(resp.previewEnabled);
|
||||
show(
|
||||
next ? "已开启预览生成,正在补扫 pending" : "已关闭预览生成",
|
||||
"success"
|
||||
);
|
||||
} catch (e) {
|
||||
// 回滚
|
||||
setEnabled(!next);
|
||||
show(e instanceof Error ? e.message : "切换失败", "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="preview-toggle">
|
||||
<div className="preview-toggle__head">
|
||||
<Film size={14} />
|
||||
<span className="preview-toggle__label">Teaser 生成</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled ?? false}
|
||||
className={`toggle-switch ${enabled ? "is-on" : ""} ${
|
||||
saving ? "is-saving" : ""
|
||||
}`}
|
||||
onClick={handleToggle}
|
||||
disabled={enabled === null || saving}
|
||||
title={enabled ? "点击关闭" : "点击开启"}
|
||||
>
|
||||
<span className="toggle-switch__dot" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+36
-36
@@ -1,36 +1,36 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "./AuthContext";
|
||||
|
||||
// 登录守卫:未登录跳 /login,并把目的地放到 state,登录后可回跳
|
||||
export function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const { status } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "#888",
|
||||
}}
|
||||
>
|
||||
加载中...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "guest") {
|
||||
return (
|
||||
<Navigate
|
||||
to="/login"
|
||||
replace
|
||||
state={{ from: location.pathname + location.search }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
import { ReactNode } from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "./AuthContext";
|
||||
|
||||
// 登录守卫:未登录跳 /login,并把目的地放到 state,登录后可回跳
|
||||
export function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const { status } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "#888",
|
||||
}}
|
||||
>
|
||||
加载中...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "guest") {
|
||||
return (
|
||||
<Navigate
|
||||
to="/login"
|
||||
replace
|
||||
state={{ from: location.pathname + location.search }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
+57
-57
@@ -1,57 +1,57 @@
|
||||
import {
|
||||
ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
type ToastKind = "info" | "success" | "error";
|
||||
type Toast = { id: number; kind: ToastKind; text: string };
|
||||
|
||||
type Ctx = {
|
||||
show: (text: string, kind?: ToastKind) => void;
|
||||
};
|
||||
|
||||
const ToastCtx = createContext<Ctx | null>(null);
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [items, setItems] = useState<Toast[]>([]);
|
||||
|
||||
const show = useCallback((text: string, kind: ToastKind = "info") => {
|
||||
const id = Date.now() + Math.random();
|
||||
setItems((list) => [...list, { id, kind, text }]);
|
||||
window.setTimeout(() => {
|
||||
setItems((list) => list.filter((t) => t.id !== id));
|
||||
}, 2600);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastCtx.Provider value={{ show }}>
|
||||
{children}
|
||||
{items.map((t) => (
|
||||
<div key={t.id} className={`admin-toast is-${t.kind}`}>
|
||||
{t.text}
|
||||
</div>
|
||||
))}
|
||||
</ToastCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast(): Ctx {
|
||||
const ctx = useContext(ToastCtx);
|
||||
if (!ctx) throw new Error("useToast must be used inside <ToastProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// 小工具:自动关闭的 toast 倒计时,用于某些异步提示展示后返回
|
||||
export function useFlashError(): [string | null, (msg: string | null) => void] {
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!err) return;
|
||||
const t = window.setTimeout(() => setErr(null), 4000);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [err]);
|
||||
return [err, setErr];
|
||||
}
|
||||
import {
|
||||
ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
type ToastKind = "info" | "success" | "error";
|
||||
type Toast = { id: number; kind: ToastKind; text: string };
|
||||
|
||||
type Ctx = {
|
||||
show: (text: string, kind?: ToastKind) => void;
|
||||
};
|
||||
|
||||
const ToastCtx = createContext<Ctx | null>(null);
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [items, setItems] = useState<Toast[]>([]);
|
||||
|
||||
const show = useCallback((text: string, kind: ToastKind = "info") => {
|
||||
const id = Date.now() + Math.random();
|
||||
setItems((list) => [...list, { id, kind, text }]);
|
||||
window.setTimeout(() => {
|
||||
setItems((list) => list.filter((t) => t.id !== id));
|
||||
}, 2600);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastCtx.Provider value={{ show }}>
|
||||
{children}
|
||||
{items.map((t) => (
|
||||
<div key={t.id} className={`admin-toast is-${t.kind}`}>
|
||||
{t.text}
|
||||
</div>
|
||||
))}
|
||||
</ToastCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast(): Ctx {
|
||||
const ctx = useContext(ToastCtx);
|
||||
if (!ctx) throw new Error("useToast must be used inside <ToastProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// 小工具:自动关闭的 toast 倒计时,用于某些异步提示展示后返回
|
||||
export function useFlashError(): [string | null, (msg: string | null) => void] {
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!err) return;
|
||||
const t = window.setTimeout(() => setErr(null), 4000);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [err]);
|
||||
return [err, setErr];
|
||||
}
|
||||
|
||||
+304
-304
@@ -1,304 +1,304 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Edit, RefreshCw, Search } from "lucide-react";
|
||||
import * as api from "./api";
|
||||
import { useToast } from "./ToastContext";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
export function VideosPage() {
|
||||
const [list, setList] = useState<api.AdminVideo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [editing, setEditing] = useState<api.AdminVideo | null>(null);
|
||||
const { show } = useToast();
|
||||
|
||||
async function refresh() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await api.listVideos();
|
||||
setList(r.items ?? []);
|
||||
} catch (e) {
|
||||
show(e instanceof Error ? e.message : "加载失败", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
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)
|
||||
);
|
||||
})
|
||||
: list;
|
||||
|
||||
async function handleRegen(v: api.AdminVideo) {
|
||||
try {
|
||||
await api.regenPreview(v.id);
|
||||
show("已触发 teaser 重生", "success");
|
||||
} catch (e) {
|
||||
show(e instanceof Error ? e.message : "触发失败", "error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<header className="admin-page__header">
|
||||
<h1 className="admin-page__title">视频管理</h1>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Search
|
||||
size={14}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 10,
|
||||
color: "#aaa",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="搜索标题 / 作者 / ID"
|
||||
style={{
|
||||
padding: "8px 10px 8px 30px",
|
||||
border: "1px solid var(--color-line)",
|
||||
borderRadius: 3,
|
||||
minWidth: 240,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button className="admin-btn" onClick={refresh}>
|
||||
<RefreshCw size={13} /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{loading ? (
|
||||
<div className="admin-empty">加载中...</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="admin-card admin-empty">
|
||||
还没有视频。先在「网盘管理」里配置好盘并触发扫描。
|
||||
</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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<EditVideoModal
|
||||
video={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
setEditing(null);
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewStatus({ s }: { s: string }) {
|
||||
if (s === "ready") return <span className="admin-status is-ok">就绪</span>;
|
||||
if (s === "failed") return <span className="admin-status is-error">失败</span>;
|
||||
return <span className="admin-status is-pending">待生成</span>;
|
||||
}
|
||||
|
||||
function formatDur(sec: number): string {
|
||||
if (!sec) return "—";
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function EditVideoModal({
|
||||
video,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
video: api.AdminVideo;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [title, setTitle] = useState(video.title);
|
||||
const [author, setAuthor] = useState(video.author ?? "");
|
||||
const [tags, setTags] = useState((video.tags ?? []).join(", "));
|
||||
const [category, setCategory] = useState(video.category ?? "");
|
||||
const [badges, setBadges] = useState((video.badges ?? []).join(", "));
|
||||
const [description, setDescription] = useState(video.description ?? "");
|
||||
const [thumbnail, setThumbnail] = useState(video.thumbnailUrl ?? "");
|
||||
const [quality, setQuality] = useState(video.quality ?? "");
|
||||
const [durationSec, setDurationSec] = useState(String(video.durationSeconds || 0));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const { show } = useToast();
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.updateVideo(video.id, {
|
||||
title: title.trim(),
|
||||
author: author.trim(),
|
||||
tags: splitList(tags),
|
||||
category: category.trim(),
|
||||
badges: splitList(badges),
|
||||
description,
|
||||
thumbnail: thumbnail.trim(),
|
||||
quality: quality.trim(),
|
||||
durationSeconds: Number(durationSec) || 0,
|
||||
});
|
||||
show("已保存", "success");
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
show(e instanceof Error ? e.message : "保存失败", "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title={`编辑视频 · ${video.title}`}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="admin-btn" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button className="admin-btn is-primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? "保存中..." : "保存"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="admin-form">
|
||||
<div className="admin-form__row">
|
||||
<label>标题</label>
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>作者</label>
|
||||
<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)} />
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>分类</label>
|
||||
<input value={category} onChange={(e) => setCategory(e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>徽标(逗号分隔,例如 精选, 原创)</label>
|
||||
<input value={badges} onChange={(e) => setBadges(e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>质量</label>
|
||||
<select value={quality} onChange={(e) => setQuality(e.target.value)}>
|
||||
<option value="">未设置</option>
|
||||
<option value="HD">HD</option>
|
||||
<option value="SD">SD</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>时长(秒)</label>
|
||||
<input
|
||||
value={durationSec}
|
||||
onChange={(e) => setDurationSec(e.target.value)}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>封面 URL</label>
|
||||
<input value={thumbnail} onChange={(e) => setThumbnail(e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>描述</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</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>Teaser</dt>
|
||||
<dd>
|
||||
<PreviewStatus s={video.previewStatus} />
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function splitList(s: string): string[] {
|
||||
return s
|
||||
.split(/[,,、\s]+/)
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
import { useEffect, useState } from "react";
|
||||
import { Edit, RefreshCw, Search } from "lucide-react";
|
||||
import * as api from "./api";
|
||||
import { useToast } from "./ToastContext";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
export function VideosPage() {
|
||||
const [list, setList] = useState<api.AdminVideo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [editing, setEditing] = useState<api.AdminVideo | null>(null);
|
||||
const { show } = useToast();
|
||||
|
||||
async function refresh() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await api.listVideos();
|
||||
setList(r.items ?? []);
|
||||
} catch (e) {
|
||||
show(e instanceof Error ? e.message : "加载失败", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
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)
|
||||
);
|
||||
})
|
||||
: list;
|
||||
|
||||
async function handleRegen(v: api.AdminVideo) {
|
||||
try {
|
||||
await api.regenPreview(v.id);
|
||||
show("已触发 teaser 重生", "success");
|
||||
} catch (e) {
|
||||
show(e instanceof Error ? e.message : "触发失败", "error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<header className="admin-page__header">
|
||||
<h1 className="admin-page__title">视频管理</h1>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Search
|
||||
size={14}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 10,
|
||||
color: "#aaa",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="搜索标题 / 作者 / ID"
|
||||
style={{
|
||||
padding: "8px 10px 8px 30px",
|
||||
border: "1px solid var(--color-line)",
|
||||
borderRadius: 3,
|
||||
minWidth: 240,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button className="admin-btn" onClick={refresh}>
|
||||
<RefreshCw size={13} /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{loading ? (
|
||||
<div className="admin-empty">加载中...</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="admin-card admin-empty">
|
||||
还没有视频。先在「网盘管理」里配置好盘并触发扫描。
|
||||
</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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<EditVideoModal
|
||||
video={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
setEditing(null);
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewStatus({ s }: { s: string }) {
|
||||
if (s === "ready") return <span className="admin-status is-ok">就绪</span>;
|
||||
if (s === "failed") return <span className="admin-status is-error">失败</span>;
|
||||
return <span className="admin-status is-pending">待生成</span>;
|
||||
}
|
||||
|
||||
function formatDur(sec: number): string {
|
||||
if (!sec) return "—";
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function EditVideoModal({
|
||||
video,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
video: api.AdminVideo;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [title, setTitle] = useState(video.title);
|
||||
const [author, setAuthor] = useState(video.author ?? "");
|
||||
const [tags, setTags] = useState((video.tags ?? []).join(", "));
|
||||
const [category, setCategory] = useState(video.category ?? "");
|
||||
const [badges, setBadges] = useState((video.badges ?? []).join(", "));
|
||||
const [description, setDescription] = useState(video.description ?? "");
|
||||
const [thumbnail, setThumbnail] = useState(video.thumbnailUrl ?? "");
|
||||
const [quality, setQuality] = useState(video.quality ?? "");
|
||||
const [durationSec, setDurationSec] = useState(String(video.durationSeconds || 0));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const { show } = useToast();
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.updateVideo(video.id, {
|
||||
title: title.trim(),
|
||||
author: author.trim(),
|
||||
tags: splitList(tags),
|
||||
category: category.trim(),
|
||||
badges: splitList(badges),
|
||||
description,
|
||||
thumbnail: thumbnail.trim(),
|
||||
quality: quality.trim(),
|
||||
durationSeconds: Number(durationSec) || 0,
|
||||
});
|
||||
show("已保存", "success");
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
show(e instanceof Error ? e.message : "保存失败", "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title={`编辑视频 · ${video.title}`}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="admin-btn" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button className="admin-btn is-primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? "保存中..." : "保存"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="admin-form">
|
||||
<div className="admin-form__row">
|
||||
<label>标题</label>
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>作者</label>
|
||||
<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)} />
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>分类</label>
|
||||
<input value={category} onChange={(e) => setCategory(e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>徽标(逗号分隔,例如 精选, 原创)</label>
|
||||
<input value={badges} onChange={(e) => setBadges(e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>质量</label>
|
||||
<select value={quality} onChange={(e) => setQuality(e.target.value)}>
|
||||
<option value="">未设置</option>
|
||||
<option value="HD">HD</option>
|
||||
<option value="SD">SD</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>时长(秒)</label>
|
||||
<input
|
||||
value={durationSec}
|
||||
onChange={(e) => setDurationSec(e.target.value)}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>封面 URL</label>
|
||||
<input value={thumbnail} onChange={(e) => setThumbnail(e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-form__row">
|
||||
<label>描述</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</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>Teaser</dt>
|
||||
<dd>
|
||||
<PreviewStatus s={video.previewStatus} />
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function splitList(s: string): string[] {
|
||||
return s
|
||||
.split(/[,,、\s]+/)
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
+170
-170
@@ -1,170 +1,170 @@
|
||||
// 管理后台 API 客户端
|
||||
// 所有请求都带 cookie,401 会抛错让路由守卫跳登录
|
||||
const BASE = "/admin/api";
|
||||
|
||||
export class UnauthorizedError extends Error {
|
||||
constructor() {
|
||||
super("unauthorized");
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
init: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const res = await fetch(BASE + path, {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
...init,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
const ct = res.headers.get("content-type") ?? "";
|
||||
if (ct.includes("application/json")) {
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
return (await res.text()) as unknown as T;
|
||||
}
|
||||
|
||||
export function login(username: string, password: string) {
|
||||
return request<{ ok: boolean }>("/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return request<{ ok: boolean }>("/logout", { method: "POST" });
|
||||
}
|
||||
|
||||
export function me() {
|
||||
return request<{ authenticated: boolean }>("/me");
|
||||
}
|
||||
|
||||
// ---------- Drives ----------
|
||||
|
||||
export type AdminDrive = {
|
||||
id: string;
|
||||
kind: "quark" | "p115" | "wopan";
|
||||
name: string;
|
||||
rootId: string;
|
||||
scanRootId: string;
|
||||
status: string;
|
||||
lastError?: string;
|
||||
hasCredential: boolean;
|
||||
};
|
||||
|
||||
export function listDrives() {
|
||||
return request<AdminDrive[]>("/drives");
|
||||
}
|
||||
|
||||
export type UpsertDriveInput = {
|
||||
id: string;
|
||||
kind: "quark" | "p115" | "wopan";
|
||||
name: string;
|
||||
rootId: string;
|
||||
scanRootId: string;
|
||||
credentials: Record<string, string>;
|
||||
};
|
||||
|
||||
export function upsertDrive(body: UpsertDriveInput) {
|
||||
return request<{ ok: boolean; warning?: string }>("/drives", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteDrive(id: string) {
|
||||
return request<{ ok: boolean }>(`/drives/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function rescan(id: string) {
|
||||
return request<{ ok: boolean }>(
|
||||
`/drives/${encodeURIComponent(id)}/rescan`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Videos ----------
|
||||
|
||||
export type AdminVideo = {
|
||||
id: string;
|
||||
driveId: string;
|
||||
fileId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
tags: string[];
|
||||
durationSeconds: number;
|
||||
size: number;
|
||||
ext: string;
|
||||
quality: string;
|
||||
thumbnailUrl: string;
|
||||
previewStatus: string;
|
||||
views: number;
|
||||
favorites: number;
|
||||
comments: number;
|
||||
likes: number;
|
||||
category: string;
|
||||
badges: string[];
|
||||
description: string;
|
||||
publishedAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export function listVideos() {
|
||||
return request<{ items: AdminVideo[]; total: number }>("/videos");
|
||||
}
|
||||
|
||||
export type UpdateVideoInput = Partial<{
|
||||
title: string;
|
||||
author: string;
|
||||
tags: string[];
|
||||
category: string;
|
||||
badges: string[];
|
||||
description: string;
|
||||
thumbnail: string;
|
||||
quality: string;
|
||||
durationSeconds: number;
|
||||
}>;
|
||||
|
||||
export function updateVideo(id: string, body: UpdateVideoInput) {
|
||||
return request<AdminVideo>(`/videos/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export function regenPreview(id: string) {
|
||||
return request<{ ok: boolean }>(
|
||||
`/videos/${encodeURIComponent(id)}/regen-preview`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Settings ----------
|
||||
|
||||
export type Settings = {
|
||||
previewEnabled: boolean;
|
||||
};
|
||||
|
||||
export function getSettings() {
|
||||
return request<Settings>("/settings");
|
||||
}
|
||||
|
||||
export function updateSettings(body: Settings) {
|
||||
return request<Settings>("/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
// 管理后台 API 客户端
|
||||
// 所有请求都带 cookie,401 会抛错让路由守卫跳登录
|
||||
const BASE = "/admin/api";
|
||||
|
||||
export class UnauthorizedError extends Error {
|
||||
constructor() {
|
||||
super("unauthorized");
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
init: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const res = await fetch(BASE + path, {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
...init,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
const ct = res.headers.get("content-type") ?? "";
|
||||
if (ct.includes("application/json")) {
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
return (await res.text()) as unknown as T;
|
||||
}
|
||||
|
||||
export function login(username: string, password: string) {
|
||||
return request<{ ok: boolean }>("/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return request<{ ok: boolean }>("/logout", { method: "POST" });
|
||||
}
|
||||
|
||||
export function me() {
|
||||
return request<{ authenticated: boolean }>("/me");
|
||||
}
|
||||
|
||||
// ---------- Drives ----------
|
||||
|
||||
export type AdminDrive = {
|
||||
id: string;
|
||||
kind: "quark" | "p115" | "pikpak" | "wopan";
|
||||
name: string;
|
||||
rootId: string;
|
||||
scanRootId: string;
|
||||
status: string;
|
||||
lastError?: string;
|
||||
hasCredential: boolean;
|
||||
};
|
||||
|
||||
export function listDrives() {
|
||||
return request<AdminDrive[]>("/drives");
|
||||
}
|
||||
|
||||
export type UpsertDriveInput = {
|
||||
id: string;
|
||||
kind: "quark" | "p115" | "pikpak" | "wopan";
|
||||
name: string;
|
||||
rootId: string;
|
||||
scanRootId: string;
|
||||
credentials: Record<string, string>;
|
||||
};
|
||||
|
||||
export function upsertDrive(body: UpsertDriveInput) {
|
||||
return request<{ ok: boolean; warning?: string }>("/drives", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteDrive(id: string) {
|
||||
return request<{ ok: boolean }>(`/drives/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function rescan(id: string) {
|
||||
return request<{ ok: boolean }>(
|
||||
`/drives/${encodeURIComponent(id)}/rescan`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Videos ----------
|
||||
|
||||
export type AdminVideo = {
|
||||
id: string;
|
||||
driveId: string;
|
||||
fileId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
tags: string[];
|
||||
durationSeconds: number;
|
||||
size: number;
|
||||
ext: string;
|
||||
quality: string;
|
||||
thumbnailUrl: string;
|
||||
previewStatus: string;
|
||||
views: number;
|
||||
favorites: number;
|
||||
comments: number;
|
||||
likes: number;
|
||||
category: string;
|
||||
badges: string[];
|
||||
description: string;
|
||||
publishedAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export function listVideos() {
|
||||
return request<{ items: AdminVideo[]; total: number }>("/videos");
|
||||
}
|
||||
|
||||
export type UpdateVideoInput = Partial<{
|
||||
title: string;
|
||||
author: string;
|
||||
tags: string[];
|
||||
category: string;
|
||||
badges: string[];
|
||||
description: string;
|
||||
thumbnail: string;
|
||||
quality: string;
|
||||
durationSeconds: number;
|
||||
}>;
|
||||
|
||||
export function updateVideo(id: string, body: UpdateVideoInput) {
|
||||
return request<AdminVideo>(`/videos/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export function regenPreview(id: string) {
|
||||
return request<{ ok: boolean }>(
|
||||
`/videos/${encodeURIComponent(id)}/regen-preview`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Settings ----------
|
||||
|
||||
export type Settings = {
|
||||
previewEnabled: boolean;
|
||||
};
|
||||
|
||||
export function getSettings() {
|
||||
return request<Settings>("/settings");
|
||||
}
|
||||
|
||||
export function updateSettings(body: Settings) {
|
||||
return request<Settings>("/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
+23
-23
@@ -1,23 +1,23 @@
|
||||
import { ReactNode } from "react";
|
||||
import { TopBar } from "./TopBar";
|
||||
import { MainNav } from "./MainNav";
|
||||
import { SubNav } from "./SubNav";
|
||||
import { Footer } from "./Footer";
|
||||
import { BackToTop } from "./BackToTop";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function AppShell({ children }: Props) {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<TopBar />
|
||||
<MainNav />
|
||||
<SubNav />
|
||||
<main className="app-shell__main">{children}</main>
|
||||
<Footer />
|
||||
<BackToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { ReactNode } from "react";
|
||||
import { TopBar } from "./TopBar";
|
||||
import { MainNav } from "./MainNav";
|
||||
import { SubNav } from "./SubNav";
|
||||
import { Footer } from "./Footer";
|
||||
import { BackToTop } from "./BackToTop";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function AppShell({ children }: Props) {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<TopBar />
|
||||
<MainNav />
|
||||
<SubNav />
|
||||
<main className="app-shell__main">{children}</main>
|
||||
<Footer />
|
||||
<BackToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
|
||||
export function BackToTop() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function onScroll() {
|
||||
setVisible(window.scrollY > 400);
|
||||
}
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`back-to-top ${visible ? "is-visible" : ""}`}
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||
aria-label="返回顶部"
|
||||
>
|
||||
<ArrowUp size={18} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
|
||||
export function BackToTop() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function onScroll() {
|
||||
setVisible(window.scrollY > 400);
|
||||
}
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`back-to-top ${visible ? "is-visible" : ""}`}
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||
aria-label="返回顶部"
|
||||
>
|
||||
<ArrowUp size={18} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
import { forwardRef } from "react";
|
||||
import { ThumbsUp } from "lucide-react";
|
||||
import type { CommentItem } from "@/types";
|
||||
import { formatCount } from "@/lib/format";
|
||||
|
||||
type Props = {
|
||||
comments: CommentItem[];
|
||||
};
|
||||
|
||||
export const CommentPanel = forwardRef<HTMLElement, Props>(function CommentPanel(
|
||||
{ comments },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<section className="comment-panel" ref={ref} aria-label="评论">
|
||||
<header className="comment-panel__header">
|
||||
评论 ({comments.length})
|
||||
</header>
|
||||
<div className="comment-panel__body">
|
||||
{comments.length === 0 ? (
|
||||
<div className="comment-empty">暂无评论,快来抢沙发</div>
|
||||
) : (
|
||||
<ul className="comment-list">
|
||||
{comments.map((c) => (
|
||||
<li key={c.id} className="comment-item">
|
||||
<div className="comment-item__meta">
|
||||
<span className="comment-item__author">{c.author}</span>
|
||||
<span>{c.createdAt}</span>
|
||||
{typeof c.likes === "number" && (
|
||||
<span>
|
||||
<ThumbsUp
|
||||
size={12}
|
||||
style={{ verticalAlign: -1, marginRight: 2 }}
|
||||
/>
|
||||
{formatCount(c.likes)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>{c.body}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
});
|
||||
import { forwardRef } from "react";
|
||||
import { ThumbsUp } from "lucide-react";
|
||||
import type { CommentItem } from "@/types";
|
||||
import { formatCount } from "@/lib/format";
|
||||
|
||||
type Props = {
|
||||
comments: CommentItem[];
|
||||
};
|
||||
|
||||
export const CommentPanel = forwardRef<HTMLElement, Props>(function CommentPanel(
|
||||
{ comments },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<section className="comment-panel" ref={ref} aria-label="评论">
|
||||
<header className="comment-panel__header">
|
||||
评论 ({comments.length})
|
||||
</header>
|
||||
<div className="comment-panel__body">
|
||||
{comments.length === 0 ? (
|
||||
<div className="comment-empty">暂无评论,快来抢沙发</div>
|
||||
) : (
|
||||
<ul className="comment-list">
|
||||
{comments.map((c) => (
|
||||
<li key={c.id} className="comment-item">
|
||||
<div className="comment-item__meta">
|
||||
<span className="comment-item__author">{c.author}</span>
|
||||
<span>{c.createdAt}</span>
|
||||
{typeof c.likes === "number" && (
|
||||
<span>
|
||||
<ThumbsUp
|
||||
size={12}
|
||||
style={{ verticalAlign: -1, marginRight: 2 }}
|
||||
/>
|
||||
{formatCount(c.likes)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>{c.body}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
});
|
||||
|
||||
+18
-18
@@ -1,18 +1,18 @@
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="footer">
|
||||
<div className="container footer__inner">
|
||||
<div className="footer__links">
|
||||
<a href="#about">关于我们</a>
|
||||
<a href="#terms">服务条款</a>
|
||||
<a href="#privacy">隐私政策</a>
|
||||
<a href="#dmca">版权声明</a>
|
||||
<a href="#contact">联系我们</a>
|
||||
</div>
|
||||
<div className="footer__copy">
|
||||
© {new Date().getFullYear()} 视频站 Demo · 布局演示用途
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="footer">
|
||||
<div className="container footer__inner">
|
||||
<div className="footer__links">
|
||||
<a href="#about">关于我们</a>
|
||||
<a href="#terms">服务条款</a>
|
||||
<a href="#privacy">隐私政策</a>
|
||||
<a href="#dmca">版权声明</a>
|
||||
<a href="#contact">联系我们</a>
|
||||
</div>
|
||||
<div className="footer__copy">
|
||||
© {new Date().getFullYear()} 视频站 Demo · 布局演示用途
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
+65
-65
@@ -1,65 +1,65 @@
|
||||
import { useState } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import {
|
||||
Crown,
|
||||
Film,
|
||||
Menu,
|
||||
Play,
|
||||
Trophy,
|
||||
Upload,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
const navItems = [
|
||||
{ to: "/upload", label: "上传", icon: Upload },
|
||||
{ to: "/list", label: "视频", icon: Film },
|
||||
{ to: "/channels", label: "频道", icon: Users },
|
||||
{ to: "/rank", label: "排行榜", icon: Trophy },
|
||||
{ to: "/membership", label: "会员", icon: Crown },
|
||||
{ to: "/creators", label: "创作者", icon: Play },
|
||||
];
|
||||
|
||||
export function MainNav() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<nav className={`main-nav ${open ? "is-open" : ""}`}>
|
||||
<div className="container main-nav__inner">
|
||||
<NavLink to="/" className="main-nav__logo">
|
||||
<span className="main-nav__logo-mark">
|
||||
<Play size={16} fill="#000" />
|
||||
</span>
|
||||
视频站
|
||||
</NavLink>
|
||||
|
||||
<ul className="main-nav__list" role="menubar">
|
||||
{navItems.map(({ to, label, icon: Icon }) => (
|
||||
<li key={to} role="none">
|
||||
<NavLink
|
||||
to={to}
|
||||
role="menuitem"
|
||||
className={({ isActive }) =>
|
||||
`main-nav__link ${isActive ? "is-active" : ""}`
|
||||
}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
className="main-nav__toggle"
|
||||
aria-label={open ? "关闭菜单" : "打开菜单"}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
{open ? <X size={22} /> : <Menu size={22} />}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
import { useState } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import {
|
||||
Crown,
|
||||
Film,
|
||||
Menu,
|
||||
Play,
|
||||
Trophy,
|
||||
Upload,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
const navItems = [
|
||||
{ to: "/upload", label: "上传", icon: Upload },
|
||||
{ to: "/list", label: "视频", icon: Film },
|
||||
{ to: "/channels", label: "频道", icon: Users },
|
||||
{ to: "/rank", label: "排行榜", icon: Trophy },
|
||||
{ to: "/membership", label: "会员", icon: Crown },
|
||||
{ to: "/creators", label: "创作者", icon: Play },
|
||||
];
|
||||
|
||||
export function MainNav() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<nav className={`main-nav ${open ? "is-open" : ""}`}>
|
||||
<div className="container main-nav__inner">
|
||||
<NavLink to="/" className="main-nav__logo">
|
||||
<span className="main-nav__logo-mark">
|
||||
<Play size={16} fill="#000" />
|
||||
</span>
|
||||
视频站
|
||||
</NavLink>
|
||||
|
||||
<ul className="main-nav__list" role="menubar">
|
||||
{navItems.map(({ to, label, icon: Icon }) => (
|
||||
<li key={to} role="none">
|
||||
<NavLink
|
||||
to={to}
|
||||
role="menuitem"
|
||||
className={({ isActive }) =>
|
||||
`main-nav__link ${isActive ? "is-active" : ""}`
|
||||
}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
className="main-nav__toggle"
|
||||
aria-label={open ? "关闭菜单" : "打开菜单"}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
{open ? <X size={22} /> : <Menu size={22} />}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,67 +1,67 @@
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
onChange: (p: number) => void;
|
||||
};
|
||||
|
||||
function buildRange(current: number, last: number): (number | "...")[] {
|
||||
if (last <= 7) return Array.from({ length: last }, (_, i) => i + 1);
|
||||
|
||||
const result: (number | "...")[] = [1];
|
||||
const start = Math.max(2, current - 1);
|
||||
const end = Math.min(last - 1, current + 1);
|
||||
|
||||
if (start > 2) result.push("...");
|
||||
for (let i = start; i <= end; i++) result.push(i);
|
||||
if (end < last - 1) result.push("...");
|
||||
|
||||
result.push(last);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function Pagination({ page, pageSize, total, onChange }: Props) {
|
||||
const last = Math.max(1, Math.ceil(total / pageSize));
|
||||
if (last <= 1) return null;
|
||||
|
||||
const range = buildRange(page, last);
|
||||
|
||||
return (
|
||||
<nav className="pagination" aria-label="分页">
|
||||
<button
|
||||
className="pagination__btn"
|
||||
onClick={() => onChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
aria-label="上一页"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
{range.map((p, idx) =>
|
||||
p === "..." ? (
|
||||
<span key={`e${idx}`} className="pagination__btn" aria-hidden>
|
||||
...
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
className={`pagination__btn ${p === page ? "is-active" : ""}`}
|
||||
onClick={() => onChange(p)}
|
||||
aria-current={p === page ? "page" : undefined}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
className="pagination__btn"
|
||||
onClick={() => onChange(page + 1)}
|
||||
disabled={page >= last}
|
||||
aria-label="下一页"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
onChange: (p: number) => void;
|
||||
};
|
||||
|
||||
function buildRange(current: number, last: number): (number | "...")[] {
|
||||
if (last <= 7) return Array.from({ length: last }, (_, i) => i + 1);
|
||||
|
||||
const result: (number | "...")[] = [1];
|
||||
const start = Math.max(2, current - 1);
|
||||
const end = Math.min(last - 1, current + 1);
|
||||
|
||||
if (start > 2) result.push("...");
|
||||
for (let i = start; i <= end; i++) result.push(i);
|
||||
if (end < last - 1) result.push("...");
|
||||
|
||||
result.push(last);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function Pagination({ page, pageSize, total, onChange }: Props) {
|
||||
const last = Math.max(1, Math.ceil(total / pageSize));
|
||||
if (last <= 1) return null;
|
||||
|
||||
const range = buildRange(page, last);
|
||||
|
||||
return (
|
||||
<nav className="pagination" aria-label="分页">
|
||||
<button
|
||||
className="pagination__btn"
|
||||
onClick={() => onChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
aria-label="上一页"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
{range.map((p, idx) =>
|
||||
p === "..." ? (
|
||||
<span key={`e${idx}`} className="pagination__btn" aria-hidden>
|
||||
...
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
className={`pagination__btn ${p === page ? "is-active" : ""}`}
|
||||
onClick={() => onChange(p)}
|
||||
aria-current={p === page ? "page" : undefined}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
className="pagination__btn"
|
||||
onClick={() => onChange(page + 1)}
|
||||
disabled={page >= last}
|
||||
aria-label="下一页"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
import { forwardRef } from "react";
|
||||
import type { PreviewState } from "@/types";
|
||||
|
||||
type Props = {
|
||||
src: string;
|
||||
state: PreviewState;
|
||||
onCanPlay: () => void;
|
||||
onError: () => void;
|
||||
onTimeUpdate?: (progress: number) => void; // 0~1
|
||||
};
|
||||
|
||||
// 底层 video 节点。只在父组件判定需要挂载时才渲染,卸载时父组件负责清理
|
||||
export const PreviewVideo = forwardRef<HTMLVideoElement, Props>(
|
||||
function PreviewVideo(
|
||||
{ src, state, onCanPlay, onError, onTimeUpdate },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<video
|
||||
ref={ref}
|
||||
className={`preview-video ${state === "playing" ? "is-visible" : ""}`}
|
||||
src={src}
|
||||
muted
|
||||
autoPlay
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
onCanPlay={onCanPlay}
|
||||
onError={onError}
|
||||
onTimeUpdate={(e) => {
|
||||
if (!onTimeUpdate) return;
|
||||
const el = e.currentTarget;
|
||||
if (el.duration > 0) {
|
||||
onTimeUpdate(el.currentTime / el.duration);
|
||||
}
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
import { forwardRef } from "react";
|
||||
import type { PreviewState } from "@/types";
|
||||
|
||||
type Props = {
|
||||
src: string;
|
||||
state: PreviewState;
|
||||
onCanPlay: () => void;
|
||||
onError: () => void;
|
||||
onTimeUpdate?: (progress: number) => void; // 0~1
|
||||
};
|
||||
|
||||
// 底层 video 节点。只在父组件判定需要挂载时才渲染,卸载时父组件负责清理
|
||||
export const PreviewVideo = forwardRef<HTMLVideoElement, Props>(
|
||||
function PreviewVideo(
|
||||
{ src, state, onCanPlay, onError, onTimeUpdate },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<video
|
||||
ref={ref}
|
||||
className={`preview-video ${state === "playing" ? "is-visible" : ""}`}
|
||||
src={src}
|
||||
muted
|
||||
autoPlay
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
onCanPlay={onCanPlay}
|
||||
onError={onError}
|
||||
onTimeUpdate={(e) => {
|
||||
if (!onTimeUpdate) return;
|
||||
const el = e.currentTarget;
|
||||
if (el.duration > 0) {
|
||||
onTimeUpdate(el.currentTime / el.duration);
|
||||
}
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { promoItems } from "@/data/categories";
|
||||
|
||||
const kindLabel: Record<string, string> = {
|
||||
channel: "频道",
|
||||
collection: "合集",
|
||||
event: "活动",
|
||||
};
|
||||
|
||||
export function PromoStrip() {
|
||||
if (promoItems.length === 0) return null;
|
||||
return (
|
||||
<div className="promo-strip" aria-label="推荐内容">
|
||||
{promoItems.map((p) => (
|
||||
<a key={p.id} className="promo-card" href={`#${p.id}`}>
|
||||
<span className="promo-card__label">
|
||||
{p.label} · {kindLabel[p.kind]}
|
||||
</span>
|
||||
<span className="promo-card__title">{p.title}</span>
|
||||
{p.meta && <span className="promo-card__meta">{p.meta}</span>}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { promoItems } from "@/data/categories";
|
||||
|
||||
const kindLabel: Record<string, string> = {
|
||||
channel: "频道",
|
||||
collection: "合集",
|
||||
event: "活动",
|
||||
};
|
||||
|
||||
export function PromoStrip() {
|
||||
if (promoItems.length === 0) return null;
|
||||
return (
|
||||
<div className="promo-strip" aria-label="推荐内容">
|
||||
{promoItems.map((p) => (
|
||||
<a key={p.id} className="promo-card" href={`#${p.id}`}>
|
||||
<span className="promo-card__label">
|
||||
{p.label} · {kindLabel[p.kind]}
|
||||
</span>
|
||||
<span className="promo-card__title">{p.title}</span>
|
||||
{p.meta && <span className="promo-card__meta">{p.meta}</span>}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { VideoItem } from "@/types";
|
||||
import { VideoGrid } from "./VideoGrid";
|
||||
|
||||
type Props = {
|
||||
videos: VideoItem[];
|
||||
};
|
||||
|
||||
export function RecommendedRail({ videos }: Props) {
|
||||
return (
|
||||
<aside className="detail-side" aria-label="推荐视频">
|
||||
<div className="detail-side__header">推荐视频</div>
|
||||
<VideoGrid videos={videos} compact />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
import type { VideoItem } from "@/types";
|
||||
import { VideoGrid } from "./VideoGrid";
|
||||
|
||||
type Props = {
|
||||
videos: VideoItem[];
|
||||
};
|
||||
|
||||
export function RecommendedRail({ videos }: Props) {
|
||||
return (
|
||||
<aside className="detail-side" aria-label="推荐视频">
|
||||
<div className="detail-side__header">推荐视频</div>
|
||||
<VideoGrid videos={videos} compact />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
type SearchType = "video" | "user" | "id" | "date";
|
||||
|
||||
const typeOptions: { value: SearchType; label: string }[] = [
|
||||
{ value: "video", label: "搜索视频" },
|
||||
{ value: "user", label: "搜索用户" },
|
||||
{ value: "id", label: "视频 ID" },
|
||||
{ value: "date", label: "按日期" },
|
||||
];
|
||||
|
||||
export function SearchPanel() {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const [keyword, setKeyword] = useState(params.get("q") ?? "");
|
||||
const [type, setType] = useState<SearchType>("video");
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
const q = keyword.trim();
|
||||
const sp = new URLSearchParams();
|
||||
if (q) sp.set("q", q);
|
||||
sp.set("type", type);
|
||||
navigate(`/list?${sp.toString()}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="search-panel" onSubmit={handleSubmit} role="search">
|
||||
<div className="search-panel__form">
|
||||
<input
|
||||
className="search-panel__input"
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="搜索关键词、作者、视频 ID"
|
||||
aria-label="关键词"
|
||||
/>
|
||||
<select
|
||||
className="search-panel__select"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as SearchType)}
|
||||
aria-label="搜索类型"
|
||||
>
|
||||
{typeOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="search-panel__submit" type="submit">
|
||||
<Search size={16} />
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
type SearchType = "video" | "user" | "id" | "date";
|
||||
|
||||
const typeOptions: { value: SearchType; label: string }[] = [
|
||||
{ value: "video", label: "搜索视频" },
|
||||
{ value: "user", label: "搜索用户" },
|
||||
{ value: "id", label: "视频 ID" },
|
||||
{ value: "date", label: "按日期" },
|
||||
];
|
||||
|
||||
export function SearchPanel() {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const [keyword, setKeyword] = useState(params.get("q") ?? "");
|
||||
const [type, setType] = useState<SearchType>("video");
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
const q = keyword.trim();
|
||||
const sp = new URLSearchParams();
|
||||
if (q) sp.set("q", q);
|
||||
sp.set("type", type);
|
||||
navigate(`/list?${sp.toString()}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="search-panel" onSubmit={handleSubmit} role="search">
|
||||
<div className="search-panel__form">
|
||||
<input
|
||||
className="search-panel__input"
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="搜索关键词、作者、视频 ID"
|
||||
aria-label="关键词"
|
||||
/>
|
||||
<select
|
||||
className="search-panel__select"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as SearchType)}
|
||||
aria-label="搜索类型"
|
||||
>
|
||||
{typeOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="search-panel__submit" type="submit">
|
||||
<Search size={16} />
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
extra?: ReactNode;
|
||||
};
|
||||
|
||||
export function SectionHeader({ title, extra }: Props) {
|
||||
return (
|
||||
<div className="section-header">
|
||||
<span className="section-header__title">{title}</span>
|
||||
{extra && <span className="section-header__extra">{extra}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
extra?: ReactNode;
|
||||
};
|
||||
|
||||
export function SectionHeader({ title, extra }: Props) {
|
||||
return (
|
||||
<div className="section-header">
|
||||
<span className="section-header__title">{title}</span>
|
||||
{extra && <span className="section-header__extra">{extra}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,62 +1,62 @@
|
||||
import { LayoutGrid, List } from "lucide-react";
|
||||
import type { SortKey } from "@/types";
|
||||
|
||||
type ViewMode = "grid" | "compact";
|
||||
|
||||
type Props = {
|
||||
sort: SortKey;
|
||||
view: ViewMode;
|
||||
onSortChange: (s: SortKey) => void;
|
||||
onViewChange: (v: ViewMode) => void;
|
||||
};
|
||||
|
||||
const sortOptions: { key: SortKey; label: string }[] = [
|
||||
{ key: "latest", label: "最新" },
|
||||
{ key: "hot", label: "最热" },
|
||||
{ key: "week", label: "本周" },
|
||||
{ key: "long", label: "最长" },
|
||||
{ key: "hd", label: "高清" },
|
||||
{ key: "featured", label: "精选" },
|
||||
];
|
||||
|
||||
export function SortToolbar({ sort, view, onSortChange, onViewChange }: Props) {
|
||||
return (
|
||||
<div className="sort-toolbar" role="toolbar" aria-label="排序和视图">
|
||||
<div className="sort-toolbar__group">
|
||||
{sortOptions.map((o) => (
|
||||
<button
|
||||
key={o.key}
|
||||
className={`sort-toolbar__btn ${sort === o.key ? "is-active" : ""}`}
|
||||
onClick={() => onSortChange(o.key)}
|
||||
aria-pressed={sort === o.key}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="sort-toolbar__spacer" />
|
||||
<div className="sort-toolbar__group" aria-label="视图切换">
|
||||
<button
|
||||
className={`sort-toolbar__btn ${view === "grid" ? "is-active" : ""}`}
|
||||
onClick={() => onViewChange("grid")}
|
||||
aria-pressed={view === "grid"}
|
||||
aria-label="基础视图"
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={`sort-toolbar__btn ${
|
||||
view === "compact" ? "is-active" : ""
|
||||
}`}
|
||||
onClick={() => onViewChange("compact")}
|
||||
aria-pressed={view === "compact"}
|
||||
aria-label="详细视图"
|
||||
>
|
||||
<List size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type { ViewMode };
|
||||
import { LayoutGrid, List } from "lucide-react";
|
||||
import type { SortKey } from "@/types";
|
||||
|
||||
type ViewMode = "grid" | "compact";
|
||||
|
||||
type Props = {
|
||||
sort: SortKey;
|
||||
view: ViewMode;
|
||||
onSortChange: (s: SortKey) => void;
|
||||
onViewChange: (v: ViewMode) => void;
|
||||
};
|
||||
|
||||
const sortOptions: { key: SortKey; label: string }[] = [
|
||||
{ key: "latest", label: "最新" },
|
||||
{ key: "hot", label: "最热" },
|
||||
{ key: "week", label: "本周" },
|
||||
{ key: "long", label: "最长" },
|
||||
{ key: "hd", label: "高清" },
|
||||
{ key: "featured", label: "精选" },
|
||||
];
|
||||
|
||||
export function SortToolbar({ sort, view, onSortChange, onViewChange }: Props) {
|
||||
return (
|
||||
<div className="sort-toolbar" role="toolbar" aria-label="排序和视图">
|
||||
<div className="sort-toolbar__group">
|
||||
{sortOptions.map((o) => (
|
||||
<button
|
||||
key={o.key}
|
||||
className={`sort-toolbar__btn ${sort === o.key ? "is-active" : ""}`}
|
||||
onClick={() => onSortChange(o.key)}
|
||||
aria-pressed={sort === o.key}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="sort-toolbar__spacer" />
|
||||
<div className="sort-toolbar__group" aria-label="视图切换">
|
||||
<button
|
||||
className={`sort-toolbar__btn ${view === "grid" ? "is-active" : ""}`}
|
||||
onClick={() => onViewChange("grid")}
|
||||
aria-pressed={view === "grid"}
|
||||
aria-label="基础视图"
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={`sort-toolbar__btn ${
|
||||
view === "compact" ? "is-active" : ""
|
||||
}`}
|
||||
onClick={() => onViewChange("compact")}
|
||||
aria-pressed={view === "compact"}
|
||||
aria-label="详细视图"
|
||||
>
|
||||
<List size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type { ViewMode };
|
||||
|
||||
+20
-20
@@ -1,20 +1,20 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { subNavLinks } from "@/data/tags";
|
||||
|
||||
export function SubNav() {
|
||||
return (
|
||||
<div className="sub-nav">
|
||||
<div className="container">
|
||||
<ul className="sub-nav__list">
|
||||
{subNavLinks.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link to={link.href} className="sub-nav__item">
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { Link } from "react-router-dom";
|
||||
import { subNavLinks } from "@/data/tags";
|
||||
|
||||
export function SubNav() {
|
||||
return (
|
||||
<div className="sub-nav">
|
||||
<div className="container">
|
||||
<ul className="sub-nav__list">
|
||||
{subNavLinks.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link to={link.href} className="sub-nav__item">
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+42
-42
@@ -1,42 +1,42 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { fetchTags, type TagItem } from "@/data/videos";
|
||||
|
||||
export function TagCloud() {
|
||||
const [params] = useSearchParams();
|
||||
const activeTag = params.get("cat");
|
||||
const [tags, setTags] = useState<TagItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
fetchTags().then((list) => {
|
||||
if (active) setTags(list);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (tags.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="tag-cloud" aria-label="热门分类">
|
||||
<span className="tag-cloud__label">分类:</span>
|
||||
{tags.map((tag) => (
|
||||
<Link
|
||||
key={tag.id}
|
||||
to={`/list?cat=${encodeURIComponent(tag.label)}`}
|
||||
className={`tag-chip ${activeTag === tag.label ? "is-active" : ""}`}
|
||||
title={
|
||||
typeof tag.count === "number" ? `${tag.count} 个视频` : undefined
|
||||
}
|
||||
>
|
||||
{tag.label}
|
||||
{typeof tag.count === "number" && tag.count > 0 && (
|
||||
<span style={{ marginLeft: 4, opacity: 0.7 }}>({tag.count})</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { fetchTags, type TagItem } from "@/data/videos";
|
||||
|
||||
export function TagCloud() {
|
||||
const [params] = useSearchParams();
|
||||
const activeTag = params.get("tag");
|
||||
const [tags, setTags] = useState<TagItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
fetchTags().then((list) => {
|
||||
if (active) setTags(list);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (tags.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="tag-cloud" aria-label="热门分类">
|
||||
<span className="tag-cloud__label">分类:</span>
|
||||
{tags.map((tag) => (
|
||||
<Link
|
||||
key={tag.id}
|
||||
to={`/list?tag=${encodeURIComponent(tag.label)}`}
|
||||
className={`tag-chip ${activeTag === tag.label ? "is-active" : ""}`}
|
||||
title={
|
||||
typeof tag.count === "number" ? `${tag.count} 个视频` : undefined
|
||||
}
|
||||
>
|
||||
{tag.label}
|
||||
{typeof tag.count === "number" && tag.count > 0 && (
|
||||
<span style={{ marginLeft: 4, opacity: 0.7 }}>({tag.count})</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+26
-26
@@ -1,26 +1,26 @@
|
||||
import { Globe, LogIn, UserPlus } from "lucide-react";
|
||||
|
||||
export function TopBar() {
|
||||
return (
|
||||
<div className="top-bar">
|
||||
<div className="container top-bar__inner">
|
||||
<div className="top-bar__side">
|
||||
<a href="#lang" aria-label="切换语言">
|
||||
<Globe size={12} style={{ marginRight: 4, verticalAlign: -1 }} />
|
||||
简体中文
|
||||
</a>
|
||||
</div>
|
||||
<div className="top-bar__side">
|
||||
<a href="#register">
|
||||
<UserPlus size={12} style={{ marginRight: 4, verticalAlign: -1 }} />
|
||||
注册
|
||||
</a>
|
||||
<a href="#login">
|
||||
<LogIn size={12} style={{ marginRight: 4, verticalAlign: -1 }} />
|
||||
登录
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { Globe, LogIn, UserPlus } from "lucide-react";
|
||||
|
||||
export function TopBar() {
|
||||
return (
|
||||
<div className="top-bar">
|
||||
<div className="container top-bar__inner">
|
||||
<div className="top-bar__side">
|
||||
<a href="#lang" aria-label="切换语言">
|
||||
<Globe size={12} style={{ marginRight: 4, verticalAlign: -1 }} />
|
||||
简体中文
|
||||
</a>
|
||||
</div>
|
||||
<div className="top-bar__side">
|
||||
<a href="#register">
|
||||
<UserPlus size={12} style={{ marginRight: 4, verticalAlign: -1 }} />
|
||||
注册
|
||||
</a>
|
||||
<a href="#login">
|
||||
<LogIn size={12} style={{ marginRight: 4, verticalAlign: -1 }} />
|
||||
登录
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+118
-118
@@ -1,118 +1,118 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Bookmark,
|
||||
Download,
|
||||
Flag,
|
||||
MessageSquare,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
} from "lucide-react";
|
||||
import type { VideoDetail } from "@/types";
|
||||
import { formatCount } from "@/lib/format";
|
||||
|
||||
type Props = {
|
||||
video: VideoDetail;
|
||||
onJumpToComments: () => void;
|
||||
};
|
||||
|
||||
export function VideoActions({ video, onJumpToComments }: Props) {
|
||||
const [likes, setLikes] = useState(video.likes ?? 0);
|
||||
const [dislikes, setDislikes] = useState(video.dislikes ?? 0);
|
||||
const [bursting, setBursting] = useState(false);
|
||||
const [favorited, setFavorited] = useState(false);
|
||||
|
||||
async function handleLike() {
|
||||
// 乐观 +1,立即给个视觉反馈
|
||||
setLikes((n) => n + 1);
|
||||
setBursting(true);
|
||||
window.setTimeout(() => setBursting(false), 240);
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/video/${encodeURIComponent(video.id)}/like`,
|
||||
{ method: "POST", credentials: "include" }
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as { likes: number };
|
||||
if (typeof data.likes === "number") {
|
||||
// 用服务端真实值对齐(并发点击时更准确)
|
||||
setLikes(data.likes);
|
||||
}
|
||||
} catch {
|
||||
// 回滚 +1
|
||||
setLikes((n) => Math.max(0, n - 1));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="video-stats">
|
||||
<span className="video-stats__item">
|
||||
<span className="video-stats__label">时长</span>
|
||||
<span className="video-stats__value">{video.duration}</span>
|
||||
</span>
|
||||
<span className="video-stats__item">
|
||||
<span className="video-stats__label">观看</span>
|
||||
<span className="video-stats__value">{formatCount(video.views)}</span>
|
||||
</span>
|
||||
<span className="video-stats__item">
|
||||
<span className="video-stats__label">评论</span>
|
||||
<span className="video-stats__value">
|
||||
{formatCount(video.comments)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="video-stats__item">
|
||||
<span className="video-stats__label">收藏</span>
|
||||
<span className="video-stats__value">
|
||||
{formatCount(video.favorites)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="video-stats__item">
|
||||
<span className="video-stats__label">点赞</span>
|
||||
<span className="video-stats__value">{formatCount(likes)}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="video-actions">
|
||||
<button
|
||||
className={`video-actions__btn video-actions__like ${
|
||||
bursting ? "is-bursting" : ""
|
||||
}`}
|
||||
onClick={handleLike}
|
||||
aria-label="点赞"
|
||||
>
|
||||
<ThumbsUp size={14} />
|
||||
点赞 · {formatCount(likes)}
|
||||
</button>
|
||||
<button
|
||||
className="video-actions__btn is-danger"
|
||||
onClick={() => setDislikes((n) => n + 1)}
|
||||
aria-label="点踩"
|
||||
>
|
||||
<ThumbsDown size={14} />
|
||||
点踩 · {formatCount(dislikes)}
|
||||
</button>
|
||||
<button
|
||||
className={`video-actions__btn ${favorited ? "is-active" : ""}`}
|
||||
onClick={() => setFavorited((v) => !v)}
|
||||
aria-pressed={favorited}
|
||||
>
|
||||
<Bookmark size={14} />
|
||||
{favorited ? "已收藏" : "收藏"}
|
||||
</button>
|
||||
<button className="video-actions__btn" onClick={onJumpToComments}>
|
||||
<MessageSquare size={14} />
|
||||
写评论
|
||||
</button>
|
||||
<button className="video-actions__btn" title="登录后可下载">
|
||||
<Download size={14} />
|
||||
下载
|
||||
</button>
|
||||
<button className="video-actions__btn" title="举报">
|
||||
<Flag size={14} />
|
||||
举报
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Bookmark,
|
||||
Download,
|
||||
Flag,
|
||||
MessageSquare,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
} from "lucide-react";
|
||||
import type { VideoDetail } from "@/types";
|
||||
import { formatCount } from "@/lib/format";
|
||||
|
||||
type Props = {
|
||||
video: VideoDetail;
|
||||
onJumpToComments: () => void;
|
||||
};
|
||||
|
||||
export function VideoActions({ video, onJumpToComments }: Props) {
|
||||
const [likes, setLikes] = useState(video.likes ?? 0);
|
||||
const [dislikes, setDislikes] = useState(video.dislikes ?? 0);
|
||||
const [bursting, setBursting] = useState(false);
|
||||
const [favorited, setFavorited] = useState(false);
|
||||
|
||||
async function handleLike() {
|
||||
// 乐观 +1,立即给个视觉反馈
|
||||
setLikes((n) => n + 1);
|
||||
setBursting(true);
|
||||
window.setTimeout(() => setBursting(false), 240);
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/video/${encodeURIComponent(video.id)}/like`,
|
||||
{ method: "POST", credentials: "include" }
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = (await res.json()) as { likes: number };
|
||||
if (typeof data.likes === "number") {
|
||||
// 用服务端真实值对齐(并发点击时更准确)
|
||||
setLikes(data.likes);
|
||||
}
|
||||
} catch {
|
||||
// 回滚 +1
|
||||
setLikes((n) => Math.max(0, n - 1));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="video-stats">
|
||||
<span className="video-stats__item">
|
||||
<span className="video-stats__label">时长</span>
|
||||
<span className="video-stats__value">{video.duration}</span>
|
||||
</span>
|
||||
<span className="video-stats__item">
|
||||
<span className="video-stats__label">观看</span>
|
||||
<span className="video-stats__value">{formatCount(video.views)}</span>
|
||||
</span>
|
||||
<span className="video-stats__item">
|
||||
<span className="video-stats__label">评论</span>
|
||||
<span className="video-stats__value">
|
||||
{formatCount(video.comments)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="video-stats__item">
|
||||
<span className="video-stats__label">收藏</span>
|
||||
<span className="video-stats__value">
|
||||
{formatCount(video.favorites)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="video-stats__item">
|
||||
<span className="video-stats__label">点赞</span>
|
||||
<span className="video-stats__value">{formatCount(likes)}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="video-actions">
|
||||
<button
|
||||
className={`video-actions__btn video-actions__like ${
|
||||
bursting ? "is-bursting" : ""
|
||||
}`}
|
||||
onClick={handleLike}
|
||||
aria-label="点赞"
|
||||
>
|
||||
<ThumbsUp size={14} />
|
||||
点赞 · {formatCount(likes)}
|
||||
</button>
|
||||
<button
|
||||
className="video-actions__btn is-danger"
|
||||
onClick={() => setDislikes((n) => n + 1)}
|
||||
aria-label="点踩"
|
||||
>
|
||||
<ThumbsDown size={14} />
|
||||
点踩 · {formatCount(dislikes)}
|
||||
</button>
|
||||
<button
|
||||
className={`video-actions__btn ${favorited ? "is-active" : ""}`}
|
||||
onClick={() => setFavorited((v) => !v)}
|
||||
aria-pressed={favorited}
|
||||
>
|
||||
<Bookmark size={14} />
|
||||
{favorited ? "已收藏" : "收藏"}
|
||||
</button>
|
||||
<button className="video-actions__btn" onClick={onJumpToComments}>
|
||||
<MessageSquare size={14} />
|
||||
写评论
|
||||
</button>
|
||||
<button className="video-actions__btn" title="登录后可下载">
|
||||
<Download size={14} />
|
||||
下载
|
||||
</button>
|
||||
<button className="video-actions__btn" title="举报">
|
||||
<Flag size={14} />
|
||||
举报
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+218
-182
@@ -1,182 +1,218 @@
|
||||
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 { useInViewport } from "@/lib/useInViewport";
|
||||
import { formatCount } from "@/lib/format";
|
||||
import { PreviewVideo } from "./PreviewVideo";
|
||||
|
||||
type Props = {
|
||||
video: VideoItem;
|
||||
};
|
||||
|
||||
const HOVER_DELAY_MS = 300;
|
||||
|
||||
function useActivePreviewId(): string | null {
|
||||
return useSyncExternalStore(
|
||||
previewController.subscribe,
|
||||
previewController.getActiveId,
|
||||
() => null
|
||||
);
|
||||
}
|
||||
|
||||
export function VideoCard({ video }: Props) {
|
||||
const [previewState, setPreviewState] = useState<PreviewState>("idle");
|
||||
const [shouldRenderPreview, setShouldRenderPreview] = useState(false);
|
||||
const [progress, setProgress] = useState(0); // 0~1
|
||||
|
||||
const rootRef = useRef<HTMLElement | null>(null);
|
||||
const hoverTimerRef = useRef<number | null>(null);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
|
||||
const activeId = useActivePreviewId();
|
||||
const inView = useInViewport(rootRef);
|
||||
|
||||
// 当全局活跃卡片不是自己时,立刻停止预览
|
||||
useEffect(() => {
|
||||
if (activeId !== video.id && shouldRenderPreview) {
|
||||
cleanup();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeId, video.id]);
|
||||
|
||||
// 离开视口时停止预览
|
||||
useEffect(() => {
|
||||
if (!inView && shouldRenderPreview) {
|
||||
cleanup();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [inView]);
|
||||
|
||||
// 卸载时清理
|
||||
useEffect(() => {
|
||||
return () => cleanup();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function cleanup() {
|
||||
if (hoverTimerRef.current) {
|
||||
window.clearTimeout(hoverTimerRef.current);
|
||||
hoverTimerRef.current = null;
|
||||
}
|
||||
|
||||
const el = videoRef.current;
|
||||
if (el) {
|
||||
try {
|
||||
el.pause();
|
||||
el.removeAttribute("src");
|
||||
el.load();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
setShouldRenderPreview(false);
|
||||
setPreviewState("idle");
|
||||
setProgress(0);
|
||||
|
||||
if (previewController.getActiveId() === video.id) {
|
||||
previewController.setActiveId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function startPreviewIntent() {
|
||||
if (!inView) return;
|
||||
setPreviewState("intent");
|
||||
|
||||
hoverTimerRef.current = window.setTimeout(() => {
|
||||
// 抢占全局播放锁
|
||||
previewController.setActiveId(video.id);
|
||||
setShouldRenderPreview(true);
|
||||
setPreviewState("loading");
|
||||
}, HOVER_DELAY_MS);
|
||||
}
|
||||
|
||||
function stopPreview() {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
// 移动端:首次点击卡片触发预览,浮层播放按钮跳转详情
|
||||
// 为了让 Link 正常跳转,我们不拦截移动端点击,移动端表现为直接跳转详情
|
||||
// 如需长按预览,后续可在此扩展
|
||||
|
||||
return (
|
||||
<article
|
||||
ref={rootRef as React.RefObject<HTMLElement>}
|
||||
className="video-card"
|
||||
onPointerEnter={startPreviewIntent}
|
||||
onPointerLeave={stopPreview}
|
||||
onFocus={startPreviewIntent}
|
||||
onBlur={stopPreview}
|
||||
>
|
||||
<Link to={video.href} className="video-card__link" tabIndex={0}>
|
||||
<div className="thumb-frame">
|
||||
<img
|
||||
className="thumb-image"
|
||||
src={video.thumbnail}
|
||||
alt={video.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
{shouldRenderPreview && (
|
||||
<PreviewVideo
|
||||
ref={videoRef}
|
||||
src={video.previewSrc}
|
||||
state={previewState}
|
||||
onCanPlay={() => setPreviewState("playing")}
|
||||
onError={() => setPreviewState("error")}
|
||||
onTimeUpdate={(p) => setProgress(p)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{previewState === "loading" && <span className="preview-loader" />}
|
||||
{previewState === "error" && (
|
||||
<span className="preview-error">预览加载失败</span>
|
||||
)}
|
||||
|
||||
{/* 预览进度条(播放时显示在底部) */}
|
||||
{previewState === "playing" && (
|
||||
<div className="preview-progress" aria-hidden="true">
|
||||
<div
|
||||
className="preview-progress__bar"
|
||||
style={{ width: `${Math.min(100, progress * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* hover 时右上角 "预览" 角标 */}
|
||||
{previewState === "playing" && (
|
||||
<span className="preview-tag" aria-hidden="true">
|
||||
预览
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="badge-row">
|
||||
{video.quality === "HD" && (
|
||||
<span className="video-badge is-hd">HD</span>
|
||||
)}
|
||||
{(video.badges ?? []).map((badge) => (
|
||||
<span className="video-badge" key={badge}>
|
||||
{badge}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span className="duration">{video.duration}</span>
|
||||
</div>
|
||||
|
||||
<h3 className="video-title" title={video.title}>
|
||||
{video.title}
|
||||
</h3>
|
||||
|
||||
<div className="video-meta">
|
||||
<span className="video-meta__author">{video.author}</span>
|
||||
<span>{formatCount(video.views)} 观看</span>
|
||||
<span>{formatCount(video.favorites)} 收藏</span>
|
||||
<span>{formatCount(video.comments)} 评论</span>
|
||||
<span>{video.publishedAt}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
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 { useInViewport } from "@/lib/useInViewport";
|
||||
import { formatCount } from "@/lib/format";
|
||||
import { PreviewVideo } from "./PreviewVideo";
|
||||
|
||||
type Props = {
|
||||
video: VideoItem;
|
||||
};
|
||||
|
||||
const HOVER_DELAY_MS = 300;
|
||||
|
||||
function useActivePreviewId(): string | null {
|
||||
return useSyncExternalStore(
|
||||
previewController.subscribe,
|
||||
previewController.getActiveId,
|
||||
() => null
|
||||
);
|
||||
}
|
||||
|
||||
export function VideoCard({ video }: Props) {
|
||||
const [previewState, setPreviewState] = useState<PreviewState>("idle");
|
||||
const [shouldRenderPreview, setShouldRenderPreview] = useState(false);
|
||||
const [progress, setProgress] = useState(0); // 0~1
|
||||
const [thumbnailRetry, setThumbnailRetry] = useState(0);
|
||||
|
||||
const rootRef = useRef<HTMLElement | null>(null);
|
||||
const hoverTimerRef = useRef<number | null>(null);
|
||||
const thumbnailRetryTimerRef = useRef<number | null>(null);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
|
||||
const activeId = useActivePreviewId();
|
||||
const inView = useInViewport(rootRef);
|
||||
|
||||
// 当全局活跃卡片不是自己时,立刻停止预览
|
||||
useEffect(() => {
|
||||
if (activeId !== video.id && shouldRenderPreview) {
|
||||
cleanup();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeId, video.id]);
|
||||
|
||||
// 离开视口时停止预览
|
||||
useEffect(() => {
|
||||
if (!inView && shouldRenderPreview) {
|
||||
cleanup();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [inView]);
|
||||
|
||||
// 卸载时清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanup();
|
||||
if (thumbnailRetryTimerRef.current) {
|
||||
window.clearTimeout(thumbnailRetryTimerRef.current);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setThumbnailRetry(0);
|
||||
if (thumbnailRetryTimerRef.current) {
|
||||
window.clearTimeout(thumbnailRetryTimerRef.current);
|
||||
thumbnailRetryTimerRef.current = null;
|
||||
}
|
||||
}, [video.id, video.thumbnail]);
|
||||
|
||||
function cleanup() {
|
||||
if (hoverTimerRef.current) {
|
||||
window.clearTimeout(hoverTimerRef.current);
|
||||
hoverTimerRef.current = null;
|
||||
}
|
||||
|
||||
const el = videoRef.current;
|
||||
if (el) {
|
||||
try {
|
||||
el.pause();
|
||||
el.removeAttribute("src");
|
||||
el.load();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
setShouldRenderPreview(false);
|
||||
setPreviewState("idle");
|
||||
setProgress(0);
|
||||
|
||||
if (previewController.getActiveId() === video.id) {
|
||||
previewController.setActiveId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleThumbnailError() {
|
||||
if (!video.thumbnail.startsWith("/p/thumb/")) return;
|
||||
if (thumbnailRetry >= 8 || thumbnailRetryTimerRef.current) return;
|
||||
|
||||
thumbnailRetryTimerRef.current = window.setTimeout(() => {
|
||||
thumbnailRetryTimerRef.current = null;
|
||||
setThumbnailRetry((n) => n + 1);
|
||||
}, Math.min(1000 + thumbnailRetry * 750, 5000));
|
||||
}
|
||||
|
||||
const thumbnailSrc =
|
||||
thumbnailRetry === 0
|
||||
? video.thumbnail
|
||||
: withRetryParam(video.thumbnail, thumbnailRetry);
|
||||
|
||||
function startPreviewIntent() {
|
||||
if (!inView) return;
|
||||
setPreviewState("intent");
|
||||
|
||||
hoverTimerRef.current = window.setTimeout(() => {
|
||||
// 抢占全局播放锁
|
||||
previewController.setActiveId(video.id);
|
||||
setShouldRenderPreview(true);
|
||||
setPreviewState("loading");
|
||||
}, HOVER_DELAY_MS);
|
||||
}
|
||||
|
||||
function stopPreview() {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
// 移动端:首次点击卡片触发预览,浮层播放按钮跳转详情
|
||||
// 为了让 Link 正常跳转,我们不拦截移动端点击,移动端表现为直接跳转详情
|
||||
// 如需长按预览,后续可在此扩展
|
||||
|
||||
return (
|
||||
<article
|
||||
ref={rootRef as React.RefObject<HTMLElement>}
|
||||
className="video-card"
|
||||
onPointerEnter={startPreviewIntent}
|
||||
onPointerLeave={stopPreview}
|
||||
onFocus={startPreviewIntent}
|
||||
onBlur={stopPreview}
|
||||
>
|
||||
<Link to={video.href} className="video-card__link" tabIndex={0}>
|
||||
<div className="thumb-frame">
|
||||
<img
|
||||
className="thumb-image"
|
||||
src={thumbnailSrc}
|
||||
alt={video.title}
|
||||
loading="lazy"
|
||||
onError={handleThumbnailError}
|
||||
/>
|
||||
|
||||
{shouldRenderPreview && (
|
||||
<PreviewVideo
|
||||
ref={videoRef}
|
||||
src={video.previewSrc}
|
||||
state={previewState}
|
||||
onCanPlay={() => setPreviewState("playing")}
|
||||
onError={() => setPreviewState("error")}
|
||||
onTimeUpdate={(p) => setProgress(p)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{previewState === "loading" && <span className="preview-loader" />}
|
||||
{previewState === "error" && (
|
||||
<span className="preview-error">预览加载失败</span>
|
||||
)}
|
||||
|
||||
{/* 预览进度条(播放时显示在底部) */}
|
||||
{previewState === "playing" && (
|
||||
<div className="preview-progress" aria-hidden="true">
|
||||
<div
|
||||
className="preview-progress__bar"
|
||||
style={{ width: `${Math.min(100, progress * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* hover 时右上角 "预览" 角标 */}
|
||||
{previewState === "playing" && (
|
||||
<span className="preview-tag" aria-hidden="true">
|
||||
预览
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="badge-row">
|
||||
{video.quality === "HD" && (
|
||||
<span className="video-badge is-hd">HD</span>
|
||||
)}
|
||||
{(video.badges ?? []).map((badge) => (
|
||||
<span className="video-badge" key={badge}>
|
||||
{badge}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span className="duration">{video.duration}</span>
|
||||
</div>
|
||||
|
||||
<h3 className="video-title" title={video.title}>
|
||||
{video.title}
|
||||
</h3>
|
||||
|
||||
<div className="video-meta">
|
||||
<span className="video-meta__author">{video.author}</span>
|
||||
<span>{formatCount(video.views)} 观看</span>
|
||||
<span>{formatCount(video.favorites)} 收藏</span>
|
||||
<span>{formatCount(video.comments)} 评论</span>
|
||||
<span>{video.publishedAt}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function withRetryParam(src: string, retry: number): string {
|
||||
const sep = src.includes("?") ? "&" : "?";
|
||||
return `${src}${sep}r=${retry}`;
|
||||
}
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
import type { VideoItem } from "@/types";
|
||||
import { VideoCard } from "./VideoCard";
|
||||
|
||||
type Props = {
|
||||
videos: VideoItem[];
|
||||
loading?: boolean;
|
||||
compact?: boolean;
|
||||
emptyText?: string;
|
||||
skeletonCount?: number;
|
||||
};
|
||||
|
||||
export function VideoGrid({
|
||||
videos,
|
||||
loading,
|
||||
compact,
|
||||
emptyText = "暂时没有视频",
|
||||
skeletonCount = 8,
|
||||
}: Props) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="video-grid-loading" aria-busy="true">
|
||||
{Array.from({ length: skeletonCount }).map((_, i) => (
|
||||
<div key={i} className="skeleton-card" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!videos || videos.length === 0) {
|
||||
return <div className="video-grid-empty">{emptyText}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`video-grid ${compact ? "is-compact" : ""}`}>
|
||||
{(videos ?? []).map((v) => (
|
||||
<VideoCard key={v.id} video={v} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import type { VideoItem } from "@/types";
|
||||
import { VideoCard } from "./VideoCard";
|
||||
|
||||
type Props = {
|
||||
videos: VideoItem[];
|
||||
loading?: boolean;
|
||||
compact?: boolean;
|
||||
emptyText?: string;
|
||||
skeletonCount?: number;
|
||||
};
|
||||
|
||||
export function VideoGrid({
|
||||
videos,
|
||||
loading,
|
||||
compact,
|
||||
emptyText = "暂时没有视频",
|
||||
skeletonCount = 8,
|
||||
}: Props) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="video-grid-loading" aria-busy="true">
|
||||
{Array.from({ length: skeletonCount }).map((_, i) => (
|
||||
<div key={i} className="skeleton-card" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!videos || videos.length === 0) {
|
||||
return <div className="video-grid-empty">{emptyText}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`video-grid ${compact ? "is-compact" : ""}`}>
|
||||
{(videos ?? []).map((v) => (
|
||||
<VideoCard key={v.id} video={v} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+130
-130
@@ -1,130 +1,130 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Copy } from "lucide-react";
|
||||
import type { VideoDetail } from "@/types";
|
||||
import { formatCount } from "@/lib/format";
|
||||
|
||||
type Props = {
|
||||
video: VideoDetail;
|
||||
};
|
||||
|
||||
export function VideoInfoPanel({ video }: 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);
|
||||
|
||||
async function copyEmbed() {
|
||||
const value = video.embedUrl;
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="info-panel" aria-label="视频信息">
|
||||
<header className="info-panel__header">视频信息</header>
|
||||
<div className="info-panel__body">
|
||||
<div className="info-row">
|
||||
<span className="info-row__label">发布时间</span>
|
||||
<span className="info-row__value">{video.publishedAt}</span>
|
||||
</div>
|
||||
|
||||
<div className="info-row">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<div className="info-row">
|
||||
<span className="info-row__label">描述</span>
|
||||
<span className="info-row__value">
|
||||
<p
|
||||
className={`description ${collapsed ? "is-collapsed" : ""}`}
|
||||
>
|
||||
{video.description}
|
||||
</p>
|
||||
<button
|
||||
className="description-toggle"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
>
|
||||
{collapsed ? "展开全文" : "收起"}
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
import { useRef, useState } from "react";
|
||||
import { Copy } from "lucide-react";
|
||||
import type { VideoDetail } from "@/types";
|
||||
import { formatCount } from "@/lib/format";
|
||||
|
||||
type Props = {
|
||||
video: VideoDetail;
|
||||
};
|
||||
|
||||
export function VideoInfoPanel({ video }: 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);
|
||||
|
||||
async function copyEmbed() {
|
||||
const value = video.embedUrl;
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="info-panel" aria-label="视频信息">
|
||||
<header className="info-panel__header">视频信息</header>
|
||||
<div className="info-panel__body">
|
||||
<div className="info-row">
|
||||
<span className="info-row__label">发布时间</span>
|
||||
<span className="info-row__value">{video.publishedAt}</span>
|
||||
</div>
|
||||
|
||||
<div className="info-row">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<div className="info-row">
|
||||
<span className="info-row__label">描述</span>
|
||||
<span className="info-row__value">
|
||||
<p
|
||||
className={`description ${collapsed ? "is-collapsed" : ""}`}
|
||||
>
|
||||
{video.description}
|
||||
</p>
|
||||
<button
|
||||
className="description-toggle"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
>
|
||||
{collapsed ? "展开全文" : "收起"}
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,87 @@
|
||||
type Props = {
|
||||
src: string;
|
||||
poster: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export function VideoPlayer({ src, poster, title }: Props) {
|
||||
return (
|
||||
<div className="video-player">
|
||||
<video
|
||||
src={src}
|
||||
poster={poster}
|
||||
controls
|
||||
preload="metadata"
|
||||
playsInline
|
||||
aria-label={title}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
src: string;
|
||||
poster: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export function VideoPlayer({ src, poster, title }: Props) {
|
||||
const isTranscode = src.includes("/p/transcode/");
|
||||
const [playbackSrc, setPlaybackSrc] = useState(isTranscode ? "" : src);
|
||||
const [transcodeStatus, setTranscodeStatus] = useState<
|
||||
"idle" | "processing" | "error"
|
||||
>("idle");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTranscode) {
|
||||
setPlaybackSrc(src);
|
||||
setTranscodeStatus("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
let timer: number | null = null;
|
||||
|
||||
async function poll(start: boolean) {
|
||||
try {
|
||||
const statusResp = await fetch(`${src}/status`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!statusResp.ok) throw new Error("status failed");
|
||||
const statusBody = (await statusResp.json()) as { status?: string };
|
||||
if (!active) return;
|
||||
|
||||
if (statusBody.status === "ready") {
|
||||
setPlaybackSrc(src);
|
||||
setTranscodeStatus("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
if (start) {
|
||||
await fetch(`${src}/start`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
}
|
||||
|
||||
setPlaybackSrc("");
|
||||
setTranscodeStatus("processing");
|
||||
timer = window.setTimeout(() => poll(false), 3000);
|
||||
} catch {
|
||||
if (!active) return;
|
||||
setPlaybackSrc("");
|
||||
setTranscodeStatus("error");
|
||||
}
|
||||
}
|
||||
|
||||
setPlaybackSrc("");
|
||||
setTranscodeStatus("processing");
|
||||
void poll(true);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (timer) window.clearTimeout(timer);
|
||||
};
|
||||
}, [isTranscode, src]);
|
||||
|
||||
return (
|
||||
<div className="video-player">
|
||||
<video
|
||||
src={playbackSrc || undefined}
|
||||
poster={poster}
|
||||
controls
|
||||
preload="metadata"
|
||||
playsInline
|
||||
aria-label={title}
|
||||
/>
|
||||
{isTranscode && !playbackSrc && (
|
||||
<div className="video-player__status">
|
||||
{transcodeStatus === "error"
|
||||
? "转码启动失败,请稍后重试"
|
||||
: "正在准备可快进版本..."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PromoItem } from "@/types";
|
||||
|
||||
// 第一版不再预置横幅。真实素材来自后续的"合集/专题"接口,这里先留空。
|
||||
export const promoItems: PromoItem[] = [];
|
||||
import type { PromoItem } from "@/types";
|
||||
|
||||
// 第一版不再预置横幅。真实素材来自后续的"合集/专题"接口,这里先留空。
|
||||
export const promoItems: PromoItem[] = [];
|
||||
|
||||
+5
-5
@@ -1,5 +1,5 @@
|
||||
// 用户相关二级导航(第一版路由未实现,保留占位但先只留最常用的)
|
||||
export const subNavLinks = [
|
||||
{ label: "我的收藏", href: "/me/favorites" },
|
||||
{ label: "历史记录", href: "/me/history" },
|
||||
];
|
||||
// 用户相关二级导航(第一版路由未实现,保留占位但先只留最常用的)
|
||||
export const subNavLinks = [
|
||||
{ label: "我的收藏", href: "/me/favorites" },
|
||||
{ label: "历史记录", href: "/me/history" },
|
||||
];
|
||||
|
||||
+42
-42
@@ -1,42 +1,42 @@
|
||||
import type { VideoDetail, VideoItem } from "@/types";
|
||||
|
||||
// 真实后端接口调用。未配置网盘时,各接口返回空数据。
|
||||
export function fetchHomeVideos(): Promise<VideoItem[]> {
|
||||
return apiGet<VideoItem[]>("/api/home").catch(() => []);
|
||||
}
|
||||
|
||||
export function fetchListing(
|
||||
page: number,
|
||||
pageSize: number,
|
||||
params?: { q?: string; tag?: string; cat?: string; sort?: string }
|
||||
): Promise<{ items: VideoItem[]; total: number }> {
|
||||
const qs = new URLSearchParams({
|
||||
page: String(page),
|
||||
size: String(pageSize),
|
||||
});
|
||||
if (params?.q) qs.set("q", params.q);
|
||||
if (params?.tag) qs.set("tag", params.tag);
|
||||
if (params?.cat) qs.set("cat", params.cat);
|
||||
if (params?.sort) qs.set("sort", params.sort);
|
||||
return apiGet<{ items: VideoItem[]; total: number }>(
|
||||
`/api/list?${qs.toString()}`
|
||||
).catch(() => ({ items: [], total: 0 }));
|
||||
}
|
||||
|
||||
export function fetchVideoDetail(id: string): Promise<VideoDetail | null> {
|
||||
return apiGet<VideoDetail>(`/api/video/${encodeURIComponent(id)}`).catch(
|
||||
() => null
|
||||
);
|
||||
}
|
||||
|
||||
export type TagItem = { id: string; label: string; count?: number };
|
||||
|
||||
export function fetchTags(): Promise<TagItem[]> {
|
||||
return apiGet<TagItem[]>("/api/tags").catch(() => []);
|
||||
}
|
||||
|
||||
async function apiGet<T>(path: string): Promise<T> {
|
||||
const res = await fetch(path, { credentials: "include" });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
import type { VideoDetail, VideoItem } from "@/types";
|
||||
|
||||
// 真实后端接口调用。未配置网盘时,各接口返回空数据。
|
||||
export function fetchHomeVideos(): Promise<VideoItem[]> {
|
||||
return apiGet<VideoItem[]>("/api/home").catch(() => []);
|
||||
}
|
||||
|
||||
export function fetchListing(
|
||||
page: number,
|
||||
pageSize: number,
|
||||
params?: { q?: string; tag?: string; cat?: string; sort?: string }
|
||||
): Promise<{ items: VideoItem[]; total: number }> {
|
||||
const qs = new URLSearchParams({
|
||||
page: String(page),
|
||||
size: String(pageSize),
|
||||
});
|
||||
if (params?.q) qs.set("q", params.q);
|
||||
if (params?.tag) qs.set("tag", params.tag);
|
||||
if (params?.cat) qs.set("cat", params.cat);
|
||||
if (params?.sort) qs.set("sort", params.sort);
|
||||
return apiGet<{ items: VideoItem[]; total: number }>(
|
||||
`/api/list?${qs.toString()}`
|
||||
).catch(() => ({ items: [], total: 0 }));
|
||||
}
|
||||
|
||||
export function fetchVideoDetail(id: string): Promise<VideoDetail | null> {
|
||||
return apiGet<VideoDetail>(`/api/video/${encodeURIComponent(id)}`).catch(
|
||||
() => null
|
||||
);
|
||||
}
|
||||
|
||||
export type TagItem = { id: string; label: string; count?: number };
|
||||
|
||||
export function fetchTags(): Promise<TagItem[]> {
|
||||
return apiGet<TagItem[]>("/api/tags").catch(() => []);
|
||||
}
|
||||
|
||||
async function apiGet<T>(path: string): Promise<T> {
|
||||
const res = await fetch(path, { credentials: "include" });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
+7
-7
@@ -1,7 +1,7 @@
|
||||
export function formatCount(n: number | undefined): string {
|
||||
if (n === undefined || n === null) return "0";
|
||||
if (n < 1000) return String(n);
|
||||
if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
|
||||
if (n < 1_000_000) return `${(n / 10000).toFixed(1)}w`;
|
||||
return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
export function formatCount(n: number | undefined): string {
|
||||
if (n === undefined || n === null) return "0";
|
||||
if (n < 1000) return String(n);
|
||||
if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
|
||||
if (n < 1_000_000) return `${(n / 10000).toFixed(1)}w`;
|
||||
return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
// 全局预览控制器:同一时刻只允许一个卡片播放预览
|
||||
// 使用模块级 singleton + 订阅模式,避免 Context 的重渲染开销
|
||||
|
||||
type Listener = (activeId: string | null) => void;
|
||||
|
||||
let activeId: string | null = null;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
export const previewController = {
|
||||
getActiveId(): string | null {
|
||||
return activeId;
|
||||
},
|
||||
|
||||
setActiveId(id: string | null) {
|
||||
if (activeId === id) return;
|
||||
activeId = id;
|
||||
listeners.forEach((fn) => fn(activeId));
|
||||
},
|
||||
|
||||
subscribe(fn: Listener): () => void {
|
||||
listeners.add(fn);
|
||||
return () => {
|
||||
listeners.delete(fn);
|
||||
};
|
||||
},
|
||||
};
|
||||
// 全局预览控制器:同一时刻只允许一个卡片播放预览
|
||||
// 使用模块级 singleton + 订阅模式,避免 Context 的重渲染开销
|
||||
|
||||
type Listener = (activeId: string | null) => void;
|
||||
|
||||
let activeId: string | null = null;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
export const previewController = {
|
||||
getActiveId(): string | null {
|
||||
return activeId;
|
||||
},
|
||||
|
||||
setActiveId(id: string | null) {
|
||||
if (activeId === id) return;
|
||||
activeId = id;
|
||||
listeners.forEach((fn) => fn(activeId));
|
||||
},
|
||||
|
||||
subscribe(fn: Listener): () => void {
|
||||
listeners.add(fn);
|
||||
return () => {
|
||||
listeners.delete(fn);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
+52
-52
@@ -1,52 +1,52 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// 全局共享一个 IntersectionObserver 实例
|
||||
// 避免每张卡片各自创建 observer,首页/列表页几十张卡片时开销明显
|
||||
type Callback = (isInView: boolean) => void;
|
||||
|
||||
let sharedObserver: IntersectionObserver | null = null;
|
||||
const callbackMap = new WeakMap<Element, Callback>();
|
||||
|
||||
function getObserver(): IntersectionObserver {
|
||||
if (sharedObserver) return sharedObserver;
|
||||
|
||||
sharedObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const cb = callbackMap.get(entry.target);
|
||||
if (cb) cb(entry.isIntersecting);
|
||||
});
|
||||
},
|
||||
{
|
||||
// 比可视区再扩 200px,让靠近视口的卡片也允许预览
|
||||
rootMargin: "200px 0px",
|
||||
threshold: 0,
|
||||
}
|
||||
);
|
||||
|
||||
return sharedObserver;
|
||||
}
|
||||
|
||||
export function useInViewport(
|
||||
ref: React.RefObject<Element>,
|
||||
enabled = true
|
||||
): boolean {
|
||||
const [inView, setInView] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const obs = getObserver();
|
||||
callbackMap.set(el, setInView);
|
||||
obs.observe(el);
|
||||
|
||||
return () => {
|
||||
obs.unobserve(el);
|
||||
callbackMap.delete(el);
|
||||
};
|
||||
}, [ref, enabled]);
|
||||
|
||||
return inView;
|
||||
}
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// 全局共享一个 IntersectionObserver 实例
|
||||
// 避免每张卡片各自创建 observer,首页/列表页几十张卡片时开销明显
|
||||
type Callback = (isInView: boolean) => void;
|
||||
|
||||
let sharedObserver: IntersectionObserver | null = null;
|
||||
const callbackMap = new WeakMap<Element, Callback>();
|
||||
|
||||
function getObserver(): IntersectionObserver {
|
||||
if (sharedObserver) return sharedObserver;
|
||||
|
||||
sharedObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const cb = callbackMap.get(entry.target);
|
||||
if (cb) cb(entry.isIntersecting);
|
||||
});
|
||||
},
|
||||
{
|
||||
// 比可视区再扩 200px,让靠近视口的卡片也允许预览
|
||||
rootMargin: "200px 0px",
|
||||
threshold: 0,
|
||||
}
|
||||
);
|
||||
|
||||
return sharedObserver;
|
||||
}
|
||||
|
||||
export function useInViewport(
|
||||
ref: React.RefObject<Element>,
|
||||
enabled = true
|
||||
): boolean {
|
||||
const [inView, setInView] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const obs = getObserver();
|
||||
callbackMap.set(el, setInView);
|
||||
obs.observe(el);
|
||||
|
||||
return () => {
|
||||
obs.unobserve(el);
|
||||
callbackMap.delete(el);
|
||||
};
|
||||
}, [ref, enabled]);
|
||||
|
||||
return inView;
|
||||
}
|
||||
|
||||
+27
-27
@@ -1,27 +1,27 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import { ToastProvider } from "./admin/ToastContext";
|
||||
import { AuthProvider } from "./admin/AuthContext";
|
||||
|
||||
import "./styles/tokens.css";
|
||||
import "./styles/base.css";
|
||||
import "./styles/layout.css";
|
||||
import "./styles/navigation.css";
|
||||
import "./styles/search.css";
|
||||
import "./styles/video-card.css";
|
||||
import "./styles/video-detail.css";
|
||||
import "./styles/admin.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import { ToastProvider } from "./admin/ToastContext";
|
||||
import { AuthProvider } from "./admin/AuthContext";
|
||||
|
||||
import "./styles/tokens.css";
|
||||
import "./styles/base.css";
|
||||
import "./styles/layout.css";
|
||||
import "./styles/navigation.css";
|
||||
import "./styles/search.css";
|
||||
import "./styles/video-card.css";
|
||||
import "./styles/video-detail.css";
|
||||
import "./styles/admin.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
+52
-52
@@ -1,52 +1,52 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { PromoStrip } from "@/components/PromoStrip";
|
||||
import { SearchPanel } from "@/components/SearchPanel";
|
||||
import { TagCloud } from "@/components/TagCloud";
|
||||
import { SectionHeader } from "@/components/SectionHeader";
|
||||
import { VideoGrid } from "@/components/VideoGrid";
|
||||
import { fetchHomeVideos } from "@/data/videos";
|
||||
import type { VideoItem } from "@/types";
|
||||
|
||||
export default function HomePage() {
|
||||
const [videos, setVideos] = useState<VideoItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "首页 · 视频聚合站";
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
fetchHomeVideos().then((items) => {
|
||||
if (!active) return;
|
||||
setVideos(items);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="container page-section">
|
||||
<PromoStrip />
|
||||
<SearchPanel />
|
||||
<TagCloud />
|
||||
</div>
|
||||
|
||||
<div className="container page-section">
|
||||
<SectionHeader title="今日排行" extra={`精选 ${videos.length} 个作品`} />
|
||||
<VideoGrid videos={videos} loading={loading} skeletonCount={12} />
|
||||
</div>
|
||||
|
||||
<div className="container page-section">
|
||||
<SectionHeader title="最新视频" />
|
||||
<VideoGrid
|
||||
videos={loading ? [] : videos.slice().reverse()}
|
||||
loading={loading}
|
||||
skeletonCount={12}
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { PromoStrip } from "@/components/PromoStrip";
|
||||
import { SearchPanel } from "@/components/SearchPanel";
|
||||
import { TagCloud } from "@/components/TagCloud";
|
||||
import { SectionHeader } from "@/components/SectionHeader";
|
||||
import { VideoGrid } from "@/components/VideoGrid";
|
||||
import { fetchHomeVideos } from "@/data/videos";
|
||||
import type { VideoItem } from "@/types";
|
||||
|
||||
export default function HomePage() {
|
||||
const [videos, setVideos] = useState<VideoItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "首页 · 视频聚合站";
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
fetchHomeVideos().then((items) => {
|
||||
if (!active) return;
|
||||
setVideos(items);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="container page-section">
|
||||
<PromoStrip />
|
||||
<SearchPanel />
|
||||
<TagCloud />
|
||||
</div>
|
||||
|
||||
<div className="container page-section">
|
||||
<SectionHeader title="今日排行" extra={`精选 ${videos.length} 个作品`} />
|
||||
<VideoGrid videos={videos} loading={loading} skeletonCount={12} />
|
||||
</div>
|
||||
|
||||
<div className="container page-section">
|
||||
<SectionHeader title="最新视频" />
|
||||
<VideoGrid
|
||||
videos={loading ? [] : videos.slice().reverse()}
|
||||
loading={loading}
|
||||
skeletonCount={12}
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
+99
-99
@@ -1,99 +1,99 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { PromoStrip } from "@/components/PromoStrip";
|
||||
import { SearchPanel } from "@/components/SearchPanel";
|
||||
import { TagCloud } from "@/components/TagCloud";
|
||||
import { SectionHeader } from "@/components/SectionHeader";
|
||||
import { SortToolbar, type ViewMode } from "@/components/SortToolbar";
|
||||
import { VideoGrid } from "@/components/VideoGrid";
|
||||
import { Pagination } from "@/components/Pagination";
|
||||
import { fetchListing } from "@/data/videos";
|
||||
import type { SortKey, VideoItem } from "@/types";
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
export default function ListingPage() {
|
||||
const [params] = useSearchParams();
|
||||
const keyword = params.get("q") ?? "";
|
||||
const tag = params.get("tag") ?? "";
|
||||
const cat = params.get("cat") ?? "";
|
||||
|
||||
const [sort, setSort] = useState<SortKey>("latest");
|
||||
const [view, setView] = useState<ViewMode>("grid");
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [items, setItems] = useState<VideoItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
// 筛选条件变更时回到第一页
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [keyword, tag, cat, sort]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = keyword
|
||||
? `搜索 "${keyword}" · 视频聚合站`
|
||||
: tag
|
||||
? `标签 ${tag} · 视频聚合站`
|
||||
: cat
|
||||
? `分类 ${cat} · 视频聚合站`
|
||||
: "视频列表 · 视频聚合站";
|
||||
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
fetchListing(page, PAGE_SIZE, { q: keyword, tag, cat, sort }).then((r) => {
|
||||
if (!active) return;
|
||||
setItems(r.items ?? []);
|
||||
setTotal(r.total ?? 0);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [keyword, tag, cat, sort, page]);
|
||||
|
||||
const title = keyword
|
||||
? `搜索结果:${keyword}`
|
||||
: tag
|
||||
? `标签:${tag}`
|
||||
: cat && cat !== "all"
|
||||
? `分类:${cat}`
|
||||
: "全部视频";
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="container page-section">
|
||||
<PromoStrip />
|
||||
<SearchPanel />
|
||||
<TagCloud />
|
||||
</div>
|
||||
|
||||
<div className="container page-section">
|
||||
<SectionHeader title={title} extra={`共 ${total} 个视频`} />
|
||||
<SortToolbar
|
||||
sort={sort}
|
||||
view={view}
|
||||
onSortChange={setSort}
|
||||
onViewChange={setView}
|
||||
/>
|
||||
<VideoGrid
|
||||
videos={items}
|
||||
loading={loading}
|
||||
compact={view === "compact"}
|
||||
skeletonCount={12}
|
||||
emptyText="没有找到匹配的视频"
|
||||
/>
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={total}
|
||||
onChange={(p) => {
|
||||
setPage(p);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { PromoStrip } from "@/components/PromoStrip";
|
||||
import { SearchPanel } from "@/components/SearchPanel";
|
||||
import { TagCloud } from "@/components/TagCloud";
|
||||
import { SectionHeader } from "@/components/SectionHeader";
|
||||
import { SortToolbar, type ViewMode } from "@/components/SortToolbar";
|
||||
import { VideoGrid } from "@/components/VideoGrid";
|
||||
import { Pagination } from "@/components/Pagination";
|
||||
import { fetchListing } from "@/data/videos";
|
||||
import type { SortKey, VideoItem } from "@/types";
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
export default function ListingPage() {
|
||||
const [params] = useSearchParams();
|
||||
const keyword = params.get("q") ?? "";
|
||||
const tag = params.get("tag") ?? "";
|
||||
const cat = params.get("cat") ?? "";
|
||||
|
||||
const [sort, setSort] = useState<SortKey>("latest");
|
||||
const [view, setView] = useState<ViewMode>("grid");
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [items, setItems] = useState<VideoItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
// 筛选条件变更时回到第一页
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [keyword, tag, cat, sort]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = keyword
|
||||
? `搜索 "${keyword}" · 视频聚合站`
|
||||
: tag
|
||||
? `标签 ${tag} · 视频聚合站`
|
||||
: cat
|
||||
? `分类 ${cat} · 视频聚合站`
|
||||
: "视频列表 · 视频聚合站";
|
||||
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
fetchListing(page, PAGE_SIZE, { q: keyword, tag, cat, sort }).then((r) => {
|
||||
if (!active) return;
|
||||
setItems(r.items ?? []);
|
||||
setTotal(r.total ?? 0);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [keyword, tag, cat, sort, page]);
|
||||
|
||||
const title = keyword
|
||||
? `搜索结果:${keyword}`
|
||||
: tag
|
||||
? `标签:${tag}`
|
||||
: cat && cat !== "all"
|
||||
? `分类:${cat}`
|
||||
: "全部视频";
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="container page-section">
|
||||
<PromoStrip />
|
||||
<SearchPanel />
|
||||
<TagCloud />
|
||||
</div>
|
||||
|
||||
<div className="container page-section">
|
||||
<SectionHeader title={title} extra={`共 ${total} 个视频`} />
|
||||
<SortToolbar
|
||||
sort={sort}
|
||||
view={view}
|
||||
onSortChange={setSort}
|
||||
onViewChange={setView}
|
||||
/>
|
||||
<VideoGrid
|
||||
videos={items}
|
||||
loading={loading}
|
||||
compact={view === "compact"}
|
||||
skeletonCount={12}
|
||||
emptyText="没有找到匹配的视频"
|
||||
/>
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={total}
|
||||
onChange={(p) => {
|
||||
setPage(p);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,95 +1,95 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { SearchPanel } from "@/components/SearchPanel";
|
||||
import { VideoPlayer } from "@/components/VideoPlayer";
|
||||
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";
|
||||
|
||||
export default function VideoDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [detail, setDetail] = useState<VideoDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const commentRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
fetchVideoDetail(id).then((d) => {
|
||||
if (!active) return;
|
||||
setDetail(d);
|
||||
setLoading(false);
|
||||
document.title = d ? `${d.title} · 视频聚合站` : "视频不存在";
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
function jumpToComments() {
|
||||
commentRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="container page-section">
|
||||
<div className="video-grid-loading">
|
||||
<div className="skeleton-card" />
|
||||
<div className="skeleton-card" />
|
||||
<div className="skeleton-card" />
|
||||
<div className="skeleton-card" />
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="container page-section">
|
||||
<div className="video-grid-empty">视频不存在或已被移除</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="container page-section">
|
||||
<SearchPanel />
|
||||
</div>
|
||||
|
||||
<div className="container">
|
||||
<div className="detail-layout">
|
||||
<div className="detail-main">
|
||||
<div className="detail-title-bar">{detail.title}</div>
|
||||
<VideoPlayer
|
||||
src={detail.videoSrc}
|
||||
poster={detail.poster}
|
||||
title={detail.title}
|
||||
/>
|
||||
<VideoActions
|
||||
video={detail}
|
||||
onJumpToComments={jumpToComments}
|
||||
/>
|
||||
<VideoInfoPanel video={detail} />
|
||||
<CommentPanel
|
||||
ref={commentRef}
|
||||
comments={detail.commentsList}
|
||||
/>
|
||||
</div>
|
||||
<RecommendedRail videos={detail.relatedVideos} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 40 }} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { SearchPanel } from "@/components/SearchPanel";
|
||||
import { VideoPlayer } from "@/components/VideoPlayer";
|
||||
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";
|
||||
|
||||
export default function VideoDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [detail, setDetail] = useState<VideoDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const commentRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
fetchVideoDetail(id).then((d) => {
|
||||
if (!active) return;
|
||||
setDetail(d);
|
||||
setLoading(false);
|
||||
document.title = d ? `${d.title} · 视频聚合站` : "视频不存在";
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
function jumpToComments() {
|
||||
commentRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="container page-section">
|
||||
<div className="video-grid-loading">
|
||||
<div className="skeleton-card" />
|
||||
<div className="skeleton-card" />
|
||||
<div className="skeleton-card" />
|
||||
<div className="skeleton-card" />
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="container page-section">
|
||||
<div className="video-grid-empty">视频不存在或已被移除</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="container page-section">
|
||||
<SearchPanel />
|
||||
</div>
|
||||
|
||||
<div className="container">
|
||||
<div className="detail-layout">
|
||||
<div className="detail-main">
|
||||
<div className="detail-title-bar">{detail.title}</div>
|
||||
<VideoPlayer
|
||||
src={detail.videoSrc}
|
||||
poster={detail.poster}
|
||||
title={detail.title}
|
||||
/>
|
||||
<VideoActions
|
||||
video={detail}
|
||||
onJumpToComments={jumpToComments}
|
||||
/>
|
||||
<VideoInfoPanel video={detail} />
|
||||
<CommentPanel
|
||||
ref={commentRef}
|
||||
comments={detail.commentsList}
|
||||
/>
|
||||
</div>
|
||||
<RecommendedRail videos={detail.relatedVideos} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 40 }} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
+511
-511
File diff suppressed because it is too large
Load Diff
+84
-84
@@ -1,84 +1,84 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
background: var(--color-page);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
background: var(--color-page);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
+151
-151
@@ -1,151 +1,151 @@
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-shell__main {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: var(--container-max);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-4);
|
||||
}
|
||||
|
||||
.page-section {
|
||||
padding: var(--space-5) 0;
|
||||
}
|
||||
|
||||
.page-section + .page-section {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 0 var(--space-4);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.section-header__title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.section-header__extra {
|
||||
font-size: 13px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.promo-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.promo-card {
|
||||
height: 96px;
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-nav) 0%,
|
||||
#3a3a3a 100%
|
||||
);
|
||||
color: var(--color-text-invert);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
border: 1px solid var(--color-card-border);
|
||||
}
|
||||
|
||||
.promo-card__label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-accent);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.promo-card__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.promo-card__meta {
|
||||
font-size: 12px;
|
||||
color: var(--color-muted-light);
|
||||
}
|
||||
|
||||
.back-to-top {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
box-shadow: var(--shadow-elevated);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 180ms ease, transform 180ms ease;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.back-to-top.is-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.back-to-top:hover {
|
||||
transform: translateY(-2px);
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: var(--space-8);
|
||||
padding: var(--space-6) 0;
|
||||
background: var(--color-nav);
|
||||
color: var(--color-muted-light);
|
||||
}
|
||||
|
||||
.footer__inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-5);
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer__links {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.footer__copy {
|
||||
font-size: 12px;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.promo-strip {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.promo-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-shell__main {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: var(--container-max);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-4);
|
||||
}
|
||||
|
||||
.page-section {
|
||||
padding: var(--space-5) 0;
|
||||
}
|
||||
|
||||
.page-section + .page-section {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 0 var(--space-4);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.section-header__title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.section-header__extra {
|
||||
font-size: 13px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.promo-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.promo-card {
|
||||
height: 96px;
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-nav) 0%,
|
||||
#3a3a3a 100%
|
||||
);
|
||||
color: var(--color-text-invert);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
border: 1px solid var(--color-card-border);
|
||||
}
|
||||
|
||||
.promo-card__label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-accent);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.promo-card__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.promo-card__meta {
|
||||
font-size: 12px;
|
||||
color: var(--color-muted-light);
|
||||
}
|
||||
|
||||
.back-to-top {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
box-shadow: var(--shadow-elevated);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 180ms ease, transform 180ms ease;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.back-to-top.is-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.back-to-top:hover {
|
||||
transform: translateY(-2px);
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: var(--space-8);
|
||||
padding: var(--space-6) 0;
|
||||
background: var(--color-nav);
|
||||
color: var(--color-muted-light);
|
||||
}
|
||||
|
||||
.footer__inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-5);
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer__links {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.footer__copy {
|
||||
font-size: 12px;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.promo-strip {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.promo-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
+149
-149
@@ -1,149 +1,149 @@
|
||||
.top-bar {
|
||||
height: 30px;
|
||||
background: var(--color-topbar);
|
||||
color: var(--color-muted-light);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.top-bar__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.top-bar__side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.top-bar a {
|
||||
color: var(--color-muted-light);
|
||||
}
|
||||
|
||||
.top-bar a:hover {
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
background: var(--color-nav);
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
.main-nav__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 60px;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.main-nav__logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
.main-nav__logo-mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: var(--color-accent);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.main-nav__list {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.main-nav__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: 15px;
|
||||
color: var(--color-muted-light);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.main-nav__link:hover {
|
||||
color: var(--color-text-invert);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.main-nav__link.is-active {
|
||||
color: var(--color-text-invert);
|
||||
border-bottom: 2px solid var(--color-accent);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.main-nav__toggle {
|
||||
display: none;
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
.sub-nav {
|
||||
background: #1a1a1a;
|
||||
color: var(--color-text-invert);
|
||||
border-bottom: 1px solid #050505;
|
||||
}
|
||||
|
||||
.sub-nav__list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-5);
|
||||
height: 36px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.sub-nav__list::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sub-nav__item {
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.sub-nav__item:hover {
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-nav__list {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main-nav__toggle {
|
||||
display: inline-flex;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.main-nav.is-open .main-nav__list {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 90px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
background: var(--color-nav);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-top: 1px solid #050505;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.main-nav.is-open .main-nav__link {
|
||||
padding: var(--space-3) var(--space-2);
|
||||
border-bottom: 1px solid #1f1f1f;
|
||||
}
|
||||
}
|
||||
.top-bar {
|
||||
height: 30px;
|
||||
background: var(--color-topbar);
|
||||
color: var(--color-muted-light);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.top-bar__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.top-bar__side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.top-bar a {
|
||||
color: var(--color-muted-light);
|
||||
}
|
||||
|
||||
.top-bar a:hover {
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
background: var(--color-nav);
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
.main-nav__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 60px;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.main-nav__logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
.main-nav__logo-mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: var(--color-accent);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.main-nav__list {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.main-nav__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: 15px;
|
||||
color: var(--color-muted-light);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.main-nav__link:hover {
|
||||
color: var(--color-text-invert);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.main-nav__link.is-active {
|
||||
color: var(--color-text-invert);
|
||||
border-bottom: 2px solid var(--color-accent);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.main-nav__toggle {
|
||||
display: none;
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
.sub-nav {
|
||||
background: #1a1a1a;
|
||||
color: var(--color-text-invert);
|
||||
border-bottom: 1px solid #050505;
|
||||
}
|
||||
|
||||
.sub-nav__list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-5);
|
||||
height: 36px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.sub-nav__list::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sub-nav__item {
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.sub-nav__item:hover {
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-nav__list {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main-nav__toggle {
|
||||
display: inline-flex;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.main-nav.is-open .main-nav__list {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 90px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
background: var(--color-nav);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-top: 1px solid #050505;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.main-nav.is-open .main-nav__link {
|
||||
padding: var(--space-3) var(--space-2);
|
||||
border-bottom: 1px solid #1f1f1f;
|
||||
}
|
||||
}
|
||||
|
||||
+136
-136
@@ -1,136 +1,136 @@
|
||||
.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);
|
||||
}
|
||||
|
||||
.search-panel__form {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-panel__input {
|
||||
flex: 1 1 220px;
|
||||
min-width: 200px;
|
||||
height: 38px;
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.search-panel__input:focus {
|
||||
border-color: var(--color-accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.search-panel__submit {
|
||||
height: 38px;
|
||||
padding: 0 var(--space-5);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.search-panel__submit:hover {
|
||||
background: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
.tag-cloud {
|
||||
margin-top: var(--space-3);
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag-cloud__label {
|
||||
font-size: 13px;
|
||||
color: var(--color-muted);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
padding: 4px 10px;
|
||||
border-radius: 99px;
|
||||
background: #f0f0ef;
|
||||
color: var(--color-text);
|
||||
font-size: 12px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.tag-chip:hover,
|
||||
.tag-chip.is-active {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
.sort-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sort-toolbar__group {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.sort-toolbar__btn:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.sort-toolbar__btn.is-active {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.sort-toolbar__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.tag-cloud {
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.tag-cloud::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
.search-panel__form {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-panel__input {
|
||||
flex: 1 1 220px;
|
||||
min-width: 200px;
|
||||
height: 38px;
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.search-panel__input:focus {
|
||||
border-color: var(--color-accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.search-panel__submit {
|
||||
height: 38px;
|
||||
padding: 0 var(--space-5);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.search-panel__submit:hover {
|
||||
background: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
.tag-cloud {
|
||||
margin-top: var(--space-3);
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag-cloud__label {
|
||||
font-size: 13px;
|
||||
color: var(--color-muted);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
padding: 4px 10px;
|
||||
border-radius: 99px;
|
||||
background: #f0f0ef;
|
||||
color: var(--color-text);
|
||||
font-size: 12px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.tag-chip:hover,
|
||||
.tag-chip.is-active {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
.sort-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sort-toolbar__group {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.sort-toolbar__btn:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.sort-toolbar__btn.is-active {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.sort-toolbar__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.tag-cloud {
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.tag-cloud::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
+33
-33
@@ -1,33 +1,33 @@
|
||||
: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-text: #202020;
|
||||
--color-text-invert: #ffffff;
|
||||
--color-muted: #8a8a8a;
|
||||
--color-muted-light: #b5b5b5;
|
||||
--color-line: #dddddd;
|
||||
--color-section: #ffffff;
|
||||
--color-danger: #e23b3b;
|
||||
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
|
||||
--radius-sm: 3px;
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 6px;
|
||||
|
||||
--container-max: 1200px;
|
||||
|
||||
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||
--shadow-elevated: 0 4px 14px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
: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-text: #202020;
|
||||
--color-text-invert: #ffffff;
|
||||
--color-muted: #8a8a8a;
|
||||
--color-muted-light: #b5b5b5;
|
||||
--color-line: #dddddd;
|
||||
--color-section: #ffffff;
|
||||
--color-danger: #e23b3b;
|
||||
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
|
||||
--radius-sm: 3px;
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 6px;
|
||||
|
||||
--container-max: 1200px;
|
||||
|
||||
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||
--shadow-elevated: 0 4px 14px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
+331
-331
@@ -1,331 +1,331 @@
|
||||
.video-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
.video-grid {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.video-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.video-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.video-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.video-grid.is-compact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.video-grid.is-compact .video-card {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.video-grid.is-compact .video-card .video-title {
|
||||
white-space: normal;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-grid.is-compact .video-meta {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.video-card {
|
||||
display: block;
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-card-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px;
|
||||
color: var(--color-text-invert);
|
||||
transition: transform 160ms ease, box-shadow 160ms ease;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.video-card:hover,
|
||||
.video-card:focus-within {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
.video-card__link {
|
||||
display: block;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.video-card__link:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.thumb-frame {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.thumb-image,
|
||||
.preview-video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.thumb-image {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.preview-video {
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
transition: opacity 180ms ease;
|
||||
}
|
||||
|
||||
.preview-video.is-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.duration {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
z-index: 3;
|
||||
padding: 2px 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.badge-row {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.video-badge {
|
||||
padding: 2px 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-accent);
|
||||
color: #000;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.video-badge.is-hd {
|
||||
background: #1fbf6a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.preview-loader {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 4;
|
||||
height: 3px;
|
||||
width: 0;
|
||||
background: var(--color-accent);
|
||||
animation: preview-progress 1.8s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes preview-progress {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 预览播放中的真实进度条(随 currentTime 同步) */
|
||||
.preview-progress {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 4;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.preview-progress__bar {
|
||||
height: 100%;
|
||||
background: var(--color-accent);
|
||||
transition: width 120ms linear;
|
||||
}
|
||||
|
||||
/* 右上角的 "预览" 角标 */
|
||||
.preview-tag {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 3;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.preview-error {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: var(--color-text-invert);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
.video-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 10px;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.video-meta__author {
|
||||
color: var(--color-muted-light);
|
||||
}
|
||||
|
||||
.video-grid-empty {
|
||||
padding: var(--space-8) 0;
|
||||
text-align: center;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.video-grid-loading {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
aspect-ratio: 16 / 9;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#1a1a1a 0%,
|
||||
#2a2a2a 50%,
|
||||
#1a1a1a 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.2s ease-in-out infinite;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
margin-top: var(--space-5);
|
||||
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);
|
||||
}
|
||||
|
||||
.pagination__btn:hover:not(:disabled) {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.pagination__btn.is-active {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
.pagination__btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.video-card {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.video-meta span:nth-child(n + 3) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.video-grid.is-compact .video-card {
|
||||
grid-template-columns: 120px 1fr;
|
||||
}
|
||||
}
|
||||
.video-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
.video-grid {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.video-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.video-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.video-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.video-grid.is-compact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.video-grid.is-compact .video-card {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.video-grid.is-compact .video-card .video-title {
|
||||
white-space: normal;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-grid.is-compact .video-meta {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.video-card {
|
||||
display: block;
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-card-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px;
|
||||
color: var(--color-text-invert);
|
||||
transition: transform 160ms ease, box-shadow 160ms ease;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.video-card:hover,
|
||||
.video-card:focus-within {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
.video-card__link {
|
||||
display: block;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.video-card__link:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.thumb-frame {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.thumb-image,
|
||||
.preview-video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.thumb-image {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.preview-video {
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
transition: opacity 180ms ease;
|
||||
}
|
||||
|
||||
.preview-video.is-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.duration {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
z-index: 3;
|
||||
padding: 2px 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.badge-row {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.video-badge {
|
||||
padding: 2px 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-accent);
|
||||
color: #000;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.video-badge.is-hd {
|
||||
background: #1fbf6a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.preview-loader {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 4;
|
||||
height: 3px;
|
||||
width: 0;
|
||||
background: var(--color-accent);
|
||||
animation: preview-progress 1.8s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes preview-progress {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 预览播放中的真实进度条(随 currentTime 同步) */
|
||||
.preview-progress {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 4;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.preview-progress__bar {
|
||||
height: 100%;
|
||||
background: var(--color-accent);
|
||||
transition: width 120ms linear;
|
||||
}
|
||||
|
||||
/* 右上角的 "预览" 角标 */
|
||||
.preview-tag {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 3;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.preview-error {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: var(--color-text-invert);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
.video-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 10px;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.video-meta__author {
|
||||
color: var(--color-muted-light);
|
||||
}
|
||||
|
||||
.video-grid-empty {
|
||||
padding: var(--space-8) 0;
|
||||
text-align: center;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.video-grid-loading {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
aspect-ratio: 16 / 9;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#1a1a1a 0%,
|
||||
#2a2a2a 50%,
|
||||
#1a1a1a 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.2s ease-in-out infinite;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
margin-top: var(--space-5);
|
||||
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);
|
||||
}
|
||||
|
||||
.pagination__btn:hover:not(:disabled) {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.pagination__btn.is-active {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
.pagination__btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.video-card {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.video-meta span:nth-child(n + 3) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.video-grid.is-compact .video-card {
|
||||
grid-template-columns: 120px 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
+345
-333
@@ -1,333 +1,345 @@
|
||||
.detail-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: var(--space-5);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.detail-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.video-player {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-player video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.video-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--color-card);
|
||||
color: var(--color-text-invert);
|
||||
border-top: 1px solid var(--color-card-border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.video-stats__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.video-stats__value {
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.video-stats__label {
|
||||
color: var(--color-muted-light);
|
||||
}
|
||||
|
||||
.video-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: #1a1a1a;
|
||||
border-top: 1px solid var(--color-card-border);
|
||||
}
|
||||
|
||||
.video-actions__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: #262626;
|
||||
color: var(--color-text-invert);
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--color-card-border);
|
||||
}
|
||||
|
||||
.video-actions__btn:hover {
|
||||
background: #333;
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
.video-actions__btn.is-active {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.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);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-panel__header {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.info-panel__body {
|
||||
padding: var(--space-4);
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: grid;
|
||||
grid-template-columns: 96px 1fr;
|
||||
gap: var(--space-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.info-row__label {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.info-row__value {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.author-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: #fafafa;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.author-card__avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
color: #000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.author-card__name {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.author-card__meta {
|
||||
font-size: 12px;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.author-card__follow {
|
||||
margin-left: auto;
|
||||
padding: 6px 14px;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.author-card__follow.is-following {
|
||||
background: #e0e0e0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--color-text);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.description.is-collapsed {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.description-toggle {
|
||||
margin-top: var(--space-1);
|
||||
color: var(--color-accent);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.embed-box {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.embed-box__input {
|
||||
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);
|
||||
resize: none;
|
||||
min-height: 36px;
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
}
|
||||
|
||||
.embed-box__copy {
|
||||
padding: 0 var(--space-3);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.embed-box__copy.is-copied {
|
||||
background: #1fbf6a;
|
||||
}
|
||||
|
||||
.comment-panel {
|
||||
margin-top: var(--space-4);
|
||||
background: var(--color-section);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.comment-panel__header {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.comment-panel__body {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.comment-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.comment-item {
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.comment-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.comment-item__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: 12px;
|
||||
color: var(--color-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.comment-item__author {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.comment-empty {
|
||||
padding: var(--space-5) 0;
|
||||
text-align: center;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.detail-side {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.detail-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.video-stats {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.info-row {
|
||||
grid-template-columns: 80px 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 点赞按钮每次点击的轻微弹跳 */
|
||||
.video-actions__like.is-bursting {
|
||||
animation: like-burst 240ms ease;
|
||||
}
|
||||
|
||||
@keyframes like-burst {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
30% {
|
||||
transform: scale(1.12);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.detail-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: var(--space-5);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.detail-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.video-player {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-player video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.video-player__status {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--space-4);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.video-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--color-card);
|
||||
color: var(--color-text-invert);
|
||||
border-top: 1px solid var(--color-card-border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.video-stats__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.video-stats__value {
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.video-stats__label {
|
||||
color: var(--color-muted-light);
|
||||
}
|
||||
|
||||
.video-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: #1a1a1a;
|
||||
border-top: 1px solid var(--color-card-border);
|
||||
}
|
||||
|
||||
.video-actions__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: #262626;
|
||||
color: var(--color-text-invert);
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--color-card-border);
|
||||
}
|
||||
|
||||
.video-actions__btn:hover {
|
||||
background: #333;
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
|
||||
.video-actions__btn.is-active {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.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);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-panel__header {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.info-panel__body {
|
||||
padding: var(--space-4);
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: grid;
|
||||
grid-template-columns: 96px 1fr;
|
||||
gap: var(--space-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.info-row__label {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.info-row__value {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.author-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: #fafafa;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.author-card__avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
color: #000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.author-card__name {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.author-card__meta {
|
||||
font-size: 12px;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.author-card__follow {
|
||||
margin-left: auto;
|
||||
padding: 6px 14px;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.author-card__follow.is-following {
|
||||
background: #e0e0e0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--color-text);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.description.is-collapsed {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.description-toggle {
|
||||
margin-top: var(--space-1);
|
||||
color: var(--color-accent);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.embed-box {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.embed-box__input {
|
||||
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);
|
||||
resize: none;
|
||||
min-height: 36px;
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
}
|
||||
|
||||
.embed-box__copy {
|
||||
padding: 0 var(--space-3);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.embed-box__copy.is-copied {
|
||||
background: #1fbf6a;
|
||||
}
|
||||
|
||||
.comment-panel {
|
||||
margin-top: var(--space-4);
|
||||
background: var(--color-section);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.comment-panel__header {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.comment-panel__body {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.comment-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.comment-item {
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.comment-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.comment-item__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: 12px;
|
||||
color: var(--color-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.comment-item__author {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.comment-empty {
|
||||
padding: var(--space-5) 0;
|
||||
text-align: center;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.detail-side {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.detail-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.video-stats {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.info-row {
|
||||
grid-template-columns: 80px 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 点赞按钮每次点击的轻微弹跳 */
|
||||
.video-actions__like.is-bursting {
|
||||
animation: like-burst 240ms ease;
|
||||
}
|
||||
|
||||
@keyframes like-burst {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
30% {
|
||||
transform: scale(1.12);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-invert);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
+80
-80
@@ -1,80 +1,80 @@
|
||||
export type VideoItem = {
|
||||
id: string;
|
||||
href: string;
|
||||
title: string;
|
||||
thumbnail: string;
|
||||
previewSrc: string;
|
||||
previewDuration: number;
|
||||
previewStrategy: "teaser-file" | "sprite-frames";
|
||||
duration: string;
|
||||
badges: string[];
|
||||
quality?: "SD" | "HD";
|
||||
sourceLabel?: string;
|
||||
author: string;
|
||||
views: number;
|
||||
favorites?: number;
|
||||
comments?: number;
|
||||
likes?: number;
|
||||
dislikes?: number;
|
||||
publishedAt: string;
|
||||
rating?: number;
|
||||
tags?: string[];
|
||||
category?: string;
|
||||
};
|
||||
|
||||
export type AuthorProfile = {
|
||||
id: string;
|
||||
name: string;
|
||||
href: string;
|
||||
badges: string[];
|
||||
signupAge?: string;
|
||||
level?: number;
|
||||
points?: number;
|
||||
videoCount?: number;
|
||||
followers?: number;
|
||||
following?: number;
|
||||
isFollowing?: boolean;
|
||||
};
|
||||
|
||||
export type CommentItem = {
|
||||
id: string;
|
||||
author: string;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
likes?: number;
|
||||
};
|
||||
|
||||
export type VideoDetail = VideoItem & {
|
||||
videoSrc: string;
|
||||
poster: string;
|
||||
description: string;
|
||||
embedUrl: string;
|
||||
points?: number;
|
||||
authorProfile: AuthorProfile;
|
||||
relatedVideos: VideoItem[];
|
||||
commentsList: CommentItem[];
|
||||
};
|
||||
|
||||
export type PreviewState = "idle" | "intent" | "loading" | "playing" | "error";
|
||||
|
||||
export type SortKey = "latest" | "hot" | "week" | "long" | "hd" | "featured";
|
||||
|
||||
export type TagItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
count?: number;
|
||||
};
|
||||
|
||||
export type CategoryItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export type PromoItem = {
|
||||
id: string;
|
||||
kind: "channel" | "collection" | "event";
|
||||
label: string;
|
||||
title: string;
|
||||
meta?: string;
|
||||
};
|
||||
export type VideoItem = {
|
||||
id: string;
|
||||
href: string;
|
||||
title: string;
|
||||
thumbnail: string;
|
||||
previewSrc: string;
|
||||
previewDuration: number;
|
||||
previewStrategy: "teaser-file" | "sprite-frames";
|
||||
duration: string;
|
||||
badges: string[];
|
||||
quality?: "SD" | "HD";
|
||||
sourceLabel?: string;
|
||||
author: string;
|
||||
views: number;
|
||||
favorites?: number;
|
||||
comments?: number;
|
||||
likes?: number;
|
||||
dislikes?: number;
|
||||
publishedAt: string;
|
||||
rating?: number;
|
||||
tags?: string[];
|
||||
category?: string;
|
||||
};
|
||||
|
||||
export type AuthorProfile = {
|
||||
id: string;
|
||||
name: string;
|
||||
href: string;
|
||||
badges: string[];
|
||||
signupAge?: string;
|
||||
level?: number;
|
||||
points?: number;
|
||||
videoCount?: number;
|
||||
followers?: number;
|
||||
following?: number;
|
||||
isFollowing?: boolean;
|
||||
};
|
||||
|
||||
export type CommentItem = {
|
||||
id: string;
|
||||
author: string;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
likes?: number;
|
||||
};
|
||||
|
||||
export type VideoDetail = VideoItem & {
|
||||
videoSrc: string;
|
||||
poster: string;
|
||||
description: string;
|
||||
embedUrl: string;
|
||||
points?: number;
|
||||
authorProfile: AuthorProfile;
|
||||
relatedVideos: VideoItem[];
|
||||
commentsList: CommentItem[];
|
||||
};
|
||||
|
||||
export type PreviewState = "idle" | "intent" | "loading" | "playing" | "error";
|
||||
|
||||
export type SortKey = "latest" | "hot" | "week" | "long" | "hd" | "featured";
|
||||
|
||||
export type TagItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
count?: number;
|
||||
};
|
||||
|
||||
export type CategoryItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export type PromoItem = {
|
||||
id: string;
|
||||
kind: "channel" | "collection" | "event";
|
||||
label: string;
|
||||
title: string;
|
||||
meta?: string;
|
||||
};
|
||||
|
||||
+25
-25
@@ -1,25 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1304,10 +1304,11 @@ VideoProject/
|
||||
├─ backend/ Go 单体服务
|
||||
│ ├─ cmd/server/main.go
|
||||
│ ├─ internal/
|
||||
│ │ ├─ drives/ Drive 接口 + 三家实现
|
||||
│ │ ├─ drives/ Drive 接口 + 多家实现
|
||||
│ │ │ ├─ iface.go List / Stat / StreamURL / RefreshAuth
|
||||
│ │ │ ├─ quark/ 自己实现(参考 OpenList quark_uc)
|
||||
│ │ │ ├─ p115/ 壳 + SheltonZhu/115driver
|
||||
│ │ │ ├─ pikpak/ 自己实现(参考 OpenList pikpak)
|
||||
│ │ │ └─ wopan/ 壳 + OpenListTeam/wopan-sdk-go
|
||||
│ │ ├─ catalog/ SQLite + VideoItem 增删改查
|
||||
│ │ ├─ scanner/ 扫目录 → 落库 + 异步抽 teaser
|
||||
@@ -1330,6 +1331,7 @@ VideoProject/
|
||||
- **SDK**:
|
||||
- 夸克:移植 OpenList `drivers/quark_uc` 的 HTTP 逻辑(纯 Cookie + resty)。
|
||||
- 115:`github.com/SheltonZhu/115driver`,通过 `replace` 指令指向 `../115driver-1.3.2`。
|
||||
- PikPak:移植 OpenList `drivers/pikpak` 的 HTTP 逻辑(用户名密码 / refresh_token + captcha_token + resty);第一版支持扫描和播放,teaser 上传走本地兜底。
|
||||
- 沃盘:`github.com/OpenListTeam/wopan-sdk-go`,`replace` 指向 `../wopan-sdk-go-0.2.0`。
|
||||
- **视频处理**:ffmpeg / ffprobe,作为外部子进程调用。
|
||||
- **部署**:本地 Windows 开发,最终部署到 Linux 服务器(二进制 + systemd + nginx 反代)。
|
||||
@@ -1474,6 +1476,7 @@ POST /admin/api/videos/:id/regen-preview
|
||||
|
||||
- **115 扫码**:`POST /admin/api/drives/:id/login` 返回二维码图片;前端轮询 `.../login/status` 直到成功
|
||||
- **夸克**:最稳是让用户在电脑浏览器登录 pan.quark.cn 后 F12 复制 Cookie,后台粘贴保存。可选:实现扫码登录(OpenList 社区有方案)
|
||||
- **PikPak**:参考 OpenList,后台粘贴 username/password 或 refresh_token;遇到 captcha URL 时手动验证后回填 captcha_token
|
||||
- **沃盘**:手机号 → 后端请求短信 → 前端填验证码 → 登录
|
||||
|
||||
### 15.9 前端改动
|
||||
|
||||
+21
-20
@@ -1,20 +1,21 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": "http://localhost:8080",
|
||||
"/p": "http://localhost:8080",
|
||||
"/admin/api": "http://localhost:8080",
|
||||
},
|
||||
},
|
||||
});
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 9191,
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:9192",
|
||||
"/p": "http://127.0.0.1:9192",
|
||||
"/admin/api": "http://127.0.0.1:9192",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user