60 Commits

Author SHA1 Message Date
nianzhibai 91352ac681 fix: reduce duplicate home recommendations 2026-06-01 19:02:41 +08:00
nianzhibai d70cea7320 fix: improve 91Spider tagging and deduped tag filters 2026-06-01 18:51:56 +08:00
nianzhibai 6654a1b730 perf: speed up catalog startup migrations 2026-06-01 18:03:21 +08:00
nianzhibai bcc887e088 feat: add 91Spider proxy support and drive improvements 2026-06-01 17:41:20 +08:00
nianzhibai 2c0cfe1d15 Add failed fingerprint retry controls 2026-06-01 13:42:32 +08:00
nianzhibai a276e6b32d 网盘 302 支持说明 2026-05-31 19:42:10 +08:00
nianzhibai fa4ea469c3 docs: update release version example 2026-05-31 17:53:38 +08:00
nianzhibai 92885748fd fix thumbnail status and frontend serving 2026-05-31 17:40:16 +08:00
nianzhibai 093724a59d feat: use root id as drive scan root 2026-05-31 17:13:51 +08:00
nianzhibai 9cd30c8059 fix: suppress deleted auto tags 2026-05-31 16:51:45 +08:00
nianzhibai bec6d9496c fix: remove setup login help text 2026-05-31 16:41:12 +08:00
nianzhibai f187302b8e fix: make install script optional checks non-fatal 2026-05-31 16:32:58 +08:00
nianzhibai 739baf1294 fix: clean up install script uninstall 2026-05-31 16:19:41 +08:00
nianzhibai af18bbbf4c feat: paginate admin tags 2026-05-31 16:07:49 +08:00
nianzhibai 309b621084 fix: preserve shorts slide on fullscreen exit 2026-05-31 16:00:56 +08:00
nianzhibai 286329c446 feat: add bulk tag deletion 2026-05-31 15:45:22 +08:00
nianzhibai 1d5b5c2495 fix: prevent duplicate scan-all jobs 2026-05-31 15:09:05 +08:00
nianzhibai fac60b0054 Merge pull request #15 from thazjswe42700/fix/logout-icon-alignment
fix: remove extra margin-right on logout button icon
2026-05-31 14:34:46 +08:00
nianzhibai 19a939e80f Merge pull request #16 from thazjswe42700/fix/scan-all-debounce
fix: debounce scan-all button and deduplicate toast notifications
2026-05-31 14:34:32 +08:00
nianzhibai 16a2a7e03c fix: improve shorts preference and scrubbing 2026-05-31 12:59:21 +08:00
nianzhibai b9b6c5e098 Merge pull request #14 from yancj9ya/feat/shorts-tag-preference
按观看标签优化短视频推荐
2026-05-31 12:36:31 +08:00
hermes-agent 4200919774 fix: debounce scan-all button and deduplicate toast notifications
- Add scanningAll state to disable the 扫描所有网盘 button while the
  API request is in-flight, preventing repeated clicks from stacking
  independent requests.
- Deduplicate toast notifications: when show() is called with the same
  text that is already visible, reset its dismiss timer instead of
  adding a duplicate overlay.

Closes #13
2026-05-31 04:26:34 +00:00
hermes-agent 33d970a322 fix: remove extra margin-right on logout button icon
The LogOut icon had an inline marginRight:4 that conflicted with the
flex gap:6 defined in CSS, causing the icon to be misaligned with the
Check Update button above it.

Closes #11
2026-05-31 04:19:32 +00:00
nianzhibai 59e9b435a0 Limit thumbnail transient retries 2026-05-31 12:02:49 +08:00
nianzhibai dcda0e2e36 Add Google Drive support 2026-05-31 11:14:03 +08:00
yancj9ya 87709792f1 feat: prefer short videos by watched tags
Recommend shorts from the least-populated tag after a user watches a video long enough, while preserving random fallback behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 10:41:27 +08:00
nianzhibai 655da05b94 Add manual tag deletion 2026-05-31 10:39:18 +08:00
nianzhibai 674a92be16 Revise upgrade instructions in README
Updated instructions for upgrading to the latest stable version.
2026-05-31 10:14:13 +08:00
nianzhibai 9ada39debb chore: deploy Docker Compose from stable release image 2026-05-31 10:04:56 +08:00
nianzhibai 24d1244bc3 fix: detect Docker image version for update checks 2026-05-31 09:55:15 +08:00
nianzhibai 0dd0c45509 Update README with service restart instruction
Add note about restarting service on first access.
2026-05-30 20:26:29 +08:00
nianzhibai ef6eadd0a6 Update README.md 2026-05-30 20:17:18 +08:00
nianzhibai 2c5a3342cc feat: prepare v0.0.4 storage release 2026-05-30 20:02:02 +08:00
nianzhibai 7ace5f8bc7 feat: probe video duration during thumbnail generation 2026-05-30 18:30:22 +08:00
nianzhibai 66b33b2a31 feat: support spider91 uploads to OneDrive 2026-05-30 18:04:15 +08:00
nianzhibai 27aefc870f feat: improve media generation pipeline status 2026-05-30 17:37:31 +08:00
nianzhibai 02d82e9a62 Add Docker Compose deployment support 2026-05-30 11:09:04 +08:00
nianzhibai 492431164b Improve fingerprint dedupe maintenance 2026-05-29 23:58:36 +08:00
nianzhibai 051f1555d5 Add sampled fingerprint deduplication 2026-05-29 23:19:52 +08:00
nianzhibai 85292ea095 Simplify OneDrive setup and redirect playback 2026-05-29 22:35:02 +08:00
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
134 changed files with 4864 additions and 27003 deletions
-10
View File
@@ -23,10 +23,8 @@ tools/
# 编译产物
backend/server
backend/server.*
release/
tsconfig.tsbuildinfo
tmp/
# 91 爬虫脚本独立运行时的默认输出文件(backend 跑时会显式 --output 到 backend/data/spider91/,所以不会落在这里)
91porn_videos.json
@@ -35,11 +33,3 @@ tmp/
91VideoSpider/__pycache__/
__pycache__/
*.pyc
# Local scratch images
/image.jpg
/image003.jpg
/image004.jpg
/image005.png
/image006.png
/image02.png
+7 -136
View File
@@ -9,12 +9,9 @@
- 视频直链 (MP4)
依赖安装:
pip install requests beautifulsoup4 lxml PySocks
pip install requests beautifulsoup4 lxml
使用方法:
# 作为 video-site-91 通用爬虫脚本运行(后台会自动这样调用)
python spider_91porn.py --job /path/to/job.json
# 全量爬取(默认行为,从 page=1 一直爬到末尾,写到 OUTPUT_FILE
python spider_91porn.py
@@ -25,7 +22,6 @@
python spider_91porn.py --target-new 15 --seen-viewkeys-file /tmp/seen.txt --output /tmp/new.json
CLI 参数:
--job FILE crawler.v1 job JSON 路径;后台爬虫管理会使用此模式
--page N 只爬第 N 页,配合 --output 用于手动调试
--target-new N 从 page 1 起翻页直到凑够 N 个新视频(不在 seen 列表里的)
--seen-viewkeys-file FILE 每行一个已知 viewkey 或 mp4 源 ID,命中即跳过;与 --target-new 配合使用
@@ -41,8 +37,6 @@ CLI 参数:
- OUTPUT_FILE : 输出文件名
输出格式 (JSON):
--job 模式下 stdout 输出 crawler.v1 JSON Lines,日志全部写到 stderr。
手动运行模式仍会写传统 JSON 文件:
{
"videos": [
{
@@ -83,8 +77,8 @@ from datetime import datetime
try:
from bs4 import BeautifulSoup
except ImportError:
print("错误: 缺少依赖库 beautifulsoup4", file=sys.stderr)
print("请运行: pip install beautifulsoup4 lxml", file=sys.stderr)
print("错误: 缺少依赖库 beautifulsoup4")
print("请运行: pip install beautifulsoup4 lxml")
sys.exit(1)
@@ -154,24 +148,9 @@ OUTPUT_FILE = "91porn_videos.json"
MAX_PAGES = None # 设置为 None 爬取所有页,或设置整数如 5 只爬前5页
RESUME = True # 是否跳过输出文件中已存在的 viewkey (断点续爬)
MAX_EMPTY_PAGES = 2 # 连续空页数达到此值时停止爬取
CRAWLER_NAME = "91Porn"
CRAWLER_PROTOCOL = "crawler.v1"
# ===================================================
def crawler_source_id(raw: str) -> str:
"""Return a backend-safe source_id, preserving existing numeric 91 IDs."""
value = str(raw or "").strip()
if not value:
return ""
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", value).strip("._-")
return safe[:160]
def write_jsonl(event: dict):
print(json.dumps(event, ensure_ascii=False), flush=True)
class Porn91Spider:
def __init__(
self,
@@ -184,7 +163,6 @@ class Porn91Spider:
target_new: int = None,
seen_viewkeys: list = None,
stream_output: bool = False,
stream_protocol: str = "legacy",
):
"""
构造函数。所有参数都有默认值,等同于使用脚本顶部的全局配置。
@@ -220,7 +198,6 @@ class Porn91Spider:
# (配合 backend Go 端 bufio.Scanner 实时消费,下载一个就开始下一个)。
# 开启后所有 log 都走 stderr。
self.stream_output = bool(stream_output)
self.stream_protocol = stream_protocol or "legacy"
# 添加重试适配器
try:
@@ -286,28 +263,7 @@ class Porn91Spider:
if not self.stream_output:
return
try:
if self.stream_protocol == "crawler.v1":
source_id = crawler_source_id(video.get("source_id") or video.get("viewkey") or "")
item = {
"title": video.get("title") or "",
"detail_url": video.get("detail_url") or "",
"author": "91porn",
"tags": ["91porn"],
"media_url": video.get("video_url") or "",
"thumbnail_url": video.get("thumb_url") or "",
"headers": {
"Referer": video.get("detail_url") or BASE_URL,
},
}
if source_id:
item["source_id"] = source_id
event = {
"type": "item",
"item": item,
}
write_jsonl(event)
else:
print(json.dumps(video, ensure_ascii=False), flush=True)
print(json.dumps(video, ensure_ascii=False), flush=True)
except Exception as e:
# stdout 异常基本只在管道断开时发生(消费方进程死了);
# 写到 stderr 让 backend 看到,然后让 crawl 循环自己 break。
@@ -741,9 +697,8 @@ class Porn91Spider:
except Exception as e:
self.log(f"保存文件失败: {e}")
# 尝试输出到控制台作为备份
backup_out = sys.stderr if self.stream_output else sys.stdout
print("\n--- 备份输出 ---", file=backup_out, flush=True)
print(json.dumps(output_data, ensure_ascii=False, indent=2), file=backup_out, flush=True)
print("\n--- 备份输出 ---")
print(json.dumps(output_data, ensure_ascii=False, indent=2))
def _print_summary(self):
"""
@@ -774,7 +729,7 @@ def print_help():
- 视频直链 (MP4)
依赖安装:
pip install requests beautifulsoup4 lxml PySocks
pip install requests beautifulsoup4 lxml
使用方法:
python spider_91porn.py
@@ -796,84 +751,6 @@ def print_help():
""")
def run_job(job_path: str):
"""Run as a crawler.v1 script plugin.
The Go host passes a job JSON file and expects stdout JSONL events. Logs go
to stderr so stdout stays machine-readable.
"""
with open(job_path, "r", encoding="utf-8") as f:
job = json.load(f)
if job.get("protocol") != CRAWLER_PROTOCOL:
raise ValueError(f"unsupported crawler protocol: {job.get('protocol')!r}")
if job.get("mode") not in ("", None, "crawl"):
raise ValueError(f"unsupported crawler mode: {job.get('mode')!r}")
try:
target_new = int(job.get("target_new") or 15)
except (TypeError, ValueError):
target_new = 15
if target_new <= 0:
target_new = 15
seen_file = job.get("seen_source_ids_file") or ""
output_dir = job.get("output_dir") or os.getcwd()
run_id = job.get("run_id") or datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
os.makedirs(output_dir, exist_ok=True)
output_file = os.path.join(output_dir, f"spider91-{run_id}.json")
network = job.get("network") if isinstance(job.get("network"), dict) else {}
proxy_url = str(network.get("proxy_url") or "").strip()
if proxy_url:
os.environ["HTTP_PROXY"] = proxy_url
os.environ["HTTPS_PROXY"] = proxy_url
os.environ["http_proxy"] = proxy_url
os.environ["https_proxy"] = proxy_url
os.environ["NO_PROXY"] = ""
os.environ["no_proxy"] = ""
seen_viewkeys = []
if seen_file:
try:
with open(seen_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
seen_viewkeys.append(line)
except FileNotFoundError:
print(f"警告: seen_source_ids_file 不存在: {seen_file}", file=sys.stderr, flush=True)
except Exception as e:
print(f"警告: 读取 seen_source_ids_file 失败: {e}", file=sys.stderr, flush=True)
prefer_ipv4_for_plain_socks5_proxy()
spider = Porn91Spider(
output_file=output_file,
start_page=1,
max_pages=None,
resume=False,
quiet=True,
target_new=target_new,
seen_viewkeys=seen_viewkeys,
stream_output=True,
stream_protocol="crawler.v1",
)
try:
spider.crawl()
done = {
"type": "done",
"stats": {
"emitted": spider.processed_videos,
"failed": spider.failed_videos,
"skipped": spider.skipped_videos,
},
}
write_jsonl(done)
except KeyboardInterrupt:
spider.log("\n用户中断,正在保存已爬取的数据...")
spider._save_results()
raise
def main():
if len(sys.argv) > 1 and sys.argv[1] in ('-h', '--help', 'help'):
print_help()
@@ -901,14 +778,8 @@ def main():
parser.add_argument("--stream-output", action="store_true",
help="流式模式:每解析一条视频直链就立即把它作为一行 JSON 写到 stdout 并 flush"
"日志改走 stderr。配合 backend 边读边下载使用。")
parser.add_argument("--job", type=str, default=None,
help="crawler.v1 job JSON 路径;作为通用脚本爬虫运行。")
args, _ = parser.parse_known_args()
if args.job:
run_job(args.job)
return
cli_out = sys.stderr if args.stream_output else sys.stdout
prefer_ipv4_for_plain_socks5_proxy()
+135
View File
@@ -0,0 +1,135 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Common Commands
### Full Local Development
```bash
npm install
./start.sh
```
`./start.sh` starts the Go backend on `127.0.0.1:9192` and the frontend on `0.0.0.0:9191`. By default it builds the frontend and runs Vite preview mode. Use hot reload with:
```bash
FRONTEND_MODE=dev ./start.sh --restart
```
Useful variants:
```bash
./start.sh --status
./start.sh --restart
./start.sh --stop
```
### Frontend
Run from the repository root:
```bash
npm run dev # Vite dev server; default port comes from Vite unless overridden
npm run dev:raw # Vite dev server on 127.0.0.1:5173
npm run build # tsc -b && vite build
npm run preview # Vite preview; vite.config.ts uses port 9191
npm run lint # TypeScript no-emit check
npm test # node --import tsx --test tests/*.test.ts
```
Run one frontend test:
```bash
node --import tsx --test tests/previewIntent.test.ts
```
### Backend
Run from `backend/` unless noted:
```bash
go run ./cmd/server
go test ./... -count=1
go build -o video-server ./cmd/server
```
Run one backend package or test:
```bash
go test ./internal/scanner -count=1
go test ./internal/scanner -run TestParse -count=1
```
The backend requires Go 1.23+ and uses vendored dependencies in `backend/vendor/`, so keep `go mod vendor` in sync after dependency changes.
### Release and Deployment
```bash
scripts/build-release.sh # builds Linux amd64/arm64 release tarballs into release/
sudo bash install.sh # prebuilt installer flow used by README
sudo bash deploy.sh # build from current checkout and install systemd services
```
Docker uses the root `Dockerfile` and `docker-compose.yml`. The runtime image exposes port `9191` and stores persistent data under `/opt/video-site-91/data`.
## Architecture Overview
This is a private video aggregation site with a React/Vite frontend and a Go backend.
### Frontend
The frontend is a React 18 SPA under `src/`. `src/main.tsx` mounts `BrowserRouter`, `ToastProvider`, and `AuthProvider`, then renders `src/App.tsx`. `App.tsx` defines the public app routes (`/`, `/list`, `/shorts`, `/upload`, `/video/:id`) and admin routes under `/admin`; both main-site and admin pages are wrapped in `RequireAuth`, while `/login` is public.
Frontend API calls are split by surface:
- `src/data/videos.ts` calls the main authenticated API under `/api` and upload/proxy-related endpoints.
- `src/admin/api.ts` is the admin API client for `/admin/api`, always sending cookies and raising `UnauthorizedError` on `401`.
`vite.config.ts` proxies `/api`, `/p`, and `/admin/api` to `http://127.0.0.1:9192`, with frontend dev/preview served on port `9191` by default. The alias `@` maps to `src`.
Styling is plain CSS loaded from `src/main.tsx` in token/base/layout/navigation/search/video/admin layers. Shared UI lives in `src/components`, page-level screens in `src/pages`, and admin screens in `src/admin`.
### Backend
The backend entrypoint is `backend/cmd/server/main.go`. It loads `config.yaml` or `VIDEO_CONFIG`, creates the SQLite catalog and preview directories, builds the app state, registers API routes, starts the nightly runner, and then asynchronously attaches configured external drives so slow upstream login checks do not block port binding.
Important backend packages:
- `internal/config`: YAML config loading and first-run admin credential setup.
- `internal/catalog`: SQLite catalog, schema migration, video metadata, settings, tags, drive records, generation status, and deduplication state. It opens SQLite with WAL and a busy timeout.
- `internal/drives`: provider abstraction. Implementations include `quark`, `p115`, `pikpak`, `wopan`, `onedrive`, `localstorage`, `localupload`, and `spider91`.
- `internal/scanner`: recursively lists drive directories, parses filenames/tags, upserts catalog videos, applies skip-directory rules, and enqueues newly discovered videos.
- `internal/preview`: ffprobe/ffmpeg thumbnail and teaser generation workers. Generated assets are local files under the configured preview directory.
- `internal/fingerprint`: asynchronous sampled SHA-256 worker used for cross-drive duplicate detection.
- `internal/proxy`: `/p/*` media serving. Some providers redirect with `302` to signed CDN URLs, while providers requiring backend-held headers are reverse-proxied with Range support.
- `internal/api`: main API and admin API route handlers.
- `internal/nightly`: daily pipeline for drive scans, spider91 crawl, migration, queue drain, and duplicate asset cleanup.
- `internal/spider91migrate`: migration from spider91 downloads to a configured cloud drive.
### Runtime Flow
1. Admin adds or edits drives through `/admin/drives`, which persists drive config in the catalog.
2. The server attaches the drive implementation into the proxy registry and can trigger scans.
3. Scans convert provider files into catalog video rows, parse titles/authors/tags from filenames, and queue preview/fingerprint work.
4. The frontend lists videos through `/api/home`, `/api/list`, `/api/video/:id`, and streams media through `/p/*` endpoints.
5. The nightly runner performs the scheduled end-to-end maintenance pipeline; admins can trigger it manually through `/admin/api/jobs/nightly/run`.
### Configuration and Data
Backend defaults come from `backend/config.example.yaml`. On first backend start, `config.yaml` is created automatically if missing. Default local development paths are:
- Backend listen address: `127.0.0.1:9192`
- SQLite DB: `backend/data/video-site.db`
- Generated previews/thumbs: `backend/data/previews`
Docker and installer deployments rewrite config paths so data lives under `/opt/video-site-91/data` or the mounted `./data` directory.
`VIDEO_FRONTEND_DIR` controls where the Go server looks for built frontend assets. If unset, it serves `./dist` when present. Backend routes (`/api`, `/admin/api`, `/p`) are excluded from the SPA fallback.
## Notes for Changes
- Main-site API routes and proxy routes require authentication; only login/setup and `/api/settings/theme` are intentionally public.
- When adding a new drive provider, implement `internal/drives.Drive`, persist any needed config through catalog/admin APIs, attach it in `cmd/server`, and decide whether `/p/stream` should redirect or reverse-proxy in `internal/proxy`.
- Generated thumbnails and teasers are local runtime assets; do not treat them as source files.
- Frontend tests use Node's built-in test runner with `tsx`; TypeScript linting only checks `src` through the root `tsconfig.json`.
+1 -2
View File
@@ -41,13 +41,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
tzdata \
&& rm -rf /var/lib/apt/lists/*
RUN python3 -c "import requests, bs4, lxml, socks"
WORKDIR /opt/video-site-91
COPY --from=backend /out/server ./server
COPY --from=frontend /app/dist ./dist
COPY backend/config.example.yaml ./config.example.yaml
COPY 91VideoSpider/ ./91VideoSpider/
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
ARG VERSION=dev
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 nianzhibai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+15 -19
View File
@@ -12,19 +12,20 @@
<a href="#快速开始">快速开始</a> ·
<a href="#功能特性">功能特性</a> ·
<a href="#预览图">预览图</a> ·
<a href="#数据存放位置">数据目录</a> ·
<a href="#许可证">许可证</a>
<a href="#数据存放位置">数据目录</a>
</p>
---
## 功能特性
- **多后端支持** — 兼容 115 云盘、PikPak 云盘、123网盘、联通网盘、OneDrive、Google Drive 和本地存储
- **低带宽播放** — 115 云盘、PikPak 云盘、123网盘、联通网盘、OneDrive 支持302模式,在线播放视频时,不占用服务器带宽,播放体验不受服务器带宽影响;Google Drive 不支持302模式,走服务器中转,观看体验会受服务器带宽影响
- **多后端支持** — 兼容 115 云盘、PikPak 云盘、OneDrive、Google Drive 和本地存储
- **低带宽播放** — 115 云盘、PikPak 云盘、OneDrive 支持302模式,在线播放视频时,不占用服务器带宽,播放体验不受服务器带宽影响;Google Drive 不支持302模式,走服务器中转,观看体验会受服务器带宽影响
- **封面 & 预览片段** — 自动为每个视频生成封面图和预览片段,首页快速选片
- **爬虫脚本** — 项目支持导入自定义脚本,但是有一些规范,具体可以参考 [SpiderFor91](https://github.com/Just-Spider/SpiderFor91),项目不再内置任何爬虫脚本
- **91 爬虫** — 内置爬虫,支持抓取 91 本月最热视频
- **双主题** — 黑黄经典主题 / 粉白清新主题,随时切换
- **短视频模式** — 一键切换抖音风格,沉浸刷片
- **低资源占用** — 2C2G 服务器稳定运行,主要性能消耗就是封面图和预览视频的生成
---
@@ -178,6 +179,15 @@ docker compose up -d # 更新并重启
---
## 更多文档
| 文档 | 内容 |
|------|------|
| [backend/README.md](backend/README.md) | 后端实现、接口说明、网盘字段 |
| [video-site-implementation-plan.md](video-site-implementation-plan.md) | 完整实现方案 |
---
## 使用须知
本项目面向**个人私有部署**,请仅接入你有权访问和管理的内容,并遵守对应网盘、站点的服务条款及所在地法律法规。
@@ -186,20 +196,6 @@ docker compose up -d # 更新并重启
---
## PR提交规范
欢迎大家提交PR,一起来完善这个项目,但是这里要说明一下PR提交的规范
1. 一个PR的功能改动要单一,不建议一个PR修改了大量功能。单个PR单个功能修改,这样也更容易Merge
2. 完善项目的PR比新增功能的PR更容易Merge(例如:例如你发现开发者没有实现爬取的视频上传到某个网盘,并且你有这个需求,此时你可以实现一下这个功能然后提交PR,也感谢你为开发者分担工作量)
3. 新增功能的PR不容易Merge,因为某些功能的需求可能不是所有人都需要的,如果一味的不断增加功能,会让项目变得过于庞大。当然如果你肯定你的新功能和想法很好,并且相信将会对于项目有很大的改善,那么热烈欢迎你的PR
---
## 许可证
本项目基于 [MIT License](LICENSE) 开源。
---
## 致谢
- [OpenList](https://github.com/OpenListTeam/OpenList) — 优秀的开源项目
+15 -15
View File
@@ -2,8 +2,8 @@
视频聚合站的 Go 后端。提供三件事:
1. 多家网盘统一抽象(夸克 / 115 / PikPak / 联通盘 / OneDrive / Google Drive / 本地存储)
2. 视频元数据目录(SQLite)+ 扫描 + 预览视频预生成
1. 多家网盘统一抽象(夸克 / 115 / PikPak / 联通盘 / OneDrive / Google Drive / 本地存储)
2. 视频元数据目录(SQLite)+ 扫描 + teaser 预生成
3. REST API(前台)+ 管理后台 + 直链代理
4. 标签池、视频隐藏、按网盘统计和详情页来源网盘类型展示能力
@@ -19,12 +19,12 @@ internal/
quark/ 夸克(自己实现,参考 OpenList quark_uc
p115/ 115(壳子 + SheltonZhu/115driver
pikpak/ PikPak(自己实现,参考 OpenList pikpak
wopan/ 联通盘(壳子 + OpenListTeam/wopan-sdk-go
wopan/ 联通盘(壳子 + OpenListTeam/wopan-sdk-go
onedrive/ OneDriveOpenList 在线续期 + Microsoft Graph 文件接口)
googledrive/ Google DriveOpenList 在线续期 + Google Drive API;播放走后端代理)
localstorage/ 本地目录扫描(服务器已有视频目录)
scanner/ 扫目录 → 落库
preview/ ffmpeg 抽封面和生成多段预览视频
preview/ ffmpeg 抽封面和生成多段 teaser
proxy/ /p/stream/*、/p/preview/* 代理
auth/ 管理员 session
api/ REST 路由
@@ -81,7 +81,7 @@ go run ./cmd/server 后端 9192
## 添加一个盘
推荐在前端管理后台 `/admin/drives` 新增网盘。保存后会立即挂载并触发扫描;视频结果可在 `/admin/videos` 按网盘查看,每页 100 条,页面会同时显示各网盘预览视频已生成、待生成、失败数量。
推荐在前端管理后台 `/admin/drives` 新增网盘。保存后会立即挂载并触发扫描;视频结果可在 `/admin/videos` 按网盘查看,每页 100 条,页面会同时显示各网盘 Teaser 已生成、待生成、失败数量。
也可以直接调用后端接口:
@@ -109,7 +109,7 @@ go run ./cmd/server 后端 9192
| pikpak | `username`、`password`(token、验证码和设备 ID 由服务端自动处理并保存) |
| wopan | `access_token`、`refresh_token`,可选 `family_id` |
| onedrive | `refresh_token` |
| googledrive | 默认只需 `refresh_token`;自建 OAuth 客户端模式还需 `use_online_api=false`、`client_id`、`client_secret` |
| googledrive | `refresh_token` |
| localstorage | `path`(服务器上的已有视频目录,如 `/mnt/videos` |
### PikPak 速度说明
@@ -120,7 +120,7 @@ go run ./cmd/server 后端 9192
OneDrive 按 OpenList 默认应用方式调用 `https://api.oplist.org/onedrive/renewapi` 在线刷新 token,不需要配置 Azure 应用的 `client_id` / `client_secret` / `redirect_uri`。后台新建 OneDrive 时只需要填 OpenList 代刷得到的 `refresh_token`;服务端会默认挂载根目录并自动回写新 token。
Google Drive 默认按 OpenList 在线 API 调用 `https://api.oplist.org/googleui/renewapi` 刷新 token。后台新建 Google Drive 时只需要填 OpenList Google Drive 获取到的 `refresh_token`。如果不想依赖 OpenList 在线 API,可以关闭“使用 OpenList 在线续期 API”,并填写同一个 Google OAuth 客户端授权得到的 `refresh_token`、`client_id`、`client_secret`,服务端会直接请求 Google OAuth token 接口续期。Google Drive 下载地址必须携带 `Authorization` 头,浏览器不能直接 302 使用,所以本站会由后端代理 `/p/stream` 播放,不加入零带宽 302 白名单。
Google Drive 按 OpenList 在线 API 调用 `https://api.oplist.org/googleui/renewapi` 刷新 token。后台新建 Google Drive 时只需要填 OpenList Google Drive 获取到的 `refresh_token`。Google Drive 下载地址必须携带 `Authorization` 头,浏览器不能直接 302 使用,所以本站会由后端代理 `/p/stream` 播放,不加入零带宽 302 白名单。
## 文件名约定
@@ -145,22 +145,22 @@ Google Drive 默认按 OpenList 在线 API 调用 `https://api.oplist.org/google
1. 同一网盘同一文件按 `(drive_id, file_id)` 形成稳定视频 ID,重复扫描只更新同一行。
2. 扫描时优先按网盘侧 `content_hash` 去重;没有 hash 时退化为 `file_name + size_bytes`。
3. 扫描、本地上传或服务启动挂载网盘后,后台指纹 worker 会异步读取视频的少量 Range 片段,生成 `sampled_sha256`。前台列表、首页、搜索、推荐会按 `size_bytes + sampled_sha256` 只展示最早入库的 canonical 视频。
3. 扫描、爬虫、本地上传或服务启动挂载网盘后,后台指纹 worker 会异步读取视频的少量 Range 片段,生成 `sampled_sha256`。前台列表、首页、搜索、推荐会按 `size_bytes + sampled_sha256` 只展示最早入库的 canonical 视频。
`sampled_sha256` 是文件级去重:适合识别同一个视频文件被复制到 115 / PikPak / OneDrive / Google Drive 等不同网盘的情况。它不会删除任何网盘文件,也不用于识别转码、裁剪、加水印后的同源视频。
`sampled_sha256` 是文件级去重:适合识别同一个视频文件被复制到 115 / PikPak / OneDrive 等不同网盘的情况。它不会删除任何网盘文件,也不用于识别转码、裁剪、加水印后的同源视频。
封面和预览视频仍然优先生成,不等待指纹完成。夜间流水线最后会做一次重复资产清理:对 `size_bytes + sampled_sha256` 命中的非 canonical 视频,只删除本机生成的重复封面和预览视频,并把对应字段重置为 `pending`。网盘原文件和视频元数据记录不会被删除;如果 canonical 视频以后被移除,这些重复项会重新进入生成队列。
封面和 teaser 仍然优先生成,不等待指纹完成。夜间流水线最后会做一次重复资产清理:对 `size_bytes + sampled_sha256` 命中的非 canonical 视频,只删除本机生成的重复封面和 teaser,并把对应字段重置为 `pending`。网盘原文件和视频元数据记录不会被删除;如果 canonical 视频以后被移除,这些重复项会重新进入生成队列。
## 管理能力
- `/admin/drives`:新增、编辑、删除网盘,触发扫描。
- `/admin/videos`:按网盘筛选视频,每页 100 条分页,查看各网盘预览视频统计,编辑标题/作者/分类/标签,单条或全量重生预览视频
- `/admin/videos`:按网盘筛选视频,每页 100 条分页,查看各网盘 Teaser 统计,编辑标题/作者/分类/标签,单条或全量重生 teaser
- `/admin/tags`:新增标签并用内置规则自动匹配已有视频;删除非系统标签时会从所有视频上同步移除该标签。
- 播放页视频信息会展示来源网盘类型;同时提供“不再展示”,点击后会把视频标记为全局隐藏。隐藏视频不会再出现在首页、列表、搜索、相关推荐和详情接口中。目前没有管理后台恢复入口,如需恢复可把数据库里对应视频的 `hidden` 字段改回 `0`。
## 预览视频生成
## Teaser 生成
scanner 扫到新视频会把 `(driveID, videoID)` 丢进 worker 队列。worker 会先用 `ffprobe` 探测时长,再用 `ffmpeg` 抽封面和生成无声预览视频
scanner 扫到新视频会把 `(driveID, videoID)` 丢进 worker 队列。worker 会先用 `ffprobe` 探测时长,再用 `ffmpeg` 抽封面和生成无声 teaser
```
ffmpeg -ss <起点> -headers "UA/Cookie/Referer" -i <直链> \
@@ -168,9 +168,9 @@ ffmpeg -ss <起点> -headers "UA/Cookie/Referer" -i <直链> \
-movflags +faststart -y <local>.mp4
```
当前策略是每段固定 3 秒;30 秒以下最多 3 段,30 秒及以上固定 4 段;长视频在 20% 到 80% 区间均匀取段。生成的预览视频和封面都只保存在本地 `data/previews/`,不会回写到网盘;旧数据中的 `preview_file_id` 会被忽略。
当前策略是每段固定 3 秒;30 秒以下最多 3 段,30 秒及以上固定 4 段;长视频在 20% 到 80% 区间均匀取段。生成的 teaser 和封面都只保存在本地 `data/previews/`,不会回写到网盘;旧数据中的 `preview_file_id` 会被忽略。
服务启动或网盘重新挂载时,如果预览视频开关已开启,后端会把历史 `pending` 任务重新入队,避免重启后长期停在“待生成”。OneDrive 扫盘和直链生成预览视频 / 封面时可能触发 Microsoft Graph 429、`TooManyRequests`、`activityLimitReached` 或 throttled 文本;Google Drive 可能返回 429、`usageLimits`、`userRateLimitExceeded`、`downloadQuotaExceeded` 等限制标识。后端会识别这类错误并让当前网盘进入冷却期,保留任务为 `pending`,避免连续请求触发更严重限流。扫盘阶段会按 `Retry-After` 或默认冷却时间等待后继续当前目录。
服务启动或网盘重新挂载时,如果 Teaser 开关已开启,后端会把历史 `pending` 任务重新入队,避免重启后长期停在“待生成”。OneDrive 扫盘和直链生成 teaser / 封面时可能触发 Microsoft Graph 429、`TooManyRequests`、`activityLimitReached` 或 throttled 文本;后端会识别这类错误并让当前网盘进入冷却期,保留任务为 `pending`,避免连续请求触发更严重限流。扫盘阶段会按 `Retry-After` 或默认冷却时间等待后继续当前目录。
前端卡片的 `previewSrc` 统一指向 `/p/preview/<videoID>`,后端只从本地 `preview_local` 文件读取。
+243 -1507
View File
File diff suppressed because it is too large Load Diff
-12
View File
@@ -38,9 +38,7 @@ func TestSpider91IntCredFallbacks(t *testing.T) {
func TestSpider91UploadDriveIDDoesNotAutoSelectTarget(t *testing.T) {
reg := proxy.NewRegistry()
reg.Set("p115-one", &spider91UploadTargetFakeDrive{id: "p115-one", kind: "p115"})
reg.Set("p123-one", &spider91UploadTargetFakeDrive{id: "p123-one", kind: "p123"})
reg.Set("onedrive-one", &spider91UploadTargetFakeDrive{id: "onedrive-one", kind: "onedrive"})
reg.Set("wopan-one", &spider91UploadTargetFakeDrive{id: "wopan-one", kind: "wopan"})
app := &App{registry: reg}
if got := app.Spider91UploadDriveID(); got != "" {
@@ -52,21 +50,11 @@ func TestSpider91UploadDriveIDDoesNotAutoSelectTarget(t *testing.T) {
t.Fatalf("explicit upload target = %q, want p115-one", got)
}
app.spider91UploadDriveID = "p123-one"
if got := app.Spider91UploadDriveID(); got != "p123-one" {
t.Fatalf("explicit p123 upload target = %q, want p123-one", got)
}
app.spider91UploadDriveID = "onedrive-one"
if got := app.Spider91UploadDriveID(); got != "onedrive-one" {
t.Fatalf("explicit onedrive upload target = %q, want onedrive-one", got)
}
app.spider91UploadDriveID = "wopan-one"
if got := app.Spider91UploadDriveID(); got != "wopan-one" {
t.Fatalf("explicit wopan upload target = %q, want wopan-one", got)
}
app.spider91UploadDriveID = "missing"
if got := app.Spider91UploadDriveID(); got != "" {
t.Fatalf("missing upload target = %q, want empty", got)
File diff suppressed because it is too large Load Diff
+11 -15
View File
@@ -22,7 +22,7 @@ server:
storage:
# SQLite 数据库文件路径
db_path: "./data/video-site.db"
# 本地预览视频和封面目录
# 本地 teaser 和封面目录
local_preview_dir: "./data/previews"
scanner:
@@ -33,30 +33,33 @@ scanner:
# 单次扫描每家网盘目录递归层数上限
max_depth: 5
# 被扫描的扩展名
video_extensions: [".mp4", ".mkv", ".mov", ".webm", ".avi", ".strm"]
video_extensions: [".mp4", ".mkv", ".mov", ".webm", ".avi"]
nightly:
# 凌晨流水线触发整点(0-23),默认 1 即每天 01:00。
# 运行时会统一编排扫描、媒体资产生成和后续清理任务。
# 凌晨流水线触发整点(0-23),默认 1 即每天 01:00。流程:
# Phase 1 扫所有非 spider91 / 非 localupload 网盘 → 检测新增 / 删除
# → 入队封面和 teaser → 等所有队列 idle
# Phase 2 spider91 爬虫(如配置)→ 入队 teaser → 等队列 idle
# Phase 3 spider91 → 云盘迁移(一次性 sweep)
cron_hour: 1
# 单次流水线总耗时上限(软超时);超过后当前 phase 跑完不启动后续 phase。
max_duration: 6h
preview:
# 是否启用 ffmpeg 抽帧生成预览视频
# 是否启用 ffmpeg 抽帧生成 teaser
enabled: true
# ffmpeg / ffprobe 可执行文件名或绝对路径
ffmpeg_path: "ffmpeg"
ffprobe_path: "ffprobe"
# 预览视频每段时长(秒),实际生成时每段最多 3 秒
# teaser 每段时长(秒),实际生成时每段最多 3 秒
duration_seconds: 3
# 兼容旧配置;当前 30 秒以下最多 3 段,30 秒及以上固定 4 段
segments: 3
# 预览视频宽度
# teaser 视频宽度
width: 480
# 盘列表。上线后请通过管理后台添加,本文件可留空。
# kind 支持 quark / p115 / p123 / pikpak / wopan / onedrive / googledrive / localstorage。
# kind 支持 quark / p115 / pikpak / wopan / onedrive / googledrive / localstorage。
# OneDrive 示例:
# - id: "my-onedrive"
# kind: "onedrive"
@@ -71,18 +74,11 @@ preview:
# root_id: "root"
# params:
# refresh_token: "..."
# # 默认 use_online_api=true,会使用 OpenList 在线续期 API。
# # 如需使用自己创建的 Google OAuth 客户端,取消下面三行注释:
# # use_online_api: "false"
# # client_id: "..."
# # client_secret: "..."
# 本地存储示例:
# - id: "local-media"
# kind: "localstorage"
# name: "本地视频目录"
# root_id: "/"
# params:
# # Docker 部署时这里和 .strm 里的绝对路径都必须使用容器内路径。
# # 例如宿主机 /mnt/videos 挂载为 /media,就填写 /media。
# path: "/mnt/videos"
drives: []
+1 -1
View File
@@ -10,7 +10,6 @@ require (
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
github.com/go-chi/chi/v5 v5.1.0
github.com/go-resty/resty/v2 v2.14.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/net v0.27.0
golang.org/x/sys v0.30.0
gopkg.in/yaml.v3 v3.0.1
@@ -29,6 +28,7 @@ require (
github.com/pierrec/lz4/v4 v4.1.17 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
golang.org/x/crypto v0.25.0 // indirect
golang.org/x/time v0.8.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+46 -171
View File
@@ -11,12 +11,10 @@ import (
"io"
"math/rand/v2"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
@@ -26,7 +24,6 @@ import (
"github.com/video-site/backend/internal/drives/localstorage"
"github.com/video-site/backend/internal/drives/localupload"
"github.com/video-site/backend/internal/drives/spider91"
"github.com/video-site/backend/internal/mediaasset"
"github.com/video-site/backend/internal/proxy"
)
@@ -56,10 +53,6 @@ type Server struct {
UploadDir string
OnVideoUploaded func(*catalog.Video)
tagCacheMu sync.Mutex
tagCacheUntil time.Time
tagCache []TagDTO
// GetTheme 返回当前生效的主题("dark" | "pink")。前台 /api/settings/theme 用,
// 不需要登录。无注入时返回 "dark"。
GetTheme func() string
@@ -93,12 +86,6 @@ type VideoDTO struct {
Category string `json:"category,omitempty"`
}
type TagDTO struct {
ID string `json:"id"`
Label string `json:"label"`
Count int `json:"count"`
}
type VideoDetailDTO struct {
VideoDTO
VideoSrc string `json:"videoSrc"`
@@ -147,7 +134,7 @@ func (s *Server) RegisterRoutes(r chi.Router, a *auth.Authenticator) {
r.Post("/api/shorts/next", s.handleShortsNext)
// 代理路由同样需要鉴权,防止绕过
r.Get("/p/stream/{driveID}/*", s.handleStream)
r.Get("/p/stream/{driveID}/{fileID}", s.handleStream)
r.Get("/p/upload/{videoID}", s.handleUploadedVideo)
r.Get("/p/spider91/{videoID}", s.handleSpider91Video)
r.Get("/p/preview/{videoID}", s.handlePreview)
@@ -190,27 +177,6 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
}
items = appendUniqueVideos(items, fallback, homePageSize)
}
if len(items) < homePageSize && len(excludeIDs) > 0 {
// The browser keeps a recent-video exclude list so normal refreshes do not
// repeat too quickly. On small libraries that list can cover every visible
// video; when that happens, start a new random round instead of returning
// an empty home section.
roundExclude := videoIDs(items)
fallback, err := s.Catalog.RandomVideosWithReadyThumbnailsExcluding(r.Context(), roundExclude, homePageSize-len(items))
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
items = appendUniqueVideos(items, fallback, homePageSize)
}
if len(items) < homePageSize && len(excludeIDs) > 0 {
fallback, err := s.Catalog.RandomVideosExcluding(r.Context(), videoIDs(items), homePageSize-len(items))
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
items = appendUniqueVideos(items, fallback, homePageSize)
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, mapVideos(items))
}
@@ -270,16 +236,6 @@ func appendUniqueVideos(dst []*catalog.Video, candidates []*catalog.Video, limit
return dst
}
func videoIDs(items []*catalog.Video) []string {
out := make([]string, 0, len(items))
for _, item := range items {
if item != nil && item.ID != "" {
out = append(out, item.ID)
}
}
return out
}
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
page, _ := strconv.Atoi(q.Get("page"))
@@ -289,13 +245,12 @@ func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
}
sort := q.Get("sort")
params := catalog.ListParams{
Keyword: q.Get("q"),
Tag: q.Get("tag"),
Category: q.Get("cat"),
Sort: sort,
Page: page,
PageSize: size,
SkipTotal: strings.EqualFold(q.Get("count"), "false"),
Keyword: q.Get("q"),
Tag: q.Get("tag"),
Category: q.Get("cat"),
Sort: sort,
Page: page,
PageSize: size,
}
if sort == "" || sort == "latest" {
params.PreferReadyThumbnails = true
@@ -314,7 +269,7 @@ func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleVideoDetail(w http.ResponseWriter, r *http.Request) {
id := routeParam(r, "id")
id := chi.URLParam(r, "id")
v, err := s.Catalog.GetVideo(r.Context(), id)
if err != nil {
writeErr(w, http.StatusNotFound, err)
@@ -324,15 +279,6 @@ func (s *Server) handleVideoDetail(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusNotFound, sql.ErrNoRows)
return
}
if v.DriveID != localUploadDriveID {
if _, err := s.Catalog.GetDrive(r.Context(), v.DriveID); err != nil {
drives, listErr := s.Catalog.ListDrives(r.Context())
if listErr != nil || len(drives) > 0 {
writeErr(w, http.StatusNotFound, sql.ErrNoRows)
return
}
}
}
related := s.pickRelatedVideos(r.Context(), v, 6)
dto := mapVideo(v)
if d, err := s.Catalog.GetDrive(r.Context(), v.DriveID); err == nil {
@@ -344,7 +290,7 @@ func (s *Server) handleVideoDetail(w http.ResponseWriter, r *http.Request) {
VideoSrc: s.videoSource(v),
Poster: thumbnailURL(v),
Description: v.Description,
EmbedURL: fmt.Sprintf(`<iframe src="/embed/%s" width="640" height="360" frameborder="0" allowfullscreen></iframe>`, pathSegment(v.ID)),
EmbedURL: fmt.Sprintf(`<iframe src="/embed/%s" width="640" height="360" frameborder="0" allowfullscreen></iframe>`, v.ID),
AuthorProfile: AuthorProfile{
ID: "author-" + v.Author,
Name: v.Author,
@@ -496,32 +442,20 @@ func appendRandomRelated(picked []*catalog.Video, pool []*catalog.Video, targetL
}
func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) {
now := time.Now()
s.tagCacheMu.Lock()
if s.tagCache != nil && now.Before(s.tagCacheUntil) {
out := append([]TagDTO(nil), s.tagCache...)
s.tagCacheMu.Unlock()
w.Header().Set("Cache-Control", "private, max-age=15")
writeJSON(w, http.StatusOK, out)
return
}
s.tagCacheMu.Unlock()
stats, err := s.Catalog.ListTags(r.Context())
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
out := make([]TagDTO, 0, len(stats))
for _, stat := range stats {
out = append(out, TagDTO{ID: stat.Label, Label: stat.Label, Count: stat.Count})
type tag struct {
ID string `json:"id"`
Label string `json:"label"`
Count int `json:"count"`
}
out := make([]tag, 0, len(stats))
for _, stat := range stats {
out = append(out, tag{ID: stat.Label, Label: stat.Label, Count: stat.Count})
}
s.tagCacheMu.Lock()
s.tagCache = append([]TagDTO(nil), out...)
s.tagCacheUntil = now.Add(30 * time.Second)
s.tagCacheMu.Unlock()
w.Header().Set("Cache-Control", "private, max-age=15")
writeJSON(w, http.StatusOK, out)
}
@@ -623,7 +557,7 @@ type updateVideoTagsReq struct {
}
func (s *Server) handleUpdateVideoTags(w http.ResponseWriter, r *http.Request) {
id := routeParam(r, "id")
id := chi.URLParam(r, "id")
var body updateVideoTagsReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, http.StatusBadRequest, err)
@@ -646,7 +580,7 @@ func (s *Server) handleUpdateVideoTags(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleLike(w http.ResponseWriter, r *http.Request) {
id := routeParam(r, "id")
id := chi.URLParam(r, "id")
likes, err := s.Catalog.IncrementLike(r.Context(), id)
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
@@ -658,7 +592,7 @@ func (s *Server) handleLike(w http.ResponseWriter, r *http.Request) {
// handleUnlike 取消点赞:likes - 1(保底 0)。
// 短视频模式中爱心按钮点击切换状态时使用。
func (s *Server) handleUnlike(w http.ResponseWriter, r *http.Request) {
id := routeParam(r, "id")
id := chi.URLParam(r, "id")
likes, err := s.Catalog.DecrementLike(r.Context(), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -672,7 +606,7 @@ func (s *Server) handleUnlike(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleView(w http.ResponseWriter, r *http.Request) {
id := routeParam(r, "id")
id := chi.URLParam(r, "id")
views, err := s.Catalog.IncrementView(r.Context(), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -686,7 +620,7 @@ func (s *Server) handleView(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleHideVideo(w http.ResponseWriter, r *http.Request) {
id := routeParam(r, "id")
id := chi.URLParam(r, "id")
if err := s.Catalog.HideVideo(r.Context(), id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeErr(w, http.StatusNotFound, err)
@@ -803,12 +737,12 @@ func (s *Server) handleUploadVideo(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
driveID := routeParam(r, "driveID")
fileID := routeWildcardParam(r, "*")
driveID := chi.URLParam(r, "driveID")
fileID := chi.URLParam(r, "fileID")
s.Proxy.ServeStream(w, r, driveID, fileID)
}
func (s *Server) handleUploadedVideo(w http.ResponseWriter, r *http.Request) {
videoID := routeParam(r, "videoID")
videoID := chi.URLParam(r, "videoID")
v, err := s.Catalog.GetVideo(r.Context(), videoID)
if err != nil || v.Hidden || v.DriveID != localUploadDriveID {
http.NotFound(w, r)
@@ -832,7 +766,7 @@ func (s *Server) handleUploadedVideo(w http.ResponseWriter, r *http.Request) {
// 路径形如 /p/spider91/<videoID>videoID = "spider91-<driveID>-<sourceID>"。
// 通过 catalog 拿到 file_id"<sourceID>.mp4"),再让 driver 解析到绝对路径并 ServeFile。
func (s *Server) handleSpider91Video(w http.ResponseWriter, r *http.Request) {
videoID := routeParam(r, "videoID")
videoID := chi.URLParam(r, "videoID")
v, err := s.Catalog.GetVideo(r.Context(), videoID)
if err != nil || v.Hidden {
http.NotFound(w, r)
@@ -867,7 +801,7 @@ func (s *Server) handleSpider91Video(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
videoID := routeParam(r, "videoID")
videoID := chi.URLParam(r, "videoID")
v, err := s.Catalog.GetVideo(r.Context(), videoID)
if err != nil {
http.NotFound(w, r)
@@ -892,20 +826,15 @@ func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleThumb(w http.ResponseWriter, r *http.Request) {
videoID := routeParam(r, "videoID")
var clean string
for _, path := range mediaasset.ThumbnailPathCandidates(s.LocalDir, videoID) {
candidate := filepath.Clean(path)
if !strings.HasPrefix(candidate, filepath.Clean(s.LocalDir)) {
http.Error(w, "invalid path", http.StatusForbidden)
return
}
if _, err := os.Stat(candidate); err == nil {
clean = candidate
break
}
videoID := chi.URLParam(r, "videoID")
// 直接读本地 thumbs 目录中 <videoID>.jpg
path := filepath.Join(s.LocalDir, "thumbs", videoID+".jpg")
clean := filepath.Clean(path)
if !strings.HasPrefix(clean, filepath.Clean(s.LocalDir)) {
http.Error(w, "invalid path", http.StatusForbidden)
return
}
if clean == "" {
if _, err := os.Stat(clean); err != nil {
w.Header().Set("Cache-Control", "no-store")
http.NotFound(w, r)
return
@@ -927,7 +856,7 @@ func mapVideo(v *catalog.Video) VideoDTO {
}
return VideoDTO{
ID: v.ID,
Href: "/video/" + pathSegment(v.ID),
Href: "/video/" + v.ID,
Title: v.Title,
Thumbnail: thumbnailURL(v),
PreviewSrc: previewURL(v),
@@ -949,7 +878,7 @@ func mapVideo(v *catalog.Video) VideoDTO {
}
func previewURL(v *catalog.Video) string {
base := "/p/preview/" + pathSegment(v.ID)
base := "/p/preview/" + v.ID
if v.UpdatedAt.IsZero() {
return base
}
@@ -957,83 +886,31 @@ func previewURL(v *catalog.Video) string {
}
func thumbnailURL(v *catalog.Video) string {
base := "/p/thumb/" + pathSegment(v.ID)
if v.ThumbnailURL != "" {
base = v.ThumbnailURL
if thumbnailURLMatchesVideoID(base, v.ID) {
base = "/p/thumb/" + pathSegment(v.ID)
}
return v.ThumbnailURL
}
if !strings.HasPrefix(base, "/p/thumb/") || v.UpdatedAt.IsZero() {
return base
}
return base + "?v=" + strconv.FormatInt(v.UpdatedAt.UnixMilli(), 10)
return "/p/thumb/" + v.ID
}
func (s *Server) videoSource(v *catalog.Video) string {
if v.DriveID == localUploadDriveID {
return "/p/upload/" + pathSegment(v.ID)
return "/p/upload/" + v.ID
}
if s.Proxy != nil && s.Proxy.Registry != nil {
if d, ok := s.Proxy.Registry.Get(v.DriveID); ok {
switch d.Kind() {
case spider91.Kind:
return "/p/spider91/" + pathSegment(v.ID)
}
if d, ok := s.Proxy.Registry.Get(v.DriveID); ok && d.Kind() == spider91.Kind {
return "/p/spider91/" + v.ID
}
}
return fmt.Sprintf("/p/stream/%s/%s", pathSegment(v.DriveID), pathSegment(v.FileID))
return fmt.Sprintf("/p/stream/%s/%s", v.DriveID, v.FileID)
}
// videoSource 兼容旧调用点,没有 server context 时按之前逻辑回退到 /p/stream。
// 内部新增的代码请使用 (*Server).videoSource。
func videoSource(v *catalog.Video) string {
if v.DriveID == localUploadDriveID {
return "/p/upload/" + pathSegment(v.ID)
return "/p/upload/" + v.ID
}
return fmt.Sprintf("/p/stream/%s/%s", pathSegment(v.DriveID), pathSegment(v.FileID))
}
func pathSegment(value string) string {
return url.PathEscape(value)
}
func routeParam(r *http.Request, key string) string {
value := chi.URLParam(r, key)
if value == "" {
return ""
}
if decoded, err := url.PathUnescape(value); err == nil {
return decoded
}
return value
}
func routeWildcardParam(r *http.Request, key string) string {
value := chi.URLParam(r, key)
if value == "" {
return ""
}
value = strings.TrimPrefix(value, "/")
if decoded, err := url.PathUnescape(value); err == nil {
return decoded
}
return value
}
func thumbnailURLMatchesVideoID(value, videoID string) bool {
if !strings.HasPrefix(value, "/p/thumb/") {
return false
}
tail := strings.TrimPrefix(value, "/p/thumb/")
if idx := strings.IndexByte(tail, '?'); idx >= 0 {
tail = tail[:idx]
}
if tail == videoID {
return true
}
decoded, err := url.PathUnescape(tail)
return err == nil && decoded == videoID
return fmt.Sprintf("/p/stream/%s/%s", v.DriveID, v.FileID)
}
func driveKindLabel(kind string) string {
@@ -1042,12 +919,10 @@ func driveKindLabel(kind string) string {
return "夸克网盘"
case "p115":
return "115 网盘"
case "p123":
return "123网盘"
case "pikpak":
return "PikPak"
case "wopan":
return "联通盘"
return "联通盘"
case "onedrive":
return "OneDrive"
case "googledrive":
-265
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
@@ -18,8 +17,6 @@ import (
"github.com/go-chi/chi/v5"
"github.com/video-site/backend/internal/catalog"
"github.com/video-site/backend/internal/drives"
"github.com/video-site/backend/internal/mediaasset"
"github.com/video-site/backend/internal/proxy"
)
@@ -68,68 +65,6 @@ func TestVideoSourceKeepsDirectStreamForMp4(t *testing.T) {
}
}
func TestVideoURLsEscapePathSegments(t *testing.T) {
updated := time.UnixMilli(1778863000123)
v := &catalog.Video{
ID: "wopan-drive-fid/with space",
DriveID: "drive-1",
FileID: "fid/with space",
Title: "Video",
UpdatedAt: updated,
}
dto := mapVideo(v)
if dto.Href != "/video/wopan-drive-fid%2Fwith%20space" {
t.Fatalf("href = %q, want escaped video id", dto.Href)
}
if dto.PreviewSrc != "/p/preview/wopan-drive-fid%2Fwith%20space?v=1778863000123" {
t.Fatalf("preview = %q, want escaped video id", dto.PreviewSrc)
}
if dto.Thumbnail != "/p/thumb/wopan-drive-fid%2Fwith%20space?v=1778863000123" {
t.Fatalf("thumbnail = %q, want escaped video id", dto.Thumbnail)
}
if got := videoSource(v); got != "/p/stream/drive-1/fid%2Fwith%20space" {
t.Fatalf("video source = %q, want escaped file id", got)
}
}
func TestThumbnailURLRewritesStoredLocalURLForUnsafeVideoID(t *testing.T) {
got := thumbnailURL(&catalog.Video{
ID: "wopan-drive-fid/with space",
ThumbnailURL: "/p/thumb/wopan-drive-fid/with space",
UpdatedAt: time.UnixMilli(1778863000123),
})
if got != "/p/thumb/wopan-drive-fid%2Fwith%20space?v=1778863000123" {
t.Fatalf("thumbnail URL = %q, want escaped local URL", got)
}
}
func TestHandleStreamDecodesEscapedWildcardFileID(t *testing.T) {
local := filepath.Join(t.TempDir(), "video.mp4")
if err := os.WriteFile(local, []byte("ok"), 0o644); err != nil {
t.Fatalf("write local video: %v", err)
}
drv := &apiStreamFakeDrive{localPath: local}
reg := proxy.NewRegistry()
reg.Set("drive-1", drv)
srv := &Server{Proxy: proxy.New(reg)}
router := chi.NewRouter()
router.Get("/p/stream/{driveID}/*", srv.handleStream)
req := httptest.NewRequest(http.MethodGet, "/p/stream/drive-1/fid%2Fwith%20space", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if drv.fileID != "fid/with space" {
t.Fatalf("fileID = %q, want decoded original", drv.fileID)
}
}
func TestVideoSourceUsesLocalUploadRoute(t *testing.T) {
v := &catalog.Video{
ID: "video-1",
@@ -164,70 +99,6 @@ func TestPreviewURLFallsBackWithoutUpdatedAt(t *testing.T) {
}
}
func TestHandleVideoDetailDecodesEscapedVideoID(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: "wopan-drive-fid/with space",
DriveID: "drive-1",
FileID: "fid/with space",
Title: "Video",
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("seed video: %v", err)
}
router := chi.NewRouter()
router.Get("/api/video/{id}", (&Server{Catalog: cat}).handleVideoDetail)
req := httptest.NewRequest(http.MethodGet, "/api/video/wopan-drive-fid%2Fwith%20space", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(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 got.ID != "wopan-drive-fid/with space" {
t.Fatalf("id = %q, want original video id", got.ID)
}
}
func TestThumbnailURLVersionsLocalGeneratedThumbnails(t *testing.T) {
got := thumbnailURL(&catalog.Video{
ID: "video-1",
ThumbnailURL: "/p/thumb/video-1",
UpdatedAt: time.UnixMilli(1778863000123),
})
if got != "/p/thumb/video-1?v=1778863000123" {
t.Fatalf("thumbnail URL = %q, want versioned local URL", got)
}
remote := "https://thumb.example/video-1.jpg"
got = thumbnailURL(&catalog.Video{
ID: "video-1",
ThumbnailURL: remote,
UpdatedAt: time.UnixMilli(1778863000123),
})
if got != remote {
t.Fatalf("remote thumbnail URL = %q, want unchanged %q", got, remote)
}
}
func TestHandleHomePrioritizesVideosWithReadyThumbnails(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
@@ -348,63 +219,6 @@ func TestHandleHomeExcludesRecentlyShownVideos(t *testing.T) {
}
}
func TestHandleHomeStartsNewRoundWhenRecentExcludesAllVisibleVideos(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()
excludes := make([]string, 0, homePageSize+2)
for i := 0; i < homePageSize+2; i++ {
id := "ready-video-" + strconv.Itoa(i)
excludes = append(excludes, "exclude="+id)
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) * time.Minute),
CreatedAt: now.Add(time.Duration(i) * time.Minute),
UpdatedAt: now.Add(time.Duration(i) * time.Minute),
}); err != nil {
t.Fatalf("seed ready video %s: %v", id, err)
}
}
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/home?"+strings.Join(excludes, "&"), 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; body=%s", len(got), homePageSize, rr.Body.String())
}
seen := map[string]bool{}
for _, item := range got {
if seen[item.ID] {
t.Fatalf("home returned duplicate video %q; items=%#v", item.ID, got)
}
seen[item.ID] = true
if !strings.HasPrefix(item.ID, "ready-video-") {
t.Fatalf("home returned unexpected video %q; items=%#v", item.ID, got)
}
}
}
func TestHandleListLatestPrefersReadyThumbnails(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
@@ -476,26 +290,6 @@ func TestHandleListLatestPrefersReadyThumbnails(t *testing.T) {
t.Fatalf("thumbnail for %q = %q, want ready thumbnail URL", item.ID, item.Thumbnail)
}
}
rr = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/api/list?page=1&size=12&sort=latest&count=false", nil)
(&Server{Catalog: cat}).handleList(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("count=false status = %d, body = %s", rr.Code, rr.Body.String())
}
got = struct {
Items []VideoDTO `json:"items"`
Total int `json:"total"`
}{}
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
t.Fatalf("decode count=false response: %v", err)
}
if got.Total != 0 {
t.Fatalf("count=false total = %d, want 0", got.Total)
}
if len(got.Items) != 12 {
t.Fatalf("count=false items = %d, want 12", len(got.Items))
}
}
func TestHandleUploadVideoSavesFileVideoTagsAndQueuesPreview(t *testing.T) {
@@ -717,34 +511,6 @@ func TestHandlePreviewIgnoresRemotePreviewFileIDAndServesLocalFile(t *testing.T)
}
}
func TestHandleThumbServesHashedPathForLongVideoID(t *testing.T) {
localDir := t.TempDir()
longID := "localstorage-" + strings.Repeat("x", 240)
thumbPath := mediaasset.ThumbnailPath(localDir, longID)
if err := os.MkdirAll(filepath.Dir(thumbPath), 0o755); err != nil {
t.Fatalf("mkdir thumb dir: %v", err)
}
if err := os.WriteFile(thumbPath, []byte("thumb-bytes"), 0o644); err != nil {
t.Fatalf("write thumb: %v", err)
}
server := &Server{
LocalDir: localDir,
Proxy: proxy.New(proxy.NewRegistry()),
}
req := requestWithRouteParam(http.MethodGet, "/p/thumb/"+longID, "videoID", longID, strings.NewReader(``))
rr := httptest.NewRecorder()
server.handleThumb(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if rr.Body.String() != "thumb-bytes" {
t.Fatalf("body = %q, want thumb bytes", rr.Body.String())
}
}
func TestHandleTagsReturnsUnifiedTagPool(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
@@ -1191,37 +957,6 @@ func sameStringSet(a, b []string) bool {
return true
}
type apiStreamFakeDrive struct {
localPath string
fileID string
}
func (d *apiStreamFakeDrive) Kind() string { return "fake" }
func (d *apiStreamFakeDrive) ID() string { return "drive-1" }
func (d *apiStreamFakeDrive) Init(context.Context) error {
return nil
}
func (d *apiStreamFakeDrive) List(context.Context, string) ([]drives.Entry, error) {
return nil, drives.ErrNotSupported
}
func (d *apiStreamFakeDrive) Stat(context.Context, string) (*drives.Entry, error) {
return nil, drives.ErrNotSupported
}
func (d *apiStreamFakeDrive) StreamURL(_ context.Context, fileID string) (*drives.StreamLink, error) {
d.fileID = fileID
return &drives.StreamLink{
URL: d.localPath,
Expires: time.Now().Add(time.Minute),
}, nil
}
func (d *apiStreamFakeDrive) Upload(context.Context, string, string, io.Reader, int64) (string, error) {
return "", drives.ErrNotSupported
}
func (d *apiStreamFakeDrive) EnsureDir(context.Context, string) (string, error) {
return "", drives.ErrNotSupported
}
func (d *apiStreamFakeDrive) RootID() string { return "root" }
func requestWithVideoID(method, target, videoID string, body *strings.Reader) *http.Request {
return requestWithRouteParam(method, target, "id", videoID, body)
}
+16 -484
View File
@@ -5,7 +5,6 @@ import (
"database/sql"
_ "embed"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
@@ -20,15 +19,6 @@ type Catalog struct {
db *sql.DB
}
type CrawlerAssetCounts struct {
Total int
Local int
Migrated int
Thumbnail DriveThumbnailCounts
Teaser DriveTeaserCounts
Fingerprint DriveFingerprintCounts
}
func Open(path string) (*Catalog, error) {
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
if err != nil {
@@ -88,11 +78,6 @@ type Video struct {
func (c *Catalog) UpsertVideo(ctx context.Context, v *Video) error {
existed := c.videoExists(ctx, v.ID)
v.ContentHash = normalizeContentHash(v.ContentHash)
v.SampledSHA256 = normalizeContentHash(v.SampledSHA256)
fingerprintStatus := nullableStatus(v.FingerprintStatus)
if v.SampledSHA256 != "" && (v.FingerprintStatus == "" || v.FingerprintStatus == "pending") {
fingerprintStatus = "ready"
}
tagsJSON, _ := json.Marshal(v.Tags)
badgesJSON, _ := json.Marshal(v.Badges)
now := time.Now().UnixMilli()
@@ -103,13 +88,13 @@ func (c *Catalog) UpsertVideo(ctx context.Context, v *Video) error {
_, err := c.db.ExecContext(ctx, `
INSERT INTO videos (
id, drive_id, file_id, file_name, content_hash, sampled_sha256, fingerprint_status, fingerprint_error, parent_id, title, author, tags,
id, drive_id, file_id, file_name, content_hash, parent_id, title, author, tags,
duration_seconds, size_bytes, ext, quality, thumbnail_url, thumbnail_status,
preview_file_id, preview_local, preview_status,
views, favorites, comments, likes, dislikes,
category, hidden, badges, description, published_at, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, CASE WHEN COALESCE(?, '') != '' THEN 'ready' ELSE 'pending' END,
?, ?, ?,
?, ?, ?, ?, ?,
@@ -128,18 +113,15 @@ ON CONFLICT(id) DO UPDATE SET
ELSE videos.content_hash
END,
sampled_sha256 = CASE
WHEN videos.size_bytes != excluded.size_bytes THEN excluded.sampled_sha256
WHEN excluded.sampled_sha256 != '' THEN excluded.sampled_sha256
WHEN videos.size_bytes != excluded.size_bytes THEN ''
ELSE videos.sampled_sha256
END,
fingerprint_status = CASE
WHEN videos.size_bytes != excluded.size_bytes THEN COALESCE(excluded.fingerprint_status, 'pending')
WHEN excluded.sampled_sha256 != '' THEN COALESCE(excluded.fingerprint_status, 'ready')
WHEN videos.size_bytes != excluded.size_bytes THEN 'pending'
ELSE COALESCE(videos.fingerprint_status, 'pending')
END,
fingerprint_error = CASE
WHEN videos.size_bytes != excluded.size_bytes THEN COALESCE(excluded.fingerprint_error, '')
WHEN excluded.sampled_sha256 != '' THEN COALESCE(excluded.fingerprint_error, '')
WHEN videos.size_bytes != excluded.size_bytes THEN ''
ELSE COALESCE(videos.fingerprint_error, '')
END,
duration_seconds= excluded.duration_seconds,
@@ -160,7 +142,7 @@ ON CONFLICT(id) DO UPDATE SET
description = excluded.description,
updated_at = excluded.updated_at
`,
v.ID, v.DriveID, v.FileID, v.FileName, v.ContentHash, v.SampledSHA256, fingerprintStatus, v.FingerprintError, v.ParentID, v.Title, v.Author, string(tagsJSON),
v.ID, v.DriveID, v.FileID, v.FileName, v.ContentHash, v.ParentID, v.Title, v.Author, string(tagsJSON),
v.DurationSeconds, v.Size, v.Ext, v.Quality, v.ThumbnailURL, v.ThumbnailURL,
v.PreviewFileID, v.PreviewLocal, nullableStatus(v.PreviewStatus),
v.Views, v.Favorites, v.Comments, v.Likes, v.Dislikes,
@@ -564,7 +546,7 @@ func (c *Catalog) ListVideosByThumbnailStatus(ctx context.Context, driveID, stat
// Besides missing thumbnails, this includes videos with an existing thumbnail but
// missing duration metadata, because the thumbnail worker probes duration while
// it already has a stream link.
// Failed thumbnails are reported separately and should not block preview-video generation.
// Failed thumbnails are reported separately and should not block teaser generation.
// Videos whose local assets were cleared because they are fingerprint duplicates
// stay pending in the DB, but uniqueVideoWhereSQL keeps them out of this queue
// while their canonical sibling still exists.
@@ -640,56 +622,6 @@ func (c *Catalog) ListVideosByDrive(ctx context.Context, driveID string) ([]*Vid
return out, rows.Err()
}
func (c *Catalog) ListVideosByIDPrefix(ctx context.Context, prefix string) ([]*Video, error) {
prefix = strings.TrimSpace(prefix)
if prefix == "" {
return nil, fmt.Errorf("catalog: list videos by id prefix: empty prefix")
}
rows, err := c.db.QueryContext(ctx,
`SELECT `+allVideoCols+` FROM videos
WHERE SUBSTR(id, 1, LENGTH(?)) = ?
ORDER BY created_at ASC, id ASC`,
prefix, prefix)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Video
for rows.Next() {
v, err := scanVideo(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
func (c *Catalog) ListVideosWithMissingDrive(ctx context.Context) ([]*Video, error) {
rows, err := c.db.QueryContext(ctx,
`SELECT `+allVideoCols+` FROM videos
WHERE drive_id != 'local-upload'
AND NOT EXISTS (
SELECT 1
FROM drives
WHERE drives.id = videos.drive_id
)
ORDER BY drive_id ASC, id ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Video
for rows.Next() {
v, err := scanVideo(rows)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
// ListVideoFileIDsByDrive 只返回某 drive 下所有视频的 file_id 集合,
// 比 ListVideosByDrive 轻量。
func (c *Catalog) ListVideoFileIDsByDrive(ctx context.Context, driveID string) ([]string, error) {
@@ -723,27 +655,10 @@ func (c *Catalog) ListVideoFileIDsByDrive(ctx context.Context, driveID string) (
// 用途:crawler 把这个集合写到 seen 文件,让 Python/Go 跳过已爬过的视频,
// 配合 --target-new 真正凑出 N 个未爬过的视频。
func (c *Catalog) ListSpider91Viewkeys(ctx context.Context, driveID string) ([]string, error) {
return c.ListCrawlerSourceIDs(ctx, "spider91", driveID)
}
// ListCrawlerSourceIDs lists source IDs that were already imported by a
// crawler-like drive. It reads both videos and deleted_videos so explicit admin
// deletions remain tombstoned for future crawler runs.
func (c *Catalog) ListCrawlerSourceIDs(ctx context.Context, kind, driveID string) ([]string, error) {
kind = strings.TrimSpace(kind)
driveID = strings.TrimSpace(driveID)
if kind == "" || driveID == "" {
return nil, nil
}
prefix := kind + "-" + driveID + "-"
prefix := "spider91-" + driveID + "-"
rows, err := c.db.QueryContext(ctx,
`SELECT SUBSTR(id, ?) FROM videos WHERE id LIKE ? || '%'
UNION
SELECT SUBSTR(id, ?) FROM deleted_videos WHERE id LIKE ? || '%'
UNION
SELECT source_id FROM crawler_seen_sources
WHERE kind = ? AND drive_id = ? AND status IN ('imported', 'duplicate')`,
len(prefix)+1, prefix, len(prefix)+1, prefix, kind, driveID)
`SELECT SUBSTR(id, ?) FROM videos WHERE id LIKE ? || '%'`,
len(prefix)+1, prefix)
if err != nil {
return nil, err
}
@@ -761,110 +676,6 @@ func (c *Catalog) ListCrawlerSourceIDs(ctx context.Context, kind, driveID string
return out, rows.Err()
}
// MarkCrawlerSourceSeen records the outcome for a crawler source item. Duplicate
// source IDs are included in future seen files so scripts can skip them before
// the backend downloads the same duplicate content again.
func (c *Catalog) MarkCrawlerSourceSeen(ctx context.Context, kind, driveID, sourceID, status, canonicalVideoID, sampledSHA256 string, size int64) error {
kind = strings.TrimSpace(kind)
driveID = strings.TrimSpace(driveID)
sourceID = strings.TrimSpace(sourceID)
status = strings.TrimSpace(status)
if kind == "" || driveID == "" || sourceID == "" {
return nil
}
switch status {
case "imported", "duplicate":
default:
return fmt.Errorf("catalog: unsupported crawler source status %q", status)
}
sampledSHA256 = normalizeContentHash(sampledSHA256)
if size < 0 {
size = 0
}
now := time.Now().UnixMilli()
_, err := c.db.ExecContext(ctx, `
INSERT INTO crawler_seen_sources (
kind, drive_id, source_id, status, canonical_video_id, sampled_sha256, size_bytes, first_seen_at, last_seen_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(kind, drive_id, source_id) DO UPDATE SET
status = excluded.status,
canonical_video_id = excluded.canonical_video_id,
sampled_sha256 = CASE
WHEN excluded.sampled_sha256 != '' THEN excluded.sampled_sha256
ELSE crawler_seen_sources.sampled_sha256
END,
size_bytes = CASE
WHEN excluded.size_bytes > 0 THEN excluded.size_bytes
ELSE crawler_seen_sources.size_bytes
END,
last_seen_at = excluded.last_seen_at`,
kind, driveID, sourceID, status, strings.TrimSpace(canonicalVideoID), sampledSHA256, size, now, now)
return err
}
// DeleteVideoWithTombstone records that an administrator explicitly deleted a
// video, then removes the visible catalog row. The tombstone is used by
// scanners/crawlers to avoid importing the same source file again.
func (c *Catalog) DeleteVideoWithTombstone(ctx context.Context, id string) error {
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var v struct {
ID string
DriveID string
FileID string
ContentHash string
FileName string
Size int64
}
row := tx.QueryRowContext(ctx, `
SELECT id, drive_id, file_id, COALESCE(content_hash, ''), COALESCE(file_name, ''), size_bytes
FROM videos
WHERE id = ?`, id)
if err := row.Scan(&v.ID, &v.DriveID, &v.FileID, &v.ContentHash, &v.FileName, &v.Size); err != nil {
return err
}
v.ContentHash = normalizeContentHash(v.ContentHash)
// 先记录这次视频关联的 tag_id,便于事务末尾清理孤儿 collection 标签。
tagIDs, err := collectVideoTagIDs(ctx, tx, id)
if err != nil {
return err
}
now := time.Now().UnixMilli()
if _, err := tx.ExecContext(ctx, `
INSERT INTO deleted_videos (id, drive_id, file_id, content_hash, file_name, size_bytes, deleted_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
drive_id = excluded.drive_id,
file_id = excluded.file_id,
content_hash = excluded.content_hash,
file_name = excluded.file_name,
size_bytes = excluded.size_bytes,
deleted_at = excluded.deleted_at`,
v.ID, v.DriveID, v.FileID, v.ContentHash, v.FileName, v.Size, now); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM video_tags WHERE video_id = ?`, id); err != nil {
return err
}
res, err := tx.ExecContext(ctx, `DELETE FROM videos WHERE id = ?`, id)
if err != nil {
return err
}
if rows, err := res.RowsAffected(); err == nil && rows == 0 {
return sql.ErrNoRows
}
if err := pruneOrphanCollectionTagsByID(ctx, tx, tagIDs); err != nil {
return err
}
return tx.Commit()
}
func (c *Catalog) DeleteVideo(ctx context.Context, id string) error {
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
@@ -898,55 +709,6 @@ func (c *Catalog) DeleteVideo(ctx context.Context, id string) error {
return tx.Commit()
}
func (c *Catalog) IsVideoDeleted(ctx context.Context, id string) (bool, error) {
id = strings.TrimSpace(id)
if id == "" {
return false, nil
}
var found int
err := c.db.QueryRowContext(ctx, `SELECT 1 FROM deleted_videos WHERE id = ? LIMIT 1`, id).Scan(&found)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
func (c *Catalog) IsDeletedVideoCandidate(ctx context.Context, id, driveID, fileID, contentHash, fileName string, size int64) (bool, error) {
id = strings.TrimSpace(id)
driveID = strings.TrimSpace(driveID)
fileID = strings.TrimSpace(fileID)
contentHash = normalizeContentHash(contentHash)
fileName = strings.TrimSpace(fileName)
if id == "" && driveID == "" {
return false, nil
}
var found int
err := c.db.QueryRowContext(ctx, `
SELECT 1
FROM deleted_videos
WHERE id = ?
OR (drive_id = ? AND ? != '' AND file_id = ?)
OR (drive_id = ? AND ? != '' AND content_hash = ?)
OR (drive_id = ? AND ? != '' AND ? > 0 AND file_name = ? AND size_bytes = ?)
LIMIT 1`,
id,
driveID, fileID, fileID,
driveID, contentHash, contentHash,
driveID, fileName, size, fileName, size,
).Scan(&found)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
func (c *Catalog) FindVideoByContentHash(ctx context.Context, hash string) (*Video, error) {
hash = normalizeContentHash(hash)
if hash == "" {
@@ -974,103 +736,6 @@ func (c *Catalog) FindVideoByFileSignature(ctx context.Context, fileName string,
return scanVideo(row)
}
// FindEquivalentVideo returns the earliest visible video that represents the
// same content as source by strong hash or sampled fingerprint, regardless of
// which drive currently owns it.
func (c *Catalog) FindEquivalentVideo(ctx context.Context, source *Video) (*Video, error) {
if source == nil {
return nil, sql.ErrNoRows
}
where, args, ok := equivalentVideoLookupWhere(source)
if !ok {
return nil, sql.ErrNoRows
}
args = append([]any{source.ID}, args...)
row := c.db.QueryRowContext(ctx,
`SELECT `+allVideoCols+` FROM videos
WHERE id != ?
AND COALESCE(hidden, 0) = 0
AND COALESCE(file_id, '') != ''
AND (`+where+`)
ORDER BY created_at ASC, id ASC
LIMIT 1`, args...)
return scanVideo(row)
}
// FindEquivalentVideoOnDrive returns a visible video on driveID that represents
// the same content as source by strong hash or sampled fingerprint.
func (c *Catalog) FindEquivalentVideoOnDrive(ctx context.Context, source *Video, driveID string) (*Video, error) {
driveID = strings.TrimSpace(driveID)
if source == nil || driveID == "" {
return nil, sql.ErrNoRows
}
where, args, ok := equivalentVideoLookupWhere(source)
if !ok {
return nil, sql.ErrNoRows
}
args = append([]any{driveID, source.ID}, args...)
row := c.db.QueryRowContext(ctx,
`SELECT `+allVideoCols+` FROM videos
WHERE drive_id = ?
AND id != ?
AND COALESCE(hidden, 0) = 0
AND COALESCE(file_id, '') != ''
AND (`+where+`)
ORDER BY created_at ASC, id ASC
LIMIT 1`, args...)
return scanVideo(row)
}
// HasReadyEquivalentPreview reports whether another visible row for the same
// content already has a ready preview video.
func (c *Catalog) HasReadyEquivalentPreview(ctx context.Context, source *Video) (bool, error) {
if source == nil {
return false, nil
}
where, args, ok := equivalentVideoLookupWhere(source)
if !ok {
return false, nil
}
args = append([]any{source.ID}, args...)
var found int
err := c.db.QueryRowContext(ctx,
`SELECT 1 FROM videos
WHERE id != ?
AND COALESCE(hidden, 0) = 0
AND COALESCE(preview_status, 'pending') = 'ready'
AND (`+where+`)
LIMIT 1`, args...).Scan(&found)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
func equivalentVideoLookupWhere(source *Video) (string, []any, bool) {
if source == nil {
return "", nil, false
}
var parts []string
var args []any
if hash := normalizeContentHash(source.ContentHash); hash != "" {
parts = append(parts, "(COALESCE(content_hash, '') != '' AND content_hash = ?)")
args = append(args, hash)
}
if source.Size > 0 {
if sampled := normalizeContentHash(source.SampledSHA256); sampled != "" {
parts = append(parts, "(size_bytes = ? AND COALESCE(sampled_sha256, '') != '' AND sampled_sha256 = ?)")
args = append(args, source.Size, sampled)
}
}
if len(parts) == 0 {
return "", nil, false
}
return strings.Join(parts, " OR "), args, true
}
func (c *Catalog) ListVideosNeedingFingerprint(ctx context.Context, driveID string, limit int) ([]*Video, error) {
if limit <= 0 {
limit = 10000
@@ -1164,7 +829,6 @@ type ListParams struct {
Sort string // latest | hot | week | long
ThumbnailReadyOnly bool
PreferReadyThumbnails bool
SkipTotal bool
Page int
PageSize int
}
@@ -1200,7 +864,6 @@ func (c *Catalog) ListVideos(ctx context.Context, p ListParams) ([]*Video, int,
where = append(where, "COALESCE(thumbnail_url, '') != ''")
}
where = append(where, "COALESCE(hidden, 0) = 0")
where = append(where, activeDriveWhereSQL)
where = append(where, uniqueVideoWhereSQL)
whereSQL := ""
@@ -1222,11 +885,10 @@ func (c *Catalog) ListVideos(ctx context.Context, p ListParams) ([]*Video, int,
orderBy = " ORDER BY " + readyOrderPrefix + "duration_seconds DESC"
}
// count
var total int
if !p.SkipTotal {
if err := c.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM videos"+whereSQL, args...).Scan(&total); err != nil {
return nil, 0, err
}
if err := c.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM videos"+whereSQL, args...).Scan(&total); err != nil {
return nil, 0, err
}
// list
@@ -1257,7 +919,6 @@ func (c *Catalog) CountVisibleVideos(ctx context.Context) (int, error) {
err := c.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM videos
WHERE COALESCE(hidden, 0) = 0
AND `+activeDriveWhereSQL+`
AND `+uniqueVideoWhereSQL,
).Scan(&total)
if err != nil {
@@ -1286,7 +947,6 @@ func (c *Catalog) randomVideosExcluding(ctx context.Context, excludeIDs []string
cleaned := cleanVideoIDs(excludeIDs)
args := make([]any, 0, len(cleaned)+1)
whereSQL := `WHERE COALESCE(hidden, 0) = 0
AND ` + activeDriveWhereSQL + `
AND ` + uniqueVideoWhereSQL
if thumbnailReadyOnly {
whereSQL += " AND COALESCE(thumbnail_url, '') != ''"
@@ -1370,7 +1030,6 @@ func (c *Catalog) LeastPopulatedVisibleUniqueTag(ctx context.Context, labels []s
`SELECT COUNT(*)
FROM videos
WHERE COALESCE(hidden, 0) = 0
AND `+activeDriveWhereSQL+`
AND `+uniqueVideoWhereSQL+`
AND EXISTS (
SELECT 1
@@ -1407,7 +1066,6 @@ func (c *Catalog) RandomVideosByTagExcluding(ctx context.Context, tag string, ex
args := make([]any, 0, len(cleaned)+2)
args = append(args, tag)
whereSQL := `WHERE COALESCE(hidden, 0) = 0
AND ` + activeDriveWhereSQL + `
AND ` + uniqueVideoWhereSQL + `
AND EXISTS (
SELECT 1
@@ -1613,121 +1271,6 @@ func (c *Catalog) CountFingerprintsByDrive(ctx context.Context) (map[string]Driv
return out, nil
}
func (c *Catalog) CountCrawlerAssets(ctx context.Context, crawlerID string, prefixes []string) (CrawlerAssetCounts, error) {
var out CrawlerAssetCounts
crawlerID = strings.TrimSpace(crawlerID)
prefixes = cleanCrawlerIDPrefixes(prefixes)
if crawlerID == "" || len(prefixes) == 0 {
return out, nil
}
where := make([]string, 0, len(prefixes))
args := make([]any, 0, 2+len(prefixes))
args = append(args, crawlerID, crawlerID)
for range prefixes {
where = append(where, "id LIKE ? ESCAPE '\\'")
}
for _, prefix := range prefixes {
args = append(args, escapeSQLLike(prefix)+"%")
}
query := `SELECT
COUNT(*) AS total_count,
COUNT(CASE WHEN drive_id = ? THEN 1 END) AS local_count,
COUNT(CASE WHEN drive_id != ? THEN 1 END) AS migrated_count,
COUNT(CASE WHEN EXISTS (
SELECT 1 FROM videos AS asset_dup
WHERE ` + crawlerAssetEquivalentSQL("asset_dup", "videos") + `
AND COALESCE(asset_dup.thumbnail_url, '') != ''
) THEN 1 END) AS thumbnail_ready_count,
COUNT(CASE WHEN NOT EXISTS (
SELECT 1 FROM videos AS asset_dup
WHERE ` + crawlerAssetEquivalentSQL("asset_dup", "videos") + `
AND COALESCE(asset_dup.thumbnail_url, '') != ''
)
AND COALESCE(thumbnail_url, '') = ''
AND COALESCE(thumbnail_status, 'pending') NOT IN ('failed', 'skipped') THEN 1 END) AS thumbnail_pending_count,
COUNT(CASE WHEN NOT EXISTS (
SELECT 1 FROM videos AS asset_dup
WHERE ` + crawlerAssetEquivalentSQL("asset_dup", "videos") + `
AND COALESCE(asset_dup.thumbnail_url, '') != ''
)
AND COALESCE(thumbnail_url, '') = ''
AND COALESCE(thumbnail_status, 'pending') = 'failed' THEN 1 END) AS thumbnail_failed_count,
COUNT(CASE WHEN EXISTS (
SELECT 1 FROM videos AS asset_dup
WHERE ` + crawlerAssetEquivalentSQL("asset_dup", "videos") + `
AND COALESCE(asset_dup.preview_status, 'pending') = 'ready'
) THEN 1 END) AS teaser_ready_count,
COUNT(CASE WHEN NOT EXISTS (
SELECT 1 FROM videos AS asset_dup
WHERE ` + crawlerAssetEquivalentSQL("asset_dup", "videos") + `
AND COALESCE(asset_dup.preview_status, 'pending') = 'ready'
)
AND COALESCE(preview_status, 'pending') = 'pending' THEN 1 END) AS teaser_pending_count,
COUNT(CASE WHEN NOT EXISTS (
SELECT 1 FROM videos AS asset_dup
WHERE ` + crawlerAssetEquivalentSQL("asset_dup", "videos") + `
AND COALESCE(asset_dup.preview_status, 'pending') = 'ready'
)
AND COALESCE(preview_status, 'pending') = 'failed' THEN 1 END) AS teaser_failed_count,
COUNT(CASE WHEN COALESCE(sampled_sha256, '') != ''
OR COALESCE(fingerprint_status, 'pending') = 'ready' THEN 1 END) AS fingerprint_ready_count,
COUNT(CASE WHEN size_bytes > 0
AND COALESCE(sampled_sha256, '') = ''
AND COALESCE(fingerprint_status, 'pending') = 'pending' THEN 1 END) AS fingerprint_pending_count,
COUNT(CASE WHEN COALESCE(sampled_sha256, '') = ''
AND COALESCE(fingerprint_status, 'pending') = 'failed' THEN 1 END) AS fingerprint_failed_count
FROM videos
WHERE COALESCE(hidden, 0) = 0
AND (` + strings.Join(where, " OR ") + `)`
err := c.db.QueryRowContext(ctx, query, args...).Scan(
&out.Total,
&out.Local,
&out.Migrated,
&out.Thumbnail.Ready,
&out.Thumbnail.Pending,
&out.Thumbnail.Failed,
&out.Teaser.Ready,
&out.Teaser.Pending,
&out.Teaser.Failed,
&out.Fingerprint.Ready,
&out.Fingerprint.Pending,
&out.Fingerprint.Failed,
)
return out, err
}
func crawlerAssetEquivalentSQL(candidateAlias, sourceAlias string) string {
return fmt.Sprintf(`(%[1]s.id = %[2]s.id
OR (COALESCE(%[2]s.content_hash, '') != ''
AND %[1]s.content_hash = %[2]s.content_hash)
OR (%[2]s.size_bytes > 0
AND COALESCE(%[2]s.sampled_sha256, '') != ''
AND %[1]s.size_bytes = %[2]s.size_bytes
AND %[1]s.sampled_sha256 = %[2]s.sampled_sha256))`, candidateAlias, sourceAlias)
}
func cleanCrawlerIDPrefixes(prefixes []string) []string {
out := make([]string, 0, len(prefixes))
seen := map[string]bool{}
for _, prefix := range prefixes {
prefix = strings.TrimSpace(prefix)
if prefix == "" || seen[prefix] {
continue
}
seen[prefix] = true
out = append(out, prefix)
}
return out
}
func escapeSQLLike(raw string) string {
raw = strings.ReplaceAll(raw, `\`, `\\`)
raw = strings.ReplaceAll(raw, `%`, `\%`)
raw = strings.ReplaceAll(raw, `_`, `\_`)
return raw
}
func (c *Catalog) CountVideosNeedingFingerprint(ctx context.Context, driveID string) (int, error) {
var count int
err := c.db.QueryRowContext(ctx,
@@ -1772,7 +1315,7 @@ func (c *Catalog) ListLocalMediaRefs(ctx context.Context) ([]LocalMediaRef, erro
// DuplicateAssetCleanupCandidate points at a non-canonical video in a
// size+sampled_sha256 duplicate group that still owns generated local assets.
// The cleanup job uses this to remove duplicate thumbnails/preview videos without
// The cleanup job uses this to remove duplicate thumbnails/teasers without
// touching the original cloud file or deleting the catalog row.
type DuplicateAssetCleanupCandidate struct {
VideoID string
@@ -1900,7 +1443,7 @@ type Drive struct {
Credentials map[string]string `json:"credentials,omitempty"`
Status string `json:"status"`
LastError string `json:"lastError,omitempty"`
// TeaserEnabled 控制是否给本盘生成预览视频/封面。
// TeaserEnabled 控制是否给本盘生成 teaser/封面。
// 替代早期的全局 preview.enabled 开关;新建 drive 时 UpsertDrive 默认置 true。
TeaserEnabled bool `json:"teaserEnabled"`
// SkipDirIDs 是用户在管理后台为该盘选定的"扫描跳过目录"集合(网盘侧的目录 fileID)。
@@ -2033,7 +1576,7 @@ func (c *Catalog) DeleteDrive(ctx context.Context, id string) error {
return err
}
// SetDriveTeaserEnabled 切换某盘的预览视频/封面生成开关。
// SetDriveTeaserEnabled 切换某盘的 teaser/封面生成开关。
//
// 与 UpsertDrive 的区别:只动 teaser_enabled + updated_at 一列,不要求调用方
// 重传 kind / name / credentials 等容易踩坑的字段。
@@ -2170,17 +1713,6 @@ COALESCE(category, ''), COALESCE(hidden, 0), COALESCE(badges, '[]'), COALESCE(de
published_at, created_at, updated_at
`
const activeDriveWhereSQL = `(videos.drive_id = 'local-upload'
OR EXISTS (
SELECT 1
FROM drives
WHERE drives.id = videos.drive_id
)
OR NOT EXISTS (
SELECT 1
FROM drives
))`
const uniqueVideoWhereSQL = `((COALESCE(videos.content_hash, '') = ''
OR NOT EXISTS (
SELECT 1
-48
View File
@@ -2,7 +2,6 @@ package catalog
import (
"context"
"database/sql"
"sort"
"testing"
"time"
@@ -127,50 +126,3 @@ func TestListSpider91ViewkeysFindsMigratedVideos(t *testing.T) {
t.Fatalf("non-existent drive: got %v, want empty", other)
}
}
func TestDeleteVideoWithTombstonePreventsReimport(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() { _ = cat.Close() })
now := time.Now()
if err := cat.UpsertVideo(ctx, &Video{
ID: "spider91-91Spider-vk004",
DriveID: "91Spider",
FileID: "vk004.mp4",
FileName: "vk004.mp4",
ContentHash: "ABCDEF",
Title: "Deleted Spider",
Size: 2048,
PreviewStatus: "ready",
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("upsert: %v", err)
}
if err := cat.DeleteVideoWithTombstone(ctx, "spider91-91Spider-vk004"); err != nil {
t.Fatalf("delete with tombstone: %v", err)
}
if _, err := cat.GetVideo(ctx, "spider91-91Spider-vk004"); err != sql.ErrNoRows {
t.Fatalf("get deleted video error = %v, want sql.ErrNoRows", err)
}
deleted, err := cat.IsDeletedVideoCandidate(ctx, "spider91-91Spider-vk004", "91Spider", "vk004.mp4", "abcdef", "vk004.mp4", 2048)
if err != nil {
t.Fatalf("check deleted candidate: %v", err)
}
if !deleted {
t.Fatal("deleted candidate was not recognized")
}
viewkeys, err := cat.ListSpider91Viewkeys(ctx, "91Spider")
if err != nil {
t.Fatalf("ListSpider91Viewkeys: %v", err)
}
if len(viewkeys) != 1 || viewkeys[0] != "vk004" {
t.Fatalf("viewkeys = %#v, want [vk004]", viewkeys)
}
}
@@ -1,64 +0,0 @@
package catalog
import (
"context"
"testing"
"time"
)
func TestListVideosHidesMissingDriveVideosWhenDrivesExist(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)
}
})
if err := cat.UpsertDrive(ctx, &Drive{
ID: "active-drive",
Kind: "pikpak",
Name: "Active",
RootID: "root",
TeaserEnabled: true,
}); err != nil {
t.Fatalf("seed drive: %v", err)
}
now := time.Now()
for _, v := range []*Video{
{
ID: "visible-video",
DriveID: "active-drive",
FileID: "visible-file",
Title: "Visible",
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
},
{
ID: "orphan-video",
DriveID: "deleted-drive",
FileID: "orphan-file",
Title: "Orphan",
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, Sort: "latest"})
if err != nil {
t.Fatalf("list videos: %v", err)
}
if total != 1 || len(items) != 1 || items[0].ID != "visible-video" {
t.Fatalf("items total=%d items=%v, want only visible-video", total, items)
}
}
+4 -41
View File
@@ -19,8 +19,8 @@ CREATE TABLE IF NOT EXISTS videos (
thumbnail_url TEXT,
thumbnail_status TEXT DEFAULT 'pending', -- pending / ready / failed / skipped
thumbnail_failures INTEGER DEFAULT 0, -- consecutive transient thumbnail generation failures
preview_file_id TEXT, -- deprecated: 旧版回写网盘后的预览视频 file id
preview_local TEXT, -- 本地预览视频路径(兜底)
preview_file_id TEXT, -- deprecated: 旧版回写网盘后的 teaser file id
preview_local TEXT, -- 本地 teaser 路径(兜底)
preview_status TEXT DEFAULT 'pending', -- pending / ready / failed
views INTEGER DEFAULT 0,
favorites INTEGER DEFAULT 0,
@@ -70,54 +70,17 @@ CREATE TABLE IF NOT EXISTS deleted_tags (
deleted_at INTEGER NOT NULL
);
-- 管理员显式删除过的视频。用于防止后续扫描 / spider91 爬虫把同一个源文件
-- 再次入库;不代表原始云盘文件已被删除。
CREATE TABLE IF NOT EXISTS deleted_videos (
id TEXT PRIMARY KEY,
drive_id TEXT NOT NULL DEFAULT '',
file_id TEXT NOT NULL DEFAULT '',
content_hash TEXT NOT NULL DEFAULT '',
file_name TEXT NOT NULL DEFAULT '',
size_bytes INTEGER NOT NULL DEFAULT 0,
deleted_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_deleted_videos_drive_file
ON deleted_videos(drive_id, file_id);
CREATE INDEX IF NOT EXISTS idx_deleted_videos_drive_hash
ON deleted_videos(drive_id, content_hash);
CREATE INDEX IF NOT EXISTS idx_deleted_videos_drive_signature
ON deleted_videos(drive_id, file_name, size_bytes);
-- 爬虫来源记录。用于把已确认重复的 source_id 写回 seen 列表,
-- 避免后续爬虫反复下载同一个候选视频。
CREATE TABLE IF NOT EXISTS crawler_seen_sources (
kind TEXT NOT NULL,
drive_id TEXT NOT NULL,
source_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'imported', -- imported / duplicate
canonical_video_id TEXT NOT NULL DEFAULT '',
sampled_sha256 TEXT NOT NULL DEFAULT '',
size_bytes INTEGER NOT NULL DEFAULT 0,
first_seen_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
PRIMARY KEY (kind, drive_id, source_id)
);
CREATE INDEX IF NOT EXISTS idx_crawler_seen_sources_drive
ON crawler_seen_sources(kind, drive_id, status);
-- 网盘账户
CREATE TABLE IF NOT EXISTS drives (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL, -- quark / p115 / p123 / pikpak / wopan / onedrive / googledrive / localstorage / spider91
kind TEXT NOT NULL, -- quark / p115 / pikpak / wopan / onedrive / googledrive / localstorage / spider91
name TEXT NOT NULL,
root_id TEXT NOT NULL DEFAULT '0',
scan_root_id TEXT, -- deprecated: 扫描起点固定等于 root_id
credentials TEXT, -- JSON: cookie / refresh_token 等
status TEXT DEFAULT 'disconnected', -- disconnected / ok / error
last_error TEXT,
-- 是否给该盘生成预览视频/封面:1 开 / 0 关。
-- 是否给该盘生成 teaser/封面:1 开 / 0 关。
-- 替代了早期的全局 preview.enabled 设置(保留旧 setting 行不再读)。
teaser_enabled INTEGER NOT NULL DEFAULT 1,
-- 扫描时要跳过的目录 ID 集合(JSON array of string)。命中其中任意一个的目录及其
+3 -121
View File
@@ -66,10 +66,10 @@ func (c *Catalog) migrate(ctx context.Context) error {
if err := c.addColumnIfMissing(ctx, "videos", "thumbnail_failures", "INTEGER DEFAULT 0"); err != nil {
return err
}
// drives.teaser_enabled:每盘预览视频开关,替代旧的全局 preview.enabled。
// drives.teaser_enabled:每盘 teaser 开关,替代旧的全局 preview.enabled。
// 升级路径:直接让 ALTER TABLE 的 DEFAULT 1 兜底 —— 每个现存 drive 都默认开启,
// 不读旧的 settings.preview.enabled 字段。这样老用户即便之前关过全局开关,
// 升级后所有盘也都恢复"默认生成预览视频",跟新建保持一致。
// 升级后所有盘也都恢复"默认生成 teaser",跟新建保持一致。
if _, err := c.addColumnIfMissingReportNew(ctx, "drives", "teaser_enabled", "INTEGER NOT NULL DEFAULT 1"); err != nil {
return err
}
@@ -79,18 +79,6 @@ func (c *Catalog) migrate(ctx context.Context) error {
if err := c.addColumnIfMissing(ctx, "drives", "skip_dir_ids", "TEXT NOT NULL DEFAULT '[]'"); err != nil {
return err
}
if _, err := c.db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS deleted_videos (
id TEXT PRIMARY KEY,
drive_id TEXT NOT NULL DEFAULT '',
file_id TEXT NOT NULL DEFAULT '',
content_hash TEXT NOT NULL DEFAULT '',
file_name TEXT NOT NULL DEFAULT '',
size_bytes INTEGER NOT NULL DEFAULT 0,
deleted_at INTEGER NOT NULL
)`); err != nil {
return err
}
if err := c.syncDriveScanRootIDToRootID(ctx); err != nil {
return err
}
@@ -112,36 +100,15 @@ CREATE TABLE IF NOT EXISTS deleted_videos (
if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_videos_content_hash ON videos(content_hash)`); err != nil {
return err
}
if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_videos_content_hash_created ON videos(content_hash, created_at, id)`); err != nil {
return err
}
if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_videos_sampled_sha256 ON videos(size_bytes, sampled_sha256)`); err != nil {
return err
}
if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_videos_sampled_sha256_created ON videos(size_bytes, sampled_sha256, created_at, id)`); err != nil {
return err
}
if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_videos_hidden ON videos(hidden)`); err != nil {
return err
}
if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_videos_visible_pub ON videos(COALESCE(hidden, 0), published_at DESC)`); err != nil {
return err
}
if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_videos_file_name_size ON videos(file_name, size_bytes)`); err != nil {
return err
}
if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_videos_file_name_size_created ON videos(file_name, size_bytes, created_at, id)`); err != nil {
return err
}
if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_deleted_videos_drive_file ON deleted_videos(drive_id, file_id)`); err != nil {
return err
}
if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_deleted_videos_drive_hash ON deleted_videos(drive_id, content_hash)`); err != nil {
return err
}
if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_deleted_videos_drive_signature ON deleted_videos(drive_id, file_name, size_bytes)`); err != nil {
return err
}
if err := c.seedSystemTags(ctx); err != nil {
return err
}
@@ -160,12 +127,6 @@ CREATE TABLE IF NOT EXISTS deleted_videos (
if err := c.clearVolatileOneDriveThumbnails(ctx); err != nil {
return err
}
if err := c.clearRemoteP123ThumbnailsOnce(ctx); err != nil {
return err
}
if err := c.clearRemoteNonSpider91Thumbnails(ctx); err != nil {
return err
}
if err := c.hideZeroSizeVideosFromKnownDrives(ctx); err != nil {
return err
}
@@ -214,7 +175,7 @@ func (c *Catalog) addColumnIfMissingReportNew(ctx context.Context, table, column
// 设为 1(开启),但仅在历史上没跑过这条迁移时执行(用 marker setting 记号)。
//
// 为什么需要:早期短暂存在过的版本会从旧的全局 preview.enabled = "0" 同步到
// 所有 drive 的 teaser_enabled = 0;用户报告升级后页面全显示"预览视频关"。新版
// 所有 drive 的 teaser_enabled = 0;用户报告升级后页面全显示"Teaser 关"。新版
// 约定 per-drive 默认开启,所以这里跑一次性修正。
//
// 幂等保证:marker setting 设过了就不再跑,确保用户在 UI 里把某盘关了不会被
@@ -296,85 +257,6 @@ UPDATE videos
return err
}
func (c *Catalog) clearRemoteP123ThumbnailsOnce(ctx context.Context) error {
// 123网盘列表返回的缩略图尺寸和稳定性都不适合作为站内封面;清空历史写入的
// 远程 URL,让封面 worker 统一从视频直链抽帧生成本地 /p/thumb/<id>。
const markerKey = "videos.p123.remote_thumbnails_cleared"
marker, err := c.GetSetting(ctx, markerKey, "")
if err != nil {
return fmt.Errorf("read %s marker: %w", markerKey, err)
}
if strings.TrimSpace(marker) == "1" {
return nil
}
var p123Drives int
if err := c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM drives WHERE kind = 'p123'`).Scan(&p123Drives); err != nil {
return fmt.Errorf("count p123 drives: %w", err)
}
if p123Drives == 0 {
return nil
}
res, err := c.db.ExecContext(ctx, `
UPDATE videos
SET thumbnail_url = '',
thumbnail_status = 'pending',
thumbnail_failures = 0,
updated_at = ?
WHERE EXISTS (
SELECT 1
FROM drives
WHERE drives.id = videos.drive_id
AND drives.kind = 'p123'
)
AND (
lower(COALESCE(thumbnail_url, '')) LIKE 'http://%'
OR lower(COALESCE(thumbnail_url, '')) LIKE 'https://%'
)
`, time.Now().UnixMilli())
if err != nil {
return err
}
if affected, err := res.RowsAffected(); err == nil && affected > 0 {
log.Printf("[catalog] cleared %d remote 123pan thumbnail(s) for local regeneration", affected)
}
if err := c.SetSetting(ctx, markerKey, "1"); err != nil {
return fmt.Errorf("write %s marker: %w", markerKey, err)
}
return nil
}
func (c *Catalog) clearRemoteNonSpider91Thumbnails(ctx context.Context) error {
// 非 91Spider 视频不再使用网盘侧返回的远程缩略图。清空历史 http/https
// thumbnail_url 后,封面 worker 会重新从视频中间帧生成本地 /p/thumb/<id>。
// 91Spider 的封面是爬虫下载后保存到本地 /p/thumb/<id>,不受这条规则影响。
res, err := c.db.ExecContext(ctx, `
UPDATE videos
SET thumbnail_url = '',
thumbnail_status = 'pending',
thumbnail_failures = 0,
updated_at = ?
WHERE (
lower(COALESCE(thumbnail_url, '')) LIKE 'http://%'
OR lower(COALESCE(thumbnail_url, '')) LIKE 'https://%'
)
AND NOT EXISTS (
SELECT 1
FROM drives
WHERE drives.id = videos.drive_id
AND drives.kind = 'spider91'
)
`, time.Now().UnixMilli())
if err != nil {
return err
}
if affected, err := res.RowsAffected(); err == nil && affected > 0 {
log.Printf("[catalog] cleared %d remote non-91Spider thumbnail(s) for local regeneration", affected)
}
return nil
}
func (c *Catalog) hideZeroSizeVideosFromKnownDrives(ctx context.Context) error {
_, err := c.db.ExecContext(ctx, `
UPDATE videos
+3 -85
View File
@@ -804,7 +804,7 @@ func TestMigrateCollapsesAVCodeTagsIntoAV(t *testing.T) {
}
}
func TestMigrateClearsRemoteNonSpiderThumbnailURLs(t *testing.T) {
func TestMigrateClearsVolatileOneDriveThumbnailURLs(t *testing.T) {
ctx := context.Background()
cat, err := Open(t.TempDir() + "/catalog.db")
if err != nil {
@@ -827,36 +827,6 @@ func TestMigrateClearsRemoteNonSpiderThumbnailURLs(t *testing.T) {
}); err != nil {
t.Fatalf("seed onedrive: %v", err)
}
if err := cat.UpsertDrive(ctx, &Drive{
ID: "p123-main",
Kind: "p123",
Name: "123Pan",
RootID: "root",
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("seed p123: %v", err)
}
if err := cat.UpsertDrive(ctx, &Drive{
ID: "pikpak-main",
Kind: "pikpak",
Name: "PikPak",
RootID: "root",
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("seed pikpak: %v", err)
}
if err := cat.UpsertDrive(ctx, &Drive{
ID: "spider91-main",
Kind: "spider91",
Name: "91Spider",
RootID: "root",
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("seed spider91: %v", err)
}
videos := []*Video{
{
@@ -880,27 +850,6 @@ func TestMigrateClearsRemoteNonSpiderThumbnailURLs(t *testing.T) {
Title: "PikPak",
ThumbnailURL: "https://sg-thumbnail-drive.mypikpak.net/v0/screenshot-thumbnails/demo",
},
{
ID: "p123-remote-thumb-video",
DriveID: "p123-main",
FileID: "file-4",
Title: "123Pan remote thumb",
ThumbnailURL: "https://download.123pan.com/thumb/file_70_70?w=70&h=70",
},
{
ID: "p123-local-thumb-video",
DriveID: "p123-main",
FileID: "file-5",
Title: "123Pan local thumb",
ThumbnailURL: "/p/thumb/p123-local-thumb-video",
},
{
ID: "spider91-local-thumb-video",
DriveID: "spider91-main",
FileID: "file-6",
Title: "91Spider local thumb",
ThumbnailURL: "/p/thumb/spider91-local-thumb-video",
},
}
for _, v := range videos {
v.PublishedAt = now
@@ -935,39 +884,8 @@ func TestMigrateClearsRemoteNonSpiderThumbnailURLs(t *testing.T) {
if err != nil {
t.Fatalf("get pikpak video: %v", err)
}
if pikpak.ThumbnailURL != "" {
t.Fatalf("pikpak thumbnail = %q, want cleared", pikpak.ThumbnailURL)
}
p123Remote, err := cat.GetVideo(ctx, "p123-remote-thumb-video")
if err != nil {
t.Fatalf("get p123 remote thumb video: %v", err)
}
if p123Remote.ThumbnailURL != "" {
t.Fatalf("p123 remote thumbnail = %q, want cleared", p123Remote.ThumbnailURL)
}
var p123Status string
if err := cat.db.QueryRowContext(ctx, `SELECT thumbnail_status FROM videos WHERE id = ?`, "p123-remote-thumb-video").Scan(&p123Status); err != nil {
t.Fatalf("read p123 thumbnail status: %v", err)
}
if p123Status != "pending" {
t.Fatalf("p123 remote thumbnail_status = %q, want pending", p123Status)
}
p123Local, err := cat.GetVideo(ctx, "p123-local-thumb-video")
if err != nil {
t.Fatalf("get p123 local thumb video: %v", err)
}
if p123Local.ThumbnailURL != "/p/thumb/p123-local-thumb-video" {
t.Fatalf("p123 local thumbnail = %q, want preserved", p123Local.ThumbnailURL)
}
spider91Local, err := cat.GetVideo(ctx, "spider91-local-thumb-video")
if err != nil {
t.Fatalf("get spider91 local thumb video: %v", err)
}
if spider91Local.ThumbnailURL != "/p/thumb/spider91-local-thumb-video" {
t.Fatalf("spider91 local thumbnail = %q, want preserved", spider91Local.ThumbnailURL)
if pikpak.ThumbnailURL == "" {
t.Fatal("pikpak thumbnail was cleared")
}
}
+2 -25
View File
@@ -16,11 +16,6 @@ const (
DefaultAdminPassword = "admin123"
)
var (
legacyDefaultVideoExtensions = []string{".mp4", ".mkv", ".mov", ".webm", ".avi"}
defaultVideoExtensions = []string{".mp4", ".mkv", ".mov", ".webm", ".avi", ".strm"}
)
type Config struct {
Server Server `yaml:"server"`
Storage Storage `yaml:"storage"`
@@ -207,7 +202,7 @@ type Nightly struct {
// 这里保留 yaml 中的静态定义,用于启动时预置盘。生产建议只在 DB 里维护。
type Drive struct {
ID string `yaml:"id"`
Kind string `yaml:"kind"` // quark / p115 / p123 / pikpak / wopan / onedrive / googledrive / localstorage
Kind string `yaml:"kind"` // quark / p115 / pikpak / wopan / onedrive / googledrive / localstorage
Name string `yaml:"name"`
RootID string `yaml:"root_id"`
Params map[string]string `yaml:"params,omitempty"`
@@ -252,9 +247,7 @@ func (c *Config) applyDefaults() {
c.Scanner.MaxDepth = 5
}
if len(c.Scanner.VideoExtensions) == 0 {
c.Scanner.VideoExtensions = append([]string{}, defaultVideoExtensions...)
} else if isLegacyDefaultVideoExtensions(c.Scanner.VideoExtensions) {
c.Scanner.VideoExtensions = append(c.Scanner.VideoExtensions, ".strm")
c.Scanner.VideoExtensions = []string{".mp4", ".mkv", ".mov", ".webm", ".avi"}
}
if c.Preview.FFmpegPath == "" {
c.Preview.FFmpegPath = "ffmpeg"
@@ -283,19 +276,3 @@ func (c *Config) applyDefaults() {
c.Nightly.CronHour = 1
}
}
func isLegacyDefaultVideoExtensions(exts []string) bool {
if len(exts) != len(legacyDefaultVideoExtensions) {
return false
}
seen := make(map[string]struct{}, len(exts))
for _, ext := range exts {
seen[strings.ToLower(strings.TrimSpace(ext))] = struct{}{}
}
for _, ext := range legacyDefaultVideoExtensions {
if _, ok := seen[ext]; !ok {
return false
}
}
return true
}
-62
View File
@@ -3,7 +3,6 @@ package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -51,64 +50,3 @@ storage:
t.Fatalf("db path = %q, want preserved value", cfg.Storage.DBPath)
}
}
func TestLoadDefaultScannerVideoExtensionsIncludeSTRM(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(`{}`), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("load config: %v", err)
}
if !hasVideoExtension(cfg.Scanner.VideoExtensions, ".strm") {
t.Fatalf("video extensions = %#v, want .strm", cfg.Scanner.VideoExtensions)
}
}
func TestLoadLegacyDefaultScannerVideoExtensionsIncludeSTRM(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(`
scanner:
video_extensions: [".mp4", ".mkv", ".mov", ".webm", ".avi"]
`), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("load config: %v", err)
}
if !hasVideoExtension(cfg.Scanner.VideoExtensions, ".strm") {
t.Fatalf("video extensions = %#v, want .strm appended for legacy default list", cfg.Scanner.VideoExtensions)
}
}
func TestLoadCustomScannerVideoExtensionsArePreserved(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(`
scanner:
video_extensions: [".mp4"]
`), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("load config: %v", err)
}
if len(cfg.Scanner.VideoExtensions) != 1 || cfg.Scanner.VideoExtensions[0] != ".mp4" {
t.Fatalf("video extensions = %#v, want custom list preserved", cfg.Scanner.VideoExtensions)
}
}
func hasVideoExtension(exts []string, want string) bool {
want = strings.ToLower(strings.TrimSpace(want))
for _, ext := range exts {
if strings.ToLower(strings.TrimSpace(ext)) == want {
return true
}
}
return false
}
+13 -558
View File
@@ -1,17 +1,10 @@
package googledrive
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"hash"
"io"
"log"
"math"
"net/http"
"net/url"
"path"
@@ -28,13 +21,10 @@ import (
const (
Kind = "googledrive"
defaultAPIBaseURL = "https://www.googleapis.com/drive/v3"
defaultUploadAPIURL = "https://www.googleapis.com/upload/drive/v3"
defaultOAuthURL = "https://www.googleapis.com/oauth2/v4/token"
defaultRenewAPIURL = "https://api.oplist.org/googleui/renewapi"
defaultListInterval = 1 * time.Second
defaultListCooldown = 5 * time.Minute
defaultLinkCooldown = 5 * time.Minute
uploadChunkSize = int64(8 * 1024 * 1024)
filesListFields = "files(id,name,mimeType,size,modifiedTime,createdTime,thumbnailLink,shortcutDetails,md5Checksum,sha1Checksum,sha256Checksum),nextPageToken"
fileInfoFields = "id,name,mimeType,size,modifiedTime,createdTime,thumbnailLink,shortcutDetails,md5Checksum,sha1Checksum,sha256Checksum"
@@ -51,19 +41,13 @@ type Driver struct {
renewAPIURL string
oauthURL string
apiBaseURL string
uploadBaseURL string
client *resty.Client
httpClient *http.Client
onTokenUpdate func(access, refresh string)
listMu sync.Mutex
lastListAt time.Time
listInterval time.Duration
listCooldown time.Duration
linkCooldownMu sync.Mutex
linkCooldownUntil time.Time
linkCooldownDuration time.Duration
}
type Config struct {
@@ -77,7 +61,6 @@ type Config struct {
RenewAPIURL string
OAuthURL string
APIBaseURL string
UploadAPIURL string
OnTokenUpdate func(access, refresh string)
}
@@ -99,10 +82,6 @@ func New(c Config) *Driver {
if apiBaseURL == "" {
apiBaseURL = defaultAPIBaseURL
}
uploadBaseURL := strings.TrimRight(strings.TrimSpace(c.UploadAPIURL), "/")
if uploadBaseURL == "" {
uploadBaseURL = deriveUploadBaseURL(apiBaseURL)
}
return &Driver{
id: c.ID,
rootID: rootID,
@@ -114,34 +93,15 @@ func New(c Config) *Driver {
renewAPIURL: renewAPIURL,
oauthURL: oauthURL,
apiBaseURL: apiBaseURL,
uploadBaseURL: uploadBaseURL,
onTokenUpdate: c.OnTokenUpdate,
client: resty.New().
SetTimeout(30*time.Second).
SetHeader("Accept", "application/json, text/plain, */*"),
httpClient: &http.Client{
Timeout: 0,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
},
listInterval: defaultListInterval,
listCooldown: defaultListCooldown,
linkCooldownDuration: defaultLinkCooldown,
listInterval: defaultListInterval,
listCooldown: defaultListCooldown,
}
}
func deriveUploadBaseURL(apiBaseURL string) string {
apiBaseURL = strings.TrimRight(strings.TrimSpace(apiBaseURL), "/")
if apiBaseURL == "" || apiBaseURL == defaultAPIBaseURL {
return defaultUploadAPIURL
}
if strings.HasSuffix(apiBaseURL, "/drive/v3") {
return strings.TrimSuffix(apiBaseURL, "/drive/v3") + "/upload/drive/v3"
}
return apiBaseURL
}
func (d *Driver) Kind() string { return Kind }
func (d *Driver) ID() string { return d.id }
func (d *Driver) RootID() string { return d.rootID }
@@ -249,19 +209,8 @@ func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLi
if fileID == "" {
return nil, errors.New("googledrive stream: empty file id")
}
if err := ctx.Err(); err != nil {
return nil, err
}
if err := d.linkCooldownError(time.Now()); err != nil {
return nil, err
}
if _, err := d.Stat(ctx, fileID); err != nil {
err = fmt.Errorf("googledrive stream: %w", err)
if wait, ok := drives.RateLimitRetryAfter(err); ok {
until := d.pauseLinkCooldown(wait)
log.Printf("[googledrive] stream link cooling down drive=%s until=%s err=%v", d.id, until.Format(time.RFC3339), err)
}
return nil, err
return nil, fmt.Errorf("googledrive stream: %w", err)
}
u := d.fileURL(fileID) + "?alt=media&acknowledgeAbuse=true&supportsAllDrives=true"
return &drives.StreamLink{
@@ -273,396 +222,12 @@ func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLi
}, nil
}
func (d *Driver) linkCooldownError(now time.Time) error {
d.linkCooldownMu.Lock()
defer d.linkCooldownMu.Unlock()
if d.linkCooldownUntil.IsZero() {
return nil
}
if !now.Before(d.linkCooldownUntil) {
d.linkCooldownUntil = time.Time{}
return nil
}
wait := d.linkCooldownUntil.Sub(now)
if wait <= 0 {
return nil
}
return &drives.RateLimitError{
Provider: Kind,
RetryAfter: wait,
Err: fmt.Errorf("googledrive stream link cooling down until %s", d.linkCooldownUntil.Format(time.RFC3339)),
}
func (d *Driver) Upload(context.Context, string, string, io.Reader, int64) (string, error) {
return "", drives.ErrNotSupported
}
func (d *Driver) pauseLinkCooldown(wait time.Duration) time.Time {
if wait <= 0 {
wait = d.linkCooldownDuration
}
if wait <= 0 {
wait = defaultLinkCooldown
}
until := time.Now().Add(wait)
d.linkCooldownMu.Lock()
if until.After(d.linkCooldownUntil) {
d.linkCooldownUntil = until
} else {
until = d.linkCooldownUntil
}
d.linkCooldownMu.Unlock()
return until
}
func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) {
res, err := d.UploadAndReportHash(ctx, parentID, name, r, size)
if err != nil {
return "", err
}
return res.FileID, nil
}
func (d *Driver) UploadAndReportHash(ctx context.Context, parentID, name string, r io.Reader, size int64) (UploadResult, error) {
parentID, name, err := d.normalizeUploadArgs(parentID, name, r, size)
if err != nil {
return UploadResult{}, err
}
sessionURL, err := d.createUploadSession(ctx, parentID, name, size)
if err != nil {
return UploadResult{}, err
}
if strings.TrimSpace(sessionURL) == "" {
return UploadResult{}, errors.New("googledrive upload session: empty upload url")
}
hasher := md5.New()
var item driveFile
var copied int64
if size == 0 {
completed, err := d.putUploadSessionChunkWithRetry(ctx, sessionURL, 0, 0, nil, hasher)
if err != nil {
return UploadResult{}, err
}
if completed != nil {
item = *completed
}
} else {
chunkSize := uploadChunkSize
if chunkSize <= 0 {
chunkSize = 8 * 1024 * 1024
}
if chunkSize > int64(math.MaxInt32) {
chunkSize = int64(math.MaxInt32)
}
buf := make([]byte, int(chunkSize))
for copied < size {
partSize := minInt64(chunkSize, size-copied)
chunk := buf[:int(partSize)]
n, err := io.ReadFull(r, chunk)
if err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return UploadResult{}, fmt.Errorf("googledrive upload: size mismatch: declared %d, copied %d", size, copied+int64(n))
}
return UploadResult{}, fmt.Errorf("googledrive upload: read body: %w", err)
}
chunk = chunk[:n]
completed, err := d.putUploadSessionChunkWithRetry(ctx, sessionURL, copied, size, chunk, hasher)
if err != nil {
return UploadResult{}, err
}
if completed != nil {
item = *completed
}
copied += int64(n)
}
}
hashHex := hex.EncodeToString(hasher.Sum(nil))
if item.ID == "" {
fileID, err := d.findUploadedFileID(ctx, parentID, name, hashHex)
if err != nil {
return UploadResult{}, err
}
item.ID = fileID
}
return UploadResult{FileID: item.ID, Hash: hashHex, Size: copied}, nil
}
func (d *Driver) normalizeUploadArgs(parentID, name string, r io.Reader, size int64) (string, string, error) {
if r == nil {
return "", "", errors.New("googledrive upload: body is required")
}
if size < 0 {
return "", "", fmt.Errorf("googledrive upload: invalid size %d", size)
}
parentID = strings.TrimSpace(parentID)
if parentID == "" || parentID == "/" {
parentID = d.rootID
}
name = strings.TrimSpace(name)
if name == "" {
return "", "", errors.New("googledrive upload: empty file name")
}
return parentID, name, nil
}
func (d *Driver) createUploadSession(ctx context.Context, parentID, name string, size int64) (string, error) {
return d.createUploadSessionOnce(ctx, parentID, name, size, true)
}
func (d *Driver) createUploadSessionOnce(ctx context.Context, parentID, name string, size int64, retry bool) (string, error) {
var apiErr apiErrorResp
res, err := d.client.R().
SetContext(ctx).
SetHeader("Authorization", "Bearer "+d.accessToken).
SetHeader("X-Upload-Content-Type", mimeType(driveFile{Name: name})).
SetHeader("X-Upload-Content-Length", strconv.FormatInt(size, 10)).
SetQueryParams(map[string]string{
"uploadType": "resumable",
"supportsAllDrives": "true",
"fields": fileInfoFields,
}).
SetBody(map[string]any{
"name": name,
"parents": []string{parentID},
}).
SetError(&apiErr).
Post(d.uploadFilesURL())
if err != nil {
return "", fmt.Errorf("googledrive upload session: %w", err)
}
if isGoogleRateLimit(res, apiErr.Error) {
return "", googleRateLimitError(res, apiErr.Error.Message)
}
if apiErr.Error.Code != 0 {
if apiErr.Error.Code == http.StatusUnauthorized && retry {
if err := d.refresh(ctx); err != nil {
return "", err
}
return d.createUploadSessionOnce(ctx, parentID, name, size, false)
}
return "", googleAPIError(apiErr.Error)
}
if res.IsError() {
return "", fmt.Errorf("googledrive upload session: status=%d body=%s", res.StatusCode(), strings.TrimSpace(res.String()))
}
return strings.TrimSpace(res.Header().Get("Location")), nil
}
func (d *Driver) putUploadSessionChunkWithRetry(ctx context.Context, uploadURL string, start, total int64, data []byte, hasher hash.Hash) (*driveFile, error) {
var last error
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
if err := sleepContext(ctx, time.Duration(attempt)*time.Second); err != nil {
return nil, err
}
}
item, retryable, err := d.putUploadSessionChunk(ctx, uploadURL, start, total, data)
if err == nil {
if hasher != nil && len(data) > 0 {
_, _ = hasher.Write(data)
}
return item, nil
}
last = err
if !retryable {
return nil, err
}
}
if last == nil {
last = errors.New("googledrive upload session: retry attempts exhausted")
}
return nil, last
}
func (d *Driver) putUploadSessionChunk(ctx context.Context, uploadURL string, start, total int64, data []byte) (*driveFile, bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, bytes.NewReader(data))
if err != nil {
return nil, false, err
}
req.ContentLength = int64(len(data))
req.Header.Set("Authorization", "Bearer "+d.accessToken)
req.Header.Set("Content-Length", strconv.Itoa(len(data)))
if total == 0 {
req.Header.Set("Content-Range", "bytes */0")
} else {
end := start + int64(len(data)) - 1
req.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, total))
}
hc := d.httpClient
if hc == nil {
hc = http.DefaultClient
}
res, err := hc.Do(req)
if err != nil {
return nil, true, fmt.Errorf("googledrive upload session: put chunk: %w", err)
}
defer res.Body.Close()
switch res.StatusCode {
case http.StatusOK, http.StatusCreated:
var item driveFile
if err := json.NewDecoder(res.Body).Decode(&item); err != nil {
return nil, false, fmt.Errorf("googledrive upload session: decode completed file: %w", err)
}
return &item, false, nil
case http.StatusPermanentRedirect:
return nil, false, nil
case http.StatusUnauthorized:
if err := d.refresh(ctx); err != nil {
return nil, false, err
}
return nil, true, fmt.Errorf("googledrive upload session: unauthorized")
default:
body, _ := io.ReadAll(io.LimitReader(res.Body, 64*1024))
var apiErr apiErrorResp
_ = json.Unmarshal(body, &apiErr)
if isGoogleUploadHTTPRateLimit(res.StatusCode, res.Header, body, apiErr.Error) {
return nil, false, googleUploadRateLimitError(res.StatusCode, res.Header, body, apiErr.Error.Message)
}
retryable := res.StatusCode == http.StatusTooManyRequests || (res.StatusCode >= 500 && res.StatusCode <= 504)
return nil, retryable, fmt.Errorf("googledrive upload session: status=%d body=%s", res.StatusCode, strings.TrimSpace(string(body)))
}
}
func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) {
currentID := d.rootID
for _, name := range splitPath(pathFromRoot) {
childID, err := d.findChildDir(ctx, currentID, name)
if err != nil {
return "", err
}
if childID == "" {
childID, err = d.makeDir(ctx, currentID, name)
if err != nil {
return "", err
}
}
currentID = childID
}
return currentID, nil
}
func (d *Driver) findChildDir(ctx context.Context, parentID, name string) (string, error) {
entries, err := d.List(ctx, parentID)
if err != nil {
return "", err
}
for _, e := range entries {
if e.IsDir && e.Name == name {
return e.ID, nil
}
}
return "", nil
}
func (d *Driver) makeDir(ctx context.Context, parentID, name string) (string, error) {
var item driveFile
err := d.request(ctx, d.filesURL(), http.MethodPost, func(req *resty.Request) {
req.SetQueryParam("fields", fileInfoFields)
req.SetBody(map[string]any{
"name": name,
"parents": []string{parentID},
"mimeType": "application/vnd.google-apps.folder",
})
}, &item)
if err != nil {
return "", fmt.Errorf("googledrive mkdir %s: %w", name, err)
}
if item.ID == "" {
return "", fmt.Errorf("googledrive mkdir %s: empty file id", name)
}
return item.ID, nil
}
func (d *Driver) Rename(ctx context.Context, fileID, newName string) error {
fileID = strings.TrimSpace(fileID)
if fileID == "" {
return errors.New("googledrive rename: empty file id")
}
newName = strings.TrimSpace(newName)
if newName == "" {
return errors.New("googledrive rename: empty new name")
}
var item driveFile
err := d.request(ctx, d.fileURL(fileID), http.MethodPatch, func(req *resty.Request) {
req.SetQueryParam("fields", fileInfoFields)
req.SetBody(map[string]string{"name": newName})
}, &item)
if err != nil {
return fmt.Errorf("googledrive rename: %w", err)
}
return nil
}
func (d *Driver) Remove(ctx context.Context, fileID string) error {
fileID = strings.TrimSpace(fileID)
if fileID == "" {
return errors.New("googledrive remove: empty file id")
}
if err := d.request(ctx, d.fileURL(fileID), http.MethodDelete, nil, nil); err != nil {
return fmt.Errorf("googledrive remove: %w", err)
}
return nil
}
func (d *Driver) findUploadedFileID(ctx context.Context, parentID, name, md5Hex string) (string, error) {
entries, err := d.List(ctx, parentID)
if err != nil {
return "", fmt.Errorf("googledrive upload verify: %w", err)
}
var hashHit string
for _, e := range entries {
if e.IsDir {
continue
}
if !strings.EqualFold(e.Hash, md5Hex) {
continue
}
if e.Name == name {
return e.ID, nil
}
if hashHit == "" {
hashHit = e.ID
}
}
if hashHit != "" {
return hashHit, nil
}
for _, e := range entries {
if !e.IsDir && e.Name == name {
return e.ID, nil
}
}
return "", fmt.Errorf("googledrive upload: uploaded file %q not found in parent %q", name, parentID)
}
var _ drives.Remover = (*Driver)(nil)
func isGoogleUploadHTTPRateLimit(status int, header http.Header, body []byte, apiErr apiErrorBody) bool {
if status == http.StatusTooManyRequests {
return true
}
if status == http.StatusForbidden && strings.TrimSpace(header.Get("Retry-After")) != "" {
return true
}
if isGoogleRateLimit(nil, apiErr) {
return true
}
return googleLimitText(string(body))
}
func googleUploadRateLimitError(status int, header http.Header, body []byte, message string) error {
if strings.TrimSpace(message) == "" {
message = "google drive upload rate limited"
}
bodyText := strings.TrimSpace(string(body))
if bodyText != "" {
message = fmt.Sprintf("%s: status=%d body=%s", message, status, bodyText)
}
return &drives.RateLimitError{
Provider: Kind,
RetryAfter: parseRetryAfterHeader(header.Get("Retry-After")),
Err: errors.New(message),
}
func (d *Driver) EnsureDir(context.Context, string) (string, error) {
return "", drives.ErrNotSupported
}
func (d *Driver) refresh(ctx context.Context) error {
@@ -723,26 +288,6 @@ func (d *Driver) applyToken(out tokenResp) {
}
func tokenResponseError(prefix string, res *resty.Response, out tokenResp, requireRefresh bool) error {
if isGoogleTokenRateLimit(res, out) {
message := strings.TrimSpace(out.Text)
if message == "" {
message = strings.TrimSpace(out.ErrorDescription)
}
if message == "" {
message = strings.TrimSpace(out.Error)
}
if message == "" {
message = "google drive token refresh rate limited"
}
if res != nil && strings.TrimSpace(res.String()) != "" {
message = fmt.Sprintf("%s: status=%d body=%s", message, res.StatusCode(), strings.TrimSpace(res.String()))
}
return &drives.RateLimitError{
Provider: Kind,
RetryAfter: parseRetryAfter(res),
Err: fmt.Errorf("%s: %s", prefix, message),
}
}
if out.Text != "" {
return fmt.Errorf("%s: %s", prefix, out.Text)
}
@@ -835,10 +380,6 @@ func (d *Driver) filesURL() string {
return d.apiBaseURL + "/files"
}
func (d *Driver) uploadFilesURL() string {
return d.uploadBaseURL + "/files"
}
func (d *Driver) fileURL(fileID string) string {
return d.filesURL() + "/" + url.PathEscape(fileID)
}
@@ -903,85 +444,18 @@ func isGoogleRateLimit(res *resty.Response, body apiErrorBody) bool {
if res != nil && res.StatusCode() == http.StatusTooManyRequests {
return true
}
if res != nil && res.StatusCode() == http.StatusForbidden && strings.TrimSpace(res.Header().Get("Retry-After")) != "" {
return true
}
if body.Code == http.StatusTooManyRequests {
return true
}
for _, e := range body.Errors {
if googleLimitReason(e.Reason) || googleLimitText(e.Message) {
return true
}
domain := compactGoogleLimitText(e.Domain)
if domain == "usagelimits" && (body.Code == http.StatusForbidden || body.Code == http.StatusTooManyRequests) {
reason := strings.ToLower(strings.TrimSpace(e.Reason))
switch reason {
case "ratelimitexceeded", "userratelimitexceeded", "downloadquotaexceeded", "sharingratelimitexceeded":
return true
}
}
return googleLimitText(body.Message)
}
func isGoogleTokenRateLimit(res *resty.Response, out tokenResp) bool {
if res != nil {
if res.StatusCode() == http.StatusTooManyRequests {
return true
}
if res.StatusCode() == http.StatusForbidden && strings.TrimSpace(res.Header().Get("Retry-After")) != "" {
return true
}
}
return googleLimitText(out.Text) ||
googleLimitText(out.Error) ||
googleLimitText(out.ErrorDescription)
}
func googleLimitReason(reason string) bool {
switch compactGoogleLimitText(reason) {
case "ratelimitexceeded",
"userratelimitexceeded",
"dailylimitexceeded",
"dailylimitexceededunreg",
"downloadquotaexceeded",
"sharingratelimitexceeded",
"quotaexceeded",
"uploadlimitexceeded",
"storagelimitexceeded",
"storagequotaexceeded":
return true
default:
return false
}
}
func googleLimitText(text string) bool {
text = strings.ToLower(strings.TrimSpace(text))
if text == "" {
return false
}
compact := compactGoogleLimitText(text)
if strings.Contains(compact, "ratelimitexceeded") ||
strings.Contains(compact, "userratelimitexceeded") ||
strings.Contains(compact, "dailylimitexceeded") ||
strings.Contains(compact, "downloadquotaexceeded") ||
strings.Contains(compact, "sharingratelimitexceeded") ||
strings.Contains(compact, "quotaexceeded") ||
strings.Contains(compact, "toomanyrequests") {
return true
}
return strings.Contains(text, "rate limit") ||
strings.Contains(text, "too many requests") ||
strings.Contains(text, "quota exceeded") ||
strings.Contains(text, "download quota") ||
strings.Contains(text, "sharing rate") ||
strings.Contains(text, "daily limit") ||
strings.Contains(text, "user rate") ||
strings.Contains(text, "usage limit")
}
func compactGoogleLimitText(text string) string {
text = strings.ToLower(strings.TrimSpace(text))
replacer := strings.NewReplacer("_", "", "-", "", " ", "", ".", "", ":", "")
return replacer.Replace(text)
msg := strings.ToLower(body.Message)
return strings.Contains(msg, "rate limit") || strings.Contains(msg, "too many requests") || strings.Contains(msg, "quota exceeded")
}
func googleRateLimitError(res *resty.Response, message string) error {
@@ -1012,11 +486,7 @@ func parseRetryAfter(res *resty.Response) time.Duration {
if res == nil {
return 0
}
return parseRetryAfterHeader(res.Header().Get("Retry-After"))
}
func parseRetryAfterHeader(raw string) time.Duration {
raw = strings.TrimSpace(raw)
raw := strings.TrimSpace(res.Header().Get("Retry-After"))
if raw == "" {
return 0
}
@@ -1032,19 +502,4 @@ func parseRetryAfterHeader(raw string) time.Duration {
return 0
}
func splitPath(p string) []string {
p = strings.Trim(p, "/")
if p == "" {
return nil
}
return strings.Split(p, "/")
}
func minInt64(a, b int64) int64 {
if a < b {
return a
}
return b
}
var _ drives.Drive = (*Driver)(nil)
@@ -2,18 +2,11 @@ package googledrive
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/video-site/backend/internal/drives"
)
func TestInitUsesOnlineRenewAPI(t *testing.T) {
@@ -138,134 +131,6 @@ func TestStreamURLReturnsAuthenticatedMediaLinkWithoutRedirectRequirement(t *tes
}
}
func TestUploadAndReportHashUsesResumableSession(t *testing.T) {
body := "hello google drive"
wantHash := md5.Sum([]byte(body))
var sawSession bool
var sawUpload bool
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/upload/drive/v3/files":
sawSession = true
if got := r.Header.Get("Authorization"); got != "Bearer access" {
t.Fatalf("session Authorization = %q", got)
}
if got := r.URL.Query().Get("uploadType"); got != "resumable" {
t.Fatalf("uploadType = %q", got)
}
if got := r.Header.Get("X-Upload-Content-Length"); got != "18" {
t.Fatalf("X-Upload-Content-Length = %q", got)
}
var meta struct {
Name string `json:"name"`
Parents []string `json:"parents"`
}
if err := json.NewDecoder(r.Body).Decode(&meta); err != nil {
t.Fatalf("decode session metadata: %v", err)
}
if meta.Name != "clip.mp4" || len(meta.Parents) != 1 || meta.Parents[0] != "parent-1" {
t.Fatalf("metadata = %+v", meta)
}
w.Header().Set("Location", srv.URL+"/upload/session/1")
w.WriteHeader(http.StatusOK)
case "/upload/session/1":
sawUpload = true
if got := r.Header.Get("Authorization"); got != "Bearer access" {
t.Fatalf("upload Authorization = %q", got)
}
if got := r.Header.Get("Content-Range"); got != "bytes 0-17/18" {
t.Fatalf("Content-Range = %q", got)
}
gotBody, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read upload body: %v", err)
}
if string(gotBody) != body {
t.Fatalf("upload body = %q", string(gotBody))
}
writeTestJSONStatus(w, http.StatusCreated, driveFile{
ID: "file-uploaded",
Name: "clip.mp4",
Size: "18",
MD5Checksum: hex.EncodeToString(wantHash[:]),
})
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer srv.Close()
d := New(Config{ID: "g", APIBaseURL: srv.URL + "/drive/v3"})
d.accessToken = "access"
res, err := d.UploadAndReportHash(context.Background(), "parent-1", "clip.mp4", strings.NewReader(body), int64(len(body)))
if err != nil {
t.Fatalf("UploadAndReportHash() error = %v", err)
}
if !sawSession || !sawUpload {
t.Fatalf("saw session/upload = %v/%v, want both", sawSession, sawUpload)
}
if res.FileID != "file-uploaded" || res.Size != int64(len(body)) || res.Hash != hex.EncodeToString(wantHash[:]) {
t.Fatalf("upload result = %+v", res)
}
}
func TestEnsureDirAndRenameUseGoogleDriveFileAPI(t *testing.T) {
var madeDir bool
var renamed bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/drive/v3/files":
writeTestJSON(w, filesResp{})
case r.Method == http.MethodPost && r.URL.Path == "/drive/v3/files":
madeDir = true
var meta struct {
Name string `json:"name"`
Parents []string `json:"parents"`
MimeType string `json:"mimeType"`
}
if err := json.NewDecoder(r.Body).Decode(&meta); err != nil {
t.Fatalf("decode mkdir body: %v", err)
}
if meta.Name != "91 Spider" || len(meta.Parents) != 1 || meta.Parents[0] != "root" || meta.MimeType != "application/vnd.google-apps.folder" {
t.Fatalf("mkdir body = %+v", meta)
}
writeTestJSON(w, driveFile{ID: "folder-91", Name: "91 Spider", MimeType: "application/vnd.google-apps.folder"})
case r.Method == http.MethodPatch && r.URL.Path == "/drive/v3/files/file-1":
renamed = true
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode rename body: %v", err)
}
if body["name"] != "new-name.mp4" {
t.Fatalf("rename body = %+v", body)
}
writeTestJSON(w, driveFile{ID: "file-1", Name: "new-name.mp4"})
default:
t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
}
}))
defer srv.Close()
d := New(Config{ID: "g", RootID: "root", APIBaseURL: srv.URL + "/drive/v3"})
d.accessToken = "access"
d.listInterval = -1
dirID, err := d.EnsureDir(context.Background(), "91 Spider")
if err != nil {
t.Fatalf("EnsureDir() error = %v", err)
}
if dirID != "folder-91" || !madeDir {
t.Fatalf("dirID/madeDir = %q/%v, want folder-91/true", dirID, madeDir)
}
if err := d.Rename(context.Background(), "file-1", "new-name.mp4"); err != nil {
t.Fatalf("Rename() error = %v", err)
}
if !renamed {
t.Fatal("rename endpoint was not called")
}
}
func TestRequestRefreshesOnUnauthorized(t *testing.T) {
var fileCalls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -314,88 +179,6 @@ func TestRequestRefreshesOnUnauthorized(t *testing.T) {
}
}
func TestRateLimitReasonsFollowGoogleDriveErrorShape(t *testing.T) {
reasons := []string{
"rateLimitExceeded",
"userRateLimitExceeded",
"dailyLimitExceeded",
"dailyLimitExceededUnreg",
"downloadQuotaExceeded",
"sharingRateLimitExceeded",
"quotaExceeded",
}
for _, reason := range reasons {
body := apiErrorBody{
Code: http.StatusForbidden,
Message: "google drive quota or rate limited",
Errors: []struct {
Domain string `json:"domain"`
Reason string `json:"reason"`
Message string `json:"message"`
LocationType string `json:"location_type"`
Location string `json:"location"`
}{
{Domain: "usageLimits", Reason: reason, Message: reason},
},
}
if !isGoogleRateLimit(nil, body) {
t.Fatalf("reason %q not treated as rate limit", reason)
}
}
}
func TestStreamURLRateLimitStartsSharedLinkCooldown(t *testing.T) {
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.Header().Set("Retry-After", "120")
writeTestJSONStatus(w, http.StatusForbidden, apiErrorResp{Error: apiErrorBody{
Code: http.StatusForbidden,
Message: "User rate limit exceeded.",
Errors: []struct {
Domain string `json:"domain"`
Reason string `json:"reason"`
Message string `json:"message"`
LocationType string `json:"location_type"`
Location string `json:"location"`
}{
{Domain: "usageLimits", Reason: "userRateLimitExceeded", Message: "User rate limit exceeded."},
},
}})
}))
defer srv.Close()
d := New(Config{ID: "g", APIBaseURL: srv.URL})
d.accessToken = "access"
d.linkCooldownDuration = time.Hour
_, err := d.StreamURL(context.Background(), "file-1")
if err == nil {
t.Fatal("first StreamURL succeeded, want rate limit")
}
var rateLimit *drives.RateLimitError
if !errors.As(err, &rateLimit) {
t.Fatalf("first error = %T %[1]v, want RateLimitError", err)
}
if rateLimit.RetryAfter != 2*time.Minute {
t.Fatalf("retry after = %s, want 2m", rateLimit.RetryAfter)
}
_, err = d.StreamURL(context.Background(), "file-1")
if err == nil {
t.Fatal("second StreamURL succeeded during cooldown")
}
if !errors.As(err, &rateLimit) {
t.Fatalf("second error = %T %[1]v, want RateLimitError", err)
}
if calls != 1 {
t.Fatalf("remote calls = %d, want 1; second call should use shared cooldown", calls)
}
if rateLimit.RetryAfter <= 0 || rateLimit.RetryAfter > 2*time.Minute {
t.Fatalf("second retry after = %s, want remaining cooldown", rateLimit.RetryAfter)
}
}
func writeTestJSON(w http.ResponseWriter, v any) {
writeTestJSONStatus(w, http.StatusOK, v)
}
+3 -11
View File
@@ -42,16 +42,8 @@ type apiErrorBody struct {
Code int `json:"code"`
Message string `json:"message"`
Errors []struct {
Domain string `json:"domain"`
Reason string `json:"reason"`
Message string `json:"message"`
LocationType string `json:"location_type"`
Location string `json:"location"`
Domain string `json:"domain"`
Reason string `json:"reason"`
Message string `json:"message"`
} `json:"errors"`
}
type UploadResult struct {
FileID string
Hash string
Size int64
}
+2 -23
View File
@@ -10,7 +10,7 @@ import (
// Drive 是多家网盘统一抽象。上层不区分盘,只区分 Kind。
type Drive interface {
// Kind 返回驱动代号:"quark" / "p115" / "p123" / "pikpak" / "wopan" / "onedrive" / "googledrive" / "localstorage"
// Kind 返回驱动代号:"quark" / "p115" / "pikpak" / "wopan" / "onedrive" / "googledrive" / "localstorage"
Kind() string
// ID 返回该盘在 catalog 中的唯一标识
@@ -30,7 +30,7 @@ type Drive interface {
StreamURL(ctx context.Context, fileID string) (*StreamLink, error)
// Upload 把本地流写入指定目录,返回新文件 fileID。
// 当前预览视频和封面只保存在本地,不再通过该方法写回网盘。
// 当前 teaser 和封面只保存在本地,不再通过该方法写回网盘。
Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error)
// EnsureDir 保证指定路径存在(相对根目录),返回最终目录 fileID。
@@ -40,27 +40,6 @@ type Drive interface {
RootID() string
}
// Remover is an optional drive capability. It mirrors OpenList's optional
// Remove interface: callers must type-assert before deleting a source file.
type Remover interface {
Remove(ctx context.Context, fileID string) error
}
// SourceFile carries the catalog metadata available when an administrator
// requests deletion of the original source file.
type SourceFile struct {
FileID string
ParentID string
Name string
Size int64
}
// SourceRemover is an optional, richer removal capability for providers whose
// playback ID is not the same ID required by their delete API.
type SourceRemover interface {
RemoveSource(ctx context.Context, source SourceFile) error
}
type Entry struct {
ID string
Name string
+3 -207
View File
@@ -7,7 +7,6 @@ import (
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strings"
@@ -18,8 +17,6 @@ import (
const Kind = "localstorage"
const maxSTRMBytes = 64 * 1024
type Config struct {
ID string
RootPath string
@@ -50,7 +47,7 @@ func (d *Driver) Init(context.Context) error {
}
info, err := os.Stat(root)
if err != nil {
return fmt.Errorf("localstorage: stat root %q: %w%s", root, err, localStoragePathHint(d.rootPath))
return fmt.Errorf("localstorage: stat root: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("localstorage: root is not a directory: %s", root)
@@ -125,126 +122,11 @@ func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLi
if err != nil {
return nil, err
}
if info.IsDir() || !info.Mode().IsRegular() {
return nil, os.ErrNotExist
}
if strings.EqualFold(filepath.Ext(p), ".strm") {
return d.streamURLFromSTRM(ctx, p)
}
if info.Size() <= 0 {
return nil, os.ErrNotExist
}
return &drives.StreamLink{
URL: p,
Expires: time.Now().Add(24 * time.Hour),
}, nil
}
func (d *Driver) streamURLFromSTRM(ctx context.Context, strmPath string) (*drives.StreamLink, error) {
target, err := readSTRMTarget(strmPath)
if err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
if filepath.IsAbs(target) {
return d.localSTRMLink(strmPath, target)
}
u, err := url.Parse(target)
if err == nil {
switch strings.ToLower(u.Scheme) {
case "http", "https":
if u.Host == "" {
return nil, fmt.Errorf("localstorage: invalid strm url %q", target)
}
return &drives.StreamLink{
URL: target,
Expires: time.Now().Add(24 * time.Hour),
}, nil
case "file":
if u.Host != "" && !strings.EqualFold(u.Host, "localhost") {
return nil, fmt.Errorf("localstorage: unsupported strm file url host %q", u.Host)
}
return d.localSTRMLink(strmPath, u.Path)
case "":
// Local path below.
default:
return nil, fmt.Errorf("localstorage: unsupported strm target scheme %q", u.Scheme)
}
} else if strings.Contains(target, "://") {
return nil, fmt.Errorf("localstorage: invalid strm url %q: %w", target, err)
}
return d.localSTRMLink(strmPath, target)
}
func readSTRMTarget(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
data, err := io.ReadAll(io.LimitReader(f, maxSTRMBytes+1))
if err != nil {
return "", err
}
if len(data) > maxSTRMBytes {
return "", errors.New("localstorage: strm file is too large")
}
lines := strings.Split(string(data), "\n")
for i, line := range lines {
if i == 0 {
line = strings.TrimPrefix(line, "\ufeff")
}
line = strings.TrimSpace(line)
if line != "" {
return line, nil
}
}
return "", errors.New("localstorage: empty strm target")
}
func (d *Driver) localSTRMLink(strmPath, target string) (*drives.StreamLink, error) {
target = strings.TrimSpace(target)
if target == "" {
return nil, errors.New("localstorage: empty strm target")
}
var p string
if filepath.IsAbs(target) {
p = filepath.Clean(target)
} else {
p = filepath.Join(filepath.Dir(strmPath), filepath.FromSlash(target))
}
p, err := filepath.Abs(p)
if err != nil {
return nil, err
}
root, err := d.root()
if err != nil {
return nil, err
}
realPath, within, err := realPathWithinRoot(root, p)
if err != nil {
return nil, err
}
if !within {
return nil, errors.New("localstorage: strm target escapes root")
}
if strings.EqualFold(filepath.Ext(p), ".strm") || strings.EqualFold(filepath.Ext(realPath), ".strm") {
return nil, errors.New("localstorage: nested strm target is not supported")
}
info, err := os.Stat(realPath)
if err != nil {
return nil, err
}
if info.IsDir() || !info.Mode().IsRegular() || info.Size() <= 0 {
return nil, os.ErrNotExist
}
return &drives.StreamLink{
URL: realPath,
URL: p,
Expires: time.Now().Add(24 * time.Hour),
}, nil
}
@@ -257,39 +139,6 @@ func (d *Driver) EnsureDir(context.Context, string) (string, error) {
return "", drives.ErrNotSupported
}
func (d *Driver) Remove(ctx context.Context, fileID string) error {
if err := ctx.Err(); err != nil {
return err
}
p, rel, err := d.pathForID(fileID)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if rel == "" {
return errors.New("localstorage: refusing to remove root")
}
info, err := os.Stat(p)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if info.IsDir() {
return errors.New("localstorage: refusing to remove directory")
}
if !info.Mode().IsRegular() {
return errors.New("localstorage: refusing to remove non-regular file")
}
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func (d *Driver) root() (string, error) {
raw := strings.TrimSpace(d.rootPath)
if raw == "" {
@@ -309,8 +158,6 @@ func (d *Driver) root() (string, error) {
return filepath.Abs(raw)
}
var _ drives.Remover = (*Driver)(nil)
func (d *Driver) pathForID(id string) (string, string, error) {
root, err := d.root()
if err != nil {
@@ -327,63 +174,12 @@ func (d *Driver) pathForID(id string) (string, string, error) {
if err != nil {
return "", "", err
}
if !pathWithinRoot(root, p) {
return "", "", errors.New("localstorage: path escapes root")
}
if _, within, err := realPathWithinRoot(root, p); err != nil {
return "", "", err
} else if !within {
if p != root && !strings.HasPrefix(p, root+string(os.PathSeparator)) {
return "", "", errors.New("localstorage: path escapes root")
}
return p, rel, nil
}
func pathWithinRoot(root, path string) bool {
rel, err := filepath.Rel(root, path)
if err != nil {
return false
}
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)))
}
func realPathWithinRoot(root, path string) (string, bool, error) {
realRoot, err := filepath.EvalSymlinks(root)
if err != nil {
return "", false, err
}
realRoot, err = filepath.Abs(realRoot)
if err != nil {
return "", false, err
}
realPath, err := filepath.EvalSymlinks(path)
if err != nil {
return "", false, err
}
realPath, err = filepath.Abs(realPath)
if err != nil {
return "", false, err
}
return realPath, pathWithinRoot(realRoot, realPath), nil
}
func localStoragePathHint(configured string) string {
cwd, _ := os.Getwd()
parts := []string{}
if strings.TrimSpace(configured) != "" {
parts = append(parts, fmt.Sprintf("configured=%q", strings.TrimSpace(configured)))
}
if cwd != "" {
parts = append(parts, fmt.Sprintf("cwd=%q", cwd))
}
if _, err := os.Stat("/.dockerenv"); err == nil {
parts = append(parts, "docker=host paths must be bind-mounted into the container")
}
if len(parts) == 0 {
return ""
}
return " (" + strings.Join(parts, ", ") + ")"
}
func decodeRel(id string) (string, error) {
id = strings.TrimSpace(id)
if id == "" || id == "/" {
@@ -58,159 +58,6 @@ func TestListEncodesRelativePathsAndStreamURLResolvesFile(t *testing.T) {
}
}
func TestStreamURLResolvesHTTPSTRM(t *testing.T) {
root := t.TempDir()
strmPath := filepath.Join(root, "movie.strm")
target := "https://media.example/clip.mp4?token=abc"
if err := os.WriteFile(strmPath, []byte("\ufeff\n "+target+"\n"), 0o644); err != nil {
t.Fatalf("write strm: %v", err)
}
drv := New(Config{ID: "local", RootPath: root})
link, err := drv.StreamURL(context.Background(), encodeRel("movie.strm"))
if err != nil {
t.Fatalf("stream url: %v", err)
}
if link.URL != target {
t.Fatalf("url = %q, want %q", link.URL, target)
}
}
func TestStreamURLResolvesRelativeLocalSTRM(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "links"), 0o755); err != nil {
t.Fatalf("mkdir links: %v", err)
}
if err := os.MkdirAll(filepath.Join(root, "media"), 0o755); err != nil {
t.Fatalf("mkdir media: %v", err)
}
videoPath := filepath.Join(root, "media", "clip.mp4")
if err := os.WriteFile(videoPath, []byte("video"), 0o644); err != nil {
t.Fatalf("write video: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "links", "movie.strm"), []byte("../media/clip.mp4\n"), 0o644); err != nil {
t.Fatalf("write strm: %v", err)
}
drv := New(Config{ID: "local", RootPath: root})
link, err := drv.StreamURL(context.Background(), encodeRel("links/movie.strm"))
if err != nil {
t.Fatalf("stream url: %v", err)
}
if link.URL != videoPath {
t.Fatalf("url = %q, want %q", link.URL, videoPath)
}
}
func TestStreamURLRejectsInvalidSTRMTargets(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, root string) string
want string
}{
{
name: "empty",
setup: func(t *testing.T, root string) string {
t.Helper()
writeLocalStorageTestFile(t, filepath.Join(root, "empty.strm"), []byte("\n \r\n"))
return "empty.strm"
},
want: "empty strm target",
},
{
name: "escapes root",
setup: func(t *testing.T, root string) string {
t.Helper()
writeLocalStorageTestFile(t, filepath.Join(filepath.Dir(root), "outside.mp4"), []byte("video"))
writeLocalStorageTestFile(t, filepath.Join(root, "escape.strm"), []byte("../outside.mp4\n"))
return "escape.strm"
},
want: "escapes root",
},
{
name: "nested",
setup: func(t *testing.T, root string) string {
t.Helper()
writeLocalStorageTestFile(t, filepath.Join(root, "nested.strm"), []byte("https://media.example/clip.mp4\n"))
writeLocalStorageTestFile(t, filepath.Join(root, "outer.strm"), []byte("nested.strm\n"))
return "outer.strm"
},
want: "nested strm target",
},
{
name: "unsupported scheme",
setup: func(t *testing.T, root string) string {
t.Helper()
writeLocalStorageTestFile(t, filepath.Join(root, "ftp.strm"), []byte("ftp://media.example/clip.mp4\n"))
return "ftp.strm"
},
want: "unsupported strm target scheme",
},
{
name: "too large",
setup: func(t *testing.T, root string) string {
t.Helper()
writeLocalStorageTestFile(t, filepath.Join(root, "large.strm"), []byte(strings.Repeat("x", maxSTRMBytes+1)))
return "large.strm"
},
want: "strm file is too large",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
root := t.TempDir()
rel := tt.setup(t, root)
drv := New(Config{ID: "local", RootPath: root})
_, err := drv.StreamURL(context.Background(), encodeRel(rel))
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("error = %v, want contain %q", err, tt.want)
}
})
}
}
func TestStreamURLRejectsSTRMTargetEscapingRootThroughSymlink(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
writeLocalStorageTestFile(t, filepath.Join(outside, "secret.mp4"), []byte("secret"))
if err := os.MkdirAll(filepath.Join(root, "links"), 0o755); err != nil {
t.Fatalf("mkdir links: %v", err)
}
if err := os.MkdirAll(filepath.Join(root, "real"), 0o755); err != nil {
t.Fatalf("mkdir real: %v", err)
}
if err := os.Symlink(outside, filepath.Join(root, "real", "outside")); err != nil {
t.Fatalf("symlink: %v", err)
}
writeLocalStorageTestFile(t, filepath.Join(root, "links", "movie.strm"), []byte("../real/outside/secret.mp4\n"))
drv := New(Config{ID: "local", RootPath: root})
_, err := drv.StreamURL(context.Background(), encodeRel("links/movie.strm"))
if err == nil || !strings.Contains(err.Error(), "strm target escapes root") {
t.Fatalf("error = %v, want strm target escapes root", err)
}
}
func TestStreamURLRejectsSymlinkFileIDEscapingRoot(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
writeLocalStorageTestFile(t, filepath.Join(outside, "secret.mp4"), []byte("secret"))
if err := os.Symlink(filepath.Join(outside, "secret.mp4"), filepath.Join(root, "link.mp4")); err != nil {
t.Fatalf("symlink: %v", err)
}
drv := New(Config{ID: "local", RootPath: root})
_, err := drv.StreamURL(context.Background(), encodeRel("link.mp4"))
if err == nil || !strings.Contains(err.Error(), "path escapes root") {
t.Fatalf("error = %v, want path escapes root", err)
}
}
func TestStreamURLRejectsEscapingID(t *testing.T) {
drv := New(Config{ID: "local", RootPath: t.TempDir()})
escaped := base64.RawURLEncoding.EncodeToString([]byte("../secret.mp4"))
@@ -223,73 +70,13 @@ func TestStreamURLRejectsEscapingID(t *testing.T) {
}
func TestInitRequiresExistingDirectory(t *testing.T) {
missing := filepath.Join(t.TempDir(), "missing")
drv := New(Config{ID: "local", RootPath: missing})
drv := New(Config{ID: "local", RootPath: filepath.Join(t.TempDir(), "missing")})
err := drv.Init(context.Background())
if err == nil || !strings.Contains(err.Error(), "stat root") {
t.Fatalf("error = %v, want stat root failure", err)
}
if !strings.Contains(err.Error(), missing) || !strings.Contains(err.Error(), "configured=") {
t.Fatalf("error = %v, want diagnostic path details", err)
}
}
func TestPathForIDAllowsRootPathSlash(t *testing.T) {
drv := New(Config{ID: "local", RootPath: string(os.PathSeparator)})
childID := encodeRel("tmp")
path, rel, err := drv.pathForID(childID)
if err != nil {
t.Fatalf("pathForID: %v", err)
}
if rel != "tmp" {
t.Fatalf("rel = %q, want tmp", rel)
}
if path != filepath.Join(string(os.PathSeparator), "tmp") {
t.Fatalf("path = %q, want /tmp", path)
}
}
func TestScannerPersistsLocalStorageSTRM(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "collection"), 0o755); err != nil {
t.Fatalf("mkdir collection: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "collection", "clip.strm"), []byte("https://media.example/clip.mp4\n"), 0o644); err != nil {
t.Fatalf("write strm: %v", err)
}
cat, err := catalog.Open(filepath.Join(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)
}
})
drv := New(Config{ID: "local", RootPath: root})
sc := scanner.New(cat, drv, []string{".strm"}, nil, nil)
stats, err := sc.Run(ctx, drv.RootID())
if err != nil {
t.Fatalf("scan: %v", err)
}
if stats.Added != 1 {
t.Fatalf("added = %d, want 1", stats.Added)
}
fileID := encodeRel("collection/clip.strm")
got, err := cat.GetVideo(ctx, Kind+"-local-"+fileID)
if err != nil {
t.Fatalf("get video: %v", err)
}
if got.Ext != "strm" || got.FileID != fileID || got.Category != "collection" {
t.Fatalf("video = %#v, want local strm video in collection", got)
}
}
func TestScannerPersistsLocalStorageVideo(t *testing.T) {
@@ -330,10 +117,3 @@ func TestScannerPersistsLocalStorageVideo(t *testing.T) {
t.Fatalf("video = %#v, want local drive video in collection", got)
}
}
func writeLocalStorageTestFile(t *testing.T, path string, data []byte) {
t.Helper()
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
@@ -78,38 +78,12 @@ func (d *Driver) EnsureDir(context.Context, string) (string, error) {
return "", drives.ErrNotSupported
}
func (d *Driver) Remove(ctx context.Context, fileID string) error {
if err := ctx.Err(); err != nil {
return err
}
path, err := d.uploadPath(fileID)
if err != nil {
return err
}
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if info.IsDir() {
return errors.New("localupload: refusing to remove directory")
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func (d *Driver) RootID() string { return d.uploadDir() }
func (d *Driver) uploadDir() string {
return d.uploadDirPath
}
var _ drives.Remover = (*Driver)(nil)
func (d *Driver) uploadPath(fileID string) (string, error) {
if strings.TrimSpace(fileID) == "" || filepath.Base(fileID) != fileID {
return "", errors.New("invalid upload file id")
@@ -501,17 +501,6 @@ func (d *Driver) Rename(ctx context.Context, fileID, newName string) error {
return nil
}
func (d *Driver) Remove(ctx context.Context, fileID string) error {
fileID = strings.TrimSpace(fileID)
if fileID == "" {
return errors.New("onedrive remove: empty file id")
}
if err := d.request(ctx, d.itemURL(fileID), http.MethodDelete, nil, nil); err != nil {
return fmt.Errorf("onedrive remove: %w", err)
}
return nil
}
func (d *Driver) request(ctx context.Context, rawURL, method string, configure func(*resty.Request), out any) error {
return d.requestOnce(ctx, rawURL, method, configure, out, true)
}
@@ -752,4 +741,3 @@ func guessMime(name string) string {
}
var _ drives.Drive = (*Driver)(nil)
var _ drives.Remover = (*Driver)(nil)
+2 -36
View File
@@ -149,10 +149,6 @@ func sleepContext(ctx context.Context, d time.Duration) error {
}
func isTransient115ListError(err error) bool {
return isTransient115UpstreamError(err)
}
func isTransient115UpstreamError(err error) bool {
if err == nil {
return false
}
@@ -252,11 +248,11 @@ func (d *Driver) streamURLWithUA(ctx context.Context, fileID string, ua string)
// 需要先拿到 pickCode
f, err := d.client.GetFile(fileID)
if err != nil {
return nil, wrap115StreamTransientError("115 get file", err)
return nil, fmt.Errorf("115 get file: %w", err)
}
info, ua, err := d.downloadInfo(f.PickCode, ua)
if err != nil {
return nil, wrap115StreamTransientError("115 download url", err)
return nil, fmt.Errorf("115 download url: %w", err)
}
if info == nil || info.Url.Url == "" {
return nil, errors.New("115 download url: empty")
@@ -292,18 +288,6 @@ func (d *Driver) downloadInfo(pickCode string, ua string) (*sdk.DownloadInfo, st
return info, ua, nil
}
func wrap115StreamTransientError(op string, err error) error {
wrapped := fmt.Errorf("%s: %w", op, err)
if !isTransient115UpstreamError(err) {
return wrapped
}
return &drives.RateLimitError{
Provider: "p115",
RetryAfter: p115ListCooldown,
Err: wrapped,
}
}
func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) {
res, err := d.UploadAndReportSha1(ctx, parentID, name, r, size)
if err != nil {
@@ -461,23 +445,6 @@ func (d *Driver) Rename(ctx context.Context, fileID, newName string) error {
return nil
}
func (d *Driver) Remove(ctx context.Context, fileID string) error {
if d.client == nil {
return errors.New("p115 remove: driver not initialized")
}
if err := ctx.Err(); err != nil {
return err
}
fileID = strings.TrimSpace(fileID)
if fileID == "" {
return errors.New("p115 remove: empty fileID")
}
if err := d.client.Delete(fileID); err != nil {
return fmt.Errorf("p115 remove: %w", err)
}
return nil
}
// bufferAndHashSha1 把 r 全量复制到一个临时文件,同时计算 SHA1。
// 返回临时文件(位置在末尾,需调用方 Seek 回 0)、SHA1 hex 大写、实际字节数。
//
@@ -580,4 +547,3 @@ func guessMime(name string) string {
}
var _ drives.Drive = (*Driver)(nil)
var _ drives.Remover = (*Driver)(nil)
@@ -10,9 +10,6 @@ import (
"os"
"strings"
"testing"
"time"
"github.com/video-site/backend/internal/drives"
)
func TestIsTransient115ListError(t *testing.T) {
@@ -37,42 +34,6 @@ func TestIsTransient115ListError(t *testing.T) {
}
}
func TestWrap115StreamTransientError(t *testing.T) {
cases := []struct {
name string
err error
wantRateLimit bool
}{
{name: "unexpected", err: errors.New("unexpected error"), wantRateLimit: true},
{name: "405 blocked", err: errors.New("405 request has been blocked"), wantRateLimit: true},
{name: "429", err: errors.New("429 too many requests"), wantRateLimit: true},
{name: "blocked", err: errors.New("blocked by waf"), wantRateLimit: true},
{name: "auth", err: errors.New("invalid credential"), wantRateLimit: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := wrap115StreamTransientError("115 get file", tc.err)
var rateLimit *drives.RateLimitError
isRateLimit := errors.As(got, &rateLimit)
if isRateLimit != tc.wantRateLimit {
t.Fatalf("rate limit = %v, want %v; err=%v", isRateLimit, tc.wantRateLimit, got)
}
if !strings.Contains(got.Error(), "115 get file") {
t.Fatalf("err = %v, want operation prefix", got)
}
if tc.wantRateLimit {
if rateLimit.Provider != "p115" {
t.Fatalf("provider = %q, want p115", rateLimit.Provider)
}
if rateLimit.RetryAfter != 10*time.Minute {
t.Fatalf("retry after = %s, want 10m", rateLimit.RetryAfter)
}
}
})
}
}
// TestBufferAndHashSha1 验证 bufferAndHashSha1
//
// - 把 reader 的全部字节落到 tmp 文件
File diff suppressed because it is too large Load Diff
-487
View File
@@ -1,487 +0,0 @@
package p123
import (
"bytes"
"context"
"crypto/md5"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/video-site/backend/internal/drives"
)
func TestStreamURLResolvesDownloadInfoRedirect(t *testing.T) {
ctx := context.Background()
var downloadReferer string
var download *httptest.Server
download = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/resolve":
downloadReferer = r.Header.Get("Referer")
http.Redirect(w, r, download.URL+"/cdn/video.mp4", http.StatusFound)
case "/cdn/video.mp4":
t.Fatalf("driver followed redirect unexpectedly")
default:
http.NotFound(w, r)
}
}))
defer download.Close()
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/user/sign_in":
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 200,
"data": map[string]string{"token": "token-1"},
})
case "/b/api/user/info":
if got := r.Header.Get("Authorization"); got != "Bearer token-1" {
t.Fatalf("Authorization = %q, want bearer token", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{}})
case "/b/api/file/list/new":
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0,
"data": map[string]any{
"Next": "-1",
"Total": 1,
"InfoList": []map[string]any{
{
"FileName": "video.mp4",
"Size": 1234,
"UpdateAt": "2026-01-02 03:04:05",
"FileId": 100,
"Type": 0,
"Etag": "ABCDEF",
"S3KeyFlag": "flag-1",
},
},
},
})
case "/b/api/file/download_info":
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode download_info body: %v", err)
}
if got := body["fileName"]; got != "video.mp4" {
t.Fatalf("fileName = %#v, want cached file metadata", got)
}
if got := body["etag"]; got != "ABCDEF" {
t.Fatalf("etag = %#v, want cached etag", got)
}
entryURL := download.URL + "/entry?params=" + base64.StdEncoding.EncodeToString([]byte(download.URL+"/resolve"))
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0,
"data": map[string]string{"DownloadUrl": entryURL},
})
default:
http.NotFound(w, r)
}
}))
defer api.Close()
var savedToken string
d := New(Config{
ID: "123-main",
Username: "user@example.com",
Password: "secret",
MainAPIBaseURL: api.URL + "/b/api",
LoginAPIBaseURL: api.URL + "/api",
OnTokenUpdate: func(access string) {
savedToken = access
},
})
if err := d.Init(ctx); err != nil {
t.Fatalf("Init() error = %v", err)
}
if savedToken != "token-1" {
t.Fatalf("saved token = %q, want token-1", savedToken)
}
if _, err := d.List(ctx, d.RootID()); err != nil {
t.Fatalf("List() error = %v", err)
}
link, err := d.StreamURL(ctx, "100")
if err != nil {
t.Fatalf("StreamURL() error = %v", err)
}
if got := link.URL; got != download.URL+"/cdn/video.mp4" {
t.Fatalf("URL = %q, want final CDN URL", got)
}
if got := link.Headers.Get("Referer"); !strings.HasPrefix(got, download.URL) {
t.Fatalf("Referer = %q, want original download host", got)
}
if downloadReferer != defaultReferer {
t.Fatalf("resolve Referer = %q, want %q", downloadReferer, defaultReferer)
}
}
func TestInitUsesAccessTokenWithoutLogin(t *testing.T) {
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/user/sign_in":
t.Fatalf("driver should not password-login when access_token is configured")
case "/b/api/user/info":
if got := r.Header.Get("Authorization"); got != "Bearer token-1" {
t.Fatalf("Authorization = %q, want bearer token", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{}})
default:
http.NotFound(w, r)
}
}))
defer api.Close()
d := New(Config{
ID: "123-main",
AccessToken: "Bearer token-1",
MainAPIBaseURL: api.URL + "/b/api",
LoginAPIBaseURL: api.URL + "/api",
})
if err := d.Init(context.Background()); err != nil {
t.Fatalf("Init() error = %v", err)
}
}
func TestLoginRiskErrorSuggestsAccessToken(t *testing.T) {
err := loginError("当前账号存在境外登录风险,请使用短信验证码或者微信进行登录。")
if err == nil || !strings.Contains(err.Error(), "access_token") {
t.Fatalf("loginError() = %v, want access_token guidance", err)
}
}
func TestRequestCode429ReturnsRateLimitError(t *testing.T) {
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Retry-After", "2")
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 429,
"message": "请求太频繁",
})
}))
defer api.Close()
d := New(Config{
ID: "123-main",
AccessToken: "token-1",
MainAPIBaseURL: api.URL,
})
_, err := d.request(context.Background(), endpointFileList, http.MethodGet, nil, nil)
var rateLimit *drives.RateLimitError
if !errors.As(err, &rateLimit) {
t.Fatalf("error = %T %[1]v, want RateLimitError", err)
}
if rateLimit.RetryAfter != 2*time.Second {
t.Fatalf("RetryAfter = %s, want 2s", rateLimit.RetryAfter)
}
}
func TestListCoolsDownAndRetriesRateLimit(t *testing.T) {
var listCalls int
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/file/list/new" {
http.NotFound(w, r)
return
}
listCalls++
if listCalls == 1 {
w.Header().Set("Retry-After", "1")
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 429,
"message": "请求太频繁",
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0,
"data": map[string]any{
"Next": "-1",
"Total": 1,
"InfoList": []map[string]any{
{
"FileName": "video.mp4",
"Size": 1234,
"UpdateAt": "2026-01-02 03:04:05",
"FileId": 100,
"Type": 0,
"Etag": "ABCDEF",
"S3KeyFlag": "flag-1",
},
},
},
})
}))
defer api.Close()
d := New(Config{
ID: "123-main",
AccessToken: "token-1",
MainAPIBaseURL: api.URL,
})
entries, err := d.List(context.Background(), d.RootID())
if err != nil {
t.Fatalf("List() error = %v", err)
}
if listCalls != 2 {
t.Fatalf("list calls = %d, want 2", listCalls)
}
if len(entries) != 1 || entries[0].ID != "100" {
t.Fatalf("entries = %#v, want one file", entries)
}
}
func TestResolveDownloadURL429ReturnsRateLimitError(t *testing.T) {
download := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Retry-After", "3")
http.Error(w, "too many requests", http.StatusTooManyRequests)
}))
defer download.Close()
d := New(Config{ID: "123-main"})
_, err := d.resolveDownloadURL(context.Background(), download.URL)
var rateLimit *drives.RateLimitError
if !errors.As(err, &rateLimit) {
t.Fatalf("error = %T %[1]v, want RateLimitError", err)
}
if rateLimit.RetryAfter != 3*time.Second {
t.Fatalf("RetryAfter = %s, want 3s", rateLimit.RetryAfter)
}
}
func TestUploadAndReportHashUsesPresignedPUTAndComplete(t *testing.T) {
ctx := context.Background()
body := []byte("video bytes for 123 upload")
wantMD5 := fmt.Sprintf("%x", md5.Sum(body))
var putBody []byte
upload := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
t.Fatalf("upload method = %s, want PUT", r.Method)
}
if r.ContentLength != int64(len(body)) {
t.Fatalf("ContentLength = %d, want %d", r.ContentLength, len(body))
}
got, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read upload body: %v", err)
}
putBody = got
w.WriteHeader(http.StatusOK)
}))
defer upload.Close()
var uploadRequest map[string]any
var uploadURLRequest map[string]any
var completeRequest map[string]any
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/file/upload_request":
if err := json.NewDecoder(r.Body).Decode(&uploadRequest); err != nil {
t.Fatalf("decode upload_request: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0,
"data": map[string]any{
"FileId": 9001,
"Bucket": "bucket-1",
"Key": "key-1",
"StorageNode": "node-1",
"UploadId": "upload-1",
},
})
case "/file/s3_upload_object/auth":
if err := json.NewDecoder(r.Body).Decode(&uploadURLRequest); err != nil {
t.Fatalf("decode s3 auth: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0,
"data": map[string]any{
"presignedUrls": map[string]string{
"1": upload.URL + "/part-1",
},
},
})
case "/file/upload_complete/v2":
if err := json.NewDecoder(r.Body).Decode(&completeRequest); err != nil {
t.Fatalf("decode complete: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{}})
default:
http.NotFound(w, r)
}
}))
defer api.Close()
d := New(Config{
ID: "123-main",
AccessToken: "token-1",
MainAPIBaseURL: api.URL,
})
res, err := d.UploadAndReportHash(ctx, "parent-1", "video.mp4", bytes.NewReader(body), int64(len(body)))
if err != nil {
t.Fatalf("UploadAndReportHash() error = %v", err)
}
if res.FileID != "9001" {
t.Fatalf("FileID = %q, want 9001", res.FileID)
}
if res.Hash != wantMD5 {
t.Fatalf("Hash = %q, want %q", res.Hash, wantMD5)
}
if res.Size != int64(len(body)) {
t.Fatalf("Size = %d, want %d", res.Size, len(body))
}
if !bytes.Equal(putBody, body) {
t.Fatalf("PUT body = %q, want %q", putBody, body)
}
if uploadRequest["etag"] != wantMD5 {
t.Fatalf("upload etag = %#v, want %q", uploadRequest["etag"], wantMD5)
}
if uploadRequest["fileName"] != "video.mp4" || uploadRequest["parentFileId"] != "parent-1" {
t.Fatalf("upload request = %#v, want fileName and parentFileId", uploadRequest)
}
if uploadURLRequest["partNumberStart"].(float64) != 1 || uploadURLRequest["partNumberEnd"].(float64) != 2 {
t.Fatalf("s3 auth request = %#v, want part range 1..2", uploadURLRequest)
}
if completeRequest["fileId"].(float64) != 9001 || completeRequest["fileSize"].(float64) != float64(len(body)) {
t.Fatalf("complete request = %#v, want file id and size", completeRequest)
}
if completeRequest["isMultipart"].(bool) {
t.Fatalf("complete isMultipart = true, want false")
}
}
func TestUploadAndReportHashReuseSkipsPUTAndComplete(t *testing.T) {
body := []byte("reused body")
var presignedCalled bool
var completeCalled bool
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/file/upload_request":
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0,
"data": map[string]any{
"FileId": 7001,
"Reuse": true,
},
})
case "/file/s3_upload_object/auth", "/file/s3_repare_upload_parts_batch":
presignedCalled = true
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0})
case "/file/upload_complete/v2":
completeCalled = true
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0})
default:
http.NotFound(w, r)
}
}))
defer api.Close()
d := New(Config{
ID: "123-main",
AccessToken: "token-1",
MainAPIBaseURL: api.URL,
})
res, err := d.UploadAndReportHash(context.Background(), "parent-1", "reused.mp4", bytes.NewReader(body), int64(len(body)))
if err != nil {
t.Fatalf("UploadAndReportHash() error = %v", err)
}
if res.FileID != "7001" {
t.Fatalf("FileID = %q, want 7001", res.FileID)
}
if presignedCalled {
t.Fatal("reuse upload should not request presigned URLs")
}
if completeCalled {
t.Fatal("reuse upload should not call upload_complete")
}
}
func TestUploadPresignedPUT429ReturnsRateLimitError(t *testing.T) {
upload := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Retry-After", "4")
http.Error(w, "too many requests", http.StatusTooManyRequests)
}))
defer upload.Close()
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/file/upload_request":
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0,
"data": map[string]any{
"FileId": 9001,
"Bucket": "bucket-1",
"Key": "key-1",
"StorageNode": "node-1",
"UploadId": "upload-1",
},
})
case "/file/s3_upload_object/auth":
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0,
"data": map[string]any{
"presignedUrls": map[string]string{"1": upload.URL},
},
})
default:
http.NotFound(w, r)
}
}))
defer api.Close()
d := New(Config{
ID: "123-main",
AccessToken: "token-1",
MainAPIBaseURL: api.URL,
})
_, err := d.UploadAndReportHash(context.Background(), "parent-1", "limited.mp4", strings.NewReader("limited"), int64(len("limited")))
var rateLimit *drives.RateLimitError
if !errors.As(err, &rateLimit) {
t.Fatalf("error = %T %[1]v, want RateLimitError", err)
}
if rateLimit.RetryAfter != 4*time.Second {
t.Fatalf("RetryAfter = %s, want 4s", rateLimit.RetryAfter)
}
}
func TestRenameSendsExpectedBody(t *testing.T) {
var renameRequest map[string]any
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/file/rename" {
http.NotFound(w, r)
return
}
if err := json.NewDecoder(r.Body).Decode(&renameRequest); err != nil {
t.Fatalf("decode rename: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{}})
}))
defer api.Close()
d := New(Config{
ID: "123-main",
AccessToken: "token-1",
MainAPIBaseURL: api.URL,
})
if err := d.Rename(context.Background(), "9001", "new name.mp4"); err != nil {
t.Fatalf("Rename() error = %v", err)
}
if renameRequest["driveId"].(float64) != 0 || renameRequest["fileId"] != "9001" || renameRequest["fileName"] != "new name.mp4" {
t.Fatalf("rename request = %#v, want driveId/fileId/fileName", renameRequest)
}
}
-285
View File
@@ -1,285 +0,0 @@
package p123
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-resty/resty/v2"
"github.com/skip2/go-qrcode"
)
const (
defaultUserAPIBase = "https://user.123pan.cn/api"
defaultQRLoginPage = "https://www.123pan.com/wx-app-login.html"
defaultQRReferer = "https://user.123pan.com/centerlogin"
defaultQROrigin = "https://user.123pan.com"
defaultQRUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36"
endpointQRCodeGenerate = "/user/qr-code/generate"
endpointQRCodeResult = "/user/qr-code/result"
endpointQRCodeWXCode = "/user/qr-code/wx_code"
)
type QRConfig struct {
UserAPIBaseURL string
HTTPClient *http.Client
Now func() time.Time
}
type QRClient struct {
userAPIBase string
client *resty.Client
now func() time.Time
}
type QRCodeSession struct {
LoginUUID string `json:"loginUuid"`
UniID string `json:"uniID"`
QRCodeURL string `json:"qrCodeUrl"`
QRImageDataURL string `json:"qrImageDataUrl"`
ExpiresAt string `json:"expiresAt,omitempty"`
}
type QRCodeStatus struct {
LoginStatus int `json:"loginStatus"`
StatusText string `json:"statusText"`
ScanPlatform int `json:"scanPlatform,omitempty"`
PlatformText string `json:"platformText,omitempty"`
AccessToken string `json:"accessToken,omitempty"`
}
func NewQRClient(c QRConfig) *QRClient {
userAPIBase := strings.TrimRight(strings.TrimSpace(c.UserAPIBaseURL), "/")
if userAPIBase == "" {
userAPIBase = defaultUserAPIBase
}
httpClient := c.HTTPClient
if httpClient == nil {
httpClient = &http.Client{Timeout: 20 * time.Second}
}
now := c.Now
if now == nil {
now = time.Now
}
return &QRClient{
userAPIBase: userAPIBase,
client: resty.NewWithClient(httpClient).
SetTimeout(20*time.Second).
SetHeader("Accept", "application/json, text/plain, */*"),
now: now,
}
}
func (c *QRClient) Generate(ctx context.Context) (QRCodeSession, error) {
loginUUID, err := newLoginUUID()
if err != nil {
return QRCodeSession{}, err
}
var resp qrGenerateResp
res, err := c.request(ctx, loginUUID).
SetResult(&resp).
Get(c.userAPIBase + endpointQRCodeGenerate)
if err != nil {
return QRCodeSession{}, err
}
if resp.Code != 0 {
return QRCodeSession{}, qrAPIError(resp.Message, res.StatusCode(), resp.Code)
}
uniID := strings.TrimSpace(resp.Data.UniID)
if uniID == "" {
return QRCodeSession{}, errors.New("123pan qr: empty uniID")
}
qrURL := buildQRLoginURL(resp.Data.URL, uniID)
png, err := qrcode.Encode(qrURL, qrcode.Medium, 220)
if err != nil {
return QRCodeSession{}, err
}
return QRCodeSession{
LoginUUID: loginUUID,
UniID: uniID,
QRCodeURL: qrURL,
QRImageDataURL: "data:image/png;base64," + base64.StdEncoding.EncodeToString(png),
ExpiresAt: c.now().Add(5 * time.Minute).Format(time.RFC3339),
}, nil
}
func (c *QRClient) Poll(ctx context.Context, loginUUID, uniID string) (QRCodeStatus, error) {
loginUUID = strings.TrimSpace(loginUUID)
uniID = strings.TrimSpace(uniID)
if loginUUID == "" {
return QRCodeStatus{}, errors.New("loginUuid is required")
}
if uniID == "" {
return QRCodeStatus{}, errors.New("uniID is required")
}
var resp qrResultResp
res, err := c.request(ctx, loginUUID).
SetQueryParam("uniID", uniID).
SetResult(&resp).
Get(c.userAPIBase + endpointQRCodeResult)
if err != nil {
return QRCodeStatus{}, err
}
if resp.Code != 0 && resp.Code != 200 {
return QRCodeStatus{}, qrAPIError(resp.Message, res.StatusCode(), resp.Code)
}
if resp.Code == 200 {
resp.Data.LoginStatus = 3
if resp.Data.ScanPlatform == 0 {
resp.Data.ScanPlatform = resp.Data.LoginType
}
}
status := QRCodeStatus{
LoginStatus: resp.Data.LoginStatus,
StatusText: qrLoginStatusText(resp.Data.LoginStatus),
ScanPlatform: resp.Data.ScanPlatform,
PlatformText: qrScanPlatformText(resp.Data.ScanPlatform),
}
if status.LoginStatus != 3 {
return status, nil
}
if token := resp.TokenValue(); token != "" {
status.AccessToken = normalizeAccessToken(token)
return status, nil
}
if resp.Data.ScanPlatform == 4 {
token, err := c.finishWechatLogin(ctx, loginUUID, uniID)
if err != nil {
return QRCodeStatus{}, err
}
status.AccessToken = normalizeAccessToken(token)
return status, nil
}
return QRCodeStatus{}, errors.New("123pan qr: confirmed login returned empty token")
}
func (c *QRClient) finishWechatLogin(ctx context.Context, loginUUID, uniID string) (string, error) {
var wxResp qrWXCodeResp
res, err := c.request(ctx, loginUUID).
SetBody(map[string]string{"uniID": uniID}).
SetResult(&wxResp).
Post(c.userAPIBase + endpointQRCodeWXCode)
if err != nil {
return "", err
}
if wxResp.Code != 0 {
return "", qrAPIError(wxResp.Message, res.StatusCode(), wxResp.Code)
}
wxCode := strings.TrimSpace(wxResp.WXCode())
if wxCode == "" {
return "", errors.New("123pan qr: empty wechat code")
}
var signIn loginResp
res, err = c.request(ctx, loginUUID).
SetBody(map[string]any{
"from": "web",
"wechat_code": wxCode,
"type": 4,
}).
SetResult(&signIn).
Post(c.userAPIBase + endpointSignIn)
if err != nil {
return "", err
}
if signIn.Code != 200 && signIn.Code != 0 {
return "", qrAPIError(signIn.Message, res.StatusCode(), signIn.Code)
}
token := strings.TrimSpace(signIn.Data.Token)
if token == "" {
return "", errors.New("123pan qr: empty token")
}
return token, nil
}
func (c *QRClient) request(ctx context.Context, loginUUID string) *resty.Request {
return c.client.R().
SetContext(ctx).
SetHeaders(map[string]string{
"Content-Type": "application/json;charset=UTF-8",
"platform": defaultPlatform,
"App-Version": defaultAppVersion,
"LoginUuid": loginUUID,
"Referer": defaultQRReferer,
"Origin": defaultQROrigin,
"User-Agent": defaultQRUserAgent,
})
}
func buildQRLoginURL(raw, uniID string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
raw = defaultQRLoginPage
}
u, err := url.Parse(raw)
if err != nil {
return defaultQRLoginPage + "?env=production&uniID=" + url.QueryEscape(uniID) + "&source=123pan&type=login"
}
q := u.Query()
q.Set("env", "production")
q.Set("uniID", uniID)
q.Set("source", "123pan")
q.Set("type", "login")
u.RawQuery = q.Encode()
return u.String()
}
func newLoginUUID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
parts := []string{
hex.EncodeToString(b[0:4]),
hex.EncodeToString(b[4:6]),
hex.EncodeToString(b[6:8]),
hex.EncodeToString(b[8:10]),
hex.EncodeToString(b[10:16]),
}
return strings.Join(parts, "-"), nil
}
func qrAPIError(message string, httpStatus, apiCode int) error {
message = strings.TrimSpace(message)
if message == "" {
message = fmt.Sprintf("HTTP %d code=%d", httpStatus, apiCode)
}
return errors.New(message)
}
func qrLoginStatusText(status int) string {
switch status {
case 0:
return "等待扫码"
case 1:
return "已扫码,等待确认"
case 2:
return "已拒绝"
case 3:
return "已确认"
case 4:
return "已过期"
default:
return "未知状态"
}
}
func qrScanPlatformText(platform int) string {
switch platform {
case 4:
return "微信"
case 7:
return "123网盘 App"
default:
return ""
}
}
-182
View File
@@ -1,182 +0,0 @@
package p123
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestQRCodeGenerateBuildsImage(t *testing.T) {
var seenLoginUUID string
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/api/user/qr-code/generate" {
http.NotFound(w, r)
return
}
seenLoginUUID = r.Header.Get("LoginUuid")
if seenLoginUUID == "" {
t.Fatalf("missing LoginUuid header")
}
if r.Header.Get("platform") != defaultPlatform {
t.Fatalf("platform header = %q, want %q", r.Header.Get("platform"), defaultPlatform)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0,
"message": "ok",
"data": map[string]string{
"uniID": "uni-1",
"url": "https://www.123pan.com/wx-app-login.html",
},
})
}))
t.Cleanup(api.Close)
got, err := NewQRClient(QRConfig{UserAPIBaseURL: api.URL + "/api"}).Generate(context.Background())
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
if got.LoginUUID != seenLoginUUID {
t.Fatalf("loginUuid = %q, want header %q", got.LoginUUID, seenLoginUUID)
}
if got.UniID != "uni-1" {
t.Fatalf("uniID = %q, want uni-1", got.UniID)
}
if !strings.Contains(got.QRCodeURL, "uniID=uni-1") || !strings.Contains(got.QRCodeURL, "type=login") {
t.Fatalf("qrCodeUrl = %q, want login params", got.QRCodeURL)
}
if !strings.HasPrefix(got.QRImageDataURL, "data:image/png;base64,") {
t.Fatalf("qrImageDataUrl missing png data url prefix")
}
if got.ExpiresAt == "" {
t.Fatalf("expiresAt is empty")
}
}
func TestQRCodePollCompletesWechatLogin(t *testing.T) {
var wxCodeRequested bool
var signInRequested bool
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Header.Get("LoginUuid") != "login-1" {
t.Fatalf("LoginUuid = %q, want login-1", r.Header.Get("LoginUuid"))
}
switch r.URL.Path {
case "/api/user/qr-code/result":
if r.URL.Query().Get("uniID") != "uni-1" {
t.Fatalf("uniID = %q, want uni-1", r.URL.Query().Get("uniID"))
}
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0,
"data": map[string]any{
"loginStatus": 3,
"scanPlatform": 4,
},
})
case "/api/user/qr-code/wx_code":
wxCodeRequested = true
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode wx_code body: %v", err)
}
if body["uniID"] != "uni-1" {
t.Fatalf("wx_code uniID = %q, want uni-1", body["uniID"])
}
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0,
"data": map[string]string{"wxCode": "wx-code-1"},
})
case "/api/user/sign_in":
signInRequested = true
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode sign_in body: %v", err)
}
if body["wechat_code"] != "wx-code-1" {
t.Fatalf("wechat_code = %#v, want wx-code-1", body["wechat_code"])
}
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 200,
"data": map[string]string{"token": "Bearer token-1"},
})
default:
http.NotFound(w, r)
}
}))
t.Cleanup(api.Close)
got, err := NewQRClient(QRConfig{UserAPIBaseURL: api.URL + "/api"}).Poll(context.Background(), "login-1", "uni-1")
if err != nil {
t.Fatalf("Poll() error = %v", err)
}
if !wxCodeRequested || !signInRequested {
t.Fatalf("wechat completion calls wx=%v signIn=%v, want both", wxCodeRequested, signInRequested)
}
if got.LoginStatus != 3 || got.AccessToken != "token-1" || got.PlatformText != "微信" {
t.Fatalf("status = %#v, want confirmed wechat token", got)
}
}
func TestQRCodePollUsesAppToken(t *testing.T) {
var wxCodeRequested bool
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/user/qr-code/result":
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 0,
"data": map[string]any{
"loginStatus": 3,
"scanPlatform": 7,
"token": "app-token",
},
})
case "/api/user/qr-code/wx_code":
wxCodeRequested = true
http.Error(w, "unexpected wx_code", http.StatusInternalServerError)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(api.Close)
got, err := NewQRClient(QRConfig{UserAPIBaseURL: api.URL + "/api"}).Poll(context.Background(), "login-1", "uni-1")
if err != nil {
t.Fatalf("Poll() error = %v", err)
}
if wxCodeRequested {
t.Fatalf("wx_code should not be called when app token is already returned")
}
if got.AccessToken != "app-token" || got.PlatformText != "123网盘 App" {
t.Fatalf("status = %#v, want app token", got)
}
}
func TestQRCodePollUsesOfficialAppSuccessCode(t *testing.T) {
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/api/user/qr-code/result" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 200,
"data": map[string]any{
"login_type": 7,
"token": "app-token",
},
})
}))
t.Cleanup(api.Close)
got, err := NewQRClient(QRConfig{UserAPIBaseURL: api.URL + "/api"}).Poll(context.Background(), "login-1", "uni-1")
if err != nil {
t.Fatalf("Poll() error = %v", err)
}
if got.LoginStatus != 3 || got.ScanPlatform != 7 || got.AccessToken != "app-token" {
t.Fatalf("status = %#v, want official app success token", got)
}
}
-204
View File
@@ -1,204 +0,0 @@
package p123
import (
"encoding/json"
"strconv"
"strings"
"time"
)
type apiEnvelope struct {
Code int `json:"code"`
Message string `json:"message"`
}
type loginResp struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
Token string `json:"token"`
} `json:"data"`
}
type qrGenerateResp struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
UniID string `json:"uniID"`
URL string `json:"url"`
} `json:"data"`
}
type qrResultResp struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
LoginStatus int `json:"loginStatus"`
ScanPlatform int `json:"scanPlatform"`
LoginType int `json:"login_type"`
Token string `json:"token"`
AccessToken string `json:"accessToken"`
} `json:"data"`
}
func (r qrResultResp) TokenValue() string {
if strings.TrimSpace(r.Data.Token) != "" {
return r.Data.Token
}
return r.Data.AccessToken
}
type qrWXCodeResp struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
WXCodeLower string `json:"wxCode"`
WXCodeTitle string `json:"WxCode"`
Code string `json:"code"`
} `json:"data"`
}
func (r qrWXCodeResp) WXCode() string {
if r.Data.WXCodeLower != "" {
return r.Data.WXCodeLower
}
if r.Data.WXCodeTitle != "" {
return r.Data.WXCodeTitle
}
return r.Data.Code
}
type fileListResp struct {
Data struct {
Next string `json:"Next"`
Total int `json:"Total"`
InfoList []panFile `json:"InfoList"`
} `json:"data"`
}
type panFile struct {
FileName string `json:"FileName"`
Size int64 `json:"Size"`
UpdateAt flexibleTime `json:"UpdateAt"`
FileID int64 `json:"FileId"`
Type int `json:"Type"`
Etag string `json:"Etag"`
S3KeyFlag string `json:"S3KeyFlag"`
}
type cachedFile struct {
file panFile
parentID string
}
type downloadInfoResp struct {
Data struct {
DownloadURL string `json:"DownloadUrl"`
DownloadURLLower string `json:"downloadUrl"`
} `json:"data"`
}
func (r downloadInfoResp) URL() string {
if r.Data.DownloadURL != "" {
return r.Data.DownloadURL
}
return r.Data.DownloadURLLower
}
type redirectResp struct {
Data struct {
RedirectURL string `json:"redirect_url"`
RedirectURLCamel string `json:"redirectUrl"`
RedirectURLTitle string `json:"RedirectUrl"`
} `json:"data"`
}
func (r redirectResp) URL() string {
if r.Data.RedirectURL != "" {
return r.Data.RedirectURL
}
if r.Data.RedirectURLCamel != "" {
return r.Data.RedirectURLCamel
}
return r.Data.RedirectURLTitle
}
type mkdirResp struct {
Data struct {
FileID int64 `json:"FileId"`
} `json:"data"`
}
type uploadResp struct {
Data struct {
AccessKeyID string `json:"AccessKeyId"`
Bucket string `json:"Bucket"`
Key string `json:"Key"`
SecretAccessKey string `json:"SecretAccessKey"`
SessionToken string `json:"SessionToken"`
FileID int64 `json:"FileId"`
Reuse bool `json:"Reuse"`
EndPoint string `json:"EndPoint"`
StorageNode string `json:"StorageNode"`
UploadID string `json:"UploadId"`
} `json:"data"`
}
type s3PreSignedURLsResp struct {
Data struct {
PreSignedURLs map[string]string `json:"presignedUrls"`
} `json:"data"`
}
type flexibleTime struct {
t time.Time
}
func (t *flexibleTime) UnmarshalJSON(data []byte) error {
if string(data) == "null" || string(data) == `""` {
return nil
}
var s string
if err := json.Unmarshal(data, &s); err == nil {
t.t = parseTimeString(s)
return nil
}
var n int64
if err := json.Unmarshal(data, &n); err == nil {
if n > 1_000_000_000_000 {
t.t = time.UnixMilli(n)
} else {
t.t = time.Unix(n, 0)
}
return nil
}
return nil
}
func (t flexibleTime) Time() time.Time {
return t.t
}
func parseTimeString(s string) time.Time {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}
}
for _, layout := range []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02 15:04:05",
"2006-01-02T15:04:05",
} {
if parsed, err := time.ParseInLocation(layout, s, time.FixedZone("UTC+8", 8*3600)); err == nil {
return parsed
}
}
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
if n > 1_000_000_000_000 {
return time.UnixMilli(n)
}
return time.Unix(n, 0)
}
return time.Time{}
}
+2 -18
View File
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"path"
@@ -44,9 +43,8 @@ type Driver struct {
algorithms []string
userAgent string
client *resty.Client
onTokenUpdate func(access, refresh, captcha, deviceID string)
uploadToOSSFunc func(context.Context, *s3Params, io.Reader) error
client *resty.Client
onTokenUpdate func(access, refresh, captcha, deviceID string)
// captchaMu serializes captcha-token refreshes triggered by 4002 / 9
// recovery in requestOnce. Without it, N concurrent callers all hitting
@@ -356,19 +354,6 @@ func (d *Driver) Rename(ctx context.Context, fileID, newName string) error {
return nil
}
func (d *Driver) Remove(ctx context.Context, fileID string) error {
fileID = strings.TrimSpace(fileID)
if fileID == "" {
return errors.New("pikpak remove: empty file id")
}
if err := d.request(ctx, filesURL+":batchTrash", http.MethodPost, func(req *resty.Request) {
req.SetBody(map[string]any{"ids": []string{fileID}})
}, nil); err != nil {
return fmt.Errorf("pikpak remove: %w", err)
}
return nil
}
func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) {
currentID := d.rootID
for _, name := range splitPath(pathFromRoot) {
@@ -578,4 +563,3 @@ func ParseBoolDefault(raw string, def bool) bool {
}
var _ drives.Drive = (*Driver)(nil)
var _ drives.Remover = (*Driver)(nil)
+10 -146
View File
@@ -6,10 +6,7 @@ import (
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
@@ -29,7 +26,7 @@ import (
// - 未命中:resumable.params 含 S3 兼容凭证(access_key / secret /
// bucket / endpoint / key / security_token
//
// 3. 用 Aliyun OSS SDK PutObject 把字节传到 PikPak 返回的临时 OSS endpoint
// 3. 用 Aliyun OSS SDK PutObject 把字节传到 endpoint+bucket+key
//
// 4. PikPak 服务端轮询 OSS,发现完成后把 resp.File.ID 标记为可用;
// 所以 Upload 完成后直接返回 resp.File.ID 即可(一开始就有,
@@ -42,9 +39,6 @@ const (
// spider91 视频通常 ~100MiB,远低于该值。超过则需走 multipart,
// 当前未实现,遇到会显式报错。
maxSinglePutSize = 5*1024*1024*1024 - 1
// 首次上传失败后最多再重试 3 次。每次重试都会重新申请 PikPak
// upload session,以避开偶发不可解析/不可达的临时上传 endpoint。
pikpakUploadMaxAttempts = 4
)
// uploadTaskData 是 POST /drive/v1/files 的响应结构。
@@ -135,49 +129,13 @@ func (d *Driver) UploadAndReportHash(ctx context.Context, parentID, name string,
_ = os.Remove(tmp.Name())
}()
result := UploadResult{Hash: gcidHex, Size: actualSize}
var lastErr error
for attempt := 1; attempt <= pikpakUploadMaxAttempts; attempt++ {
if err := ctx.Err(); err != nil {
return UploadResult{}, err
}
resp, err := d.requestUploadSession(ctx, parentID, name, actualSize, gcidHex)
if err != nil {
lastErr = fmt.Errorf("pikpak upload: request session: %w", err)
if !shouldRetryPikPakUploadAttempt(lastErr, attempt) {
return UploadResult{}, lastErr
}
d.logUploadRetry(name, attempt, lastErr)
if err := pikpakSleepContext(ctx, pikpakUploadRetryDelay(attempt)); err != nil {
return UploadResult{}, err
}
continue
}
out, err := d.completeUploadAttempt(ctx, tmp, parentID, name, result, resp)
if err == nil {
return out, nil
}
lastErr = err
if !shouldRetryPikPakUploadAttempt(lastErr, attempt) {
return UploadResult{}, lastErr
}
d.logUploadRetry(name, attempt, lastErr)
if err := pikpakSleepContext(ctx, pikpakUploadRetryDelay(attempt)); err != nil {
return UploadResult{}, err
}
}
return UploadResult{}, lastErr
}
func (d *Driver) requestUploadSession(ctx context.Context, parentID, name string, size int64, gcidHex string) (uploadTaskData, error) {
// 2) 申请上传会话。
var resp uploadTaskData
if err := d.request(ctx, filesURL, http.MethodPost, func(req *resty.Request) {
req.SetBody(map[string]any{
"kind": "drive#file",
"name": name,
"size": size,
"size": actualSize,
"hash": gcidHex,
"upload_type": "UPLOAD_TYPE_RESUMABLE",
"objProvider": map[string]any{"provider": "UPLOAD_TYPE_UNKNOWN"},
@@ -185,13 +143,12 @@ func (d *Driver) requestUploadSession(ctx context.Context, parentID, name string
"folder_type": "NORMAL",
})
}, &resp); err != nil {
return uploadTaskData{}, err
return UploadResult{}, fmt.Errorf("pikpak upload: request session: %w", err)
}
return resp, nil
}
func (d *Driver) completeUploadAttempt(ctx context.Context, tmp *os.File, parentID, name string, result UploadResult, resp uploadTaskData) (UploadResult, error) {
// 命中秒传:服务端已经知道这个 hash,直接返回新文件 ID。
result := UploadResult{Hash: gcidHex, Size: actualSize}
// 3) 命中秒传:服务端已经知道这个 hash,直接返回新文件 ID。
if resp.Resumable == nil {
if resp.File.ID != "" {
result.FileID = resp.File.ID
@@ -206,7 +163,7 @@ func (d *Driver) completeUploadAttempt(ctx context.Context, tmp *os.File, parent
return result, nil
}
// 未命中秒传:把字节传到 S3 兼容存储。
// 4) 未命中秒传:把字节传到 S3 兼容存储。
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
return UploadResult{}, fmt.Errorf("pikpak upload: seek tmp: %w", err)
}
@@ -214,7 +171,7 @@ func (d *Driver) completeUploadAttempt(ctx context.Context, tmp *os.File, parent
return UploadResult{}, fmt.Errorf("pikpak upload: oss put: %w", err)
}
// 拿到 fileID。优先走响应里的预分配 ID;为空就回查目录。
// 5) 拿到 fileID。优先走响应里的预分配 ID;为空就回查目录。
if resp.File.ID != "" {
result.FileID = resp.File.ID
return result, nil
@@ -227,58 +184,6 @@ func (d *Driver) completeUploadAttempt(ctx context.Context, tmp *os.File, parent
return result, nil
}
func shouldRetryPikPakUploadAttempt(err error, attempt int) bool {
return attempt < pikpakUploadMaxAttempts && isRetryablePikPakUploadError(err)
}
func pikpakUploadRetryDelay(attempt int) time.Duration {
if attempt <= 0 {
return 0
}
return time.Duration(attempt) * time.Second
}
func (d *Driver) logUploadRetry(name string, attempt int, err error) {
log.Printf("[pikpak] upload retry drive=%s name=%q next_attempt=%d/%d err=%v",
d.id, name, attempt+1, pikpakUploadMaxAttempts, err)
}
func isRetryablePikPakUploadError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
var serviceErr oss.ServiceError
if errors.As(err, &serviceErr) {
return serviceErr.StatusCode == http.StatusTooManyRequests || serviceErr.StatusCode >= 500
}
var netErr net.Error
if errors.As(err, &netErr) {
return true
}
text := strings.ToLower(err.Error())
return strings.Contains(text, "no such host") ||
strings.Contains(text, "temporary failure in name resolution") ||
strings.Contains(text, "server misbehaving") ||
strings.Contains(text, "connection reset") ||
strings.Contains(text, "connection refused") ||
strings.Contains(text, "broken pipe") ||
strings.Contains(text, "eof") ||
strings.Contains(text, "i/o timeout") ||
strings.Contains(text, "tls handshake timeout") ||
strings.Contains(text, "http 429") ||
strings.Contains(text, "http 500") ||
strings.Contains(text, "http 502") ||
strings.Contains(text, "http 503") ||
strings.Contains(text, "http 504") ||
strings.Contains(text, "http 509") ||
strings.Contains(text, "too many requests") ||
strings.Contains(text, "temporarily unavailable") ||
strings.Contains(text, "service unavailable")
}
// bufferAndHashGCID 把 r 复制到一个临时文件,同时计算 GCID。
// 返回临时文件(位置在末尾,需要调用方 Seek 回 0)、GCID hex 大写、实际写入字节数。
//
@@ -310,13 +215,10 @@ func bufferAndHashGCID(r io.Reader, size int64) (*os.File, string, int64, error)
//
// 参数复用 PikPak 的临时凭证;必须带 Security Token 头部 + UserAgent,与 OpenList 一致。
func (d *Driver) uploadToOSS(ctx context.Context, p *s3Params, body io.Reader) error {
if d.uploadToOSSFunc != nil {
return d.uploadToOSSFunc(ctx, p, body)
}
if p == nil {
return errors.New("pikpak upload: nil s3 params")
}
client, err := newPikPakOSSClient(p)
client, err := oss.New(p.Endpoint, p.AccessKeyID, p.AccessKeySecret)
if err != nil {
return fmt.Errorf("oss client: %w", err)
}
@@ -333,44 +235,6 @@ func (d *Driver) uploadToOSS(ctx context.Context, p *s3Params, body io.Reader) e
)
}
func newPikPakOSSClient(p *s3Params, options ...oss.ClientOption) (*oss.Client, error) {
if p == nil {
return nil, errors.New("pikpak upload: nil s3 params")
}
clientOptions := make([]oss.ClientOption, 0, len(options)+1)
if isPikPakCNAMEEndpoint(p.Endpoint) {
clientOptions = append(clientOptions, oss.UseCname(true))
}
clientOptions = append(clientOptions, options...)
return oss.New(p.Endpoint, p.AccessKeyID, p.AccessKeySecret, clientOptions...)
}
func isPikPakCNAMEEndpoint(endpoint string) bool {
host := endpointHost(endpoint)
if host == "" {
return false
}
host = strings.TrimSuffix(strings.ToLower(host), ".")
return host != "mypikpak.com" && host != "mypikpak.net" &&
(strings.HasSuffix(host, ".mypikpak.com") || strings.HasSuffix(host, ".mypikpak.net"))
}
func endpointHost(endpoint string) string {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
return ""
}
if u, err := url.Parse(endpoint); err == nil && u.Host != "" {
endpoint = u.Host
} else if idx := strings.IndexByte(endpoint, '/'); idx >= 0 {
endpoint = endpoint[:idx]
}
if host, _, err := net.SplitHostPort(endpoint); err == nil {
endpoint = host
}
return strings.Trim(endpoint, "[]")
}
type readerWithCtx struct {
ctx context.Context
r io.Reader
@@ -6,15 +6,12 @@ import (
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"github.com/go-resty/resty/v2"
)
@@ -184,95 +181,6 @@ func TestUploadInstantSuccessFallsBackToListWhenFileIDMissing(t *testing.T) {
}
}
func TestUploadRetriesWithNewSessionWhenOSSEndpointDNSFails(t *testing.T) {
sessionRequests := 0
mux := http.NewServeMux()
mux.HandleFunc("/drive/v1/files", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %q, want POST", r.Method)
}
sessionRequests++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(fmt.Sprintf(`{
"upload_type": "UPLOAD_TYPE_RESUMABLE",
"resumable": {
"kind": "drive#resumable",
"provider": "UPLOAD_TYPE_UNKNOWN",
"params": {
"access_key_id": "ak",
"access_key_secret": "sk",
"bucket": "bucket",
"endpoint": "https://vip-lixian-%02d.upload-a10b.mypikpak.com",
"key": "object-key-%02d",
"security_token": "token"
}
},
"file": {"id": "retry-file-%02d", "name": "retry.mp4", "kind": "drive#file"}
}`, sessionRequests, sessionRequests, sessionRequests)))
})
server := httptest.NewServer(mux)
defer server.Close()
d := newTestDriver(t, server)
uploadAttempts := 0
var uploaded []byte
d.uploadToOSSFunc = func(_ context.Context, _ *s3Params, body io.Reader) error {
uploadAttempts++
if uploadAttempts == 1 {
return &net.DNSError{Err: "no such host", Name: "vip-lixian-01.upload-a10b.mypikpak.com"}
}
var err error
uploaded, err = io.ReadAll(body)
return err
}
payload := []byte("retry payload body")
id, err := d.Upload(context.Background(), "parent-id", "retry.mp4", bytes.NewReader(payload), int64(len(payload)))
if err != nil {
t.Fatalf("upload: %v", err)
}
if id != "retry-file-02" {
t.Fatalf("file id = %q, want retry-file-02 from the second session", id)
}
if sessionRequests != 2 {
t.Fatalf("session requests = %d, want 2", sessionRequests)
}
if uploadAttempts != 2 {
t.Fatalf("upload attempts = %d, want 2", uploadAttempts)
}
if !bytes.Equal(uploaded, payload) {
t.Fatalf("uploaded body = %q, want %q", string(uploaded), string(payload))
}
}
func TestPikPakOSSClientUsesCNAMEForPikPakUploadEndpoint(t *testing.T) {
params := &s3Params{
AccessKeyID: "ak",
AccessKeySecret: "sk",
Bucket: "vip-lixian-07",
Endpoint: "http://upload-a10b.mypikpak.com",
Key: "upload_tmp/object-key",
}
client, err := newPikPakOSSClient(params)
if err != nil {
t.Fatalf("new oss client: %v", err)
}
bucket, err := client.Bucket(params.Bucket)
if err != nil {
t.Fatalf("bucket: %v", err)
}
signed, err := bucket.SignURL(params.Key, oss.HTTPPut, 60)
if err != nil {
t.Fatalf("sign url: %v", err)
}
if strings.Contains(signed, "vip-lixian-07.upload-a10b.mypikpak.com") {
t.Fatalf("signed url uses invalid bucket-prefixed PikPak host: %s", signed)
}
if !strings.Contains(signed, "http://upload-a10b.mypikpak.com/upload_tmp%2Fobject-key") {
t.Fatalf("signed url = %s, want PikPak endpoint host with object key path", signed)
}
}
func TestUploadRejectsInvalidArguments(t *testing.T) {
d := New(Config{ID: "x", Username: "u", Password: "p", Platform: "web"})
cases := []struct {
+13 -30
View File
@@ -16,23 +16,23 @@ import (
)
const (
defaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/2.5.20 Chrome/100.0.4896.160 Electron/18.3.5.4-b478491100 Safari/537.36 Channel/pckk_other_ch"
defaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/2.5.20 Chrome/100.0.4896.160 Electron/18.3.5.4-b478491100 Safari/537.36 Channel/pckk_other_ch"
defaultReferer = "https://pan.quark.cn"
defaultAPI = "https://drive.quark.cn/1/clouddrive"
defaultPR = "ucpro"
)
type Driver struct {
id string
cookie string
rootID string
ua string
referer string
apiBase string
pr string
client *resty.Client
onCookieUpdate func(string)
useTranscodingAddress bool
id string
cookie string
rootID string
ua string
referer string
apiBase string
pr string
client *resty.Client
onCookieUpdate func(string)
useTranscodingAddress bool
}
type Config struct {
@@ -60,7 +60,7 @@ func New(c Config) *Driver {
onCookieUpdate: c.OnCookieUpdate,
}
d.client = resty.New().
SetTimeout(30*time.Second).
SetTimeout(30 * time.Second).
SetHeader("Accept", "application/json, text/plain, */*").
SetHeader("Referer", d.referer).
SetHeader("User-Agent", d.ua)
@@ -263,28 +263,12 @@ func (d *Driver) findChildDir(ctx context.Context, parent, name string) (string,
return "", nil
}
// ---------- 上传(第一版不实现,走本地预览视频兜底) ----------
// ---------- 上传(第一版不实现,走本地 teaser 兜底) ----------
func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader, size int64) (string, error) {
return "", drives.ErrNotSupported
}
func (d *Driver) Remove(ctx context.Context, fileID string) error {
fileID = strings.TrimSpace(fileID)
if fileID == "" {
return errors.New("quark remove: empty file id")
}
body := map[string]any{
"action_type": 1,
"exclude_fids": []string{},
"filelist": []string{fileID},
}
if err := d.request(ctx, "/file/delete", http.MethodPost, nil, body, nil); err != nil {
return fmt.Errorf("quark remove: %w", err)
}
return nil
}
// ---------- helpers ----------
func fileToEntry(f *file, parentID string) drives.Entry {
@@ -359,4 +343,3 @@ func setCookieValue(cookie, key, value string) string {
}
var _ drives.Drive = (*Driver)(nil)
var _ drives.Remover = (*Driver)(nil)
File diff suppressed because it is too large Load Diff
@@ -1,653 +0,0 @@
package scriptcrawler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/video-site/backend/internal/catalog"
"github.com/video-site/backend/internal/fingerprint"
)
const (
scriptCrawlerDuplicateBytes = "duplicate-video-bytes"
scriptCrawlerUniqueBytes = "unique-video-bytes"
)
func writeScriptCrawlerFFprobeStub(t *testing.T, dir string, ok bool) string {
t.Helper()
name := "ffprobe-ok.sh"
body := "#!/bin/sh\necho video\nexit 0\n"
if !ok {
name = "ffprobe-fail.sh"
body = "#!/bin/sh\necho 'moov atom not found' >&2\nexit 1\n"
}
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
t.Fatalf("write ffprobe stub: %v", err)
}
return path
}
func writeScriptCrawlerFFmpegStub(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "ffmpeg-hls.sh")
body := "#!/bin/sh\nout=\"\"\nfor arg do out=\"$arg\"; done\nprintf 'hls-video-bytes' > \"$out\"\n"
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
t.Fatalf("write ffmpeg stub: %v", err)
}
return path
}
func TestCrawlerRunOnceImportsLocalFileAndSkipsExisting(t *testing.T) {
ctx := context.Background()
tmp := t.TempDir()
cat, err := catalog.Open(filepath.Join(tmp, "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)
}
})
drv := New(Config{ID: "demo", RootDir: filepath.Join(tmp, "crawler")})
if err := drv.Init(ctx); err != nil {
t.Fatalf("driver init: %v", err)
}
dummyScript := filepath.Join(tmp, "helper-script")
if err := os.WriteFile(dummyScript, []byte("helper"), 0o755); err != nil {
t.Fatalf("write dummy script: %v", err)
}
wrapper := filepath.Join(tmp, "helper-wrapper.sh")
wrapperScript := fmt.Sprintf("#!/bin/sh\nexec %q -test.run=TestScriptCrawlerHelperProcess \"$@\"\n", os.Args[0])
if err := os.WriteFile(wrapper, []byte(wrapperScript), 0o755); err != nil {
t.Fatalf("write helper wrapper: %v", err)
}
t.Setenv("GO_WANT_SCRIPTCRAWLER_HELPER", "1")
c := NewCrawler(CrawlerConfig{
Driver: drv,
Catalog: cat,
CrawlerName: "Demo Crawler",
PythonPath: wrapper,
FFprobePath: writeScriptCrawlerFFprobeStub(t, tmp, true),
ScriptPath: dummyScript,
})
res, err := c.RunOnce(ctx, 1)
if err != nil {
t.Fatalf("run once: %v", err)
}
if res.NewVideos != 1 || res.Skipped != 0 || res.Failed != 0 {
t.Fatalf("result = new:%d skipped:%d failed:%d, want 1/0/0", res.NewVideos, res.Skipped, res.Failed)
}
v, err := cat.GetVideo(ctx, BuildVideoID("demo", "abc-123"))
if err != nil {
t.Fatalf("get video: %v", err)
}
if v.Title != "Imported From Helper" || v.FileID != "abc-123.mp4" || v.Size == 0 {
t.Fatalf("video = title:%q file:%q size:%d", v.Title, v.FileID, v.Size)
}
if !hasString(v.Tags, "Demo Crawler") {
t.Fatalf("video tags = %#v, want crawler name tag", v.Tags)
}
if _, err := os.Stat(filepath.Join(drv.VideosDir(), "abc-123.mp4")); err != nil {
t.Fatalf("video file not copied: %v", err)
}
res, err = c.RunOnce(ctx, 1)
if err != nil {
t.Fatalf("second run: %v", err)
}
if res.NewVideos != 0 || res.Skipped != 1 {
t.Fatalf("second result = new:%d skipped:%d, want 0/1", res.NewVideos, res.Skipped)
}
if res.SeenSnapshot != 1 {
t.Fatalf("seen snapshot = %d, want 1", res.SeenSnapshot)
}
}
func TestCrawlerRunOnceUsesSourceKindNamespace(t *testing.T) {
ctx := context.Background()
tmp := t.TempDir()
cat, err := catalog.Open(filepath.Join(tmp, "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)
}
})
drv := New(Config{ID: "demo", RootDir: filepath.Join(tmp, "crawler")})
if err := drv.Init(ctx); err != nil {
t.Fatalf("driver init: %v", err)
}
dummyScript := filepath.Join(tmp, "helper-script")
if err := os.WriteFile(dummyScript, []byte("helper"), 0o755); err != nil {
t.Fatalf("write dummy script: %v", err)
}
wrapper := filepath.Join(tmp, "helper-wrapper.sh")
wrapperScript := fmt.Sprintf("#!/bin/sh\nexec %q -test.run=TestScriptCrawlerHelperProcess \"$@\"\n", os.Args[0])
if err := os.WriteFile(wrapper, []byte(wrapperScript), 0o755); err != nil {
t.Fatalf("write helper wrapper: %v", err)
}
t.Setenv("GO_WANT_SCRIPTCRAWLER_HELPER", "1")
c := NewCrawler(CrawlerConfig{
Driver: drv,
Catalog: cat,
SourceKind: "spider91",
PythonPath: wrapper,
FFprobePath: writeScriptCrawlerFFprobeStub(t, tmp, true),
ScriptPath: dummyScript,
})
res, err := c.RunOnce(ctx, 1)
if err != nil {
t.Fatalf("run once: %v", err)
}
if res.NewVideos != 1 || res.SeenSnapshot != 0 {
t.Fatalf("result = new:%d seen:%d, want 1/0", res.NewVideos, res.SeenSnapshot)
}
videoID := BuildVideoIDForKind("spider91", "demo", "abc-123")
if _, err := cat.GetVideo(ctx, videoID); err != nil {
t.Fatalf("get source-kind video: %v", err)
}
if _, err := cat.GetVideo(ctx, BuildVideoID("demo", "abc-123")); err == nil {
t.Fatalf("default namespace video unexpectedly exists")
}
res, err = c.RunOnce(ctx, 1)
if err != nil {
t.Fatalf("second run: %v", err)
}
if res.NewVideos != 0 || res.Skipped != 1 || res.SeenSnapshot != 1 {
t.Fatalf("second result = new:%d skipped:%d seen:%d, want 0/1/1", res.NewVideos, res.Skipped, res.SeenSnapshot)
}
}
func TestCrawlerRunOncePassesAbsoluteJobPathsWhenWorkDirDiffers(t *testing.T) {
ctx := context.Background()
tmp := t.TempDir()
t.Chdir(tmp)
cat, err := catalog.Open(filepath.Join(tmp, "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)
}
})
drv := New(Config{ID: "demo", RootDir: filepath.Join("data", "crawler")})
if err := drv.Init(ctx); err != nil {
t.Fatalf("driver init: %v", err)
}
scriptDir := filepath.Join(tmp, "scripts")
if err := os.MkdirAll(scriptDir, 0o755); err != nil {
t.Fatalf("mkdir script dir: %v", err)
}
dummyScript := filepath.Join(scriptDir, "helper-script")
if err := os.WriteFile(dummyScript, []byte("helper"), 0o755); err != nil {
t.Fatalf("write dummy script: %v", err)
}
wrapper := filepath.Join(tmp, "helper-wrapper.sh")
wrapperScript := fmt.Sprintf("#!/bin/sh\nexec %q -test.run=TestScriptCrawlerHelperProcess \"$@\"\n", os.Args[0])
if err := os.WriteFile(wrapper, []byte(wrapperScript), 0o755); err != nil {
t.Fatalf("write helper wrapper: %v", err)
}
t.Setenv("GO_WANT_SCRIPTCRAWLER_HELPER", "1")
t.Setenv("GO_WANT_SCRIPTCRAWLER_ASSERT_ABS", "1")
c := NewCrawler(CrawlerConfig{
Driver: drv,
Catalog: cat,
PythonPath: wrapper,
FFprobePath: writeScriptCrawlerFFprobeStub(t, tmp, true),
ScriptPath: dummyScript,
WorkDir: scriptDir,
})
res, err := c.RunOnce(ctx, 1)
if err != nil {
t.Fatalf("run once: %v", err)
}
if res.NewVideos != 1 || res.Skipped != 0 || res.Failed != 0 {
t.Fatalf("result = new:%d skipped:%d failed:%d, want 1/0/0", res.NewVideos, res.Skipped, res.Failed)
}
if !filepath.IsAbs(res.JobFile) || !filepath.IsAbs(res.SeenFile) {
t.Fatalf("result paths should be absolute: job=%q seen=%q", res.JobFile, res.SeenFile)
}
}
func TestCrawlerRunOnceImportsSimpleMediaURLWithoutSourceID(t *testing.T) {
ctx := context.Background()
tmp := t.TempDir()
cat, err := catalog.Open(filepath.Join(tmp, "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)
}
})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/video.mp4" {
http.NotFound(w, r)
return
}
_, _ = w.Write([]byte("simple-video-bytes"))
}))
defer srv.Close()
drv := New(Config{ID: "demo", RootDir: filepath.Join(tmp, "crawler")})
if err := drv.Init(ctx); err != nil {
t.Fatalf("driver init: %v", err)
}
dummyScript := filepath.Join(tmp, "helper-script")
if err := os.WriteFile(dummyScript, []byte("helper"), 0o755); err != nil {
t.Fatalf("write dummy script: %v", err)
}
wrapper := filepath.Join(tmp, "helper-wrapper.sh")
wrapperScript := fmt.Sprintf("#!/bin/sh\nexec %q -test.run=TestScriptCrawlerHelperProcess \"$@\"\n", os.Args[0])
if err := os.WriteFile(wrapper, []byte(wrapperScript), 0o755); err != nil {
t.Fatalf("write helper wrapper: %v", err)
}
t.Setenv("GO_WANT_SCRIPTCRAWLER_HELPER", "1")
t.Setenv("GO_WANT_SCRIPTCRAWLER_SIMPLE", "1")
t.Setenv("GO_SCRIPTCRAWLER_MEDIA_URL", srv.URL+"/video.mp4?token=first")
c := NewCrawler(CrawlerConfig{
Driver: drv,
Catalog: cat,
PythonPath: wrapper,
FFprobePath: writeScriptCrawlerFFprobeStub(t, tmp, true),
ScriptPath: dummyScript,
HTTPClient: srv.Client(),
})
res, err := c.RunOnce(ctx, 1)
if err != nil {
t.Fatalf("run once: %v", err)
}
if res.NewVideos != 1 || res.Skipped != 0 || res.Failed != 0 {
t.Fatalf("result = new:%d skipped:%d failed:%d, want 1/0/0", res.NewVideos, res.Skipped, res.Failed)
}
videos, err := cat.ListVideosByDrive(ctx, "demo")
if err != nil {
t.Fatalf("list videos: %v", err)
}
if len(videos) != 1 {
t.Fatalf("videos = %d, want 1", len(videos))
}
v := videos[0]
if !strings.HasPrefix(v.ID, BuildVideoID("demo", "auto-")) {
t.Fatalf("video id = %q, want generated auto source id", v.ID)
}
if v.Title != "Simple Protocol Video" || v.Ext != "mp4" || v.ThumbnailURL != "" || v.Size == 0 {
t.Fatalf("video = title:%q ext:%q thumb:%q size:%d", v.Title, v.Ext, v.ThumbnailURL, v.Size)
}
if _, err := os.Stat(filepath.Join(drv.VideosDir(), v.FileID)); err != nil {
t.Fatalf("video file not downloaded: %v", err)
}
t.Setenv("GO_SCRIPTCRAWLER_MEDIA_URL", srv.URL+"/video.mp4?token=second")
res, err = c.RunOnce(ctx, 1)
if err != nil {
t.Fatalf("second run: %v", err)
}
if res.NewVideos != 0 || res.Skipped != 1 {
t.Fatalf("second result = new:%d skipped:%d, want 0/1", res.NewVideos, res.Skipped)
}
}
func TestCrawlerRunOnceSkipsFingerprintDuplicateAndContinues(t *testing.T) {
ctx := context.Background()
tmp := t.TempDir()
cat, err := catalog.Open(filepath.Join(tmp, "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)
}
})
drv := New(Config{ID: "demo", RootDir: filepath.Join(tmp, "crawler")})
if err := drv.Init(ctx); err != nil {
t.Fatalf("driver init: %v", err)
}
seedFile := "seed-canonical.mp4"
if err := os.WriteFile(filepath.Join(drv.VideosDir(), seedFile), []byte(scriptCrawlerDuplicateBytes), 0o644); err != nil {
t.Fatalf("write seed video: %v", err)
}
seed := &catalog.Video{
ID: "seed-for-hash",
DriveID: drv.ID(),
FileID: seedFile,
Title: "Seed",
Size: int64(len(scriptCrawlerDuplicateBytes)),
PublishedAt: time.Now(),
}
sampled, err := fingerprint.Compute(ctx, drv, seed, fingerprint.Config{}, nil)
if err != nil {
t.Fatalf("compute seed fingerprint: %v", err)
}
_ = os.Remove(filepath.Join(drv.VideosDir(), seedFile))
now := time.Now()
if err := cat.UpsertVideo(ctx, &catalog.Video{
ID: "existing-canonical",
DriveID: "other-drive",
FileID: "existing.mp4",
FileName: "existing.mp4",
Title: "Existing Canonical",
Size: int64(len(scriptCrawlerDuplicateBytes)),
Ext: "mp4",
SampledSHA256: sampled,
FingerprintStatus: "ready",
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("seed canonical video: %v", err)
}
dummyScript := filepath.Join(tmp, "helper-script")
if err := os.WriteFile(dummyScript, []byte("helper"), 0o755); err != nil {
t.Fatalf("write dummy script: %v", err)
}
wrapper := filepath.Join(tmp, "helper-wrapper.sh")
wrapperScript := fmt.Sprintf("#!/bin/sh\nexec %q -test.run=TestScriptCrawlerHelperProcess \"$@\"\n", os.Args[0])
if err := os.WriteFile(wrapper, []byte(wrapperScript), 0o755); err != nil {
t.Fatalf("write helper wrapper: %v", err)
}
t.Setenv("GO_WANT_SCRIPTCRAWLER_HELPER", "1")
t.Setenv("GO_WANT_SCRIPTCRAWLER_DUP_UNIQUE", "1")
c := NewCrawler(CrawlerConfig{
Driver: drv,
Catalog: cat,
PythonPath: wrapper,
FFprobePath: writeScriptCrawlerFFprobeStub(t, tmp, true),
ScriptPath: dummyScript,
})
res, err := c.RunOnce(ctx, 1)
if err != nil {
t.Fatalf("run once: %v", err)
}
if res.NewVideos != 1 || res.Skipped != 1 || res.Failed != 0 || res.TotalEntries != 2 {
t.Fatalf("result = total:%d new:%d skipped:%d failed:%d, want 2/1/1/0", res.TotalEntries, res.NewVideos, res.Skipped, res.Failed)
}
if res.CandidateBudget <= res.TargetNew {
t.Fatalf("candidate budget = %d, target = %d; want expanded budget", res.CandidateBudget, res.TargetNew)
}
if _, err := cat.GetVideo(ctx, BuildVideoID("demo", "dup-source")); err == nil {
t.Fatal("duplicate candidate should not be imported")
}
if _, err := os.Stat(filepath.Join(drv.VideosDir(), "dup-source.mp4")); !os.IsNotExist(err) {
t.Fatalf("duplicate local file stat = %v, want removed", err)
}
v, err := cat.GetVideo(ctx, BuildVideoID("demo", "unique-source"))
if err != nil {
t.Fatalf("unique video should be imported: %v", err)
}
if v.SampledSHA256 == "" || v.FingerprintStatus != "ready" {
t.Fatalf("unique fingerprint = %q status=%q, want ready sampled fingerprint", v.SampledSHA256, v.FingerprintStatus)
}
seen, err := cat.ListCrawlerSourceIDs(ctx, Kind, "demo")
if err != nil {
t.Fatalf("list seen source ids: %v", err)
}
seenSet := map[string]bool{}
for _, id := range seen {
seenSet[id] = true
}
if !seenSet["dup-source"] || !seenSet["unique-source"] {
t.Fatalf("seen ids = %#v, want duplicate and imported source ids", seen)
}
}
func TestCrawlerRunOnceRejectsInvalidDownloadedVideo(t *testing.T) {
ctx := context.Background()
tmp := t.TempDir()
cat, err := catalog.Open(filepath.Join(tmp, "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)
}
})
drv := New(Config{ID: "demo", RootDir: filepath.Join(tmp, "crawler")})
if err := drv.Init(ctx); err != nil {
t.Fatalf("driver init: %v", err)
}
dummyScript := filepath.Join(tmp, "helper-script")
if err := os.WriteFile(dummyScript, []byte("helper"), 0o755); err != nil {
t.Fatalf("write dummy script: %v", err)
}
wrapper := filepath.Join(tmp, "helper-wrapper.sh")
wrapperScript := fmt.Sprintf("#!/bin/sh\nexec %q -test.run=TestScriptCrawlerHelperProcess \"$@\"\n", os.Args[0])
if err := os.WriteFile(wrapper, []byte(wrapperScript), 0o755); err != nil {
t.Fatalf("write helper wrapper: %v", err)
}
t.Setenv("GO_WANT_SCRIPTCRAWLER_HELPER", "1")
c := NewCrawler(CrawlerConfig{
Driver: drv,
Catalog: cat,
CrawlerName: "Demo Crawler",
PythonPath: wrapper,
FFprobePath: writeScriptCrawlerFFprobeStub(t, tmp, false),
ScriptPath: dummyScript,
})
res, err := c.RunOnce(ctx, 1)
if err != nil {
t.Fatalf("run once: %v", err)
}
if res.NewVideos != 0 || res.Skipped != 0 || res.Failed != 1 || res.TotalEntries != 1 {
t.Fatalf("result = total:%d new:%d skipped:%d failed:%d, want 1/0/0/1", res.TotalEntries, res.NewVideos, res.Skipped, res.Failed)
}
if _, err := cat.GetVideo(ctx, BuildVideoID("demo", "abc-123")); err == nil {
t.Fatal("invalid video should not be imported")
}
if _, err := os.Stat(filepath.Join(drv.VideosDir(), "abc-123.mp4")); !os.IsNotExist(err) {
t.Fatalf("invalid local video stat = %v, want removed", err)
}
seen, err := cat.ListCrawlerSourceIDs(ctx, Kind, "demo")
if err != nil {
t.Fatalf("list seen source ids: %v", err)
}
if len(seen) != 0 {
t.Fatalf("seen ids = %#v, want none for invalid video", seen)
}
}
func TestCrawlerRunOnceDownloadsHLSMediaURL(t *testing.T) {
ctx := context.Background()
tmp := t.TempDir()
cat, err := catalog.Open(filepath.Join(tmp, "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)
}
})
drv := New(Config{ID: "demo", RootDir: filepath.Join(tmp, "crawler")})
if err := drv.Init(ctx); err != nil {
t.Fatalf("driver init: %v", err)
}
dummyScript := filepath.Join(tmp, "helper-script")
if err := os.WriteFile(dummyScript, []byte("helper"), 0o755); err != nil {
t.Fatalf("write dummy script: %v", err)
}
wrapper := filepath.Join(tmp, "helper-wrapper.sh")
wrapperScript := fmt.Sprintf("#!/bin/sh\nexec %q -test.run=TestScriptCrawlerHelperProcess \"$@\"\n", os.Args[0])
if err := os.WriteFile(wrapper, []byte(wrapperScript), 0o755); err != nil {
t.Fatalf("write helper wrapper: %v", err)
}
t.Setenv("GO_WANT_SCRIPTCRAWLER_HELPER", "1")
t.Setenv("GO_WANT_SCRIPTCRAWLER_HLS", "1")
c := NewCrawler(CrawlerConfig{
Driver: drv,
Catalog: cat,
CrawlerName: "Demo Crawler",
PythonPath: wrapper,
FFmpegPath: writeScriptCrawlerFFmpegStub(t, tmp),
FFprobePath: writeScriptCrawlerFFprobeStub(t, tmp, true),
ScriptPath: dummyScript,
})
res, err := c.RunOnce(ctx, 1)
if err != nil {
t.Fatalf("run once: %v", err)
}
if res.NewVideos != 1 || res.Skipped != 0 || res.Failed != 0 {
t.Fatalf("result = new:%d skipped:%d failed:%d, want 1/0/0", res.NewVideos, res.Skipped, res.Failed)
}
v, err := cat.GetVideo(ctx, BuildVideoID("demo", "hls-source"))
if err != nil {
t.Fatalf("get hls video: %v", err)
}
if v.FileID != "hls-source.mp4" || v.Size != int64(len("hls-video-bytes")) {
t.Fatalf("video file=%q size=%d, want hls-source.mp4 size %d", v.FileID, v.Size, len("hls-video-bytes"))
}
data, err := os.ReadFile(filepath.Join(drv.VideosDir(), "hls-source.mp4"))
if err != nil {
t.Fatalf("read hls output: %v", err)
}
if string(data) != "hls-video-bytes" {
t.Fatalf("hls output = %q", string(data))
}
}
func TestScriptCrawlerHelperProcess(t *testing.T) {
if os.Getenv("GO_WANT_SCRIPTCRAWLER_HELPER") != "1" {
return
}
args := os.Args
jobPath := ""
for i := 0; i < len(args)-1; i++ {
if args[i] == "--job" {
jobPath = args[i+1]
break
}
}
if jobPath == "" {
fmt.Fprintln(os.Stderr, "missing --job")
os.Exit(2)
}
data, err := os.ReadFile(jobPath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
var job Job
if err := json.Unmarshal(data, &job); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if os.Getenv("GO_WANT_SCRIPTCRAWLER_ASSERT_ABS") == "1" {
if !filepath.IsAbs(jobPath) || !filepath.IsAbs(job.SeenSourceIDsFile) || !filepath.IsAbs(job.OutputDir) {
fmt.Fprintf(os.Stderr, "expected absolute paths, got job=%q seen=%q output=%q\n", jobPath, job.SeenSourceIDsFile, job.OutputDir)
os.Exit(2)
}
}
if os.Getenv("GO_WANT_SCRIPTCRAWLER_SIMPLE") == "1" {
event := map[string]any{
"title": "Simple Protocol Video",
"media_url": os.Getenv("GO_SCRIPTCRAWLER_MEDIA_URL"),
}
_ = json.NewEncoder(os.Stdout).Encode(event)
os.Exit(0)
}
if os.Getenv("GO_WANT_SCRIPTCRAWLER_HLS") == "1" {
event := Event{
Type: "item",
Item: Item{
SourceID: "hls-source",
Title: "HLS Protocol Video",
Author: "helper",
Media: MediaRef{
URL: "https://media.example.test/video.m3u8",
Headers: map[string]string{
"Referer": "https://example.test/",
},
},
},
}
_ = json.NewEncoder(os.Stdout).Encode(event)
os.Exit(0)
}
if os.Getenv("GO_WANT_SCRIPTCRAWLER_DUP_UNIQUE") == "1" {
duplicateFile := filepath.Join(job.OutputDir, "duplicate.mp4")
if err := os.WriteFile(duplicateFile, []byte(scriptCrawlerDuplicateBytes), 0o644); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
uniqueFile := filepath.Join(job.OutputDir, "unique.mp4")
if err := os.WriteFile(uniqueFile, []byte(scriptCrawlerUniqueBytes), 0o644); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
for _, event := range []Event{
{
Type: "item",
Item: Item{
SourceID: "dup-source",
Title: "Duplicate Candidate",
Author: "helper",
Media: MediaRef{LocalFile: duplicateFile},
},
},
{
Type: "item",
Item: Item{
SourceID: "unique-source",
Title: "Unique Candidate",
Author: "helper",
Media: MediaRef{LocalFile: uniqueFile},
},
},
} {
_ = json.NewEncoder(os.Stdout).Encode(event)
}
os.Exit(0)
}
localFile := filepath.Join(job.OutputDir, "helper.mp4")
if err := os.WriteFile(localFile, []byte("helper-video"), 0o644); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
event := Event{
Type: "item",
Item: Item{
SourceID: "abc-123",
Title: "Imported From Helper",
Author: "helper",
Media: MediaRef{LocalFile: localFile},
},
}
_ = json.NewEncoder(os.Stdout).Encode(event)
os.Exit(0)
}
func hasString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
@@ -1,213 +0,0 @@
// Package scriptcrawler provides a generic local drive for script-based
// crawlers. A crawler script discovers videos; the Go runner downloads them
// into this drive and the existing preview/fingerprint workers consume them
// through the normal drives.Drive interface.
package scriptcrawler
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/video-site/backend/internal/drives"
)
const Kind = "scriptcrawler"
type Config struct {
ID string
RootDir string
}
type Driver struct {
id string
rootDir string
}
func New(c Config) *Driver {
return &Driver{id: c.ID, rootDir: c.RootDir}
}
func (d *Driver) Kind() string { return Kind }
func (d *Driver) ID() string { return d.id }
func (d *Driver) RootID() string { return "/" }
func (d *Driver) Init(context.Context) error {
if strings.TrimSpace(d.id) == "" {
return errors.New("scriptcrawler: empty drive id")
}
if strings.TrimSpace(d.rootDir) == "" {
return errors.New("scriptcrawler: empty root dir")
}
for _, sub := range []string{"videos", "thumbs", "output", ".crawl"} {
if err := os.MkdirAll(filepath.Join(d.rootDir, sub), 0o755); err != nil {
return err
}
}
return nil
}
func (d *Driver) RootDir() string { return d.rootDir }
func (d *Driver) VideosDir() string { return filepath.Join(d.rootDir, "videos") }
func (d *Driver) ThumbsDir() string { return filepath.Join(d.rootDir, "thumbs") }
func (d *Driver) OutputDir() string { return filepath.Join(d.rootDir, "output") }
func (d *Driver) CrawlDir() string { return filepath.Join(d.rootDir, ".crawl") }
func (d *Driver) VideoPath(fileID string) (string, error) {
return safeJoin(d.VideosDir(), fileID)
}
func (d *Driver) ThumbPath(fileID string) (string, error) {
return safeJoin(d.ThumbsDir(), fileID)
}
func (d *Driver) OutputPath(fileName string) (string, error) {
return safeJoin(d.OutputDir(), fileName)
}
func (d *Driver) List(context.Context, string) ([]drives.Entry, error) {
entries, err := os.ReadDir(d.VideosDir())
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
out := make([]drives.Entry, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, drives.Entry{
ID: e.Name(),
Name: e.Name(),
Size: info.Size(),
IsDir: false,
ModTime: info.ModTime(),
})
}
return out, nil
}
func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error) {
path, err := d.VideoPath(fileID)
if err != nil {
return nil, err
}
info, err := os.Stat(path)
if err != nil {
return nil, err
}
return &drives.Entry{
ID: fileID,
Name: fileID,
Size: info.Size(),
IsDir: info.IsDir(),
ModTime: info.ModTime(),
}, nil
}
func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) {
path, err := d.VideoPath(fileID)
if err != nil {
return nil, err
}
info, err := os.Stat(path)
if err != nil {
return nil, err
}
if info.IsDir() || info.Size() == 0 {
return nil, os.ErrNotExist
}
return &drives.StreamLink{
URL: path,
Expires: time.Now().Add(24 * time.Hour),
}, nil
}
func (d *Driver) Upload(context.Context, string, string, io.Reader, int64) (string, error) {
return "", drives.ErrNotSupported
}
func (d *Driver) EnsureDir(context.Context, string) (string, error) {
return "", drives.ErrNotSupported
}
func (d *Driver) Remove(ctx context.Context, fileID string) error {
if err := ctx.Err(); err != nil {
return err
}
videoPath, err := d.VideoPath(fileID)
if err != nil {
return err
}
info, err := os.Stat(videoPath)
if err != nil {
if os.IsNotExist(err) {
removeThumbCandidates(d.ThumbPath, strings.TrimSuffix(fileID, filepath.Ext(fileID)))
return nil
}
return err
}
if info.IsDir() {
return errors.New("scriptcrawler: refusing to remove directory")
}
if err := os.Remove(videoPath); err != nil && !os.IsNotExist(err) {
return err
}
removeThumbCandidates(d.ThumbPath, strings.TrimSuffix(fileID, filepath.Ext(fileID)))
return nil
}
func removeThumbCandidates(pathFor func(string) (string, error), stem string) {
stem = strings.TrimSpace(stem)
if stem == "" {
return
}
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp"} {
path, err := pathFor(stem + ext)
if err != nil {
continue
}
_ = os.Remove(path)
}
}
func safeJoin(root, fileID string) (string, error) {
id := strings.TrimSpace(fileID)
if id == "" || filepath.Base(id) != id {
return "", errors.New("scriptcrawler: invalid file id")
}
if strings.TrimSpace(root) == "" {
return "", errors.New("scriptcrawler: empty root")
}
rootAbs, err := filepath.Abs(root)
if err != nil {
return "", err
}
pathAbs, err := filepath.Abs(filepath.Join(rootAbs, id))
if err != nil {
return "", err
}
if pathAbs != rootAbs && !strings.HasPrefix(pathAbs, rootAbs+string(os.PathSeparator)) {
return "", errors.New("scriptcrawler: file id escapes root")
}
return pathAbs, nil
}
var _ drives.Drive = (*Driver)(nil)
var _ drives.Remover = (*Driver)(nil)
@@ -1,386 +0,0 @@
package scriptcrawler
import (
"bufio"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
)
// DryRun 在不入库的前提下试跑一个爬虫脚本:临时目录里生成 job.json,
// 启动脚本进程,拿到第一条(或前 MaxItems 条)item 事件后立即停止,
// 再对视频直链做一次小范围探测,验证脚本"能不能爬取到视频"。
// 用于后台导入脚本后的"测试脚本"按钮。
const (
defaultDryRunTimeout = 2 * time.Minute
dryRunLogTailLines = 60
dryRunMediaProbeLimit = 20 * time.Second
dryRunStopGrace = 100 * time.Millisecond
)
type DryRunConfig struct {
PythonPath string
ScriptPath string
ProxyURL string
ConfigJSON string
// MaxItems 收到多少条 item 后停止脚本,默认 1。
MaxItems int
// Timeout 整个试跑的硬上限,默认 2 分钟。
Timeout time.Duration
// SkipMediaProbe 跳过视频直链可达性探测(单测注入用)。
SkipMediaProbe bool
HTTPClient *http.Client
}
type DryRunItem struct {
Title string `json:"title"`
SourceID string `json:"sourceId,omitempty"`
MediaURL string `json:"mediaUrl,omitempty"`
MediaLocalFile string `json:"mediaLocalFile,omitempty"`
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
DetailURL string `json:"detailUrl,omitempty"`
}
type DryRunMediaCheck struct {
OK bool `json:"ok"`
Status int `json:"status,omitempty"`
ContentType string `json:"contentType,omitempty"`
ContentLength int64 `json:"contentLengthBytes,omitempty"`
Error string `json:"error,omitempty"`
}
type DryRunResult struct {
OK bool `json:"ok"`
Items []DryRunItem `json:"items"`
MediaCheck *DryRunMediaCheck `json:"mediaCheck,omitempty"`
Error string `json:"error,omitempty"`
Log []string `json:"log,omitempty"`
DurationMs int64 `json:"durationMs"`
}
func DryRun(ctx context.Context, cfg DryRunConfig) *DryRunResult {
started := time.Now()
result := &DryRunResult{Items: []DryRunItem{}}
defer func() { result.DurationMs = time.Since(started).Milliseconds() }()
scriptPath := strings.TrimSpace(cfg.ScriptPath)
if scriptPath == "" {
result.Error = "脚本路径为空,请先导入脚本"
return result
}
if _, err := os.Stat(scriptPath); err != nil {
result.Error = fmt.Sprintf("脚本不存在: %v", err)
return result
}
pythonPath := strings.TrimSpace(cfg.PythonPath)
if pythonPath == "" {
pythonPath = "python3"
}
maxItems := cfg.MaxItems
if maxItems <= 0 {
maxItems = 1
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = defaultDryRunTimeout
}
tmpDir, err := os.MkdirTemp("", "crawler-dryrun-")
if err != nil {
result.Error = fmt.Sprintf("创建临时目录失败: %v", err)
return result
}
defer os.RemoveAll(tmpDir)
outputDir := filepath.Join(tmpDir, "output")
if err := os.MkdirAll(outputDir, 0o755); err != nil {
result.Error = fmt.Sprintf("创建输出目录失败: %v", err)
return result
}
seenPath := filepath.Join(tmpDir, "seen.txt")
if err := os.WriteFile(seenPath, nil, 0o644); err != nil {
result.Error = fmt.Sprintf("写入 seen 文件失败: %v", err)
return result
}
configJSON := json.RawMessage([]byte("{}"))
if raw := strings.TrimSpace(cfg.ConfigJSON); raw != "" {
if !json.Valid([]byte(raw)) {
result.Error = "自定义配置必须是合法 JSON"
return result
}
configJSON = json.RawMessage(raw)
}
job := Job{
Protocol: "crawler.v1",
Mode: "crawl",
RunID: "dryrun-" + started.UTC().Format("20060102T150405Z"),
CrawlerID: "dryrun",
TargetNew: maxItems,
SeenSourceIDsFile: seenPath,
OutputDir: outputDir,
Config: configJSON,
Network: JobNetwork{ProxyURL: strings.TrimSpace(cfg.ProxyURL)},
}
jobPath := filepath.Join(tmpDir, "job.json")
jobData, err := json.MarshalIndent(job, "", " ")
if err != nil {
result.Error = fmt.Sprintf("生成 job 文件失败: %v", err)
return result
}
if err := os.WriteFile(jobPath, jobData, 0o600); err != nil {
result.Error = fmt.Sprintf("写入 job 文件失败: %v", err)
return result
}
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(runCtx, pythonPath, scriptPath, "--job", jobPath)
cmd.Dir = filepath.Dir(scriptPath)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
return killDryRunProcess(cmd)
}
// 超时或提前 kill 后,脚本派生的子进程可能仍持有 stdout/stderr 管道;
// WaitDelay 强制在宽限期后关闭管道,避免读取端永久阻塞。
cmd.WaitDelay = 3 * time.Second
if proxyURL := strings.TrimSpace(cfg.ProxyURL); proxyURL != "" {
cmd.Env = append(os.Environ(),
"HTTP_PROXY="+proxyURL,
"HTTPS_PROXY="+proxyURL,
"http_proxy="+proxyURL,
"https_proxy="+proxyURL,
"NO_PROXY=",
"no_proxy=",
)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
result.Error = fmt.Sprintf("启动脚本失败: %v", err)
return result
}
stderr, err := cmd.StderrPipe()
if err != nil {
_ = stdout.Close()
result.Error = fmt.Sprintf("启动脚本失败: %v", err)
return result
}
if err := cmd.Start(); err != nil {
_ = stdout.Close()
_ = stderr.Close()
result.Error = fmt.Sprintf("启动脚本失败: %v", err)
return result
}
// stderr 是脚本日志,保留尾部若干行用于排错回显。
var logMu sync.Mutex
logTail := make([]string, 0, dryRunLogTailLines)
stderrDone := make(chan struct{})
go func() {
defer close(stderrDone)
scanner := bufio.NewScanner(stderr)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
logMu.Lock()
if len(logTail) >= dryRunLogTailLines {
logTail = logTail[1:]
}
logTail = append(logTail, line)
logMu.Unlock()
}
}()
items := []DryRunItem{}
var firstMediaHeaders map[string]string
parseFailures := 0
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
for scanner.Scan() {
if runCtx.Err() != nil {
break
}
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var event Event
if err := json.Unmarshal([]byte(line), &event); err != nil {
parseFailures++
continue
}
eventType := strings.ToLower(strings.TrimSpace(event.Type))
item := event.normalizedItem()
if eventType == "" && item.hasPayload() {
eventType = "item"
}
if eventType != "item" {
continue
}
normalized, _, err := normalizeItemForImport(item)
if err != nil {
result.Error = fmt.Sprintf("item 字段不完整: %v", err)
continue
}
mediaURL := strings.TrimSpace(normalized.Media.URL)
if len(items) == 0 {
firstMediaHeaders = normalized.Media.Headers
}
items = append(items, DryRunItem{
Title: strings.TrimSpace(normalized.Title),
SourceID: strings.TrimSpace(item.SourceID),
MediaURL: mediaURL,
MediaLocalFile: strings.TrimSpace(normalized.Media.LocalFile),
ThumbnailURL: strings.TrimSpace(normalized.Thumbnail.URL),
DetailURL: strings.TrimSpace(normalized.DetailURL),
})
if len(items) >= maxItems {
break
}
}
// 拿够了就停掉脚本,避免它继续翻页。给已经自然结束的脚本一个很短
// 的宽限期,让 stderr 日志先被管道读完,避免 dry-run 回显偶发为空。
waitDone := make(chan struct{})
go func() {
_ = cmd.Wait()
close(waitDone)
}()
select {
case <-waitDone:
case <-time.After(dryRunStopGrace):
_ = killDryRunProcess(cmd)
<-waitDone
}
<-stderrDone
logMu.Lock()
result.Log = append([]string{}, logTail...)
logMu.Unlock()
result.Items = items
if len(items) == 0 {
if result.Error == "" {
switch {
case runCtx.Err() != nil && ctx.Err() == nil:
result.Error = fmt.Sprintf("测试超时(%s),脚本没有输出任何视频", timeout)
case parseFailures > 0:
result.Error = "脚本 stdout 不是合法的 crawler.v1 JSON Lines(日志应输出到 stderr"
default:
result.Error = "脚本退出但没有输出任何视频"
}
}
return result
}
result.Error = ""
first := items[0]
switch {
case cfg.SkipMediaProbe:
result.OK = true
case first.MediaLocalFile != "":
// 脚本自己下载到 output_dir 的模式:试跑用的是临时目录,
// 文件已随目录清理,能输出合法 local_file 即视为通过。
result.OK = true
default:
check := probeMediaURL(ctx, cfg, first, firstMediaHeaders)
result.MediaCheck = check
result.OK = check.OK
}
return result
}
func killDryRunProcess(cmd *exec.Cmd) error {
if cmd == nil || cmd.Process == nil {
return nil
}
if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil {
if err == syscall.ESRCH {
return nil
}
return cmd.Process.Kill()
}
return nil
}
// probeMediaURL 对视频直链发一个 Range: bytes=0-0 的小请求,
// 验证直链可达(带上脚本给的防盗链 headers 和代理)。
func probeMediaURL(ctx context.Context, cfg DryRunConfig, item DryRunItem, mediaHeaders map[string]string) *DryRunMediaCheck {
check := &DryRunMediaCheck{}
if item.MediaURL == "" {
check.Error = "item 没有视频直链"
return check
}
client := cfg.HTTPClient
if client == nil {
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
ResponseHeaderTimeout: dryRunMediaProbeLimit,
}
if err := configureExplicitProxy(transport, cfg.ProxyURL); err != nil {
check.Error = fmt.Sprintf("代理配置无效: %v", err)
return check
}
client = &http.Client{Transport: transport}
}
probeCtx, cancel := context.WithTimeout(ctx, dryRunMediaProbeLimit)
defer cancel()
req, err := http.NewRequestWithContext(probeCtx, http.MethodGet, item.MediaURL, nil)
if err != nil {
check.Error = fmt.Sprintf("视频直链无效: %v", err)
return check
}
req.Header.Set("User-Agent", defaultUserAgent)
req.Header.Set("Range", "bytes=0-0")
if item.DetailURL != "" {
req.Header.Set("Referer", item.DetailURL)
}
for k, v := range mediaHeaders {
k = strings.TrimSpace(k)
if k == "" {
continue
}
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
check.Error = fmt.Sprintf("视频直链请求失败: %v", err)
return check
}
defer resp.Body.Close()
check.Status = resp.StatusCode
check.ContentType = resp.Header.Get("Content-Type")
if cr := resp.Header.Get("Content-Range"); cr != "" {
// Content-Range: bytes 0-0/12345 → 取总大小
if idx := strings.LastIndex(cr, "/"); idx >= 0 {
var total int64
if _, err := fmt.Sscanf(cr[idx+1:], "%d", &total); err == nil {
check.ContentLength = total
}
}
}
if check.ContentLength == 0 && resp.StatusCode == http.StatusOK {
check.ContentLength = resp.ContentLength
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
check.Error = fmt.Sprintf("视频直链返回 HTTP %d", resp.StatusCode)
return check
}
check.OK = true
return check
}
@@ -1,153 +0,0 @@
package scriptcrawler
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func writeDryRunScript(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "crawler.sh")
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body), 0o755); err != nil {
t.Fatalf("write script: %v", err)
}
return path
}
func TestDryRunCollectsFirstItem(t *testing.T) {
script := writeDryRunScript(t, `
echo '[log] fetching list page' >&2
echo '{"type":"item","item":{"title":"Test Video","media_url":"https://cdn.example.test/v.mp4","source_id":"123","thumbnail_url":"https://cdn.example.test/t.jpg"}}'
echo '{"type":"done","stats":{"emitted":1}}'
`)
result := DryRun(context.Background(), DryRunConfig{
PythonPath: "/bin/sh",
ScriptPath: script,
SkipMediaProbe: true,
})
if !result.OK {
t.Fatalf("ok = false, error = %q, log = %v", result.Error, result.Log)
}
if len(result.Items) != 1 {
t.Fatalf("items = %d, want 1", len(result.Items))
}
item := result.Items[0]
if item.Title != "Test Video" || item.MediaURL != "https://cdn.example.test/v.mp4" || item.SourceID != "123" {
t.Fatalf("item = %+v", item)
}
if len(result.Log) == 0 || !strings.Contains(result.Log[0], "fetching list page") {
t.Fatalf("log tail = %v, want stderr captured", result.Log)
}
}
func TestDryRunProbesMediaURL(t *testing.T) {
var gotRange, gotReferer string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotRange = r.Header.Get("Range")
gotReferer = r.Header.Get("Referer")
w.Header().Set("Content-Type", "video/mp4")
w.Header().Set("Content-Range", "bytes 0-0/4096")
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write([]byte("x"))
}))
t.Cleanup(srv.Close)
script := writeDryRunScript(t, fmt.Sprintf(
`echo '{"type":"item","title":"Probe Video","media_url":"%s/v.mp4","detail_url":"https://example.test/view"}'`,
srv.URL,
))
result := DryRun(context.Background(), DryRunConfig{
PythonPath: "/bin/sh",
ScriptPath: script,
})
if !result.OK {
t.Fatalf("ok = false, error = %q, mediaCheck = %+v", result.Error, result.MediaCheck)
}
if result.MediaCheck == nil || !result.MediaCheck.OK {
t.Fatalf("mediaCheck = %+v, want ok", result.MediaCheck)
}
if result.MediaCheck.Status != http.StatusPartialContent || result.MediaCheck.ContentLength != 4096 {
t.Fatalf("mediaCheck = %+v, want 206 with total 4096", result.MediaCheck)
}
if gotRange != "bytes=0-0" || gotReferer != "https://example.test/view" {
t.Fatalf("probe headers range=%q referer=%q", gotRange, gotReferer)
}
}
func TestDryRunReportsBrokenMediaURL(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "forbidden", http.StatusForbidden)
}))
t.Cleanup(srv.Close)
script := writeDryRunScript(t, fmt.Sprintf(
`echo '{"type":"item","title":"Dead Link","media_url":"%s/v.mp4"}'`,
srv.URL,
))
result := DryRun(context.Background(), DryRunConfig{
PythonPath: "/bin/sh",
ScriptPath: script,
})
if result.OK {
t.Fatal("ok = true, want false for HTTP 403 media url")
}
if result.MediaCheck == nil || result.MediaCheck.OK || result.MediaCheck.Status != http.StatusForbidden {
t.Fatalf("mediaCheck = %+v, want failed 403", result.MediaCheck)
}
if len(result.Items) != 1 {
t.Fatalf("items = %d, want item still returned for debugging", len(result.Items))
}
}
func TestDryRunRejectsNonJSONStdout(t *testing.T) {
script := writeDryRunScript(t, `echo 'plain text progress output'`)
result := DryRun(context.Background(), DryRunConfig{
PythonPath: "/bin/sh",
ScriptPath: script,
SkipMediaProbe: true,
})
if result.OK {
t.Fatal("ok = true, want false for non-JSON stdout")
}
if !strings.Contains(result.Error, "JSON Lines") {
t.Fatalf("error = %q, want JSON Lines hint", result.Error)
}
}
func TestDryRunTimesOut(t *testing.T) {
script := writeDryRunScript(t, `sleep 30`)
start := time.Now()
result := DryRun(context.Background(), DryRunConfig{
PythonPath: "/bin/sh",
ScriptPath: script,
Timeout: 2 * time.Second,
SkipMediaProbe: true,
})
if result.OK {
t.Fatal("ok = true, want false on timeout")
}
if !strings.Contains(result.Error, "超时") {
t.Fatalf("error = %q, want timeout message", result.Error)
}
if elapsed := time.Since(start); elapsed > 10*time.Second {
t.Fatalf("dry run took %s, script was not killed", elapsed)
}
}
func TestDryRunMissingScript(t *testing.T) {
result := DryRun(context.Background(), DryRunConfig{
PythonPath: "/bin/sh",
ScriptPath: filepath.Join(t.TempDir(), "missing.py"),
})
if result.OK || result.Error == "" {
t.Fatalf("result = %+v, want error for missing script", result)
}
}
@@ -1,117 +0,0 @@
package scriptcrawler
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
const maxCrawlerNameRunes = 80
type Metadata struct {
Name string `json:"name"`
}
func ReadMetadata(scriptPath string) (Metadata, error) {
scriptPath = strings.TrimSpace(scriptPath)
if scriptPath == "" {
return Metadata{}, errors.New("脚本路径为空")
}
if filepath.Ext(scriptPath) != ".py" {
return Metadata{}, errors.New("目前只支持 .py 爬虫脚本")
}
data, err := os.ReadFile(scriptPath)
if err != nil {
return Metadata{}, fmt.Errorf("读取脚本失败: %w", err)
}
return ExtractMetadata(string(data))
}
func ExtractMetadata(source string) (Metadata, error) {
for _, line := range strings.Split(source, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
if !strings.HasPrefix(trimmed, "CRAWLER_NAME") {
continue
}
left, right, ok := strings.Cut(trimmed, "=")
if !ok || strings.TrimSpace(left) != "CRAWLER_NAME" {
continue
}
name, ok := parsePythonStringLiteral(right)
if !ok {
return Metadata{}, errors.New(`CRAWLER_NAME 必须是字符串字面量,例如 CRAWLER_NAME = "示例爬虫"`)
}
name = strings.TrimSpace(name)
if name == "" {
return Metadata{}, errors.New("CRAWLER_NAME 不能为空")
}
if len([]rune(name)) > maxCrawlerNameRunes {
return Metadata{}, fmt.Errorf("CRAWLER_NAME 不能超过 %d 个字符", maxCrawlerNameRunes)
}
return Metadata{Name: name}, nil
}
return Metadata{}, errors.New(`脚本必须声明 CRAWLER_NAME,例如 CRAWLER_NAME = "示例爬虫"`)
}
func parsePythonStringLiteral(raw string) (string, bool) {
s := strings.TrimSpace(raw)
if s == "" {
return "", false
}
rawString := false
for len(s) > 0 {
switch s[0] {
case 'r', 'R':
rawString = true
s = strings.TrimSpace(s[1:])
case 'u', 'U', 'b', 'B':
s = strings.TrimSpace(s[1:])
default:
goto parseQuote
}
}
parseQuote:
if len(s) < 2 || (s[0] != '"' && s[0] != '\'') {
return "", false
}
quote := s[0]
var b strings.Builder
escaped := false
for i := 1; i < len(s); i++ {
ch := s[i]
if escaped {
switch {
case rawString:
b.WriteByte('\\')
b.WriteByte(ch)
case ch == 'n':
b.WriteByte('\n')
case ch == 'r':
b.WriteByte('\r')
case ch == 't':
b.WriteByte('\t')
case ch == '\\' || ch == quote || ch == '"' || ch == '\'':
b.WriteByte(ch)
default:
b.WriteByte(ch)
}
escaped = false
continue
}
if ch == '\\' {
escaped = true
continue
}
if ch == quote {
return b.String(), true
}
b.WriteByte(ch)
}
return "", false
}
@@ -1,39 +0,0 @@
package scriptcrawler
import (
"strings"
"testing"
)
func TestExtractMetadataReadsCrawlerName(t *testing.T) {
meta, err := ExtractMetadata(`
# comment
CRAWLER_NAME = "示例爬虫"
`)
if err != nil {
t.Fatalf("extract metadata: %v", err)
}
if meta.Name != "示例爬虫" {
t.Fatalf("name = %q", meta.Name)
}
}
func TestExtractMetadataRejectsMissingCrawlerName(t *testing.T) {
_, err := ExtractMetadata(`print("hello")`)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "CRAWLER_NAME") {
t.Fatalf("error = %v, want CRAWLER_NAME guidance", err)
}
}
func TestExtractMetadataRejectsEmptyCrawlerName(t *testing.T) {
_, err := ExtractMetadata(`CRAWLER_NAME = " "`)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "不能为空") {
t.Fatalf("error = %v, want empty-name error", err)
}
}
+8 -72
View File
@@ -21,7 +21,6 @@ import (
"time"
"github.com/video-site/backend/internal/catalog"
"github.com/video-site/backend/internal/mediaasset"
"golang.org/x/net/proxy"
)
@@ -62,14 +61,8 @@ type CrawlerConfig struct {
// DownloadTimeout 限制单条视频/封面下载的耗时。
DownloadTimeout time.Duration
// OnNewVideo 是新视频成功入库后的回调,用于触发预览视频 worker。
// OnNewVideo 是新视频成功入库后的回调,用于触发 teaser worker。
OnNewVideo func(v *catalog.Video)
// OnProgress 在抓取统计变化时触发,用于后台管理页展示实时进度。
OnProgress func(progress CrawlProgress)
// OnCheckedVideo 在 Python 爬虫开始检查一个列表页视频时触发。
OnCheckedVideo func()
// OnExtractedVideo 在 Python 爬虫提取到一个新视频直链时触发。
OnExtractedVideo func()
}
// Crawler 把 Python 爬虫产出包装成 catalog 入库流程。
@@ -225,16 +218,6 @@ type CrawlResult struct {
SeenFile string
}
// CrawlProgress 是 RunOnce 过程中可安全对外发布的实时计数。
type CrawlProgress struct {
TargetNew int
TotalEntries int
NewVideos int
Skipped int
Failed int
SeenSnapshot int
}
// spiderVideoEntry 对应 spider_91porn.py 输出 JSON 中的单条视频。
type spiderVideoEntry struct {
Title string `json:"title"`
@@ -252,7 +235,7 @@ type spiderVideoEntry struct {
// 3. Go 端 bufio.Scanner 按行读:每行立即下载视频和封面、入库。
// 这样 "Python 翻页找下一个" 与 "Go 下载当前一个" 在时间上重叠,缩短整轮耗时;
// 更重要的是不会让前几个下载耽误后面签名链接 e= 过期。
// 4. 全部消费完 + 子进程退出 → 返回 CrawlResult。预览视频不在此处入队,
// 4. 全部消费完 + 子进程退出 → 返回 CrawlResult。teaser 不在此处入队,
// 由调用方 (App.runSpider91Crawl) 在 RunOnce 后统一调 enqueueDriveGeneration。
//
// targetNew <= 0 会被规范化成 spider91DefaultTargetNew15)。
@@ -282,20 +265,6 @@ func (c *Crawler) RunOnce(ctx context.Context, targetNew int) (*CrawlResult, err
result := &CrawlResult{TargetNew: targetNew, StartedAt: time.Now()}
defer func() { result.FinishedAt = time.Now() }()
emitProgress := func() {
if c.cfg.OnProgress == nil {
return
}
c.cfg.OnProgress(CrawlProgress{
TargetNew: result.TargetNew,
TotalEntries: result.TotalEntries,
NewVideos: result.NewVideos,
Skipped: result.Skipped,
Failed: result.Failed,
SeenSnapshot: result.SeenSnapshot,
})
}
emitProgress()
// 1. 准备 .crawl/ 目录 + 已知源视频 ID 列表
//
@@ -321,7 +290,6 @@ func (c *Crawler) RunOnce(ctx context.Context, targetNew int) (*CrawlResult, err
return result, fmt.Errorf("spider91 crawler: build seen list: %w", err)
}
result.SeenSnapshot = seenCount
emitProgress()
// 2-3. 启动 Python 爬虫(流式 stdout 协议),并边读边处理。
//
@@ -352,11 +320,9 @@ func (c *Crawler) RunOnce(ctx context.Context, targetNew int) (*CrawlResult, err
continue
}
result.TotalEntries++
emitProgress()
sourceID := sourceIDForItem(item)
if sourceID == "" || strings.TrimSpace(item.VideoURL) == "" {
result.Failed++
emitProgress()
continue
}
if result.NewVideos >= targetNew {
@@ -364,31 +330,16 @@ func (c *Crawler) RunOnce(ctx context.Context, targetNew int) (*CrawlResult, err
break
}
videoID := buildVideoID(c.cfg.Driver.ID(), sourceID)
deleted, err := c.cfg.Catalog.IsVideoDeleted(ctx, videoID)
if err != nil {
log.Printf("[spider91] drive=%s viewkey=%s source_id=%s check deleted: %v", c.cfg.Driver.ID(), item.Viewkey, sourceID, err)
result.Failed++
emitProgress()
continue
}
if deleted {
result.Skipped++
emitProgress()
continue
}
if existing, _ := c.cfg.Catalog.GetVideo(ctx, videoID); existing != nil {
result.Skipped++
emitProgress()
continue
}
if perr := c.processOne(ctx, videoID, item); perr != nil {
log.Printf("[spider91] drive=%s viewkey=%s source_id=%s failed: %v", c.cfg.Driver.ID(), item.Viewkey, sourceID, perr)
result.Failed++
emitProgress()
continue
}
result.NewVideos++
emitProgress()
}
if scerr := scanner.Err(); scerr != nil {
log.Printf("[spider91] drive=%s stdout scan: %v", c.cfg.Driver.ID(), scerr)
@@ -496,12 +447,12 @@ func (c *Crawler) startSpiderTargetNew(ctx context.Context, targetNew int, seenP
return nil, nil, fmt.Errorf("start: %w", err)
}
// stderr 转发到 backend log。子进程退出时 reader 自动 EOFgoroutine 自然结束。
go forwardSpiderLog(c.cfg.Driver.ID(), stderr, c.cfg.OnCheckedVideo, c.cfg.OnExtractedVideo)
go forwardSpiderLog(c.cfg.Driver.ID(), stderr)
return cmd, stdout, nil
}
// forwardSpiderLog 把 Python stderr 逐行转发到 backend log,便于调试。
func forwardSpiderLog(driveID string, r io.Reader, onCheckedVideo func(), onExtractedVideo func()) {
func forwardSpiderLog(driveID string, r io.Reader) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
@@ -510,23 +461,9 @@ func forwardSpiderLog(driveID string, r io.Reader, onCheckedVideo func(), onExtr
continue
}
log.Printf("[spider91:py] drive=%s %s", driveID, line)
if onCheckedVideo != nil && isSpider91CheckedVideoLogLine(line) {
onCheckedVideo()
}
if onExtractedVideo != nil && isSpider91ExtractedVideoLogLine(line) {
onExtractedVideo()
}
}
}
func isSpider91CheckedVideoLogLine(line string) bool {
return checkedVideoLogRE.MatchString(line)
}
func isSpider91ExtractedVideoLogLine(line string) bool {
return strings.Contains(line, "[OK] 成功提取视频直链")
}
// processOne 处理单个 91 源视频:下载视频 + 封面 + 复制封面 + 入库。
// 任一步失败会清理已写入的临时文件,不留半成品。
func (c *Crawler) processOne(ctx context.Context, videoID string, item spiderVideoEntry) error {
@@ -588,7 +525,7 @@ func (c *Crawler) processOne(ctx context.Context, videoID string, item spiderVid
log.Printf("[spider91] drive=%s mkdir common thumbs: %v", c.cfg.Driver.ID(), err)
thumbReady = false
} else {
dst := mediaasset.ThumbnailPathInDir(c.cfg.CommonThumbDir, videoID)
dst := filepath.Join(c.cfg.CommonThumbDir, videoID+".jpg")
if err := copyFileAtomic(thumbPath, dst); err != nil {
log.Printf("[spider91] drive=%s viewkey=%s source_id=%s copy thumb to common dir: %v", c.cfg.Driver.ID(), viewkey, sourceID, err)
thumbReady = false
@@ -899,10 +836,9 @@ func spider91CookieHeader(cookies []*http.Cookie) string {
}
var (
checkedVideoLogRE = regexp.MustCompile(`处理视频\s+\d+/\d+:`)
strencode2RE = regexp.MustCompile(`strencode2\(["']([^"']+)["']\)`)
srcAttrRE = regexp.MustCompile(`src=['"]([^'"]+)['"]`)
mp4URLRE = regexp.MustCompile(`https?://[^\s"'<>]+\.mp4[^\s"'<>]*`)
strencode2RE = regexp.MustCompile(`strencode2\(["']([^"']+)["']\)`)
srcAttrRE = regexp.MustCompile(`src=['"]([^'"]+)['"]`)
mp4URLRE = regexp.MustCompile(`https?://[^\s"'<>]+\.mp4[^\s"'<>]*`)
)
func parseSpider91VideoURL(html string) string {
@@ -707,18 +707,6 @@ func TestSpider91CookieHeader(t *testing.T) {
}
}
func TestSpider91ProgressLogLineClassifiers(t *testing.T) {
if !isSpider91CheckedVideoLogLine("[2026-06-08 16:49:17] 处理视频 3/24: 标题") {
t.Fatal("checked video log line was not recognized")
}
if isSpider91CheckedVideoLogLine("[2026-06-08 16:49:17] [页 2] 发现 24 个视频") {
t.Fatal("page summary log line should not count as checked video")
}
if !isSpider91ExtractedVideoLogLine("[2026-06-08 16:49:39] [OK] 成功提取视频直链") {
t.Fatal("extracted video log line was not recognized")
}
}
func spider91DetailHTML(videoURL string) string {
fragment := `<video><source src="` + videoURL + `" type="video/mp4"></video>`
return `document.write(strencode2("` + url.PathEscape(fragment) + `"));`
+1 -42
View File
@@ -138,7 +138,7 @@ func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error)
// StreamURL 返回本地视频文件路径,给 ffmpeg / 上层服务使用。
// 注意:proxy.serve 不能直接处理本地路径,回放要走 api.handleSpider91Video。
// 预览视频/封面 worker 通过 localPreviewLink 兜底走本地文件,刚好兼容 path 形式的 URL。
// teaser/封面 worker 通过 localPreviewLink 兜底走本地文件,刚好兼容 path 形式的 URL。
func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) {
path, err := d.VideoPath(fileID)
if err != nil {
@@ -167,46 +167,6 @@ func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, er
return "", drives.ErrNotSupported
}
func (d *Driver) Remove(ctx context.Context, fileID string) error {
if err := ctx.Err(); err != nil {
return err
}
videoPath, err := d.VideoPath(fileID)
if err != nil {
return err
}
info, err := os.Stat(videoPath)
if err != nil {
if os.IsNotExist(err) {
removeThumbCandidates(d.ThumbPath, strings.TrimSuffix(fileID, filepath.Ext(fileID)))
return nil
}
return err
}
if info.IsDir() {
return errors.New("spider91: refusing to remove directory")
}
if err := os.Remove(videoPath); err != nil && !os.IsNotExist(err) {
return err
}
removeThumbCandidates(d.ThumbPath, strings.TrimSuffix(fileID, filepath.Ext(fileID)))
return nil
}
func removeThumbCandidates(pathFor func(string) (string, error), stem string) {
stem = strings.TrimSpace(stem)
if stem == "" {
return
}
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp"} {
path, err := pathFor(stem + ext)
if err != nil {
continue
}
_ = os.Remove(path)
}
}
// safeJoin 把 fileID 拼到 root 下,保证最终路径不会逃出 root。
// fileID 必须是单纯的文件名(不含 / 或 .. 等组件)。
func safeJoin(root, fileID string) (string, error) {
@@ -232,4 +192,3 @@ func safeJoin(root, fileID string) (string, error) {
}
var _ drives.Drive = (*Driver)(nil)
var _ drives.Remover = (*Driver)(nil)
+10 -352
View File
@@ -2,23 +2,19 @@ package wopan
import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path"
"strings"
"sync"
"time"
sdk "github.com/OpenListTeam/wopan-sdk-go"
"github.com/go-resty/resty/v2"
"github.com/video-site/backend/internal/drives"
)
// Driver 封装联通
// Driver 封装联通
type Driver struct {
id string
rootID string
@@ -27,14 +23,6 @@ type Driver struct {
refreshToken string
client *sdk.WoClient
onTokenUpdate func(access, refresh string)
listMu sync.Mutex
lastListAt time.Time
listInterval time.Duration
listCooldown time.Duration
fileIDMu sync.RWMutex
fidToID map[string]string
}
type Config struct {
@@ -59,9 +47,6 @@ func New(c Config) *Driver {
accessToken: c.AccessToken,
refreshToken: c.RefreshToken,
onTokenUpdate: c.OnTokenUpdate,
listInterval: 800 * time.Millisecond,
listCooldown: 5 * time.Minute,
fidToID: make(map[string]string),
}
}
@@ -93,41 +78,15 @@ func (d *Driver) spaceType() string {
}
func (d *Driver) List(ctx context.Context, dirID string) ([]drives.Entry, error) {
d.listMu.Lock()
defer d.listMu.Unlock()
var result []drives.Entry
pageNum := 0
pageSize := 100
for {
var data *sdk.QueryAllFilesData
for attempt := 0; ; attempt++ {
if err := d.waitForListSlotLocked(ctx); err != nil {
return nil, err
}
var err error
data, err = d.client.QueryAllFiles(d.spaceType(), dirID, pageNum, pageSize, 0, d.familyID, func(req *resty.Request) {
req.SetContext(ctx)
})
if err == nil {
break
}
err = wopanRequestError("list", err)
wait, ok := drives.RateLimitRetryAfter(err)
if !ok {
return nil, err
}
if wait <= 0 {
wait = d.listCooldown
}
log.Printf("[wopan] list cooling down drive=%s dir=%s page=%d cooldown=%s attempt=%d err=%v",
d.id, dirID, pageNum, wait, attempt+1, err)
if err := sleepContext(ctx, wait); err != nil {
return nil, err
}
data, err := d.client.QueryAllFiles(d.spaceType(), dirID, pageNum, pageSize, 0, d.familyID)
if err != nil {
return nil, fmt.Errorf("wopan list: %w", err)
}
for _, f := range data.Files {
d.rememberFileID(f)
result = append(result, fileToEntry(f, dirID))
}
if len(data.Files) < pageSize {
@@ -144,11 +103,9 @@ func (d *Driver) Stat(ctx context.Context, fileID string) (*drives.Entry, error)
}
func (d *Driver) StreamURL(ctx context.Context, fileID string) (*drives.StreamLink, error) {
data, err := d.client.GetDownloadUrlV2([]string{fileID}, func(req *resty.Request) {
req.SetContext(ctx)
})
data, err := d.client.GetDownloadUrlV2([]string{fileID})
if err != nil {
return nil, wopanRequestError("download url", err)
return nil, fmt.Errorf("wopan download url: %w", err)
}
if len(data.List) == 0 {
return nil, fmt.Errorf("wopan download url: empty response")
@@ -185,151 +142,9 @@ func (d *Driver) Upload(ctx context.Context, parentID, name string, r io.Reader,
if err != nil {
return "", fmt.Errorf("wopan upload: %w", err)
}
if fid != "" {
if objectID, err := d.findDeleteFileIDInParent(ctx, parentID, drives.SourceFile{
FileID: fid,
Name: name,
Size: size,
}); err == nil {
d.rememberFIDMapping(fid, objectID)
} else {
log.Printf("[wopan] upload drive=%s parent=%s fid=%s resolve object id: %v", d.id, parentID, fid, err)
}
}
return fid, nil
}
func (d *Driver) Rename(ctx context.Context, fileID, newName string) error {
if d.client == nil {
return fmt.Errorf("wopan rename: driver not initialized")
}
fileID = strings.TrimSpace(fileID)
if fileID == "" {
return fmt.Errorf("wopan rename: empty file id")
}
newName = strings.TrimSpace(newName)
if newName == "" {
return fmt.Errorf("wopan rename: empty new name")
}
renameID := fileID
if cached := d.cachedDeleteFileID(fileID); cached != "" {
renameID = cached
}
if err := d.client.RenameFileOrDirectory(d.spaceType(), 1, renameID, newName, d.familyID, func(req *resty.Request) {
req.SetContext(ctx)
}); err != nil {
return wopanRequestError("rename", err)
}
return nil
}
func (d *Driver) Remove(ctx context.Context, fileID string) error {
if d.client == nil {
return fmt.Errorf("wopan remove: driver not initialized")
}
fileID = strings.TrimSpace(fileID)
if fileID == "" {
return fmt.Errorf("wopan remove: empty file id")
}
deleteID := fileID
if cached := d.cachedDeleteFileID(fileID); cached != "" {
deleteID = cached
}
if err := d.deleteFileByObjectID(ctx, deleteID); err != nil {
return fmt.Errorf("wopan remove: %w", err)
}
return nil
}
func (d *Driver) RemoveSource(ctx context.Context, source drives.SourceFile) error {
if d.client == nil {
return fmt.Errorf("wopan remove: driver not initialized")
}
fileID := strings.TrimSpace(source.FileID)
if fileID == "" {
return fmt.Errorf("wopan remove: empty file id")
}
deleteID, err := d.resolveDeleteFileID(ctx, source)
if err != nil {
return err
}
if err := d.deleteFileByObjectID(ctx, deleteID); err != nil {
return fmt.Errorf("wopan remove: %w", err)
}
return nil
}
func (d *Driver) deleteFileByObjectID(ctx context.Context, fileID string) error {
if err := d.client.DeleteFile(d.spaceType(), nil, []string{fileID}, func(req *resty.Request) {
req.SetContext(ctx)
}); err != nil {
return err
}
return nil
}
func (d *Driver) resolveDeleteFileID(ctx context.Context, source drives.SourceFile) (string, error) {
fileID := strings.TrimSpace(source.FileID)
if fileID == "" {
return "", fmt.Errorf("wopan remove: empty file id")
}
if cached := d.cachedDeleteFileID(fileID); cached != "" {
return cached, nil
}
parentID := strings.TrimSpace(source.ParentID)
if parentID == "" {
return fileID, nil
}
return d.findDeleteFileIDInParent(ctx, parentID, source)
}
func (d *Driver) findDeleteFileIDInParent(ctx context.Context, parentID string, source drives.SourceFile) (string, error) {
d.listMu.Lock()
defer d.listMu.Unlock()
pageNum := 0
pageSize := 100
for {
var data *sdk.QueryAllFilesData
for attempt := 0; ; attempt++ {
if err := d.waitForListSlotLocked(ctx); err != nil {
return "", err
}
var err error
data, err = d.client.QueryAllFiles(d.spaceType(), parentID, pageNum, pageSize, 0, d.familyID, func(req *resty.Request) {
req.SetContext(ctx)
})
if err == nil {
break
}
err = wopanRequestError("resolve delete id", err)
wait, ok := drives.RateLimitRetryAfter(err)
if !ok {
return "", err
}
if wait <= 0 {
wait = d.listCooldown
}
log.Printf("[wopan] resolve delete id cooling down drive=%s parent=%s page=%d cooldown=%s attempt=%d err=%v",
d.id, parentID, pageNum, wait, attempt+1, err)
if err := sleepContext(ctx, wait); err != nil {
return "", err
}
}
for _, f := range data.Files {
d.rememberFileID(f)
if id, ok := deleteFileIDFromWopanFile(f, source); ok {
return id, nil
}
}
if len(data.Files) < pageSize {
break
}
pageNum++
}
return "", fmt.Errorf("wopan remove: source file %q not found under parent %q", source.FileID, parentID)
}
func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) {
parts := splitPath(pathFromRoot)
currentID := d.rootID
@@ -339,11 +154,9 @@ func (d *Driver) EnsureDir(ctx context.Context, pathFromRoot string) (string, er
return "", err
}
if childID == "" {
resp, err := d.client.CreateDirectory(d.spaceType(), currentID, name, d.familyID, func(req *resty.Request) {
req.SetContext(ctx)
})
resp, err := d.client.CreateDirectory(d.spaceType(), currentID, name, d.familyID)
if err != nil {
return "", wopanRequestError("mkdir "+name, err)
return "", fmt.Errorf("wopan mkdir %s: %w", name, err)
}
childID = resp.Id
}
@@ -377,12 +190,9 @@ func fileToEntry(f *sdk.File, parentID string) drives.Entry {
mod, _ := time.Parse("2006-01-02 15:04:05", f.CreateTime)
name := f.Name
isDir := f.Type == 0
id := f.Id
if !isDir && f.Fid != "" {
id = f.Fid
}
id := f.Fid
if id == "" {
id = f.Fid
id = f.Id
}
if isDir && !strings.HasSuffix(name, "/") {
// 不改 name,只标志
@@ -398,156 +208,6 @@ func fileToEntry(f *sdk.File, parentID string) drives.Entry {
}
}
func (d *Driver) rememberFileID(f *sdk.File) {
if f == nil || f.Type == 0 {
return
}
objectID := strings.TrimSpace(f.Id)
fid := strings.TrimSpace(f.Fid)
if objectID == "" {
return
}
d.fileIDMu.Lock()
if d.fidToID == nil {
d.fidToID = make(map[string]string)
}
d.fidToID[objectID] = objectID
if fid != "" {
d.fidToID[fid] = objectID
}
d.fileIDMu.Unlock()
}
func (d *Driver) rememberFIDMapping(fid, objectID string) {
fid = strings.TrimSpace(fid)
objectID = strings.TrimSpace(objectID)
if fid == "" || objectID == "" {
return
}
d.fileIDMu.Lock()
if d.fidToID == nil {
d.fidToID = make(map[string]string)
}
d.fidToID[fid] = objectID
d.fidToID[objectID] = objectID
d.fileIDMu.Unlock()
}
func (d *Driver) cachedDeleteFileID(fileID string) string {
fileID = strings.TrimSpace(fileID)
if fileID == "" {
return ""
}
d.fileIDMu.RLock()
defer d.fileIDMu.RUnlock()
return strings.TrimSpace(d.fidToID[fileID])
}
func deleteFileIDFromWopanFile(f *sdk.File, source drives.SourceFile) (string, bool) {
if f == nil || f.Type == 0 {
return "", false
}
sourceID := strings.TrimSpace(source.FileID)
if sourceID == "" {
return "", false
}
objectID := strings.TrimSpace(f.Id)
fid := strings.TrimSpace(f.Fid)
if objectID == "" {
return "", false
}
if sourceID != objectID && sourceID != fid {
return "", false
}
return objectID, true
}
func (d *Driver) waitForListSlotLocked(ctx context.Context) error {
if d.listInterval <= 0 || d.lastListAt.IsZero() {
d.lastListAt = time.Now()
return ctx.Err()
}
next := d.lastListAt.Add(d.listInterval)
now := time.Now()
if now.Before(next) {
if err := sleepContext(ctx, next.Sub(now)); err != nil {
return err
}
}
d.lastListAt = time.Now()
return ctx.Err()
}
func sleepContext(ctx context.Context, d time.Duration) error {
if d <= 0 {
return ctx.Err()
}
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func wopanRequestError(step string, err error) error {
if err == nil {
return nil
}
wrapped := fmt.Errorf("wopan %s: %w", step, err)
if isWopanRateLimitError(err) {
return &drives.RateLimitError{
Provider: "wopan",
Err: wrapped,
}
}
return wrapped
}
func isWopanRateLimitError(err error) bool {
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
text := strings.ToLower(strings.TrimSpace(err.Error()))
if text == "" {
return false
}
return strings.Contains(text, "status: 429") ||
strings.Contains(text, "status 429") ||
strings.Contains(text, "http status: 429") ||
strings.Contains(text, "status: 500") ||
strings.Contains(text, "status 500") ||
strings.Contains(text, "status: 502") ||
strings.Contains(text, "status 502") ||
strings.Contains(text, "status: 503") ||
strings.Contains(text, "status 503") ||
strings.Contains(text, "status: 504") ||
strings.Contains(text, "status 504") ||
strings.Contains(text, "status: 509") ||
strings.Contains(text, "status 509") ||
strings.Contains(text, "too many request") ||
strings.Contains(text, "too many requests") ||
strings.Contains(text, "rate limit") ||
strings.Contains(text, "rate-limit") ||
strings.Contains(text, "throttl") ||
strings.Contains(text, "blocked") ||
strings.Contains(text, "request has been blocked") ||
strings.Contains(text, "操作频繁") ||
strings.Contains(text, "请求频繁") ||
strings.Contains(text, "请求太频繁") ||
strings.Contains(text, "请求过于频繁") ||
strings.Contains(text, "频率限制") ||
strings.Contains(text, "请求次数过多") ||
strings.Contains(text, "系统繁忙") ||
strings.Contains(text, "服务繁忙") ||
strings.Contains(text, "稍后再试") ||
strings.Contains(text, "稍后重试") ||
strings.Contains(text, "访问被阻断") ||
strings.Contains(text, "风控")
}
func guessMime(name string) string {
ext := strings.ToLower(path.Ext(name))
switch ext {
@@ -569,5 +229,3 @@ func guessMime(name string) string {
// 确保实现接口
var _ drives.Drive = (*Driver)(nil)
var _ drives.Remover = (*Driver)(nil)
var _ drives.SourceRemover = (*Driver)(nil)
@@ -1,113 +0,0 @@
package wopan
import (
"errors"
"testing"
sdk "github.com/OpenListTeam/wopan-sdk-go"
"github.com/video-site/backend/internal/drives"
)
func TestFileToEntryUsesDirectoryIDAndFileFID(t *testing.T) {
dir := fileToEntry(&sdk.File{
Id: "dir-object-id",
Fid: "0",
Type: 0,
Name: "collection",
}, "root")
if !dir.IsDir {
t.Fatal("directory entry IsDir = false")
}
if dir.ID != "dir-object-id" {
t.Fatalf("directory id = %q, want object id", dir.ID)
}
file := fileToEntry(&sdk.File{
Id: "file-object-id",
Fid: "fid/with/slash",
Type: 1,
Name: "clip.mp4",
Size: 123,
}, "dir-object-id")
if file.IsDir {
t.Fatal("file entry IsDir = true")
}
if file.ID != "fid/with/slash" {
t.Fatalf("file id = %q, want fid for download", file.ID)
}
}
func TestDeleteFileIDFromWopanFileUsesObjectIDForFID(t *testing.T) {
got, ok := deleteFileIDFromWopanFile(&sdk.File{
Id: "file-object-id",
Fid: "fid/with/slash",
Type: 1,
Name: "clip.mp4",
Size: 123,
}, drives.SourceFile{
FileID: "fid/with/slash",
Name: "clip.mp4",
Size: 123,
})
if !ok {
t.Fatal("delete file id not resolved")
}
if got != "file-object-id" {
t.Fatalf("delete file id = %q, want object id", got)
}
}
func TestDeleteFileIDFromWopanFileAcceptsObjectID(t *testing.T) {
got, ok := deleteFileIDFromWopanFile(&sdk.File{
Id: "file-object-id",
Fid: "fid-1",
Type: 1,
Name: "clip.mp4",
Size: 123,
}, drives.SourceFile{
FileID: "file-object-id",
Name: "clip.mp4",
Size: 123,
})
if !ok {
t.Fatal("delete file id not resolved")
}
if got != "file-object-id" {
t.Fatalf("delete file id = %q, want object id", got)
}
}
func TestDeleteFileIDFromWopanFileRejectsIDMismatch(t *testing.T) {
if _, ok := deleteFileIDFromWopanFile(&sdk.File{
Id: "file-object-id",
Fid: "fid-1",
Type: 1,
Name: "clip.mp4",
Size: 123,
}, drives.SourceFile{
FileID: "other-fid",
Name: "clip.mp4",
Size: 123,
}); ok {
t.Fatal("delete file id resolved despite id mismatch")
}
}
func TestWopanRequestErrorWrapsRateLimit(t *testing.T) {
err := wopanRequestError("list", errors.New("request failed with status: 429 Too Many Requests"))
var rateLimit *drives.RateLimitError
if !errors.As(err, &rateLimit) {
t.Fatalf("error = %T %[1]v, want RateLimitError", err)
}
if rateLimit.Provider != "wopan" {
t.Fatalf("provider = %q, want wopan", rateLimit.Provider)
}
}
func TestWopanRequestErrorLeavesNormalErrors(t *testing.T) {
err := wopanRequestError("download url", errors.New("invalid access token"))
var rateLimit *drives.RateLimitError
if errors.As(err, &rateLimit) {
t.Fatalf("error = %T %[1]v, want non-rate-limit error", err)
}
}
-349
View File
@@ -1,349 +0,0 @@
package wopan
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/go-resty/resty/v2"
)
const (
defaultQRCodeAPIBase = "https://panservice.mail.wo.cn/wohome/open/v1/QRCode"
defaultQRCodeClient = "1001000021"
)
type QRConfig struct {
APIBaseURL string
HTTPClient *http.Client
Now func() time.Time
}
type QRClient struct {
apiBase string
client *resty.Client
now func() time.Time
}
type QRCodeSession struct {
UUID string `json:"uuid"`
QRImageDataURL string `json:"qrImageDataUrl"`
ExpiresAt string `json:"expiresAt,omitempty"`
}
type QRCodeStatus struct {
State int `json:"state"`
StatusText string `json:"statusText"`
AccessToken string `json:"accessToken,omitempty"`
RefreshToken string `json:"refreshToken,omitempty"`
FamilyID string `json:"familyID,omitempty"`
}
func NewQRClient(c QRConfig) *QRClient {
apiBase := strings.TrimRight(strings.TrimSpace(c.APIBaseURL), "/")
if apiBase == "" {
apiBase = defaultQRCodeAPIBase
}
httpClient := c.HTTPClient
if httpClient == nil {
httpClient = &http.Client{Timeout: 20 * time.Second}
}
now := c.Now
if now == nil {
now = time.Now
}
return &QRClient{
apiBase: apiBase,
client: resty.NewWithClient(httpClient).
SetTimeout(20*time.Second).
SetHeader("Accept", "application/json"),
now: now,
}
}
func (c *QRClient) Generate(ctx context.Context) (QRCodeSession, error) {
var envelope qrEnvelope
res, err := c.request(ctx).
SetResult(&envelope).
Get(c.apiBase + "/generate")
if err != nil {
return QRCodeSession{}, err
}
if res.IsError() {
return QRCodeSession{}, qrAPIError(envelope.message(), res.StatusCode())
}
var result qrGenerateResult
if err := decodeResult(envelope.Result, &result); err != nil {
return QRCodeSession{}, err
}
result.UUID = strings.TrimSpace(result.UUID)
result.Image = strings.TrimSpace(result.Image)
if result.UUID == "" {
return QRCodeSession{}, errors.New("wopan qr: empty uuid")
}
if result.Image == "" {
return QRCodeSession{}, errors.New("wopan qr: empty image")
}
return QRCodeSession{
UUID: result.UUID,
QRImageDataURL: qrImageDataURL(result.Image),
ExpiresAt: c.now().Add(60 * time.Second).Format(time.RFC3339),
}, nil
}
func (c *QRClient) Poll(ctx context.Context, uuid string) (QRCodeStatus, error) {
uuid = strings.TrimSpace(uuid)
if uuid == "" {
return QRCodeStatus{}, errors.New("uuid is required")
}
var envelope qrEnvelope
res, err := c.request(ctx).
SetQueryParam("uuid", uuid).
SetResult(&envelope).
Get(c.apiBase + "/query")
if err != nil {
return QRCodeStatus{}, err
}
if res.IsError() {
return QRCodeStatus{}, qrAPIError(envelope.message(), res.StatusCode())
}
result, err := decodeResultMap(envelope.Result)
if err != nil {
return QRCodeStatus{}, err
}
state := intValue(result["state"])
status := QRCodeStatus{
State: state,
StatusText: qrStateText(state),
}
if state != 3 {
return status, nil
}
status.AccessToken = findStringByKeys(result, "access_token", "accessToken", "token", "tokenValue")
status.RefreshToken = findStringByKeys(result, "refresh_token", "refreshToken")
status.FamilyID = findStringByKeys(result, "family_id", "familyId", "familyID", "defaultFamilyId", "defaultHomeId", "homeId")
if status.AccessToken == "" || status.RefreshToken == "" {
missing := make([]string, 0, 2)
if status.AccessToken == "" {
missing = append(missing, "access_token")
}
if status.RefreshToken == "" {
missing = append(missing, "refresh_token")
}
return QRCodeStatus{}, fmt.Errorf("wopan qr: login succeeded but missing %s; available keys: %s",
strings.Join(missing, ", "), strings.Join(collectJSONKeys(result), ", "))
}
return status, nil
}
func (c *QRClient) request(ctx context.Context) *resty.Request {
return c.client.R().
SetContext(ctx).
SetHeaders(map[string]string{
"client-id": defaultQRCodeClient,
"x-yp-client-id": defaultQRCodeClient,
"Accept": "application/json",
"Accept-Language": "zh-CN,zh;q=0.9",
})
}
type qrEnvelope struct {
Meta qrMeta `json:"meta"`
Result json.RawMessage `json:"result"`
Code any `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Msg string `json:"msg,omitempty"`
}
type qrMeta struct {
Code any `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Msg string `json:"msg,omitempty"`
}
type qrGenerateResult struct {
UUID string `json:"uuid"`
Image string `json:"image"`
}
func (e qrEnvelope) message() string {
for _, s := range []string{e.Message, e.Msg, e.Meta.Message, e.Meta.Msg} {
if strings.TrimSpace(s) != "" {
return strings.TrimSpace(s)
}
}
return ""
}
func decodeResult(raw json.RawMessage, dst any) error {
if len(raw) == 0 || string(raw) == "null" {
return errors.New("wopan qr: empty result")
}
if err := json.Unmarshal(raw, dst); err != nil {
return fmt.Errorf("wopan qr: decode result: %w", err)
}
return nil
}
func decodeResultMap(raw json.RawMessage) (map[string]any, error) {
var result map[string]any
if err := decodeResult(raw, &result); err != nil {
return nil, err
}
if result == nil {
return nil, errors.New("wopan qr: empty result")
}
return result, nil
}
func qrImageDataURL(image string) string {
image = strings.TrimSpace(image)
if strings.HasPrefix(strings.ToLower(image), "data:image/") {
return image
}
return "data:image/png;base64," + image
}
func qrAPIError(message string, httpStatus int) error {
message = strings.TrimSpace(message)
if message == "" {
message = fmt.Sprintf("HTTP %d", httpStatus)
}
return errors.New(message)
}
func qrStateText(state int) string {
switch state {
case 1:
return "等待扫码"
case 2:
return "已扫码,请在联通网盘 App 确认"
case 3:
return "登录成功"
case 4:
return "二维码已过期"
default:
return "未知状态"
}
}
func intValue(v any) int {
switch x := v.(type) {
case int:
return x
case int64:
return int(x)
case float64:
return int(x)
case json.Number:
n, _ := x.Int64()
return int(n)
case string:
n, _ := strconv.Atoi(strings.TrimSpace(x))
return n
default:
return 0
}
}
func findStringByKeys(v any, keys ...string) string {
targets := make(map[string]struct{}, len(keys))
for _, key := range keys {
targets[normalizeJSONKey(key)] = struct{}{}
}
return findStringByNormalizedKeys(v, targets)
}
func findStringByNormalizedKeys(v any, targets map[string]struct{}) string {
switch x := v.(type) {
case map[string]any:
for key, value := range x {
if _, ok := targets[normalizeJSONKey(key)]; ok {
if s := stringValue(value); s != "" {
return s
}
}
}
for _, value := range x {
if s := findStringByNormalizedKeys(value, targets); s != "" {
return s
}
}
case []any:
for _, value := range x {
if s := findStringByNormalizedKeys(value, targets); s != "" {
return s
}
}
}
return ""
}
func stringValue(v any) string {
switch x := v.(type) {
case string:
return strings.TrimSpace(x)
case int:
return strconv.Itoa(x)
case int64:
return strconv.FormatInt(x, 10)
case float64:
if x == float64(int64(x)) {
return strconv.FormatInt(int64(x), 10)
}
return strconv.FormatFloat(x, 'f', -1, 64)
case json.Number:
return strings.TrimSpace(x.String())
default:
return ""
}
}
func normalizeJSONKey(key string) string {
key = strings.ToLower(strings.TrimSpace(key))
key = strings.ReplaceAll(key, "_", "")
key = strings.ReplaceAll(key, "-", "")
key = strings.ReplaceAll(key, " ", "")
return key
}
func collectJSONKeys(v any) []string {
seen := map[string]struct{}{}
var walk func(any)
walk = func(value any) {
switch x := value.(type) {
case map[string]any:
for key, child := range x {
if strings.TrimSpace(key) != "" {
seen[key] = struct{}{}
}
walk(child)
}
case []any:
for _, child := range x {
walk(child)
}
}
}
walk(v)
keys := make([]string, 0, len(seen))
for key := range seen {
keys = append(keys, key)
}
sort.Strings(keys)
if len(keys) > 16 {
keys = append(keys[:16], "...")
}
return keys
}
-128
View File
@@ -1,128 +0,0 @@
package wopan
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestQRCodeGenerateUsesServiceImage(t *testing.T) {
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/QRCode/generate" {
http.NotFound(w, r)
return
}
if r.Header.Get("client-id") != defaultQRCodeClient {
t.Fatalf("client-id = %q, want %q", r.Header.Get("client-id"), defaultQRCodeClient)
}
if r.Header.Get("x-yp-client-id") != defaultQRCodeClient {
t.Fatalf("x-yp-client-id = %q, want %q", r.Header.Get("x-yp-client-id"), defaultQRCodeClient)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"meta": map[string]string{"code": "0000", "message": "ok"},
"result": map[string]string{
"uuid": "uuid-1",
"image": "iVBORw0KGgo=",
},
})
}))
t.Cleanup(api.Close)
got, err := NewQRClient(QRConfig{APIBaseURL: api.URL + "/QRCode"}).Generate(context.Background())
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
if got.UUID != "uuid-1" {
t.Fatalf("uuid = %q, want uuid-1", got.UUID)
}
if got.QRImageDataURL != "data:image/png;base64,iVBORw0KGgo=" {
t.Fatalf("qrImageDataUrl = %q, want PNG data URL", got.QRImageDataURL)
}
if got.ExpiresAt == "" {
t.Fatalf("expiresAt is empty")
}
}
func TestQRCodePollPending(t *testing.T) {
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/QRCode/query" {
http.NotFound(w, r)
return
}
if r.URL.Query().Get("uuid") != "uuid-1" {
t.Fatalf("uuid query = %q, want uuid-1", r.URL.Query().Get("uuid"))
}
_ = json.NewEncoder(w).Encode(map[string]any{
"meta": map[string]string{"code": "0000", "message": "ok"},
"result": map[string]any{
"state": 1,
"token": nil,
"refreshToken": nil,
},
})
}))
t.Cleanup(api.Close)
got, err := NewQRClient(QRConfig{APIBaseURL: api.URL + "/QRCode"}).Poll(context.Background(), "uuid-1")
if err != nil {
t.Fatalf("Poll() error = %v", err)
}
if got.State != 1 || got.StatusText != "等待扫码" || got.AccessToken != "" || got.RefreshToken != "" {
t.Fatalf("status = %#v, want pending without tokens", got)
}
}
func TestQRCodePollSuccessMapsTokenFields(t *testing.T) {
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/QRCode/query" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"meta": map[string]string{"code": "0000", "message": "ok"},
"result": map[string]any{
"state": 3,
"token": "access-1",
"refreshToken": "refresh-1",
},
})
}))
t.Cleanup(api.Close)
got, err := NewQRClient(QRConfig{APIBaseURL: api.URL + "/QRCode"}).Poll(context.Background(), "uuid-1")
if err != nil {
t.Fatalf("Poll() error = %v", err)
}
if got.State != 3 || got.AccessToken != "access-1" || got.RefreshToken != "refresh-1" {
t.Fatalf("status = %#v, want token and refreshToken mapped", got)
}
}
func TestQRCodePollSuccessReportsMissingTokenKeys(t *testing.T) {
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"meta": map[string]string{"code": "0000", "message": "ok"},
"result": map[string]any{
"state": 3,
"user": map[string]string{"name": "demo"},
},
})
}))
t.Cleanup(api.Close)
_, err := NewQRClient(QRConfig{APIBaseURL: api.URL + "/QRCode"}).Poll(context.Background(), "uuid-1")
if err == nil {
t.Fatal("Poll() error is nil, want missing token error")
}
if !strings.Contains(err.Error(), "missing access_token, refresh_token") ||
!strings.Contains(err.Error(), "available keys") {
t.Fatalf("error = %q, want missing token keys", err.Error())
}
}
-107
View File
@@ -149,28 +149,6 @@ func (w *Worker) Status() TaskStatus {
return status
}
// WaitIdle blocks until the fingerprint queue is empty and no item is being processed.
func (w *Worker) WaitIdle(ctx context.Context) error {
if w == nil {
return nil
}
if w.queue.lengthExcluding("") == 0 {
return nil
}
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if w.queue.lengthExcluding("") == 0 {
return nil
}
}
}
}
func (w *Worker) processQueued(ctx context.Context, v *catalog.Video) {
defer w.queue.release(v.ID)
if w.Catalog == nil || w.Drive == nil || v == nil || v.ID == "" {
@@ -349,96 +327,11 @@ func readHTTPRange(ctx context.Context, hc *http.Client, link *drives.StreamLink
return data, nil
}
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if remoteRangeResponseLooksRateLimited(link.URL, resp.StatusCode, body) {
return nil, &drives.RateLimitError{
Provider: "fingerprint",
RetryAfter: parseRetryAfter(resp.Header.Get("Retry-After")),
Err: fmt.Errorf("remote sample rate limited: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body))),
}
}
return nil, fmt.Errorf("fingerprint: range request got status=%d for bytes=%d-%d", resp.StatusCode, r.start, end)
}
return io.ReadAll(io.LimitReader(resp.Body, r.length))
}
func remoteRangeResponseLooksRateLimited(rawURL string, status int, body []byte) bool {
if status == http.StatusTooManyRequests {
return true
}
if isWopanMediaURL(rawURL) && (status == http.StatusForbidden || status == http.StatusTooManyRequests ||
status == http.StatusInternalServerError || status == http.StatusBadGateway ||
status == http.StatusServiceUnavailable || status == http.StatusGatewayTimeout ||
status == 509) {
return true
}
text := strings.ToLower(strings.TrimSpace(string(body)))
compact := compactRemoteRangeErrorText(text)
if strings.Contains(text, "too many request") ||
strings.Contains(text, "too many requests") ||
strings.Contains(text, "rate limit") ||
strings.Contains(text, "quota exceeded") ||
strings.Contains(text, "操作频繁") ||
strings.Contains(text, "请求频繁") ||
strings.Contains(text, "请求太频繁") ||
strings.Contains(text, "请求过于频繁") ||
strings.Contains(text, "频率限制") ||
strings.Contains(text, "请求次数过多") ||
strings.Contains(text, "系统繁忙") ||
strings.Contains(text, "服务繁忙") ||
strings.Contains(text, "稍后再试") ||
strings.Contains(text, "稍后重试") ||
strings.Contains(text, "访问被阻断") ||
strings.Contains(text, "风控") ||
strings.Contains(text, "download quota") ||
strings.Contains(text, "sharing rate") ||
strings.Contains(text, "daily limit") ||
strings.Contains(text, "user rate") ||
strings.Contains(text, "usage limit") ||
strings.Contains(compact, "ratelimitexceeded") ||
strings.Contains(compact, "userratelimitexceeded") ||
strings.Contains(compact, "dailylimitexceeded") ||
strings.Contains(compact, "downloadquotaexceeded") ||
strings.Contains(compact, "sharingratelimitexceeded") ||
strings.Contains(compact, "quotaexceeded") ||
strings.Contains(compact, "toomanyrequests") ||
strings.Contains(compact, "usagelimits") {
return true
}
if status == http.StatusForbidden && isGoogleDriveMediaURL(rawURL) {
return true
}
return false
}
func isWopanMediaURL(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil {
return false
}
host := strings.ToLower(u.Hostname())
path := strings.ToLower(u.Path)
return (strings.HasSuffix(host, "pan.wo.cn") ||
strings.HasSuffix(host, "smartont.net") ||
strings.Contains(host, "wo.cn")) &&
strings.Contains(path, "/openapi/download")
}
func isGoogleDriveMediaURL(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil {
return false
}
host := strings.ToLower(u.Host)
path := strings.ToLower(u.Path)
return strings.Contains(host, "googleapis.com") && strings.Contains(path, "/drive/")
}
func compactRemoteRangeErrorText(text string) string {
replacer := strings.NewReplacer("_", "", "-", "", " ", "", ".", "", ":", "")
return replacer.Replace(strings.ToLower(strings.TrimSpace(text)))
}
func parseRetryAfter(raw string) time.Duration {
raw = strings.TrimSpace(raw)
if raw == "" {
@@ -2,7 +2,6 @@ package fingerprint
import (
"context"
"errors"
"fmt"
"io"
"net/http"
@@ -86,51 +85,6 @@ func TestComputeRemoteUsesRangeSamples(t *testing.T) {
}
}
func TestComputeRemoteGoogleQuotaExceededReturnsRateLimit(t *testing.T) {
ctx := context.Background()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Retry-After", "60")
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":{"code":403,"message":"The download quota for this file has been exceeded.","errors":[{"domain":"usageLimits","reason":"downloadQuotaExceeded","message":"The download quota for this file has been exceeded."}]}}`))
}))
defer srv.Close()
drv := &fakeDrive{paths: map[string]string{"remote": srv.URL + "/drive/v3/files/file-1?alt=media"}}
_, err := Compute(ctx, drv, &catalog.Video{ID: "remote", FileID: "remote", Size: 1024 * 1024}, Config{
SampleSizeBytes: 4,
FullHashMaxSize: 8,
HTTPClient: srv.Client(),
}, srv.Client())
if err == nil {
t.Fatal("compute succeeded, want rate limit")
}
var rateLimit *drives.RateLimitError
if !errors.As(err, &rateLimit) {
t.Fatalf("error = %T %[1]v, want RateLimitError", err)
}
if rateLimit.RetryAfter != time.Minute {
t.Fatalf("retry after = %s, want 1m", rateLimit.RetryAfter)
}
}
func TestWopanRemoteRangeErrorsLookRateLimited(t *testing.T) {
for _, tc := range []struct {
rawURL string
status int
}{
{rawURL: "https://gxdownload.pan.wo.cn:8445/openapi/download?fid=encoded", status: http.StatusForbidden},
{rawURL: "https://du.smartont.net:8445/openapi/download?fid=encoded", status: http.StatusServiceUnavailable},
{rawURL: "https://du.smartont.net:8445/openapi/download?fid=encoded", status: 509},
} {
if !remoteRangeResponseLooksRateLimited(tc.rawURL, tc.status, nil) {
t.Fatalf("remoteRangeResponseLooksRateLimited(%q, %d) = false, want true", tc.rawURL, tc.status)
}
}
if remoteRangeResponseLooksRateLimited("https://example.com/video.mp4", http.StatusForbidden, nil) {
t.Fatal("generic 403 should not be treated as wopan rate limit")
}
}
type fakeDrive struct {
paths map[string]string
}
-69
View File
@@ -1,69 +0,0 @@
package mediaasset
import (
"crypto/sha256"
"encoding/hex"
"path/filepath"
"strings"
)
const maxPlainStemBytes = 180
const maxLegacyFilenameBytes = 255
func PreviewPath(localDir, videoID string) string {
return filepath.Join(localDir, PreviewFilename(videoID))
}
func ThumbnailPath(localDir, videoID string) string {
return ThumbnailPathInDir(filepath.Join(localDir, "thumbs"), videoID)
}
func ThumbnailPathInDir(thumbDir, videoID string) string {
return filepath.Join(thumbDir, ThumbnailFilename(videoID))
}
func PreviewPathCandidates(localDir, videoID string) []string {
return pathCandidates(localDir, videoID, ".mp4", "")
}
func ThumbnailPathCandidates(localDir, videoID string) []string {
return pathCandidates(localDir, videoID, ".jpg", "thumbs")
}
func PreviewFilename(videoID string) string {
return safeFilename(videoID, ".mp4")
}
func ThumbnailFilename(videoID string) string {
return safeFilename(videoID, ".jpg")
}
func pathCandidates(localDir, videoID, ext, subdir string) []string {
safe := safeFilename(videoID, ext)
legacy := videoID + ext
base := localDir
if subdir != "" {
base = filepath.Join(base, subdir)
}
out := []string{filepath.Join(base, safe)}
if legacy != safe && isPlainSafeStem(videoID) && len([]byte(legacy)) <= maxLegacyFilenameBytes {
out = append(out, filepath.Join(base, legacy))
}
return out
}
func safeFilename(videoID, ext string) string {
if isPlainSafeStem(videoID) && len([]byte(videoID))+len(ext) <= maxPlainStemBytes {
return videoID + ext
}
sum := sha256.Sum256([]byte(videoID))
return "v-" + hex.EncodeToString(sum[:]) + ext
}
func isPlainSafeStem(value string) bool {
value = strings.TrimSpace(value)
if value == "" || value == "." || value == ".." {
return false
}
return !strings.ContainsAny(value, `/\`+"\x00")
}
-56
View File
@@ -1,56 +0,0 @@
package mediaasset
import (
"path/filepath"
"strings"
"testing"
)
func TestFilenamesKeepShortSafeIDs(t *testing.T) {
if got := ThumbnailFilename("video-1"); got != "video-1.jpg" {
t.Fatalf("thumbnail filename = %q, want video-1.jpg", got)
}
if got := PreviewFilename("video-1"); got != "video-1.mp4" {
t.Fatalf("preview filename = %q, want video-1.mp4", got)
}
}
func TestFilenamesHashLongOrUnsafeIDs(t *testing.T) {
longID := "localstorage-" + strings.Repeat("x", 240)
got := ThumbnailFilename(longID)
if !strings.HasPrefix(got, "v-") || !strings.HasSuffix(got, ".jpg") {
t.Fatalf("thumbnail filename = %q, want hashed jpg", got)
}
if len([]byte(got)) >= len([]byte(longID+".jpg")) {
t.Fatalf("thumbnail filename = %q should be shorter than original id", got)
}
unsafe := ThumbnailFilename("dir/video")
if unsafe == "dir/video.jpg" || strings.ContainsAny(unsafe, `/\`) {
t.Fatalf("unsafe thumbnail filename = %q, want hashed single filename", unsafe)
}
}
func TestThumbnailPathCandidatesIncludeLegacyForHashedIDs(t *testing.T) {
localDir := t.TempDir()
mediumID := "localstorage-" + strings.Repeat("x", 190)
got := ThumbnailPathCandidates(localDir, mediumID)
if len(got) != 2 {
t.Fatalf("candidates = %#v, want hashed and legacy paths", got)
}
if got[0] != ThumbnailPath(localDir, mediumID) {
t.Fatalf("first candidate = %q, want safe path %q", got[0], ThumbnailPath(localDir, mediumID))
}
if filepath.Base(got[1]) != mediumID+".jpg" {
t.Fatalf("legacy candidate = %q, want original id jpg", got[1])
}
}
func TestThumbnailPathCandidatesSkipOverlongLegacy(t *testing.T) {
localDir := t.TempDir()
longID := "localstorage-" + strings.Repeat("x", 240)
got := ThumbnailPathCandidates(localDir, longID)
if len(got) != 1 {
t.Fatalf("candidates = %#v, want only hashed path for overlong id", got)
}
}
+9 -45
View File
@@ -5,11 +5,11 @@
// "扫描所有网盘"):
//
// Phase 1: for each non-spider91 cloud drive
// scan + delete-detection + enqueue thumb + enqueue preview video
// wait until all thumb / preview-video queues are idle
// scan + delete-detection + enqueue thumb + enqueue teaser
// wait until all thumb / teaser queues are idle
// Phase 2: if any spider91 drive configured
// crawl + enqueue preview video for new videos
// wait until preview-video queues are idle
// crawl + enqueue teaser for new videos
// wait until teaser queues are idle
// Phase 3: spider91 → cloud migration (single sweep, captcha cooldown still
// honored within this call)
// Phase 4: cleanup duplicate local preview/thumbnail assets after sampled
@@ -76,10 +76,10 @@ type Config struct {
ListSpider91Drives func(ctx context.Context) []string
// RunSpider91Crawl synchronously runs one crawl cycle (downloads + thumbs +
// preview-video enqueue) for a single spider91 drive.
// teaser enqueue) for a single spider91 drive.
RunSpider91Crawl func(ctx context.Context, driveID string)
// WaitPreviewQueuesIdle blocks until both the thumbnail and preview-video queues
// WaitPreviewQueuesIdle blocks until both the thumbnail and teaser queues
// across all drives are drained (queue empty + no in-flight task). It must
// honor ctx cancellation.
WaitPreviewQueuesIdle func(ctx context.Context) error
@@ -115,7 +115,6 @@ type Runner struct {
queued bool
startedAt time.Time
lastFinishedAt time.Time
currentCancel context.CancelFunc
}
// New constructs a Runner. cfg is shallow-copied; defaults are applied.
@@ -176,28 +175,6 @@ func (r *Runner) TriggerNow() bool {
}
}
// StopCurrent cancels the currently running pipeline and drops one queued
// manual trigger, if present. It returns true when there was something to stop.
func (r *Runner) StopCurrent() bool {
r.stateMu.Lock()
wasRunning := r.running
wasQueued := r.queued
cancel := r.currentCancel
r.queued = false
r.stateMu.Unlock()
if wasQueued {
select {
case <-r.trigger:
default:
}
}
if cancel != nil {
cancel()
}
return wasRunning || wasQueued || cancel != nil
}
func (r *Runner) Status() Status {
r.stateMu.Lock()
running := r.running
@@ -255,25 +232,14 @@ func shouldRun(now time.Time, lastRunDate string) bool {
//
// 流水线没有总耗时上限:一直跑到 ctx 取消(进程退出)或所有 phase 完成。
func (r *Runner) runPipelineLocked(ctx context.Context, manual bool) {
if manual {
r.stateMu.Lock()
queued := r.queued
r.stateMu.Unlock()
if !queued {
log.Printf("[nightly] manual trigger was canceled before start")
return
}
}
if !r.runMu.TryLock() {
log.Printf("[nightly] another pipeline is already running, skipping this trigger")
return
}
started := r.cfg.Now()
runCtx, cancel := context.WithCancel(ctx)
r.markStarted(started, cancel)
r.markStarted(started)
defer func() {
cancel()
r.markFinished(r.cfg.Now())
r.runMu.Unlock()
}()
@@ -284,7 +250,7 @@ func (r *Runner) runPipelineLocked(ctx context.Context, manual bool) {
}
log.Printf("[nightly] pipeline (%s) start", mode)
r.runPipeline(runCtx)
r.runPipeline(ctx)
finished := r.cfg.Now()
log.Printf("[nightly] pipeline (%s) finish; took=%s", mode, finished.Sub(started).Round(time.Second))
@@ -298,13 +264,12 @@ func (r *Runner) runPipelineLocked(ctx context.Context, manual bool) {
}
}
func (r *Runner) markStarted(started time.Time, cancel context.CancelFunc) {
func (r *Runner) markStarted(started time.Time) {
r.stateMu.Lock()
defer r.stateMu.Unlock()
r.running = true
r.queued = false
r.startedAt = started
r.currentCancel = cancel
}
func (r *Runner) markFinished(finished time.Time) {
@@ -313,7 +278,6 @@ func (r *Runner) markFinished(finished time.Time) {
r.running = false
r.startedAt = time.Time{}
r.lastFinishedAt = finished
r.currentCancel = nil
}
// runPipeline executes the three phases. It returns when the pipeline finishes
-55
View File
@@ -395,61 +395,6 @@ func TestStatusTracksQueuedRunningAndFinished(t *testing.T) {
}
}
func TestStopCurrentCancelsRunningPipeline(t *testing.T) {
scanStarted := make(chan struct{})
scanCanceled := make(chan struct{})
var startedOnce sync.Once
r := New(Config{
Settings: newStubSettings(),
ListScanTargets: func(context.Context) []string {
return []string{"drive"}
},
RunScan: func(ctx context.Context, _ string) {
startedOnce.Do(func() { close(scanStarted) })
<-ctx.Done()
close(scanCanceled)
},
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go r.Run(ctx)
if !r.TriggerNow() {
t.Fatal("TriggerNow should queue a manual run")
}
select {
case <-scanStarted:
case <-time.After(time.Second):
t.Fatal("pipeline did not start")
}
if !r.StopCurrent() {
t.Fatal("StopCurrent should report a running pipeline")
}
select {
case <-scanCanceled:
case <-time.After(time.Second):
t.Fatal("StopCurrent did not cancel pipeline context")
}
}
func TestStopCurrentDropsQueuedTrigger(t *testing.T) {
r := New(Config{Settings: newStubSettings()})
if !r.TriggerNow() {
t.Fatal("TriggerNow should queue a manual run")
}
if !r.StopCurrent() {
t.Fatal("StopCurrent should report a queued pipeline")
}
if got := r.Status(); got.State != "idle" || got.Running || got.Queued {
t.Fatalf("status = %#v, want idle after dropping queued trigger", got)
}
if !r.TriggerNow() {
t.Fatal("TriggerNow should accept a new request after queued stop")
}
}
func TestTriggerNowAcceptsOnlyOneConcurrentRequest(t *testing.T) {
r := New(Config{Settings: newStubSettings()})
+35 -203
View File
@@ -21,16 +21,15 @@ import (
"github.com/video-site/backend/internal/catalog"
"github.com/video-site/backend/internal/drives"
"github.com/video-site/backend/internal/mediaasset"
)
type Config struct {
FFmpegPath string
FFprobePath string
DurationSeconds int // 兼容旧配置;当前预览视频每段固定 3 秒
DurationSeconds int // 兼容旧配置;当前 teaser 每段固定 3 秒
Width int
Segments int // 兼容旧配置;当前 30 秒及以上视频固定使用 4 段
LocalDir string // 本地预览视频和封面目录
LocalDir string // 本地 teaser 和封面目录
}
type Generator struct {
@@ -237,43 +236,23 @@ func appendUniqueStart(starts []float64, start, eachSec float64) []float64 {
return append(starts, start)
}
// thumbnailOffsets 选封面抽帧的时间点(秒)。独立于预览视频
// 默认取视频中间帧;时长未知时退回早期帧。
func thumbnailOffsets(duration float64) []float64 {
if duration <= 0 {
return []float64{5, 1, 0}
}
mid := duration / 2
out := []float64{mid}
for _, fallback := range []float64{5, 1, 0} {
if !containsOffset(out, fallback) {
out = append(out, fallback)
}
}
return out
}
func containsOffset(offsets []float64, target float64) bool {
for _, offset := range offsets {
if math.Abs(offset-target) < 0.01 {
return true
}
}
return false
// thumbnailOffsets 选封面抽帧的时间点(秒)。独立于 teaser
func thumbnailOffsets() []float64 {
return []float64{5, 1, 0}
}
// --- 封面 ---
// GenerateThumbnail 抽一张 jpg 封面。默认从视频中间抽帧,失败时回退到更早时间点。
// GenerateThumbnail 抽一张 jpg 封面。默认从第 5 秒抽帧,失败时回退到更早时间点。
func (g *Generator) GenerateThumbnail(ctx context.Context, link *drives.StreamLink, videoID string, duration float64) (string, error) {
dir := filepath.Join(g.cfg.LocalDir, "thumbs")
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
dst := mediaasset.ThumbnailPath(g.cfg.LocalDir, videoID)
dst := filepath.Join(dir, videoID+".jpg")
var lastErr error
offsets := thumbnailOffsets(duration)
offsets := thumbnailOffsets()
for i, offset := range offsets {
if i > 0 {
_ = os.Remove(dst)
@@ -384,9 +363,9 @@ func (g *Generator) Probe(ctx context.Context, link *drives.StreamLink) (float64
return strconv.ParseFloat(raw, 64)
}
// --- 预览视频 ---
// --- Teaser ---
// Generate 拉取预览视频到本地临时文件,返回路径。
// Generate 拉取 teaser 到本地临时文件,返回路径。
// 根据 Config.Segments 和视频时长决定是单段还是多段拼接。
func (g *Generator) Generate(ctx context.Context, link *drives.StreamLink, duration float64) (string, error) {
return g.generate(ctx, duration, func(int) (*drives.StreamLink, error) {
@@ -967,10 +946,7 @@ func ffmpegOutputLooksRateLimited(output []byte) bool {
// MoveToLocal 把临时文件改名到稳定位置,返回最终路径
func (g *Generator) MoveToLocal(tmpPath, videoID string) (string, error) {
if err := os.MkdirAll(g.cfg.LocalDir, 0o755); err != nil {
return "", err
}
dst := mediaasset.PreviewPath(g.cfg.LocalDir, videoID)
dst := filepath.Join(g.cfg.LocalDir, videoID+".mp4")
if err := os.Rename(tmpPath, dst); err != nil {
// 跨盘 rename 可能失败,fallback 到 copy
if cerr := copyFile(tmpPath, dst); cerr != nil {
@@ -1362,19 +1338,12 @@ func (w *ThumbWorker) Run(ctx context.Context) {
func (w *Worker) processQueued(ctx context.Context, v *catalog.Video) {
defer w.queue.release(v)
if w.Catalog == nil || v == nil || v.ID == "" {
return
}
current, err := w.Catalog.GetVideo(ctx, v.ID)
if err != nil || current.Hidden {
return
}
w.activity.start(current)
w.activity.start(v)
defer w.activity.done()
if !waitForRateLimitCooldown(ctx, &w.rateLimit, "preview", w.Drive) {
return
}
w.process(ctx, current)
w.process(ctx, v)
}
func (w *ThumbWorker) processQueued(ctx context.Context, v *catalog.Video) {
@@ -1427,17 +1396,11 @@ func (w *Worker) skipIfRateLimited(v *catalog.Video) bool {
}
func (w *Worker) pauseForRateLimit(err error, step, title string) bool {
wait, ok := drives.RateLimitRetryAfter(err)
_, ok := drives.RateLimitRetryAfter(err)
if !ok {
return false
}
if wait <= 0 {
wait = w.RateLimitCooldown
if wait <= 0 {
wait = defaultGenerationRateLimitCooldown
}
}
until := w.rateLimit.pause(time.Now(), wait)
until := w.rateLimit.pause(time.Now(), defaultGenerationRateLimitCooldown)
log.Printf("[preview] drive=%s rate-limited until=%s step=%s video=%s: %v", w.Drive.ID(), until.Format(time.RFC3339), step, title, err)
return true
}
@@ -1466,17 +1429,11 @@ func (w *ThumbWorker) skipIfRateLimited(v *catalog.Video) bool {
}
func (w *ThumbWorker) pauseForRateLimit(err error, step, title string) bool {
wait, ok := drives.RateLimitRetryAfter(err)
_, ok := drives.RateLimitRetryAfter(err)
if !ok {
return false
}
if wait <= 0 {
wait = w.RateLimitCooldown
if wait <= 0 {
wait = defaultGenerationRateLimitCooldown
}
}
until := w.rateLimit.pause(time.Now(), wait)
until := w.rateLimit.pause(time.Now(), defaultGenerationRateLimitCooldown)
log.Printf("[thumb] drive=%s rate-limited until=%s step=%s video=%s: %v", w.Drive.ID(), until.Format(time.RFC3339), step, title, err)
return true
}
@@ -1529,7 +1486,7 @@ func driveErrorShouldCooldown(d drives.Drive, err error) bool {
strings.Contains(text, "request has been blocked") ||
strings.Contains(text, "访问被阻断")
case "pikpak":
// PikPak 在预览视频 / 封面生成阶段(取链或拉直链字节)可能命中:
// PikPak 在 teaser / 封面生成阶段(取链或拉直链字节)可能命中:
// - error_code=10 操作频繁
// - HTTP 429 / 5xx / 509 限流和服务端不可用
// - 通用文本:rate limit / too many requests / blocked
@@ -1547,136 +1504,29 @@ func driveErrorShouldCooldown(d drives.Drive, err error) bool {
strings.Contains(text, "too many requests") ||
strings.Contains(text, "rate limit") ||
strings.Contains(text, "blocked") ||
strings.Contains(text, "moov atom not found") ||
strings.Contains(text, "partial file") ||
strings.Contains(text, "service unavailable")
case "p123":
// 123网盘直链解析 / ffmpeg 读取阶段可能返回 429、5xx,或 WAF 类
// blocked / 访问阻断文本。命中时冷却,避免封面和预览视频生成连续打接口。
text := strings.ToLower(err.Error())
return strings.Contains(text, "请求太频繁") ||
strings.Contains(text, "请求过于频繁") ||
strings.Contains(text, "请求频繁") ||
strings.Contains(text, "操作频繁") ||
strings.Contains(text, "频率限制") ||
strings.Contains(text, "请求次数过多") ||
strings.Contains(text, "429") ||
strings.Contains(text, "http 500") ||
strings.Contains(text, "http 502") ||
strings.Contains(text, "http 503") ||
strings.Contains(text, "http 504") ||
strings.Contains(text, "server returned 403") ||
strings.Contains(text, "403 forbidden") ||
strings.Contains(text, "too many request") ||
strings.Contains(text, "too many requests") ||
strings.Contains(text, "rate limit") ||
strings.Contains(text, "blocked") ||
strings.Contains(text, "访问被阻断") ||
strings.Contains(text, "service unavailable")
case "wopan":
// 联通网盘的取链接口和下载直链都可能返回"操作频繁"、429、5xx
// 或 WAF 阻断文本。封面/预览失败时先冷却,避免持续触发风控。
text := strings.ToLower(err.Error())
return strings.Contains(text, "请求太频繁") ||
strings.Contains(text, "请求过于频繁") ||
strings.Contains(text, "请求频繁") ||
strings.Contains(text, "操作频繁") ||
strings.Contains(text, "频率限制") ||
strings.Contains(text, "请求次数过多") ||
strings.Contains(text, "系统繁忙") ||
strings.Contains(text, "服务繁忙") ||
strings.Contains(text, "稍后再试") ||
strings.Contains(text, "稍后重试") ||
strings.Contains(text, "429") ||
strings.Contains(text, "http 500") ||
strings.Contains(text, "http 502") ||
strings.Contains(text, "http 503") ||
strings.Contains(text, "http 504") ||
strings.Contains(text, "http 509") ||
strings.Contains(text, "server returned 403") ||
strings.Contains(text, "403 forbidden") ||
strings.Contains(text, "server returned 429") ||
strings.Contains(text, "server returned 500") ||
strings.Contains(text, "server returned 502") ||
strings.Contains(text, "server returned 503") ||
strings.Contains(text, "server returned 504") ||
strings.Contains(text, "too many request") ||
strings.Contains(text, "too many requests") ||
strings.Contains(text, "rate limit") ||
strings.Contains(text, "rate-limit") ||
strings.Contains(text, "throttl") ||
strings.Contains(text, "blocked") ||
strings.Contains(text, "request has been blocked") ||
strings.Contains(text, "访问被阻断") ||
strings.Contains(text, "风控") ||
strings.Contains(text, "service unavailable")
case "googledrive":
// Google Drive 下载/取样阶段常把频控和配额问题包装成 403,
// 具体标识在 error.errors[].reason/message 里(OpenList 也按该结构解析)。
// ffmpeg/ffprobe 只能看到 stderr 文本时,按这些 reason/文本兜底冷却。
text := strings.ToLower(err.Error())
return googleDriveMediaErrorShouldCooldown(text)
}
return false
}
func googleDriveMediaErrorShouldCooldown(text string) bool {
if text == "" {
return false
}
compact := compactGoogleDriveErrorText(text)
return strings.Contains(text, "server returned 403") ||
strings.Contains(text, "403 forbidden") ||
strings.Contains(text, "server returned 429") ||
strings.Contains(text, "http 429") ||
strings.Contains(text, "http 500") ||
strings.Contains(text, "http 502") ||
strings.Contains(text, "http 503") ||
strings.Contains(text, "http 504") ||
strings.Contains(text, "too many request") ||
strings.Contains(text, "too many requests") ||
strings.Contains(text, "rate limit") ||
strings.Contains(text, "quota exceeded") ||
strings.Contains(text, "download quota") ||
strings.Contains(text, "sharing rate") ||
strings.Contains(text, "daily limit") ||
strings.Contains(text, "user rate") ||
strings.Contains(text, "usage limit") ||
strings.Contains(text, "service unavailable") ||
strings.Contains(compact, "ratelimitexceeded") ||
strings.Contains(compact, "userratelimitexceeded") ||
strings.Contains(compact, "dailylimitexceeded") ||
strings.Contains(compact, "downloadquotaexceeded") ||
strings.Contains(compact, "sharingratelimitexceeded") ||
strings.Contains(compact, "quotaexceeded") ||
strings.Contains(compact, "toomanyrequests") ||
strings.Contains(compact, "usagelimits")
}
func compactGoogleDriveErrorText(text string) string {
replacer := strings.NewReplacer("_", "", "-", "", " ", "", ".", "", ":", "")
return replacer.Replace(strings.ToLower(strings.TrimSpace(text)))
}
func (w *ThumbWorker) process(ctx context.Context, v *catalog.Video) bool {
if w.skipIfRateLimited(v) {
return false
}
if w.Catalog == nil || v == nil || v.ID == "" {
return false
}
queued := v
loaded, err := w.Catalog.GetVideo(ctx, v.ID)
if err != nil || loaded.Hidden {
return false
}
if loaded.PreviewLocal == "" {
loaded.PreviewLocal = queued.PreviewLocal
}
current := loaded
v = loaded
if loaded.ThumbnailURL != "" && loaded.DurationSeconds > 0 {
_ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{ThumbnailStatus: "ready"})
return false
current := v
if loaded, err := w.Catalog.GetVideo(ctx, v.ID); err == nil {
if loaded.PreviewLocal == "" {
loaded.PreviewLocal = queued.PreviewLocal
}
current = loaded
v = loaded
if loaded.ThumbnailURL != "" && loaded.DurationSeconds > 0 {
_ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{ThumbnailStatus: "ready"})
return false
}
}
if current.ThumbnailURL != "" {
durationBackfillFailed := false
@@ -1703,11 +1553,6 @@ func (w *ThumbWorker) process(ctx context.Context, v *catalog.Video) bool {
return false
}
_ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{ThumbnailStatus: "pending"})
if isSpider91OriginVideo(v) {
log.Printf("[thumb] skip %s: spider91-origin video must use crawled thumbnail", v.Title)
_ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{ThumbnailStatus: "failed"})
return false
}
link, err := w.streamLink(ctx, v)
if err != nil {
if w.pauseForRecoverableError(ctx, v, err, "streamURL") {
@@ -1773,26 +1618,17 @@ func (w *ThumbWorker) probeDuration(ctx context.Context, v *catalog.Video, link
}
func (w *ThumbWorker) generateThumbnailFromLink(ctx context.Context, v *catalog.Video, link *drives.StreamLink) error {
local, err := w.Gen.GenerateThumbnail(ctx, link, v.ID, float64(v.DurationSeconds))
if err != nil {
if _, err := w.Gen.GenerateThumbnail(ctx, link, v.ID, 0); err != nil {
return err
}
if err := w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{
_ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{
ThumbnailURL: "/p/thumb/" + v.ID,
ThumbnailStatus: "ready",
}); err != nil {
_ = os.Remove(local)
log.Printf("[thumb] update %s after generate: %v", v.Title, err)
return nil
}
})
log.Printf("[thumb] ready %s", v.Title)
return nil
}
func isSpider91OriginVideo(v *catalog.Video) bool {
return v != nil && strings.HasPrefix(v.ID, "spider91-")
}
func localPreviewLink(v *catalog.Video) (*drives.StreamLink, bool) {
if v.PreviewLocal == "" {
return nil, false
@@ -1841,7 +1677,7 @@ func (w *Worker) process(ctx context.Context, v *catalog.Video) {
}
}
// 2) 预览视频
// 2) teaser
tmp, err := w.generateTeaser(ctx, v, link, duration)
if err != nil {
if w.pauseForRecoverableError(err, "generate", v.Title) {
@@ -1859,11 +1695,7 @@ func (w *Worker) process(ctx context.Context, v *catalog.Video) {
}
removePreviousLocalTeaser(v.PreviewLocal, local)
if err := w.Catalog.UpdatePreview(ctx, v.ID, local, "ready"); err != nil {
removePreviousLocalTeaser(local, "")
log.Printf("[preview] update %s after generate: %v", v.Title, err)
return
}
w.Catalog.UpdatePreview(ctx, v.ID, local, "ready")
log.Printf("[preview] ready %s (duration=%.1fs)", v.Title, duration)
}
+9 -22
View File
@@ -168,29 +168,16 @@ func TestMediumAndLongVideosStillRequirePlannedTeaserSegments(t *testing.T) {
}
}
func TestThumbnailOffsetsPreferMiddleFrame(t *testing.T) {
tests := []struct {
name string
duration float64
want []float64
}{
{name: "unknown duration", duration: 0, want: []float64{5, 1, 0}},
{name: "long video", duration: 2804.9, want: []float64{1402.45, 5, 1, 0}},
{name: "short video", duration: 8.9, want: []float64{4.45, 5, 1, 0}},
{name: "middle equals fallback", duration: 10, want: []float64{5, 1, 0}},
func TestThumbnailOffsetsUseFiveSecondsWithEarlyFallbacks(t *testing.T) {
got := thumbnailOffsets()
want := []float64{5, 1, 0}
if len(got) != len(want) {
t.Fatalf("offsets = %#v, want %#v", got, want)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := thumbnailOffsets(tt.duration)
if len(got) != len(tt.want) {
t.Fatalf("offsets = %#v, want %#v", got, tt.want)
}
for i := range tt.want {
if math.Abs(got[i]-tt.want[i]) > 0.001 {
t.Fatalf("offset[%d] = %.2f, want %.2f", i, got[i], tt.want[i])
}
}
})
for i := range want {
if got[i] != want[i] {
t.Fatalf("offset[%d] = %.2f, want %.2f", i, got[i], want[i])
}
}
}
+5 -114
View File
@@ -39,8 +39,8 @@ func TestThumbWorkerUpdatesThumbnailAndDurationWithoutChangingPreviewStatus(t *t
if gen.thumbnailVideoID != video.ID {
t.Fatalf("thumbnail video id = %q, want %q", gen.thumbnailVideoID, video.ID)
}
if gen.thumbnailDuration != 42 {
t.Fatalf("thumbnail duration = %.1f, want probed duration", gen.thumbnailDuration)
if gen.thumbnailDuration != 0 {
t.Fatalf("thumbnail duration = %.1f, want fixed-offset thumbnail generation", gen.thumbnailDuration)
}
if gen.probeCalls != 1 {
t.Fatalf("probe calls = %d, want 1 for thumbnail generation", gen.probeCalls)
@@ -89,35 +89,6 @@ func TestThumbWorkerBackfillsDurationWhenThumbnailAlreadyExists(t *testing.T) {
}
}
func TestThumbWorkerDoesNotGenerateThumbnailForSpider91OriginVideo(t *testing.T) {
ctx := context.Background()
cat, video := seedPreviewTestVideo(t, "spider91-91-spider-1200001")
gen := &fakeThumbGenerator{probeDuration: 42}
drv := &previewFakeDrive{kind: "pikpak"}
worker := NewThumbWorker(gen, cat, drv)
worker.process(ctx, video)
got, err := cat.GetVideo(ctx, video.ID)
if err != nil {
t.Fatalf("get video: %v", err)
}
if got.ThumbnailURL != "" {
t.Fatalf("thumbnail = %q, want empty when crawled spider91 thumbnail is missing", got.ThumbnailURL)
}
failed, err := cat.ListVideosByThumbnailStatus(ctx, video.DriveID, "failed", 0)
if err != nil {
t.Fatalf("list failed thumbnails: %v", err)
}
if len(failed) != 1 || failed[0].ID != video.ID {
t.Fatalf("failed thumbnails = %#v, want only %s", failed, video.ID)
}
if gen.probeCalls != 0 || gen.generateCalls != 0 {
t.Fatalf("generator calls probe=%d generate=%d, want no ffmpeg work for spider91-origin thumbnail", gen.probeCalls, gen.generateCalls)
}
}
func TestThumbWorkerSkipsDurationBackfillWhenExistingThumbnailCannotBeProbed(t *testing.T) {
ctx := context.Background()
cat, video := seedPreviewTestVideo(t, "thumb-worker-existing-thumbnail-probe-fails")
@@ -442,7 +413,7 @@ func TestPreviewWorkerRateLimitLeavesCurrentPendingAndSkipsNextVideo(t *testing.
if gen.generateCalls != 1 {
t.Fatalf("generate calls = %d, want 1", gen.generateCalls)
}
assertCooldownAround(t, worker.Status().CooldownUntil, before, 2*time.Hour)
assertCooldownAround(t, worker.Status().CooldownUntil, before, 5*time.Minute)
gen.generateErr = nil
worker.process(ctx, &second)
@@ -458,7 +429,7 @@ func TestPreviewWorkerRateLimitLeavesCurrentPendingAndSkipsNextVideo(t *testing.
}
}
func TestThumbWorkerRateLimitHonorsRetryAfter(t *testing.T) {
func TestThumbWorkerRateLimitCoolsDownFiveMinutes(t *testing.T) {
ctx := context.Background()
cat, video := seedPreviewTestVideo(t, "thumb-rate-limit")
@@ -482,7 +453,7 @@ func TestThumbWorkerRateLimitHonorsRetryAfter(t *testing.T) {
if got.ThumbnailURL != "" {
t.Fatalf("thumbnail = %q, want unchanged after rate limit", got.ThumbnailURL)
}
assertCooldownAround(t, worker.Status().CooldownUntil, before, 2*time.Hour)
assertCooldownAround(t, worker.Status().CooldownUntil, before, 5*time.Minute)
}
func TestThumbWorkerP115TransientErrorFailsAfterRetryLimit(t *testing.T) {
@@ -592,35 +563,6 @@ func TestThumbWorkerRequeuesP115TransientErrorBeforeRetryLimit(t *testing.T) {
}
}
func TestThumbWorkerPikPakMoovAtomErrorFailsWithoutCooldown(t *testing.T) {
ctx := context.Background()
cat, video := seedPreviewTestVideo(t, "thumb-pikpak-missing-moov")
mediaErr := errors.New("ffprobe: exit status 1, stderr: moov atom not found Invalid data found when processing input")
gen := &fakeThumbGenerator{
probeErr: mediaErr,
generateErr: mediaErr,
}
drv := &previewFakeDrive{kind: "pikpak"}
worker := NewThumbWorker(gen, cat, drv)
worker.process(ctx, video)
failed, err := cat.ListVideosByThumbnailStatus(ctx, video.DriveID, "failed", 0)
if err != nil {
t.Fatalf("list failed thumbnails: %v", err)
}
if len(failed) != 1 || failed[0].ID != video.ID {
t.Fatalf("failed thumbnails = %#v, want only %s", failed, video.ID)
}
if !worker.Status().CooldownUntil.IsZero() {
t.Fatalf("cooldown until = %s, want no cooldown for invalid PikPak MP4", worker.Status().CooldownUntil)
}
if gen.generateCalls != 1 {
t.Fatalf("generate calls = %d, want 1", gen.generateCalls)
}
}
func TestPreviewWorkerP115TransientErrorKeepsVideoPending(t *testing.T) {
ctx := context.Background()
cat, video := seedPreviewTestVideo(t, "preview-p115-transient")
@@ -645,57 +587,6 @@ func TestPreviewWorkerP115TransientErrorKeepsVideoPending(t *testing.T) {
}
}
func TestP123TransientErrorsShouldCooldown(t *testing.T) {
drv := &previewFakeDrive{kind: "p123"}
for _, err := range []error{
errors.New("Server returned 403 Forbidden"),
errors.New("请求太频繁"),
errors.New("http 503 service unavailable"),
} {
if !driveErrorShouldCooldown(drv, err) {
t.Fatalf("driveErrorShouldCooldown(%v) = false, want true", err)
}
}
if driveErrorShouldCooldown(drv, errors.New("invalid credential")) {
t.Fatal("invalid credential should not trigger p123 cooldown")
}
}
func TestWopanTransientErrorsShouldCooldown(t *testing.T) {
drv := &previewFakeDrive{kind: "wopan"}
for _, err := range []error{
errors.New("ffmpeg: Server returned 403 Forbidden"),
errors.New("wopan download url: request failed with status: 429 Too Many Requests"),
errors.New("操作频繁,请稍后重试"),
errors.New("http 503 service unavailable"),
} {
if !driveErrorShouldCooldown(drv, err) {
t.Fatalf("driveErrorShouldCooldown(%v) = false, want true", err)
}
}
if driveErrorShouldCooldown(drv, errors.New("invalid access token")) {
t.Fatal("invalid access token should not trigger wopan cooldown")
}
}
func TestGoogleDriveMediaErrorsShouldCooldown(t *testing.T) {
drv := &previewFakeDrive{kind: "googledrive"}
for _, err := range []error{
errors.New("google drive api error: usageLimits userRateLimitExceeded"),
errors.New("ffmpeg: Server returned 403 Forbidden"),
errors.New("downloadQuotaExceeded: The download quota for this file has been exceeded"),
errors.New("sharingRateLimitExceeded"),
errors.New("http 503 service unavailable"),
} {
if !driveErrorShouldCooldown(drv, err) {
t.Fatalf("driveErrorShouldCooldown(%v) = false, want true", err)
}
}
if driveErrorShouldCooldown(drv, errors.New("invalid credentials")) {
t.Fatal("invalid credentials should not trigger googledrive cooldown")
}
}
func assertCooldownAround(t *testing.T, until time.Time, before time.Time, want time.Duration) {
t.Helper()
if until.IsZero() {
+3 -7
View File
@@ -147,17 +147,13 @@ func (p *Proxy) ServeStream(w http.ResponseWriter, r *http.Request, driveID, fil
// CDN 不校验请求头,直连可获得最佳带宽并避免占用 backend 出站
// - onedriveMicrosoft Graph 返回的 @microsoft.graph.downloadUrl 是短期
// 免鉴权下载 URL,不需要后端继续代传视频字节
// - p123123网盘 download_info 返回的下载页会再跳 CDNdriver 已在后端
// 先解出最终 Location,浏览器可直接 302 到该短期地址
// - wopan:联通网盘 GetDownloadUrlV2 返回的是短期直链,OpenList 也是直接
// 将该 URL 交给客户端使用;不需要后端持续代传视频字节
//
// 其余网盘(如夸克等)仍走反代,因为它们的下载
// 其余网盘(如沃盘 / 夸克等)仍走反代,因为它们的下载
// 链接通常需要随请求带上后端持有的 Cookie / Authorization / Range
// 的特殊处理,浏览器拿不到这些上下文。
func shouldRedirect(d drives.Drive) bool {
switch d.Kind() {
case "p115", "pikpak", "onedrive", "p123", "wopan":
case "p115", "pikpak", "onedrive":
return true
}
return false
@@ -218,7 +214,7 @@ func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, link *drives.Strea
_, _ = io.Copy(w, resp.Body)
}
// ServeLocal 服务本地预览视频文件
// ServeLocal 服务本地 teaser 文件
func (p *Proxy) ServeLocal(w http.ResponseWriter, r *http.Request, path string) {
http.ServeFile(w, r, path)
}
-50
View File
@@ -176,56 +176,6 @@ func TestServeStreamRedirectsOneDrive(t *testing.T) {
}
}
func TestServeStreamRedirectsP123(t *testing.T) {
reg := NewRegistry()
drv := &proxyFakeSimpleDrive{
kind: "p123",
url: "https://cdn.123pan.example/video.mp4",
}
reg.Set("p123", drv)
p := New(reg)
req := httptest.NewRequest(http.MethodGet, "/p/stream/p123/file-1", nil)
rr := httptest.NewRecorder()
p.ServeStream(rr, req, "p123", "file-1")
if rr.Code != http.StatusFound {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusFound)
}
if got := rr.Header().Get("Location"); got != "https://cdn.123pan.example/video.mp4" {
t.Fatalf("Location = %q", got)
}
if drv.calls != 1 {
t.Fatalf("link calls = %d, want 1", drv.calls)
}
}
func TestServeStreamRedirectsWopan(t *testing.T) {
reg := NewRegistry()
drv := &proxyFakeSimpleDrive{
kind: "wopan",
url: "https://du.smartont.net:8445/openapi/download?fid=encoded",
}
reg.Set("wopan", drv)
p := New(reg)
req := httptest.NewRequest(http.MethodGet, "/p/stream/wopan/file-1", nil)
rr := httptest.NewRecorder()
p.ServeStream(rr, req, "wopan", "file-1")
if rr.Code != http.StatusFound {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusFound)
}
if got := rr.Header().Get("Location"); got != "https://du.smartont.net:8445/openapi/download?fid=encoded" {
t.Fatalf("Location = %q", got)
}
if drv.calls != 1 {
t.Fatalf("link calls = %d, want 1", drv.calls)
}
}
func TestServeStreamServesLocalFilePath(t *testing.T) {
path := filepath.Join(t.TempDir(), "video.mp4")
if err := os.WriteFile(path, []byte("0123456789"), 0o644); err != nil {
+18 -64
View File
@@ -2,7 +2,6 @@ package scanner
import (
"context"
"encoding/base64"
"fmt"
"log"
"path"
@@ -24,10 +23,8 @@ type Scanner struct {
//
// nil / 空集合 → 行为等同于不跳过任何目录。
SkipDirIDs map[string]struct{}
// 回调:新视频被加入后触发预览视频生成
// 回调:新视频被加入后触发 teaser 生成
OnNewVideo func(v *catalog.Video)
// OnProgress 在扫描进度变化时触发。回调只应读取 Stats 里的计数,不应修改 map 字段。
OnProgress func(stats Stats)
// ProgressInterval 控制扫描内部 heartbeat 的最小输出间隔。
// 0 → 默认 30s< 0 → 关闭 heartbeat(仅留外层 start / done 两行)。
// heartbeat 单行格式:
@@ -94,9 +91,6 @@ func (s *Scanner) Run(ctx context.Context, startDirID string) (Stats, error) {
driveID = s.Drive.ID()
}
progress := func(currentDir string) {
if s.OnProgress != nil {
s.OnProgress(stats)
}
if interval < 0 {
return
}
@@ -133,11 +127,8 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
}
for _, e := range entries {
if err := ctx.Err(); err != nil {
return err
}
if e.IsDir {
// 跳过 previews 目录,避免扫到自己生成的预览视频
// 跳过 previews 目录,避免扫到自己生成的 teaser
if strings.EqualFold(e.Name, "previews") {
continue
}
@@ -146,15 +137,13 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
continue
}
if err := s.walk(ctx, e.ID, e.Name, stats, progress); err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
stats.Errors++
log.Printf("[scanner] walk %s error: %v", e.Name, err)
}
continue
}
stats.Scanned++
ext := strings.ToLower(path.Ext(e.Name))
if !s.Exts[ext] {
continue
@@ -162,22 +151,9 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
if e.Size <= 0 {
continue
}
stats.Scanned++
progress(dirName)
stats.SeenFileIDs[e.ID] = struct{}{}
id := s.Drive.Kind() + "-" + s.Drive.ID() + "-" + videoIDFilePart(e.ID)
if deleted, err := s.Catalog.IsDeletedVideoCandidate(ctx, id, s.Drive.ID(), e.ID, e.Hash, e.Name, e.Size); err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
stats.Errors++
log.Printf("[scanner] check deleted video %s error: %v", id, err)
continue
} else if deleted {
continue
}
id := s.Drive.Kind() + "-" + s.Drive.ID() + "-" + e.ID
parsed := Parse(e.Name)
if parsed.Title == "" {
parsed.Title = strings.TrimSuffix(e.Name, ext)
@@ -186,20 +162,11 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
if matched, err := s.Catalog.MatchTags(ctx, e.Name+" "+dirName+" "+parsed.Author); err == nil {
tags = mergeTags(tags, matched)
}
if err := ctx.Err(); err != nil {
return err
}
if label, ok, err := s.Catalog.EnsureCollectionTag(ctx, dirName); err == nil && ok {
tags = mergeTags(tags, []string{label})
}
if err := ctx.Err(); err != nil {
return err
}
existing, _ := s.Catalog.GetVideo(ctx, id)
if err := ctx.Err(); err != nil {
return err
}
if existing != nil {
patch := catalog.VideoMetaPatch{}
if e.Hash != "" && existing.ContentHash == "" {
@@ -214,33 +181,26 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
if existing.Category == "" && dirName != "" {
patch.Category = dirName
}
if patch.Category != "" || patch.ContentHash != "" || patch.FileName != "" {
if existing.ThumbnailURL == "" && e.ThumbnailURL != "" {
patch.ThumbnailURL = e.ThumbnailURL
}
if patch.Category != "" || patch.ThumbnailURL != "" || patch.ContentHash != "" || patch.FileName != "" {
_ = s.Catalog.UpdateVideoMeta(ctx, id, patch)
if err := ctx.Err(); err != nil {
return err
}
}
if dup := s.findDuplicate(ctx, e.Hash, e.Name, e.Size, id); dup != nil {
s.backfillDuplicateThumbnail(ctx, dup, e.ThumbnailURL)
continue
}
if err := ctx.Err(); err != nil {
return err
}
if !sameTags(existing.Tags, tags) {
_ = s.Catalog.SetAutoVideoTags(ctx, id, tags)
if err := ctx.Err(); err != nil {
return err
}
}
continue
}
if dup := s.findDuplicate(ctx, e.Hash, e.Name, e.Size, id); dup != nil {
s.backfillDuplicateThumbnail(ctx, dup, e.ThumbnailURL)
continue
}
if err := ctx.Err(); err != nil {
return err
}
now := time.Now()
v := &catalog.Video{
@@ -256,6 +216,7 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
Ext: strings.TrimPrefix(ext, "."),
Quality: "HD",
Size: e.Size,
ThumbnailURL: e.ThumbnailURL,
PreviewStatus: "pending",
Category: dirName,
PublishedAt: now,
@@ -263,17 +224,10 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
UpdatedAt: now,
}
if err := s.Catalog.UpsertVideo(ctx, v); err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
log.Printf("[scanner] upsert %s error: %v", v.Title, err)
continue
}
if err := ctx.Err(); err != nil {
return err
}
stats.Added++
progress(dirName)
if s.OnNewVideo != nil {
s.OnNewVideo(v)
}
@@ -314,6 +268,13 @@ func (s *Scanner) findDuplicateByFileSignature(ctx context.Context, fileName str
return dup
}
func (s *Scanner) backfillDuplicateThumbnail(ctx context.Context, canonical *catalog.Video, thumbnailURL string) {
if canonical.ThumbnailURL != "" || thumbnailURL == "" {
return
}
_ = s.Catalog.UpdateVideoMeta(ctx, canonical.ID, catalog.VideoMetaPatch{ThumbnailURL: thumbnailURL})
}
func sameTags(a, b []string) bool {
if len(a) != len(b) {
return false
@@ -340,10 +301,3 @@ func mergeTags(lists ...[]string) []string {
}
return out
}
func videoIDFilePart(fileID string) string {
if !strings.ContainsAny(fileID, `/\`+"\x00") {
return fileID
}
return "b64_" + base64.RawURLEncoding.EncodeToString([]byte(fileID))
}
+6 -184
View File
@@ -3,7 +3,6 @@ package scanner
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"log"
@@ -15,7 +14,7 @@ import (
"github.com/video-site/backend/internal/drives"
)
func TestRunIgnoresRemoteThumbnailFromDriveEntry(t *testing.T) {
func TestRunPersistsRemoteThumbnailFromDriveEntry(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
if err != nil {
@@ -51,8 +50,8 @@ func TestRunIgnoresRemoteThumbnailFromDriveEntry(t *testing.T) {
if err != nil {
t.Fatalf("get video: %v", err)
}
if got.ThumbnailURL != "" {
t.Fatalf("thumbnail = %q, want empty so local thumbnail worker regenerates it", got.ThumbnailURL)
if got.ThumbnailURL != "https://thumbnail.example/clip.jpg" {
t.Fatalf("thumbnail = %q, want remote thumbnail", got.ThumbnailURL)
}
}
@@ -91,184 +90,7 @@ func TestRunIgnoresZeroSizeVideoFiles(t *testing.T) {
}
}
func TestRunScannedCountsOnlyVideoCandidates(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)
}
})
drv := &scannerFakeDrive{
entries: []drives.Entry{
{ID: "file-1", Name: "clip.mp4", Size: 123},
{ID: "file-2", Name: "notes.txt", Size: 123},
{ID: "file-3", Name: "empty.mp4", Size: 0},
},
}
sc := New(cat, drv, []string{".mp4"}, nil, nil)
stats, err := sc.Run(ctx, "")
if err != nil {
t.Fatalf("scan: %v", err)
}
if stats.Scanned != 1 {
t.Fatalf("scanned = %d, want one non-empty video candidate", stats.Scanned)
}
if stats.Added != 1 {
t.Fatalf("added = %d, want one added video", stats.Added)
}
}
func TestRunUsesPathSafeVideoIDForUnsafeFileID(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)
}
})
drv := &scannerFakeDrive{
entries: []drives.Entry{{
ID: "fid/with space",
Name: "clip.mp4",
Size: 123,
}},
}
sc := New(cat, drv, []string{".mp4"}, nil, nil)
stats, err := sc.Run(ctx, "")
if err != nil {
t.Fatalf("scan: %v", err)
}
if stats.Added != 1 {
t.Fatalf("added = %d, want 1", stats.Added)
}
if _, ok := stats.SeenFileIDs["fid/with space"]; !ok {
t.Fatalf("seen file ids = %#v, want original file id", stats.SeenFileIDs)
}
wantID := "fake-drive-b64_ZmlkL3dpdGggc3BhY2U"
got, err := cat.GetVideo(ctx, wantID)
if err != nil {
t.Fatalf("get video %s: %v", wantID, err)
}
if strings.Contains(got.ID, "/") {
t.Fatalf("video id = %q, must not contain slash", got.ID)
}
if got.FileID != "fid/with space" {
t.Fatalf("file id = %q, want original", got.FileID)
}
}
func TestRunStopsWhenContextCanceledDuringFileLoop(t *testing.T) {
ctx, cancel := context.WithCancel(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)
}
})
drv := &scannerFakeDrive{
entries: []drives.Entry{
{ID: "file-1", Name: "one.mp4", Size: 123},
{ID: "file-2", Name: "two.mp4", Size: 123},
{ID: "file-3", Name: "three.mp4", Size: 123},
},
}
callbacks := 0
sc := New(cat, drv, []string{".mp4"}, nil, func(*catalog.Video) {
callbacks++
cancel()
})
stats, err := sc.Run(ctx, "")
if !errors.Is(err, context.Canceled) {
t.Fatalf("scan error = %v, want context.Canceled", err)
}
if stats.Added != 1 || callbacks != 1 {
t.Fatalf("added=%d callbacks=%d, want exactly one video before cancellation", stats.Added, callbacks)
}
if _, err := cat.GetVideo(context.Background(), "fake-drive-file-1"); err != nil {
t.Fatalf("first video should be persisted before cancellation: %v", err)
}
if _, err := cat.GetVideo(context.Background(), "fake-drive-file-2"); err != sql.ErrNoRows {
t.Fatalf("second video lookup error = %v, want sql.ErrNoRows", err)
}
if _, err := cat.GetVideo(context.Background(), "fake-drive-file-3"); err != sql.ErrNoRows {
t.Fatalf("third video lookup error = %v, want sql.ErrNoRows", err)
}
}
func TestRunSkipsAdminDeletedVideo(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: "fake-drive-file-1",
DriveID: "drive",
FileID: "file-1",
FileName: "clip.mp4",
ContentHash: "HASH-1",
Title: "Deleted Clip",
Size: 123,
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("seed video: %v", err)
}
if err := cat.DeleteVideoWithTombstone(ctx, "fake-drive-file-1"); err != nil {
t.Fatalf("delete with tombstone: %v", err)
}
drv := &scannerFakeDrive{
entries: []drives.Entry{{
ID: "file-1",
Name: "clip.mp4",
Size: 123,
Hash: "hash-1",
MimeType: "video/mp4",
ModTime: now,
}},
}
sc := New(cat, drv, []string{".mp4"}, nil, nil)
stats, err := sc.Run(ctx, "")
if err != nil {
t.Fatalf("scan: %v", err)
}
if stats.Added != 0 {
t.Fatalf("added = %d, want 0", stats.Added)
}
if _, err := cat.GetVideo(ctx, "fake-drive-file-1"); err != sql.ErrNoRows {
t.Fatalf("deleted video was recreated, get error = %v", err)
}
}
func TestRunDoesNotBackfillRemoteThumbnailForExistingVideo(t *testing.T) {
func TestRunBackfillsRemoteThumbnailForExistingVideo(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
if err != nil {
@@ -318,8 +140,8 @@ func TestRunDoesNotBackfillRemoteThumbnailForExistingVideo(t *testing.T) {
if err != nil {
t.Fatalf("get video: %v", err)
}
if got.ThumbnailURL != "" {
t.Fatalf("thumbnail = %q, want empty so local thumbnail worker regenerates it", got.ThumbnailURL)
if got.ThumbnailURL != "https://thumbnail.example/backfilled.jpg" {
t.Fatalf("thumbnail = %q, want backfilled remote thumbnail", got.ThumbnailURL)
}
}
+83 -589
View File
@@ -1,11 +1,10 @@
// Package spider91migrate 周期性把 spider91 drive 下载到本地的视频
// 上传到一个指定的目标 drive 目录(PikPak、115、123、OneDrive、Google Drive 或联通网盘),上传成功后:
// 上传到一个指定的目标 drive 目录(PikPak、115OneDrive),上传成功后:
//
// - 改写 catalog 行:drive_id / file_id / content_hash 改成目标盘的;
// 视频自身的 id 不变(仍是 spider91-<driveID>-<viewkey>),video_tags、
// 收藏、点赞、views 等关联数据全部保留
// - 删除本地 mp4spider91/<id>/videos/<viewkey>.<ext>)和 thumb
// spider91/<id>/thumbs/<viewkey>.jpg);公共 /p/thumb/<videoID> 副本会保留
// - 删除本地 mp4spider91/<id>/videos/<viewkey>.<ext>)和 thumbspider91/<id>/thumbs/<viewkey>.jpg
//
// 之后回放时,videoSource() 自动落到 /p/stream/<target>/<file_id>
// proxy 层走对应盘的直链 / 302 直连。
@@ -16,7 +15,6 @@ package spider91migrate
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
@@ -30,27 +28,19 @@ import (
"github.com/video-site/backend/internal/catalog"
"github.com/video-site/backend/internal/drives"
"github.com/video-site/backend/internal/drives/googledrive"
"github.com/video-site/backend/internal/drives/onedrive"
"github.com/video-site/backend/internal/drives/p115"
"github.com/video-site/backend/internal/drives/p123"
"github.com/video-site/backend/internal/drives/pikpak"
"github.com/video-site/backend/internal/drives/scriptcrawler"
"github.com/video-site/backend/internal/drives/spider91"
"github.com/video-site/backend/internal/drives/wopan"
"github.com/video-site/backend/internal/mediaasset"
)
// uploadTarget 是 migrator 调用目标 drive 的最小接口。任何一种"接收 spider91 上传"的
// 网盘都要实现它;当前 PikPak、115、123、OneDrive、Google Drive 和联通网盘各自通过适配器满足。
// 网盘都要实现它;当前 PikPak 和 115 各自通过适配器满足。
//
// 这一层抽象把"迁移调用方"和"具体盘的 SDK 协议"解耦:
// - PikPak 走 GCID + OSS PutObjectpikpak.UploadResult
// - 115 走 SHA1 + 秒传 / OSS / 分片(p115.UploadResult
// - 123 走 MD5 + 秒传 / S3 预签名分片(p123.UploadResult
// - OneDrive 走 SHA1 + 小文件 PUT / 大文件 upload session
// - Google Drive 走 MD5 + resumable upload session
// - 联通网盘 走 SDK Upload2C,当前上游不返回内容 hash
//
// 各家返回值都被归一成本地的 UploadResult,并在 catalog 改写阶段统一处理。
type uploadTarget interface {
@@ -62,21 +52,10 @@ type uploadTarget interface {
Rename(ctx context.Context, fileID, newName string) error
}
// Spider91LocalSource is the local source interface used by the migration
// worker. Legacy spider91.Driver and the new scriptcrawler.Driver both satisfy
// it when they are mounted for the Spider91 built-in crawler.
type Spider91LocalSource interface {
drives.Drive
VideosDir() string
ThumbsDir() string
VideoPath(fileID string) (string, error)
ThumbPath(fileID string) (string, error)
}
// UploadResult 是 uploadTarget.UploadAndReportHash 的归一返回。
//
// FileID 目标盘上的新文件 ID;
// Hash GCIDPikPak、MD5 HEX123 / Google Drive或 SHA1 HEX115 / OneDrive),写入 catalog.content_hash 用于跨盘去重;联通网盘暂为空;
// Hash GCIDPikPak)或 SHA1 HEX115 / OneDrive),写入 catalog.content_hash 用于跨盘去重;
// Size 实际上传字节数。
type UploadResult struct {
FileID string
@@ -84,33 +63,9 @@ type UploadResult struct {
Size int64
}
type UploadProgress struct {
DriveID string
State string
CurrentTitle string
QueueLength int
DoneCount int
TotalCount int
}
const spider91UploadDirName = "91 Spider"
const (
spider91UploadDirName = "91 Spider"
scriptCrawlerUploadRootDirName = "Script Crawlers"
)
type migrationPlan struct {
source Spider91LocalSource
row *catalog.Drive
sourceKinds []string
targetDriveID string
target uploadTarget
uploadDir string
keepLatestN int
requireAssetsReady bool
legacyBackfill bool
}
// pikpakAdapter / p115Adapter / p123Adapter / onedriveAdapter / googledriveAdapter / wopanAdapter 把具体 driver 包装成 uploadTarget。
// pikpakAdapter / p115Adapter / onedriveAdapter 把具体 driver 包装成 uploadTarget。
//
// 之所以不让 driver 直接实现 uploadTarget
//
@@ -159,27 +114,6 @@ func (a *p115Adapter) Rename(ctx context.Context, fileID, newName string) error
return a.d.Rename(ctx, fileID, newName)
}
type p123Adapter struct {
d *p123.Driver
}
func (a *p123Adapter) ID() string { return a.d.ID() }
func (a *p123Adapter) Kind() string { return a.d.Kind() }
func (a *p123Adapter) RootID() string { return a.d.RootID() }
func (a *p123Adapter) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) {
return a.d.EnsureDir(ctx, pathFromRoot)
}
func (a *p123Adapter) UploadAndReportHash(ctx context.Context, parentID, name string, r io.Reader, size int64) (UploadResult, error) {
res, err := a.d.UploadAndReportHash(ctx, parentID, name, r, size)
if err != nil {
return UploadResult{}, err
}
return UploadResult{FileID: res.FileID, Hash: res.Hash, Size: res.Size}, nil
}
func (a *p123Adapter) Rename(ctx context.Context, fileID, newName string) error {
return a.d.Rename(ctx, fileID, newName)
}
type onedriveAdapter struct {
d *onedrive.Driver
}
@@ -201,48 +135,6 @@ func (a *onedriveAdapter) Rename(ctx context.Context, fileID, newName string) er
return a.d.Rename(ctx, fileID, newName)
}
type googledriveAdapter struct {
d *googledrive.Driver
}
func (a *googledriveAdapter) ID() string { return a.d.ID() }
func (a *googledriveAdapter) Kind() string { return a.d.Kind() }
func (a *googledriveAdapter) RootID() string { return a.d.RootID() }
func (a *googledriveAdapter) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) {
return a.d.EnsureDir(ctx, pathFromRoot)
}
func (a *googledriveAdapter) UploadAndReportHash(ctx context.Context, parentID, name string, r io.Reader, size int64) (UploadResult, error) {
res, err := a.d.UploadAndReportHash(ctx, parentID, name, r, size)
if err != nil {
return UploadResult{}, err
}
return UploadResult{FileID: res.FileID, Hash: res.Hash, Size: res.Size}, nil
}
func (a *googledriveAdapter) Rename(ctx context.Context, fileID, newName string) error {
return a.d.Rename(ctx, fileID, newName)
}
type wopanAdapter struct {
d *wopan.Driver
}
func (a *wopanAdapter) ID() string { return a.d.ID() }
func (a *wopanAdapter) Kind() string { return a.d.Kind() }
func (a *wopanAdapter) RootID() string { return a.d.RootID() }
func (a *wopanAdapter) EnsureDir(ctx context.Context, pathFromRoot string) (string, error) {
return a.d.EnsureDir(ctx, pathFromRoot)
}
func (a *wopanAdapter) UploadAndReportHash(ctx context.Context, parentID, name string, r io.Reader, size int64) (UploadResult, error) {
fileID, err := a.d.Upload(ctx, parentID, name, r, size)
if err != nil {
return UploadResult{}, err
}
return UploadResult{FileID: fileID, Size: size}, nil
}
func (a *wopanAdapter) Rename(ctx context.Context, fileID, newName string) error {
return a.d.Rename(ctx, fileID, newName)
}
// adaptUploadTarget 把通用 drive 包装成 uploadTarget。
// 不支持的盘 kind 返回 error;调用方静默跳过。
func adaptUploadTarget(d drives.Drive) (uploadTarget, error) {
@@ -251,14 +143,8 @@ func adaptUploadTarget(d drives.Drive) (uploadTarget, error) {
return &pikpakAdapter{d: v}, nil
case *p115.Driver:
return &p115Adapter{d: v}, nil
case *p123.Driver:
return &p123Adapter{d: v}, nil
case *onedrive.Driver:
return &onedriveAdapter{d: v}, nil
case *googledrive.Driver:
return &googledriveAdapter{d: v}, nil
case *wopan.Driver:
return &wopanAdapter{d: v}, nil
case uploadTarget:
// 测试或自定义实现可以直接传入;优先使用具体类型分支以拿到适配器。
return v, nil
@@ -288,10 +174,8 @@ type Config struct {
// CaptchaCooldown 是迁移 worker 在遇到 PikPak captcha 错误(error_code
// 4002 / 9)后整体进入冷却的时长。冷却期间 runOnce 直接返回,不再发起任何
// PikPak API 请求,避免被进一步风控。0 时默认 5 分钟;< 0 关闭冷却(仅用于测试)。
CaptchaCooldown time.Duration
CommonThumbDir string
OnMigrated func(videoID string)
OnUploadProgress func(UploadProgress)
CaptchaCooldown time.Duration
OnMigrated func(videoID string)
}
type Migrator struct {
@@ -420,79 +304,59 @@ func (m *Migrator) runOnce(ctx context.Context) {
log.Printf("[spider91migrate] captcha cooldown ended at %s, resuming migration", until.Format(time.RFC3339))
}
plans := m.migrationPlans(ctx)
if len(plans) == 0 {
// 没目标就静默 —— 用户选择了本地保存,或目标盘还没挂载
target, pp, err := m.resolveTarget()
if err != nil {
// 没目标就静默 —— 用户选择了本地保存,或还没配 115/PikPak drive
return
}
migrated := 0
backfillTargets := map[string]uploadTarget{}
for _, plan := range plans {
for _, src := range m.spider91Drives() {
if err := ctx.Err(); err != nil {
return
}
n, err := m.migrateDrive(ctx, plan)
n, err := m.migrateDrive(ctx, src, target, pp)
if err != nil {
log.Printf("[spider91migrate] drive=%s migrate batch error: %v", plan.source.ID(), err)
log.Printf("[spider91migrate] drive=%s migrate batch error: %v", src.ID(), err)
}
migrated += n
if active, _ := m.inCooldown(); active {
if migrated > 0 {
log.Printf("[spider91migrate] migrated %d video(s)", migrated)
log.Printf("[spider91migrate] migrated %d video(s) to drive=%s", migrated, target)
}
return
}
if plan.legacyBackfill {
backfillTargets[plan.targetDriveID] = plan.target
}
}
if migrated > 0 {
log.Printf("[spider91migrate] migrated %d video(s)", migrated)
log.Printf("[spider91migrate] migrated %d video(s) to drive=%s", migrated, target)
}
// 收尾:扫每个本地爬虫 drive 的 videos 目录,把 catalog 已经迁到别处但本地
// 收尾:扫每个 spider91 drive 的本地目录,把 catalog 已经迁到别处但本地
// 仍有残留的孤儿文件清掉。这是纯防御性兜底——正常路径下 migrateDrive
// 已经在迁移成功后立刻 CleanupSpider91Local,不会留孤儿。
for _, plan := range plans {
for _, src := range m.spider91Drives() {
if err := ctx.Err(); err != nil {
return
}
deleted, err := m.cleanupOldLocalVideos(ctx, plan)
deleted, err := m.cleanupOldLocalVideos(ctx, src)
if err != nil {
log.Printf("[spider91migrate] cleanup drive=%s: %v", plan.source.ID(), err)
log.Printf("[spider91migrate] cleanup drive=%s: %v", src.ID(), err)
}
if deleted > 0 {
log.Printf("[spider91migrate] cleanup drive=%s deleted %d orphan local file(s)", plan.source.ID(), deleted)
log.Printf("[spider91migrate] cleanup drive=%s deleted %d orphan local file(s)", src.ID(), deleted)
}
}
// 回填:把已迁移到 PikPak 的 spider91-* 视频里文件名仍是旧格式
// (比如刚迁完没改、或人工导入)的统一改成方案 B 期望的格式。
// 这一步幂等:已经是期望格式的不会再调 Rename。
for targetDriveID, pp := range backfillTargets {
if renamed, err := m.backfillFileNames(ctx, targetDriveID, pp); err != nil {
log.Printf("[spider91migrate] backfill names: %v", err)
} else if renamed > 0 {
log.Printf("[spider91migrate] backfilled %d %s file name(s) to desired format", renamed, pp.Kind())
}
if renamed, err := m.backfillFileNames(ctx, target, pp); err != nil {
log.Printf("[spider91migrate] backfill names: %v", err)
} else if renamed > 0 {
log.Printf("[spider91migrate] backfilled %d %s file name(s) to desired format", renamed, m.targetKindForLog())
}
}
func (m *Migrator) reportUploadProgress(progress UploadProgress) {
if m == nil || m.cfg.OnUploadProgress == nil {
return
}
progress.DriveID = strings.TrimSpace(progress.DriveID)
if progress.DriveID == "" {
return
}
if progress.State == "" {
progress.State = "idle"
}
m.cfg.OnUploadProgress(progress)
}
// targetKindForLog 把当前目标盘 kind 转成对人友好的简称,用于日志。
// 解析失败时回退 "target"。
func (m *Migrator) targetKindForLog() string {
@@ -517,17 +381,9 @@ func (m *Migrator) resolveTarget() (string, uploadTarget, error) {
return "", nil, errors.New("no target getter")
}
id := m.cfg.GetTargetDriveID()
return m.resolveTargetID(id)
}
func (m *Migrator) resolveTargetID(id string) (string, uploadTarget, error) {
id = strings.TrimSpace(id)
if id == "" {
return "", nil, errors.New("target drive not configured")
}
if m.cfg.Registry == nil {
return "", nil, errors.New("registry not configured")
}
d, ok := m.cfg.Registry.Get(id)
if !ok {
return "", nil, fmt.Errorf("target drive %q not in registry", id)
@@ -539,142 +395,33 @@ func (m *Migrator) resolveTargetID(id string) (string, uploadTarget, error) {
return id, t, nil
}
func (m *Migrator) migrationPlans(ctx context.Context) []migrationPlan {
if m == nil || m.cfg.Catalog == nil || m.cfg.Registry == nil {
return nil
}
// spider91Drives 返回当前注册的所有 spider91 driver。
func (m *Migrator) spider91Drives() []*spider91.Driver {
all := m.cfg.Registry.All()
out := make([]migrationPlan, 0, len(all))
out := make([]*spider91.Driver, 0, len(all))
for _, d := range all {
if d == nil {
if d.Kind() != spider91.Kind {
continue
}
src, ok := d.(Spider91LocalSource)
if !ok {
continue
}
row, err := m.cfg.Catalog.GetDrive(ctx, d.ID())
if (err != nil || row == nil) && d.Kind() == spider91.Kind {
row = &catalog.Drive{ID: d.ID(), Kind: spider91.Kind, RootID: "/"}
}
if row == nil {
continue
}
switch row.Kind {
case scriptcrawler.Kind:
targetID := strings.TrimSpace(row.Credentials["upload_drive_id"])
if targetID == "" {
continue
}
resolvedID, target, err := m.resolveTargetID(targetID)
if err != nil {
log.Printf("[spider91migrate] crawler=%s upload target=%q unavailable: %v", row.ID, targetID, err)
continue
}
out = append(out, migrationPlan{
source: src,
row: row,
sourceKinds: crawlerSourceKindsForRow(row),
targetDriveID: resolvedID,
target: target,
uploadDir: scriptCrawlerUploadDir(row.ID),
keepLatestN: 0,
requireAssetsReady: true,
})
case spider91.Kind:
if m.cfg.GetTargetDriveID == nil {
continue
}
targetID := strings.TrimSpace(m.cfg.GetTargetDriveID())
if targetID == "" {
continue
}
resolvedID, target, err := m.resolveTargetID(targetID)
if err != nil {
continue
}
out = append(out, migrationPlan{
source: src,
row: row,
sourceKinds: []string{spider91.Kind},
targetDriveID: resolvedID,
target: target,
uploadDir: spider91UploadDirName,
keepLatestN: m.cfg.KeepLatestN,
legacyBackfill: true,
})
}
}
return out
}
func crawlerSourceKindsForRow(d *catalog.Drive) []string {
kinds := []string{scriptcrawler.Kind}
if d != nil && strings.EqualFold(strings.TrimSpace(d.Credentials["builtin"]), spider91.Kind) {
kinds = append(kinds, spider91.Kind)
}
return kinds
}
func scriptCrawlerUploadDir(driveID string) string {
driveID = sanitizeUploadDirSegment(driveID)
if driveID == "" {
driveID = "crawler"
}
return scriptCrawlerUploadRootDirName + "/" + driveID
}
func sanitizeUploadDirSegment(raw string) string {
clean := sanitizeTitle(raw)
clean = strings.Trim(clean, "/")
if clean == "." || clean == ".." {
return ""
}
return clean
}
// spider91Drives 返回当前注册的所有 Spider91 来源本地爬虫 driver。
func (m *Migrator) spider91Drives(ctx context.Context) []Spider91LocalSource {
all := m.cfg.Registry.All()
out := make([]Spider91LocalSource, 0, len(all))
for _, d := range all {
if !m.isSpider91SourceDrive(ctx, d) {
continue
}
if sd, ok := d.(Spider91LocalSource); ok {
if sd, ok := d.(*spider91.Driver); ok {
out = append(out, sd)
}
}
return out
}
func (m *Migrator) isSpider91SourceDrive(ctx context.Context, d drives.Drive) bool {
if d == nil {
return false
}
if d.Kind() == spider91.Kind {
return true
}
if d.Kind() != scriptcrawler.Kind || m.cfg.Catalog == nil {
return false
}
row, err := m.cfg.Catalog.GetDrive(ctx, d.ID())
if err != nil || row == nil {
return false
}
if row.Kind == spider91.Kind {
return true
}
return row.Kind == scriptcrawler.Kind && strings.EqualFold(strings.TrimSpace(row.Credentials["builtin"]), spider91.Kind)
}
// migrateDrive 对单个本地爬虫 drive 跑一批迁移;返回成功迁移的条数。
func (m *Migrator) migrateDrive(ctx context.Context, plan migrationPlan) (int, error) {
src := plan.source
if src == nil || plan.target == nil || plan.targetDriveID == "" {
return 0, nil
}
keepN := plan.keepLatestN
// migrateDrive 对单个 spider91 drive 跑一批迁移;返回成功迁移的条数。
//
// 策略(与"本地缓存最新 N 个"语义一致):
// - 列出 spider91 drive 本地 videos/ 目录所有 mp4 文件,按 mtime 降序排
// - 跳过最新 KeepLatestN 个:这些是用户希望保留在本地的最新爬取
// - 对剩下的(更旧)逐个处理:
// - 还没迁移(drive_id 仍是 src.ID())→ 上传到目标盘 + 改 catalog + 删本地
// - 已经迁移过但本地还有残留 → 仅删本地(兜底)
//
// KeepLatestN < 0 时不保护任何本地文件,全部尝试迁移(旧行为,主要给测试用)。
func (m *Migrator) migrateDrive(ctx context.Context, src *spider91.Driver, targetDriveID string, pp uploadTarget) (int, error) {
keepN := m.cfg.KeepLatestN
if keepN < 0 {
keepN = 0
}
@@ -704,46 +451,28 @@ func (m *Migrator) migrateDrive(ctx context.Context, plan migrationPlan) (int, e
files = append(files, localFile{name: e.Name(), modTime: info.ModTime()})
}
if plan.keepLatestN >= 0 && len(files) <= keepN {
// 本地数量没超过 keepN 时不动任何文件 —— 这条是 KeepLatestN 语义的核心
if m.cfg.KeepLatestN >= 0 && len(files) <= keepN {
return 0, nil
}
// 按 mtime 降序:最新的排前面,保留前 keepN 个
sort.Slice(files, func(i, j int) bool { return files[i].modTime.After(files[j].modTime) })
// 候选 = 跳过最新 keepN 个之外的(更旧的)。KeepLatestN < 0 时 candidates=files。
skip := keepN
if plan.keepLatestN < 0 {
if m.cfg.KeepLatestN < 0 {
skip = 0
}
candidates := files
if skip < len(files) {
candidates = files[skip:]
} else {
m.reportUploadProgress(UploadProgress{DriveID: src.ID(), State: "idle"})
return 0, nil
}
totalCandidates := len(candidates)
m.reportUploadProgress(UploadProgress{
DriveID: src.ID(),
State: "uploading",
QueueLength: totalCandidates,
TotalCount: totalCandidates,
})
defer m.reportUploadProgress(UploadProgress{DriveID: src.ID(), State: "idle"})
localVideos, err := m.cfg.Catalog.ListVideosByDriveID(ctx, src.ID(), 100000)
if err != nil {
return 0, fmt.Errorf("list local catalog videos: %w", err)
}
byFileID := make(map[string]*catalog.Video, len(localVideos))
for _, v := range localVideos {
if v != nil && strings.TrimSpace(v.FileID) != "" {
byFileID[v.FileID] = v
}
}
migrated := 0
processed := 0
for index, f := range candidates {
for _, f := range candidates {
if err := ctx.Err(); err != nil {
return migrated, err
}
@@ -751,87 +480,21 @@ func (m *Migrator) migrateDrive(ctx context.Context, plan migrationPlan) (int, e
break
}
v := m.findVideoForLocalFile(ctx, plan, f.name, byFileID)
if v == nil {
processed++
m.reportUploadProgress(UploadProgress{
DriveID: src.ID(),
State: "uploading",
QueueLength: maxInt(totalCandidates-processed, 0),
DoneCount: processed,
TotalCount: totalCandidates,
})
viewkey := stripExt(f.name)
videoID := "spider91-" + src.ID() + "-" + viewkey
v, err := m.cfg.Catalog.GetVideo(ctx, videoID)
if err != nil || v == nil {
// 找不到 catalog 行:保险起见保留本地,让管理员可见
continue
}
m.reportUploadProgress(UploadProgress{
DriveID: src.ID(),
State: "uploading",
CurrentTitle: v.Title,
QueueLength: maxInt(totalCandidates-index-1, 0),
DoneCount: processed,
TotalCount: totalCandidates,
})
if v.DriveID != src.ID() {
CleanupSpider91Local(src, f.name)
processed++
m.reportUploadProgress(UploadProgress{
DriveID: src.ID(),
State: "uploading",
QueueLength: maxInt(totalCandidates-processed, 0),
DoneCount: processed,
TotalCount: totalCandidates,
})
// catalog 已迁移到别的 drive,但本地还有残留 → 兜底删本地
CleanupSpider91Local(src, v.FileID)
continue
}
if targetDuplicate, err := m.cfg.Catalog.FindEquivalentVideoOnDrive(ctx, v, plan.targetDriveID); err != nil {
if !errors.Is(err, sql.ErrNoRows) {
log.Printf("[spider91migrate] %s find target duplicate: %v", v.ID, err)
}
} else if targetDuplicate != nil {
ok, err := m.bindToExistingTarget(ctx, v, targetDuplicate, plan)
if err != nil {
log.Printf("[spider91migrate] %s: %v", v.ID, err)
continue
}
if ok {
migrated++
if m.cfg.OnMigrated != nil {
m.cfg.OnMigrated(v.ID)
}
}
processed++
m.reportUploadProgress(UploadProgress{
DriveID: src.ID(),
State: "uploading",
QueueLength: maxInt(totalCandidates-processed, 0),
DoneCount: processed,
TotalCount: totalCandidates,
})
continue
}
if plan.requireAssetsReady {
ready, err := m.crawlerVideoAssetsReady(ctx, v)
if err != nil {
log.Printf("[spider91migrate] %s check generated assets: %v", v.ID, err)
continue
}
if !ready {
processed++
m.reportUploadProgress(UploadProgress{
DriveID: src.ID(),
State: "uploading",
QueueLength: maxInt(totalCandidates-processed, 0),
DoneCount: processed,
TotalCount: totalCandidates,
})
continue
}
}
ok, err := m.migrateOne(ctx, v, plan)
ok, err := m.migrateOne(ctx, v, src, targetDriveID, pp)
if err != nil {
log.Printf("[spider91migrate] %s: %v", v.ID, err)
// captcha 错误(4002 / 9)说明 PikPak 当前正拒绝我们;继续在
@@ -851,64 +514,14 @@ func (m *Migrator) migrateDrive(ctx context.Context, plan migrationPlan) (int, e
m.cfg.OnMigrated(v.ID)
}
}
processed++
m.reportUploadProgress(UploadProgress{
DriveID: src.ID(),
State: "uploading",
QueueLength: maxInt(totalCandidates-processed, 0),
DoneCount: processed,
TotalCount: totalCandidates,
})
}
return migrated, nil
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func (m *Migrator) findVideoForLocalFile(ctx context.Context, plan migrationPlan, localFile string, byFileID map[string]*catalog.Video) *catalog.Video {
if v := byFileID[localFile]; v != nil {
return v
}
sourceID := stripExt(localFile)
driveID := ""
if plan.source != nil {
driveID = plan.source.ID()
}
for _, kind := range plan.sourceKinds {
id := scriptcrawler.BuildVideoIDForKind(kind, driveID, sourceID)
v, err := m.cfg.Catalog.GetVideo(ctx, id)
if err == nil && v != nil {
return v
}
}
return nil
}
func (m *Migrator) crawlerVideoAssetsReady(ctx context.Context, v *catalog.Video) (bool, error) {
if v == nil {
return false, nil
}
fingerprintReady := strings.EqualFold(strings.TrimSpace(v.FingerprintStatus), "ready") || strings.TrimSpace(v.SampledSHA256) != ""
if !fingerprintReady {
return false, nil
}
if strings.EqualFold(strings.TrimSpace(v.PreviewStatus), "ready") {
return true, nil
}
return m.cfg.Catalog.HasReadyEquivalentPreview(ctx, v)
}
// migrateOne 把单条本地爬虫视频上传到目标盘并改写 catalog。
// migrateOne 把单条 spider91 视频上传到目标盘并改写 catalog。
// 返回 (true, nil) 表示真的迁了一条;(false, nil) 表示跳过(本地文件已不在等);
// (false, err) 表示真出错。
func (m *Migrator) migrateOne(ctx context.Context, v *catalog.Video, plan migrationPlan) (bool, error) {
src := plan.source
pp := plan.target
func (m *Migrator) migrateOne(ctx context.Context, v *catalog.Video, src *spider91.Driver, targetDriveID string, pp uploadTarget) (bool, error) {
path, err := src.VideoPath(v.FileID)
if err != nil {
return false, fmt.Errorf("resolve local path: %w", err)
@@ -932,11 +545,20 @@ func (m *Migrator) migrateOne(ctx context.Context, v *catalog.Video, plan migrat
}
defer f.Close()
parent, err := pp.EnsureDir(ctx, plan.uploadDir)
// 上传到目标盘 rootID 下的固定 "91 Spider" 子目录。若用户把目标盘 rootID
// 配成某个自定义目录,这里会在该自定义目录下查找/创建 "91 Spider"。
// 上传名走 desiredPikPakName 算出来的方案 B 格式:
//
// <sanitized title>-<viewkey 后 8 位>.<ext>
//
// 这样网盘 Web 端列出来的文件名能直接看出是哪个视频,
// 又用 viewkey 后 8 位避免同标题撞名。所有目标盘共用同一格式,
// 简化前端 / catalog 的认知。
parent, err := pp.EnsureDir(ctx, spider91UploadDirName)
if err != nil {
return false, fmt.Errorf("%s ensure %q dir: %w", pp.Kind(), plan.uploadDir, err)
return false, fmt.Errorf("%s ensure %q dir: %w", pp.Kind(), spider91UploadDirName, err)
}
uploadName := desiredPikPakName(v.Title, sourceIDForUploadName(v, plan), v.Ext)
uploadName := desiredPikPakName(v.Title, extractViewKey(v.ID), v.Ext)
res, err := pp.UploadAndReportHash(ctx, parent, uploadName, f, info.Size())
if err != nil {
return false, fmt.Errorf("%s upload: %w", pp.Kind(), err)
@@ -946,157 +568,28 @@ func (m *Migrator) migrateOne(ctx context.Context, v *catalog.Video, plan migrat
}
// 事务性改写 catalog 行:drive_id / file_id / content_hash
if err := m.cfg.Catalog.MigrateVideoToDrive(ctx, v.ID, plan.targetDriveID, res.FileID, res.Hash); err != nil {
if err := m.cfg.Catalog.MigrateVideoToDrive(ctx, v.ID, targetDriveID, res.FileID, res.Hash); err != nil {
return false, fmt.Errorf("catalog migrate: %w", err)
}
m.preserveCrawledThumbnail(ctx, src, v)
// 同步 catalog 里的 file_name,让下次目标盘扫盘时 (file_name, size) 也能匹配上
if err := m.cfg.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{FileName: uploadName}); err != nil {
log.Printf("[spider91migrate] %s update file_name after migrate: %v", v.ID, err)
}
// 删除本地 mp4 和 thumb公共 /p/thumb 副本已在 preserveCrawledThumbnail 中保留)。
// 删除本地 mp4 和 thumbthumb 在 previews/thumbs/ 还有副本,不影响展示)
CleanupSpider91Local(src, v.FileID)
log.Printf("[spider91migrate] %s migrated to drive=%s(kind=%s) file=%s name=%q", v.ID, plan.targetDriveID, pp.Kind(), res.FileID, uploadName)
log.Printf("[spider91migrate] %s migrated to drive=%s(kind=%s) file=%s name=%q", v.ID, targetDriveID, pp.Kind(), res.FileID, uploadName)
return true, nil
}
func (m *Migrator) bindToExistingTarget(ctx context.Context, v, target *catalog.Video, plan migrationPlan) (bool, error) {
if v == nil || target == nil || plan.source == nil {
return false, nil
}
if plan.targetDriveID == "" || target.FileID == "" {
return false, nil
}
if err := m.cfg.Catalog.MigrateVideoToDrive(ctx, v.ID, plan.targetDriveID, target.FileID, firstNonEmpty(target.ContentHash, v.ContentHash)); err != nil {
return false, fmt.Errorf("catalog bind existing target: %w", err)
}
if target.FileName != "" {
if err := m.cfg.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{FileName: target.FileName}); err != nil {
log.Printf("[spider91migrate] %s update file_name after duplicate bind: %v", v.ID, err)
}
}
m.preserveCrawledThumbnail(ctx, plan.source, v)
CleanupSpider91Local(plan.source, v.FileID)
log.Printf("[spider91migrate] %s bound to existing drive=%s(kind=%s) file=%s duplicate=%s", v.ID, plan.targetDriveID, plan.target.Kind(), target.FileID, target.ID)
return true, nil
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func sourceIDForUploadName(v *catalog.Video, plan migrationPlan) string {
if v == nil {
return ""
}
if plan.legacyBackfill {
return extractViewKey(v.ID)
}
for _, kind := range plan.sourceKinds {
prefix := kind + "-" + plan.source.ID() + "-"
if strings.HasPrefix(v.ID, prefix) {
return strings.TrimPrefix(v.ID, prefix)
}
}
if v.FileID != "" {
return stripExt(v.FileID)
}
return extractViewKey(v.ID)
}
func (m *Migrator) preserveCrawledThumbnail(ctx context.Context, src Spider91LocalSource, v *catalog.Video) {
if m == nil || m.cfg.Catalog == nil || src == nil || v == nil || v.ID == "" || v.FileID == "" {
return
}
commonDir := strings.TrimSpace(m.cfg.CommonThumbDir)
if commonDir == "" {
return
}
thumbPath, ok := findSpider91ThumbPath(src, v.FileID)
if !ok {
if v.ThumbnailURL == "" {
log.Printf("[spider91migrate] %s crawled thumbnail missing before migration cleanup", v.ID)
}
return
}
if err := os.MkdirAll(commonDir, 0o755); err != nil {
log.Printf("[spider91migrate] %s mkdir common thumbs: %v", v.ID, err)
return
}
dst := mediaasset.ThumbnailPathInDir(commonDir, v.ID)
if _, err := os.Stat(dst); err != nil {
if !os.IsNotExist(err) {
log.Printf("[spider91migrate] %s stat common thumb: %v", v.ID, err)
return
}
if err := copyFileAtomic(thumbPath, dst); err != nil {
log.Printf("[spider91migrate] %s preserve crawled thumbnail: %v", v.ID, err)
return
}
}
if err := m.cfg.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{
ThumbnailURL: "/p/thumb/" + v.ID,
}); err != nil {
log.Printf("[spider91migrate] %s update crawled thumbnail url: %v", v.ID, err)
return
}
v.ThumbnailURL = "/p/thumb/" + v.ID
}
func findSpider91ThumbPath(src Spider91LocalSource, fileID string) (string, bool) {
thumbBase := stripExt(fileID)
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp"} {
thumbPath, err := src.ThumbPath(thumbBase + ext)
if err != nil {
continue
}
info, statErr := os.Stat(thumbPath)
if statErr == nil && info.Mode().IsRegular() && info.Size() > 0 {
return thumbPath, true
}
}
return "", false
}
func copyFileAtomic(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
tmp := dst + ".part"
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return err
}
_, copyErr := io.Copy(out, in)
closeErr := out.Close()
if copyErr != nil {
_ = os.Remove(tmp)
return copyErr
}
if closeErr != nil {
_ = os.Remove(tmp)
return closeErr
}
return os.Rename(tmp, dst)
}
// CleanupSpider91Local 删除已迁移视频的本地 mp4 和 thumb。
//
// thumb 删除是 best-effort —— 找不到就算了(spider91 thumb 文件名带后缀,
// 我们不知道具体是 .jpg 还是别的,逐个尝试常见后缀)。
//
// 暴露成包级函数方便 cleanup 模块复用(任务 6)。
func CleanupSpider91Local(src Spider91LocalSource, fileID string) {
func CleanupSpider91Local(src *spider91.Driver, fileID string) {
videoPath, err := src.VideoPath(fileID)
if err == nil {
if err := os.Remove(videoPath); err != nil && !os.IsNotExist(err) {
@@ -1133,11 +626,7 @@ func stripExt(name string) string {
// 找到孤儿。
//
// 返回实际删除的文件个数。
func (m *Migrator) cleanupOldLocalVideos(ctx context.Context, plan migrationPlan) (int, error) {
src := plan.source
if src == nil {
return 0, nil
}
func (m *Migrator) cleanupOldLocalVideos(ctx context.Context, src *spider91.Driver) (int, error) {
entries, err := os.ReadDir(src.VideosDir())
if err != nil {
if os.IsNotExist(err) {
@@ -1154,13 +643,18 @@ func (m *Migrator) cleanupOldLocalVideos(ctx context.Context, plan migrationPlan
if e.IsDir() {
continue
}
v := m.findVideoForLocalFile(ctx, plan, e.Name(), nil)
if v == nil {
viewkey := stripExt(e.Name())
videoID := "spider91-" + src.ID() + "-" + viewkey
v, err := m.cfg.Catalog.GetVideo(ctx, videoID)
if err != nil || v == nil {
// 找不到 catalog 行:保险起见保留,等管理员处理
continue
}
if v.DriveID == src.ID() {
// 还没迁移,归 migrateDrive 管,不在这里动
continue
}
// 已迁移到别的 drive 但本地还有 → 删
path, perr := src.VideoPath(e.Name())
if perr != nil {
continue
@@ -1183,7 +677,7 @@ func (m *Migrator) cleanupOldLocalVideos(ctx context.Context, plan migrationPlan
return deleted, nil
}
// backfillFileNames 扫描目标 drivePikPak、115、123、OneDrive、Google Drive 或联通网盘)下所有 spider91-* 起始 ID 的视频,
// backfillFileNames 扫描目标 drivePikPak、115OneDrive)下所有 spider91-* 起始 ID 的视频,
// 对文件名不是 desiredPikPakName(...) 期望格式的,调 target.Rename 修正,
// 并把 catalog.file_name 同步到新名字。
//
@@ -14,12 +14,8 @@ import (
"github.com/video-site/backend/internal/catalog"
"github.com/video-site/backend/internal/drives"
"github.com/video-site/backend/internal/drives/googledrive"
"github.com/video-site/backend/internal/drives/p123"
"github.com/video-site/backend/internal/drives/pikpak"
"github.com/video-site/backend/internal/drives/scriptcrawler"
"github.com/video-site/backend/internal/drives/spider91"
"github.com/video-site/backend/internal/drives/wopan"
)
// fakeRegistry 是 Registry 接口的最小实现。
@@ -138,19 +134,6 @@ func (d *fakeP115) Kind() string { return "p115" }
var _ drives.Drive = (*fakeP115)(nil)
var _ uploadTarget = (*fakeP115)(nil)
type fakeP123 struct {
*fakePikPak
}
func newFakeP123(id, rootID string) *fakeP123 {
return &fakeP123{fakePikPak: newFakePikPak(id, rootID)}
}
func (d *fakeP123) Kind() string { return "p123" }
var _ drives.Drive = (*fakeP123)(nil)
var _ uploadTarget = (*fakeP123)(nil)
type fakeOneDrive struct {
*fakePikPak
}
@@ -345,81 +328,6 @@ func writeSpider91Video(t *testing.T, cat *catalog.Catalog, d *spider91.Driver,
return id
}
func setupScriptCrawler(t *testing.T, id string) *scriptcrawler.Driver {
t.Helper()
d := scriptcrawler.New(scriptcrawler.Config{ID: id, RootDir: t.TempDir()})
if err := d.Init(context.Background()); err != nil {
t.Fatalf("scriptcrawler init: %v", err)
}
return d
}
func seedScriptCrawlerDrive(t *testing.T, cat *catalog.Catalog, d *scriptcrawler.Driver, uploadDriveID string) {
t.Helper()
if err := cat.UpsertDrive(context.Background(), &catalog.Drive{
ID: d.ID(),
Kind: scriptcrawler.Kind,
Name: "Script Crawler",
RootID: "/",
Credentials: map[string]string{
"script_path": "/tmp/crawler.py",
"upload_drive_id": uploadDriveID,
},
}); err != nil {
t.Fatalf("seed scriptcrawler drive: %v", err)
}
}
func writeScriptCrawlerVideo(t *testing.T, cat *catalog.Catalog, d *scriptcrawler.Driver, sourceID, ext string, content []byte, readyAssets bool) string {
t.Helper()
fileID := sourceID + ext
path, err := d.VideoPath(fileID)
if err != nil {
t.Fatalf("video path: %v", err)
}
if err := os.WriteFile(path, content, 0o644); err != nil {
t.Fatalf("write video: %v", err)
}
thumbPath, err := d.ThumbPath(sourceID + ".jpg")
if err != nil {
t.Fatalf("thumb path: %v", err)
}
if err := os.WriteFile(thumbPath, []byte("thumb"), 0o644); err != nil {
t.Fatalf("write thumb: %v", err)
}
now := time.Now()
id := scriptcrawler.BuildVideoID(d.ID(), sourceID)
previewStatus := "pending"
if readyAssets {
previewStatus = "ready"
}
v := &catalog.Video{
ID: id,
DriveID: d.ID(),
FileID: fileID,
FileName: fileID,
Title: "Crawler " + sourceID,
Author: "tester",
Ext: strings.TrimPrefix(ext, "."),
Quality: "HD",
Size: int64(len(content)),
ThumbnailURL: "/p/thumb/" + id,
PreviewStatus: previewStatus,
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}
if err := cat.UpsertVideo(context.Background(), v); err != nil {
t.Fatalf("upsert scriptcrawler video: %v", err)
}
if readyAssets {
if err := cat.UpdateVideoFingerprint(context.Background(), id, "sampled-"+sourceID, "ready", ""); err != nil {
t.Fatalf("mark fingerprint ready: %v", err)
}
}
return id
}
func TestRunOnceMigratesSpider91VideosAndCleansLocalFiles(t *testing.T) {
cat := setupCatalog(t)
src, _ := setupSpider91(t)
@@ -431,14 +339,12 @@ func TestRunOnceMigratesSpider91VideosAndCleansLocalFiles(t *testing.T) {
now := time.Now()
id := writeSpider91Video(t, cat, src, "vk001", ".mp4", []byte("video bytes here"), now)
commonThumbDir := t.TempDir()
m := New(Config{
Catalog: cat,
Registry: reg,
GetTargetDriveID: func() string { return pp.ID() },
KeepLatestN: -1, // 关闭"保留最新 N 个",让 1 条也能立即上传
CommonThumbDir: commonThumbDir,
})
m.runOnce(context.Background())
@@ -476,15 +382,8 @@ func TestRunOnceMigratesSpider91VideosAndCleansLocalFiles(t *testing.T) {
if got.ContentHash == "" {
t.Fatalf("content_hash should be set after migration")
}
if got.ThumbnailURL != "/p/thumb/"+id {
t.Fatalf("thumbnail_url = %q, want preserved crawled thumbnail URL", got.ThumbnailURL)
}
commonThumbPath := filepath.Join(commonThumbDir, id+".jpg")
if data, err := os.ReadFile(commonThumbPath); err != nil || string(data) != "thumb" {
t.Fatalf("common thumb = %q, %v; want copied crawled thumb", string(data), err)
}
// 3) 本地视频和 thumb 都被删了;公共 /p/thumb 副本保留。
// 3) 本地视频和 thumb 都被删了
videoPath, _ := src.VideoPath("vk001.mp4")
if _, err := os.Stat(videoPath); !os.IsNotExist(err) {
t.Fatalf("local mp4 still exists or stat error %v", err)
@@ -495,174 +394,6 @@ func TestRunOnceMigratesSpider91VideosAndCleansLocalFiles(t *testing.T) {
}
}
func TestRunOnceMigratesReadyScriptCrawlerVideoToConfiguredUploadDrive(t *testing.T) {
cat := setupCatalog(t)
src := setupScriptCrawler(t, "crawler-alpha")
pp := newFakePikPak("pikpak-target", "pikpak-root-id")
seedScriptCrawlerDrive(t, cat, src, pp.ID())
reg := newFakeRegistry()
reg.Add(src)
reg.Add(pp)
id := writeScriptCrawlerVideo(t, cat, src, "source-with-dash-001", ".mp4", []byte("script video bytes"), true)
commonThumbDir := t.TempDir()
m := New(Config{
Catalog: cat,
Registry: reg,
CommonThumbDir: commonThumbDir,
})
m.runOnce(context.Background())
if pp.uploadCalls != 1 {
t.Fatalf("upload calls = %d, want 1", pp.uploadCalls)
}
wantDir := "Script Crawlers/crawler-alpha"
if len(pp.ensureCalls) != 1 || pp.ensureCalls[0] != wantDir {
t.Fatalf("ensure calls = %#v, want %q", pp.ensureCalls, wantDir)
}
wantName := desiredPikPakName("Crawler source-with-dash-001", "source-with-dash-001", "mp4")
if gotParent := pp.gotParents[wantName]; gotParent != "pikpak-root-id/"+wantDir {
t.Fatalf("upload parent = %q, want root/%s", gotParent, wantDir)
}
got, err := cat.GetVideo(context.Background(), id)
if err != nil {
t.Fatalf("get migrated video: %v", err)
}
if got.DriveID != pp.ID() {
t.Fatalf("drive_id = %q, want %q", got.DriveID, pp.ID())
}
if got.FileID != "remote-"+wantName {
t.Fatalf("file_id = %q, want remote upload id", got.FileID)
}
if got.FileName != wantName {
t.Fatalf("file_name = %q, want %q", got.FileName, wantName)
}
if got.PreviewStatus != "ready" || got.FingerprintStatus != "ready" || got.SampledSHA256 == "" {
t.Fatalf("generated assets not preserved after migration: preview=%q fingerprint=%q sampled=%q", got.PreviewStatus, got.FingerprintStatus, got.SampledSHA256)
}
videoPath, _ := src.VideoPath("source-with-dash-001.mp4")
if _, err := os.Stat(videoPath); !os.IsNotExist(err) {
t.Fatalf("local scriptcrawler video still exists or stat error %v", err)
}
thumbPath, _ := src.ThumbPath("source-with-dash-001.jpg")
if _, err := os.Stat(thumbPath); !os.IsNotExist(err) {
t.Fatalf("local scriptcrawler thumb still exists or stat error %v", err)
}
commonThumbPath := filepath.Join(commonThumbDir, id+".jpg")
if data, err := os.ReadFile(commonThumbPath); err != nil || string(data) != "thumb" {
t.Fatalf("common thumb = %q, %v; want copied crawled thumb", string(data), err)
}
}
func TestRunOnceSkipsScriptCrawlerVideoUntilPreviewAndFingerprintReady(t *testing.T) {
cat := setupCatalog(t)
src := setupScriptCrawler(t, "crawler-beta")
pp := newFakePikPak("pikpak-target", "pikpak-root-id")
seedScriptCrawlerDrive(t, cat, src, pp.ID())
reg := newFakeRegistry()
reg.Add(src)
reg.Add(pp)
id := writeScriptCrawlerVideo(t, cat, src, "pending-assets", ".mp4", []byte("script video bytes"), false)
m := New(Config{Catalog: cat, Registry: reg})
m.runOnce(context.Background())
if pp.uploadCalls != 0 {
t.Fatalf("upload calls = %d, want 0 while generated assets are pending", pp.uploadCalls)
}
got, err := cat.GetVideo(context.Background(), id)
if err != nil {
t.Fatalf("get video: %v", err)
}
if got.DriveID != src.ID() {
t.Fatalf("drive_id = %q, want local crawler drive %q", got.DriveID, src.ID())
}
videoPath, _ := src.VideoPath("pending-assets.mp4")
if _, err := os.Stat(videoPath); err != nil {
t.Fatalf("local video should remain while assets pending: %v", err)
}
}
func TestRunOnceBindsScriptCrawlerDuplicateToExistingTargetWithoutUpload(t *testing.T) {
cat := setupCatalog(t)
src := setupScriptCrawler(t, "crawler-duplicate")
pp := newFakePikPak("pikpak-target", "pikpak-root-id")
seedScriptCrawlerDrive(t, cat, src, pp.ID())
reg := newFakeRegistry()
reg.Add(src)
reg.Add(pp)
content := []byte("duplicate script video bytes")
id := writeScriptCrawlerVideo(t, cat, src, "duplicate-source", ".mp4", content, false)
sampled := "same-sampled-fingerprint"
if err := cat.UpdateVideoFingerprint(context.Background(), id, sampled, "ready", ""); err != nil {
t.Fatalf("mark source fingerprint ready: %v", err)
}
now := time.Now()
target := &catalog.Video{
ID: "pikpak-existing-duplicate",
DriveID: pp.ID(),
FileID: "existing-target-file",
FileName: "existing-target-name.mp4",
ContentHash: "existing-content-hash",
Title: "Existing duplicate",
Ext: "mp4",
Size: int64(len(content)),
PreviewStatus: "ready",
PublishedAt: now.Add(-time.Hour),
CreatedAt: now.Add(-time.Hour),
UpdatedAt: now.Add(-time.Hour),
}
if err := cat.UpsertVideo(context.Background(), target); err != nil {
t.Fatalf("upsert existing target: %v", err)
}
if err := cat.UpdateVideoFingerprint(context.Background(), target.ID, sampled, "ready", ""); err != nil {
t.Fatalf("mark target fingerprint ready: %v", err)
}
commonThumbDir := t.TempDir()
m := New(Config{Catalog: cat, Registry: reg, CommonThumbDir: commonThumbDir})
m.runOnce(context.Background())
if pp.uploadCalls != 0 {
t.Fatalf("upload calls = %d, want 0 when equivalent target file already exists", pp.uploadCalls)
}
got, err := cat.GetVideo(context.Background(), id)
if err != nil {
t.Fatalf("get bound video: %v", err)
}
if got.DriveID != pp.ID() {
t.Fatalf("drive_id = %q, want %q", got.DriveID, pp.ID())
}
if got.FileID != target.FileID {
t.Fatalf("file_id = %q, want existing target file %q", got.FileID, target.FileID)
}
if got.FileName != target.FileName {
t.Fatalf("file_name = %q, want existing target name %q", got.FileName, target.FileName)
}
if got.ContentHash != target.ContentHash {
t.Fatalf("content_hash = %q, want %q", got.ContentHash, target.ContentHash)
}
videoPath, _ := src.VideoPath("duplicate-source.mp4")
if _, err := os.Stat(videoPath); !os.IsNotExist(err) {
t.Fatalf("local duplicate video still exists or stat error %v", err)
}
thumbPath, _ := src.ThumbPath("duplicate-source.jpg")
if _, err := os.Stat(thumbPath); !os.IsNotExist(err) {
t.Fatalf("local duplicate thumb still exists or stat error %v", err)
}
commonThumbPath := filepath.Join(commonThumbDir, id+".jpg")
if data, err := os.ReadFile(commonThumbPath); err != nil || string(data) != "thumb" {
t.Fatalf("common thumb = %q, %v; want copied crawled thumb", string(data), err)
}
}
func TestRunOnceSkipsWhenLocalFileMissing(t *testing.T) {
cat := setupCatalog(t)
src, _ := setupSpider91(t)
@@ -822,10 +553,7 @@ func TestCleanupRemovesAllAlreadyMigratedOrphans(t *testing.T) {
GetTargetDriveID: func() string { return pp.ID() },
})
deleted, err := m.cleanupOldLocalVideos(context.Background(), migrationPlan{
source: src,
sourceKinds: []string{spider91.Kind},
})
deleted, err := m.cleanupOldLocalVideos(context.Background(), src)
if err != nil {
t.Fatalf("cleanup: %v", err)
}
@@ -847,95 +575,6 @@ func TestCleanupRemovesAllAlreadyMigratedOrphans(t *testing.T) {
}
}
func TestRunOnceMigratesBuiltInSpider91ScriptCrawlerSource(t *testing.T) {
ctx := context.Background()
cat := setupCatalog(t)
src := scriptcrawler.New(scriptcrawler.Config{ID: "spider-script", RootDir: t.TempDir()})
if err := src.Init(ctx); err != nil {
t.Fatalf("scriptcrawler init: %v", err)
}
if err := cat.UpsertDrive(ctx, &catalog.Drive{
ID: src.ID(),
Kind: scriptcrawler.Kind,
Name: "Built-in Spider91",
Credentials: map[string]string{
"builtin": "spider91",
"script_path": "/tmp/spider91.py",
"upload_drive_id": "pikpak-target",
},
}); err != nil {
t.Fatalf("upsert source drive: %v", err)
}
pp := newFakePikPak("pikpak-target", "pikpak-root-id")
reg := newFakeRegistry()
reg.Add(src)
reg.Add(pp)
fileID := "vk-script.mp4"
videoPath, err := src.VideoPath(fileID)
if err != nil {
t.Fatalf("video path: %v", err)
}
if err := os.WriteFile(videoPath, []byte("scriptcrawler spider91 video"), 0o644); err != nil {
t.Fatalf("write video: %v", err)
}
thumbPath, err := src.ThumbPath("vk-script.jpg")
if err != nil {
t.Fatalf("thumb path: %v", err)
}
if err := os.WriteFile(thumbPath, []byte("thumb"), 0o644); err != nil {
t.Fatalf("write thumb: %v", err)
}
now := time.Now()
id := "spider91-" + src.ID() + "-vk-script"
if err := cat.UpsertVideo(ctx, &catalog.Video{
ID: id,
DriveID: src.ID(),
FileID: fileID,
FileName: fileID,
Title: "Scriptcrawler Spider91",
Author: "91porn",
Ext: "mp4",
Quality: "HD",
Size: int64(len("scriptcrawler spider91 video")),
PreviewStatus: "ready",
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("upsert video: %v", err)
}
if err := cat.UpdateVideoFingerprint(ctx, id, "sampled-vk-script", "ready", ""); err != nil {
t.Fatalf("mark fingerprint ready: %v", err)
}
m := New(Config{
Catalog: cat,
Registry: reg,
GetTargetDriveID: func() string { return pp.ID() },
KeepLatestN: -1,
CommonThumbDir: t.TempDir(),
})
m.runOnce(ctx)
if pp.uploadCalls != 1 {
t.Fatalf("upload calls = %d, want 1", pp.uploadCalls)
}
got, err := cat.GetVideo(ctx, id)
if err != nil {
t.Fatalf("get migrated video: %v", err)
}
if got.DriveID != pp.ID() {
t.Fatalf("drive_id = %q, want %q", got.DriveID, pp.ID())
}
if _, err := os.Stat(videoPath); !os.IsNotExist(err) {
t.Fatalf("local video stat err = %v, want not exist", err)
}
if _, err := os.Stat(thumbPath); !os.IsNotExist(err) {
t.Fatalf("local thumb stat err = %v, want not exist", err)
}
}
// TestRunOnceKeepsAllLocalWhenWithinKeepWindow 验证:本地文件数 ≤ KeepLatestN 时
// 一律不上传,全部留作"最新 N"缓存。这是用户的核心需求:刚爬下来的 15 个不要立即被传走。
func TestRunOnceKeepsAllLocalWhenWithinKeepWindow(t *testing.T) {
@@ -1298,66 +937,6 @@ func TestRunOnceMigratesToP115Target(t *testing.T) {
}
}
func TestRunOnceMigratesToP123Target(t *testing.T) {
cat := setupCatalog(t)
src, _ := setupSpider91(t)
target := newFakeP123("p123-target", "p123-root-id")
reg := newFakeRegistry()
reg.Add(src)
reg.Add(target)
now := time.Now()
id := writeSpider91Video(t, cat, src, "vk-123-001", ".mp4", []byte("video bytes 123"), now)
m := New(Config{
Catalog: cat,
Registry: reg,
GetTargetDriveID: func() string { return target.ID() },
KeepLatestN: -1,
})
m.runOnce(context.Background())
if target.uploadCalls != 1 {
t.Fatalf("p123 upload calls = %d, want 1", target.uploadCalls)
}
got, err := cat.GetVideo(context.Background(), id)
if err != nil {
t.Fatalf("get video: %v", err)
}
if got.DriveID != target.ID() {
t.Fatalf("drive_id = %q, want %q", got.DriveID, target.ID())
}
wantName := "Sample vk-123-001-001.mp4"
if _, ok := target.gotBodies[wantName]; !ok {
t.Fatalf("p123 did not receive expected upload name %q (got names: %v)", wantName, keysOf(target.gotBodies))
}
if gotParent := target.gotParents[wantName]; gotParent != "p123-root-id/"+spider91UploadDirName {
t.Fatalf("p123 upload parent = %q, want root/91 Spider", gotParent)
}
if len(target.ensureCalls) != 1 || target.ensureCalls[0] != spider91UploadDirName {
t.Fatalf("p123 ensure calls = %#v, want %q", target.ensureCalls, spider91UploadDirName)
}
if got.FileID != "remote-"+wantName {
t.Fatalf("file_id = %q, want %q", got.FileID, "remote-"+wantName)
}
if got.FileName != wantName {
t.Fatalf("file_name = %q, want %q", got.FileName, wantName)
}
if got.ContentHash == "" {
t.Fatal("content_hash should be set after p123 migration")
}
videoPath, _ := src.VideoPath("vk-123-001.mp4")
if _, err := os.Stat(videoPath); !os.IsNotExist(err) {
t.Fatalf("local mp4 still exists after p123 migration or stat error: %v", err)
}
thumbPath, _ := src.ThumbPath("vk-123-001.jpg")
if _, err := os.Stat(thumbPath); !os.IsNotExist(err) {
t.Fatalf("local thumb still exists after p123 migration or stat error: %v", err)
}
}
func TestRunOnceMigratesToOneDriveTarget(t *testing.T) {
cat := setupCatalog(t)
src, _ := setupSpider91(t)
@@ -1418,53 +997,7 @@ func TestRunOnceMigratesToOneDriveTarget(t *testing.T) {
}
}
func TestAdaptUploadTargetSupportsP123Driver(t *testing.T) {
d := p123.New(p123.Config{
ID: "p123-target",
RootID: "root-123",
AccessToken: "token-1",
})
target, err := adaptUploadTarget(d)
if err != nil {
t.Fatalf("adaptUploadTarget() error = %v", err)
}
if target.ID() != "p123-target" || target.Kind() != "p123" || target.RootID() != "root-123" {
t.Fatalf("target id/kind/root = %q/%q/%q, want p123-target/p123/root-123", target.ID(), target.Kind(), target.RootID())
}
}
func TestAdaptUploadTargetSupportsGoogleDriveDriver(t *testing.T) {
d := googledrive.New(googledrive.Config{
ID: "google-target",
RootID: "root-google",
RefreshToken: "refresh-token",
})
target, err := adaptUploadTarget(d)
if err != nil {
t.Fatalf("adaptUploadTarget() error = %v", err)
}
if target.ID() != "google-target" || target.Kind() != "googledrive" || target.RootID() != "root-google" {
t.Fatalf("target id/kind/root = %q/%q/%q, want google-target/googledrive/root-google", target.ID(), target.Kind(), target.RootID())
}
}
func TestAdaptUploadTargetSupportsWopanDriver(t *testing.T) {
d := wopan.New(wopan.Config{
ID: "wopan-target",
RootID: "root-wopan",
AccessToken: "access-token",
RefreshToken: "refresh-token",
})
target, err := adaptUploadTarget(d)
if err != nil {
t.Fatalf("adaptUploadTarget() error = %v", err)
}
if target.ID() != "wopan-target" || target.Kind() != "wopan" || target.RootID() != "root-wopan" {
t.Fatalf("target id/kind/root = %q/%q/%q, want wopan-target/wopan/root-wopan", target.ID(), target.Kind(), target.RootID())
}
}
// TestResolveTargetRejectsUnsupportedKind 验证当目标 drive 既不是 PikPak、115、123、OneDrive、Google Drive 也不是联通网盘时,
// TestResolveTargetRejectsUnsupportedKind 验证当目标 drive 既不是 PikPak、115 也不是 OneDrive 时,
// resolveTarget 拒绝并返回 error,让 runOnce 静默跳过(不会做破坏性变更)。
func TestResolveTargetRejectsUnsupportedKind(t *testing.T) {
cat := setupCatalog(t)
+8 -11
View File
@@ -5,8 +5,6 @@ import (
"os"
"path/filepath"
"strings"
"github.com/video-site/backend/internal/mediaasset"
)
type VideoAssetRef struct {
@@ -73,15 +71,14 @@ func Compute(
continue
}
driveUsage := out.Drives[ref.DriveID]
for _, thumbPath := range mediaasset.ThumbnailPathCandidates(localDir, ref.ID) {
if size, exists, err := regularFileSize(thumbPath); err != nil {
return Usage{}, err
} else if exists {
key := ref.DriveID + "\x00thumb\x00" + thumbPath
if !seen[key] {
driveUsage.ThumbnailBytes += size
seen[key] = true
}
thumbPath := filepath.Join(localDir, "thumbs", ref.ID+".jpg")
if size, exists, err := regularFileSize(thumbPath); err != nil {
return Usage{}, err
} else if exists {
key := ref.DriveID + "\x00thumb\x00" + thumbPath
if !seen[key] {
driveUsage.ThumbnailBytes += size
seen[key] = true
}
}
@@ -3,10 +3,7 @@ package storageusage
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/video-site/backend/internal/mediaasset"
)
func TestComputeCountsLocalThumbnailsAndTeasersByDrive(t *testing.T) {
@@ -16,8 +13,6 @@ func TestComputeCountsLocalThumbnailsAndTeasersByDrive(t *testing.T) {
}
writeSizedFile(t, filepath.Join(localDir, "thumbs", "video-a.jpg"), 3)
writeSizedFile(t, filepath.Join(localDir, "thumbs", "video-b.jpg"), 5)
longID := "localstorage-" + strings.Repeat("x", 240)
writeSizedFile(t, mediaasset.ThumbnailPath(localDir, longID), 13)
teaserA := filepath.Join(localDir, "video-a.mp4")
teaserB := filepath.Join(localDir, "video-b.mp4")
writeSizedFile(t, teaserA, 7)
@@ -29,7 +24,6 @@ func TestComputeCountsLocalThumbnailsAndTeasersByDrive(t *testing.T) {
{ID: "video-a", DriveID: "drive-a", PreviewLocal: teaserA},
{ID: "video-a-copy", DriveID: "drive-a", PreviewLocal: teaserA},
{ID: "video-b", DriveID: "drive-b", PreviewLocal: teaserB},
{ID: longID, DriveID: "drive-b"},
{ID: "outside", DriveID: "drive-b", PreviewLocal: outside},
{ID: "unknown-drive-video", DriveID: "missing", PreviewLocal: teaserB},
}, []string{"drive-a", "drive-b"}, func(string) (DiskStats, error) {
@@ -47,11 +41,11 @@ func TestComputeCountsLocalThumbnailsAndTeasersByDrive(t *testing.T) {
t.Fatalf("drive-a usage = %#v, want thumbnails=3 teaser=7 total=10", driveA)
}
driveB := got.Drives["drive-b"]
if driveB.ThumbnailBytes != 18 || driveB.TeaserBytes != 11 || driveB.TotalBytes != 29 {
t.Fatalf("drive-b usage = %#v, want thumbnails=18 teaser=11 total=29", driveB)
if driveB.ThumbnailBytes != 5 || driveB.TeaserBytes != 11 || driveB.TotalBytes != 16 {
t.Fatalf("drive-b usage = %#v, want thumbnails=5 teaser=11 total=16", driveB)
}
if got.ThumbnailBytes != 21 || got.TeaserBytes != 18 || got.TotalBytes != 39 {
t.Fatalf("totals = %#v, want thumbnails=21 teaser=18 total=39", got)
if got.ThumbnailBytes != 8 || got.TeaserBytes != 18 || got.TotalBytes != 26 {
t.Fatalf("totals = %#v, want thumbnails=8 teaser=18 total=26", got)
}
}
+5 -26
View File
@@ -49,7 +49,7 @@ Common overrides:
FRONTEND_PORT=9191 Public web port
FRONTEND_HOST=0.0.0.0 Public web bind address
GO_VERSION=1.23.12
INSTALL_DEPS=0 Do not install missing Node/Go/ffmpeg/Python runtime deps
INSTALL_DEPS=0 Do not install missing Node/Go/ffmpeg
CONFIGURE_UFW=0 Do not open UFW port automatically
DEPLOY_USER=<user> Service user; defaults to sudo user or root
@@ -130,25 +130,7 @@ apt_install() {
export DEBIAN_FRONTEND=noninteractive
log "installing base packages"
apt-get update
apt-get install -y ca-certificates curl git ffmpeg openssl iproute2 build-essential \
python3 python3-requests python3-bs4 python3-lxml python3-socks
}
verify_crawler_python_deps() {
command -v python3 >/dev/null 2>&1 || die "python3 is required for crawler scripts"
python3 - <<'PY' || die "missing Python modules for crawler scripts: requests, bs4, lxml, socks"
import importlib.util
import sys
missing = [
name
for name in ("requests", "bs4", "lxml", "socks")
if importlib.util.find_spec(name) is None
]
if missing:
print("missing Python modules: " + ", ".join(missing), file=sys.stderr)
sys.exit(1)
PY
apt-get install -y ca-certificates curl git ffmpeg openssl iproute2 build-essential
}
install_node() {
@@ -200,7 +182,6 @@ install_dependencies() {
install_go
command -v ffmpeg >/dev/null 2>&1 || die "ffmpeg is required"
command -v ffprobe >/dev/null 2>&1 || die "ffprobe is required"
verify_crawler_python_deps
}
ensure_ownership() {
@@ -334,8 +315,8 @@ EOF
}
open_firewall_port() {
[[ "$CONFIGURE_UFW" == "1" ]] || return 0
command -v ufw >/dev/null 2>&1 || return 0
[[ "$CONFIGURE_UFW" == "1" ]] || return
command -v ufw >/dev/null 2>&1 || return
if ufw status 2>/dev/null | grep -qi "Status: active"; then
log "UFW is active; allowing ${FRONTEND_PORT}/tcp"
ufw allow "${FRONTEND_PORT}/tcp"
@@ -378,9 +359,7 @@ install_or_update() {
open_firewall_port
restart_services
show_status
if [[ "$mode" == "install" ]]; then
show_summary
fi
[[ "$mode" == "install" ]] && show_summary
}
uninstall_services() {
-1
View File
@@ -2,7 +2,6 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="referrer" content="no-referrer" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="description" content="91 视频站" />
+5 -26
View File
@@ -122,27 +122,6 @@ asset_name() {
printf '%s-linux-%s.tar.gz' "$APP_NAME" "$ARCH"
}
verify_runtime_deps() {
local cmd
for cmd in curl tar ffmpeg ffprobe openssl python3; do
command -v "$cmd" >/dev/null 2>&1 || die "missing command: $cmd"
done
python3 - <<'PY' || die "missing Python modules for crawler scripts: requests, bs4, lxml, socks"
import importlib.util
import sys
missing = [
name
for name in ("requests", "bs4", "lxml", "socks")
if importlib.util.find_spec(name) is None
]
if missing:
print("missing Python modules: " + ", ".join(missing), file=sys.stderr)
sys.exit(1)
PY
}
install_deps() {
if [[ "$INSTALL_DEPS" != "1" ]]; then
return
@@ -151,12 +130,13 @@ install_deps() {
export DEBIAN_FRONTEND=noninteractive
log "installing runtime dependencies"
apt-get update
apt-get install -y ca-certificates curl tar ffmpeg openssl iproute2 python3 python3-requests python3-bs4 python3-lxml python3-socks
verify_runtime_deps
apt-get install -y ca-certificates curl tar ffmpeg openssl iproute2 python3 python3-requests python3-bs4 python3-lxml
return
fi
verify_runtime_deps
for cmd in curl tar ffmpeg ffprobe openssl; do
command -v "$cmd" >/dev/null 2>&1 || die "missing command: $cmd"
done
}
check_system() {
@@ -709,14 +689,13 @@ update_app() {
exec_latest_manager_update
fi
install_deps
if should_skip_update; then
log "already up to date; skipped app update"
return 0
fi
check_disk_space
install_deps
local backup
backup="$(mktemp -d)"
+2 -38
View File
@@ -1,16 +1,13 @@
{
"name": "video-site",
"version": "0.1.6",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "video-site",
"version": "0.1.6",
"license": "MIT",
"version": "0.1.0",
"dependencies": {
"artplayer": "^5.4.0",
"hls.js": "^1.6.16",
"lucide-react": "0.453.0",
"react": "18.3.1",
"react-dom": "18.3.1",
@@ -477,15 +474,6 @@
}
}
},
"node_modules/artplayer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/artplayer/-/artplayer-5.4.0.tgz",
"integrity": "sha512-2B+plbx8N2yNsjK4nJU3+EOG8TULm1LRZk/QPkWRAMEX2Ee/MSnZG/WJYz8kcoZxZuLKcQ3uXifqLuPxZOH29A==",
"license": "MIT",
"dependencies": {
"option-validator": "^2.0.6"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -536,27 +524,12 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/hls.js": {
"version": "1.6.16",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
"license": "Apache-2.0"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -858,15 +831,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/option-validator": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/option-validator/-/option-validator-2.0.6.tgz",
"integrity": "sha512-tmZDan2LRIRQyhUGvkff68/O0R8UmF+Btmiiz0SmSw2ng3CfPZB9wJlIjHpe/MKUZqyIZkVIXCrwr1tIN+0Dzg==",
"license": "MIT",
"dependencies": {
"kind-of": "^6.0.3"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+1 -4
View File
@@ -1,8 +1,7 @@
{
"name": "video-site",
"private": true,
"license": "MIT",
"version": "0.1.6",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -13,8 +12,6 @@
"test": "node --import tsx --test tests/*.test.ts"
},
"dependencies": {
"artplayer": "^5.4.0",
"hls.js": "^1.6.16",
"lucide-react": "0.453.0",
"react": "18.3.1",
"react-dom": "18.3.1",
+2
View File
@@ -63,6 +63,8 @@ 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"
cat >"$work/README.txt" <<EOF
$APP_NAME $VERSION
-2
View File
@@ -8,7 +8,6 @@ import { AdminLayout } from "@/admin/AdminLayout";
import { LoginPage } from "@/admin/LoginPage";
import { RequireAuth } from "@/admin/RequireAuth";
import { DrivesPage } from "@/admin/DrivesPage";
import { CrawlersPage } from "@/admin/CrawlersPage";
import { VideosPage } from "@/admin/VideosPage";
import { TagsPage } from "@/admin/TagsPage";
import { ThemePage } from "@/admin/ThemePage";
@@ -71,7 +70,6 @@ export default function App() {
>
<Route index element={<Navigate to="/admin/drives" replace />} />
<Route path="drives" element={<DrivesPage />} />
<Route path="crawlers" element={<CrawlersPage />} />
<Route path="videos" element={<VideosPage />} />
<Route path="tags" element={<TagsPage />} />
<Route path="theme" element={<ThemePage />} />
+36 -115
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useState } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import {
HardDrive,
@@ -9,30 +9,16 @@ import {
Tags,
Palette,
RefreshCw,
MoreVertical,
} from "lucide-react";
import * as api from "./api";
import { useAuth } from "./AuthContext";
import { useToast } from "./ToastContext";
import { SpiderIcon } from "./icons/SpiderIcon";
export function AdminLayout() {
const { logout } = useAuth();
const navigate = useNavigate();
const { show } = useToast();
const [checkingUpdate, setCheckingUpdate] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
useEffect(() => {
if (!mobileMenuOpen) return;
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
setMobileMenuOpen(false);
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [mobileMenuOpen]);
async function handleCheckUpdate() {
if (checkingUpdate) return;
@@ -78,79 +64,41 @@ export function AdminLayout() {
<span className="admin-sidebar__brand-text">91</span>
</div>
<nav className="admin-nav">
<div className="admin-nav__group admin-nav__group--home">
<span className="admin-nav__group-label"></span>
<NavLink to="/" className="admin-nav__link">
<span className="admin-nav__icon"><Home size={16} /></span>
<span className="admin-nav__text">
<span className="admin-nav__title"></span>
</span>
</NavLink>
</div>
<div className="admin-nav__group">
<span className="admin-nav__group-label"></span>
<NavLink
to="/admin/drives"
className={({ isActive }) =>
`admin-nav__link ${isActive ? "is-active" : ""}`
}
>
<span className="admin-nav__icon"><HardDrive size={16} /></span>
<span className="admin-nav__text">
<span className="admin-nav__title"></span>
</span>
</NavLink>
<NavLink
to="/admin/crawlers"
className={({ isActive }) =>
`admin-nav__link ${isActive ? "is-active" : ""}`
}
>
<span className="admin-nav__icon"><SpiderIcon size={16} /></span>
<span className="admin-nav__text">
<span className="admin-nav__title"></span>
</span>
</NavLink>
</div>
<div className="admin-nav__group">
<span className="admin-nav__group-label"></span>
<NavLink
to="/admin/videos"
className={({ isActive }) =>
`admin-nav__link ${isActive ? "is-active" : ""}`
}
>
<span className="admin-nav__icon"><Film size={16} /></span>
<span className="admin-nav__text">
<span className="admin-nav__title"></span>
</span>
</NavLink>
<NavLink
to="/admin/tags"
className={({ isActive }) =>
`admin-nav__link ${isActive ? "is-active" : ""}`
}
>
<span className="admin-nav__icon"><Tags size={16} /></span>
<span className="admin-nav__text">
<span className="admin-nav__title"></span>
</span>
</NavLink>
</div>
<div className="admin-nav__group">
<span className="admin-nav__group-label"></span>
<NavLink
to="/admin/theme"
className={({ isActive }) =>
`admin-nav__link ${isActive ? "is-active" : ""}`
}
>
<span className="admin-nav__icon"><Palette size={16} /></span>
<span className="admin-nav__text">
<span className="admin-nav__title"></span>
</span>
</NavLink>
</div>
<NavLink to="/" className="admin-nav__link">
<Home size={16} />
</NavLink>
<NavLink
to="/admin/drives"
className={({ isActive }) =>
`admin-nav__link ${isActive ? "is-active" : ""}`
}
>
<HardDrive size={16} />
</NavLink>
<NavLink
to="/admin/videos"
className={({ isActive }) =>
`admin-nav__link ${isActive ? "is-active" : ""}`
}
>
<Film size={16} />
</NavLink>
<NavLink
to="/admin/tags"
className={({ isActive }) =>
`admin-nav__link ${isActive ? "is-active" : ""}`
}
>
<Tags size={16} />
</NavLink>
<NavLink
to="/admin/theme"
className={({ isActive }) =>
`admin-nav__link ${isActive ? "is-active" : ""}`
}
>
<Palette size={16} />
</NavLink>
</nav>
<div className="admin-sidebar__footer">
<button
@@ -166,34 +114,7 @@ export function AdminLayout() {
退
</button>
</div>
<button
className="admin-sidebar__mobile-menu"
onClick={() => setMobileMenuOpen((v) => !v)}
aria-label="更多操作"
>
<MoreVertical size={18} />
</button>
</aside>
{mobileMenuOpen && (
<div className="admin-sidebar__mobile-overlay" onClick={() => setMobileMenuOpen(false)} />
)}
<div className={`admin-sidebar__mobile-panel${mobileMenuOpen ? " is-open" : ""}`}>
<NavLink to="/" className="admin-sidebar__home" onClick={() => setMobileMenuOpen(false)}>
<Home size={14} />
</NavLink>
<button
className="admin-sidebar__check-update"
onClick={() => { handleCheckUpdate(); setMobileMenuOpen(false); }}
disabled={checkingUpdate}
>
<RefreshCw size={14} />
{checkingUpdate ? "检查中" : "检查更新"}
</button>
<button className="admin-sidebar__logout" onClick={() => { handleLogout(); setMobileMenuOpen(false); }}>
<LogOut size={14} />
退
</button>
</div>
<main className="admin-main">
<Outlet />
</main>
-76
View File
@@ -1,76 +0,0 @@
import type { ReactNode } from "react";
import { AlertTriangle } from "lucide-react";
import { Modal } from "./Modal";
type ConfirmModalProps = {
open: boolean;
title: string;
message: string;
details?: string[];
confirmText?: string;
cancelText?: string;
danger?: boolean;
centerMessage?: boolean;
modalClassName?: string;
loading?: boolean;
children?: ReactNode;
onCancel: () => void;
onConfirm: () => void;
};
export function ConfirmModal({
open,
title,
message,
details,
confirmText = "确认",
cancelText = "取消",
danger = false,
centerMessage = false,
modalClassName = "",
loading = false,
children,
onCancel,
onConfirm,
}: ConfirmModalProps) {
return (
<Modal
open={open}
title={title}
onClose={onCancel}
className={modalClassName}
footer={
<>
<button type="button" className="admin-btn" onClick={onCancel} disabled={loading}>
{cancelText}
</button>
<button
type="button"
className={`admin-btn${danger ? " is-danger" : " is-primary"}`}
onClick={onConfirm}
disabled={loading}
>
{loading ? "处理中..." : confirmText}
</button>
</>
}
>
<div className={`admin-confirm${centerMessage ? " is-message-centered" : ""}`}>
<div className={`admin-confirm__icon${danger ? " is-danger" : ""}`} aria-hidden={centerMessage}>
<AlertTriangle size={20} />
</div>
<div className="admin-confirm__content">
<p className="admin-confirm__message">{message}</p>
{details && details.length > 0 && (
<ul className="admin-confirm__list">
{details.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
)}
{children}
</div>
</div>
</Modal>
);
}
File diff suppressed because it is too large Load Diff
+1124 -449
View File
File diff suppressed because it is too large Load Diff
+4 -16
View File
@@ -16,7 +16,6 @@ export function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const { show } = useToast();
const passwordMismatch = setupRequired === true && p2.length > 0 && p !== p2;
useEffect(() => {
let active = true;
@@ -84,9 +83,8 @@ export function LoginPage() {
</h1>
<div className="admin-form">
<div className="admin-form__row">
<label htmlFor="admin-login-username"></label>
<label></label>
<input
id="admin-login-username"
autoFocus
value={u}
onChange={(e) => setU(e.target.value)}
@@ -94,9 +92,8 @@ export function LoginPage() {
/>
</div>
<div className="admin-form__row">
<label htmlFor="admin-login-password"></label>
<label></label>
<input
id="admin-login-password"
type="password"
value={p}
onChange={(e) => setP(e.target.value)}
@@ -105,28 +102,19 @@ export function LoginPage() {
</div>
{setupRequired && (
<div className="admin-form__row">
<label htmlFor="admin-login-password-confirm"></label>
<label></label>
<input
id="admin-login-password-confirm"
type="password"
value={p2}
onChange={(e) => setP2(e.target.value)}
autoComplete="new-password"
className={passwordMismatch ? "is-invalid" : undefined}
aria-invalid={passwordMismatch ? "true" : undefined}
aria-describedby={passwordMismatch ? "admin-login-password-confirm-error" : undefined}
/>
{passwordMismatch && (
<div className="admin-form__error" id="admin-login-password-confirm-error">
</div>
)}
</div>
)}
<button
className="admin-btn is-primary"
type="submit"
disabled={loading || !u || !p || (setupRequired && (!p2 || passwordMismatch))}
disabled={loading || !u || !p || (setupRequired && !p2)}
>
{loading
? setupRequired
+10 -98
View File
@@ -1,4 +1,4 @@
import { useEffect, useId, useRef, ReactNode } from "react";
import { ReactNode } from "react";
import { X } from "lucide-react";
type Props = {
@@ -7,87 +7,21 @@ type Props = {
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
className?: string;
};
export function Modal({ open, title, onClose, children, footer, className = "" }: Props) {
const dialogRef = useRef<HTMLDivElement>(null);
const onCloseRef = useRef(onClose);
const titleId = useId();
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
useEffect(() => {
if (!open) return;
const previousFocus =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
function onKeyDown(e: KeyboardEvent) {
const dialog = dialogRef.current;
if (!dialog || !isTopDialog(dialog)) return;
if (e.key === "Escape") {
e.preventDefault();
onCloseRef.current();
return;
}
if (e.key !== "Tab") return;
const focusable = getFocusableElements(dialog);
if (focusable.length === 0) {
e.preventDefault();
dialog.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const current = document.activeElement;
if (e.shiftKey && current === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && current === last) {
e.preventDefault();
first.focus();
}
}
const focusTimer = window.setTimeout(() => {
const dialog = dialogRef.current;
if (!dialog || !isTopDialog(dialog)) return;
const first = getFocusableElements(dialog)[0];
(first ?? dialog).focus();
}, 0);
document.addEventListener("keydown", onKeyDown);
return () => {
window.clearTimeout(focusTimer);
document.removeEventListener("keydown", onKeyDown);
if (previousFocus?.isConnected) {
previousFocus.focus();
}
};
}, [open]);
export function Modal({ open, title, onClose, children, footer }: Props) {
if (!open) return null;
return (
<div className="admin-modal-backdrop">
<div
ref={dialogRef}
className={`admin-modal${className ? ` ${className}` : ""}`}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
>
<div
className="admin-modal-backdrop"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="admin-modal" role="dialog" aria-modal="true">
<div className="admin-modal__header">
<span id={titleId}>{title}</span>
<span>{title}</span>
<button
type="button"
className="admin-btn"
onClick={onClose}
aria-label="关闭"
@@ -102,25 +36,3 @@ export function Modal({ open, title, onClose, children, footer, className = "" }
</div>
);
}
function getFocusableElements(root: HTMLElement): HTMLElement[] {
const selectors = [
"a[href]",
"button:not([disabled])",
"textarea:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
return Array.from(root.querySelectorAll<HTMLElement>(selectors)).filter(
(el) => !el.hasAttribute("disabled") && el.getAttribute("aria-hidden") !== "true"
);
}
function isTopDialog(dialog: HTMLElement): boolean {
const dialogs = Array.from(
document.querySelectorAll<HTMLElement>('[role="dialog"][aria-modal="true"]')
);
return dialogs[dialogs.length - 1] === dialog;
}
+5 -1
View File
@@ -8,7 +8,11 @@ export function RequireAuth({ children }: { children: ReactNode }) {
const location = useLocation();
if (status === "loading") {
return null;
return (
<div className="admin-loading-screen">
...
</div>
);
}
if (status === "guest") {
+47 -103
View File
@@ -2,26 +2,18 @@ import { useEffect, useMemo, useState } from "react";
import { CheckSquare, Film, Plus, RefreshCw, Search, Tags, Trash2 } from "lucide-react";
import * as api from "./api";
import { useToast } from "./ToastContext";
import { ConfirmModal } from "./ConfirmModal";
const DESKTOP_TAGS_PAGE_SIZE = 25;
const MOBILE_TAGS_PAGE_SIZE = 8;
const TAGS_MOBILE_QUERY = "(max-width: 640px)";
type DeleteConfirmState =
| { kind: "single"; tag: api.AdminTag }
| { kind: "bulk"; ids: number[] }
| null;
export function TagsPage() {
const [tags, setTags] = useState<api.AdminTag[]>([]);
const [label, setLabel] = useState("");
const [aliases, setAliases] = useState("");
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState("");
const [saving, setSaving] = useState(false);
const [deletingId, setDeletingId] = useState<number | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<DeleteConfirmState>(null);
const [searchQuery, setSearchQuery] = useState("");
const [filterSource, setFilterSource] = useState<string>("all");
const [selectMode, setSelectMode] = useState(false);
@@ -33,13 +25,10 @@ export function TagsPage() {
async function refresh() {
setLoading(true);
setLoadError("");
try {
setTags(await api.listTags());
} catch (e) {
const message = e instanceof Error ? e.message : "加载标签失败";
setLoadError(message);
show(message, "error");
show(e instanceof Error ? e.message : "加载标签失败", "error");
} finally {
setLoading(false);
}
@@ -66,9 +55,21 @@ export function TagsPage() {
}
}
function handleDelete(tag: api.AdminTag) {
async function handleDelete(tag: api.AdminTag) {
if (tag.source === "system") return;
setDeleteConfirm({ kind: "single", tag });
if (!window.confirm(`确定删除标签「${tag.label}」吗?此操作会从所有视频上移除该标签。`)) {
return;
}
setDeletingId(tag.id);
try {
const r = await api.deleteTag(tag.id);
show(`已删除标签,并从 ${r.removedVideos} 个视频移除`, "success");
await refresh();
} catch (e) {
show(e instanceof Error ? e.message : "删除标签失败", "error");
} finally {
setDeletingId(null);
}
}
function toggleSelectMode() {
@@ -87,40 +88,24 @@ export function TagsPage() {
async function handleBulkDelete() {
const ids = [...selected];
if (ids.length === 0) return;
setDeleteConfirm({ kind: "bulk", ids });
}
async function confirmDelete() {
if (!deleteConfirm) return;
if (deleteConfirm.kind === "single") {
const tag = deleteConfirm.tag;
setDeletingId(tag.id);
try {
const r = await api.deleteTag(tag.id);
show(`已删除标签,并从 ${r.removedVideos} 个视频移除`, "success");
setDeleteConfirm(null);
await refresh();
} catch (e) {
show(e instanceof Error ? e.message : "删除标签失败", "error");
} finally {
setDeletingId(null);
}
if (!window.confirm(`确定删除选中的 ${ids.length} 个标签吗?此操作会从所有视频上移除这些标签。`)) {
return;
}
const ids = deleteConfirm.ids;
setBulkDeleting(true);
try {
const results = await Promise.allSettled(
ids.map((id) => api.deleteTag(id))
);
const ok = results.filter((r) => r.status === "fulfilled").length;
let ok = 0;
for (const id of ids) {
try {
await api.deleteTag(id);
ok += 1;
} catch {
/* 统计失败数,继续删除其余标签 */
}
}
const failed = ids.length - ok;
show(failed ? `已删除 ${ok} 个,${failed} 个失败` : `已删除 ${ok} 个标签`, failed ? "error" : "success");
setSelected(new Set());
setSelectMode(false);
setDeleteConfirm(null);
await refresh();
} finally {
setBulkDeleting(false);
@@ -203,7 +188,7 @@ export function TagsPage() {
<section>
<header className="admin-page__header">
<h1 className="admin-page__title"></h1>
<button type="button" className="admin-btn" onClick={refresh}>
<button className="admin-btn" onClick={refresh}>
<RefreshCw size={13} />
</button>
</header>
@@ -215,39 +200,34 @@ export function TagsPage() {
<div className="admin-card__title">
<Plus size={15} />
</div>
<form
className="admin-form"
onSubmit={(e) => {
e.preventDefault();
handleCreate();
}}
>
<div className="admin-form">
<div className="admin-form__row">
<label htmlFor="admin-tag-label"></label>
<label></label>
<input
id="admin-tag-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="例如:清纯"
/>
</div>
<div className="admin-form__row">
<label htmlFor="admin-tag-aliases"></label>
<label></label>
<input
id="admin-tag-aliases"
value={aliases}
onChange={(e) => setAliases(e.target.value)}
placeholder="逗号分隔,例如:纯欲, 清新"
/>
<div className="admin-form__help">
</div>
</div>
<button
type="submit"
className="admin-btn is-primary"
onClick={handleCreate}
disabled={saving || !label.trim()}
>
<Plus size={13} /> {saving ? "添加中..." : "添加并自动归类"}
</button>
</form>
</div>
</div>
<div className="admin-card">
@@ -273,7 +253,6 @@ export function TagsPage() {
<div className="admin-tags-search">
<Search className="admin-tags-search__icon" size={14} />
<input
aria-label="搜索标签名或别名"
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
@@ -357,18 +336,7 @@ export function TagsPage() {
)}
{loading ? (
<div className="admin-loading-state">
<RefreshCw size={20} className="admin-spin" />
<span>...</span>
</div>
) : loadError ? (
<div className="admin-error-state">
<strong></strong>
<span>{loadError}</span>
<button type="button" className="admin-btn" onClick={refresh}>
<RefreshCw size={13} />
</button>
</div>
<div className="admin-empty">...</div>
) : filteredTags.length === 0 ? (
<div className="admin-card admin-empty">
@@ -379,18 +347,21 @@ export function TagsPage() {
{pagedTags.map((tag) => {
const selectable = selectMode && tag.source !== "system";
const isSelected = selected.has(tag.id);
const cardClass = `admin-tag-card${selectable ? " is-selectable" : ""}${
selectable && isSelected ? " is-selected" : ""
}`;
const cardContent = (
<>
return (
<div
key={tag.id}
className={`admin-tag-card${selectable ? " is-selectable" : ""}${
selectable && isSelected ? " is-selected" : ""
}`}
onClick={selectable ? () => toggleSelect(tag.id) : undefined}
>
<div className="admin-tag-card__head">
{selectable && (
<input
type="checkbox"
className="admin-tag-card__check"
checked={isSelected}
onChange={() => toggleSelect(tag.id)}
readOnly
/>
)}
<span className="admin-tag-card__title">{tag.label}</span>
@@ -409,7 +380,7 @@ export function TagsPage() {
</div>
)}
<div className="admin-tag-card__footer">
<div className="admin-tag-card__footer">
<span className="admin-tag-card__count">
<Film size={13} />
<strong>{tag.count}</strong>
@@ -428,17 +399,8 @@ export function TagsPage() {
<span>{deletingId === tag.id ? "删除中" : "删除"}</span>
</button>
)}
</div>
</div>
</>
);
return selectable ? (
<label key={tag.id} className={cardClass}>
{cardContent}
</label>
) : (
<div key={tag.id} className={cardClass}>
{cardContent}
</div>
</div>
</div>
);
})}
@@ -487,24 +449,6 @@ export function TagsPage() {
)}
</div>
</div>
<ConfirmModal
open={!!deleteConfirm}
title={deleteConfirm?.kind === "bulk" ? "删除选中标签" : "删除标签"}
message={
deleteConfirm?.kind === "bulk"
? `确定要删除选中的 ${deleteConfirm.ids.length} 个标签吗?`
: `确定要删除标签「${deleteConfirm?.tag.label ?? ""}」吗?`
}
confirmText="确认删除"
danger
centerMessage
modalClassName="admin-modal--delete-confirm"
loading={deletingId !== null || bulkDeleting}
onCancel={() => {
if (deletingId === null && !bulkDeleting) setDeleteConfirm(null);
}}
onConfirm={confirmDelete}
/>
</section>
);
}
+3
View File
@@ -103,6 +103,9 @@ export function ThemePage() {
<div className="theme-page">
<header className="theme-page__head">
<h1 className="theme-page__title"></h1>
<p className="theme-page__sub">
访
</p>
</header>
<div className="theme-grid">
+5 -7
View File
@@ -43,13 +43,11 @@ export function ToastProvider({ children }: { children: ReactNode }) {
return (
<ToastCtx.Provider value={{ show }}>
{children}
<div className="admin-toast-stack" role="status" aria-live="polite">
{items.map((t) => (
<div key={t.id} className={`admin-toast is-${t.kind}`}>
{t.text}
</div>
))}
</div>
{items.map((t) => (
<div key={t.id} className={`admin-toast is-${t.kind}`}>
{t.text}
</div>
))}
</ToastCtx.Provider>
);
}
+138 -439
View File
@@ -1,45 +1,28 @@
import { useEffect, useId, useState } from "react";
import { ChevronDown, Edit, RefreshCw, Search, CheckSquare, Square, Image, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Edit, RefreshCw, Search } from "lucide-react";
import * as api from "./api";
import { useToast } from "./ToastContext";
import { Modal } from "./Modal";
import { ConfirmModal } from "./ConfirmModal";
import { formatBytes } from "./storageFormat";
const DESKTOP_VIDEOS_PAGE_SIZE = 50;
const MOBILE_VIDEOS_PAGE_SIZE = 20;
const VIDEOS_MOBILE_QUERY = "(max-width: 640px)";
const PAGE_SIZE = 100;
export function VideosPage() {
const [list, setList] = useState<api.AdminVideo[]>([]);
const [drives, setDrives] = useState<api.AdminDrive[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState("");
const [keyword, setKeyword] = useState("");
const [searchKeyword, setSearchKeyword] = useState("");
const [driveId, setDriveId] = useState("");
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [editing, setEditing] = useState<api.AdminVideo | null>(null);
const [availableTags, setAvailableTags] = useState<api.AdminTag[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [batchRegenOpen, setBatchRegenOpen] = useState(false);
const [batchRegening, setBatchRegening] = useState(false);
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false);
const [batchDeleting, setBatchDeleting] = useState(false);
const [batchDeleteSource, setBatchDeleteSource] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<api.AdminVideo | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteSource, setDeleteSource] = useState(false);
const pageSize = useVideosPageSize();
const { show } = useToast();
async function refresh() {
setLoading(true);
setLoadError("");
try {
const [r, tagList, driveList] = await Promise.all([
api.listVideos({ driveId, page, size: pageSize, keyword: searchKeyword }),
api.listVideos({ driveId, page, size: PAGE_SIZE }),
api.listTags(),
api.listDrives(),
]);
@@ -47,11 +30,8 @@ export function VideosPage() {
setTotal(r.total ?? 0);
setAvailableTags(tagList);
setDrives(driveList ?? []);
setSelectedIds(new Set());
} catch (e) {
const message = e instanceof Error ? e.message : "加载失败";
setLoadError(message);
show(message, "error");
show(e instanceof Error ? e.message : "加载失败", "error");
} finally {
setLoading(false);
}
@@ -59,325 +39,164 @@ export function VideosPage() {
useEffect(() => {
refresh();
}, [driveId, page, searchKeyword, pageSize]);
useEffect(() => {
setPage(1);
}, [pageSize]);
useEffect(() => {
if (keyword === searchKeyword) return;
const timer = window.setTimeout(() => {
setSearchKeyword(keyword);
setPage(1);
}, 300);
return () => window.clearTimeout(timer);
}, [keyword]);
}, [driveId, page]);
const driveNameMap = new Map(
drives.map((d) => [d.id, d.name || d.id])
);
const listItems = list;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const pageStart = total === 0 ? 0 : (page - 1) * pageSize + 1;
const pageEnd = Math.min(total, page * pageSize);
const listSummary = driveId
? `${driveNameMap.get(driveId) ?? driveId}:共 ${total} 个视频,第 ${page} / ${totalPages} 页,显示 ${pageStart}-${pageEnd}`
: `全部网盘:共 ${total} 个视频,第 ${page} / ${totalPages} 页,显示 ${pageStart}-${pageEnd}`;
const filtered = keyword.trim()
? list.filter((v) => {
const k = keyword.toLowerCase();
return (
v.title.toLowerCase().includes(k) ||
(v.author ?? "").toLowerCase().includes(k)
);
})
: list;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const pageStart = total === 0 ? 0 : (page - 1) * PAGE_SIZE + 1;
const pageEnd = Math.min(total, page * PAGE_SIZE);
async function handleRegen(v: api.AdminVideo) {
try {
await api.regenPreview(v.id);
show("已触发预览视频重生", "success");
show("已触发 teaser 重生", "success");
} catch (e) {
show(e instanceof Error ? e.message : "触发失败", "error");
}
}
async function handleBatchRegen() {
if (selectedIds.size === 0) return;
setBatchRegenOpen(true);
}
async function handleBatchDelete() {
if (selectedIds.size === 0) return;
setBatchDeleteSource(false);
setBatchDeleteOpen(true);
}
async function confirmBatchRegen() {
const ids = [...selectedIds];
setBatchRegening(true);
let success = 0;
try {
const results = await Promise.allSettled(
ids.map((id) => api.regenPreview(id))
);
for (const r of results) {
if (r.status === "fulfilled") success++;
}
show(`批量触发完成,成功 ${success} / ${ids.length}`, success === ids.length ? "success" : "info");
setSelectedIds(new Set());
setBatchRegenOpen(false);
} finally {
setBatchRegening(false);
}
}
async function confirmDeleteVideo() {
if (!deleteTarget) return;
const target = deleteTarget;
setDeleting(true);
try {
const result = await api.deleteVideo(target.id, { deleteSource });
setDeleteTarget(null);
setDeleteSource(false);
setSelectedIds((ids) => {
const next = new Set(ids);
next.delete(target.id);
return next;
});
show(result.deletedSource ? "已删除视频,并清理源文件" : "已删除视频", "success");
if (listItems.length === 1 && page > 1) {
setPage((p) => Math.max(1, p - 1));
} else {
refresh();
}
} catch (e) {
show(e instanceof Error ? e.message : "删除失败", "error");
} finally {
setDeleting(false);
}
}
async function confirmBatchDelete() {
const ids = [...selectedIds];
if (ids.length === 0) return;
setBatchDeleting(true);
try {
let success = 0;
let deletedSources = 0;
for (const id of ids) {
try {
const result = await api.deleteVideo(id, { deleteSource: batchDeleteSource });
success++;
if (result.deletedSource) deletedSources++;
} catch {
// Keep deleting the rest of the selected videos; report aggregate failure below.
}
}
const failed = ids.length - success;
if (failed === 0) {
const extra = deletedSources > 0 ? `,其中 ${deletedSources} 个清理了源文件` : "";
show(`批量删除完成,成功 ${success}${extra}`, "success");
} else {
show(`批量删除完成,成功 ${success} / ${ids.length} 个,失败 ${failed}`, success > 0 ? "info" : "error");
}
setSelectedIds(new Set());
setBatchDeleteOpen(false);
setBatchDeleteSource(false);
if (success >= listItems.length && page > 1) {
setPage((p) => Math.max(1, p - 1));
} else {
refresh();
}
} finally {
setBatchDeleting(false);
}
}
const toggleSelectAll = () => {
if (selectedIds.size === listItems.length && listItems.length > 0) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(listItems.map(v => v.id)));
}
};
const toggleSelect = (id: string) => {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedIds(next);
};
function handleSearchSubmit(e: React.FormEvent) {
e.preventDefault();
setSearchKeyword(keyword);
setPage(1);
}
return (
<section>
<header className="admin-page__header">
<h1 className="admin-page__title"></h1>
<div className="admin-page__actions admin-videos-filter">
<div className="admin-videos-filter__select-wrap">
<select
className="admin-videos-filter__select"
value={driveId}
onChange={(e) => {
setDriveId(e.target.value);
setPage(1);
}}
>
<option value=""></option>
{drives.map((d) => (
<option key={d.id} value={d.id}>
{d.name || d.id} {d.teaserReadyCount ?? 0}{" "}
{d.teaserPendingCount ?? 0}
</option>
))}
</select>
<ChevronDown size={15} className="admin-videos-filter__select-icon" aria-hidden="true" />
</div>
<form className="admin-videos-filter__search" onSubmit={handleSearchSubmit}>
<select
className="admin-videos-filter__select"
value={driveId}
onChange={(e) => {
setDriveId(e.target.value);
setPage(1);
}}
>
<option value=""></option>
{drives.map((d) => (
<option key={d.id} value={d.id}>
{d.name || d.id} {d.teaserReadyCount ?? 0}{" "}
{d.teaserPendingCount ?? 0}
</option>
))}
</select>
<div className="admin-videos-filter__search">
<Search size={14} className="admin-videos-filter__search-icon" />
<input
aria-label="搜索标题或作者"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索标题 / 作者"
/>
</form>
<button type="button" className="admin-btn" onClick={refresh}>
</div>
<button className="admin-btn" onClick={refresh}>
<RefreshCw size={13} />
</button>
</div>
</header>
{!loading && (
<div className="admin-videos-list-toolbar">
<div className="admin-videos-summary">{listSummary}</div>
{selectedIds.size > 0 && (
<div className="admin-videos-bulk-actions">
<span className="admin-videos-bulk-actions__count">
{selectedIds.size}
{drives.length > 0 && (
<div className="admin-drive-teasers" aria-label="网盘 Teaser 统计">
{drives.map((d) => (
<button
key={d.id}
type="button"
className={`admin-drive-teaser${
driveId === d.id ? " is-active" : ""
}`}
onClick={() => {
setDriveId(d.id);
setPage(1);
}}
>
<span className="admin-drive-teaser__name">{d.name || d.id}</span>
<span className="admin-drive-teaser__metric is-ready">
{d.teaserReadyCount ?? 0}
</span>
<button type="button" className="admin-btn is-primary admin-videos-bulk-actions__btn" onClick={handleBatchRegen}>
<RefreshCw size={13} />
</button>
<button type="button" className="admin-btn is-danger admin-videos-bulk-actions__btn" onClick={handleBatchDelete}>
<Trash2 size={13} />
</button>
</div>
)}
<span className="admin-drive-teaser__metric is-pending">
{d.teaserPendingCount ?? 0}
</span>
{(d.teaserFailedCount ?? 0) > 0 && (
<span className="admin-drive-teaser__metric is-failed">
{d.teaserFailedCount}
</span>
)}
</button>
))}
</div>
)}
{!loading && (
<div className="admin-videos-summary">
{driveId
? `${driveNameMap.get(driveId) ?? driveId}:共 ${total} 个视频,第 ${page} / ${totalPages} 页,显示 ${pageStart}-${pageEnd}`
: `全部网盘:共 ${total} 个视频,第 ${page} / ${totalPages} 页,显示 ${pageStart}-${pageEnd}`}
</div>
)}
{loading ? (
<div className="admin-loading-state">
<RefreshCw size={20} className="admin-spin" />
<span>...</span>
</div>
) : loadError ? (
<div className="admin-error-state">
<strong></strong>
<span>{loadError}</span>
<button type="button" className="admin-btn" onClick={refresh}>
<RefreshCw size={13} />
</button>
</div>
) : listItems.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-state__icon">
<Image size={48} />
</div>
<div className="admin-empty-state__text">
{driveId
? "这个网盘下还没有可显示的视频,或未匹配到搜索结果。"
: "还没有视频。先在「网盘管理」里配置好盘并触发扫描,或调整搜索词。"}
</div>
<div className="admin-empty">...</div>
) : filtered.length === 0 ? (
<div className="admin-card admin-empty">
{driveId
? "这个网盘下还没有可显示的视频。可以在「网盘管理」里触发重扫。"
: "还没有视频。先在「网盘管理」里配置好盘并触发扫描。"}
</div>
) : (
<>
<table className="admin-table is-selectable admin-videos-table">
<table className="admin-table">
<thead>
<tr>
<th className="is-checkbox" style={{ width: '40px' }}>
<button
type="button"
className="admin-table-checkbox-btn"
onClick={toggleSelectAll}
aria-label={selectedIds.size > 0 && selectedIds.size === listItems.length ? "清空当前页选择" : "选择当前页视频"}
>
{selectedIds.size > 0 && selectedIds.size === listItems.length ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th>Teaser</th>
<th></th>
<th className="is-actions"></th>
</tr>
</thead>
<tbody>
{listItems.map((v) => (
<tr key={v.id} className={selectedIds.has(v.id) ? "is-selected" : ""}>
<td className="is-checkbox">
<button
type="button"
className="admin-table-checkbox-btn"
onClick={() => toggleSelect(v.id)}
aria-label={`${selectedIds.has(v.id) ? "取消选择" : "选择"}视频 ${v.title}`}
>
{selectedIds.has(v.id) ? <CheckSquare size={16} color="var(--accent)" /> : <Square size={16} color="var(--border-strong)" />}
</button>
</td>
{filtered.map((v) => (
<tr key={v.id}>
<td data-label="标题">
<div className="admin-video-title-cell">
<div className="admin-video-thumb-wrap" aria-hidden="true">
{v.thumbnailUrl ? (
<img className="admin-video-thumb" src={v.thumbnailUrl} alt="" />
) : (
<div className="admin-video-thumb-placeholder">
<Image size={14} />
</div>
)}
<div className="admin-video-title">{v.title}</div>
{fileMeta(v) && (
<div className="admin-video-filemeta">
{fileMeta(v)}
</div>
<div className="admin-video-title-body">
<div className="admin-video-title">{v.title}</div>
{fileMeta(v) && (
<div className="admin-video-filemeta">{fileMeta(v)}</div>
)}
{(v.tags ?? []).length > 0 && (
<div className="admin-pills admin-video-title-tags">
{(v.tags ?? []).map((t) => (
<span key={t} className="admin-pill">{t}</span>
))}
</div>
)}
<VideoFileMetaPills video={v} />
</div>
</div>
)}
</td>
<td data-label="作者">{v.author || <span className="admin-text-faint"></span>}</td>
<td data-label="标签">
<div className="admin-pills">
{(v.tags ?? []).map((t) => (
<span key={t} className="admin-pill">
{t}
</span>
))}
</div>
</td>
<td data-label="时长">{formatDur(v.durationSeconds)}</td>
<td data-label="预览视频">
<td data-label="Teaser">
<PreviewStatus s={v.previewStatus} />
</td>
<td data-label="来源" className="admin-mono-cell">
{driveNameMap.get(v.driveId) ?? v.driveId}
</td>
<td className="is-actions" data-label="操作">
<button type="button" className="admin-btn" onClick={() => setEditing(v)} title="编辑视频">
<Edit size={13} />
<button className="admin-btn" onClick={() => setEditing(v)}>
<Edit size={13} />
</button>{" "}
<button type="button" className="admin-btn" onClick={() => handleRegen(v)} title="重生预览视频">
<RefreshCw size={13} />
</button>{" "}
<button
type="button"
className="admin-btn is-danger"
onClick={() => {
setDeleteSource(false);
setDeleteTarget(v);
}}
title="删除视频"
>
<Trash2 size={13} />
<button className="admin-btn" onClick={() => handleRegen(v)}>
<RefreshCw size={13} /> teaser
</button>
</td>
</tr>
@@ -386,7 +205,6 @@ export function VideosPage() {
</table>
<div className="admin-table-pagination">
<button
type="button"
className="admin-btn"
onClick={() => setPage(1)}
disabled={page <= 1}
@@ -394,7 +212,6 @@ export function VideosPage() {
</button>
<button
type="button"
className="admin-btn"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
@@ -402,10 +219,9 @@ export function VideosPage() {
</button>
<span className="admin-table-pagination__info">
{page} / {totalPages} {pageSize}
{page} / {totalPages} {PAGE_SIZE}
</span>
<button
type="button"
className="admin-btn"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
@@ -413,7 +229,6 @@ export function VideosPage() {
</button>
<button
type="button"
className="admin-btn"
onClick={() => setPage(totalPages)}
disabled={page >= totalPages}
@@ -435,77 +250,6 @@ export function VideosPage() {
}}
/>
)}
<ConfirmModal
open={batchRegenOpen}
title="批量重生预览视频"
message={`确定要为当前页选中的 ${selectedIds.size} 个视频重新生成预览视频吗?`}
confirmText="确认重生"
loading={batchRegening}
onCancel={() => {
if (!batchRegening) setBatchRegenOpen(false);
}}
onConfirm={confirmBatchRegen}
/>
<ConfirmModal
open={deleteTarget !== null}
title="删除视频"
message={deleteTarget ? `确定要删除「${deleteTarget.title}」吗?` : ""}
confirmText="删除视频"
danger
centerMessage
modalClassName="admin-modal--delete-confirm"
loading={deleting}
onCancel={() => {
if (!deleting) {
setDeleteTarget(null);
setDeleteSource(false);
}
}}
onConfirm={confirmDeleteVideo}
>
<label className="admin-delete-source-option">
<input
type="checkbox"
checked={deleteSource}
disabled={deleting}
onChange={(e) => setDeleteSource(e.target.checked)}
/>
<span>
<strong></strong>
<small></small>
</span>
</label>
</ConfirmModal>
<ConfirmModal
open={batchDeleteOpen}
title="批量删除视频"
message={`确定要删除当前页选中的 ${selectedIds.size} 个视频吗?`}
confirmText="批量删除"
danger
centerMessage
modalClassName="admin-modal--delete-confirm"
loading={batchDeleting}
onCancel={() => {
if (!batchDeleting) {
setBatchDeleteOpen(false);
setBatchDeleteSource(false);
}
}}
onConfirm={confirmBatchDelete}
>
<label className="admin-delete-source-option">
<input
type="checkbox"
checked={batchDeleteSource}
disabled={batchDeleting}
onChange={(e) => setBatchDeleteSource(e.target.checked)}
/>
<span>
<strong></strong>
<small></small>
</span>
</label>
</ConfirmModal>
</section>
);
}
@@ -517,27 +261,6 @@ function PreviewStatus({ s }: { s: string }) {
return <span className="admin-status is-pending"></span>;
}
function VideoFileMetaPills({ video }: { video: api.AdminVideo }) {
const parts = fileMetaParts(video);
const category = (video.category ?? "").trim();
if (parts.length === 0 && !category) return null;
return (
<div className="admin-video-filemeta-pills" aria-label="视频文件信息">
{parts.map((part, index) => (
<span key={`${part}-${index}`} className="admin-video-filemeta-pill">
{part}
</span>
))}
{category && (
<span className="admin-video-filemeta-pill is-category">
{category}
</span>
)}
</div>
);
}
function formatDur(sec: number): string {
if (!sec) return "—";
const m = Math.floor(sec / 60);
@@ -545,26 +268,6 @@ function formatDur(sec: number): string {
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
}
function useVideosPageSize() {
const [pageSize, setPageSize] = useState(() =>
window.matchMedia(VIDEOS_MOBILE_QUERY).matches
? MOBILE_VIDEOS_PAGE_SIZE
: DESKTOP_VIDEOS_PAGE_SIZE
);
useEffect(() => {
const media = window.matchMedia(VIDEOS_MOBILE_QUERY);
const update = () => {
setPageSize(media.matches ? MOBILE_VIDEOS_PAGE_SIZE : DESKTOP_VIDEOS_PAGE_SIZE);
};
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
return pageSize;
}
function EditVideoModal({
video,
availableTags,
@@ -576,7 +279,6 @@ function EditVideoModal({
onClose: () => void;
onSaved: () => void;
}) {
const idPrefix = useId();
const [title, setTitle] = useState(video.title);
const [author, setAuthor] = useState(video.author ?? "");
const [selectedTags, setSelectedTags] = useState(video.tags ?? []);
@@ -619,10 +321,10 @@ function EditVideoModal({
onClose={onClose}
footer={
<>
<button type="button" className="admin-btn" onClick={onClose}>
<button className="admin-btn" onClick={onClose}>
</button>
<button type="button" className="admin-btn is-primary" onClick={handleSave} disabled={saving}>
<button className="admin-btn is-primary" onClick={handleSave} disabled={saving}>
{saving ? "保存中..." : "保存"}
</button>
</>
@@ -630,15 +332,15 @@ function EditVideoModal({
>
<div className="admin-form">
<div className="admin-form__row">
<label htmlFor={`${idPrefix}-video-title`}></label>
<input id={`${idPrefix}-video-title`} value={title} onChange={(e) => setTitle(e.target.value)} />
<label></label>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div className="admin-form__row">
<label htmlFor={`${idPrefix}-video-author`}></label>
<input id={`${idPrefix}-video-author`} value={author} onChange={(e) => setAuthor(e.target.value)} />
<label></label>
<input value={author} onChange={(e) => setAuthor(e.target.value)} />
</div>
<div className="admin-form__row">
<div className="admin-form__label"></div>
<label></label>
<div className="admin-tag-picker">
{availableTags.map((tag) => (
<label key={tag.id} className="admin-check">
@@ -654,49 +356,36 @@ function EditVideoModal({
</div>
</div>
<div className="admin-form__row">
<label htmlFor={`${idPrefix}-video-category`}></label>
<input id={`${idPrefix}-video-category`} value={category} onChange={(e) => setCategory(e.target.value)} />
<label></label>
<input value={category} onChange={(e) => setCategory(e.target.value)} />
</div>
<div className="admin-form__row">
<label htmlFor={`${idPrefix}-video-badges`}> , </label>
<input id={`${idPrefix}-video-badges`} value={badges} onChange={(e) => setBadges(e.target.value)} />
<label> , </label>
<input value={badges} onChange={(e) => setBadges(e.target.value)} />
</div>
<div className="admin-form__row">
<label htmlFor={`${idPrefix}-video-quality`}></label>
<select id={`${idPrefix}-video-quality`} value={quality} onChange={(e) => setQuality(e.target.value)}>
<label></label>
<select value={quality} onChange={(e) => setQuality(e.target.value)}>
<option value=""></option>
<option value="HD">HD</option>
<option value="SD">SD</option>
</select>
</div>
<div className="admin-form__row">
<label htmlFor={`${idPrefix}-video-duration`}></label>
<label></label>
<input
id={`${idPrefix}-video-duration`}
value={durationSec}
onChange={(e) => setDurationSec(e.target.value)}
inputMode="numeric"
/>
</div>
<div className="admin-form__row">
<label htmlFor={`${idPrefix}-video-thumbnail`}> URL</label>
<div className="admin-thumbnail-preview">
<input id={`${idPrefix}-video-thumbnail`} value={thumbnail} onChange={(e) => setThumbnail(e.target.value)} />
{thumbnail && (
<img
src={thumbnail}
alt="封面预览"
className="admin-thumbnail-img"
onError={(e) => (e.currentTarget.style.display = 'none')}
onLoad={(e) => (e.currentTarget.style.display = 'block')}
/>
)}
</div>
<label> URL</label>
<input value={thumbnail} onChange={(e) => setThumbnail(e.target.value)} />
</div>
<div className="admin-form__row">
<label htmlFor={`${idPrefix}-video-description`}></label>
<label></label>
<textarea
id={`${idPrefix}-video-description`}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
@@ -706,7 +395,7 @@ function EditVideoModal({
<dd>{video.driveId}</dd>
<dt></dt>
<dd>{fileMeta(video) || "—"}</dd>
<dt></dt>
<dt>Teaser</dt>
<dd>
<PreviewStatus s={video.previewStatus} />
</dd>
@@ -726,15 +415,12 @@ function EditVideoModal({
}
function fileMeta(v: api.AdminVideo): string {
return fileMetaParts(v).join(" · ");
}
function fileMetaParts(v: api.AdminVideo): string[] {
return [
const parts = [
normalizeExt(v.ext),
v.quality,
v.size > 0 ? formatBytes(v.size) : "",
formatBytes(v.size),
].filter(Boolean);
return parts.join(" · ");
}
function normalizeExt(ext: string): string {
@@ -742,6 +428,19 @@ function normalizeExt(ext: string): string {
return value ? value.toUpperCase() : "";
}
function formatBytes(size: number): string {
if (!size || size <= 0) return "";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = size;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
const digits = unit === 0 || value >= 100 ? 0 : 1;
return `${value.toFixed(digits)} ${units[unit]}`;
}
function splitList(s: string): string[] {
return s
.split(/[,,、\s]+/)
+17 -232
View File
@@ -12,14 +12,13 @@ async function request<T>(
path: string,
init: RequestInit = {}
): Promise<T> {
const headers = new Headers(init.headers ?? {});
if (!(init.body instanceof FormData) && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const res = await fetch(BASE + path, {
credentials: "include",
headers: {
"Content-Type": "application/json",
...(init.headers ?? {}),
},
...init,
headers,
});
if (res.status === 401) {
throw new UnauthorizedError();
@@ -78,13 +77,13 @@ export function checkUpdate() {
export type AdminDrive = {
id: string;
kind: "quark" | "p115" | "p123" | "pikpak" | "wopan" | "onedrive" | "googledrive" | "localstorage" | "spider91";
kind: "quark" | "p115" | "pikpak" | "wopan" | "onedrive" | "googledrive" | "localstorage" | "spider91";
name: string;
rootId: string;
status: string;
lastError?: string;
hasCredential: boolean;
/** 当前是否给该盘生成预览视频/封面(per-drive 开关,替代旧的全局 preview.enabled)。 */
/** 当前是否给该盘生成 teaser/封面(per-drive 开关,替代旧的全局 preview.enabled)。 */
teaserEnabled: boolean;
/**
* admin "扫描跳过目录"drive fileID
@@ -96,9 +95,6 @@ export type AdminDrive = {
lastCrawlAt?: number;
// spider91 专用代理地址;仅后台管理接口返回,用于编辑表单回显。
spider91Proxy?: string;
// Google Drive 是否使用 OpenList 在线续期 API;未配置时后端按 true 返回。
googleDriveUseOnlineAPI?: boolean;
scanGenerationStatus?: DriveGenerationStatus;
thumbnailGenerationStatus?: DriveGenerationStatus;
previewGenerationStatus?: DriveGenerationStatus;
fingerprintGenerationStatus?: DriveGenerationStatus;
@@ -119,10 +115,6 @@ export type DriveGenerationStatus = {
currentTitle?: string;
queueLength: number;
cooldownUntil?: string;
scannedCount: number;
addedCount: number;
doneCount: number;
totalCount: number;
};
export function listDrives() {
@@ -147,7 +139,7 @@ export function getDriveStorage() {
export type UpsertDriveInput = {
id: string;
kind: "quark" | "p115" | "p123" | "pikpak" | "wopan" | "onedrive" | "googledrive" | "localstorage" | "spider91";
kind: "quark" | "p115" | "pikpak" | "wopan" | "onedrive" | "googledrive" | "localstorage" | "spider91";
name: string;
rootId: string;
credentials: Record<string, string>;
@@ -166,212 +158,23 @@ export function upsertDrive(body: UpsertDriveInput) {
});
}
export type DeleteDriveInput = {
deleteVideos: true;
};
export function deleteDrive(id: string, body: DeleteDriveInput) {
return request<{ ok: boolean; deletedVideos: number }>(`/drives/${encodeURIComponent(id)}`, {
export function deleteDrive(id: string) {
return request<{ ok: boolean }>(`/drives/${encodeURIComponent(id)}`, {
method: "DELETE",
body: JSON.stringify(body),
});
}
export function rescan(id: string) {
return request<{ ok: boolean; accepted: boolean; message?: string; status?: NightlyJobStatus }>(
return request<{ ok: boolean }>(
`/drives/${encodeURIComponent(id)}/rescan`,
{ method: "POST" }
);
}
export function stopDriveTasks(id: string) {
return request<{ ok: boolean; stopped: boolean }>(
`/drives/${encodeURIComponent(id)}/tasks/stop`,
{ method: "POST" }
);
}
// ---------- Crawlers ----------
export type AdminCrawler = {
id: string;
name: string;
kind: "scriptcrawler" | "spider91";
status: string;
lastError?: string;
scriptPath: string;
scriptSourceUrl?: string;
proxy?: string;
targetNew?: string;
uploadDriveId?: string;
lastCrawlAt?: number;
scanGenerationStatus?: DriveGenerationStatus;
thumbnailGenerationStatus?: DriveGenerationStatus;
previewGenerationStatus?: DriveGenerationStatus;
fingerprintGenerationStatus?: DriveGenerationStatus;
uploadGenerationStatus?: DriveGenerationStatus;
thumbnailReadyCount: number;
thumbnailPendingCount: number;
thumbnailFailedCount: number;
teaserReadyCount: number;
teaserPendingCount: number;
teaserFailedCount: number;
fingerprintReadyCount: number;
fingerprintPendingCount: number;
fingerprintFailedCount: number;
totalCrawledCount: number;
localVideoCount: number;
migratedVideoCount: number;
};
export type UpsertCrawlerInput = {
id?: string;
scriptPath: string;
scriptSourceUrl?: string;
proxy?: string;
targetNew?: string;
uploadDriveId?: string;
};
export type ImportCrawlerScriptResult = {
scriptPath: string;
name: string;
sourceUrl?: string;
};
export type CrawlerDryRunItem = {
title: string;
sourceId?: string;
mediaUrl?: string;
mediaLocalFile?: string;
thumbnailUrl?: string;
detailUrl?: string;
};
export type CrawlerDryRunMediaCheck = {
ok: boolean;
status?: number;
contentType?: string;
contentLengthBytes?: number;
error?: string;
};
export type CrawlerDryRunResult = {
ok: boolean;
items: CrawlerDryRunItem[];
mediaCheck?: CrawlerDryRunMediaCheck;
error?: string;
log?: string[];
durationMs: number;
};
export function listCrawlers() {
return request<AdminCrawler[]>("/crawlers");
}
export function upsertCrawler(body: UpsertCrawlerInput) {
return request<{ ok: boolean; id: string; warning?: string }>("/crawlers", {
method: "POST",
body: JSON.stringify(body),
});
}
export function importCrawlerScriptFile(file: File) {
const form = new FormData();
form.append("file", file);
return request<ImportCrawlerScriptResult>("/crawlers/import-file", {
method: "POST",
body: form,
});
}
export function importCrawlerScriptURL(url: string) {
return request<ImportCrawlerScriptResult>("/crawlers/import-url", {
method: "POST",
body: JSON.stringify({ url }),
});
}
export function testCrawlerScript(body: { scriptPath: string; proxy?: string }) {
return request<CrawlerDryRunResult>("/crawlers/test-script", {
method: "POST",
body: JSON.stringify(body),
});
}
export function runCrawler(id: string) {
return request<{ ok: boolean; accepted: boolean; message?: string; status?: NightlyJobStatus }>(
`/crawlers/${encodeURIComponent(id)}/run`,
{ method: "POST" }
);
}
export function stopCrawlerTasks(id: string) {
return request<{ ok: boolean; stopped: boolean }>(
`/crawlers/${encodeURIComponent(id)}/tasks/stop`,
{ method: "POST" }
);
}
export function deleteCrawler(id: string) {
return request<{ ok: boolean; deletedVideos: number; deletedScript?: boolean; warning?: string }>(`/crawlers/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
export type P123QRSession = {
loginUuid: string;
uniID: string;
qrCodeUrl: string;
qrImageDataUrl: string;
expiresAt?: string;
};
export type P123QRStatus = {
loginStatus: number;
statusText: string;
scanPlatform?: number;
platformText?: string;
accessToken?: string;
};
export function startP123QRLogin() {
return request<P123QRSession>("/drives/p123/qr", { method: "POST" });
}
export function getP123QRStatus(uniID: string, loginUuid: string) {
const qs = new URLSearchParams({ loginUuid });
return request<P123QRStatus>(
`/drives/p123/qr/${encodeURIComponent(uniID)}?${qs.toString()}`
);
}
export type WopanQRSession = {
uuid: string;
qrImageDataUrl: string;
expiresAt?: string;
};
export type WopanQRStatus = {
state: number;
statusText: string;
accessToken?: string;
refreshToken?: string;
familyID?: string;
};
export function startWopanQRLogin() {
return request<WopanQRSession>("/drives/wopan/qr", { method: "POST" });
}
export function getWopanQRStatus(uuid: string) {
return request<WopanQRStatus>(`/drives/wopan/qr/${encodeURIComponent(uuid)}`);
}
/**
* toggle
* teaser toggle
*
* catalog.drives.teaser_enabled pending
* catalog.drives.teaser_enabled pending teaser
*
*/
export function setDriveTeaserEnabled(id: string, enabled: boolean) {
@@ -430,7 +233,7 @@ export function regenFailedPreviews(id: string) {
/**
* drive thumbnail_status=failed
* regenFailedPreviews
* regenFailedPreviews teaser
*
* 202 listDrives thumbnailFailedCount /
* thumbnailGenerationStatus
@@ -482,12 +285,11 @@ export type AdminVideoList = {
size: number;
};
export function listVideos(params: { driveId?: string; page?: number; size?: number; keyword?: string } = {}) {
export function listVideos(params: { driveId?: string; page?: number; size?: number } = {}) {
const qs = new URLSearchParams();
if (params.driveId) qs.set("driveId", params.driveId);
if (params.page) qs.set("page", String(params.page));
if (params.size) qs.set("size", String(params.size));
if (params.keyword) qs.set("keyword", params.keyword);
const suffix = qs.toString() ? `?${qs.toString()}` : "";
return request<AdminVideoList>(`/videos${suffix}`);
}
@@ -511,16 +313,6 @@ export function updateVideo(id: string, body: UpdateVideoInput) {
});
}
export function deleteVideo(id: string, options: { deleteSource?: boolean } = {}) {
return request<{ ok: boolean; deletedSource: boolean }>(
`/videos/${encodeURIComponent(id)}`,
{
method: "DELETE",
body: JSON.stringify({ deleteSource: !!options.deleteSource }),
}
);
}
export function regenPreview(id: string) {
return request<{ ok: boolean }>(
`/videos/${encodeURIComponent(id)}/regen-preview`,
@@ -563,9 +355,9 @@ export type Theme = "dark" | "pink";
export type Settings = {
theme: Theme;
/**
* spider91 drive ID pikpakp115p123onedrivegoogledrive wopan drive
* spider91 drive ID pikpakp115 onedrive drive
* -
* - drive kind {pikpak, p115, p123, onedrive, googledrive, wopan}
* - drive kind {pikpak, p115, onedrive}
*/
spider91UploadDriveId: string;
};
@@ -609,15 +401,8 @@ export function getNightlyJobStatus() {
}
export function runNightlyJob() {
return request<{ ok: boolean; accepted: boolean; status: NightlyJobStatus; message?: string }>(
return request<{ ok: boolean; accepted: boolean; status: NightlyJobStatus }>(
"/jobs/nightly/run",
{ method: "POST" }
);
}
export function stopAllTasks() {
return request<{ ok: boolean; stoppedDrives: number; status: NightlyJobStatus }>(
"/tasks/stop",
{ method: "POST" }
);
}
-46
View File
@@ -1,46 +0,0 @@
import { Trash2 } from "lucide-react";
import * as api from "../api";
import { Modal } from "../Modal";
export function DeleteDriveModal({
drive,
deleting,
onCancel,
onConfirm,
}: {
drive: api.AdminDrive | null;
deleting: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
const name = drive?.name || drive?.id || "";
const isSpider91 = drive?.kind === "spider91";
const title = isSpider91 ? "删除 91Spider" : "删除存储";
const primaryText = deleting ? "删除中..." : "确认删除";
return (
<Modal
open={!!drive}
title={title}
onClose={onCancel}
className="admin-modal--delete-confirm"
footer={
<>
<button className="admin-btn" onClick={onCancel} disabled={deleting}>
</button>
<button className="admin-btn is-danger" onClick={onConfirm} disabled={deleting}>
<Trash2 size={13} />
{primaryText}
</button>
</>
}
>
<div className="admin-confirm is-message-centered">
<div className="admin-confirm__content">
<p className="admin-confirm__message">{`确定要删除「${name}」吗?`}</p>
</div>
</div>
</Modal>
);
}
-323
View File
@@ -1,323 +0,0 @@
import { PlayCircle, Power, PowerOff, RotateCcw } from "lucide-react";
import * as api from "../api";
import { formatBytes } from "../storageFormat";
import {
generationStateLabel,
generationStateClass,
generationDetail,
generationTitle,
} from "./constants";
export function StorageSummary({ storage }: { storage: api.AdminDriveStorage }) {
return (
<section className="admin-card admin-storage-summary" aria-label="本地媒体存储">
<div className="admin-storage-summary__metric">
<span></span>
<strong>{formatBytes(storage.thumbnailBytes)}</strong>
</div>
<div className="admin-storage-summary__metric">
<span></span>
<strong>{formatBytes(storage.teaserBytes)}</strong>
</div>
<div className="admin-storage-summary__metric">
<span></span>
<strong>{formatBytes(storage.totalBytes)}</strong>
</div>
<div className="admin-storage-summary__metric">
<span></span>
<strong>{formatBytes(storage.availableBytes)}</strong>
</div>
</section>
);
}
export function GenerationCounts({
ready,
pending,
failed,
durationPending,
}: {
ready?: number;
pending?: number;
failed?: number;
durationPending?: number;
}) {
return (
<div className="admin-generation-counts">
<span className="admin-drive-teaser__metric is-ready">
{ready ?? 0}
</span>
<span className="admin-drive-teaser__metric is-pending">
{pending ?? 0}
</span>
<span className="admin-drive-teaser__metric is-failed">
{failed ?? 0}
</span>
{(durationPending ?? 0) > 0 && (
<span className="admin-drive-teaser__metric">
{durationPending}
</span>
)}
</div>
);
}
export function GenerationStatusLine({
label,
status,
}: {
label: string;
status?: api.DriveGenerationStatus;
}) {
const state = status?.state || "idle";
const queueLength = status?.queueLength ?? 0;
const detail = generationDetail(status);
const title = generationTitle(status, detail);
const countText = queueLength > 0 ? `${label === "封面" ? "待处理" : "队列"} ${queueLength}` : "";
return (
<div className="admin-generation-row" title={title}>
<span className="admin-generation-kind">{label}</span>
<span className={`admin-status admin-generation-state is-${generationStateClass(state)}`}>
{generationStateLabel(state)}
</span>
{(detail || queueLength > 0) && (
<span className="admin-generation-detail">
{[detail, countText].filter(Boolean).join(" / ")}
</span>
)}
</div>
);
}
export function StatusTag({
kind,
status,
error,
hasCred,
}: {
kind: string;
status: string;
error?: string;
hasCred: boolean;
}) {
if (kind === "spider91") {
return (
<span className="admin-status is-error" title={error || "请到爬虫管理添加爬虫脚本"}>
</span>
);
}
if (kind !== "spider91" && !hasCred) {
return <span className="admin-status is-pending"></span>;
}
if (status === "ok") {
return <span className="admin-status is-ok"></span>;
}
if (status === "error")
return (
<span className="admin-status is-error" title={error}>
</span>
);
return <span className="admin-status">{status || "未连接"}</span>;
}
export function DriveCardMetrics({ d }: { d: api.AdminDrive }) {
return (
<div className="admin-drive-card__info">
<div className="admin-drive-card__metric">
<span> (/)</span>
<strong>
{d.thumbnailReadyCount ?? 0}
<span style={{ fontSize: "11px", fontWeight: "normal", color: "var(--text-faint)" }}>
{" "}/ {d.thumbnailFailedCount ?? 0}
</span>
</strong>
</div>
<div className="admin-drive-card__metric">
<span> (/)</span>
<strong>
{d.teaserReadyCount ?? 0}
<span style={{ fontSize: "11px", fontWeight: "normal", color: "var(--text-faint)" }}>
{" "}/ {d.teaserFailedCount ?? 0}
</span>
</strong>
</div>
<div className="admin-drive-card__metric">
<span> (/)</span>
<strong>
{d.fingerprintReadyCount ?? 0}
<span style={{ fontSize: "11px", fontWeight: "normal", color: "var(--text-faint)" }}>
{" "}/ {d.fingerprintFailedCount ?? 0}
</span>
</strong>
</div>
</div>
);
}
export function DriveGenerationPanel({
d,
regenFailedId,
regenFailedThumbId,
regenFailedFingerprintId,
togglingTeaserId,
onToggleTeaser,
onRegenFailed,
onRegenFailedThumbnails,
onRegenFailedFingerprints,
}: {
d: api.AdminDrive;
regenFailedId: string;
regenFailedThumbId: string;
regenFailedFingerprintId: string;
togglingTeaserId: string;
onToggleTeaser: () => void;
onRegenFailed: () => void;
onRegenFailedThumbnails: () => void;
onRegenFailedFingerprints: () => void;
}) {
const canQueueThumbnails =
(d.thumbnailFailedCount ?? 0) > 0 ||
(d.thumbnailPendingCount ?? 0) > 0 ||
(d.thumbnailDurationPendingCount ?? 0) > 0;
const canQueuePreviews =
(d.teaserFailedCount ?? 0) > 0 || (d.teaserPendingCount ?? 0) > 0;
const canQueueFingerprints =
(d.fingerprintFailedCount ?? 0) > 0 || (d.fingerprintPendingCount ?? 0) > 0;
return (
<div className="admin-detail-card">
<header className="admin-detail-card__title">
<div className="admin-detail-card__title-left">
<PlayCircle size={16} />
<span></span>
</div>
<div className="admin-detail-actions-inline">
<button
className={`admin-btn ${d.teaserEnabled ? "is-success" : ""}`}
onClick={onToggleTeaser}
disabled={togglingTeaserId === d.id}
style={{ padding: "4px 10px", fontSize: "11px" }}
>
{d.teaserEnabled ? <Power size={11} /> : <PowerOff size={11} />}
<span>{d.teaserEnabled ? "预览视频:开" : "预览视频:关"}</span>
</button>
</div>
</header>
<div className="admin-gen-columns">
<DriveGenCol
label={d.kind === "spider91" ? "已废弃" : "扫盘"}
status={d.scanGenerationStatus}
showCounts={false}
/>
<DriveGenCol
label="封面"
status={d.thumbnailGenerationStatus}
ready={d.thumbnailReadyCount}
pending={d.thumbnailPendingCount}
failed={d.thumbnailFailedCount}
extra={d.thumbnailDurationPendingCount}
/>
<DriveGenCol
label="预览视频"
status={d.previewGenerationStatus}
ready={d.teaserReadyCount}
pending={d.teaserPendingCount}
failed={d.teaserFailedCount}
/>
<DriveGenCol
label="视频指纹"
status={d.fingerprintGenerationStatus}
ready={d.fingerprintReadyCount}
pending={d.fingerprintPendingCount}
failed={d.fingerprintFailedCount}
/>
</div>
<div className="admin-detail-actions">
<button
className="admin-btn"
disabled={!canQueueThumbnails || regenFailedThumbId === d.id}
onClick={onRegenFailedThumbnails}
>
<RotateCcw size={13} />
<span>{(d.thumbnailFailedCount ?? 0) > 0 ? "重试失败封面" : "继续生成封面"}</span>
</button>
<button
className="admin-btn"
disabled={!canQueuePreviews || regenFailedId === d.id}
onClick={onRegenFailed}
>
<RotateCcw size={13} />
<span>{(d.teaserFailedCount ?? 0) > 0 ? "重试失败预览视频" : "继续生成预览视频"}</span>
</button>
<button
className="admin-btn"
disabled={!canQueueFingerprints || regenFailedFingerprintId === d.id}
onClick={onRegenFailedFingerprints}
>
<RotateCcw size={13} />
<span>{(d.fingerprintFailedCount ?? 0) > 0 ? "重试失败指纹" : "继续生成指纹"}</span>
</button>
</div>
</div>
);
}
function DriveGenCol({
label,
status,
ready,
pending,
failed,
extra,
showCounts = true,
}: {
label: string;
status?: api.DriveGenerationStatus;
ready?: number;
pending?: number;
failed?: number;
extra?: number;
showCounts?: boolean;
}) {
const state = status?.state || "idle";
const detail = generationDetail(status);
const title = generationTitle(status, detail);
const stateLabel = label === "抓取" && state === "scanning" ? "抓取中" : generationStateLabel(state);
const showScanProgress = !showCounts && (state === "scanning" || (status?.scannedCount ?? 0) > 0 || (status?.addedCount ?? 0) > 0);
const scannedLabel = label === "抓取" ? "已抓取" : "已扫描";
return (
<div className="admin-gen-col">
<div className="admin-gen-col__head">
<span className="admin-gen-col__label">{label}</span>
<span
className={`admin-status admin-generation-state is-${generationStateClass(state)}`}
title={title || undefined}
>
{stateLabel}
</span>
</div>
{detail && <div className="admin-gen-col__detail">{detail}</div>}
{showScanProgress && (
<div className="admin-gen-col__counts admin-gen-col__counts--scan">
<div className="admin-gen-col__count"><span>{scannedLabel}</span><strong>{status?.scannedCount ?? 0}</strong></div>
<div className="admin-gen-col__count"><span></span><strong>{status?.addedCount ?? 0}</strong></div>
</div>
)}
{showCounts && (
<div className="admin-gen-col__counts">
<div className="admin-gen-col__count"><span></span><strong>{ready ?? 0}</strong></div>
<div className="admin-gen-col__count"><span></span><strong>{pending ?? 0}</strong></div>
<div className="admin-gen-col__count"><span></span><strong>{failed ?? 0}</strong></div>
{(extra ?? 0) > 0 && (
<div className="admin-gen-col__count"><span></span><strong>{extra}</strong></div>
)}
</div>
)}
</div>
);
}
-269
View File
@@ -1,269 +0,0 @@
import { useId, useMemo, useState } from "react";
import { ArrowLeft, ChevronDown } from "lucide-react";
import { P123QRCodeLogin } from "./P123QRCodeLogin";
import { WopanQRCodeLogin } from "./WopanQRCodeLogin";
import { Spider91UploadTargetField } from "./Spider91UploadTargetField";
import {
FormState,
Kind,
credentialFields,
credentialHelp,
usesRootDirectoryID,
rootIdPlaceholder,
} from "./constants";
import * as api from "../api";
type DriveOption = {
kind: Kind;
label: string;
abbr: string;
desc: string;
};
const DRIVE_OPTIONS: DriveOption[] = [
{ kind: "p115", label: "115 网盘", abbr: "115", desc: "302直链,不占带宽" },
{ kind: "p123", label: "123网盘", abbr: "123", desc: "扫码登录,302直链" },
{ kind: "pikpak", label: "PikPak", abbr: "Pk", desc: "302直链,稳定快速" },
{ kind: "onedrive", label: "OneDrive", abbr: "OD", desc: "302直链,微软网盘" },
{ kind: "googledrive", label: "Google Drive", abbr: "GD", desc: "服务器中转模式" },
{ kind: "localstorage", label: "本地存储", abbr: "Lo", desc: "本机文件目录" },
{ kind: "quark", label: "夸克网盘", abbr: "Qk", desc: "302直链" },
{ kind: "wopan", label: "联通网盘", abbr: "Wo", desc: "302直链" },
];
export function DriveForm({
form,
onChange,
isEdit,
uploadTargets,
nameError,
onNameBlur,
onBack,
}: {
form: FormState;
onChange: (f: FormState) => void;
isEdit: boolean;
uploadTargets: api.AdminDrive[];
nameError?: string;
onNameBlur?: () => void;
onBack?: () => void;
}) {
const idPrefix = useId();
const fields = useMemo(() => credentialFields(form.kind, form.creds), [form.kind, form.creds]);
const help = credentialHelp(form.kind, isEdit);
const [step, setStep] = useState<"type" | "form">(isEdit ? "form" : "type");
const nameId = `${idPrefix}-drive-name`;
const rootId = `${idPrefix}-drive-root`;
function set<K extends keyof FormState>(k: K, v: FormState[K]) {
onChange({ ...form, [k]: v });
}
function setCred(k: string, v: string) {
onChange({ ...form, creds: { ...form.creds, [k]: v } });
}
function setKind(v: Kind) {
onChange({
...form,
kind: v,
rootId: "",
creds: {},
});
}
function selectType(kind: Kind) {
setKind(kind);
setStep("form");
}
function goBack() {
setStep("type");
onChange({
...form,
name: "",
rootId: "",
creds: {},
});
onBack?.();
}
const selectedOption = DRIVE_OPTIONS.find((o) => o.kind === form.kind);
if (step === "type" && !isEdit) {
return (
<div className="admin-drive-type-picker">
<div className="admin-drive-type-grid">
{DRIVE_OPTIONS.map((opt) => (
<button
key={opt.kind}
type="button"
className="admin-drive-type-card"
data-kind={opt.kind}
onClick={() => selectType(opt.kind)}
>
<span className="admin-drive-type-card__icon" data-kind={opt.kind}>
{opt.abbr}
</span>
<span className="admin-drive-type-card__label">{opt.label}</span>
</button>
))}
</div>
</div>
);
}
return (
<div className="admin-form">
{!isEdit && selectedOption && (
<div className="admin-drive-selected-bar" data-kind={form.kind}>
<span className="admin-drive-selected-bar__icon" data-kind={form.kind}>
{selectedOption.abbr}
</span>
<div className="admin-drive-selected-bar__text">
<span className="admin-drive-selected-bar__name">{selectedOption.label}</span>
<span className="admin-drive-selected-bar__desc">{selectedOption.desc}</span>
</div>
<button type="button" className="admin-drive-selected-bar__back" onClick={goBack}>
<ArrowLeft size={12} />
</button>
</div>
)}
<div className="admin-form__section">
<div className="admin-form__row">
<label htmlFor={nameId}> *</label>
<input
id={nameId}
value={form.name}
onChange={(e) => set("name", e.target.value)}
onBlur={onNameBlur}
placeholder="给这个盘起个名字"
className={nameError ? "is-invalid" : undefined}
aria-invalid={nameError ? "true" : undefined}
aria-describedby={nameError ? `${nameId}-error` : undefined}
/>
{nameError && (
<div className="admin-form__error" id={`${nameId}-error`}>
{nameError}
</div>
)}
</div>
{usesRootDirectoryID(form.kind) && (
<div className="admin-form__row">
<label htmlFor={rootId}> ID</label>
<input
id={rootId}
value={form.rootId}
onChange={(e) => set("rootId", e.target.value)}
placeholder={rootIdPlaceholder(form.kind)}
/>
<div className="admin-form__help">
使
</div>
</div>
)}
</div>
{(help || fields.length > 0) && (
<div className="admin-form__section">
<h3 className="admin-form__section-label"></h3>
{help && (
<div className="admin-form__help admin-form__help--lead">
{help}
</div>
)}
{form.kind === "p123" && (
<P123QRCodeLogin
onToken={(token) => setCred("access_token", token)}
/>
)}
{form.kind === "wopan" && (
<WopanQRCodeLogin
onCredentials={(credentials) =>
onChange({
...form,
creds: {
...form.creds,
access_token: credentials.accessToken,
refresh_token: credentials.refreshToken,
...(credentials.familyID ? { family_id: credentials.familyID } : {}),
},
})
}
/>
)}
{fields.map((f) => (
<div key={f.key} className="admin-form__row">
{f.type === "select" ? (
<>
<label htmlFor={`${idPrefix}-credential-${f.key}`}>
{f.label}
{f.required && " *"}
</label>
<div className="admin-form-select-wrap">
<select
id={`${idPrefix}-credential-${f.key}`}
className="admin-form-select"
value={form.creds[f.key] ?? f.defaultValue ?? ""}
onChange={(e) => setCred(f.key, e.target.value)}
>
{(f.options ?? []).map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<ChevronDown size={15} className="admin-form-select__icon" aria-hidden="true" />
</div>
</>
) : (
<>
<label htmlFor={`${idPrefix}-credential-${f.key}`}>
{f.label}
{f.required && " *"}
</label>
{f.multiline ? (
<textarea
id={`${idPrefix}-credential-${f.key}`}
value={form.creds[f.key] ?? ""}
onChange={(e) => setCred(f.key, e.target.value)}
placeholder={f.placeholder}
required={f.required && !isEdit}
/>
) : (
<input
id={`${idPrefix}-credential-${f.key}`}
type={credentialInputType(f.key)}
value={form.creds[f.key] ?? ""}
onChange={(e) => setCred(f.key, e.target.value)}
placeholder={f.placeholder}
required={f.required && !isEdit}
/>
)}
</>
)}
{f.help && <div className="admin-form__help">{f.help}</div>}
</div>
))}
</div>
)}
{form.kind === "spider91" && (
<div className="admin-form__section">
<h3 className="admin-form__section-label"></h3>
<Spider91UploadTargetField
value={form.spider91UploadDriveId}
onChange={(v) => set("spider91UploadDriveId", v)}
uploadTargets={uploadTargets}
/>
</div>
)}
</div>
);
}
function credentialInputType(key: string): string {
return /password|token|secret/i.test(key) ? "password" : "text";
}
-142
View File
@@ -1,142 +0,0 @@
import { useEffect, useState } from "react";
import { QrCode } from "lucide-react";
import * as api from "../api";
import { useToast } from "../ToastContext";
function p123QRStatusClass(
status: api.P123QRStatus | null,
completed: boolean,
error: string
): string {
if (completed || status?.loginStatus === 3) return "is-ok";
if (error || status?.loginStatus === 2 || status?.loginStatus === 4) {
return "is-error";
}
return "is-pending";
}
export function P123QRCodeLogin({ onToken }: { onToken: (token: string) => void }) {
const { show } = useToast();
const [session, setSession] = useState<api.P123QRSession | null>(null);
const [status, setStatus] = useState<api.P123QRStatus | null>(null);
const [starting, setStarting] = useState(false);
const [pollingError, setPollingError] = useState("");
const [completed, setCompleted] = useState(false);
async function start() {
setStarting(true);
setPollingError("");
setCompleted(false);
setStatus(null);
try {
const next = await api.startP123QRLogin();
setSession(next);
} catch (e) {
setSession(null);
show(e instanceof Error ? e.message : "生成二维码失败", "error");
} finally {
setStarting(false);
}
}
useEffect(() => {
if (!session || completed) return;
const activeSession = session;
let stopped = false;
let inFlight = false;
let timer: number | undefined;
async function poll() {
if (stopped || inFlight) return;
inFlight = true;
try {
const next = await api.getP123QRStatus(activeSession.uniID, activeSession.loginUuid);
if (stopped) return;
setStatus(next);
setPollingError("");
if (next.accessToken) {
stopped = true;
if (timer) window.clearInterval(timer);
setCompleted(true);
onToken(next.accessToken);
show("扫码成功,已填入 access_token,保存后生效", "success");
return;
}
if (next.loginStatus === 2 || next.loginStatus === 4) {
stopped = true;
if (timer) window.clearInterval(timer);
}
} catch (e) {
if (stopped) return;
setPollingError(e instanceof Error ? e.message : "查询扫码状态失败");
} finally {
inFlight = false;
}
}
poll();
timer = window.setInterval(poll, 1800);
return () => {
stopped = true;
if (timer) window.clearInterval(timer);
};
}, [session, completed, onToken, show]);
const statusText = completed
? "已获取 token"
: pollingError || status?.statusText || (session ? "等待扫码" : "未生成二维码");
const statusClass = p123QRStatusClass(status, completed, pollingError);
const platform = status?.platformText ? ` · ${status.platformText}` : "";
return (
<div className="admin-form__row">
<label></label>
<div className="admin-p123-qr">
<div className="admin-p123-qr__actions">
<button
type="button"
className="admin-btn"
onClick={start}
disabled={starting}
>
<QrCode size={14} />
{starting ? "生成中..." : session ? "重新生成二维码" : "生成二维码"}
</button>
<span className={`admin-status ${statusClass}`}>
{statusText}
{platform}
</span>
</div>
{session && (
<div className="admin-p123-qr__body">
<img
className="admin-p123-qr__image"
src={session.qrImageDataUrl}
alt="123网盘扫码登录二维码"
/>
<div className="admin-p123-qr__meta">
<div className="admin-form__help">
使 123 App access_token
</div>
{session.expiresAt && (
<div className="admin-form__help">
{new Date(session.expiresAt).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})}
</div>
)}
{(status?.loginStatus === 2 || status?.loginStatus === 4) && (
<div className="admin-form__help">
{status.loginStatus === 2 ? "已被拒绝" : "已过期"}
</div>
)}
</div>
</div>
)}
</div>
</div>
);
}
-311
View File
@@ -1,311 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import * as api from "../api";
import { useToast } from "../ToastContext";
import { kindLabel } from "./constants";
type SkipDirsPanelProps = {
drive: api.AdminDrive;
onSaved: (saved: { id: string; skipDirIds: string[] }) => void;
};
export function SkipDirsPanel({ drive, onSaved }: SkipDirsPanelProps) {
const { show } = useToast();
const [selected, setSelected] = useState<Set<string>>(
() => new Set(drive.skipDirIds ?? [])
);
const [saving, setSaving] = useState(false);
useEffect(() => {
setSelected(new Set(drive.skipDirIds ?? []));
}, [drive.id, drive.skipDirIds]);
const toggle = useCallback((id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
async function handleSave() {
setSaving(true);
try {
const ids = Array.from(selected);
const resp = await api.setDriveSkipDirIds(drive.id, ids);
onSaved({ id: drive.id, skipDirIds: resp.skipDirIds });
} catch (e) {
show(e instanceof Error ? e.message : "保存失败", "error");
} finally {
setSaving(false);
}
}
const selectedList = useMemo(() => Array.from(selected), [selected]);
return (
<div className="admin-detail-card">
<header className="admin-detail-card__title">
<div className="admin-detail-card__title-left">
<span></span>
</div>
<button
className="admin-btn is-primary"
onClick={handleSave}
disabled={saving}
style={{ padding: "4px 10px", fontSize: "12px", height: "auto" }}
>
{saving ? "保存中..." : `保存更改 (${selectedList.length})`}
</button>
</header>
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
<p className="admin-text-faint" style={{ margin: 0, fontSize: "12px", lineHeight: "1.5" }}>
</p>
<SelectedDirsChips
drive={drive}
selected={selectedList}
onRemove={toggle}
/>
<div className="admin-detail-tree-container">
<DirTreeNode
driveId={drive.id}
id=""
name={drive.name || drive.id}
depth={0}
initiallyOpen
ancestorSkipped={false}
selected={selected}
onToggle={toggle}
/>
</div>
</div>
</div>
);
}
function SelectedDirsChips({
drive,
selected,
onRemove,
}: {
drive: api.AdminDrive;
selected: string[];
onRemove: (id: string) => void;
}) {
if (selected.length === 0) {
return (
<div
className="admin-text-faint"
style={{ fontSize: "13px", padding: "6px 0" }}
>
{kindLabel[drive.kind] ?? drive.kind}{" "}
</div>
);
}
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px" }}>
{selected.map((id) => (
<span
key={id}
className="admin-mono-cell"
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "3px 10px",
border: "1px solid var(--border-subtle)",
borderRadius: "999px",
fontSize: "12px",
}}
title="点击 × 移除"
>
{id}
<button
type="button"
onClick={() => onRemove(id)}
style={{
border: "none",
background: "transparent",
cursor: "pointer",
color: "var(--text-secondary)",
padding: 0,
lineHeight: 1,
fontSize: "14px",
}}
aria-label={`移除 ${id}`}
>
×
</button>
</span>
))}
</div>
);
}
type DirTreeNodeProps = {
driveId: string;
id: string;
name: string;
depth: number;
initiallyOpen?: boolean;
ancestorSkipped: boolean;
selected: Set<string>;
onToggle: (id: string) => void;
};
function DirTreeNode({
driveId,
id,
name,
depth,
initiallyOpen,
ancestorSkipped,
selected,
onToggle,
}: DirTreeNodeProps) {
const [open, setOpen] = useState(!!initiallyOpen);
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const [children, setChildren] = useState<api.DriveDirEntry[]>([]);
const [error, setError] = useState("");
const isRoot = depth === 0;
const isSelected = id !== "" && selected.has(id);
const dimmed = ancestorSkipped;
const loadChildren = useCallback(async () => {
if (loaded || loading) return;
setLoading(true);
setError("");
try {
const data = await api.listDriveDirChildren(driveId, id || undefined);
setChildren(data ?? []);
setLoaded(true);
} catch (e) {
setError(e instanceof Error ? e.message : "加载失败");
} finally {
setLoading(false);
}
}, [driveId, id, loaded, loading]);
useEffect(() => {
if (open && !loaded) {
void loadChildren();
}
}, [open, loaded, loadChildren]);
function handleToggleOpen() {
setOpen((v) => !v);
}
return (
<div
style={{
paddingLeft: depth === 0 ? 0 : 16,
opacity: dimmed && !isSelected ? 0.55 : 1,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "6px",
padding: "4px 6px",
borderRadius: "4px",
background: isSelected ? "var(--accent-soft, rgba(255,140,0,0.12))" : "transparent",
}}
>
{!isRoot ? (
<button
type="button"
onClick={handleToggleOpen}
style={{
border: "none",
background: "transparent",
cursor: "pointer",
padding: 0,
display: "inline-flex",
alignItems: "center",
}}
aria-label={open ? "折叠" : "展开"}
>
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
) : (
<span style={{ width: 14, display: "inline-block" }} />
)}
{!isRoot && (
<input
type="checkbox"
checked={isSelected}
onChange={() => onToggle(id)}
aria-label={`跳过目录 ${name}`}
/>
)}
<span
style={{
fontSize: "13px",
cursor: isRoot ? "default" : "pointer",
userSelect: "none",
fontWeight: isRoot ? 600 : 400,
}}
onClick={isRoot ? undefined : handleToggleOpen}
>
{name}
{isRoot ? " (根目录)" : ""}
</span>
{!isRoot && (
<span
className="admin-mono-cell admin-text-faint"
style={{ fontSize: "11px", marginLeft: "6px" }}
>
{id}
</span>
)}
</div>
{open && (
<div>
{loading && (
<div className="admin-text-faint" style={{ fontSize: "12px", padding: "4px 28px" }}>
...
</div>
)}
{error && (
<div style={{ fontSize: "12px", padding: "4px 28px", color: "var(--danger, #d33)" }}>
{error}
</div>
)}
{loaded && !error && children.length === 0 && (
<div className="admin-text-faint" style={{ fontSize: "12px", padding: "4px 28px" }}>
</div>
)}
{children.map((child) => (
<DirTreeNode
key={child.id}
driveId={driveId}
id={child.id}
name={child.name}
depth={depth + 1}
ancestorSkipped={ancestorSkipped || isSelected}
selected={selected}
onToggle={onToggle}
/>
))}
</div>
)}
</div>
);
}
@@ -1,38 +0,0 @@
import { useId } from "react";
import { ChevronDown } from "lucide-react";
import { kindLabel } from "./constants";
import * as api from "../api";
export function Spider91UploadTargetField({
value,
onChange,
uploadTargets,
}: {
value: string;
onChange: (v: string) => void;
uploadTargets: api.AdminDrive[];
}) {
const targetId = useId();
return (
<div className="admin-form__row">
<label htmlFor={targetId}></label>
<div className="admin-form-select-wrap">
<select
id={targetId}
className="admin-form-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>
<ChevronDown size={15} className="admin-form-select__icon" aria-hidden="true" />
</div>
</div>
);
}
-148
View File
@@ -1,148 +0,0 @@
import { useEffect, useState } from "react";
import { QrCode } from "lucide-react";
import * as api from "../api";
import { useToast } from "../ToastContext";
function wopanQRStatusClass(
status: api.WopanQRStatus | null,
completed: boolean,
error: string
): string {
if (completed || status?.state === 3) return "is-ok";
if (error || status?.state === 4) return "is-error";
return "is-pending";
}
export function WopanQRCodeLogin({
onCredentials,
}: {
onCredentials: (credentials: {
accessToken: string;
refreshToken: string;
familyID?: string;
}) => void;
}) {
const { show } = useToast();
const [session, setSession] = useState<api.WopanQRSession | null>(null);
const [status, setStatus] = useState<api.WopanQRStatus | null>(null);
const [starting, setStarting] = useState(false);
const [pollingError, setPollingError] = useState("");
const [completed, setCompleted] = useState(false);
async function start() {
setStarting(true);
setPollingError("");
setCompleted(false);
setStatus(null);
try {
const next = await api.startWopanQRLogin();
setSession(next);
} catch (e) {
setSession(null);
show(e instanceof Error ? e.message : "生成二维码失败", "error");
} finally {
setStarting(false);
}
}
useEffect(() => {
if (!session || completed) return;
const activeSession = session;
let stopped = false;
let inFlight = false;
let timer: number | undefined;
async function poll() {
if (stopped || inFlight) return;
inFlight = true;
try {
const next = await api.getWopanQRStatus(activeSession.uuid);
if (stopped) return;
setStatus(next);
setPollingError("");
if (next.accessToken && next.refreshToken) {
stopped = true;
if (timer) window.clearInterval(timer);
setCompleted(true);
onCredentials({
accessToken: next.accessToken,
refreshToken: next.refreshToken,
familyID: next.familyID,
});
show("扫码成功,已填入 access_token 和 refresh_token,保存后生效", "success");
return;
}
if (next.state === 4) {
stopped = true;
if (timer) window.clearInterval(timer);
}
} catch (e) {
if (stopped) return;
setPollingError(e instanceof Error ? e.message : "查询扫码状态失败");
} finally {
inFlight = false;
}
}
poll();
timer = window.setInterval(poll, 1200);
return () => {
stopped = true;
if (timer) window.clearInterval(timer);
};
}, [session, completed, onCredentials, show]);
const statusText = completed
? "已获取凭证"
: pollingError || status?.statusText || (session ? "等待扫码" : "未生成二维码");
const statusClass = wopanQRStatusClass(status, completed, pollingError);
return (
<div className="admin-form__row">
<label></label>
<div className="admin-p123-qr">
<div className="admin-p123-qr__actions">
<button
type="button"
className="admin-btn"
onClick={start}
disabled={starting}
>
<QrCode size={14} />
{starting ? "生成中..." : session ? "重新生成二维码" : "生成二维码"}
</button>
<span className={`admin-status ${statusClass}`}>{statusText}</span>
</div>
{session && (
<div className="admin-p123-qr__body">
<img
className="admin-p123-qr__image"
src={session.qrImageDataUrl}
alt="联通网盘扫码登录二维码"
/>
<div className="admin-p123-qr__meta">
<div className="admin-form__help">
使 App access_token refresh_token
</div>
{session.expiresAt && (
<div className="admin-form__help">
{new Date(session.expiresAt).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})}
</div>
)}
{status?.state === 4 && (
<div className="admin-form__help">
</div>
)}
</div>
</div>
)}
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More