mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 08:45:41 +08:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46bb1fa9f2 | |||
| 7f4407ac28 | |||
| 79b5b5c37d | |||
| f3180f7b3c | |||
| 353b01b8e7 | |||
| d27eae9c62 | |||
| 003efa301b | |||
| f72898f530 | |||
| 641d29e008 | |||
| fed46b51bb | |||
| 304559203c | |||
| 62ccd6a998 | |||
| 720a92af7a | |||
| e33384c786 | |||
| 2e7c761aaf | |||
| 0d6c3c6ac9 | |||
| 10426e5483 | |||
| 863abe2064 | |||
| 60d0640a01 |
@@ -15,6 +15,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
@@ -36,8 +38,11 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
gh release upload "$TAG" release/*.tar.gz --clobber
|
||||
else
|
||||
gh release create "$TAG" release/*.tar.gz --title "$TAG" --notes "Prebuilt Linux release packages."
|
||||
git tag -d "$TAG" >/dev/null 2>&1 || true
|
||||
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
|
||||
NOTES="$(git tag -l "$TAG" --format='%(contents)')"
|
||||
if [ -z "$NOTES" ]; then
|
||||
NOTES="Prebuilt Linux release packages."
|
||||
fi
|
||||
gh release delete "$TAG" --yes >/dev/null 2>&1 || true
|
||||
gh release create "$TAG" release/*.tar.gz --title "$TAG" --notes "$NOTES" --verify-tag
|
||||
|
||||
@@ -1,79 +1,60 @@
|
||||
# 视频聚合站
|
||||
# 91
|
||||
|
||||
把散落在不同网盘里的视频,整理成一个可以自己登录、自己浏览、自己管理的私人视频站。
|
||||
<p align="center">
|
||||
<img width="120" height="120" alt="91" src="https://github.com/user-attachments/assets/5b323c94-bbd3-4dce-bbc8-adc86935b7de" />
|
||||
</p>
|
||||
|
||||
网盘适合存东西,却不适合慢慢看东西。文件多了以后,你很难记住它们在哪里、叫什么、有没有看过、还能不能快速预览。这个项目做的是中间那一层:文件仍然留在原来的网盘里,但你可以用一个更像视频站的界面去搜索、筛选、预览和管理它们。
|
||||
<p align="center">
|
||||
😄个人 91 站😄
|
||||
</p>
|
||||
|
||||
它不是另一个网盘客户端,也不是内容平台。它更像是给你自己的视频收藏做一个入口:安静、集中、可控。
|
||||
---
|
||||
|
||||
## 它能做什么
|
||||
## 项目说明
|
||||
|
||||
- **统一入口**:把 115、PikPak、夸克、联通沃盘、OneDrive、本地上传和可选的 91 爬虫源放在同一个站里浏览。
|
||||
- **像视频站一样浏览**:首页推荐、最新视频、列表页、搜索、标签筛选、详情播放和相关推荐都已经接好。
|
||||
- **自动生成预览**:后端会用 ffmpeg 在本地生成封面和短 teaser,扫到新视频后不用一条条手动整理。
|
||||
- **保留网盘本身**:视频文件不需要搬家,播放时由后端按来源取链和代理。
|
||||
- **后台可管理**:在管理后台添加网盘、扫描所有网盘、编辑视频信息、维护标签、切换主题。
|
||||
- **首次部署更直接**:第一次访问时会要求设置管理员用户名和密码,设置后保存到本地配置文件。
|
||||
- **适合长期运行**:扫描、预览、隐藏视频、标签归类这些重复工作,都尽量交给系统处理。
|
||||
支持 115 云盘、PikPak 云盘作为视频播放后端。
|
||||
|
||||
## 适合谁
|
||||
采用 115 云盘和 PikPak 云盘的 302 重定向播放,不占用服务器带宽,也不会因为服务器带宽小而影响视频播放体验。
|
||||
|
||||
如果你有一批视频散落在多个网盘里,想把它们整理成一个自己的私有站点,这个项目会比较合适。
|
||||
服务器只负责扫描云盘中的视频文件,并给每个视频生成封面图和预览片段。
|
||||
|
||||
如果你只是想临时播放单个文件,直接用网盘客户端更简单;如果你想做公开视频网站,这个项目也不是为那个场景设计的。它的重点是个人部署、个人管理、个人观看。
|
||||
你可以通过封面图和预览片段,在首页快速选择想看的视频。
|
||||
|
||||
## 支持的来源
|
||||
支持 91 爬虫,爬取 91 的本月最热视频。
|
||||
|
||||
- 115 网盘
|
||||
- PikPak
|
||||
- 91 爬虫源
|
||||
- 夸克网盘
|
||||
- 联通沃盘
|
||||
- OneDrive
|
||||
- 本地上传
|
||||
内置两种主题:黑黄主题(91 经典主题)和粉白主题。
|
||||
|
||||
91 爬虫源是一种特殊存储来源,用来把爬虫抓到的视频和封面接入站内目录。它不是必须项;如果你只想管理自己的网盘,可以完全不启用。
|
||||
支持短视频模式,一键切换成熟悉的抖音模式。
|
||||
|
||||
该项目2C2G服务器稳定跑👍👍👍
|
||||
|
||||
---
|
||||
|
||||
## 预览图
|
||||
|
||||
### 电脑端
|
||||
|
||||
<p>
|
||||
<img width="49%" alt="91 电脑端首页" src="https://github.com/user-attachments/assets/9808fceb-760b-4dd5-b7d2-8622b95b90d5" />
|
||||
<img width="49%" alt="91 电脑端播放页" src="https://github.com/user-attachments/assets/859db4aa-1fba-44f2-bb46-1db07c2f964f" />
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<img width="49%" alt="91 电脑端主题" src="https://github.com/user-attachments/assets/96bea37a-8764-413e-9b70-1856b4ae0cd2" />
|
||||
<img width="49%" alt="91 电脑端管理页" src="https://github.com/user-attachments/assets/29c1e27a-7651-4dfc-93dd-556331844214" />
|
||||
</p>
|
||||
|
||||
### 手机端
|
||||
|
||||
<p align="center">
|
||||
<img width="1284" height="1134" alt="PixPin_2026-05-29_11-54-12" src="https://github.com/user-attachments/assets/bdb7a86c-a4e5-483e-a307-e02c0bb34dac" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
需要先准备:
|
||||
|
||||
- Node.js 18+
|
||||
- Go 1.23+
|
||||
- ffmpeg 和 ffprobe
|
||||
|
||||
启动项目:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
./start.sh
|
||||
```
|
||||
|
||||
默认访问地址:
|
||||
|
||||
- 前台:`http://127.0.0.1:9191/`
|
||||
- 后台:`http://127.0.0.1:9191/admin`
|
||||
- 后端:`127.0.0.1:9192`
|
||||
|
||||
第一次打开时,如果还没有设置管理员账号,页面会引导你创建用户名和密码。保存后会写入本地的 `backend/config.yaml`。
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
./start.sh --status
|
||||
./start.sh --restart
|
||||
./start.sh --stop
|
||||
```
|
||||
|
||||
需要前端热更新时:
|
||||
|
||||
```bash
|
||||
FRONTEND_MODE=dev ./start.sh --restart
|
||||
```
|
||||
|
||||
## 新服务器一键安装
|
||||
|
||||
如果你只是想在一台 Ubuntu / Debian 服务器上尽快跑起来,推荐使用预编译安装脚本。普通用户不需要安装 Go、Node.js,也不需要自己编译;脚本会按服务器 CPU 架构下载 GitHub Release 里的预编译包,安装运行依赖,写入 systemd 服务并启动。
|
||||
一键安装:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
@@ -87,17 +68,7 @@ sudo bash install.sh
|
||||
- 前台:`http://服务器IP:9191/`
|
||||
- 后台:`http://服务器IP:9191/admin`
|
||||
|
||||
第一次打开后台会要求设置管理员用户名和密码。常用维护命令:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh status
|
||||
sudo bash install.sh logs
|
||||
sudo bash install.sh update
|
||||
sudo bash install.sh restart
|
||||
sudo bash install.sh stop
|
||||
```
|
||||
|
||||
安装后会自动创建 `91` 指令,和 OpenList 的管理指令类似:
|
||||
安装后会自动创建 `91` 指令:
|
||||
|
||||
```bash
|
||||
91 # 打开管理菜单
|
||||
@@ -110,62 +81,54 @@ sudo bash install.sh stop
|
||||
|
||||
同时也保留 `video-site-91` 作为同等别名。
|
||||
|
||||
**旧版本用户升级说明:**
|
||||
|
||||
如果你是在 `v0.0.2` 之前部署的项目,系统里可能还保留旧的 `91` 管理脚本。旧脚本直接运行 `91 update` 可能更新失败。先执行下面的一次性修复命令,后续再使用 `91 update` 即可:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/nianzhibai/91/main/install.sh -o /tmp/install-91.sh
|
||||
sudo bash /tmp/install-91.sh update
|
||||
```
|
||||
|
||||
想换端口:
|
||||
|
||||
```bash
|
||||
FRONTEND_PORT=8080 sudo -E bash install.sh
|
||||
```
|
||||
|
||||
如果服务器还有云厂商安全组,请记得放行对应端口,默认是 `9191/tcp`。
|
||||
---
|
||||
|
||||
如果你是项目维护者,要预先编译发布包:
|
||||
|
||||
```bash
|
||||
scripts/build-release.sh
|
||||
```
|
||||
|
||||
它会生成:
|
||||
|
||||
- `release/video-site-91-linux-amd64.tar.gz`
|
||||
- `release/video-site-91-linux-arm64.tar.gz`
|
||||
|
||||
把这两个文件上传到 GitHub Release 后,`install.sh` 就能自动下载。仓库也带了 GitHub Actions:推送 `v*` 标签时会自动构建并上传这两个 Release 包。
|
||||
|
||||
源码部署仍然保留在 `deploy.sh`,适合你想在服务器上直接 clone、编译和调试时使用。
|
||||
|
||||
## 第一次使用
|
||||
|
||||
1. 打开 `http://127.0.0.1:9191/`,先完成管理员账号设置。
|
||||
2. 进入 `/admin`,在网盘管理里新建一个来源。
|
||||
3. 填入名称和对应凭证,保存。
|
||||
4. 点击“扫描所有网盘”,等待视频入库。
|
||||
5. 回到前台,用首页、搜索、标签和详情页浏览内容。
|
||||
|
||||
## 数据放在哪里
|
||||
## 数据存放位置
|
||||
|
||||
项目会把运行数据保存在本地:
|
||||
|
||||
- `backend/config.yaml`:本地配置、管理员账号、网盘凭证。
|
||||
- `backend/data/video-site.db`:SQLite 数据库。
|
||||
- `backend/data/previews/`:本地生成的封面和 teaser。
|
||||
- `/opt/video-site-91/config.yaml`:本地配置、管理员账号、网盘凭证。
|
||||
- `/opt/video-site-91/data/video-site.db`:SQLite 数据库。
|
||||
- `/opt/video-site-91/data/previews/`:本地生成的封面和 teaser。
|
||||
|
||||
这些文件不应该提交到公开仓库。仓库里的 `backend/config.example.yaml` 只是模板,不应该放真实账号、Cookie、Token 或密码。
|
||||
---
|
||||
|
||||
## 更多文档
|
||||
## 了解更多
|
||||
|
||||
根目录 README 只保留项目介绍和最短上手路径。更细的实现、接口、网盘字段和部署方式可以看:
|
||||
|
||||
- [backend/README.md](backend/README.md)
|
||||
- [video-site-implementation-plan.md](video-site-implementation-plan.md)
|
||||
|
||||
## 开发验证
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm test
|
||||
cd backend && go test ./... -count=1
|
||||
```
|
||||
---
|
||||
|
||||
## 使用边界
|
||||
|
||||
这个项目面向个人私有部署。请只接入你有权访问和管理的内容,并遵守对应网盘、站点服务条款以及所在地法律法规。
|
||||
|
||||
不要传播,仅限个人使用,个人视频站。
|
||||
|
||||
---
|
||||
|
||||
## 致谢
|
||||
|
||||
感谢开源项目 OpenList。
|
||||
|
||||
感谢 <a href="https://linux.do/">LinuxDo</a> 社区,学 AI 上 L 站。
|
||||
|
||||
感谢 <a href="https://nodeseek.com/">NodeSeek</a> 社区,MJJ 上 N 站。
|
||||
|
||||
+11
-29
@@ -271,8 +271,8 @@ type App struct {
|
||||
|
||||
// 全站主题("dark" | "pink"),从 DB 读
|
||||
theme string
|
||||
// 显式指定的 spider91 上传目标 drive ID;
|
||||
// 未设置时由 Spider91UploadDriveID() 在所有 pikpak/p115 drive 中自动挑选唯一一个。
|
||||
// 显式指定的 spider91 上传目标 drive ID。
|
||||
// 空字符串表示本地保存不上传,不再自动挑选 pikpak/p115 drive。
|
||||
spider91UploadDriveID string
|
||||
|
||||
// spider91Migrator 周期把 spider91 视频上传到目标 drive(PikPak 或 115)。
|
||||
@@ -360,42 +360,24 @@ func (a *App) loadTheme(ctx context.Context) {
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
// Spider91UploadDriveID 返回当前生效的 spider91 上传目标 drive ID。
|
||||
//
|
||||
// 解析顺序:
|
||||
// 1. 管理员通过 PUT /admin/api/settings 显式设置过 → 验证该 drive 仍存在且是
|
||||
// 合法目标盘(pikpak 或 p115)→ 返回该 ID。
|
||||
// 2. 否则系统中如果只有一个合法目标盘(即 pikpak drive 数量+p115 drive 数量==1),
|
||||
// 自动返回它。这样单网盘场景"开箱即用"。
|
||||
// 3. 多个候选并存时返回空串:迁移 worker 静默跳过,等管理员显式指定。
|
||||
//
|
||||
// 注意"合法目标盘"目前是 pikpak ∪ p115。后续添加新的可上传盘要在两个分支同步加。
|
||||
// Spider91UploadDriveID 返回当前配置的 spider91 上传目标 drive ID。
|
||||
// 空字符串表示本地保存不上传;只有管理员显式选择 pikpak/p115 drive 时才迁移上传。
|
||||
func (a *App) Spider91UploadDriveID() string {
|
||||
a.mu.Lock()
|
||||
explicit := a.spider91UploadDriveID
|
||||
a.mu.Unlock()
|
||||
if explicit != "" {
|
||||
// 验证显式设置的 drive 仍然存在且 kind 合法;不在则降级到自动选取
|
||||
if d, ok := a.registry.Get(explicit); ok && isSpider91UploadKind(d.Kind()) {
|
||||
return explicit
|
||||
}
|
||||
if explicit == "" {
|
||||
return ""
|
||||
}
|
||||
var found string
|
||||
for _, d := range a.registry.All() {
|
||||
if !isSpider91UploadKind(d.Kind()) {
|
||||
continue
|
||||
}
|
||||
if found != "" {
|
||||
// 多个候选 drive 时不自动选;管理员必须显式指定
|
||||
return ""
|
||||
}
|
||||
found = d.ID()
|
||||
// 验证显式设置的 drive 仍然存在且 kind 合法;不在则视为未配置。
|
||||
if d, ok := a.registry.Get(explicit); ok && isSpider91UploadKind(d.Kind()) {
|
||||
return explicit
|
||||
}
|
||||
return found
|
||||
return ""
|
||||
}
|
||||
|
||||
// SetSpider91UploadDriveID 设置 spider91 上传目标 drive ID 并持久化。
|
||||
// 接受空字符串(清除显式设置,回退到自动模式)。
|
||||
// 接受空字符串(本地保存不上传)。
|
||||
// 设置一个不存在或 kind 不是 pikpak / p115 的 drive 会返回错误。
|
||||
func (a *App) SetSpider91UploadDriveID(ctx context.Context, driveID string) error {
|
||||
driveID = strings.TrimSpace(driveID)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/video-site/backend/internal/catalog"
|
||||
"github.com/video-site/backend/internal/drives"
|
||||
"github.com/video-site/backend/internal/proxy"
|
||||
)
|
||||
|
||||
func TestSpider91IntCredFallbacks(t *testing.T) {
|
||||
@@ -30,3 +34,50 @@ func TestSpider91IntCredFallbacks(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpider91UploadDriveIDDoesNotAutoSelectTarget(t *testing.T) {
|
||||
reg := proxy.NewRegistry()
|
||||
reg.Set("p115-one", &spider91UploadTargetFakeDrive{id: "p115-one", kind: "p115"})
|
||||
|
||||
app := &App{registry: reg}
|
||||
if got := app.Spider91UploadDriveID(); got != "" {
|
||||
t.Fatalf("empty upload target selected %q, want local-only empty target", got)
|
||||
}
|
||||
|
||||
app.spider91UploadDriveID = "p115-one"
|
||||
if got := app.Spider91UploadDriveID(); got != "p115-one" {
|
||||
t.Fatalf("explicit upload target = %q, want p115-one", got)
|
||||
}
|
||||
|
||||
app.spider91UploadDriveID = "missing"
|
||||
if got := app.Spider91UploadDriveID(); got != "" {
|
||||
t.Fatalf("missing upload target = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
type spider91UploadTargetFakeDrive struct {
|
||||
id string
|
||||
kind string
|
||||
}
|
||||
|
||||
func (d *spider91UploadTargetFakeDrive) Kind() string { return d.kind }
|
||||
func (d *spider91UploadTargetFakeDrive) ID() string { return d.id }
|
||||
func (d *spider91UploadTargetFakeDrive) Init(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
func (d *spider91UploadTargetFakeDrive) List(context.Context, string) ([]drives.Entry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (d *spider91UploadTargetFakeDrive) Stat(context.Context, string) (*drives.Entry, error) {
|
||||
return nil, drives.ErrNotSupported
|
||||
}
|
||||
func (d *spider91UploadTargetFakeDrive) StreamURL(context.Context, string) (*drives.StreamLink, error) {
|
||||
return nil, drives.ErrNotSupported
|
||||
}
|
||||
func (d *spider91UploadTargetFakeDrive) Upload(context.Context, string, string, io.Reader, int64) (string, error) {
|
||||
return "", drives.ErrNotSupported
|
||||
}
|
||||
func (d *spider91UploadTargetFakeDrive) EnsureDir(context.Context, string) (string, error) {
|
||||
return "", drives.ErrNotSupported
|
||||
}
|
||||
func (d *spider91UploadTargetFakeDrive) RootID() string { return "root" }
|
||||
|
||||
@@ -40,7 +40,7 @@ type AdminServer struct {
|
||||
// Theme 读写("dark" | "pink")
|
||||
GetTheme func() string
|
||||
SetTheme func(theme string) error
|
||||
// Spider91 → PikPak 上传目标 drive ID 读写
|
||||
// Spider91 → 115/PikPak 上传目标 drive ID 读写
|
||||
GetSpider91UploadDriveID func() string
|
||||
SetSpider91UploadDriveID func(driveID string) error
|
||||
// OnRunNightlyJob 触发一次完整的凌晨流水线(Phase1 扫盘 + Phase2 91 爬虫 +
|
||||
@@ -748,7 +748,7 @@ func (a *AdminServer) handleGetSettings(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (a *AdminServer) handlePutSettings(w http.ResponseWriter, r *http.Request) {
|
||||
// 用 map 区分"没传"和"传了空字符串"两种语义;空 spider91 上传 ID 表示
|
||||
// 清除显式设置(回退到自动模式)。
|
||||
// 本地保存不上传。
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
|
||||
@@ -155,26 +155,67 @@ func (s *Server) handleGetTheme(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
// 拉一批候选(按发布时间倒序,覆盖最近 200 个),然后随机洗牌取前 homePageSize 个。
|
||||
// 如果库内不足 200 个会自动按实际数量返回,最后裁剪到 homePageSize。
|
||||
// 首页优先展示封面已经生成好的视频,避免新盘扫盘时大量黑封面占满首页。
|
||||
// 候选仍按发布时间覆盖最近 200 个,随后随机洗牌;封面不足时再用普通可见视频补齐。
|
||||
const candidatePool = 200
|
||||
items, _, err := s.Catalog.ListVideos(r.Context(), catalog.ListParams{
|
||||
Sort: "latest", Page: 1, PageSize: candidatePool,
|
||||
readyItems, _, err := s.Catalog.ListVideos(r.Context(), catalog.ListParams{
|
||||
Sort: "latest", Page: 1, PageSize: candidatePool, ThumbnailReadyOnly: true,
|
||||
})
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
rand.Shuffle(len(items), func(i, j int) {
|
||||
items[i], items[j] = items[j], items[i]
|
||||
rand.Shuffle(len(readyItems), func(i, j int) {
|
||||
readyItems[i], readyItems[j] = readyItems[j], readyItems[i]
|
||||
})
|
||||
|
||||
items := appendUniqueVideos(nil, readyItems, homePageSize)
|
||||
if len(items) > homePageSize {
|
||||
items = items[:homePageSize]
|
||||
}
|
||||
if len(items) < homePageSize {
|
||||
fallback, _, err := s.Catalog.ListVideos(r.Context(), catalog.ListParams{
|
||||
Sort: "latest", Page: 1, PageSize: candidatePool,
|
||||
})
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
rand.Shuffle(len(fallback), func(i, j int) {
|
||||
fallback[i], fallback[j] = fallback[j], fallback[i]
|
||||
})
|
||||
items = appendUniqueVideos(items, fallback, homePageSize)
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusOK, mapVideos(items))
|
||||
}
|
||||
|
||||
func appendUniqueVideos(dst []*catalog.Video, candidates []*catalog.Video, limit int) []*catalog.Video {
|
||||
if len(dst) >= limit {
|
||||
return dst[:limit]
|
||||
}
|
||||
seen := make(map[string]struct{}, len(dst))
|
||||
for _, v := range dst {
|
||||
if v != nil {
|
||||
seen[v.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, v := range candidates {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[v.ID]; ok {
|
||||
continue
|
||||
}
|
||||
dst = append(dst, v)
|
||||
seen[v.ID] = struct{}{}
|
||||
if len(dst) >= limit {
|
||||
return dst
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
page, _ := strconv.Atoi(q.Get("page"))
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -98,6 +99,73 @@ func TestPreviewURLFallsBackWithoutUpdatedAt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleHomePrioritizesVideosWithReadyThumbnails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
|
||||
if err != nil {
|
||||
t.Fatalf("open catalog: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := cat.Close(); err != nil {
|
||||
t.Fatalf("close catalog: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
now := time.Now()
|
||||
for i := 0; i < 20; i++ {
|
||||
id := "pending-video-" + strconv.Itoa(i)
|
||||
if err := cat.UpsertVideo(ctx, &catalog.Video{
|
||||
ID: id,
|
||||
DriveID: "drive",
|
||||
FileID: id,
|
||||
Title: id,
|
||||
PublishedAt: now.Add(time.Duration(i) * time.Minute),
|
||||
CreatedAt: now.Add(time.Duration(i) * time.Minute),
|
||||
UpdatedAt: now.Add(time.Duration(i) * time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed pending video %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
for i := 0; i < homePageSize+2; i++ {
|
||||
id := "ready-video-" + strconv.Itoa(i)
|
||||
if err := cat.UpsertVideo(ctx, &catalog.Video{
|
||||
ID: id,
|
||||
DriveID: "drive",
|
||||
FileID: id,
|
||||
Title: id,
|
||||
ThumbnailURL: "https://thumb.example/" + id + ".jpg",
|
||||
PublishedAt: now.Add(-time.Duration(i+1) * time.Hour),
|
||||
CreatedAt: now.Add(-time.Duration(i+1) * time.Hour),
|
||||
UpdatedAt: now.Add(-time.Duration(i+1) * time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed ready video %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/home", nil)
|
||||
(&Server{Catalog: cat}).handleHome(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got []VideoDTO
|
||||
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(got) != homePageSize {
|
||||
t.Fatalf("home items = %d, want %d", len(got), homePageSize)
|
||||
}
|
||||
for _, item := range got {
|
||||
if !strings.HasPrefix(item.ID, "ready-video-") {
|
||||
t.Fatalf("home returned %q without a ready thumbnail; items=%#v", item.ID, got)
|
||||
}
|
||||
if !strings.HasPrefix(item.Thumbnail, "https://thumb.example/") {
|
||||
t.Fatalf("thumbnail for %q = %q, want ready thumbnail URL", item.ID, item.Thumbnail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUploadVideoSavesFileVideoTagsAndQueuesPreview(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
|
||||
|
||||
@@ -199,8 +199,8 @@ func (c *Catalog) MigrateVideoToDrive(ctx context.Context, videoID, newDriveID,
|
||||
}
|
||||
|
||||
// ListVideosByDriveID 列出指定 drive 下所有未隐藏的视频,按 published_at 倒序。
|
||||
// 给 spider91 → PikPak 迁移 worker 用:扫描 spider91 drive 下所有视频,
|
||||
// 检查哪些还有本地文件,依次上传到 PikPak。
|
||||
// 给 spider91 → 115/PikPak 迁移 worker 用:扫描 spider91 drive 下所有视频,
|
||||
// 检查哪些还有本地文件,依次上传到目标盘。
|
||||
func (c *Catalog) ListVideosByDriveID(ctx context.Context, driveID string, limit int) ([]*Video, error) {
|
||||
if driveID == "" {
|
||||
return nil, fmt.Errorf("catalog: list videos by drive: empty drive id")
|
||||
@@ -669,13 +669,14 @@ func (c *Catalog) FindVideoByFileSignature(ctx context.Context, fileName string,
|
||||
}
|
||||
|
||||
type ListParams struct {
|
||||
Keyword string
|
||||
DriveID string
|
||||
Tag string
|
||||
Category string
|
||||
Sort string // latest | hot | week | long
|
||||
Page int
|
||||
PageSize int
|
||||
Keyword string
|
||||
DriveID string
|
||||
Tag string
|
||||
Category string
|
||||
Sort string // latest | hot | week | long
|
||||
ThumbnailReadyOnly bool
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
func (c *Catalog) ListVideos(ctx context.Context, p ListParams) ([]*Video, int, error) {
|
||||
@@ -710,6 +711,9 @@ func (c *Catalog) ListVideos(ctx context.Context, p ListParams) ([]*Video, int,
|
||||
where = append(where, "category = ?")
|
||||
args = append(args, p.Category)
|
||||
}
|
||||
if p.ThumbnailReadyOnly {
|
||||
where = append(where, "COALESCE(thumbnail_url, '') != ''")
|
||||
}
|
||||
where = append(where, "COALESCE(hidden, 0) = 0")
|
||||
where = append(where, uniqueVideoWhereSQL)
|
||||
|
||||
|
||||
@@ -581,6 +581,59 @@ func TestListVideosHidesDuplicateContentHashes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListVideosCanFilterReadyThumbnails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cat, err := Open(t.TempDir() + "/catalog.db")
|
||||
if err != nil {
|
||||
t.Fatalf("open catalog: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := cat.Close(); err != nil {
|
||||
t.Fatalf("close catalog: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
now := time.Now()
|
||||
for _, v := range []*Video{
|
||||
{
|
||||
ID: "ready-video",
|
||||
DriveID: "drive",
|
||||
FileID: "file-ready",
|
||||
Title: "Ready",
|
||||
ThumbnailURL: "/p/thumb/ready-video",
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: "pending-video",
|
||||
DriveID: "drive",
|
||||
FileID: "file-pending",
|
||||
Title: "Pending",
|
||||
PublishedAt: now.Add(time.Second),
|
||||
CreatedAt: now.Add(time.Second),
|
||||
UpdatedAt: now.Add(time.Second),
|
||||
},
|
||||
} {
|
||||
if err := cat.UpsertVideo(ctx, v); err != nil {
|
||||
t.Fatalf("seed video %s: %v", v.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
items, total, err := cat.ListVideos(ctx, ListParams{
|
||||
Page: 1, PageSize: 10, ThumbnailReadyOnly: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list videos: %v", err)
|
||||
}
|
||||
if total != 1 || len(items) != 1 {
|
||||
t.Fatalf("ready videos total=%d len=%d, want 1", total, len(items))
|
||||
}
|
||||
if items[0].ID != "ready-video" {
|
||||
t.Fatalf("ready video id = %q, want ready-video", items[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func sameStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
|
||||
@@ -199,9 +199,8 @@ func (d *Driver) refreshCaptchaToken(ctx context.Context, action string, meta ma
|
||||
|
||||
// refreshCaptchaTokenOnce 调 /v1/shield/captcha/init 申请新 captcha token。
|
||||
//
|
||||
// 如果 retry=true 且服务端返回 4002(captcha_token expired,意味着 body 里
|
||||
// 携带的 d.captchaToken 已经过期),就清空缓存的 captcha_token 后再调一次;
|
||||
// 这次 body 里 captcha_token 为空,服务端会直接发一个新的。这覆盖
|
||||
// 如果 retry=true 且服务端返回 captcha 失效错误(4002 或 9),就清空缓存的
|
||||
// captcha_token 后再调一次;这次 body 里 captcha_token 为空,服务端会直接发一个新的。这覆盖
|
||||
// driver 重启后 Init() 用持久化的旧 captcha_token 调 captcha init 失败的
|
||||
// 场景。
|
||||
func (d *Driver) refreshCaptchaTokenOnce(ctx context.Context, action string, meta map[string]string, retry bool) error {
|
||||
@@ -230,7 +229,7 @@ func (d *Driver) refreshCaptchaTokenOnce(ctx context.Context, action string, met
|
||||
return err
|
||||
}
|
||||
if e.isError() {
|
||||
if retry && e.ErrorCode == 4002 && d.captchaToken != "" {
|
||||
if retry && isCaptchaTokenRejectedCode(e.ErrorCode) && d.captchaToken != "" {
|
||||
d.captchaToken = ""
|
||||
return d.refreshCaptchaTokenOnce(ctx, action, meta, false)
|
||||
}
|
||||
|
||||
@@ -96,6 +96,65 @@ func TestRefreshCaptchaTokenRecoversFrom4002(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshCaptchaTokenRecoversFrom9 覆盖 PikPak 返回 error_code=9
|
||||
// captcha_invalid 的路径。这个错误和 4002 一样表示当前 captcha_token 已被拒绝;
|
||||
// 重试 captcha/init 前必须先清空旧 token,否则服务端会继续拒绝。
|
||||
func TestRefreshCaptchaTokenRecoversFrom9(t *testing.T) {
|
||||
var calls int32
|
||||
type bodyShape struct {
|
||||
CaptchaToken string `json:"captcha_token"`
|
||||
}
|
||||
var (
|
||||
firstBody bodyShape
|
||||
secondBody bodyShape
|
||||
)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1/shield/captcha/init", func(w http.ResponseWriter, r *http.Request) {
|
||||
n := atomic.AddInt32(&calls, 1)
|
||||
switch n {
|
||||
case 1:
|
||||
_ = json.NewDecoder(r.Body).Decode(&firstBody)
|
||||
writeErrorJSON(w, `{
|
||||
"error_code": 9,
|
||||
"error": "captcha_invalid",
|
||||
"error_description": "Verification code is invalid"
|
||||
}`)
|
||||
case 2:
|
||||
_ = json.NewDecoder(r.Body).Decode(&secondBody)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"captcha_token": "fresh-captcha",
|
||||
"expires_in": 300
|
||||
}`))
|
||||
default:
|
||||
t.Errorf("unexpected captcha init call #%d", n)
|
||||
}
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
d := newTestDriver(t, server)
|
||||
d.captchaToken = "expired-captcha"
|
||||
|
||||
if err := d.refreshCaptchaTokenAtLogin(context.Background(), "GET:/drive/v1/files", "user-1"); err != nil {
|
||||
t.Fatalf("refreshCaptchaTokenAtLogin: %v", err)
|
||||
}
|
||||
|
||||
if got := atomic.LoadInt32(&calls); got != 2 {
|
||||
t.Fatalf("captcha init called %d times, want 2", got)
|
||||
}
|
||||
if firstBody.CaptchaToken != "expired-captcha" {
|
||||
t.Errorf("first body captcha_token = %q, want \"expired-captcha\"", firstBody.CaptchaToken)
|
||||
}
|
||||
if secondBody.CaptchaToken != "" {
|
||||
t.Errorf("second body captcha_token = %q, want empty (cleared after error_code=9)", secondBody.CaptchaToken)
|
||||
}
|
||||
if d.captchaToken != "fresh-captcha" {
|
||||
t.Errorf("d.captchaToken = %q, want \"fresh-captcha\"", d.captchaToken)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshCaptchaTokenDoesNotLoopOn4002WithEmptyToken 防止退化成无限重试:
|
||||
// 如果调用方一开始 captchaToken 就是空,又遇上 4002,不应该再清空一次重试
|
||||
// (清空后还是空,再发会拿到同样的错误),应该直接返回错误让上层处理。
|
||||
@@ -121,6 +180,141 @@ func TestRefreshCaptchaTokenDoesNotLoopOn4002WithEmptyToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitWithRefreshTokenDoesNotSendPersistedCaptchaToken(t *testing.T) {
|
||||
var captchaCalls int32
|
||||
var captchaBody struct {
|
||||
CaptchaToken string `json:"captcha_token"`
|
||||
}
|
||||
var persisted struct {
|
||||
access, refresh, captcha string
|
||||
calls int
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1/auth/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"access_token": "fresh-access",
|
||||
"refresh_token": "fresh-refresh",
|
||||
"sub": "user-1"
|
||||
}`))
|
||||
})
|
||||
mux.HandleFunc("/v1/shield/captcha/init", func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&captchaCalls, 1)
|
||||
_ = json.NewDecoder(r.Body).Decode(&captchaBody)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"captcha_token": "fresh-captcha",
|
||||
"expires_in": 300
|
||||
}`))
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
d := newTestDriver(t, server)
|
||||
d.captchaToken = "persisted-stale-captcha"
|
||||
d.onTokenUpdate = func(access, refresh, captcha, deviceID string) {
|
||||
persisted.access = access
|
||||
persisted.refresh = refresh
|
||||
persisted.captcha = captcha
|
||||
persisted.calls++
|
||||
}
|
||||
|
||||
if err := d.Init(context.Background()); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
if got := atomic.LoadInt32(&captchaCalls); got != 1 {
|
||||
t.Fatalf("captcha init calls = %d, want 1", got)
|
||||
}
|
||||
if captchaBody.CaptchaToken != "" {
|
||||
t.Errorf("captcha init body captcha_token = %q, want empty", captchaBody.CaptchaToken)
|
||||
}
|
||||
if d.captchaToken != "fresh-captcha" {
|
||||
t.Errorf("d.captchaToken = %q, want \"fresh-captcha\"", d.captchaToken)
|
||||
}
|
||||
if persisted.access != "fresh-access" || persisted.refresh != "fresh-refresh" || persisted.captcha != "fresh-captcha" {
|
||||
t.Errorf("persisted tokens = (%q, %q, %q), want fresh values", persisted.access, persisted.refresh, persisted.captcha)
|
||||
}
|
||||
if persisted.calls < 2 {
|
||||
t.Errorf("persist callback calls = %d, want at least 2 (clear stale + persist fresh)", persisted.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitFallsBackToLoginWhenRefreshReturnsCaptchaInvalid(t *testing.T) {
|
||||
var (
|
||||
tokenCalls int32
|
||||
captchaCalls int32
|
||||
signinCalls int32
|
||||
)
|
||||
var signinBody struct {
|
||||
CaptchaToken string `json:"captcha_token"`
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1/auth/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&tokenCalls, 1)
|
||||
writeErrorJSON(w, `{
|
||||
"error_code": 4002,
|
||||
"error": "captcha_invalid",
|
||||
"error_description": "Code(4002) - captcha_token expired"
|
||||
}`)
|
||||
})
|
||||
mux.HandleFunc("/v1/shield/captcha/init", func(w http.ResponseWriter, r *http.Request) {
|
||||
n := atomic.AddInt32(&captchaCalls, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch n {
|
||||
case 1:
|
||||
_, _ = w.Write([]byte(`{
|
||||
"captcha_token": "login-captcha",
|
||||
"expires_in": 300
|
||||
}`))
|
||||
case 2:
|
||||
_, _ = w.Write([]byte(`{
|
||||
"captcha_token": "files-captcha",
|
||||
"expires_in": 300
|
||||
}`))
|
||||
default:
|
||||
t.Errorf("unexpected captcha init call #%d", n)
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/v1/auth/signin", func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&signinCalls, 1)
|
||||
_ = json.NewDecoder(r.Body).Decode(&signinBody)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"access_token": "login-access",
|
||||
"refresh_token": "login-refresh",
|
||||
"sub": "user-1"
|
||||
}`))
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
d := newTestDriver(t, server)
|
||||
d.captchaToken = "persisted-stale-captcha"
|
||||
|
||||
if err := d.Init(context.Background()); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
if got := atomic.LoadInt32(&tokenCalls); got != 1 {
|
||||
t.Fatalf("token refresh calls = %d, want 1", got)
|
||||
}
|
||||
if got := atomic.LoadInt32(&signinCalls); got != 1 {
|
||||
t.Fatalf("signin calls = %d, want 1", got)
|
||||
}
|
||||
if got := atomic.LoadInt32(&captchaCalls); got != 2 {
|
||||
t.Fatalf("captcha init calls = %d, want 2 (login + post-login files action)", got)
|
||||
}
|
||||
if signinBody.CaptchaToken != "login-captcha" {
|
||||
t.Errorf("signin captcha_token = %q, want \"login-captcha\"", signinBody.CaptchaToken)
|
||||
}
|
||||
if d.accessToken != "login-access" || d.refreshToken != "login-refresh" || d.captchaToken != "files-captcha" {
|
||||
t.Errorf("driver tokens = (%q, %q, %q), want login/files tokens", d.accessToken, d.refreshToken, d.captchaToken)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestOnceRecoversFrom4002OnAPICall 验证一个普通 API 调用收到 4002
|
||||
// 时,requestOnce 会先清空 captchaToken、再走 captcha 刷新,最后用新 token
|
||||
// 重试请求,最终成功返回。
|
||||
@@ -196,6 +390,76 @@ func TestRequestOnceRecoversFrom4002OnAPICall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestOnceRecoversFrom9OnAPICall 验证普通 API 调用收到 error_code=9
|
||||
// 时,会先清空旧 captchaToken,再刷新 captcha 并重试原请求。
|
||||
func TestRequestOnceRecoversFrom9OnAPICall(t *testing.T) {
|
||||
var (
|
||||
filesCalls int32
|
||||
captchaCalls int32
|
||||
)
|
||||
type capturedFiles struct {
|
||||
captchaHeader string
|
||||
}
|
||||
var firstFiles, secondFiles capturedFiles
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/drive/v1/files", func(w http.ResponseWriter, r *http.Request) {
|
||||
n := atomic.AddInt32(&filesCalls, 1)
|
||||
switch n {
|
||||
case 1:
|
||||
firstFiles.captchaHeader = r.Header.Get("X-Captcha-Token")
|
||||
writeErrorJSON(w, `{
|
||||
"error_code": 9,
|
||||
"error": "captcha_invalid",
|
||||
"error_description": "Verification code is invalid"
|
||||
}`)
|
||||
case 2:
|
||||
secondFiles.captchaHeader = r.Header.Get("X-Captcha-Token")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"files": [], "next_page_token": ""}`))
|
||||
default:
|
||||
t.Errorf("unexpected /drive/v1/files call #%d", n)
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/v1/shield/captcha/init", func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&captchaCalls, 1)
|
||||
var body struct {
|
||||
CaptchaToken string `json:"captcha_token"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body.CaptchaToken != "" {
|
||||
t.Errorf("captcha init body captcha_token = %q, want empty (error_code=9 path should clear cache)", body.CaptchaToken)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"captcha_token": "fresh-captcha", "expires_in": 300}`))
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
d := newTestDriver(t, server)
|
||||
d.captchaToken = "expired-captcha"
|
||||
|
||||
if _, err := d.List(context.Background(), "any-parent"); err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
|
||||
if got := atomic.LoadInt32(&filesCalls); got != 2 {
|
||||
t.Fatalf("/drive/v1/files calls = %d, want 2 (initial + retry)", got)
|
||||
}
|
||||
if got := atomic.LoadInt32(&captchaCalls); got != 1 {
|
||||
t.Fatalf("captcha init calls = %d, want 1", got)
|
||||
}
|
||||
if firstFiles.captchaHeader != "expired-captcha" {
|
||||
t.Errorf("first request X-Captcha-Token = %q, want \"expired-captcha\"", firstFiles.captchaHeader)
|
||||
}
|
||||
if secondFiles.captchaHeader != "fresh-captcha" {
|
||||
t.Errorf("retry X-Captcha-Token = %q, want \"fresh-captcha\"", secondFiles.captchaHeader)
|
||||
}
|
||||
if d.captchaToken != "fresh-captcha" {
|
||||
t.Errorf("d.captchaToken after recovery = %q, want \"fresh-captcha\"", d.captchaToken)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestOnceDoesNotRetryTwiceOn4002 验证 4002 恢复路径只重试一次;
|
||||
// 如果重试请求依然失败(哪怕是再来一个 4002),也不会再次进入恢复逻辑,
|
||||
// 而是把错误返回出去,避免无限循环。
|
||||
|
||||
@@ -121,9 +121,28 @@ func (d *Driver) ID() string { return d.id }
|
||||
func (d *Driver) RootID() string { return d.rootID }
|
||||
|
||||
func (d *Driver) Init(ctx context.Context) error {
|
||||
clearPersistedCaptcha := func() {
|
||||
if d.captchaToken == "" {
|
||||
return
|
||||
}
|
||||
d.captchaToken = ""
|
||||
d.persistTokens()
|
||||
}
|
||||
|
||||
if d.refreshToken != "" {
|
||||
if err := d.refresh(ctx, d.refreshToken); err != nil {
|
||||
return err
|
||||
if !IsCaptchaError(err) || d.username == "" || d.password == "" {
|
||||
return err
|
||||
}
|
||||
clearPersistedCaptcha()
|
||||
if err := d.login(ctx); err != nil {
|
||||
return fmt.Errorf("pikpak refresh captcha recovery login: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Persisted captcha tokens are short-lived. With a refresh token we can
|
||||
// safely request a fresh captcha token after auth, and avoiding the
|
||||
// stored value prevents known-stale tokens from poisoning startup.
|
||||
clearPersistedCaptcha()
|
||||
}
|
||||
} else {
|
||||
if err := d.login(ctx); err != nil {
|
||||
@@ -408,14 +427,15 @@ func (d *Driver) requestOnce(ctx context.Context, url, method string, configure
|
||||
// serialized. Once we hold the lock, if d.captchaToken has
|
||||
// already moved past staleToken, another goroutine has refreshed
|
||||
// it for us — we skip the refresh and just retry. Otherwise we
|
||||
// clear the cached token (4002 means "the value in the body is
|
||||
// expired"; sending it again will keep returning 4002) and ask
|
||||
// /v1/shield/captcha/init for a fresh one.
|
||||
// clear the cached token before asking /v1/shield/captcha/init
|
||||
// for a fresh one. PikPak may report stale captcha as either
|
||||
// 4002 or 9, and sending the rejected token into captcha init can
|
||||
// keep returning captcha_invalid.
|
||||
staleToken := d.captchaToken
|
||||
d.captchaMu.Lock()
|
||||
var refreshErr error
|
||||
if d.captchaToken == staleToken {
|
||||
if e.ErrorCode == 4002 {
|
||||
if d.captchaToken != "" {
|
||||
d.captchaToken = ""
|
||||
}
|
||||
refreshErr = d.refreshCaptchaTokenAtLogin(ctx, getAction(method, url), d.userID)
|
||||
|
||||
@@ -59,6 +59,10 @@ func (e *errResp) Error() string {
|
||||
return fmt.Sprintf("pikpak error_code=%d error=%s description=%s", e.ErrorCode, e.ErrorMsg, e.ErrorDescription)
|
||||
}
|
||||
|
||||
func isCaptchaTokenRejectedCode(code int64) bool {
|
||||
return code == 9 || code == 4002
|
||||
}
|
||||
|
||||
// APIError is the public alias for the PikPak API error response. Callers
|
||||
// outside this package (e.g. the spider91→PikPak migrator, tests) can either
|
||||
// construct it for fakes or unwrap it via errors.As. Prefer IsCaptchaError
|
||||
@@ -76,7 +80,7 @@ func IsCaptchaError(err error) bool {
|
||||
}
|
||||
var e *errResp
|
||||
if errors.As(err, &e) {
|
||||
return e != nil && (e.ErrorCode == 4002 || e.ErrorCode == 9)
|
||||
return e != nil && isCaptchaTokenRejectedCode(e.ErrorCode)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -289,7 +289,7 @@ func (g *Generator) generateThumbnailAtOffset(ctx context.Context, link *drives.
|
||||
args = append(args,
|
||||
"-i", ffmpegLink.URL,
|
||||
"-frames:v", "1",
|
||||
"-vf", fmt.Sprintf("scale=%d:-2", g.cfg.Width),
|
||||
"-vf", thumbnailVideoFilter(g.cfg.Width),
|
||||
"-q:v", "3",
|
||||
"-y", dst,
|
||||
)
|
||||
@@ -307,6 +307,12 @@ func (g *Generator) generateThumbnailAtOffset(ctx context.Context, link *drives.
|
||||
return nil
|
||||
}
|
||||
|
||||
func thumbnailVideoFilter(width int) string {
|
||||
// FFmpeg 7 rejects non-full-range YUV for MJPEG/JPEG output. Force the
|
||||
// scaled frame into a JPEG-friendly full-range pixel format before encode.
|
||||
return fmt.Sprintf("scale=%d:-2:out_range=pc,format=yuvj420p", width)
|
||||
}
|
||||
|
||||
func thumbnailOffsetFallbackAllowed(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
|
||||
@@ -161,6 +161,16 @@ func TestThumbnailOffsetsUseFiveSecondsWithEarlyFallbacks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestThumbnailVideoFilterUsesFullRangeJPEGPixelFormat(t *testing.T) {
|
||||
got := thumbnailVideoFilter(480)
|
||||
if !strings.Contains(got, "scale=480:-2:out_range=pc") {
|
||||
t.Fatalf("thumbnail filter = %q, want full-range scale output", got)
|
||||
}
|
||||
if !strings.Contains(got, "format=yuvj420p") {
|
||||
t.Fatalf("thumbnail filter = %q, want JPEG-friendly pixel format", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThumbnailOffsetFallbackAllowedForEmptyOutputAndTimeouts(t *testing.T) {
|
||||
for _, err := range []error{
|
||||
errors.New("ffmpeg thumb produced empty file, stderr: "),
|
||||
|
||||
@@ -272,7 +272,7 @@ func (m *Migrator) runOnce(ctx context.Context) {
|
||||
|
||||
target, pp, err := m.resolveTarget()
|
||||
if err != nil {
|
||||
// 没目标就静默 —— 用户可能还没配 PikPak drive
|
||||
// 没目标就静默 —— 用户选择了本地保存,或还没配 115/PikPak drive。
|
||||
return
|
||||
}
|
||||
|
||||
@@ -382,7 +382,7 @@ func (m *Migrator) spider91Drives() []*spider91.Driver {
|
||||
// - 列出 spider91 drive 本地 videos/ 目录所有 mp4 文件,按 mtime 降序排
|
||||
// - 跳过最新 KeepLatestN 个:这些是用户希望保留在本地的最新爬取
|
||||
// - 对剩下的(更旧)逐个处理:
|
||||
// - 还没迁移(drive_id 仍是 src.ID())→ 上传到 PikPak + 改 catalog + 删本地
|
||||
// - 还没迁移(drive_id 仍是 src.ID())→ 上传到目标盘 + 改 catalog + 删本地
|
||||
// - 已经迁移过但本地还有残留 → 仅删本地(兜底)
|
||||
//
|
||||
// KeepLatestN < 0 时不保护任何本地文件,全部尝试迁移(旧行为,主要给测试用)。
|
||||
@@ -484,7 +484,7 @@ func (m *Migrator) migrateDrive(ctx context.Context, src *spider91.Driver, targe
|
||||
return migrated, nil
|
||||
}
|
||||
|
||||
// migrateOne 把单条 spider91 视频上传到 PikPak 并改写 catalog。
|
||||
// migrateOne 把单条 spider91 视频上传到目标盘并改写 catalog。
|
||||
// 返回 (true, nil) 表示真的迁了一条;(false, nil) 表示跳过(本地文件已不在等);
|
||||
// (false, err) 表示真出错。
|
||||
func (m *Migrator) migrateOne(ctx context.Context, v *catalog.Video, src *spider91.Driver, targetDriveID string, pp uploadTarget) (bool, error) {
|
||||
|
||||
@@ -588,7 +588,7 @@ func TestRunOnceKeepsAllLocalWhenWithinKeepWindow(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestRunOnceMigratesOnlyOlderFilesBeyondKeepWindow 验证:本地文件数 > KeepLatestN 时
|
||||
// 按 mtime 降序保留最新 N 个,超出部分(更旧的)才上传到 PikPak。
|
||||
// 按 mtime 降序保留最新 N 个,超出部分(更旧的)才上传到目标盘。
|
||||
func TestRunOnceMigratesOnlyOlderFilesBeyondKeepWindow(t *testing.T) {
|
||||
cat := setupCatalog(t)
|
||||
src, _ := setupSpider91(t)
|
||||
@@ -841,7 +841,6 @@ func TestNonCaptchaErrorDoesNotTriggerCooldown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// TestRunOnceMigratesToP115Target 验证:当目标 drive 是 115(kind="p115")时,
|
||||
// migrator 也能正确把 spider91 视频上传过去并改写 catalog。
|
||||
//
|
||||
|
||||
+165
-19
@@ -11,6 +11,11 @@ VERSION="${VERSION:-latest}"
|
||||
GH_PROXY="${GH_PROXY:-}"
|
||||
CONFIGURE_UFW="${CONFIGURE_UFW:-1}"
|
||||
INSTALL_DEPS="${INSTALL_DEPS:-1}"
|
||||
SELF_UPDATE="${SELF_UPDATE:-1}"
|
||||
INSTALL_SCRIPT_REF="${INSTALL_SCRIPT_REF:-main}"
|
||||
INSTALL_SCRIPT_URL="${INSTALL_SCRIPT_URL:-${GH_PROXY}https://raw.githubusercontent.com/${GITHUB_REPO}/${INSTALL_SCRIPT_REF}/install.sh}"
|
||||
VIDEO_SITE_SKIP_SELF_UPDATE="${VIDEO_SITE_SKIP_SELF_UPDATE:-0}"
|
||||
SERVICE_READY_TIMEOUT="${SERVICE_READY_TIMEOUT:-90}"
|
||||
VERSION_FILE="$INSTALL_PATH/.version"
|
||||
MANAGER_PATH="/usr/local/sbin/${APP_NAME}-manager"
|
||||
COMMAND_LINK="/usr/local/bin/91"
|
||||
@@ -47,7 +52,7 @@ Default action:
|
||||
|
||||
Actions:
|
||||
install Install to $INSTALL_PATH
|
||||
update Download latest release and replace program files, keeping config/data
|
||||
update Refresh manager script, download latest release, and keep config/data
|
||||
restart Restart service
|
||||
stop Stop service
|
||||
status Show service status
|
||||
@@ -62,6 +67,10 @@ Options via environment:
|
||||
GH_PROXY=$GH_PROXY
|
||||
INSTALL_DEPS=$INSTALL_DEPS
|
||||
CONFIGURE_UFW=$CONFIGURE_UFW
|
||||
SELF_UPDATE=$SELF_UPDATE
|
||||
INSTALL_SCRIPT_REF=$INSTALL_SCRIPT_REF
|
||||
INSTALL_SCRIPT_URL=$INSTALL_SCRIPT_URL
|
||||
SERVICE_READY_TIMEOUT=$SERVICE_READY_TIMEOUT
|
||||
|
||||
Examples:
|
||||
sudo bash install.sh
|
||||
@@ -158,6 +167,30 @@ download_file() {
|
||||
return 1
|
||||
}
|
||||
|
||||
backup_install_files() {
|
||||
local backup="$1"
|
||||
mkdir -p "$backup"
|
||||
cp -a "$INSTALL_PATH/server" "$backup/server"
|
||||
for item in dist config.example.yaml 91VideoSpider config.yaml .version; do
|
||||
if [[ -e "$INSTALL_PATH/$item" ]]; then
|
||||
cp -a "$INSTALL_PATH/$item" "$backup/$item"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
restore_install_files() {
|
||||
local backup="$1"
|
||||
mkdir -p "$INSTALL_PATH"
|
||||
cp -a "$backup/server" "$INSTALL_PATH/server"
|
||||
for item in dist config.example.yaml 91VideoSpider config.yaml .version; do
|
||||
rm -rf "${INSTALL_PATH:?}/$item"
|
||||
if [[ -e "$backup/$item" ]]; then
|
||||
cp -a "$backup/$item" "$INSTALL_PATH/$item"
|
||||
fi
|
||||
done
|
||||
chmod +x "$INSTALL_PATH/server"
|
||||
}
|
||||
|
||||
prepare_config() {
|
||||
local cfg="$INSTALL_PATH/config.yaml"
|
||||
local example="$INSTALL_PATH/config.example.yaml"
|
||||
@@ -217,12 +250,71 @@ EOF
|
||||
install_cli() {
|
||||
local src
|
||||
src="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
|
||||
if [[ -f "$src" ]]; then
|
||||
cp "$src" "$MANAGER_PATH"
|
||||
chmod 755 "$MANAGER_PATH"
|
||||
ln -sf "$MANAGER_PATH" "$COMMAND_LINK"
|
||||
ln -sf "$MANAGER_PATH" "$APP_COMMAND_LINK"
|
||||
install_cli_from_file "$src"
|
||||
}
|
||||
|
||||
install_cli_from_file() {
|
||||
local src="$1"
|
||||
local tmp
|
||||
[[ -f "$src" ]] || return 0
|
||||
mkdir -p "$(dirname "$MANAGER_PATH")" "$(dirname "$COMMAND_LINK")" "$(dirname "$APP_COMMAND_LINK")"
|
||||
tmp="${MANAGER_PATH}.tmp.$$"
|
||||
cp "$src" "$tmp"
|
||||
chmod 755 "$tmp"
|
||||
mv "$tmp" "$MANAGER_PATH"
|
||||
ln -sfn "$MANAGER_PATH" "$COMMAND_LINK"
|
||||
ln -sfn "$MANAGER_PATH" "$APP_COMMAND_LINK"
|
||||
}
|
||||
|
||||
self_update_manager() {
|
||||
[[ "$SELF_UPDATE" == "1" ]] || return 1
|
||||
[[ "$VIDEO_SITE_SKIP_SELF_UPDATE" != "1" ]] || return 1
|
||||
[[ -n "$INSTALL_SCRIPT_URL" ]] || return 1
|
||||
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
log "checking latest manager script"
|
||||
if ! download_file "$INSTALL_SCRIPT_URL" "$tmp"; then
|
||||
warn "manager self-update skipped: cannot download $INSTALL_SCRIPT_URL"
|
||||
rm -f "$tmp"
|
||||
return 1
|
||||
fi
|
||||
if ! bash -n "$tmp"; then
|
||||
warn "manager self-update skipped: downloaded script has syntax errors"
|
||||
rm -f "$tmp"
|
||||
return 1
|
||||
fi
|
||||
if [[ -f "$MANAGER_PATH" ]] && cmp -s "$tmp" "$MANAGER_PATH"; then
|
||||
rm -f "$tmp"
|
||||
return 1
|
||||
fi
|
||||
|
||||
install_cli_from_file "$tmp"
|
||||
rm -f "$tmp"
|
||||
log "manager script updated"
|
||||
return 0
|
||||
}
|
||||
|
||||
exec_latest_manager_update() {
|
||||
local env_args=(
|
||||
"VIDEO_SITE_SKIP_SELF_UPDATE=1"
|
||||
"APP_NAME=$APP_NAME"
|
||||
"GITHUB_REPO=$GITHUB_REPO"
|
||||
"INSTALL_PATH=$INSTALL_PATH"
|
||||
"SERVICE_NAME=$SERVICE_NAME"
|
||||
"VERSION=$VERSION"
|
||||
"GH_PROXY=$GH_PROXY"
|
||||
"CONFIGURE_UFW=$CONFIGURE_UFW"
|
||||
"INSTALL_DEPS=$INSTALL_DEPS"
|
||||
"SELF_UPDATE=$SELF_UPDATE"
|
||||
"INSTALL_SCRIPT_REF=$INSTALL_SCRIPT_REF"
|
||||
"INSTALL_SCRIPT_URL=$INSTALL_SCRIPT_URL"
|
||||
"SERVICE_READY_TIMEOUT=$SERVICE_READY_TIMEOUT"
|
||||
)
|
||||
if [[ -n "$FRONTEND_PORT_WAS_SET" ]]; then
|
||||
env_args+=("FRONTEND_PORT=$FRONTEND_PORT")
|
||||
fi
|
||||
exec env "${env_args[@]}" bash "$MANAGER_PATH" update
|
||||
}
|
||||
|
||||
open_firewall_port() {
|
||||
@@ -234,6 +326,55 @@ open_firewall_port() {
|
||||
fi
|
||||
}
|
||||
|
||||
listen_port_from_config() {
|
||||
local cfg="$INSTALL_PATH/config.yaml"
|
||||
local listen="" port
|
||||
if [[ -f "$cfg" ]]; then
|
||||
listen="$(sed -nE 's/^[[:space:]]*listen:[[:space:]]*"?([^" #]+)"?.*/\1/p' "$cfg" | head -n1)"
|
||||
fi
|
||||
port="${listen##*:}"
|
||||
if [[ "$port" =~ ^[0-9]+$ ]]; then
|
||||
printf '%s' "$port"
|
||||
return
|
||||
fi
|
||||
printf '%s' "$FRONTEND_PORT"
|
||||
}
|
||||
|
||||
service_health_url() {
|
||||
printf 'http://127.0.0.1:%s/admin/api/setup' "$(listen_port_from_config)"
|
||||
}
|
||||
|
||||
wait_for_service_ready() {
|
||||
local url deadline
|
||||
url="$(service_health_url)"
|
||||
deadline=$((SECONDS + SERVICE_READY_TIMEOUT))
|
||||
log "waiting for service at $url"
|
||||
while (( SECONDS < deadline )); do
|
||||
if curl -fsS --connect-timeout 2 --max-time 5 "$url" >/dev/null 2>&1; then
|
||||
log "service is ready"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
restart_service_ready() {
|
||||
if systemctl restart "${SERVICE_NAME}.service" && wait_for_service_ready; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
warn "service did not become ready; retrying restart"
|
||||
if systemctl restart "${SERVICE_NAME}.service" && wait_for_service_ready; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
warn "service failed to become ready"
|
||||
systemctl --no-pager --full status "${SERVICE_NAME}.service" || true
|
||||
journalctl -u "${SERVICE_NAME}.service" -n 80 --no-pager || true
|
||||
return 1
|
||||
}
|
||||
|
||||
fetch_and_unpack() {
|
||||
local tmp archive url root
|
||||
tmp="$(mktemp -d)"
|
||||
@@ -298,7 +439,7 @@ show_success() {
|
||||
version="$(head -n1 "$VERSION_FILE" 2>/dev/null || echo unknown)"
|
||||
|
||||
echo
|
||||
printf "${GREEN}安装完成${RESET}\n"
|
||||
printf '%b安装完成%b\n' "$GREEN" "$RESET"
|
||||
echo "版本:$version"
|
||||
[[ -n "$local_ip" ]] && echo "局域网:http://${local_ip}:${FRONTEND_PORT}/"
|
||||
[[ -n "$public_ip" ]] && echo "公网: http://${public_ip}:${FRONTEND_PORT}/"
|
||||
@@ -319,8 +460,8 @@ install_app() {
|
||||
write_service
|
||||
install_cli
|
||||
open_firewall_port
|
||||
restart_service_ready || die "service failed to start"
|
||||
record_version
|
||||
systemctl restart "${SERVICE_NAME}.service"
|
||||
show_success
|
||||
}
|
||||
|
||||
@@ -330,27 +471,32 @@ update_app() {
|
||||
install_deps
|
||||
[[ -f "$INSTALL_PATH/server" ]] || die "not installed at $INSTALL_PATH"
|
||||
|
||||
if self_update_manager; then
|
||||
log "re-running update with latest manager script"
|
||||
exec_latest_manager_update
|
||||
fi
|
||||
|
||||
local backup
|
||||
backup="$(mktemp -d)"
|
||||
cp "$INSTALL_PATH/server" "$backup/server"
|
||||
[[ -d "$INSTALL_PATH/dist" ]] && cp -R "$INSTALL_PATH/dist" "$backup/dist"
|
||||
backup_install_files "$backup"
|
||||
|
||||
systemctl stop "${SERVICE_NAME}.service" 2>/dev/null || true
|
||||
if ! fetch_and_unpack; then
|
||||
if ! (fetch_and_unpack && prepare_config && write_service && install_cli); then
|
||||
warn "update failed; restoring previous files"
|
||||
cp "$backup/server" "$INSTALL_PATH/server"
|
||||
rm -rf "$INSTALL_PATH/dist"
|
||||
[[ -d "$backup/dist" ]] && cp -R "$backup/dist" "$INSTALL_PATH/dist"
|
||||
restore_install_files "$backup"
|
||||
systemctl start "${SERVICE_NAME}.service" 2>/dev/null || true
|
||||
rm -rf "$backup"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
prepare_config
|
||||
write_service
|
||||
install_cli
|
||||
if ! restart_service_ready; then
|
||||
warn "new version failed to start; restoring previous files"
|
||||
restore_install_files "$backup"
|
||||
restart_service_ready 2>/dev/null || true
|
||||
rm -rf "$backup"
|
||||
exit 1
|
||||
fi
|
||||
record_version
|
||||
systemctl restart "${SERVICE_NAME}.service"
|
||||
rm -rf "$backup"
|
||||
log "updated"
|
||||
}
|
||||
@@ -430,7 +576,7 @@ main() {
|
||||
;;
|
||||
restart)
|
||||
need_root "$@"
|
||||
systemctl restart "${SERVICE_NAME}.service"
|
||||
restart_service_ready || die "service failed to start"
|
||||
;;
|
||||
stop)
|
||||
need_root "$@"
|
||||
|
||||
Generated
+829
-1531
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -15,14 +15,14 @@
|
||||
"lucide-react": "0.453.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-router-dom": "6.26.2"
|
||||
"react-router-dom": "^6.30.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@vitejs/plugin-react": "4.3.3",
|
||||
"tsx": "^4.19.2",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "5.6.3",
|
||||
"vite": "5.4.10"
|
||||
"vite": "^8.0.14"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ build_package() {
|
||||
)
|
||||
|
||||
cp "$ROOT_DIR/backend/config.example.yaml" "$work/config.example.yaml"
|
||||
cp "$ROOT_DIR/install.sh" "$work/install.sh"
|
||||
cp -R "$ROOT_DIR/dist" "$work/dist"
|
||||
mkdir -p "$work/91VideoSpider"
|
||||
cp "$ROOT_DIR/91VideoSpider/spider_91porn.py" "$work/91VideoSpider/spider_91porn.py"
|
||||
@@ -69,10 +70,11 @@ build_package() {
|
||||
$APP_NAME $VERSION
|
||||
|
||||
This is a prebuilt release package.
|
||||
Use install.sh from the repository to install it on a Linux server.
|
||||
Use install.sh in this package or from the repository to install it on a Linux server.
|
||||
EOF
|
||||
|
||||
chmod +x "$work/server"
|
||||
chmod +x "$work/install.sh"
|
||||
tar -C "$OUT_DIR/.work" -czf "$OUT_DIR/$artifact.tar.gz" "$artifact"
|
||||
log "wrote $OUT_DIR/$artifact.tar.gz"
|
||||
}
|
||||
|
||||
+17
-51
@@ -48,7 +48,7 @@ type FormState = {
|
||||
* 单独通过 PUT /admin/api/settings 写到全局 setting。在 form state 里维护它
|
||||
* 是为了让 DriveForm 能读写同一份编辑状态。
|
||||
*
|
||||
* 空字符串 = 自动模式(系统中唯一的 pikpak/p115 drive)。
|
||||
* 空字符串 = 本地保存,不上传。
|
||||
*/
|
||||
spider91UploadDriveId: string;
|
||||
};
|
||||
@@ -81,7 +81,7 @@ export function DrivesPage() {
|
||||
const { show } = useToast();
|
||||
|
||||
// 当前系统中可作为 spider91 上传目标的 drive 列表(pikpak ∪ p115)。
|
||||
// 用户保存 spider91 drive 时从这里挑一个;空表示走"自动"模式。
|
||||
// 用户保存 spider91 drive 时从这里挑一个;空表示本地保存不上传。
|
||||
const uploadTargets = useMemo(
|
||||
() => list.filter((d) => d.kind === "pikpak" || d.kind === "p115"),
|
||||
[list]
|
||||
@@ -126,7 +126,7 @@ export function DrivesPage() {
|
||||
|
||||
function openCreate() {
|
||||
// 创建时把全局 setting 当前值带进表单,方便用户在新建第一个 spider91 drive 时
|
||||
// 直接看到当前的上传目标选择(一般是空 = 自动)。
|
||||
// 直接看到当前的上传目标选择(一般是空 = 本地保存)。
|
||||
setForm({
|
||||
...emptyForm,
|
||||
spider91UploadDriveId: settings?.spider91UploadDriveId ?? "",
|
||||
@@ -953,10 +953,9 @@ function DriveForm({
|
||||
* Spider91UploadTargetField 是 spider91 drive 表单专属的"上传目标"下拉。
|
||||
*
|
||||
* 行为:
|
||||
* - 选项 = "(自动)" + 系统中所有 pikpak/p115 drive
|
||||
* - "自动" 模式(value="")下,后端在迁移 worker 启动时挑唯一的目标盘;
|
||||
* 系统中如果同时挂着 pikpak 和 p115 drive 必须显式选定,否则不会上传
|
||||
* - 没有任何 pikpak/p115 drive 时给一行提示文字,告诉用户先去添加目标盘
|
||||
* - 选项 = "本地保存,不上传" + 系统中所有 pikpak/p115 drive
|
||||
* - value="" 时后端不迁移上传,视频保存在服务器本地
|
||||
* - 没有任何 pikpak/p115 drive 时仍允许选择本地保存
|
||||
* - 该字段写入的是全局 setting `spider91.upload_drive_id`,不是 drive 自己的
|
||||
* credentials —— 所有 spider91 drive 共享同一个上传目标
|
||||
*/
|
||||
@@ -969,53 +968,20 @@ function Spider91UploadTargetField({
|
||||
onChange: (v: string) => void;
|
||||
uploadTargets: api.AdminDrive[];
|
||||
}) {
|
||||
// 文案根据系统中实际挂载的目标盘 kind 自适应:
|
||||
// - 只挂了 PikPak → 文案只讲 "PikPak"
|
||||
// - 只挂了 115 → 文案只讲 "115 网盘"
|
||||
// - 两类都挂 → 文案讲 "PikPak / 115 网盘"
|
||||
// 这样在单一类型场景下用户不会被另一类的字样干扰。
|
||||
const kindsPresent = new Set(uploadTargets.map((d) => d.kind));
|
||||
const hasPikPak = kindsPresent.has("pikpak");
|
||||
const has115 = kindsPresent.has("p115");
|
||||
const presentLabel =
|
||||
hasPikPak && has115
|
||||
? "PikPak / 115 网盘"
|
||||
: hasPikPak
|
||||
? "PikPak"
|
||||
: has115
|
||||
? "115 网盘"
|
||||
: "PikPak 或 115 网盘";
|
||||
|
||||
return (
|
||||
<div className="admin-form__row">
|
||||
<label>视频上传目标</label>
|
||||
{uploadTargets.length === 0 ? (
|
||||
<>
|
||||
<select value="" disabled>
|
||||
<option value="">(请先添加 {presentLabel})</option>
|
||||
</select>
|
||||
<div className="admin-form__help">
|
||||
目前系统里还没有 {presentLabel} drive。可以先保存 91 爬虫,之后再回来选择上传目标。
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">(自动:唯一的 {presentLabel})</option>
|
||||
{uploadTargets.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{kindLabel[d.kind] ?? d.kind} · {d.name || d.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="admin-form__help">
|
||||
爬取后的旧视频会上传到该云盘根目录。该设置全局生效;
|
||||
{uploadTargets.length > 1
|
||||
? `如果同时挂着多个 ${presentLabel} drive,"自动"模式不会工作,必须显式选定一个。`
|
||||
: `当前只挂着 1 个 ${presentLabel},"自动"模式会直接选用它。`}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">本地保存,不上传</option>
|
||||
{uploadTargets.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{kindLabel[d.kind] ?? d.kind} · {d.name || d.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="admin-form__help">
|
||||
选择本地保存时,爬取视频只保存在服务器本地;选择 115 网盘或 PikPak 后,较早的视频会上传到该云盘根目录。该设置全局生效。
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -325,7 +325,7 @@ export type Settings = {
|
||||
theme: Theme;
|
||||
/**
|
||||
* spider91 视频迁移到云盘时的目标 drive ID(必须是已挂载的 pikpak 或 p115 drive)。
|
||||
* - 空字符串:自动模式。系统中如果只挂着一个 pikpak/p115 drive 就用它;多个并存时迁移会跳过。
|
||||
* - 空字符串:本地保存,不上传到云盘。
|
||||
* - 非空:显式指定。后端会校验 drive 存在且 kind ∈ {pikpak, p115}。
|
||||
*/
|
||||
spider91UploadDriveId: string;
|
||||
|
||||
+13
-3
@@ -953,12 +953,16 @@
|
||||
* ========================================================= */
|
||||
@media (max-width: 768px) {
|
||||
.admin-shell {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
flex: 0 0 48px;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
flex-direction: row;
|
||||
@@ -1023,7 +1027,14 @@
|
||||
.admin-nav__link.is-active::before { display: none; }
|
||||
|
||||
.admin-main {
|
||||
padding: var(--space-4) var(--space-3);
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3) var(--space-4);
|
||||
}
|
||||
|
||||
.admin-page__header {
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.admin-page__title {
|
||||
@@ -2195,4 +2206,3 @@
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-default);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,3 +13,9 @@ test("spider91 drive form does not expose advanced crawler credentials", () => {
|
||||
assert.doesNotMatch(drivesPageSource, /python_path/);
|
||||
assert.doesNotMatch(drivesPageSource, /script_path/);
|
||||
});
|
||||
|
||||
test("spider91 upload target uses explicit local-save option instead of auto target", () => {
|
||||
assert.match(drivesPageSource, /本地保存,不上传/);
|
||||
assert.doesNotMatch(drivesPageSource, /自动:唯一/);
|
||||
assert.doesNotMatch(drivesPageSource, /自动模式/);
|
||||
});
|
||||
|
||||
@@ -82,10 +82,14 @@ test("admin modals and action footers adapt on mobile", () => {
|
||||
test("mobile admin top navigation stays compact", () => {
|
||||
const css = mobileCss();
|
||||
|
||||
assert.match(ruleBody(css, ".admin-shell"), /display\s*:\s*flex/);
|
||||
assert.match(ruleBody(css, ".admin-shell"), /flex-direction\s*:\s*column/);
|
||||
assert.match(ruleBody(css, ".admin-sidebar"), /height\s*:\s*48px/);
|
||||
assert.match(ruleBody(css, ".admin-sidebar"), /min-height\s*:\s*48px/);
|
||||
assert.match(ruleBody(css, ".admin-nav"), /align-items\s*:\s*center/);
|
||||
assert.match(ruleBody(css, ".admin-nav__link"), /height\s*:\s*34px/);
|
||||
assert.match(ruleBody(css, ".admin-nav__link"), /line-height\s*:\s*1/);
|
||||
assert.match(ruleBody(css, ".admin-nav__link"), /flex\s*:\s*0\s+0\s+auto/);
|
||||
assert.match(ruleBody(css, ".admin-main"), /padding\s*:\s*var\(--space-2\)\s+var\(--space-3\)\s+var\(--space-4\)/);
|
||||
assert.match(ruleBody(css, ".admin-page__header"), /margin-bottom\s*:\s*var\(--space-3\)/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user