mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 00:44:30 +08:00
feat: improve admin setup and drive management
This commit is contained in:
@@ -1,457 +1,109 @@
|
||||
# 视频聚合站
|
||||
|
||||
把夸克 / 115 / PikPak / 联通沃盘 / OneDrive 作为存储后端的视频聚合前台。按 `video-site-implementation-plan.md` 的设计实现。
|
||||
把散落在不同网盘里的视频,整理成一个可以自己登录、自己浏览、自己管理的私人视频站。
|
||||
|
||||
- 前端:React 18 + Vite + TypeScript
|
||||
- 后端:Go 1.23,SQLite(纯 Go 驱动,无 CGO),ffmpeg 生成 teaser 和封面
|
||||
- 网盘接入:夸克自研 + 115driver SDK + PikPak 自研(参考 OpenList)+ wopan-sdk-go SDK + OneDrive(OpenList 在线续期 + 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` 或手动启动。
|
||||
|
||||
#### 方式 A:systemd(生产 / 长跑)
|
||||
|
||||
仓库不直接提交 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 出故障时当应急后路。
|
||||
|
||||
#### 方式 B:start.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 爬虫脚本(Python,spider91 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 drive,spider91 爬下来的视频会按"**本地保留最近一次爬取的 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 在 02–07 点窗口扫一次 | 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
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,7 +2,8 @@
|
||||
server:
|
||||
# 本地开发默认配合 vite.config.ts 的代理端口。
|
||||
listen: "127.0.0.1:9192"
|
||||
# 管理后台用户,生产环境请务必修改
|
||||
# 管理后台用户。保留默认值时,首次访问登录页会要求设置新用户名和密码,
|
||||
# 并写回 backend/config.yaml。
|
||||
admin:
|
||||
username: "admin"
|
||||
password: "admin123"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 是每日触发整点(0–23);默认 1 表示 01:00。
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user