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:
nianzhibai
2026-05-10 23:55:04 +08:00
parent 87866858b8
commit 3506328441
87 changed files with 10644 additions and 8464 deletions
+11
View File
@@ -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
View File
@@ -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/` 到这里
+112 -111
View File
@@ -1,111 +1,112 @@
# 视频聚合站
把夸克 / 115 / 联通沃盘作为存储后端的视频聚合前台。按 `video-site-implementation-plan.md` 的设计实现。
- 前端:React 18 + Vite + TypeScript
- 后端:Go 1.23SQLite(纯 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
- Teaser3 段拼接(`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.23SQLite(纯 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
- Teaser3 段拼接(`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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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})
}
+118
View File
@@ -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
View File
@@ -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()})
}
+145
View File
@@ -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
}
+81 -81
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+72 -72
View File
@@ -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
View File
@@ -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
}
}
+65 -64
View File
@@ -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")
+217 -217
View File
@@ -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 / Refererinfo.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 / Refererinfo.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)
+283
View File
@@ -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[:])
}
+333
View File
@@ -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)
}
}
+87
View File
@@ -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,
}
}
+345 -345
View File
@@ -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)
+231 -231
View File
@@ -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 路径使用 ListStat 保留 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 路径使用 ListStat 保留 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)
+100
View File
@@ -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
}
+33
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+159
View File
@@ -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
View File
@@ -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 + "/" + fileIDvalue: 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 + "/" + fileIDvalue: 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 }
+42 -49
View File
@@ -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
}
+32
View File
@@ -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
View File
@@ -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
}
+184
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
+25 -25
View File
@@ -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>
);
}
+47 -47
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
+67 -67
View File
@@ -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>
);
}
+41 -41
View File
@@ -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"
/>
);
}
);
+24 -24
View File
@@ -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>
);
}
+15 -15
View File
@@ -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>
);
}
+59 -59
View File
@@ -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>
);
}
+15 -15
View File
@@ -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>
);
}
+62 -62
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}`;
}
+40 -40
View File
@@ -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
View File
@@ -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>
);
}
+87 -20
View File
@@ -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>
);
}
+4 -4
View File
@@ -1,4 +1,4 @@
import type { PromoItem } from "@/types";
// 第一版不再预置横幅。真实素材来自后续的"合集/专题"接口,这里先留空。
export const promoItems: PromoItem[] = [];
import type { PromoItem } from "@/types";
// 第一版不再预置横幅。真实素材来自后续的"合集/专题"接口,这里先留空。
export const promoItems: PromoItem[] = [];
+5 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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`;
}
+26 -26
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
+95 -95
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+84 -84
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
}
+4 -1
View File
@@ -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
View File
@@ -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",
},
},
});