mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 16:55:42 +08:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fea5e984d1 | |||
| 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 站。
|
||||
|
||||
+23
-31
@@ -104,6 +104,14 @@ func main() {
|
||||
}
|
||||
setupRequired := config.RequiresAdminSetup(cfg)
|
||||
var setupMu sync.Mutex
|
||||
versionFilePath := strings.TrimSpace(os.Getenv("VIDEO_VERSION_FILE"))
|
||||
if versionFilePath == "" {
|
||||
versionFilePath = filepath.Join(filepath.Dir(cfgPath), ".version")
|
||||
}
|
||||
githubRepo := strings.TrimSpace(os.Getenv("VIDEO_GITHUB_REPO"))
|
||||
if githubRepo == "" {
|
||||
githubRepo = strings.TrimSpace(os.Getenv("GITHUB_REPO"))
|
||||
}
|
||||
|
||||
apiServer := &api.Server{
|
||||
Catalog: cat,
|
||||
@@ -117,8 +125,10 @@ func main() {
|
||||
}
|
||||
|
||||
adminServer := &api.AdminServer{
|
||||
Catalog: cat,
|
||||
Auth: authr,
|
||||
Catalog: cat,
|
||||
Auth: authr,
|
||||
VersionFilePath: versionFilePath,
|
||||
GitHubRepo: githubRepo,
|
||||
SetupRequired: func() bool {
|
||||
setupMu.Lock()
|
||||
defer setupMu.Unlock()
|
||||
@@ -271,8 +281,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 +370,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" }
|
||||
|
||||
@@ -5,9 +5,12 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
@@ -18,6 +21,13 @@ import (
|
||||
type AdminServer struct {
|
||||
Catalog *catalog.Catalog
|
||||
Auth *auth.Authenticator
|
||||
// VersionFilePath points to the installer-written .version file.
|
||||
VersionFilePath string
|
||||
// GitHubRepo is the owner/name repo used for update checks.
|
||||
GitHubRepo string
|
||||
// ReleaseAPIURL and HTTPClient are injectable for tests. Production code leaves them empty.
|
||||
ReleaseAPIURL string
|
||||
HTTPClient *http.Client
|
||||
// SetupRequired 表示当前是否仍处于首次部署初始化状态。
|
||||
SetupRequired func() bool
|
||||
// OnSetup 持久化首次部署时设置的管理员账号密码,并更新运行中认证器。
|
||||
@@ -40,12 +50,12 @@ 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 爬虫 +
|
||||
// Phase3 迁移)。立即返回 —— 实际任务在后台跑,admin 在日志或下次状态查询里
|
||||
// 看进度。重复点击会被 Runner.TryLock 丢弃。
|
||||
// 看进度。若流水线正在跑,Runner 最多保留一个待触发请求,当前轮结束后再跑一轮。
|
||||
OnRunNightlyJob func()
|
||||
// ListDriveDirChildren 列出某个 drive 在 parentID 目录下的直接子目录。
|
||||
// parentID 为空时使用 drive 的 RootID。返回 (子目录列表, error)。
|
||||
@@ -112,11 +122,25 @@ func (a *AdminServer) Register(r chi.Router) {
|
||||
r.Put("/settings", a.handlePutSettings)
|
||||
|
||||
// 运维任务
|
||||
r.Get("/update/check", a.handleCheckUpdate)
|
||||
r.Post("/jobs/nightly/run", a.handleRunNightlyJob)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
type updateCheckDTO struct {
|
||||
CurrentVersion string `json:"currentVersion"`
|
||||
LatestVersion string `json:"latestVersion"`
|
||||
HasUpdate bool `json:"hasUpdate"`
|
||||
ReleaseURL string `json:"releaseUrl,omitempty"`
|
||||
CheckedAt string `json:"checkedAt"`
|
||||
}
|
||||
|
||||
type githubReleaseDTO struct {
|
||||
TagName string `json:"tag_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
|
||||
type loginReq struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
@@ -221,6 +245,91 @@ func (a *AdminServer) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"authenticated": ok})
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleCheckUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
info, err := a.checkUpdate(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadGateway, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusOK, info)
|
||||
}
|
||||
|
||||
func (a *AdminServer) checkUpdate(ctx context.Context) (updateCheckDTO, error) {
|
||||
current := a.installedVersion()
|
||||
if current == "" {
|
||||
current = "unknown"
|
||||
}
|
||||
release, err := a.latestRelease(ctx)
|
||||
if err != nil {
|
||||
return updateCheckDTO{
|
||||
CurrentVersion: current,
|
||||
CheckedAt: time.Now().Format(time.RFC3339),
|
||||
}, err
|
||||
}
|
||||
latest := strings.TrimSpace(release.TagName)
|
||||
return updateCheckDTO{
|
||||
CurrentVersion: current,
|
||||
LatestVersion: latest,
|
||||
HasUpdate: current != "unknown" && latest != "" && current != latest,
|
||||
ReleaseURL: release.HTMLURL,
|
||||
CheckedAt: time.Now().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *AdminServer) installedVersion() string {
|
||||
path := strings.TrimSpace(a.VersionFilePath)
|
||||
if path == "" {
|
||||
path = ".version"
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(lines[0])
|
||||
}
|
||||
|
||||
func (a *AdminServer) latestRelease(ctx context.Context) (githubReleaseDTO, error) {
|
||||
url := strings.TrimSpace(a.ReleaseAPIURL)
|
||||
if url == "" {
|
||||
repo := strings.TrimSpace(a.GitHubRepo)
|
||||
if repo == "" {
|
||||
repo = "nianzhibai/91"
|
||||
}
|
||||
url = "https://api.github.com/repos/" + repo + "/releases/latest"
|
||||
}
|
||||
client := a.HTTPClient
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 8 * time.Second}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return githubReleaseDTO{}, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "video-site-91")
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return githubReleaseDTO{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return githubReleaseDTO{}, fmt.Errorf("github release check failed: HTTP %d", res.StatusCode)
|
||||
}
|
||||
var release githubReleaseDTO
|
||||
if err := json.NewDecoder(res.Body).Decode(&release); err != nil {
|
||||
return githubReleaseDTO{}, err
|
||||
}
|
||||
if strings.TrimSpace(release.TagName) == "" {
|
||||
return githubReleaseDTO{}, errors.New("github release check returned empty tag")
|
||||
}
|
||||
return release, nil
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleListDrives(w http.ResponseWriter, r *http.Request) {
|
||||
drives, err := a.Catalog.ListDrives(r.Context())
|
||||
if err != nil {
|
||||
@@ -425,7 +534,7 @@ func (a *AdminServer) handleRescan(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// handleRunNightlyJob 触发一次完整的凌晨流水线(不论当前时间,不论今日是否已跑)。
|
||||
// 立即返回 202;进度通过 backend 日志和下次 GET /admin/api/drives 的状态变化观察。
|
||||
// 流水线已在跑时 Runner 会丢弃此次触发并记日志。
|
||||
// 流水线已在跑时 Runner 最多排队一个后续触发;如果已有待触发请求,新的点击会被忽略。
|
||||
func (a *AdminServer) handleRunNightlyJob(w http.ResponseWriter, r *http.Request) {
|
||||
if a.OnRunNightlyJob != nil {
|
||||
a.OnRunNightlyJob()
|
||||
@@ -748,7 +857,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)
|
||||
|
||||
@@ -115,6 +115,85 @@ func TestHandleSetupStoresCredentialsAndCreatesSession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCheckUpdateReportsNewRelease(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
versionFile := filepath.Join(dir, ".version")
|
||||
if err := os.WriteFile(versionFile, []byte("v0.1.0\n2026-05-29 12:00:00\n"), 0o644); err != nil {
|
||||
t.Fatalf("write version file: %v", err)
|
||||
}
|
||||
releaseServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("User-Agent") == "" {
|
||||
http.Error(w, "missing user agent", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"tag_name": "v0.2.0",
|
||||
"html_url": "https://github.com/nianzhibai/91/releases/tag/v0.2.0",
|
||||
})
|
||||
}))
|
||||
t.Cleanup(releaseServer.Close)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/api/update/check", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
(&AdminServer{
|
||||
VersionFilePath: versionFile,
|
||||
ReleaseAPIURL: releaseServer.URL,
|
||||
}).handleCheckUpdate(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got updateCheckDTO
|
||||
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.CurrentVersion != "v0.1.0" {
|
||||
t.Fatalf("currentVersion = %q, want v0.1.0", got.CurrentVersion)
|
||||
}
|
||||
if got.LatestVersion != "v0.2.0" {
|
||||
t.Fatalf("latestVersion = %q, want v0.2.0", got.LatestVersion)
|
||||
}
|
||||
if !got.HasUpdate {
|
||||
t.Fatalf("hasUpdate = false, want true")
|
||||
}
|
||||
if got.ReleaseURL == "" {
|
||||
t.Fatalf("releaseUrl is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCheckUpdateReportsUpToDate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
versionFile := filepath.Join(dir, ".version")
|
||||
if err := os.WriteFile(versionFile, []byte("v0.2.0\n"), 0o644); err != nil {
|
||||
t.Fatalf("write version file: %v", err)
|
||||
}
|
||||
releaseServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"tag_name": "v0.2.0",
|
||||
"html_url": "https://github.com/nianzhibai/91/releases/tag/v0.2.0",
|
||||
})
|
||||
}))
|
||||
t.Cleanup(releaseServer.Close)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/api/update/check", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
(&AdminServer{
|
||||
VersionFilePath: versionFile,
|
||||
ReleaseAPIURL: releaseServer.URL,
|
||||
}).handleCheckUpdate(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got updateCheckDTO
|
||||
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.HasUpdate {
|
||||
t.Fatalf("hasUpdate = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpsertDrivePreservesExistingCredentialsWhenRequestCredentialsEmpty(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
|
||||
|
||||
+161
-58
@@ -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"))
|
||||
@@ -182,14 +223,18 @@ func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
if size <= 0 {
|
||||
size = 24
|
||||
}
|
||||
sort := q.Get("sort")
|
||||
params := catalog.ListParams{
|
||||
Keyword: q.Get("q"),
|
||||
Tag: q.Get("tag"),
|
||||
Category: q.Get("cat"),
|
||||
Sort: q.Get("sort"),
|
||||
Sort: sort,
|
||||
Page: page,
|
||||
PageSize: size,
|
||||
}
|
||||
if sort == "" || sort == "latest" {
|
||||
params.PreferReadyThumbnails = true
|
||||
}
|
||||
items, total, err := s.Catalog.ListVideos(r.Context(), params)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
@@ -241,7 +286,8 @@ func (s *Server) handleVideoDetail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// pickRelatedVideos 选 total 个推荐视频。
|
||||
// 一半(向上取整)来自同标签命中,剩下用全库随机补齐;不会重复,也不会包含当前视频。
|
||||
// 一半来自同标签命中,剩下用全库随机补齐;两段都优先取已有封面的视频,
|
||||
// 不够时再回退到未生成封面的候选。结果不会重复,也不会包含当前视频。
|
||||
func (s *Server) pickRelatedVideos(ctx context.Context, current *catalog.Video, total int) []*catalog.Video {
|
||||
if total <= 0 || current == nil {
|
||||
return nil
|
||||
@@ -254,67 +300,124 @@ func (s *Server) pickRelatedVideos(ctx context.Context, current *catalog.Video,
|
||||
picked := make([]*catalog.Video, 0, total)
|
||||
seen := map[string]struct{}{current.ID: {}}
|
||||
|
||||
// 1) 同标签候选:对每个 tag 取一批,合并去重,洗牌后取 tagQuota 个
|
||||
// 1) 同标签候选:先取已有封面的候选,数量不够再从全部候选里补。
|
||||
if tagQuota > 0 && len(current.Tags) > 0 {
|
||||
var tagPool []*catalog.Video
|
||||
for _, tag := range current.Tags {
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
items, _, err := s.Catalog.ListVideos(ctx, catalog.ListParams{
|
||||
Tag: tag, Sort: "latest", Page: 1, PageSize: 30,
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, v := range items {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[v.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v.ID] = struct{}{}
|
||||
tagPool = append(tagPool, v)
|
||||
}
|
||||
picked = appendRandomRelated(
|
||||
picked,
|
||||
s.relatedTagPool(ctx, current.Tags, seen, true),
|
||||
tagQuota,
|
||||
seen,
|
||||
)
|
||||
if len(picked) < tagQuota {
|
||||
picked = appendRandomRelated(
|
||||
picked,
|
||||
s.relatedTagPool(ctx, current.Tags, seen, false),
|
||||
tagQuota,
|
||||
seen,
|
||||
)
|
||||
}
|
||||
rand.Shuffle(len(tagPool), func(i, j int) {
|
||||
tagPool[i], tagPool[j] = tagPool[j], tagPool[i]
|
||||
})
|
||||
if len(tagPool) > tagQuota {
|
||||
tagPool = tagPool[:tagQuota]
|
||||
}
|
||||
picked = append(picked, tagPool...)
|
||||
}
|
||||
|
||||
// 2) 随机补齐:从全库取一批(避开已选 ID),洗牌后取剩下的名额
|
||||
remaining := total - len(picked)
|
||||
if remaining > 0 {
|
||||
// 2) 随机补齐:同样优先已有封面的全库候选,不够再回退。
|
||||
if len(picked) < total {
|
||||
picked = appendRandomRelated(
|
||||
picked,
|
||||
s.relatedListPool(ctx, seen, true, 200),
|
||||
total,
|
||||
seen,
|
||||
)
|
||||
}
|
||||
if len(picked) < total {
|
||||
picked = appendRandomRelated(
|
||||
picked,
|
||||
s.relatedListPool(ctx, seen, false, 200),
|
||||
total,
|
||||
seen,
|
||||
)
|
||||
}
|
||||
|
||||
return picked
|
||||
}
|
||||
|
||||
func (s *Server) relatedTagPool(ctx context.Context, tags []string, seen map[string]struct{}, readyOnly bool) []*catalog.Video {
|
||||
var pool []*catalog.Video
|
||||
poolSeen := make(map[string]struct{})
|
||||
for _, tag := range tags {
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
items, _, err := s.Catalog.ListVideos(ctx, catalog.ListParams{
|
||||
Sort: "latest", Page: 1, PageSize: 200,
|
||||
Tag: tag,
|
||||
Sort: "latest",
|
||||
Page: 1,
|
||||
PageSize: 30,
|
||||
ThumbnailReadyOnly: readyOnly,
|
||||
PreferReadyThumbnails: !readyOnly,
|
||||
})
|
||||
if err == nil {
|
||||
var randomPool []*catalog.Video
|
||||
for _, v := range items {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[v.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v.ID] = struct{}{}
|
||||
randomPool = append(randomPool, v)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, v := range items {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
rand.Shuffle(len(randomPool), func(i, j int) {
|
||||
randomPool[i], randomPool[j] = randomPool[j], randomPool[i]
|
||||
})
|
||||
if len(randomPool) > remaining {
|
||||
randomPool = randomPool[:remaining]
|
||||
if _, ok := seen[v.ID]; ok {
|
||||
continue
|
||||
}
|
||||
picked = append(picked, randomPool...)
|
||||
if _, ok := poolSeen[v.ID]; ok {
|
||||
continue
|
||||
}
|
||||
poolSeen[v.ID] = struct{}{}
|
||||
pool = append(pool, v)
|
||||
}
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
func (s *Server) relatedListPool(ctx context.Context, seen map[string]struct{}, readyOnly bool, pageSize int) []*catalog.Video {
|
||||
items, _, err := s.Catalog.ListVideos(ctx, catalog.ListParams{
|
||||
Sort: "latest",
|
||||
Page: 1,
|
||||
PageSize: pageSize,
|
||||
ThumbnailReadyOnly: readyOnly,
|
||||
PreferReadyThumbnails: !readyOnly,
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
pool := make([]*catalog.Video, 0, len(items))
|
||||
for _, v := range items {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[v.ID]; ok {
|
||||
continue
|
||||
}
|
||||
pool = append(pool, v)
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
func appendRandomRelated(picked []*catalog.Video, pool []*catalog.Video, targetLen int, seen map[string]struct{}) []*catalog.Video {
|
||||
if len(picked) >= targetLen || len(pool) == 0 {
|
||||
return picked
|
||||
}
|
||||
rand.Shuffle(len(pool), func(i, j int) {
|
||||
pool[i], pool[j] = pool[j], pool[i]
|
||||
})
|
||||
for _, v := range pool {
|
||||
if len(picked) >= targetLen {
|
||||
break
|
||||
}
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[v.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v.ID] = struct{}{}
|
||||
picked = append(picked, v)
|
||||
}
|
||||
return picked
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -98,6 +99,146 @@ 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 TestHandleListLatestPrefersReadyThumbnails(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-latest-" + 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 < 12; i++ {
|
||||
id := "ready-latest-" + 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/list?page=1&size=12&sort=latest", nil)
|
||||
(&Server{Catalog: cat}).handleList(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got struct {
|
||||
Items []VideoDTO `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if got.Total != 32 {
|
||||
t.Fatalf("total = %d, want all matching videos included", got.Total)
|
||||
}
|
||||
if len(got.Items) != 12 {
|
||||
t.Fatalf("items = %d, want 12", len(got.Items))
|
||||
}
|
||||
for _, item := range got.Items {
|
||||
if !strings.HasPrefix(item.ID, "ready-latest-") {
|
||||
t.Fatalf("latest list returned %q before ready thumbnails; items=%#v", item.ID, got.Items)
|
||||
}
|
||||
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")
|
||||
@@ -509,6 +650,88 @@ func TestHandleVideoDetailIncludesDriveKindLabel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVideoDetailRecommendationsPreferReadyThumbnails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
|
||||
if err != nil {
|
||||
t.Fatalf("open catalog: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := cat.Close(); err != nil {
|
||||
t.Fatalf("close catalog: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
now := time.Now()
|
||||
if err := cat.UpsertVideo(ctx, &catalog.Video{
|
||||
ID: "current-video",
|
||||
DriveID: "drive",
|
||||
FileID: "current-video",
|
||||
Title: "Current",
|
||||
Tags: []string{"same-tag"},
|
||||
ThumbnailURL: "https://thumb.example/current-video.jpg",
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed current video: %v", err)
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
id := "pending-related-" + strconv.Itoa(i)
|
||||
if err := cat.UpsertVideo(ctx, &catalog.Video{
|
||||
ID: id,
|
||||
DriveID: "drive",
|
||||
FileID: id,
|
||||
Title: id,
|
||||
Tags: []string{"same-tag"},
|
||||
PublishedAt: now.Add(time.Duration(i+1) * time.Minute),
|
||||
CreatedAt: now.Add(time.Duration(i+1) * time.Minute),
|
||||
UpdatedAt: now.Add(time.Duration(i+1) * time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed pending related video %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
for i := 0; i < 8; i++ {
|
||||
id := "ready-related-" + strconv.Itoa(i)
|
||||
if err := cat.UpsertVideo(ctx, &catalog.Video{
|
||||
ID: id,
|
||||
DriveID: "drive",
|
||||
FileID: id,
|
||||
Title: id,
|
||||
Tags: []string{"same-tag"},
|
||||
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 related video %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
req := requestWithVideoID(http.MethodGet, "/api/video/current-video", "current-video", strings.NewReader(``))
|
||||
rr := httptest.NewRecorder()
|
||||
(&Server{Catalog: cat}).handleVideoDetail(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got VideoDetailDTO
|
||||
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(got.RelatedVideos) != 6 {
|
||||
t.Fatalf("related videos = %d, want 6; items=%#v", len(got.RelatedVideos), got.RelatedVideos)
|
||||
}
|
||||
for _, item := range got.RelatedVideos {
|
||||
if !strings.HasPrefix(item.ID, "ready-related-") {
|
||||
t.Fatalf("related returned %q before ready thumbnails; items=%#v", item.ID, got.RelatedVideos)
|
||||
}
|
||||
if !strings.HasPrefix(item.Thumbnail, "https://thumb.example/") {
|
||||
t.Fatalf("thumbnail for %q = %q, want ready thumbnail URL", item.ID, item.Thumbnail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleHideVideoRemovesVideoFromPublicListAndDetail(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,15 @@ 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
|
||||
PreferReadyThumbnails bool
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
func (c *Catalog) ListVideos(ctx context.Context, p ListParams) ([]*Video, int, error) {
|
||||
@@ -710,21 +712,29 @@ 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)
|
||||
|
||||
whereSQL := ""
|
||||
whereSQL = " WHERE " + strings.Join(where, " AND ")
|
||||
|
||||
orderBy := " ORDER BY published_at DESC"
|
||||
readyOrderPrefix := ""
|
||||
if p.PreferReadyThumbnails {
|
||||
readyOrderPrefix = "CASE WHEN COALESCE(thumbnail_url, '') != '' THEN 0 ELSE 1 END, "
|
||||
}
|
||||
|
||||
orderBy := " ORDER BY " + readyOrderPrefix + "published_at DESC"
|
||||
switch p.Sort {
|
||||
case "hot":
|
||||
// 热度 = 点赞数,点赞相同按最新
|
||||
orderBy = " ORDER BY likes DESC, published_at DESC"
|
||||
orderBy = " ORDER BY " + readyOrderPrefix + "likes DESC, published_at DESC"
|
||||
case "week":
|
||||
orderBy = " ORDER BY likes DESC"
|
||||
orderBy = " ORDER BY " + readyOrderPrefix + "likes DESC"
|
||||
case "long":
|
||||
orderBy = " ORDER BY duration_seconds DESC"
|
||||
orderBy = " ORDER BY " + readyOrderPrefix + "duration_seconds DESC"
|
||||
}
|
||||
|
||||
// count
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -131,9 +131,10 @@ func (r *Runner) Run(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// TriggerNow asks the running loop to fire a pipeline ASAP. If a pipeline is
|
||||
// already in progress (or another trigger is already pending), the request
|
||||
// is dropped — the in-progress run will absorb the intent.
|
||||
// TriggerNow asks the running loop to fire a pipeline ASAP. The trigger channel
|
||||
// is buffered(1): if a pipeline is already in progress, one follow-up run may
|
||||
// remain pending and will start after the current run finishes. Additional
|
||||
// clicks while that follow-up is pending are ignored.
|
||||
func (r *Runner) TriggerNow() {
|
||||
select {
|
||||
case r.trigger <- struct{}{}:
|
||||
|
||||
@@ -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。
|
||||
//
|
||||
|
||||
+224
-23
@@ -11,6 +11,12 @@ VERSION="${VERSION:-latest}"
|
||||
GH_PROXY="${GH_PROXY:-}"
|
||||
CONFIGURE_UFW="${CONFIGURE_UFW:-1}"
|
||||
INSTALL_DEPS="${INSTALL_DEPS:-1}"
|
||||
SELF_UPDATE="${SELF_UPDATE:-1}"
|
||||
FORCE_UPDATE="${FORCE_UPDATE:-0}"
|
||||
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 +53,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 +68,11 @@ Options via environment:
|
||||
GH_PROXY=$GH_PROXY
|
||||
INSTALL_DEPS=$INSTALL_DEPS
|
||||
CONFIGURE_UFW=$CONFIGURE_UFW
|
||||
SELF_UPDATE=$SELF_UPDATE
|
||||
FORCE_UPDATE=$FORCE_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 +169,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"
|
||||
@@ -200,6 +235,8 @@ RestartSec=5
|
||||
TimeoutStopSec=20
|
||||
Environment=VIDEO_CONFIG=${INSTALL_PATH}/config.yaml
|
||||
Environment=VIDEO_FRONTEND_DIR=${INSTALL_PATH}/dist
|
||||
Environment=VIDEO_VERSION_FILE=${VERSION_FILE}
|
||||
Environment=VIDEO_GITHUB_REPO=${GITHUB_REPO}
|
||||
Environment=HOME=/root
|
||||
Environment=PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
|
||||
LimitNOFILE=65536
|
||||
@@ -217,12 +254,72 @@ 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"
|
||||
"FORCE_UPDATE=$FORCE_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 +331,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)"
|
||||
@@ -271,19 +417,63 @@ fetch_and_unpack() {
|
||||
rm -rf "$tmp"
|
||||
}
|
||||
|
||||
current_version_from_github() {
|
||||
installed_version() {
|
||||
if [[ -f "$VERSION_FILE" ]]; then
|
||||
head -n1 "$VERSION_FILE" 2>/dev/null | tr -d '\r'
|
||||
fi
|
||||
}
|
||||
|
||||
target_version() {
|
||||
if [[ "$VERSION" != "latest" ]]; then
|
||||
printf '%s' "$VERSION"
|
||||
return
|
||||
fi
|
||||
curl -fsSL "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" \
|
||||
|
||||
local body version effective_url
|
||||
body="$(curl -fsSL \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "User-Agent: video-site-91-installer" \
|
||||
"https://api.github.com/repos/${GITHUB_REPO}/releases/latest" 2>/dev/null || true)"
|
||||
version="$(printf '%s\n' "$body" \
|
||||
| sed -nE 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' \
|
||||
| head -n1)"
|
||||
if [[ -n "$version" ]]; then
|
||||
printf '%s' "$version"
|
||||
return
|
||||
fi
|
||||
|
||||
effective_url="$(curl -fsSLI -o /dev/null -w '%{url_effective}' "$(download_base_url)/$(asset_name)" 2>/dev/null || true)"
|
||||
printf '%s\n' "$effective_url" \
|
||||
| sed -nE 's#.*/releases/download/([^/]+)/.*#\1#p' \
|
||||
| head -n1
|
||||
}
|
||||
|
||||
should_skip_update() {
|
||||
[[ "$FORCE_UPDATE" != "1" ]] || return 1
|
||||
|
||||
local current target
|
||||
current="$(installed_version)"
|
||||
target="$(target_version || true)"
|
||||
|
||||
if [[ -z "$target" ]]; then
|
||||
warn "cannot determine target version; continuing update"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -z "$current" ]]; then
|
||||
log "installed version: unknown"
|
||||
log "target version: $target"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "installed version: $current"
|
||||
log "target version: $target"
|
||||
[[ "$current" == "$target" ]]
|
||||
}
|
||||
|
||||
record_version() {
|
||||
local version
|
||||
version="$(current_version_from_github || true)"
|
||||
version="$(target_version || true)"
|
||||
[[ -n "$version" ]] || version="$VERSION"
|
||||
{
|
||||
echo "$version"
|
||||
@@ -298,7 +488,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,38 +509,49 @@ 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
|
||||
}
|
||||
|
||||
update_app() {
|
||||
check_system
|
||||
[[ -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
|
||||
|
||||
if should_skip_update; then
|
||||
log "already up to date; skipped app update"
|
||||
return 0
|
||||
fi
|
||||
|
||||
check_disk_space
|
||||
install_deps
|
||||
[[ -f "$INSTALL_PATH/server" ]] || die "not installed at $INSTALL_PATH"
|
||||
|
||||
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 +631,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"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { useState } from "react";
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import { HardDrive, Film, LogOut, Play, Home, Tags, Palette } from "lucide-react";
|
||||
import {
|
||||
HardDrive,
|
||||
Film,
|
||||
LogOut,
|
||||
Play,
|
||||
Home,
|
||||
Tags,
|
||||
Palette,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import * as api from "./api";
|
||||
import { useAuth } from "./AuthContext";
|
||||
import { useToast } from "./ToastContext";
|
||||
|
||||
@@ -7,6 +18,31 @@ export function AdminLayout() {
|
||||
const { logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { show } = useToast();
|
||||
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
||||
|
||||
async function handleCheckUpdate() {
|
||||
if (checkingUpdate) return;
|
||||
setCheckingUpdate(true);
|
||||
try {
|
||||
const result = await api.checkUpdate();
|
||||
if (result.hasUpdate) {
|
||||
show(
|
||||
`发现新版本 ${result.latestVersion},当前 ${result.currentVersion}`,
|
||||
"success"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (result.currentVersion === "unknown") {
|
||||
show(`当前版本未知,GitHub 最新版本为 ${result.latestVersion}`, "info");
|
||||
return;
|
||||
}
|
||||
show(`当前已是最新版本:${result.currentVersion}`, "success");
|
||||
} catch {
|
||||
show("检查更新失败,请稍后重试", "error");
|
||||
} finally {
|
||||
setCheckingUpdate(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
@@ -65,6 +101,14 @@ export function AdminLayout() {
|
||||
</NavLink>
|
||||
</nav>
|
||||
<div className="admin-sidebar__footer">
|
||||
<button
|
||||
className="admin-sidebar__check-update"
|
||||
onClick={handleCheckUpdate}
|
||||
disabled={checkingUpdate}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
{checkingUpdate ? "检查中" : "检查更新"}
|
||||
</button>
|
||||
<button className="admin-sidebar__logout" onClick={handleLogout}>
|
||||
<LogOut size={14} style={{ verticalAlign: -2, marginRight: 4 }} />
|
||||
退出登录
|
||||
|
||||
+18
-52
@@ -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 ?? "",
|
||||
@@ -232,7 +232,7 @@ export function DrivesPage() {
|
||||
/**
|
||||
* 立即触发完整凌晨流水线(Phase1 扫所有云盘 → Phase2 spider91 爬虫 →
|
||||
* Phase3 spider91 → 云盘迁移)。后端立即返回 202;进度看 backend 日志。
|
||||
* 已在跑时后端会丢弃此次触发,按钮再点也不会重复进入。
|
||||
* 如果当前已有流水线在跑,后端最多保留一个待触发请求,当前轮结束后再跑一轮。
|
||||
*/
|
||||
async function handleRunNightly() {
|
||||
try {
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+14
-2
@@ -61,6 +61,18 @@ export function me() {
|
||||
return request<{ authenticated: boolean }>("/me");
|
||||
}
|
||||
|
||||
export type UpdateCheck = {
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
hasUpdate: boolean;
|
||||
releaseUrl?: string;
|
||||
checkedAt: string;
|
||||
};
|
||||
|
||||
export function checkUpdate() {
|
||||
return request<UpdateCheck>("/update/check");
|
||||
}
|
||||
|
||||
// ---------- Drives ----------
|
||||
|
||||
export type AdminDrive = {
|
||||
@@ -325,7 +337,7 @@ export type Settings = {
|
||||
theme: Theme;
|
||||
/**
|
||||
* spider91 视频迁移到云盘时的目标 drive ID(必须是已挂载的 pikpak 或 p115 drive)。
|
||||
* - 空字符串:自动模式。系统中如果只挂着一个 pikpak/p115 drive 就用它;多个并存时迁移会跳过。
|
||||
* - 空字符串:本地保存,不上传到云盘。
|
||||
* - 非空:显式指定。后端会校验 drive 存在且 kind ∈ {pikpak, p115}。
|
||||
*/
|
||||
spider91UploadDriveId: string;
|
||||
@@ -355,7 +367,7 @@ export function updateSettings(body: Partial<Settings>) {
|
||||
* 立即触发一次完整的凌晨流水线(Phase1 扫盘 + Phase2 91 爬虫 + Phase3 迁移),
|
||||
* 不论当前时间或今日是否已跑。立即返回 202;进度通过 backend 日志观察。
|
||||
*
|
||||
* 流水线已在跑时后端会丢弃此次触发。
|
||||
* 流水线已在跑时后端最多保留一个待触发请求;已有待触发请求时,新的点击会被忽略。
|
||||
*/
|
||||
export function runNightlyJob() {
|
||||
return request<{ ok: boolean }>("/jobs/nightly/run", { method: "POST" });
|
||||
|
||||
+48
-3
@@ -121,10 +121,45 @@
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.admin-sidebar__check-update {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin-sidebar__check-update:hover:not(:disabled) {
|
||||
color: var(--text-strong);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.admin-sidebar__check-update:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.admin-sidebar__check-update:disabled svg {
|
||||
animation: admin-update-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes admin-update-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.admin-sidebar__logout {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
margin-top: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -953,12 +988,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 +1062,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 +2241,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