feat: improve admin setup and drive management

This commit is contained in:
nianzhibai
2026-05-28 18:41:40 +08:00
parent 54ed98f04f
commit bb8818a55a
16 changed files with 627 additions and 519 deletions
+67 -415
View File
@@ -1,457 +1,109 @@
# 视频聚合站
夸克 / 115 / PikPak / 联通沃盘 / OneDrive 作为存储后端的视频聚合前台。按 `video-site-implementation-plan.md` 的设计实现
散落在不同网盘里的视频,整理成一个可以自己登录、自己浏览、自己管理的私人视频站
- 前端:React 18 + Vite + TypeScript
- 后端:Go 1.23SQLite(纯 Go 驱动,无 CGO),ffmpeg 生成 teaser 和封面
- 网盘接入:夸克自研 + 115driver SDK + PikPak 自研(参考 OpenList+ wopan-sdk-go SDK + OneDriveOpenList 在线续期 + Microsoft Graph 文件接口)
- 爬虫接入:91 爬虫(`91VideoSpider/spider_91porn.py`,由凌晨流水线触发,拉一页视频 + 封面到本地)
网盘适合存东西,却不适合慢慢看东西。文件多了以后,你很难记住它们在哪里、叫什么、有没有看过、还能不能快速预览。这个项目做的是中间那一层:文件仍然留在原来的网盘里,但你可以用一个更像视频站的界面去搜索、筛选、预览和管理它们。
## 当前功能
它不是另一个网盘客户端,也不是内容平台。它更像是给你自己的视频收藏做一个入口:安静、集中、可控。
- 前台需要登录后访问,支持首页、列表页、搜索、分类/标签筛选、分页、详情播放和相关推荐。
- 首页"随机推荐"从最近 200 个视频里随机抽 12 个展示;"最新视频"按发布时间(即视频入库时刻)倒序展示最新 12 个。从详情页返回首页时不会刷新,保持之前看到的内容。手机端首页每个板块显示 8 个视频。
- 列表页默认每页 24 个视频;选择具体标签筛选时每页显示 12 个。电脑端每行 4 个卡片,手机端每行 2 个。列表页会记住筛选、分页和滚动位置。
- 视频卡片支持封面、画质标签、时长、移动端点按预览。
- 播放页显示来源网盘类型,提供点赞、点踩、标签编辑和 **不再展示**。不再展示是全局隐藏:写入数据库后,该视频不会再出现在首页、列表、相关推荐中,详情接口也会返回 404。
- 全站支持两套主题:**暗黑 + 暖橙**(默认)和 **奶油白 + 樱花粉**,在管理后台 → 外观 切换。所有访客共用一套主题,写入 SQLite 永久保存;前端通过 `<html data-theme>` 属性热切换 CSS 变量,无需重载页面。
- 管理后台支持网盘管理、视频管理、标签管理、外观(主题)和运行时 Teaser 生成开关。
- 管理后台登录带 IP 封禁保护:同一 IP 在 30 分钟内登录失败超过 3 次会被永久封禁,封禁记录写入 SQLite。
- 视频管理支持按网盘筛选、每页 100 条分页、每个网盘的 Teaser 已生成/待生成/失败统计、单条或全量重生 teaser、编辑标题/作者/分类/标签等元数据。
- 标签管理支持创建标签并自动分类已有视频;内置规则会把常见番号污染归并到 `AV` 等系统标签,降低标签列表噪声。
- 115 生成 teaser 时会顺序取链并分段生成,降低 CDN 403 / WAF 风控导致的大量失败概率;遇到疑似风控会进入冷却并保留任务为 `pending`
- 网盘扫描支持**用户配置的"跳过目录"**:在 `/admin/drives` → 网盘行的「跳过目录」按钮里树形浏览网盘,勾选要跳过的目录,保存后下次扫描时这些目录及其全部子目录都不会被递归。**没有目录深度上限**,扫描会一直递归到没有子目录为止。早期 115 硬编码跳过名为「影视」的目录的特例分支已被替换为通用的"用户可选跳过目录"机制。
## 它能做什么
## 前端 UI
- **统一入口**:把 115、PikPak、夸克、联通沃盘、OneDrive、本地上传和可选的 91 爬虫源放在同一个站里浏览。
- **像视频站一样浏览**:首页推荐、最新视频、列表页、搜索、标签筛选、详情播放和相关推荐都已经接好。
- **自动生成预览**:后端会用 ffmpeg 在本地生成封面和短 teaser,扫到新视频后不用一条条手动整理。
- **保留网盘本身**:视频文件不需要搬家,播放时由后端按来源取链和代理。
- **后台可管理**:在管理后台添加网盘、扫描所有网盘、编辑视频信息、维护标签、切换主题。
- **首次部署更直接**:第一次访问时会要求设置管理员用户名和密码,设置后保存到本地配置文件。
- **适合长期运行**:扫描、预览、隐藏视频、标签归类这些重复工作,都尽量交给系统处理。
- 两套主题:**暗黑 + 暖橙**(默认)走深邃灰阶 + 渐变橙色主色;**奶油白 + 樱花粉**走柔和奶白底 + 樱花粉主色 + 深咖紫文本。两套都覆盖前台所有页面和管理后台。
- 主题通过 `<html data-theme>` 属性切换,所有颜色都走 `tokens.css` 里的 CSS 变量;切换不重载页面。
- 导航栏 sticky + 毛玻璃效果;手机端汉堡菜单
- 视频卡片 hover 上浮 + 阴影 + 缩略图微缩放;手机端改为按压缩放反馈。
- 搜索框聚焦时主色发光环;标签使用圆形药丸样式
- 后台管理:渐变品牌标识、圆角导航、卡片阴影、模态框毛玻璃背景。
- 全局自定义滚动条会跟随主题颜色。
- 只展示有实际功能的 UI 元素,无占位链接。
## 适合谁
如果你有一批视频散落在多个网盘里,想把它们整理成一个自己的私有站点,这个项目会比较合适
如果你只是想临时播放单个文件,直接用网盘客户端更简单;如果你想做公开视频网站,这个项目也不是为那个场景设计的。它的重点是个人部署、个人管理、个人观看
## 支持的来源
- 115 网盘
- PikPak
- 91 爬虫源
- 夸克网盘
- 联通沃盘
- OneDrive
- 本地上传
91 爬虫源是一种特殊存储来源,用来把爬虫抓到的视频和封面接入站内目录。它不是必须项;如果你只想管理自己的网盘,可以完全不启用。
## 快速开始
### 环境要求
需要先准备:
- Node.js 18+ 和 npm
- Node.js 18+
- Go 1.23+
- ffmpeg 和 ffprobe(用于生成预览 teaser 和抽封面)
- ffmpeg 和 ffprobe
Windows 用户可以把 Go 和 ffmpeg 解压到 `%USERPROFILE%\tools\`,然后把 `\tools\go\bin``\tools\ffmpeg\bin` 加到 PATH 即可,不需要管理员权限。
### 运行
线上服务器以 **systemd** 守护两个进程常驻运行(推荐,开机自启 + 崩溃自愈 + 日志走 journalctl);本地开发可用 `start.sh` 或手动启动。
#### 方式 Asystemd(生产 / 长跑)
仓库不直接提交 unit 文件;把下面两段写到 `/etc/systemd/system/` 即可。后端走预编译二进制,前端走 vite preview 提供 `dist/` 静态文件。
```ini
# /etc/systemd/system/video-site-backend.service
[Unit]
Description=Video Site Backend (Go server)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/root/myProject/91/backend
ExecStart=/root/myProject/91/backend/server
Restart=on-failure
RestartSec=5
TimeoutStopSec=20
# spider91 / OneDrive / PikPak 等海外接口走本机 mihomo;按实际代理改
Environment=HTTPS_PROXY=http://127.0.0.1:7890
Environment=HTTP_PROXY=http://127.0.0.1:7890
Environment=NO_PROXY=127.0.0.1,localhost,::1
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Environment=HOME=/root
LimitNOFILE=65536
StandardOutput=journal
StandardError=journal
SyslogIdentifier=video-site-backend
[Install]
WantedBy=multi-user.target
```
```ini
# /etc/systemd/system/video-site-frontend.service
[Unit]
Description=Video Site Frontend (Vite preview, serves dist/)
After=network-online.target video-site-backend.service
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/root/myProject/91
ExecStart=/usr/bin/npm run preview -- --host 0.0.0.0 --port 9191
Restart=on-failure
RestartSec=5
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Environment=HOME=/root
Environment=NODE_ENV=production
StandardOutput=journal
StandardError=journal
SyslogIdentifier=video-site-frontend
[Install]
WantedBy=multi-user.target
```
首次部署:
```bash
# 1. 编译后端二进制 + 构建前端静态产物
cd /root/myProject/91
npm install && npm run build
cd backend && go build -o server ./cmd/server
# 2. 启用并启动两个 unit
sudo systemctl daemon-reload
sudo systemctl enable --now video-site-backend.service video-site-frontend.service
```
日常运维:
```bash
# 状态 / 重启 / 停止
systemctl status video-site-backend video-site-frontend
systemctl restart video-site-backend
systemctl stop video-site-frontend
# 实时日志
journalctl -u video-site-backend -f
journalctl -u video-site-frontend -f
# 改了 Go 代码 → 重编译 + 重启后端
cd /root/myProject/91/backend && go build -o server ./cmd/server && sudo systemctl restart video-site-backend
# 改了前端代码 → 重新 build + 重启前端
cd /root/myProject/91 && npm run build && sudo systemctl restart video-site-frontend
```
注意 systemd 模式下不要再用 `./start.sh` 来 start/stop —— 它会和 systemd 抢端口、抢进程;只在 systemd 出故障时当应急后路。
#### 方式 Bstart.sh(本地开发 / 快速试跑)
启动项目:
```bash
npm install
./start.sh # 前端 9191,后端 9192;默认 preview 模式(无热更新)
./start.sh --status # 查看运行状态
./start.sh --restart # 重启
./start.sh --stop # 停止
./start.sh
```
需要前端热更新:`FRONTEND_MODE=dev ./start.sh --restart`
默认访问地址:
也可以分两个终端手动启动:
- 前台:`http://127.0.0.1:9191/`
- 后台:`http://127.0.0.1:9191/admin`
- 后端:`127.0.0.1:9192`
第一次打开时,如果还没有设置管理员账号,页面会引导你创建用户名和密码。保存后会写入本地的 `backend/config.yaml`
常用命令:
```bash
# 前端
npm install
npm run build
npm run preview # 监听 http://127.0.0.1:9191,无热更新
# 后端(另开终端)
cd backend
go run ./cmd/server # 默认监听 127.0.0.1:9192,依赖已 vendor 入库,无需 go mod tidy
./start.sh --status
./start.sh --restart
./start.sh --stop
```
#### 启动后
首次启动后端会自动生成:
- `backend/config.yaml`(从 `config.example.yaml` 复制)
- `backend/data/video-site.db`SQLite
- `backend/data/previews/`teaser 和封面本地目录)
Vite dev / preview server 都已配置把 `/api``/p``/admin/api` 反代到 `127.0.0.1:9192`。浏览器访问 `http://127.0.0.1:9191/` 进入前台,`/admin` 进入管理后台(默认 `admin` / `admin123`,请在 `backend/config.yaml` 里改)。如果本地已经存在旧的 `backend/config.yaml`,请确认 `server.listen` 与 Vite 代理端口一致。
## 目录
```
.
├─ src/ React 前端
├─ backend/ Go 后端(单体服务)
│ └─ vendor/ Go 依赖全量源码,入库,支持完全离线构建
├─ 91VideoSpider/ 91 爬虫脚本(Pythonspider91 drive 调用)
├─ OpenList-4.2.1/ OpenList 完整源码,网盘协议对接参考
├─ tests/ 前端纯逻辑测试
├─ start.sh 本地前后端启动脚本
├─ 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/ # 入库
FRONTEND_MODE=dev ./start.sh --restart
```
### `vendor-refs/` 要不要在意?
## 第一次使用
不需要。它只存 OpenList 源码作协议参考,删除或保留都不影响项目编译
1. 打开 `http://127.0.0.1:9191/`,先完成管理员账号设置
2. 进入 `/admin`,在网盘管理里新建一个来源。
3. 填入名称和对应凭证,保存。
4. 点击“扫描所有网盘”,等待视频入库。
5. 回到前台,用首页、搜索、标签和详情页浏览内容。
## 加一个网盘
## 数据放在哪里
1. 登录 `/admin` → 网盘管理 → 新建
2. 选类型(夸克 / 115 / PikPak / 沃盘 / OneDrive),填名称 + 凭证
3. 保存后会自动触发一次扫描
4.`/admin/videos` 里看扫到了多少视频
5. 侧栏底部 **Teaser 生成** 开关开着,就会按配置给每个视频生成封面和多段 teaser
项目会把运行数据保存在本地:
网盘凭证字段:
- `backend/config.yaml`:本地配置、管理员账号、网盘凭证
- `backend/data/video-site.db`SQLite 数据库。
- `backend/data/previews/`:本地生成的封面和 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;后续会加扫码/短信登录 |
| OneDrive | `refresh_token`,可选 `access_token``api_url_address``region``is_sharepoint``site_id` | 按 OpenList 默认方式调用 `https://api.oplist.org/onedrive/renewapi` 在线刷新 token`rootId` / `scanRootId` 默认填 `root`SharePoint 需填 `is_sharepoint=true``site_id` |
| 91 爬虫 | 可选 `target_new``crawl_hour``proxy``python_path``script_path` | 详见下文「91 爬虫源」 |
这些文件不应该提交到公开仓库。仓库里的 `backend/config.example.yaml` 只是模板,不应该放真实账号、Cookie、Token 或密码。
## 视频播放路径
## 更多文档
不同网盘的视频字节走法不同,这影响:用户带宽来源、客户端 IP 暴露、backend 出站流量。
根目录 README 只保留项目介绍和最短上手路径。更细的实现、接口、网盘字段和部署方式可以看:
| 网盘 | 用户播放 `/p/stream/<driveID>/<fileID>` | ffmpeg 生成 teaser |
|---|---|---|
| **115、PikPak** | **302 重定向直连 CDN**(视频字节不过 backend)| 进程内本地代理转发 Range 请求(加必要请求头) |
| OneDrive、夸克、沃盘 | backend 反代字节(需要后端 Cookie / Authorization 鉴权)| 同左 |
| spider91、localupload | backend 直接 ServeFile 本地文件 | 直接读本地 mp4 |
- [backend/README.md](backend/README.md)
- [video-site-implementation-plan.md](video-site-implementation-plan.md)
**302 直连** 意味着:
- 浏览器拿到签名 CDN URL 后自己访问,**视频字节不流经服务器**,省服务器带宽。
- 用户端到该网盘 CDN 节点的网络质量决定播放速度(国内用户对 PikPak 海外节点尤其敏感)。
- 客户端 IP 暴露给该网盘 CDN(和 OpenList 行为一致)。
- 链接有过期时间(115 / PikPak 都是几分钟到 10 分钟),超长暂停后 Range 续传 403 时刷新页面会自动重新取链。
**backend 反代** 意味着:
- 字节经过 backend 出站,受服务器带宽和 backend 的代理策略影响。
- 客户端 IP 不暴露给上游网盘。
- backend 持有的 Cookie / Authorization 才能拉到下载流(这些字段不能交给浏览器)。
**ffmpeg 走本地代理** 是 backend 内部行为:teaser worker 启动一个临时的 `127.0.0.1:<随机端口>` HTTP server,给 ffmpeg 一个本地 URL,本地代理把 Range 请求转发到 CDN 并自动加 UA / Cookie。这样:
- 签名 URL 不出现在 ffmpeg 命令行里(避免日志泄漏)
- 多段抽帧时 ffmpeg 总是访问同一个本地 URL,但每个 Range 都会被代理转换成新一次取链请求,避开 CDN 对同一签名 URL 多段并发的风控
### 115 说明
115 的下载直链对同一个 CDN URL 的多段随机读取比较敏感,尤其是大文件生成多段 teaser 时,容易出现 `403 Forbidden`、WAF 阻断、`moov atom not found``partial file`。后端对 115 做了专门处理:
- **用户播放**:和 PikPak 一样走 302 直连 115 CDN,浏览器自己请求 CDN(不经过 backend)。客户端 IP 暴露给 115 CDN,链接 `e=` 过期时刷新页面即可重新签。
- **取流优先用移动端下载接口**,失败再回退到原 chrome 下载接口(仅影响 backend 内部取链阶段,比如 ffmpeg 抽 teaser 时)。
- **生成 teaser** 不再让 ffmpeg 同时打开多个 115 直链;每个 3 秒片段单独取链、单独生成本地小片段,最后在本地 concat。
- **ffmpeg 访问 115 CDN** 会经过进程内本地代理转发 Range 请求,避免直接暴露签名 URL,并统一处理必要请求头。
- **扫描列目录的限频冷却**:115 列目录返回 `405 / 429 / WAF 阻断 / 安全威胁 / unexpected error` 等疑似限频错误时,当前 drive 的列目录会冷却 **10 分钟**后重试;如果再次命中限频继续冷却 10 分钟(无重试上限,直到成功或 ctx 取消)。冷却期间该 drive 的 scanner 单线挂起;其它 drive 不受影响。
- 如果 115 返回 403 / 405 / WAF 阻断 / `moov atom not found` / `partial file` 等疑似临时风控错误,当前网盘的封面/teaser worker 会进入默认 **5 分钟冷却**,到点后自动继续处理后续任务;当前命中限频的任务保持 `pending` 留待下一轮 nightly,避免反复请求加重风控。
管理后台的"重生失败 teaser"会把 `failed` 重置为 `pending` 并入队。一次性重生大量 115 视频仍可能触发上游风控;建议点一次后观察日志,如果出现 `transient media source error until=...`,等待冷却结束再继续,不要反复点击。
### PikPak 说明
PikPak 视频流采用 **302 重定向直连**(和 OpenList 一致):浏览器请求 `/p/stream/<driveID>/<fileID>` 时,backend 调用 PikPak API 拿到签名直链,直接 `302 Location: <PikPak CDN URL>` 出去,视频字节走浏览器 ↔ CDN 直连,**不经过 backend**。
带来的影响:
- 服务器带宽不消耗在视频字节上,单纯做"取链"中介。
- 网盘所在 CDN 节点的可用性 / 速度直接决定播放体验:能直连 PikPak CDN 的客户端走得很快,反之则慢。这一点对国内访问尤其敏感。
- 客户端 IP 暴露给 PikPak CDN(与 OpenList 行为一致)。
- 签名链接在客户端缓存,大概 10 分钟左右过期;超长暂停后 Range 续传 403 时刷新页面会自动重新取链。
`disable_media_link` 字段的取舍仍然有效:
- 默认 `true`:用 `web_content_link` 原始下载链接,CDN 节点偏慢,但稳定。
- `false`:改用 `usage=CACHE` 返回的 media/cache 链接,通常更快(当前服务器实测原 `/p/stream` 反代模式下约 8.9 MiB/s vs `true` 模式 ~3 MiB/s),但 media/cache 节点偶尔有波动。改成 302 直连后,浏览器侧的下载速度只看本地到 PikPak CDN 的网络,与 backend 出站策略(如 sing-box TUN)无关。
teaser / 封面生成走的是 backend 内部取流路径,不受 302 重定向影响(仍由 backend 拉数据后喂给 ffmpeg)。
### OneDrive 说明
OneDrive 当前采用 OpenList 在线 API 的续期方式,不要求用户提供 Azure 应用的 `client_id` / `client_secret` / `redirect_uri`。配置时至少填 `refresh_token`;如使用 OpenList 代刷获得的 token,可把 refresh token 填到本项目。普通 OneDrive 的 `rootId` / `scanRootId` 推荐填 `root`SharePoint 文档库需额外设置 `is_sharepoint=true``site_id`
### 91 爬虫源
91 爬虫不是真正的网盘,而是把 `91VideoSpider/spider_91porn.py` 包装成一种 drive:每天凌晨自动跑一次脚本,从 91porn 本月最热第 1 页起翻页,跳过已经爬过的 viewkey,凑够指定数量的新视频后停止;下载视频和封面到本地,再以 `spider91` 类型的 drive 接入到现有的视频列表 / 详情 / 标签 / teaser 流水线。
**部署前置条件**
1. 服务器装好 Python 3 + 依赖:
```bash
pip install requests beautifulsoup4 lxml
```
2. 91porn 的 CDN 节点(cdn77.org / btc620.com 等)位于海外,国内服务器直连下载通常只有几 KB/s。**必须经过代理**,可以两种方式之一:
- 全局:让 backend 进程能拿到 `HTTPS_PROXY` 环境变量(如 `export HTTPS_PROXY=http://127.0.0.1:7890`),然后 `./start.sh --restart`
- 单 drive:在管理后台 spider91 drive 的 `proxy` 字段里填 `http://127.0.0.1:7890`,覆盖环境变量
实测通过本地 mihomo HTTP 代理,下载速度约 12-15 MB/s,15 个视频(约 1.2 GB)端到端 2-3 分钟跑完。
**配置方式**:在 `/admin/drives` 新建,类型选 "91 爬虫",所有字段都有合理默认值,可以直接保存:
| 字段 | 默认值 | 说明 |
|---|---|---|
| `target_new` | `15` | 每次爬取的新视频数。从 page 1 起翻页,跳过已知 viewkey,凑够这么多个新视频后停止 |
| `crawl_hour` | `0` | **已废弃**:旧版每天按这个整点触发爬取,现在统一由 `nightly.cron_hour` 调度(默认 01:00)。字段保留仅为兼容旧 yaml |
| `proxy` | `(空)` | 下载代理 URL,如 `http://127.0.0.1:7890`;留空时回退到 backend 进程的 `HTTPS_PROXY` 环境变量 |
| `python_path` | `python3` | 解释器路径,可填绝对路径 |
| `script_path` | (自动定位) | 脚本绝对路径;不填时从仓库结构里推断 `91VideoSpider/spider_91porn.py` |
服务启动时会自动从 `backend/` 父目录推断 `script_path`,所以正常运行 `cd backend && go run ./cmd/server` 时不需要手填。
**管理后台 UI 适配**`spider91` 行的"状态"列显示 `已就绪`/`错误`(不会出现"未配置凭证"),"扫描根"列改成显示 `上次抓取 N 小时前`,操作里的 `重扫` 按钮变成 `立即抓取`(点击后立刻触发一次完整流程,不受 12 小时间隔约束)。
**目录结构**
```
backend/data/spider91/<driveID>/
├─ videos/<viewkey>.mp4 # 下载下来的视频文件(后缀按直链 URL 推断)
├─ thumbs/<viewkey>.jpg # 下载下来的封面(也会复制一份到 backend/data/previews/thumbs/
└─ .crawl/ # 每次爬虫输出的 JSON 和已知 viewkey 列表,带时间戳,便于排查
```
**触发逻辑**
- 每天 `nightly.cron_hour`(默认 01:00)作为整条流水线 Phase 2 串行触发;详见上文「凌晨流水线」章节
- 管理后台点 spider91 drive 的「立即抓取」按钮:只跑该 drive 一次爬取(不连带 Phase 1 / Phase 3
- 顶栏「立即跑全流程」按钮:跑完整 Phase 1 + 2 + 3
- `crawl_hour` 字段保留但已不参与调度判定;`last_crawl_at` 仅作 admin UI「上次抓取 N 小时前」显示用
**去重**:用 91porn 网站的 `viewkey` 作为唯一标识,配合 `videos.id = "spider91-<driveID>-<viewkey>"` 的拼接规则去重。每次爬取前 backend 会把 catalog 里已存在的 viewkey 列表写到 `.crawl/seen-<时间戳>.txt`,作为 `--seen-viewkeys-file` 传给 Python 脚本;脚本只会请求未见过 viewkey 的详情页。
**视频文件格式**:保存到磁盘时的扩展名按视频直链 URL 真实后缀决定(`.mp4` / `.webm` / `.mkv` / `.mov` / `.m4v` / `.flv` / `.avi`);对 `.m3u8` 等流媒体清单回退到 `.mp4`。`videos.ext` 字段也会跟实际后缀保持一致。
**封面、标签和 teaser**
- 封面直接用爬虫拿到的网站原图,不调用 ffmpeg 抽帧;入库时 `thumbnail_status` 直接置为 `ready`,封面 worker 不会处理 spider91 视频
- 所有 spider91 视频自动打 **`91porn`** 标签(`source=system`)。挂载 spider91 drive 时会自动建标签 + 给已入库的视频按 author 字段补打;新视频入库时直接带上
- teaser 走现有 ffmpeg 生成流水线(`Teaser 生成` 总开关开启时),mp4 下载完后 3-4 秒内生成
**风险和注意事项**
- 视频直链带过期 token`e=` 参数),爬完必须立刻下载,不能延后
- 91porn 有 Cloudflare 防护,连续访问可能触发 403;脚本内置 3-6 秒列表页延时和 2-5 秒详情页延时
- `target_new=15` 配合 page 上下文,单次任务大概要请求 15-30 个详情页(部分页面会是已爬过的 viewkey,会跳过详情页请求);Python 阶段约 1 分钟,下载阶段在代理畅通时约 1.5 分钟
- 单条视频平均 100 MB,每天 15 个新视频约占 1.5 GB;运行一段时间后注意磁盘容量
### Spider91 → PikPak 自动迁移
只要管理后台同时挂着 spider91 drive 和 PikPak drivespider91 爬下来的视频会按"**本地保留最近一次爬取的 15 个,更旧的上传到 PikPak**"的策略由独立 worker 处理;上传完后回放自动走 PikPak 302 直连,本地副本被删除。
- **保留策略**:每个 spider91 drive 的 `videos/` 目录按 mtime 降序,**最新 N=15 个文件被保留在本地不上传**(默认值,可通过 Migrator Config 调);只有超过这 15 个之外的更旧文件才会被传到 PikPak。
- 第一次爬完:本地 15 个,全部留下,PikPak 不增。
- 第二次爬完:本地 30 个,最旧 15 个传到 PikPak + 删本地,本地剩最新 15 个。
- 稳态:本地恒为 ≤15 个最新视频,PikPak 累积所有历史。
- **目标 PikPak drive 选择**`spider91_upload_drive_id` 全局设置;admin 可通过 `PUT /admin/api/settings` 显式指定。**未设置时会自动选取唯一的 PikPak drive**;如果有多个 PikPak drive,必须在管理后台显式选定其中一个,否则迁移不会发生。
- **PikPak 目录**:用该 PikPak drive 的 `rootId` 作为上传父目录。建议在 PikPak Web 端预先建一个空的子目录(比如 `/91Spider/`),把这个目录的 file ID 填到 PikPak drive 的 `rootId`,这样既能让自动迁移落到这个子目录,也能让该 PikPak drive 的扫描根只看这个子目录,不会和 115 等其它网盘内容重叠。
- **PikPak 文件名**:上传时使用 `<视频标题>-<viewkey后8位>.<ext>` 格式(方案 B)。标题被 sanitize 过:去控制字符、非法字符 `/ \ : * ? " < > |` 替成空格、折叠空白、首尾去点号、按 unicode 截断 80 字符。catalog 的 `file_name` 同步更新成上传名,下次 PikPak 扫盘时按 `(file_name, size)` 也能匹配上。
- **触发节奏**:迁移作为凌晨流水线的 Phase 3 触发 —— Phase 2 spider91 爬取 + 所有新视频 teaser 生成完毕后才进入;详见上文「凌晨流水线」章节。每天最多触发一次(除非管理员点「立即跑全流程」)。触发不等于上传 —— 是否上传由"本地是否超过 15 个"决定。
- **catalog 改写**:上传成功后事务性地把视频行的 `drive_id` / `file_id` / `file_name` / `content_hash` 改成 PikPak 的;视频自身的 `id``spider91-<driveID>-<viewkey>`)保持不变,所以 `video_tags`、`views`、`likes`、`91porn` 标签等关联数据全部保留。改写后再次扫盘时,scanner 通过 `(content_hash)` 或 `(file_name, size)` 现成的 `findDuplicate` 兜底逻辑认出来,不会重复入库。
- **本地清理**:迁移成功立即删本地 mp4 + thumb(封面已复制到 `backend/data/previews/thumbs/`,前端展示不受影响)。每轮 worker 末尾还有一道防御性兜底 —— 扫所有本地文件,对 catalog 中 `drive_id` 已迁走但本地仍有残留的孤儿做清理(正常路径不会触发)。
- **去重 seen 文件**crawler 每次跑前会写一份 "已知 viewkey" 文件喂给 Python 脚本,让它跳过已爬过的详情页。这个列表按 `id LIKE 'spider91-<driveID>-%'` 查(不依赖 `drive_id`),所以 spider91 视频被迁到 PikPak 后还能被认出来,**不会重复爬**。
- **失败处理**:上传失败时本地文件保留、catalog 行保持原样;下次轮询会重试。账户超额或永久错误目前没有特殊标记,watch 日志(`[spider91migrate]`)即可。
- **不开 PikPak** 不指定 `spider91_upload_drive_id` 也不挂 PikPak drive 时,spider91 视频继续从本地服务(`/p/spider91/<videoID>`),跟以前一样工作;磁盘会持续增长,需要手动管理。
## 凌晨流水线
所有定时任务统一进一条流水线,每天 `nightly.cron_hour`(默认 1 即 01:00)触发一次:
```
01:00 ▶ NightlyJob 启动
├─ Phase 1 扫所有非 spider91 / 非 localupload 网盘
│ 为每个 drive 串行:scan → 检测新增视频 → 检测被删视频
│ (本项目数据库 + 本地封面 / teaser 一并清理;扫描 errors > 0
│ 时跳过删除清理避免误删)→ 入队封面 + teaser(如该 drive
│ 的 teaser_enabled 为 true
│ 等所有 drive 的封面 + teaser 队列 idle
├─ Phase 2 仅当存在 spider91 drive:串行跑 91 爬虫,新视频入队 teaser
│ 等 teaser 队列 idle
└─ Phase 3 spider91 → 云盘迁移(一次完整 sweep;本地保留最新 15 个,
更旧的传到 spider91_upload_drive_id 指定的 PikPak 或 115
```
**关键属性**
- **不设总超时**:早期版本有 6 小时 `nightly.max_duration` 软超时,已移除。流水线会一直跑到所有 phase 完成,或进程被关停。yaml 里的 `nightly.max_duration` 字段保留仅为兼容旧配置,运行时被忽略。原因:单条 phase 内部的网盘风控冷却(115 列目录 10min × N、teaser 限频 5min)可能积累到很长时间,硬切到下一天反而把被中断的子任务延后到再下一晚。
- **当天去重**:流水线启动后会把当天日期写入 `settings.nightly.last_run_date`;同一天 01:00 的下一次 tick 看到日期相同就跳过,进程崩溃 + 重启也不会重复跑。
- **失败处理**:单个 drive 扫描失败、单条 teaser 生成失败、单条迁移失败都不会阻塞流水线;都通过日志可观测,下次流水线再试。teaser `failed` 状态需管理员手动「重生失败 teaser」恢复。
- **每 drive 的 teaser 开关**`drives.teaser_enabled` 字段在 Phase 1 / Phase 2 入队时被尊重;关闭时 teaser worker 不会被入队,封面仍然生成。
- **手动触发**`/admin/drives` 顶栏「立即跑全流程」按钮(POST `/admin/api/jobs/nightly/run`)忽略时间窗立即跑一遍,已在跑时被 `runMu.TryLock` 丢弃。
**已不存在的旧调度**(替代关系):
| 旧机制 | 替代为 |
|---|---|
| `scanLoop`:每 6h 在 0207 点窗口扫一次 | Phase 1 |
| `crawlerLoop`spider91 drive 按 `crawl_hour` 每分钟轮询 | Phase 2 |
| `Migrator.Run`:每 60 秒 + 爬完立即触发的迁移 worker | Phase 3 |
`config.yaml` 里 `scanner.interval_seconds` 字段保留但已不生效;spider91 drive 的 `crawl_hour` 凭证字段保留但已不参与调度,`last_crawl_at` 仅作 admin UI 显示「上次抓取 N 小时前」用。
## Teaser 和封面生成策略
- 封面:固定从第 5 秒抽一帧 jpg,不再为封面单独探测视频时长
- Teaser:每段固定 3 秒;30 秒以下最多 3 段,30 秒及以上固定 4 段;长视频在 20% 到 80% 区间均匀取段
- 生成的封面和 teaser 都只保存在本地 `backend/data/previews/`,不会回写到网盘;旧数据中的 `preview_file_id` 会被忽略
- 极短视频会按可容纳的完整 3 秒片段数自动降级
- 30 秒以下短视频会尽量生成多段 teaser,但只要生成到至少 1 个有效片段就会视为成功,避免短视频随机切点无有效视频流时反复失败
- 首次失败的任务标 `preview_status = failed`,不再自动重试;管理后台可手动重新生成
- 封面或 teaser 生成遇到明确频率限制(如 429)时,对应 worker 固定冷却 5 分钟。
- 服务启动或网盘重新挂载时,如果 Teaser 开关已开启,会自动把历史 `pending` 任务重新入队,避免重启后停在"待生成"。
- 115 使用顺序分段生成:每段独立取链、独立转码,最后本地拼接,避免同一 115 CDN 链接被多输入并发读取。
- OneDrive 直链生成 teaser 时可能触发 Microsoft 429 限流;后端会识别这类错误并让当前网盘进入冷却期,保留任务为 `pending`,避免连续请求触发更严重限流。
- 115 直链生成 teaser 时如果触发 403 / WAF / 截断数据等临时错误,也会让当前网盘进入冷却期,保留任务为 `pending`。
- 详见 plan 15.12 节
## 常用管理能力
- `/admin/drives`:新增/编辑/删除网盘,触发扫描。
- `/admin/videos`:按网盘查看视频、分页浏览、查看各网盘 Teaser 统计、编辑元数据、重生 teaser。
- `/admin/tags`:新增标签并自动匹配已有视频。
- 播放页:视频信息会显示来源网盘类型;"不再展示"是全局隐藏功能。当前没有恢复入口,如需恢复可直接把数据库中对应视频的 `hidden` 字段改回 `0`,后续可在管理后台补恢复 UI。
## 验证
## 开发验证
```bash
npm run lint
npm run build
npm test # 跑所有 tests/*.test.ts(用 tsx 加载 TS
cd backend
go test ./... -count=1
npm test
cd backend && go test ./... -count=1
```
## 部署到 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 节(后端)。
这个项目面向个人私有部署。请只接入你有权访问和管理的内容,并遵守对应网盘、站点服务条款以及所在地法律法规。
+2 -2
View File
@@ -65,7 +65,7 @@ go run ./cmd/server
- `data/video-site.db`
- `data/previews/`
默认监听 `127.0.0.1:9192`,默认管理员 `admin / admin123`(务必在 `config.yaml` 里改)。如果本地已有旧的 `config.yaml`,请确认 `server.listen` 与前端代理端口一致。
默认监听 `127.0.0.1:9192`。首次部署如果仍是默认管理员配置,登录页会要求先设置用户名和密码,并写回 `config.yaml`。如果本地已有旧的 `config.yaml`,请确认 `server.listen` 与前端代理端口一致。
### 连接前端
@@ -83,7 +83,7 @@ go run ./cmd/server 后端 9192
也可以直接调用后端接口:
1. 登录管理后台:`POST /admin/api/login` body `{"username":"admin","password":"admin123"}`
1. 先在浏览器访问 `/login` 完成首次管理员设置,或使用已有管理员账号登录:`POST /admin/api/login`
2. 新建盘:`POST /admin/api/drives`
```json
{
+26 -4
View File
@@ -101,6 +101,8 @@ func main() {
Password: cfg.Server.Admin.Password,
Catalog: cat,
}
setupRequired := config.RequiresAdminSetup(cfg)
var setupMu sync.Mutex
apiServer := &api.Server{
Catalog: cat,
@@ -114,8 +116,28 @@ func main() {
}
adminServer := &api.AdminServer{
Catalog: cat,
Auth: authr,
Catalog: cat,
Auth: authr,
SetupRequired: func() bool {
setupMu.Lock()
defer setupMu.Unlock()
return setupRequired
},
OnSetup: func(username, password string) error {
setupMu.Lock()
defer setupMu.Unlock()
if !setupRequired {
return nil
}
if err := config.WriteAdminCredentials(cfgPath, username, password); err != nil {
return err
}
cfg.Server.Admin.Username = username
cfg.Server.Admin.Password = password
authr.SetCredentials(username, password)
setupRequired = false
return nil
},
LocalPreviewDir: cfg.Storage.LocalPreviewDir,
OnDriveSaved: func(driveID string) error {
d, err := cat.GetDrive(ctx, driveID)
@@ -195,7 +217,7 @@ func main() {
// Phase 1 扫所有非 spider91 / localupload 网盘 + 删除检测 + 入队封面/teaser
// Phase 2 spider91 爬虫 + 入队 teaser
// Phase 3 spider91 → 云盘迁移
// 也响应 admin "立即跑全流程" 按钮(POST /admin/api/jobs/nightly/run → TriggerNow)。
// 也响应 admin "扫描所有网盘" 按钮(POST /admin/api/jobs/nightly/run → TriggerNow)。
app.nightlyRunner = nightly.New(nightly.Config{
Settings: cat,
CronHour: cfg.Nightly.CronHour,
@@ -255,7 +277,7 @@ type App struct {
spider91Migrator *spider91migrate.Migrator
// nightlyRunner 是凌晨流水线调度器:每天 cron_hour 串行跑扫盘 → 91 爬虫 → 迁移。
// 也响应 admin 「立即跑全流程」按钮(TriggerNow)。
// 也响应 admin 「扫描所有网盘」按钮(TriggerNow)。
nightlyRunner *nightly.Runner
// scanGlobalMu 串行化所有云盘扫盘任务,确保同一时刻全系统只有一个扫盘
+2 -1
View File
@@ -2,7 +2,8 @@
server:
# 本地开发默认配合 vite.config.ts 的代理端口。
listen: "127.0.0.1:9192"
# 管理后台用户,生产环境请务必修改
# 管理后台用户。保留默认值时,首次访问登录页会要求设置新用户名和密码,
# 并写回 backend/config.yaml。
admin:
username: "admin"
password: "admin123"
+70 -1
View File
@@ -7,6 +7,7 @@ import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
@@ -17,6 +18,10 @@ import (
type AdminServer struct {
Catalog *catalog.Catalog
Auth *auth.Authenticator
// SetupRequired 表示当前是否仍处于首次部署初始化状态。
SetupRequired func() bool
// OnSetup 持久化首次部署时设置的管理员账号密码,并更新运行中认证器。
OnSetup func(username, password string) error
// LocalPreviewDir is the local directory that stores generated teasers and thumbs.
LocalPreviewDir string
// Hooks:外层注入实际执行者
@@ -69,7 +74,9 @@ type DriveGenerationStatuses struct {
func (a *AdminServer) Register(r chi.Router) {
r.Route("/admin/api", func(r chi.Router) {
// 登录、登出不需要鉴权
// 登录、登出和首次部署初始化不需要鉴权
r.Get("/setup", a.handleSetupStatus)
r.Post("/setup", a.handleSetup)
r.Post("/login", a.handleLogin)
r.Post("/logout", a.handleLogout)
r.Get("/me", a.handleMe)
@@ -115,7 +122,69 @@ type loginReq struct {
Password string `json:"password"`
}
type setupReq struct {
Username string `json:"username"`
Password string `json:"password"`
}
func (a *AdminServer) setupRequired() bool {
return a.SetupRequired != nil && a.SetupRequired()
}
func (a *AdminServer) handleSetupStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, map[string]any{"required": a.setupRequired()})
}
func (a *AdminServer) handleSetup(w http.ResponseWriter, r *http.Request) {
if !a.setupRequired() {
http.Error(w, "setup already completed", http.StatusConflict)
return
}
if a.OnSetup == nil || a.Auth == nil {
http.Error(w, "setup is not available", http.StatusInternalServerError)
return
}
var body setupReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
username := strings.TrimSpace(body.Username)
password := body.Password
if username == "" {
http.Error(w, "username is required", http.StatusBadRequest)
return
}
if len(password) < 6 {
http.Error(w, "password must be at least 6 characters", http.StatusBadRequest)
return
}
if err := a.OnSetup(username, password); err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
ok, err := a.Auth.Login(w, r, username, password)
if err != nil {
if errors.Is(err, auth.ErrLoginIPBanned) {
http.Error(w, "ip banned", http.StatusForbidden)
return
}
writeErr(w, http.StatusInternalServerError, err)
return
}
if !ok {
http.Error(w, "setup completed but login failed", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) {
if a.setupRequired() {
http.Error(w, "setup required", http.StatusPreconditionRequired)
return
}
var body loginReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, http.StatusBadRequest, err)
+68
View File
@@ -47,6 +47,74 @@ func TestHandleLoginReturnsForbiddenForBannedIP(t *testing.T) {
}
}
func TestHandleLoginRequiresSetupBeforeDefaultLogin(t *testing.T) {
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)
}
})
req := httptest.NewRequest(http.MethodPost, "/admin/api/login", strings.NewReader(`{"username":"admin","password":"admin123"}`))
rr := httptest.NewRecorder()
(&AdminServer{
Catalog: cat,
Auth: &auth.Authenticator{Username: "admin", Password: "admin123", Catalog: cat},
SetupRequired: func() bool { return true },
}).handleLogin(rr, req)
if rr.Code != http.StatusPreconditionRequired {
t.Fatalf("status = %d, want 428; body = %s", rr.Code, rr.Body.String())
}
}
func TestHandleSetupStoresCredentialsAndCreatesSession(t *testing.T) {
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)
}
})
authr := &auth.Authenticator{Username: "admin", Password: "admin123", Catalog: cat}
setupRequired := true
var savedUser, savedPass string
req := httptest.NewRequest(http.MethodPost, "/admin/api/setup", strings.NewReader(`{"username":"owner","password":"secret123"}`))
rr := httptest.NewRecorder()
(&AdminServer{
Catalog: cat,
Auth: authr,
SetupRequired: func() bool { return setupRequired },
OnSetup: func(username, password string) error {
savedUser, savedPass = username, password
authr.SetCredentials(username, password)
setupRequired = false
return nil
},
}).handleSetup(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
if savedUser != "owner" || savedPass != "secret123" {
t.Fatalf("saved credentials = %q/%q, want owner/secret123", savedUser, savedPass)
}
cookies := rr.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("setup did not set a session cookie")
}
ok, err := cat.ValidateSession(context.Background(), cookies[0].Value)
if err != nil || !ok {
t.Fatalf("setup session valid=%v err=%v", ok, err)
}
}
func TestHandleUpsertDrivePreservesExistingCredentialsWhenRequestCredentialsEmpty(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
+17 -2
View File
@@ -30,6 +30,7 @@ type Authenticator struct {
Catalog *catalog.Catalog
Now func() time.Time
credMu sync.RWMutex
mu sync.Mutex
failures map[string]loginFailure
}
@@ -40,6 +41,7 @@ type loginFailure struct {
}
func (a *Authenticator) Login(w http.ResponseWriter, r *http.Request, user, pass string) (bool, error) {
expectedUser, expectedPass := a.Credentials()
ip := clientIP(r)
if ip != "" {
banned, err := a.Catalog.IsLoginIPBanned(r.Context(), ip)
@@ -50,8 +52,8 @@ func (a *Authenticator) Login(w http.ResponseWriter, r *http.Request, user, pass
return false, ErrLoginIPBanned
}
}
if subtle.ConstantTimeCompare([]byte(user), []byte(a.Username)) != 1 ||
subtle.ConstantTimeCompare([]byte(pass), []byte(a.Password)) != 1 {
if subtle.ConstantTimeCompare([]byte(user), []byte(expectedUser)) != 1 ||
subtle.ConstantTimeCompare([]byte(pass), []byte(expectedPass)) != 1 {
if ip != "" {
if err := a.recordFailure(r, ip); err != nil {
return false, err
@@ -80,6 +82,19 @@ func (a *Authenticator) Login(w http.ResponseWriter, r *http.Request, user, pass
return true, nil
}
func (a *Authenticator) Credentials() (string, string) {
a.credMu.RLock()
defer a.credMu.RUnlock()
return a.Username, a.Password
}
func (a *Authenticator) SetCredentials(username, password string) {
a.credMu.Lock()
defer a.credMu.Unlock()
a.Username = username
a.Password = password
}
func (a *Authenticator) recordFailure(r *http.Request, ip string) error {
now := a.now()
a.mu.Lock()
+131 -1
View File
@@ -1,14 +1,21 @@
package config
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
)
const (
DefaultAdminUsername = "admin"
DefaultAdminPassword = "admin123"
)
type Config struct {
Server Server `yaml:"server"`
Storage Storage `yaml:"storage"`
@@ -34,6 +41,129 @@ type Admin struct {
Password string `yaml:"password"`
}
func RequiresAdminSetup(c *Config) bool {
if c == nil {
return true
}
username := strings.TrimSpace(c.Server.Admin.Username)
password := c.Server.Admin.Password
if username == "" || password == "" {
return true
}
return username == DefaultAdminUsername && password == DefaultAdminPassword
}
func WriteAdminCredentials(path, username, password string) error {
username = strings.TrimSpace(username)
if username == "" {
return fmt.Errorf("username is required")
}
if password == "" {
return fmt.Errorf("password is required")
}
b, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read config: %w", err)
}
var root yaml.Node
if err := yaml.Unmarshal(b, &root); err != nil {
return fmt.Errorf("parse config: %w", err)
}
doc := ensureDocumentMapping(&root)
server := ensureMappingValue(doc, "server")
admin := ensureMappingValue(server, "admin")
setScalarValue(admin, "username", username)
setScalarValue(admin, "password", password)
var out bytes.Buffer
enc := yaml.NewEncoder(&out)
enc.SetIndent(2)
if err := enc.Encode(&root); err != nil {
_ = enc.Close()
return fmt.Errorf("encode config: %w", err)
}
if err := enc.Close(); err != nil {
return fmt.Errorf("encode config: %w", err)
}
mode := os.FileMode(0o644)
if st, err := os.Stat(path); err == nil {
mode = st.Mode().Perm()
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, out.Bytes(), mode); err != nil {
return fmt.Errorf("write temp config: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("replace config: %w", err)
}
return nil
}
func ensureDocumentMapping(root *yaml.Node) *yaml.Node {
if root.Kind == 0 {
root.Kind = yaml.DocumentNode
root.Content = []*yaml.Node{{Kind: yaml.MappingNode}}
}
if root.Kind != yaml.DocumentNode {
clone := *root
root.Kind = yaml.DocumentNode
root.Content = []*yaml.Node{&clone}
}
if len(root.Content) == 0 || root.Content[0] == nil {
root.Content = []*yaml.Node{{Kind: yaml.MappingNode}}
}
if root.Content[0].Kind != yaml.MappingNode {
root.Content[0].Kind = yaml.MappingNode
root.Content[0].Content = nil
}
return root.Content[0]
}
func ensureMappingValue(parent *yaml.Node, key string) *yaml.Node {
if parent.Kind != yaml.MappingNode {
parent.Kind = yaml.MappingNode
parent.Content = nil
}
for i := 0; i+1 < len(parent.Content); i += 2 {
if parent.Content[i].Value == key {
if parent.Content[i+1].Kind != yaml.MappingNode {
parent.Content[i+1].Kind = yaml.MappingNode
parent.Content[i+1].Content = nil
}
return parent.Content[i+1]
}
}
value := &yaml.Node{Kind: yaml.MappingNode}
parent.Content = append(parent.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key},
value,
)
return value
}
func setScalarValue(parent *yaml.Node, key, value string) {
if parent.Kind != yaml.MappingNode {
parent.Kind = yaml.MappingNode
parent.Content = nil
}
for i := 0; i+1 < len(parent.Content); i += 2 {
if parent.Content[i].Value == key {
parent.Content[i+1].Kind = yaml.ScalarNode
parent.Content[i+1].Tag = "!!str"
parent.Content[i+1].Value = value
parent.Content[i+1].Content = nil
return
}
}
parent.Content = append(parent.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key},
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value},
)
}
type Storage struct {
DBPath string `yaml:"db_path"`
LocalPreviewDir string `yaml:"local_preview_dir"`
@@ -59,7 +189,7 @@ type Preview struct {
// Nightly 是凌晨流水线(扫盘 → 91 爬虫 → 迁移)的调度配置。
//
// 一个进程只跑一条 nightly 流水线;该 cron 时间到达且当天还没跑过时触发,
// 也可被管理后台「立即跑全流程」按钮手动触发。MaxDuration 是软超时,超过
// 也可被管理后台「扫描所有网盘」按钮手动触发。MaxDuration 是软超时,超过
// 后当前 phase 完成、后续 phase 不再启动。
type Nightly struct {
// CronHour 是每日触发整点(023);默认 1 表示 01:00。
+52
View File
@@ -0,0 +1,52 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestRequiresAdminSetup(t *testing.T) {
if !RequiresAdminSetup(&Config{Server: Server{Admin: Admin{Username: DefaultAdminUsername, Password: DefaultAdminPassword}}}) {
t.Fatal("default admin credentials should require setup")
}
if RequiresAdminSetup(&Config{Server: Server{Admin: Admin{Username: "owner", Password: "secret123"}}}) {
t.Fatal("custom admin credentials should not require setup")
}
}
func TestWriteAdminCredentialsUpdatesConfigFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(`
server:
listen: "127.0.0.1:9192"
admin:
username: "admin"
password: "admin123"
storage:
db_path: "./data/video-site.db"
`), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
if err := WriteAdminCredentials(path, "owner", "new-secret"); err != nil {
t.Fatalf("write admin credentials: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("load config: %v", err)
}
if cfg.Server.Admin.Username != "owner" {
t.Fatalf("username = %q, want owner", cfg.Server.Admin.Username)
}
if cfg.Server.Admin.Password != "new-secret" {
t.Fatalf("password = %q, want new-secret", cfg.Server.Admin.Password)
}
if cfg.Server.Listen != "127.0.0.1:9192" {
t.Fatalf("listen = %q, want preserved value", cfg.Server.Listen)
}
if cfg.Storage.DBPath != "./data/video-site.db" {
t.Fatalf("db path = %q, want preserved value", cfg.Storage.DBPath)
}
}
+2 -2
View File
@@ -2,7 +2,7 @@
// replaces the legacy scanLoop / crawlerLoop / spider91 migrator periodic loop.
//
// Pipeline (fired once per day at cron_hour, also via TriggerNow for admin
// "立即跑全流程"):
// "扫描所有网盘"):
//
// Phase 1: for each non-spider91 cloud drive
// scan + delete-detection + enqueue thumb + enqueue teaser
@@ -192,7 +192,7 @@ func (r *Runner) runPipelineLocked(ctx context.Context, manual bool) {
// Mark today as processed regardless of success/error. This is intentional:
// a partial / failing pipeline shouldn't trigger again the same day, the
// admin can inspect logs and click "立即跑全流程" to retry explicitly.
// admin can inspect logs and click "扫描所有网盘" to retry explicitly.
dateStr := started.Format(dateLayout)
if err := r.cfg.Settings.SetSetting(ctx, settingLastRunDate, dateStr); err != nil {
log.Printf("[nightly] persist last_run_date: %v", err)
+56 -82
View File
@@ -18,6 +18,7 @@ import * as api from "./api";
import { useToast } from "./ToastContext";
import { Modal } from "./Modal";
import { formatBytes } from "./storageFormat";
import { makeUniqueDriveId } from "./driveId";
const kindLabel: Record<string, string> = {
quark: "夸克网盘",
@@ -31,6 +32,10 @@ const kindLabel: Record<string, string> = {
type Kind = api.AdminDrive["kind"];
type FormState = {
/**
* 内部稳定标识。编辑现有网盘时由后端数据填入;新建时不展示给用户,
* 保存前根据名称和类型自动生成。
*/
id: string;
kind: Kind;
name: string;
@@ -50,7 +55,7 @@ type FormState = {
const emptyForm: FormState = {
id: "",
kind: "quark",
kind: "p115",
name: "",
rootId: "0",
scanRootId: "0",
@@ -143,17 +148,22 @@ export function DrivesPage() {
}
async function handleSave() {
if (!form.id || !form.kind) {
show("请填 ID 和类型", "error");
const name = form.name.trim();
if (!name || !form.kind) {
show("请填名称和类型", "error");
return;
}
const existing = list.find((x) => x.id === form.id);
const driveID = existing
? form.id
: makeUniqueDriveId(form.kind, name, list);
// 若编辑且没有提供凭证,提示一下但仍允许保存(不改凭证)
setSaving(true);
try {
const resp = await api.upsertDrive({
id: form.id,
id: driveID,
kind: form.kind,
name: form.name || form.id,
name,
rootId: form.rootId || defaultRootId(form.kind),
scanRootId: form.scanRootId || form.rootId || defaultRootId(form.kind),
credentials: form.creds,
@@ -227,7 +237,7 @@ export function DrivesPage() {
async function handleRunNightly() {
try {
await api.runNightlyJob();
show("已触发完整流水线(扫盘 → 91 爬虫 → 迁移),耗时较长,可在 backend 日志观察进度", "success");
show("已触发扫描所有网盘,耗时较长,可在 backend 日志观察进度", "success");
} catch (e) {
show(e instanceof Error ? e.message : "触发失败", "error");
}
@@ -388,7 +398,7 @@ export function DrivesPage() {
)}
</button>
<button className="admin-btn" onClick={() => openEdit(d)}>
{d.kind === "spider91" ? "编辑配置" : "编辑配置凭证"}
</button>
<button className="admin-btn is-danger" onClick={() => {
handleDelete(d);
@@ -560,9 +570,9 @@ export function DrivesPage() {
type="button"
className="admin-btn"
onClick={handleRunNightly}
title="立即跑一次完整流水线:扫所有云盘 → 91 爬虫 → spider91 视频迁移到云盘。耗时较长,期间不要重复触发。"
title="立即扫描所有网盘。耗时较长,期间不要重复触发。"
>
<PlayCircle size={14} />
<PlayCircle size={14} />
</button>
<button className="admin-btn is-primary" onClick={openCreate}>
<Plus size={14} />
@@ -826,6 +836,7 @@ function DriveForm({
uploadTargets: api.AdminDrive[];
}) {
const fields = useMemo(() => credentialFields(form.kind), [form.kind]);
const help = credentialHelp(form.kind, isEdit);
function set<K extends keyof FormState>(k: K, v: FormState[K]) {
onChange({ ...form, [k]: v });
@@ -846,17 +857,7 @@ function DriveForm({
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>
<label> *</label>
<input
value={form.name}
onChange={(e) => set("name", e.target.value)}
@@ -870,12 +871,12 @@ function DriveForm({
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="spider91">91 </option>
<option value="quark"></option>
<option value="wopan"></option>
<option value="onedrive">OneDrive</option>
<option value="spider91">91 </option>
</select>
</div>
{form.kind !== "spider91" && (
@@ -902,31 +903,37 @@ function DriveForm({
</>
)}
<hr className="admin-form__divider" />
{(help || fields.length > 0) && (
<>
<hr className="admin-form__divider" />
<div className="admin-form__help admin-form__help--lead">
{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}
/>
{help && (
<div className="admin-form__help admin-form__help--lead">
{help}
</div>
)}
{f.help && <div className="admin-form__help">{f.help}</div>}
</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>
))}
</>
)}
{form.kind === "spider91" && (
<>
@@ -988,8 +995,7 @@ function Spider91UploadTargetField({
<option value=""> {presentLabel}</option>
</select>
<div className="admin-form__help">
spider91 15
{presentLabel} drive spider91
{presentLabel} drive 91
</div>
</>
) : (
@@ -1003,8 +1009,7 @@ function Spider91UploadTargetField({
))}
</select>
<div className="admin-form__help">
spider91
{uploadTargets.length > 1
? `如果同时挂着多个 ${presentLabel} drive"自动"模式不会工作,必须显式选定一个。`
: `当前只挂着 1 个 ${presentLabel},"自动"模式会直接选用它。`}
@@ -1029,7 +1034,7 @@ function credentialHelp(kind: Kind, isEdit: boolean): string {
case "onedrive":
return `按 OpenList 默认方式,通过 api.oplist.org 在线刷新 token。只需要 refresh_token;保存后会自动回写新的 access_token / refresh_token。${note}`;
case "spider91":
return `91 爬虫源:每天凌晨自动跑 91VideoSpider/spider_91porn.py,从本月最热第 1 页起翻页,遇到已爬过的 viewkey 自动跳过,凑够 target_new(默认 15)个新视频后停止。需要服务器装好 python3 + requests + beautifulsoup4 + lxml。${note}`;
return "91 爬虫会把定时抓取到的视频和封面先保存到本机,并作为一个视频来源接入站点;它不是外部网盘,不需要填写 Cookie 或目录 ID。后续流水线会把较早的视频上传到你选择的 115 / PikPak 目标盘。";
default:
return "";
}
@@ -1166,38 +1171,7 @@ function credentialFields(kind: Kind): Array<{
},
];
case "spider91":
return [
{
key: "target_new",
label: "每次爬取的新视频数",
placeholder: "15",
help: "默认 15。从 91porn 本月最热第 1 页起翻页,遇到已爬过的 viewkey 自动跳过,凑够这么多个新视频后停止。",
},
{
key: "crawl_hour",
label: "凌晨触发的小时(0-23",
placeholder: "0",
help: "默认 0,即在 00:00-00:59 之间触发。距离上次成功爬取至少 12 小时才会再触发。",
},
{
key: "proxy",
label: "下载代理(可选)",
placeholder: "留空则使用 HTTPS_PROXY 环境变量",
help: "91porn CDN 在海外,国内服务器直连通常很慢。可填 http://127.0.0.1:7890 这样的本地代理;留空则自动读 backend 进程的 HTTPS_PROXY 环境变量。",
},
{
key: "python_path",
label: "python 可执行文件",
placeholder: "python3",
help: "默认 python3;可填绝对路径,例如 /usr/bin/python3 或 conda 环境路径。",
},
{
key: "script_path",
label: "spider_91porn.py 路径(可选)",
placeholder: "留空自动定位 91VideoSpider/spider_91porn.py",
help: "服务启动时会从 backend/ 的父目录推断。如果脚本被你挪到了别处,请填绝对路径。",
},
];
return [];
}
}
+62 -9
View File
@@ -1,20 +1,40 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { Navigate, useLocation, useNavigate } from "react-router-dom";
import { Play } from "lucide-react";
import { useAuth } from "./AuthContext";
import { useToast } from "./ToastContext";
import * as api from "./api";
export function LoginPage() {
const { status, login } = useAuth();
const { status, login, refresh } = useAuth();
const [u, setU] = useState("");
const [p, setP] = useState("");
const [p2, setP2] = useState("");
const [setupRequired, setSetupRequired] = useState<boolean | null>(null);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const { show } = useToast();
if (status === "loading") {
useEffect(() => {
let active = true;
api.setupStatus()
.then((res) => {
if (active) setSetupRequired(res.required);
})
.catch((e) => {
if (active) {
setSetupRequired(false);
setErr(e instanceof Error ? e.message : "初始化状态检查失败");
}
});
return () => {
active = false;
};
}, []);
if (status === "loading" || setupRequired === null) {
return (
<div className="admin-loading-screen">
...
@@ -31,10 +51,21 @@ export function LoginPage() {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setErr(null);
if (setupRequired && p !== p2) {
setErr("两次输入的密码不一致");
return;
}
setLoading(true);
try {
await login(u, p);
show("登录成功", "success");
if (setupRequired) {
await api.setupAdmin(u, p);
await refresh();
setSetupRequired(false);
show("管理员账号已设置", "success");
} else {
await login(u, p);
show("登录成功", "success");
}
const from = (location.state as { from?: string } | null)?.from ?? "/";
navigate(from, { replace: true });
} catch (e) {
@@ -48,9 +79,14 @@ export function LoginPage() {
<div className="admin-login">
<form className="admin-login__card" onSubmit={handleSubmit}>
<h1 className="admin-login__title">
<Play size={18} fill="currentColor" />
<Play size={18} fill="currentColor" /> {setupRequired ? "首次设置管理员" : "登录"}
</h1>
<div className="admin-form">
{setupRequired && (
<div className="admin-form__help admin-form__help--lead">
使
</div>
)}
<div className="admin-form__row">
<label></label>
<input
@@ -66,15 +102,32 @@ export function LoginPage() {
type="password"
value={p}
onChange={(e) => setP(e.target.value)}
autoComplete="current-password"
autoComplete={setupRequired ? "new-password" : "current-password"}
/>
</div>
{setupRequired && (
<div className="admin-form__row">
<label></label>
<input
type="password"
value={p2}
onChange={(e) => setP2(e.target.value)}
autoComplete="new-password"
/>
</div>
)}
<button
className="admin-btn is-primary"
type="submit"
disabled={loading || !u || !p}
disabled={loading || !u || !p || (setupRequired && !p2)}
>
{loading ? "登录中..." : "登录"}
{loading
? setupRequired
? "保存中..."
: "登录中..."
: setupRequired
? "保存并进入"
: "登录"}
</button>
{err && <div className="admin-login__error">{err}</div>}
</div>
+11
View File
@@ -42,6 +42,17 @@ export function login(username: string, password: string) {
});
}
export function setupStatus() {
return request<{ required: boolean }>("/setup");
}
export function setupAdmin(username: string, password: string) {
return request<{ ok: boolean }>("/setup", {
method: "POST",
body: JSON.stringify({ username, password }),
});
}
export function logout() {
return request<{ ok: boolean }>("/logout", { method: "POST" });
}
+30
View File
@@ -0,0 +1,30 @@
import type { AdminDrive } from "./api";
type DriveKind = AdminDrive["kind"];
type ExistingDrive = Pick<AdminDrive, "id"> | string;
function normalizeName(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function existingDriveId(drive: ExistingDrive): string {
return typeof drive === "string" ? drive : drive.id;
}
export function makeUniqueDriveId(
kind: DriveKind,
name: string,
existingDrives: ExistingDrive[]
): string {
const used = new Set(existingDrives.map(existingDriveId));
const base = normalizeName(name) || kind;
let candidate = base;
for (let suffix = 2; used.has(candidate); suffix += 1) {
candidate = `${base}-${suffix}`;
}
return candidate;
}
+15
View File
@@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const drivesPageSource = readFileSync(
new URL("../src/admin/DrivesPage.tsx", import.meta.url),
"utf8"
);
test("spider91 drive form does not expose advanced crawler credentials", () => {
assert.doesNotMatch(drivesPageSource, /target_new/);
assert.doesNotMatch(drivesPageSource, /crawl_hour/);
assert.doesNotMatch(drivesPageSource, /python_path/);
assert.doesNotMatch(drivesPageSource, /script_path/);
});
+16
View File
@@ -0,0 +1,16 @@
import assert from "node:assert/strict";
import test from "node:test";
import { makeUniqueDriveId } from "../src/admin/driveId.ts";
test("generates readable drive ids from latin names", () => {
assert.equal(makeUniqueDriveId("pikpak", "My PikPak", []), "my-pikpak");
});
test("falls back to drive kind when the name has no ascii id parts", () => {
assert.equal(makeUniqueDriveId("p115", "主盘", []), "p115");
});
test("adds a suffix when the generated drive id already exists", () => {
assert.equal(makeUniqueDriveId("p115", "115 主盘", ["115", "115-2"]), "115-3");
});