20 Commits

Author SHA1 Message Date
nianzhibai fea5e984d1 Release v0.0.3 improvements 2026-05-29 18:34:38 +08:00
nianzhibai 46bb1fa9f2 Recreate releases with assets 2026-05-29 16:46:02 +08:00
nianzhibai 7f4407ac28 Fetch annotated tag notes for releases 2026-05-29 16:39:12 +08:00
nianzhibai 79b5b5c37d Use tag notes for release body 2026-05-29 16:34:29 +08:00
nianzhibai f3180f7b3c Prioritize ready thumbnails on home 2026-05-29 16:23:13 +08:00
nianzhibai 353b01b8e7 Update README with upgrade instructions and cleanup
Added upgrade instructions for old version users and removed redundant access troubleshooting note.
2026-05-29 15:39:51 +08:00
nianzhibai d27eae9c62 Document legacy update recovery 2026-05-29 15:37:40 +08:00
nianzhibai 003efa301b Wait for service readiness after install 2026-05-29 15:34:48 +08:00
nianzhibai f72898f530 Harden installer update flow 2026-05-29 15:23:42 +08:00
nianzhibai 641d29e008 Fix PikPak captcha recovery 2026-05-29 14:49:47 +08:00
nianzhibai fed46b51bb Fix spider91 upload target and thumbnails 2026-05-29 06:28:18 +00:00
nianzhibai 304559203c Update mobile section images in README 2026-05-29 11:54:56 +08:00
nianzhibai 62ccd6a998 更新 README.md 2026-05-29 11:28:02 +08:00
nianzhibai 720a92af7a Update LinuxDo community link in README 2026-05-28 21:30:38 +08:00
nianzhibai e33384c786 Include restart command for access issues
Add troubleshooting tip for project access issues.
2026-05-28 21:26:49 +08:00
nianzhibai 2e7c761aaf Revise README content for clarity and updates
Updated the README to enhance the description and clarify features.
2026-05-28 21:23:29 +08:00
nianzhibai 0d6c3c6ac9 docs: polish README layout 2026-05-28 21:15:40 +08:00
nianzhibai 10426e5483 Enhance README with new features and preview images
Added preview images for desktop and mobile, included theme options and short video mode.
2026-05-28 21:11:13 +08:00
nianzhibai 863abe2064 fix: reduce mobile admin content gap 2026-05-28 20:50:46 +08:00
nianzhibai 60d0640a01 Revise README for project overview and setup
Updated project description and installation instructions in README.md.
2026-05-28 20:41:40 +08:00
29 changed files with 2325 additions and 1856 deletions
+9 -4
View File
@@ -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
+73 -110
View File
@@ -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
View File
@@ -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 视频上传到目标 drivePikPak 或 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)
+51
View File
@@ -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" }
+113 -4
View File
@@ -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)
+79
View File
@@ -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
View File
@@ -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
}
+223
View File
@@ -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")
+23 -13
View File
@@ -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
+53
View File
@@ -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
+3 -4
View File
@@ -199,9 +199,8 @@ func (d *Driver) refreshCaptchaToken(ctx context.Context, action string, meta ma
// refreshCaptchaTokenOnce 调 /v1/shield/captcha/init 申请新 captcha token。
//
// 如果 retry=true 且服务端返回 4002captcha_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),也不会再次进入恢复逻辑,
// 而是把错误返回出去,避免无限循环。
+25 -5
View File
@@ -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)
+5 -1
View File
@@ -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
}
+4 -3
View File
@@ -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{}{}:
+7 -1
View File
@@ -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
+10
View File
@@ -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: "),
+3 -3
View File
@@ -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 是 115kind="p115")时,
// migrator 也能正确把 spider91 视频上传过去并改写 catalog。
//
+224 -23
View File
@@ -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 "$@"
+829 -1531
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -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"
}
}
+3 -1
View File
@@ -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"
}
+45 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+6
View File
@@ -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, /自动模式/);
});
+4
View File
@@ -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\)/);
});